@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
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/mcp-server.ts
4
4
  import { existsSync as existsSync11, statSync as statSync2 } from "node:fs";
5
- import { basename as basename4, dirname as dirname8, join as join15, resolve as resolve3 } from "node:path";
5
+ import { basename as basename5, dirname as dirname8, join as join16, resolve as resolve4 } from "node:path";
6
6
  import { fileURLToPath as fileURLToPath5 } from "node:url";
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -521,8 +521,8 @@ ${envBlock}`);
521
521
  const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
522
522
  if (enterRe.test(content)) {
523
523
  content = content.replace(enterRe, (_m, head, close) => {
524
- const sep = /[,[]\s*$/.test(head) ? "" : ",";
525
- return `${head}${sep}
524
+ const sep2 = /[,[]\s*$/.test(head) ? "" : ",";
525
+ return `${head}${sep2}
526
526
  ${enterAdds}${close}`;
527
527
  });
528
528
  } else {
@@ -792,19 +792,151 @@ var NodeRecipe = class extends Recipe {
792
792
  // src/commands/hermes/EnsureTemplateConfig.ts
793
793
  import { homedir, platform } from "node:os";
794
794
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "node:fs";
795
- import { join as join5, dirname as dirname2 } from "node:path";
795
+ import { join as join6, dirname as dirname2 } from "node:path";
796
+
797
+ // src/parity/bmadPack.ts
798
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync as readFileSync2, readdirSync } from "node:fs";
799
+ import { createHash } from "node:crypto";
800
+ import { basename, join as join5, relative, resolve, sep } from "node:path";
801
+ var BMAD_PACK_VERSION = "6.10.1-next.31";
802
+ var BMAD_PACK_CHECKSUMS_SHA256 = "a8bc005612ac60e3ec775fff5a11eafe38be6acdae96efa3d770b48322cb3224";
803
+ var BMAD_PACK_SKILL_COUNT = 76;
804
+ var BMAD_PACK_PAYLOAD_FILES = 1072;
805
+ function sha256(content) {
806
+ return createHash("sha256").update(content).digest("hex");
807
+ }
808
+ function readRegularFile(path) {
809
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
810
+ try {
811
+ if (!fstatSync(fd).isFile()) throw new Error(`BMAD pack entry is not a regular file: ${path}`);
812
+ return readFileSync2(fd);
813
+ } finally {
814
+ closeSync(fd);
815
+ }
816
+ }
817
+ function safeRelativePath(value) {
818
+ if (!value || value.includes("\\") || value.startsWith("/") || value.split("/").some((part) => part === "" || part === "." || part === "..")) {
819
+ throw new Error(`Unsafe BMAD checksum path: ${JSON.stringify(value)}`);
820
+ }
821
+ return value;
822
+ }
823
+ function parsePackToml(content) {
824
+ const required = [
825
+ /^name = "bmad"$/m,
826
+ new RegExp(`^version = "${BMAD_PACK_VERSION.replaceAll(".", "\\.")}"$`, "m"),
827
+ /^upstream = "bmad-method"$/m,
828
+ new RegExp(`^upstream_version = "${BMAD_PACK_VERSION.replaceAll(".", "\\.")}"$`, "m"),
829
+ /^rendered_from = "\.agent\/skills"$/m,
830
+ /^immutable = true$/m,
831
+ /^project_projection = "symlink"$/m
832
+ ];
833
+ if (required.some((pattern) => !pattern.test(content))) {
834
+ throw new Error(`BMAD pack.toml does not declare the trusted ${BMAD_PACK_VERSION} contract`);
835
+ }
836
+ const payloadMatch = content.match(/^payload_files = (\d+)$/m);
837
+ const skillsMatch = content.match(/^skills = \[\n([\s\S]*?)^\]$/m);
838
+ if (!payloadMatch || !skillsMatch) throw new Error("BMAD pack.toml is missing payload inventory metadata");
839
+ const skillNames = [...skillsMatch[1].matchAll(/^\s+"([^"]+)",$/gm)].map((match) => match[1]);
840
+ const payloadFiles = Number.parseInt(payloadMatch[1], 10);
841
+ if (skillNames.length !== BMAD_PACK_SKILL_COUNT || new Set(skillNames).size !== skillNames.length) {
842
+ throw new Error(`BMAD pack.toml must declare exactly ${BMAD_PACK_SKILL_COUNT} unique skills`);
843
+ }
844
+ if (payloadFiles !== BMAD_PACK_PAYLOAD_FILES) {
845
+ throw new Error(`BMAD pack.toml must declare exactly ${BMAD_PACK_PAYLOAD_FILES} payload files`);
846
+ }
847
+ for (const name of skillNames) {
848
+ if (!name.startsWith("bmad-") || basename(name) !== name) throw new Error(`Unsafe BMAD skill identity: ${name}`);
849
+ }
850
+ return { skillNames, payloadFiles };
851
+ }
852
+ function walkRegularTree(root) {
853
+ const files = /* @__PURE__ */ new Map();
854
+ const directories = /* @__PURE__ */ new Set();
855
+ const visit = (directory) => {
856
+ for (const name of readdirSync(directory).sort()) {
857
+ const path = join5(directory, name);
858
+ const stat = lstatSync(path);
859
+ if (stat.isSymbolicLink()) throw new Error(`BMAD pack may not contain symlinks: ${path}`);
860
+ if (stat.isDirectory()) {
861
+ directories.add(relative(root, path).split(sep).join("/"));
862
+ visit(path);
863
+ } else if (stat.isFile()) files.set(relative(root, path).split(sep).join("/"), readRegularFile(path));
864
+ else throw new Error(`BMAD pack may contain only regular files/directories: ${path}`);
865
+ }
866
+ };
867
+ visit(root);
868
+ return { files, directories };
869
+ }
870
+ function validateTrustedBmadPack(packRoot) {
871
+ const root = resolve(packRoot);
872
+ const rootStat = lstatSync(root);
873
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
874
+ throw new Error(`BMAD pack root must be a real directory: ${root}`);
875
+ }
876
+ const checksumsContent = readRegularFile(join5(root, "SHA256SUMS"));
877
+ if (sha256(checksumsContent) !== BMAD_PACK_CHECKSUMS_SHA256) {
878
+ throw new Error(`BMAD pack checksum manifest is not the trusted ${BMAD_PACK_VERSION} manifest`);
879
+ }
880
+ const expected = /* @__PURE__ */ new Map();
881
+ for (const line of checksumsContent.toString("utf8").split("\n")) {
882
+ if (!line) continue;
883
+ const match = line.match(/^([0-9a-f]{64}) (.+)$/);
884
+ if (!match) throw new Error(`Invalid BMAD SHA256SUMS entry: ${line}`);
885
+ const path = safeRelativePath(match[2]);
886
+ if (expected.has(path)) throw new Error(`Duplicate BMAD SHA256SUMS entry: ${path}`);
887
+ expected.set(path, match[1]);
888
+ }
889
+ const walked = walkRegularTree(root);
890
+ const actual = walked.files;
891
+ actual.delete("SHA256SUMS");
892
+ const missing = [...expected.keys()].filter((path) => !actual.has(path));
893
+ const extra = [...actual.keys()].filter((path) => !expected.has(path));
894
+ if (missing.length || extra.length) {
895
+ throw new Error(`BMAD checksum coverage mismatch; missing=${JSON.stringify(missing)} extra=${JSON.stringify(extra)}`);
896
+ }
897
+ for (const [path, digest] of expected) {
898
+ const content = actual.get(path);
899
+ if (!content || sha256(content) !== digest) throw new Error(`BMAD pack digest mismatch: ${path}`);
900
+ }
901
+ for (const directory of walked.directories) {
902
+ if (![...actual.keys()].some((path) => path.startsWith(`${directory}/`))) {
903
+ throw new Error(`BMAD pack contains an unauthenticated empty directory: ${directory}`);
904
+ }
905
+ }
906
+ const packToml = actual.get("pack.toml");
907
+ if (!packToml) throw new Error("BMAD pack.toml is missing");
908
+ const metadata = parsePackToml(packToml.toString("utf8"));
909
+ const skillSet = new Set(metadata.skillNames);
910
+ const topLevelDirectories = readdirSync(root).filter((name) => lstatSync(join5(root, name)).isDirectory()).sort();
911
+ if (topLevelDirectories.length !== skillSet.size || topLevelDirectories.some((name) => !skillSet.has(name))) {
912
+ throw new Error("BMAD pack directory inventory differs from authenticated pack.toml skills");
913
+ }
914
+ for (const name of metadata.skillNames) {
915
+ const skillMd = join5(root, name, "SKILL.md");
916
+ if (!lstatSync(skillMd).isFile() || lstatSync(skillMd).isSymbolicLink()) {
917
+ throw new Error(`BMAD skill is missing a regular SKILL.md: ${name}`);
918
+ }
919
+ }
920
+ const payloadCount = [...actual.keys()].filter((path) => skillSet.has(path.split("/", 1)[0])).length;
921
+ if (payloadCount !== metadata.payloadFiles) {
922
+ throw new Error(`BMAD payload inventory mismatch: ${payloadCount} != ${metadata.payloadFiles}`);
923
+ }
924
+ return { root, skillNames: metadata.skillNames, payloadFiles: metadata.payloadFiles };
925
+ }
926
+
927
+ // src/commands/hermes/EnsureTemplateConfig.ts
796
928
  function resolveTemplateConfigPath() {
797
929
  const fromEnv = process.env.HERMES_TEMPLATE_CONFIG;
798
930
  if (fromEnv && fromEnv.trim()) return fromEnv.trim();
799
931
  const xdg = process.env.XDG_CONFIG_HOME?.trim();
800
- const base = xdg && xdg.length ? xdg : join5(homedir(), ".config");
801
- return join5(base, "hermes-agent-template", "config.toml");
932
+ const base = xdg && xdg.length ? xdg : join6(homedir(), ".config");
933
+ return join6(base, "hermes-agent-template", "config.toml");
802
934
  }
803
935
  function detectHermesBin(home) {
804
936
  const candidates = [
805
- join5(home, "code", "hermes-agent", "venv", "bin", "hermes"),
806
- join5(home, "code", "hermes-agent", ".venv", "bin", "hermes"),
807
- join5(home, ".local", "bin", "hermes")
937
+ join6(home, "code", "hermes-agent", "venv", "bin", "hermes"),
938
+ join6(home, "code", "hermes-agent", ".venv", "bin", "hermes"),
939
+ join6(home, ".local", "bin", "hermes")
808
940
  ];
809
941
  for (const c of candidates) {
810
942
  if (existsSync3(c)) return c;
@@ -814,11 +946,11 @@ function detectHermesBin(home) {
814
946
  function renderHostConfig() {
815
947
  const home = homedir();
816
948
  const hermesBin = detectHermesBin(home);
817
- const hermesRepo = join5(home, "code", "hermes-agent");
818
- const scaffoldDir = join5(home, "code", "hermes-agent-template", "runtime-scaffold");
819
- const skillsDir = join5(home, ".agents", "skills");
820
- const pmExternalSkillGlobalDir = join5(home, "code", "skillex", "skill-sets", "global", ".system");
821
- const pmExternalSkillBmadDir = join5(home, "code", "skillex", "packs", "bmad", "6.10.2");
949
+ const hermesRepo = join6(home, "code", "hermes-agent");
950
+ const scaffoldDir = join6(home, "code", "hermes-agent-template", "runtime-scaffold");
951
+ const skillsDir = join6(home, ".agents", "skills");
952
+ const pmExternalSkillGlobalDir = join6(home, "code", "skillex", "skill-sets", "global", ".system");
953
+ const pmExternalSkillBmadDir = join6(home, "code", "skillex", "packs", "bmad", BMAD_PACK_VERSION);
822
954
  return `# hermes-agent-template \u2014 host configuration
823
955
  # Bootstrapped by \`pjangler config bootstrap\` for $HOME=${home} (platform=${platform()}).
824
956
  #
@@ -890,8 +1022,8 @@ var EnsureTemplateConfig = class extends Command {
890
1022
  };
891
1023
 
892
1024
  // src/commands/hermes/PromptForAgentConfig.ts
893
- import { basename, join as join6 } from "node:path";
894
- import { readFileSync as readFileSync2 } from "node:fs";
1025
+ import { basename as basename2, join as join7 } from "node:path";
1026
+ import { readFileSync as readFileSync3 } from "node:fs";
895
1027
  import * as p from "@clack/prompts";
896
1028
 
897
1029
  // src/commands/hermes/types.ts
@@ -906,7 +1038,7 @@ function deriveProfileName(repo, role) {
906
1038
  // src/commands/hermes/PromptForAgentConfig.ts
907
1039
  function detectTicketProvider(targetDir) {
908
1040
  try {
909
- const t = JSON.parse(readFileSync2(join6(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
1041
+ const t = JSON.parse(readFileSync3(join7(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
910
1042
  return t === "plane" || t === "trello" ? t : void 0;
911
1043
  } catch {
912
1044
  return void 0;
@@ -915,7 +1047,7 @@ function detectTicketProvider(targetDir) {
915
1047
  var PromptForAgentConfig = class extends Command {
916
1048
  async invoke() {
917
1049
  const ctx = this.context;
918
- const defaultRepo = basename(ctx.targetDir).toLowerCase();
1050
+ const defaultRepo = basename2(ctx.targetDir).toLowerCase();
919
1051
  ctx.targetRepo = (ctx.targetRepo ?? defaultRepo).toLowerCase();
920
1052
  ctx.role ??= "pm";
921
1053
  ctx.agentPurpose ??= `${ctx.role} agent for ${ctx.targetRepo}`;
@@ -964,7 +1096,7 @@ var PromptForAgentConfig = class extends Command {
964
1096
  // src/commands/hermes/RunCopierTemplate.ts
965
1097
  import { spawnSync } from "node:child_process";
966
1098
  import { homedir as homedir2 } from "node:os";
967
- import { join as join7, dirname as dirname3 } from "node:path";
1099
+ import { join as join8, dirname as dirname3 } from "node:path";
968
1100
  import { existsSync as existsSync4, mkdirSync as mkdirSync3 } from "node:fs";
969
1101
  import { fileURLToPath } from "node:url";
970
1102
  import * as p2 from "@clack/prompts";
@@ -976,8 +1108,8 @@ function resolveVendoredTemplate(name) {
976
1108
  return void 0;
977
1109
  }
978
1110
  for (let i = 0; i < 8; i++) {
979
- const candidate = join7(dir, "templates", name);
980
- if (existsSync4(join7(candidate, "copier.yml"))) return candidate;
1111
+ const candidate = join8(dir, "templates", name);
1112
+ if (existsSync4(join8(candidate, "copier.yml"))) return candidate;
981
1113
  const parent = dirname3(dir);
982
1114
  if (parent === dir) break;
983
1115
  dir = parent;
@@ -996,7 +1128,7 @@ var RunCopierTemplate = class extends Command {
996
1128
  message: "PromptForAgentConfig must run before RunCopierTemplate (targetRepo/role unset)"
997
1129
  };
998
1130
  }
999
- const roleDir = join7(ctx.targetDir, "agents", "hermes", role);
1131
+ const roleDir = join8(ctx.targetDir, "agents", "hermes", role);
1000
1132
  ctx.roleDir = roleDir;
1001
1133
  ctx.runtimeRepo = `delorenj/agent-hm-${targetRepo}-${role}`;
1002
1134
  const which = spawnSync("which", ["copier"], { encoding: "utf8" });
@@ -1006,7 +1138,7 @@ var RunCopierTemplate = class extends Command {
1006
1138
  message: "\u2717 copier not found on PATH. Install with: `uv tool install copier` or `pip install copier`"
1007
1139
  };
1008
1140
  }
1009
- if (existsSync4(join7(roleDir, "role.yaml")) && !ctx.force) {
1141
+ if (existsSync4(join8(roleDir, "role.yaml")) && !ctx.force) {
1010
1142
  if (ctx.yes) {
1011
1143
  ctx.force = true;
1012
1144
  } else {
@@ -1027,15 +1159,16 @@ var RunCopierTemplate = class extends Command {
1027
1159
  ...process.env,
1028
1160
  SKIP_TELEGRAM: "1",
1029
1161
  SKIP_EMAIL: "1",
1030
- // We DO want copier to run runtime-repo + plane + bloodbank + systemd.
1162
+ // Bloodbank is a fleet-shared Hermes gateway. Never provision the legacy
1163
+ // per-profile file consumer, even when an older template still exposes it.
1031
1164
  SKIP_RUNTIME_REPO: ctx.skipRuntimeRepo ? "1" : "0",
1032
1165
  SKIP_PLANE: ctx.skipPlane ? "1" : "0",
1033
- SKIP_BLOODBANK: ctx.skipBloodbank ? "1" : "0",
1166
+ SKIP_BLOODBANK: "1",
1034
1167
  SKIP_SYSTEMD: ctx.skipSystemd ? "1" : "0"
1035
1168
  };
1036
- const LOCAL_TEMPLATE = join7(homedir2(), "code", "hermes-agent-template");
1169
+ const LOCAL_TEMPLATE = join8(homedir2(), "code", "hermes-agent-template");
1037
1170
  const vendored = resolveVendoredTemplate("hermes-agent");
1038
- const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync4(join7(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
1171
+ const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync4(join8(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
1039
1172
  const args = [
1040
1173
  "copy",
1041
1174
  templateSrc,
@@ -1066,7 +1199,7 @@ var RunCopierTemplate = class extends Command {
1066
1199
  message: this.formatMessage(`Would run: copier ${args.join(" ")}`)
1067
1200
  };
1068
1201
  }
1069
- mkdirSync3(join7(ctx.targetDir, "agents", "hermes"), { recursive: true });
1202
+ mkdirSync3(join8(ctx.targetDir, "agents", "hermes"), { recursive: true });
1070
1203
  const spinner4 = p2.spinner();
1071
1204
  spinner4.start(`Running copier copy (target: agents/hermes/${role})`);
1072
1205
  const result = spawnSync("copier", args, {
@@ -1090,20 +1223,26 @@ var RunCopierTemplate = class extends Command {
1090
1223
  };
1091
1224
 
1092
1225
  // src/commands/hermes/UntrackHermesRuntimes.ts
1093
- import { existsSync as existsSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync4, readdirSync } from "fs";
1094
- import { join as join8 } from "path";
1226
+ import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4, readdirSync as readdirSync2 } from "fs";
1227
+ import { join as join9 } from "path";
1095
1228
  import { spawnSync as spawnSync2 } from "node:child_process";
1229
+ function sectionHasPath(section, targetPath) {
1230
+ return section.split(/\r?\n/).some((line) => /^\s*path\s*=/.test(line) && line.replace(/^\s*path\s*=\s*/, "").trim() === targetPath);
1231
+ }
1232
+ function removeSubmodulePath(content, targetPath) {
1233
+ return content.replace(/^\[submodule "[^"\n]+"\][\s\S]*?(?=^\[submodule "|(?![\s\S]))/gm, (section) => sectionHasPath(section, targetPath) ? "" : section).replace(/\n{3,}/g, "\n\n").trim();
1234
+ }
1096
1235
  var UntrackHermesRuntimes = class extends Command {
1097
1236
  async invoke() {
1098
1237
  const targetDir = this.context.targetDir;
1099
- const rolesDir = join8(targetDir, "agents", "hermes");
1238
+ const rolesDir = join9(targetDir, "agents", "hermes");
1100
1239
  if (!existsSync5(rolesDir)) {
1101
1240
  return {
1102
1241
  success: true,
1103
1242
  message: "No Hermes agents found (no agents/hermes directory)."
1104
1243
  };
1105
1244
  }
1106
- const roles = readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
1245
+ const roles = readdirSync2(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
1107
1246
  if (roles.length === 0) {
1108
1247
  return {
1109
1248
  success: true,
@@ -1113,47 +1252,79 @@ var UntrackHermesRuntimes = class extends Command {
1113
1252
  let modifiedAny = false;
1114
1253
  const details = [];
1115
1254
  for (const role of roles) {
1116
- const roleDir = join8("agents", "hermes", role);
1117
- const runtimePath = join8(roleDir, "runtime");
1118
- const gitignorePath = join8(roleDir, ".gitignore");
1255
+ const roleDir = join9("agents", "hermes", role);
1256
+ const runtimePath = join9(roleDir, "runtime");
1257
+ const gitignorePath = join9(roleDir, ".gitignore");
1258
+ const gitmodulesPath = join9(targetDir, ".gitmodules");
1119
1259
  let isTracked = false;
1120
- const lsResult = spawnSync2("git", ["ls-files", "--stage", runtimePath], {
1260
+ const lsResult = spawnSync2("git", ["ls-files", "--stage", "--", runtimePath], {
1121
1261
  cwd: targetDir,
1122
1262
  encoding: "utf8"
1123
1263
  });
1264
+ if (lsResult.status !== 0) {
1265
+ return {
1266
+ success: false,
1267
+ message: `\u2717 Failed to inspect runtime index at ${runtimePath}: ${lsResult.stderr.trim() || `exit ${lsResult.status}`}`
1268
+ };
1269
+ }
1124
1270
  if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
1125
1271
  isTracked = true;
1126
1272
  }
1273
+ let hasStaleMapping = false;
1274
+ let gitmodulesContent = "";
1275
+ if (existsSync5(gitmodulesPath)) {
1276
+ gitmodulesContent = readFileSync4(gitmodulesPath, "utf8");
1277
+ const sections = gitmodulesContent.match(/^\[submodule "[^"\n]+"\][\s\S]*?(?=^\[submodule "|(?![\s\S]))/gm) ?? [];
1278
+ hasStaleMapping = sections.some((section) => sectionHasPath(section, runtimePath));
1279
+ }
1127
1280
  let isIgnored = false;
1128
- const fullGitignorePath = join8(targetDir, gitignorePath);
1281
+ const fullGitignorePath = join9(targetDir, gitignorePath);
1129
1282
  if (existsSync5(fullGitignorePath)) {
1130
- const content = readFileSync3(fullGitignorePath, "utf8");
1283
+ const content = readFileSync4(fullGitignorePath, "utf8");
1131
1284
  const lines = content.split(/\r?\n/).map((line) => line.trim());
1132
1285
  isIgnored = lines.includes("runtime/") || lines.includes("runtime");
1133
1286
  }
1134
- if (isTracked || !isIgnored) {
1287
+ if (isTracked || hasStaleMapping || !isIgnored) {
1135
1288
  modifiedAny = true;
1136
1289
  if (isTracked) {
1137
1290
  details.push(`untrack agents/hermes/${role}/runtime`);
1138
1291
  if (!this.context.dryRun) {
1139
- const rmResult = spawnSync2("git", ["rm", "--cached", "-r", runtimePath], {
1292
+ const rmResult = spawnSync2("git", ["rm", "--cached", "-r", "-f", "--", runtimePath], {
1140
1293
  cwd: targetDir,
1141
1294
  encoding: "utf8"
1142
1295
  });
1143
1296
  if (rmResult.status !== 0) {
1144
1297
  return {
1145
1298
  success: false,
1146
- message: `Failed to untrack agents/hermes/${role}/runtime: ${rmResult.stderr}`
1299
+ message: `\u2717 Failed to untrack ${runtimePath}: ${rmResult.stderr.trim() || `exit ${rmResult.status}`}`
1300
+ };
1301
+ }
1302
+ const verifyResult = spawnSync2("git", ["ls-files", "--stage", "--", runtimePath], {
1303
+ cwd: targetDir,
1304
+ encoding: "utf8"
1305
+ });
1306
+ if (verifyResult.status !== 0 || verifyResult.stdout.trim()) {
1307
+ return {
1308
+ success: false,
1309
+ 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}`
1147
1310
  };
1148
1311
  }
1149
1312
  }
1150
1313
  }
1314
+ if (hasStaleMapping) {
1315
+ details.push(`remove stale .gitmodules mapping for ${runtimePath}`);
1316
+ if (!this.context.dryRun) {
1317
+ const next = removeSubmodulePath(gitmodulesContent, runtimePath);
1318
+ writeFileSync4(gitmodulesPath, next ? `${next}
1319
+ ` : "", "utf8");
1320
+ }
1321
+ }
1151
1322
  if (!isIgnored) {
1152
1323
  details.push(`ignore runtime/ in agents/hermes/${role}/.gitignore`);
1153
1324
  if (!this.context.dryRun) {
1154
1325
  let content = "";
1155
1326
  if (existsSync5(fullGitignorePath)) {
1156
- content = readFileSync3(fullGitignorePath, "utf8");
1327
+ content = readFileSync4(fullGitignorePath, "utf8");
1157
1328
  }
1158
1329
  if (content && !content.endsWith("\n")) {
1159
1330
  content += "\n";
@@ -1181,7 +1352,7 @@ ${details.map((d) => ` - ${d}`).join("\n")}`
1181
1352
 
1182
1353
  // src/commands/hermes/WireTelegram.ts
1183
1354
  import { spawnSync as spawnSync3 } from "node:child_process";
1184
- import { join as join9 } from "node:path";
1355
+ import { join as join10 } from "node:path";
1185
1356
  import { existsSync as existsSync6, unlinkSync } from "node:fs";
1186
1357
  import * as p3 from "@clack/prompts";
1187
1358
  var WireTelegram = class extends Command {
@@ -1270,14 +1441,14 @@ var WireTelegram = class extends Command {
1270
1441
  if (p3.isCancel(allowedAnswer)) {
1271
1442
  return { success: false, message: "\u2717 Aborted; Telegram step deferred." };
1272
1443
  }
1273
- const script = join9(roleDir, ".scripts", "30-telegram.sh");
1444
+ const script = join10(roleDir, ".scripts", "30-telegram.sh");
1274
1445
  if (!existsSync6(script)) {
1275
1446
  return {
1276
1447
  success: false,
1277
1448
  message: `\u2717 ${script} not found. Did copier finish? Re-run with --skip-runtime-repo=0 if you skipped it.`
1278
1449
  };
1279
1450
  }
1280
- const marker = join9(roleDir, ".scripts", ".done-30-telegram");
1451
+ const marker = join10(roleDir, ".scripts", ".done-30-telegram");
1281
1452
  if (existsSync6(marker)) unlinkSync(marker);
1282
1453
  const spinner4 = p3.spinner();
1283
1454
  spinner4.start("Verifying token + wiring profile");
@@ -1305,7 +1476,7 @@ function cap(s) {
1305
1476
 
1306
1477
  // src/commands/hermes/WireEmail.ts
1307
1478
  import { spawnSync as spawnSync4 } from "node:child_process";
1308
- import { join as join10 } from "node:path";
1479
+ import { join as join11 } from "node:path";
1309
1480
  import { existsSync as existsSync7, unlinkSync as unlinkSync2 } from "node:fs";
1310
1481
  import * as p4 from "@clack/prompts";
1311
1482
  var WireEmail = class extends Command {
@@ -1321,7 +1492,7 @@ var WireEmail = class extends Command {
1321
1492
  if (!targetRepo || !role || !roleDir) {
1322
1493
  return { success: false, message: "Cannot wire email: missing target_repo/role/roleDir" };
1323
1494
  }
1324
- const script = join10(roleDir, ".scripts", "50-email.sh");
1495
+ const script = join11(roleDir, ".scripts", "50-email.sh");
1325
1496
  if (!existsSync7(script)) {
1326
1497
  return { success: false, message: `\u2717 ${script} not found` };
1327
1498
  }
@@ -1384,7 +1555,7 @@ var WireEmail = class extends Command {
1384
1555
  }
1385
1556
  }
1386
1557
  }
1387
- const marker = join10(roleDir, ".scripts", ".done-50-email");
1558
+ const marker = join11(roleDir, ".scripts", ".done-50-email");
1388
1559
  if (existsSync7(marker)) unlinkSync2(marker);
1389
1560
  const spinner4 = p4.spinner();
1390
1561
  spinner4.start("Creating Cloudflare Email Routing rule");
@@ -1413,7 +1584,6 @@ var PrintHermesSummary = class extends Command {
1413
1584
  const botHandle = `${targetRepo?.toLowerCase().replace(/-/g, "_")}_${role?.toLowerCase()}_bot`;
1414
1585
  const email = `${targetRepo}-${role}@delo.sh`;
1415
1586
  const gw = `hermes-${agentId}-gateway.service`;
1416
- const csm = `hermes-${agentId}-consumer.service`;
1417
1587
  const hb = `hermes-${agentId}-heartbeat.timer`;
1418
1588
  const lines = [];
1419
1589
  lines.push(`agent_id ${agentId}`);
@@ -1423,13 +1593,13 @@ var PrintHermesSummary = class extends Command {
1423
1593
  if (!skipEmail) lines.push(`email ${email}`);
1424
1594
  lines.push("");
1425
1595
  lines.push("Start daemons:");
1426
- lines.push(` systemctl --user start ${csm}`);
1427
1596
  lines.push(` systemctl --user start ${hb}`);
1428
1597
  if (!skipTelegram) {
1429
1598
  lines.push(` systemctl --user start ${gw}`);
1430
1599
  } else {
1431
1600
  lines.push(` # gateway needs Telegram wired first (re-run with --skip-telegram=0)`);
1432
1601
  }
1602
+ lines.push(" # Bloodbank commands arrive through the fleet-shared Hermes gateway");
1433
1603
  lines.push("");
1434
1604
  lines.push("Talk locally:");
1435
1605
  lines.push(` ${ctx.roleDir}/hermes chat "status"`);
@@ -1472,15 +1642,15 @@ var HermesAgentRecipe = class extends Recipe {
1472
1642
 
1473
1643
  // src/commands/AgentHooksCommands.ts
1474
1644
  import { homedir as homedir4 } from "node:os";
1475
- import { join as join12, dirname as dirname5 } from "node:path";
1476
- import { existsSync as existsSync9, cpSync, mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "node:fs";
1645
+ import { join as join13, dirname as dirname5 } from "node:path";
1646
+ import { existsSync as existsSync9, cpSync, mkdirSync as mkdirSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "node:fs";
1477
1647
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1478
1648
 
1479
1649
  // src/project/index.ts
1480
1650
  import { spawnSync as spawnSync5 } from "node:child_process";
1481
- import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync4, renameSync, statSync, writeFileSync as writeFileSync5 } from "node:fs";
1651
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync5, renameSync, statSync, writeFileSync as writeFileSync5 } from "node:fs";
1482
1652
  import { homedir as homedir3 } from "node:os";
1483
- import { basename as basename2, delimiter, dirname as dirname4, join as join11, resolve } from "node:path";
1653
+ import { basename as basename3, delimiter, dirname as dirname4, join as join12, resolve as resolve2 } from "node:path";
1484
1654
  import YAML2 from "yaml";
1485
1655
 
1486
1656
  // src/project/RegistryStore.ts
@@ -1715,18 +1885,18 @@ var PROJECT_SOURCE_SKILL_ROOTS_ENV = "PJ_SOURCE_SKILL_ROOTS";
1715
1885
  var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
1716
1886
  var DEFAULT_SOURCE_SKILL_ROOTS = [
1717
1887
  "/home/delorenj/code/skillex/all-skills",
1718
- join11(homedir3(), ".agents", "skills"),
1719
- join11(homedir3(), ".codex", "skills")
1888
+ join12(homedir3(), ".agents", "skills"),
1889
+ join12(homedir3(), ".codex", "skills")
1720
1890
  ];
1721
1891
  function projectRegistryPath(env2 = process.env) {
1722
- return expandHome(env2[PROJECT_REGISTRY_ENV] || join11(homedir3(), ".config", "pjangler", "projects.yaml"));
1892
+ return expandHome(env2[PROJECT_REGISTRY_ENV] || join12(homedir3(), ".config", "pjangler", "projects.yaml"));
1723
1893
  }
1724
1894
  function emptyProjectRegistry() {
1725
1895
  return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
1726
1896
  }
1727
1897
  function loadProjectRegistry(path = projectRegistryPath()) {
1728
1898
  if (!existsSync8(path)) return emptyProjectRegistry();
1729
- const raw = YAML2.parse(readFileSync4(path, "utf8"));
1899
+ const raw = YAML2.parse(readFileSync5(path, "utf8"));
1730
1900
  if (raw == null) return emptyProjectRegistry();
1731
1901
  if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
1732
1902
  const registry = raw;
@@ -1756,7 +1926,7 @@ function validateProjectRegistry(registry) {
1756
1926
  validateProjectRecord(project, slug);
1757
1927
  if (slugs.has(project.slug)) throw new Error(`Duplicate project slug: ${project.slug}`);
1758
1928
  slugs.add(project.slug);
1759
- const repoKey = resolve(project.repo_path);
1929
+ const repoKey = resolve2(project.repo_path);
1760
1930
  const existingRepoSlug = repoPaths.get(repoKey);
1761
1931
  if (existingRepoSlug && existingRepoSlug !== slug) {
1762
1932
  throw new Error(`Duplicate project repo_path: ${project.repo_path} used by ${existingRepoSlug} and ${slug}`);
@@ -1823,7 +1993,7 @@ function resolveAgentHooksLayer(input, env2 = process.env) {
1823
1993
  const override = env2.PJ_AGENT_HOOKS_LAYER;
1824
1994
  if (override === "0" || override === "false") return false;
1825
1995
  if (override === "1" || override === "true") return true;
1826
- return !existsSync8(join11(homedir3(), ".agents", "hooks"));
1996
+ return !existsSync8(join12(homedir3(), ".agents", "hooks"));
1827
1997
  }
1828
1998
  function jsonStable(value) {
1829
1999
  return JSON.stringify(value);
@@ -1836,14 +2006,14 @@ function projectRecordEquivalent(a, b) {
1836
2006
  }
1837
2007
  function defaultProjectTargetDir(name, cwd = process.cwd()) {
1838
2008
  const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
1839
- return resolve(dirname4(resolve(cwd)), compactName);
2009
+ return resolve2(dirname4(resolve2(cwd)), compactName);
1840
2010
  }
1841
2011
  function sourceSkillRoots(env2 = process.env) {
1842
2012
  const configuredRoots = (env2[PROJECT_SOURCE_SKILL_ROOTS_ENV] || "").split(delimiter).map((root) => root.trim()).filter(Boolean);
1843
2013
  const seen = /* @__PURE__ */ new Set();
1844
2014
  const roots = [];
1845
2015
  for (const root of [...DEFAULT_SOURCE_SKILL_ROOTS, ...configuredRoots]) {
1846
- const normalized = resolve(expandHome(root));
2016
+ const normalized = resolve2(expandHome(root));
1847
2017
  if (seen.has(normalized)) continue;
1848
2018
  seen.add(normalized);
1849
2019
  roots.push(normalized);
@@ -1853,12 +2023,12 @@ function sourceSkillRoots(env2 = process.env) {
1853
2023
  function resolveSourceSkillPath(sourceSkill, env2 = process.env) {
1854
2024
  if (!sourceSkill) return void 0;
1855
2025
  const expanded = expandHome(sourceSkill);
1856
- const direct = resolve(expanded);
2026
+ const direct = resolve2(expanded);
1857
2027
  if (existsSync8(direct)) return direct;
1858
- const name = basename2(sourceSkill);
2028
+ const name = basename3(sourceSkill);
1859
2029
  const roots = sourceSkillRoots(env2);
1860
2030
  for (const root of roots) {
1861
- const candidate = join11(root, name);
2031
+ const candidate = join12(root, name);
1862
2032
  if (existsSync8(candidate)) return candidate;
1863
2033
  }
1864
2034
  const searched = roots.length ? ` Searched roots: ${roots.join(", ")}.` : "";
@@ -1867,11 +2037,11 @@ function resolveSourceSkillPath(sourceSkill, env2 = process.env) {
1867
2037
  }
1868
2038
  function planProjectInit(input) {
1869
2039
  if (!input.name.trim()) throw new Error("Project name is required");
1870
- const registryPath2 = resolve(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
2040
+ const registryPath2 = resolve2(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
1871
2041
  const registry = loadProjectRegistry(registryPath2);
1872
2042
  const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
1873
2043
  const slug = input.projectSlug ?? slugifyProjectName(input.name);
1874
- const targetDir = resolve(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
2044
+ const targetDir = resolve2(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
1875
2045
  const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
1876
2046
  const existing = registry.projects[slug];
1877
2047
  const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
@@ -1914,7 +2084,7 @@ function planProjectInit(input) {
1914
2084
  updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
1915
2085
  };
1916
2086
  validateNoDuplicateProject(registry, project, overwrite);
1917
- const pjanglerRoot = resolve(input.pjanglerRoot ?? resolvePjanglerRoot());
2087
+ const pjanglerRoot = resolve2(input.pjanglerRoot ?? resolvePjanglerRoot());
1918
2088
  const manifest = projectManifestFromRegistryProject(project);
1919
2089
  const apply = input.apply ?? false;
1920
2090
  const live = input.live ?? false;
@@ -1940,7 +2110,7 @@ function planProjectInit(input) {
1940
2110
  }));
1941
2111
  }
1942
2112
  actions.push(
1943
- { kind: "project.write-manifest", path: join11(targetDir, ".project.json"), manifest },
2113
+ { kind: "project.write-manifest", path: join12(targetDir, ".project.json"), manifest },
1944
2114
  {
1945
2115
  kind: "ticket-provider.create-or-link",
1946
2116
  enabled: live,
@@ -2001,7 +2171,7 @@ async function executeProjectInitPlan(plan) {
2001
2171
  mkdirSync4(dirname4(action.path), { recursive: true });
2002
2172
  const next = `${JSON.stringify(action.manifest, null, 2)}
2003
2173
  `;
2004
- const current = existsSync8(action.path) ? readFileSync4(action.path, "utf8") : void 0;
2174
+ const current = existsSync8(action.path) ? readFileSync5(action.path, "utf8") : void 0;
2005
2175
  if (current !== next) {
2006
2176
  writeFileSync5(action.path, next, "utf8");
2007
2177
  changedFiles.push(action.path);
@@ -2066,7 +2236,7 @@ function getProject(registry, slug) {
2066
2236
  return project;
2067
2237
  }
2068
2238
  function buildCommonProjectCopierAction(input) {
2069
- const templateDir = join11(input.pjanglerRoot, "templates", "commonproject");
2239
+ const templateDir = join12(input.pjanglerRoot, "templates", "commonproject");
2070
2240
  const data = {
2071
2241
  project_name: input.projectName,
2072
2242
  project_description: input.projectDescription ?? "",
@@ -2095,19 +2265,19 @@ function buildCommonProjectCopierAction(input) {
2095
2265
  function resolvePjanglerRoot() {
2096
2266
  let dir = dirname4(new URL(import.meta.url).pathname);
2097
2267
  while (dir !== dirname4(dir)) {
2098
- if (existsSync8(join11(dir, "package.json")) && existsSync8(join11(dir, "templates", "commonproject", "copier.yml"))) return dir;
2268
+ if (existsSync8(join12(dir, "package.json")) && existsSync8(join12(dir, "templates", "commonproject", "copier.yml"))) return dir;
2099
2269
  dir = dirname4(dir);
2100
2270
  }
2101
- return resolve(process.cwd());
2271
+ return resolve2(process.cwd());
2102
2272
  }
2103
2273
  function validateNoDuplicateProject(registry, project, overwrite) {
2104
2274
  const existingSameSlug = registry.projects[project.slug];
2105
- if (existingSameSlug && !overwrite && resolve(existingSameSlug.repo_path) !== resolve(project.repo_path)) {
2275
+ if (existingSameSlug && !overwrite && resolve2(existingSameSlug.repo_path) !== resolve2(project.repo_path)) {
2106
2276
  throw new Error(`Project slug already exists in registry: ${project.slug}`);
2107
2277
  }
2108
2278
  for (const [slug, existing] of Object.entries(registry.projects)) {
2109
2279
  if (slug === project.slug) continue;
2110
- if (resolve(existing.repo_path) === resolve(project.repo_path)) {
2280
+ if (resolve2(existing.repo_path) === resolve2(project.repo_path)) {
2111
2281
  throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
2112
2282
  }
2113
2283
  if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
@@ -2127,7 +2297,7 @@ function validateProjectRecord(project, key) {
2127
2297
  }
2128
2298
  function expandHome(path) {
2129
2299
  if (path === "~") return homedir3();
2130
- if (path.startsWith("~/")) return join11(homedir3(), path.slice(2));
2300
+ if (path.startsWith("~/")) return join12(homedir3(), path.slice(2));
2131
2301
  return path;
2132
2302
  }
2133
2303
  function isRecord(value) {
@@ -2144,16 +2314,16 @@ function resolveTemplateRoot() {
2144
2314
  try {
2145
2315
  let dir = dirname5(fileURLToPath2(import.meta.url));
2146
2316
  for (let i = 0; i < 8; i++) {
2147
- candidates.push(join12(dir, "templates", "commonproject", "template"));
2317
+ candidates.push(join13(dir, "templates", "commonproject", "template"));
2148
2318
  const parent = dirname5(dir);
2149
2319
  if (parent === dir) break;
2150
2320
  dir = parent;
2151
2321
  }
2152
2322
  } catch {
2153
2323
  }
2154
- candidates.push(join12(homedir4(), "code", "pjangler", "templates", "commonproject", "template"));
2324
+ candidates.push(join13(homedir4(), "code", "pjangler", "templates", "commonproject", "template"));
2155
2325
  for (const c of candidates) {
2156
- if (existsSync9(join12(c, ".agents", "hooks", "hooks.master.json"))) return c;
2326
+ if (existsSync9(join13(c, ".agents", "hooks", "hooks.master.json"))) return c;
2157
2327
  }
2158
2328
  throw new Error(
2159
2329
  "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
@@ -2179,8 +2349,8 @@ var CopyAgentHooksTree = class extends Command {
2179
2349
  const created = [];
2180
2350
  const skipped = [];
2181
2351
  for (const { rel, dir } of items) {
2182
- const src = join12(templateRoot, rel);
2183
- const dest = join12(this.context.targetDir, rel);
2352
+ const src = join13(templateRoot, rel);
2353
+ const dest = join13(this.context.targetDir, rel);
2184
2354
  if (!existsSync9(src)) continue;
2185
2355
  if (existsSync9(dest) && !this.context.force) {
2186
2356
  skipped.push(rel);
@@ -2208,14 +2378,14 @@ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
2208
2378
  if (!resolveAgentHooksLayer()) {
2209
2379
  return { success: true, message: this.formatMessage(AGENT_HOOKS_SKIP_MESSAGE) };
2210
2380
  }
2211
- const misePath = join12(this.context.targetDir, "mise.toml");
2381
+ const misePath = join13(this.context.targetDir, "mise.toml");
2212
2382
  if (!existsSync9(misePath)) {
2213
2383
  return {
2214
2384
  success: false,
2215
2385
  message: "\u26A0\uFE0F No mise.toml found \u2014 run `pjangler init mise` first, then re-run."
2216
2386
  };
2217
2387
  }
2218
- let content = readFileSync5(misePath, "utf8");
2388
+ let content = readFileSync6(misePath, "utf8");
2219
2389
  if (content.includes(_WireMiseAgentHooks.MARKER)) {
2220
2390
  return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for agent-hooks") };
2221
2391
  }
@@ -2230,15 +2400,15 @@ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
2230
2400
  const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
2231
2401
  if (enterRe.test(content)) {
2232
2402
  content = content.replace(enterRe, (_m, head, close) => {
2233
- const sep = /[,[]\s*$/.test(head) ? "" : ",";
2234
- return `${head}${sep}
2403
+ const sep2 = /[,[]\s*$/.test(head) ? "" : ",";
2404
+ return `${head}${sep2}
2235
2405
  ${enterAdds}${close}`;
2236
2406
  });
2237
2407
  const leaveRe = /(leave\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
2238
2408
  if (leaveRe.test(content)) {
2239
2409
  content = content.replace(leaveRe, (_m, head, close) => {
2240
- const sep = /[,[]\s*$/.test(head) ? "" : ",";
2241
- return `${head}${sep}
2410
+ const sep2 = /[,[]\s*$/.test(head) ? "" : ",";
2411
+ return `${head}${sep2}
2242
2412
  "${cr}/.agents/hooks/sync.py --uninstall --quiet",${close}`;
2243
2413
  });
2244
2414
  } else {
@@ -2463,15 +2633,15 @@ function createRecipe(name, context) {
2463
2633
  }
2464
2634
 
2465
2635
  // src/utils/version.ts
2466
- import { readFileSync as readFileSync6 } from "node:fs";
2467
- import { dirname as dirname6, join as join13 } from "node:path";
2636
+ import { readFileSync as readFileSync7 } from "node:fs";
2637
+ import { dirname as dirname6, join as join14 } from "node:path";
2468
2638
  import { fileURLToPath as fileURLToPath3 } from "node:url";
2469
2639
  var PJANGLER_VERSION = (() => {
2470
2640
  try {
2471
2641
  let dir = dirname6(fileURLToPath3(import.meta.url));
2472
2642
  for (let i = 0; i < 4; i++) {
2473
2643
  try {
2474
- const raw = readFileSync6(join13(dir, "package.json"), "utf8");
2644
+ const raw = readFileSync7(join14(dir, "package.json"), "utf8");
2475
2645
  return JSON.parse(raw).version ?? "0.0.0";
2476
2646
  } catch {
2477
2647
  const parent = dirname6(dir);
@@ -2485,24 +2655,41 @@ var PJANGLER_VERSION = (() => {
2485
2655
  })();
2486
2656
 
2487
2657
  // src/parity/index.ts
2488
- import { existsSync as existsSync10, lstatSync, mkdirSync as mkdirSync6, readFileSync as readFileSync7, readlinkSync, readdirSync as readdirSync2, renameSync as renameSync2, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync7, chmodSync as chmodSync4, copyFileSync, rmSync } from "node:fs";
2489
- import { basename as basename3, dirname as dirname7, join as join14, relative, resolve as resolve2 } from "node:path";
2490
- import { fileURLToPath as fileURLToPath4 } from "node:url";
2658
+ import { existsSync as existsSync10, lstatSync as lstatSync2, mkdirSync as mkdirSync6, mkdtempSync, readFileSync as readFileSync8, readlinkSync, readdirSync as readdirSync3, realpathSync, renameSync as renameSync2, rmdirSync, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync7, chmodSync as chmodSync4, copyFileSync, rmSync } from "node:fs";
2659
+ import { basename as basename4, dirname as dirname7, join as join15, relative as relative2, resolve as resolve3 } from "node:path";
2660
+ import { fileURLToPath as fileURLToPath4, pathToFileURL } from "node:url";
2491
2661
  import { homedir as homedir5 } from "node:os";
2492
2662
  import { spawnSync as spawnSync6 } from "node:child_process";
2493
2663
  import YAML3 from "yaml";
2494
2664
  var LINK_AGENTFILES_SCRIPT = "'{{config_root}}/.mise/scripts/link-agentfiles.sh'";
2495
2665
  var OP_INJECT_SCRIPT = "op inject -i .env.op > .env";
2496
- var SYNC_SKILLS_SCRIPT = "sync-skills.py --scope project";
2666
+ var PROVISION_BMAD_SKILLS_SCRIPT = "python3 '{{config_root}}/.mise/scripts/provision-bmad-skills.py'";
2667
+ var SYNC_SKILLS_SCRIPT = "python3 '{{config_root}}/.mise/scripts/sync-skills.py' --scope project";
2497
2668
  var CODEGRAPH_SCRIPT = "[ -f '{{config_root}}/.mise/scripts/codegraph.sh' ] && '{{config_root}}/.mise/scripts/codegraph.sh' || true";
2498
2669
  var SKILLS_REGISTRY_URL = "https://github.com/delorenj/skillex.git";
2670
+ var PROJECT_CLI_SKILL_DIRS = [
2671
+ ".gemini/skills",
2672
+ ".codex/skills",
2673
+ ".kimi/skills",
2674
+ ".augment/skills",
2675
+ ".config/opencode/skills",
2676
+ ".hermes/skills",
2677
+ ".claude/skills",
2678
+ ".openclaw/skills"
2679
+ ];
2680
+ var CANONICAL_CLAUDE_SKILLS_ALIAS = "../.agents/skills";
2499
2681
  var HOOKS_COMMENT_HEADER = `# This block will handle the linking of
2500
2682
  # agent files to the main AGENTS.md file.
2501
2683
  #
2502
2684
  # TODO: Ensure this works for all levels of nesting.
2503
2685
  # i.e. All linked agent files MUST be siblings at
2504
2686
  # any given level of nesting.`;
2505
- var LINK_AGENTFILES_HOOK_ENTRIES = [LINK_AGENTFILES_SCRIPT, OP_INJECT_SCRIPT, SYNC_SKILLS_SCRIPT];
2687
+ var LINK_AGENTFILES_HOOK_ENTRIES = [
2688
+ LINK_AGENTFILES_SCRIPT,
2689
+ OP_INJECT_SCRIPT,
2690
+ PROVISION_BMAD_SKILLS_SCRIPT,
2691
+ SYNC_SKILLS_SCRIPT
2692
+ ];
2506
2693
  var LINK_AGENTFILES_WATCH_TASK_BLOCK = `[[watch_files]]
2507
2694
  patterns = ["AGENTS.md"]
2508
2695
  task = "link-agentfiles"
@@ -2517,7 +2704,12 @@ run = "'{{config_root}}/.mise/scripts/link-agentfiles.sh'"
2517
2704
 
2518
2705
  [tasks.skills-sync]
2519
2706
  description = "Sync skills from manifest to local CLI dirs"
2520
- run = "sync-skills.py --scope project"`;
2707
+ depends = ["skills-provision-bmad"]
2708
+ run = "python3 '{{config_root}}/.mise/scripts/sync-skills.py' --scope project"
2709
+
2710
+ [tasks.skills-provision-bmad]
2711
+ description = "Provision pinned BMAD skills from the Skillex pack"
2712
+ run = "python3 '{{config_root}}/.mise/scripts/provision-bmad-skills.py'"`;
2521
2713
  var VERSIONING_BLOCK = `# >>> mise-versioning >>> (managed block \u2014 do not edit by hand; re-run init to update)
2522
2714
  [tasks."version"]
2523
2715
  description = "Print the current version (vX.Y.Z)"
@@ -2547,7 +2739,7 @@ run = "'{{config_root}}/.mise/scripts/versioning.sh' sync"
2547
2739
  function resolvePjanglerRoot2() {
2548
2740
  let dir = dirname7(fileURLToPath4(import.meta.url));
2549
2741
  while (dir !== dirname7(dir)) {
2550
- if (existsSync10(join14(dir, "package.json")) && existsSync10(join14(dir, "templates", "commonproject", "copier.yml"))) {
2742
+ if (existsSync10(join15(dir, "package.json")) && existsSync10(join15(dir, "templates", "commonproject", "copier.yml"))) {
2551
2743
  return dir;
2552
2744
  }
2553
2745
  dir = dirname7(dir);
@@ -2558,7 +2750,7 @@ function normalizeNewlines(value) {
2558
2750
  return value.replace(/\r\n/g, "\n");
2559
2751
  }
2560
2752
  function readText(path) {
2561
- return normalizeNewlines(readFileSync7(path, "utf8"));
2753
+ return normalizeNewlines(readFileSync8(path, "utf8"));
2562
2754
  }
2563
2755
  function safeReadText(path) {
2564
2756
  return existsSync10(path) ? readText(path) : null;
@@ -2594,7 +2786,7 @@ function readSymlinkTarget(path) {
2594
2786
  }
2595
2787
  function ensureSymlink(path, target, dryRun) {
2596
2788
  if (existsSync10(path)) {
2597
- const stat = lstatSync(path);
2789
+ const stat = lstatSync2(path);
2598
2790
  if (stat.isSymbolicLink()) {
2599
2791
  const current = readSymlinkTarget(path);
2600
2792
  if (current === target) return { changed: false };
@@ -2604,18 +2796,18 @@ function ensureSymlink(path, target, dryRun) {
2604
2796
  }
2605
2797
  return { changed: true };
2606
2798
  }
2607
- return { changed: false, blocked: `${relative(process.cwd(), path) || path} exists and is not a symlink` };
2799
+ return { changed: false, blocked: `${relative2(process.cwd(), path) || path} exists and is not a symlink` };
2608
2800
  }
2609
2801
  if (!dryRun) symlinkSync(target, path);
2610
2802
  return { changed: true };
2611
2803
  }
2612
2804
  function bootstrapAgentsFile(repoRoot, dryRun) {
2613
- const agentsPath = join14(repoRoot, "AGENTS.md");
2805
+ const agentsPath = join15(repoRoot, "AGENTS.md");
2614
2806
  if (existsSync10(agentsPath)) return { changedFiles: [], details: [] };
2615
2807
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2616
- const source = join14(repoRoot, file);
2808
+ const source = join15(repoRoot, file);
2617
2809
  if (!existsSync10(source)) continue;
2618
- const stat = lstatSync(source);
2810
+ const stat = lstatSync2(source);
2619
2811
  if (stat.isSymbolicLink()) continue;
2620
2812
  if (stat.isFile()) {
2621
2813
  if (!dryRun) renameSync2(source, agentsPath);
@@ -2623,9 +2815,9 @@ function bootstrapAgentsFile(repoRoot, dryRun) {
2623
2815
  }
2624
2816
  return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
2625
2817
  }
2626
- const readmePath = join14(repoRoot, "README.md");
2818
+ const readmePath = join15(repoRoot, "README.md");
2627
2819
  if (existsSync10(readmePath)) {
2628
- const stat = lstatSync(readmePath);
2820
+ const stat = lstatSync2(readmePath);
2629
2821
  if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
2630
2822
  if (!dryRun) copyFileSync(readmePath, agentsPath);
2631
2823
  return { changedFiles: [agentsPath], details: ["Copied README.md to AGENTS.md before wiring agent-file symlinks"] };
@@ -2663,11 +2855,11 @@ function yamlGet(text2, keyPath) {
2663
2855
  return "";
2664
2856
  }
2665
2857
  function discoverRoles(repoRoot) {
2666
- const rolesDir = join14(repoRoot, "agents", "hermes");
2858
+ const rolesDir = join15(repoRoot, "agents", "hermes");
2667
2859
  if (!existsSync10(rolesDir)) return [];
2668
- return readdirSync2(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
2669
- const roleDir = join14(rolesDir, entry.name);
2670
- const roleYamlPath = join14(roleDir, "role.yaml");
2860
+ return readdirSync3(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
2861
+ const roleDir = join15(rolesDir, entry.name);
2862
+ const roleYamlPath = join15(roleDir, "role.yaml");
2671
2863
  if (!existsSync10(roleYamlPath)) return null;
2672
2864
  const text2 = readText(roleYamlPath);
2673
2865
  const runtimeRepoRaw = yamlGet(text2, "runtime.github_repo");
@@ -2696,7 +2888,7 @@ function discoverRoles(repoRoot) {
2696
2888
  }).filter((value) => Boolean(value));
2697
2889
  }
2698
2890
  function registryPath(homeDir) {
2699
- return join14(homeDir, ".hermes", "agents-registry.yaml");
2891
+ return join15(homeDir, ".hermes", "agents-registry.yaml");
2700
2892
  }
2701
2893
  function systemctlUser(args) {
2702
2894
  const result = spawnSync6("systemctl", ["--user", ...args], { encoding: "utf8" });
@@ -2707,7 +2899,7 @@ function systemctlUser(args) {
2707
2899
  };
2708
2900
  }
2709
2901
  function templateScript(ctx, name) {
2710
- const source = join14(ctx.pjanglerRoot, ".mise", "scripts", name);
2902
+ const source = join15(ctx.pjanglerRoot, ".mise", "scripts", name);
2711
2903
  return existsSync10(source) ? readText(source) : void 0;
2712
2904
  }
2713
2905
  function templateVersioningScript(ctx) {
@@ -2720,8 +2912,8 @@ function resolveAgentHooksLayer2(ctx) {
2720
2912
  const override = process.env.PJ_AGENT_HOOKS_LAYER;
2721
2913
  if (override === "0" || override === "false") return false;
2722
2914
  if (override === "1" || override === "true") return true;
2723
- if (existsSync10(join14(ctx.repoRoot, ".agents", "hooks", "sync.py"))) return true;
2724
- return !existsSync10(join14(ctx.homeDir, ".agents", "hooks"));
2915
+ if (existsSync10(join15(ctx.repoRoot, ".agents", "hooks", "sync.py"))) return true;
2916
+ return !existsSync10(join15(ctx.homeDir, ".agents", "hooks"));
2725
2917
  }
2726
2918
  function evaluateMiseConditionals(template, agentHooksLayer) {
2727
2919
  const out = [];
@@ -2747,13 +2939,13 @@ function evaluateMiseConditionals(template, agentHooksLayer) {
2747
2939
  }
2748
2940
  function renderGeneratedProjectMiseToml(ctx, template) {
2749
2941
  const project = readProjectJson(ctx);
2750
- const projectName = String(project?.project_name ?? basename3(ctx.repoRoot) ?? "project");
2942
+ const projectName = String(project?.project_name ?? basename4(ctx.repoRoot) ?? "project");
2751
2943
  return evaluateMiseConditionals(template, resolveAgentHooksLayer2(ctx)).replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
2752
2944
  }
2753
2945
  function ensureMiseTomlFromTemplate(ctx, changedFiles) {
2754
- const targetPath = join14(ctx.repoRoot, "mise.toml");
2946
+ const targetPath = join15(ctx.repoRoot, "mise.toml");
2755
2947
  if (existsSync10(targetPath)) return false;
2756
- const sourcePath = join14(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
2948
+ const sourcePath = join15(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
2757
2949
  if (!existsSync10(sourcePath)) return false;
2758
2950
  changedFiles.push(targetPath);
2759
2951
  if (!ctx.dryRun) {
@@ -2762,24 +2954,426 @@ function ensureMiseTomlFromTemplate(ctx, changedFiles) {
2762
2954
  return true;
2763
2955
  }
2764
2956
  function templateCommonProjectText(ctx, rel) {
2765
- const path = join14(ctx.pjanglerRoot, "templates", "commonproject", "template", rel);
2957
+ const path = join15(ctx.pjanglerRoot, "templates", "commonproject", "template", rel);
2766
2958
  return existsSync10(path) ? readText(path) : void 0;
2767
2959
  }
2768
- function canonicalSkillsManifest() {
2960
+ function validateSkillName(name) {
2961
+ if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\") || basename4(name) !== name) {
2962
+ throw new Error(`Unsafe skill name: ${JSON.stringify(name)}`);
2963
+ }
2964
+ return name;
2965
+ }
2966
+ function lstatIfPresent(path) {
2967
+ try {
2968
+ return lstatSync2(path);
2969
+ } catch (error) {
2970
+ if (error.code === "ENOENT") return void 0;
2971
+ throw error;
2972
+ }
2973
+ }
2974
+ function isContainedBy(root, target) {
2975
+ const rel = relative2(root, target);
2976
+ return rel === "" || rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\");
2977
+ }
2978
+ function prepareSafeProjectSkillsDirs(ctx) {
2979
+ const projectRoot = realpathSync(ctx.repoRoot);
2980
+ const agentsDir = join15(projectRoot, ".agents");
2981
+ const skillsDir = join15(agentsDir, "skills");
2982
+ for (const path of [agentsDir, skillsDir]) {
2983
+ if (!isContainedBy(projectRoot, path)) throw new Error(`Project skills path escapes repository: ${path}`);
2984
+ const stat = lstatIfPresent(path);
2985
+ if (stat?.isSymbolicLink()) throw new Error(`Refusing symlinked project skills directory: ${path}`);
2986
+ if (stat && !stat.isDirectory()) throw new Error(`Project skills path is not a directory: ${path}`);
2987
+ }
2988
+ if (!ctx.dryRun) {
2989
+ if (!existsSync10(agentsDir)) mkdirSync6(agentsDir, { recursive: false });
2990
+ if (!existsSync10(skillsDir)) mkdirSync6(skillsDir, { recursive: false });
2991
+ for (const path of [agentsDir, skillsDir]) {
2992
+ if (lstatSync2(path).isSymbolicLink() || !lstatSync2(path).isDirectory()) {
2993
+ throw new Error(`Unsafe project skills directory after creation: ${path}`);
2994
+ }
2995
+ if (!isContainedBy(projectRoot, realpathSync(path))) {
2996
+ throw new Error(`Resolved project skills directory escapes repository: ${path}`);
2997
+ }
2998
+ }
2999
+ }
3000
+ return { agentsDir, skillsDir };
3001
+ }
3002
+ function projectSkillTopologyIssues(repoRoot) {
3003
+ const issues = [];
3004
+ let projectRoot;
3005
+ try {
3006
+ projectRoot = realpathSync(repoRoot);
3007
+ } catch (error) {
3008
+ return [`Project root is not a readable real directory: ${error instanceof Error ? error.message : String(error)}`];
3009
+ }
3010
+ const managedSkills = join15(projectRoot, ".agents", "skills");
3011
+ for (const rel of PROJECT_CLI_SKILL_DIRS) {
3012
+ const cliDir = join15(projectRoot, rel);
3013
+ const parent = dirname7(cliDir);
3014
+ const parentStat = lstatIfPresent(parent);
3015
+ if (!parentStat) continue;
3016
+ if (parentStat.isSymbolicLink() || !parentStat.isDirectory()) {
3017
+ issues.push(`${rel} has an unsafe symlinked/non-directory parent`);
3018
+ continue;
3019
+ }
3020
+ if (!isContainedBy(projectRoot, realOrSelf(parent))) {
3021
+ issues.push(`${rel} parent resolves outside the project`);
3022
+ continue;
3023
+ }
3024
+ const stat = lstatIfPresent(cliDir);
3025
+ if (!stat) continue;
3026
+ if (stat.isSymbolicLink()) {
3027
+ let rawTarget = "";
3028
+ try {
3029
+ rawTarget = readlinkSync(cliDir);
3030
+ } catch {
3031
+ issues.push(`${rel} is an unreadable skills directory symlink`);
3032
+ continue;
3033
+ }
3034
+ if (rel !== ".claude/skills" || rawTarget !== CANONICAL_CLAUDE_SKILLS_ALIAS) {
3035
+ issues.push(`${rel} is an unsupported skills directory symlink`);
3036
+ continue;
3037
+ }
3038
+ const managedStat = lstatIfPresent(managedSkills);
3039
+ if (!managedStat || managedStat.isSymbolicLink() || !managedStat.isDirectory()) {
3040
+ issues.push(`${rel} canonical alias target .agents/skills is missing or unsafe`);
3041
+ continue;
3042
+ }
3043
+ try {
3044
+ if (realpathSync(cliDir) !== realpathSync(managedSkills)) {
3045
+ issues.push(`${rel} canonical alias resolves outside .agents/skills`);
3046
+ }
3047
+ } catch {
3048
+ issues.push(`${rel} canonical alias is broken`);
3049
+ }
3050
+ continue;
3051
+ }
3052
+ if (!stat.isDirectory()) {
3053
+ issues.push(`${rel} is not a directory`);
3054
+ continue;
3055
+ }
3056
+ if (!isContainedBy(projectRoot, realOrSelf(cliDir))) {
3057
+ issues.push(`${rel} resolves outside the project`);
3058
+ }
3059
+ }
3060
+ return issues;
3061
+ }
3062
+ function bmadPackRoot(ctx) {
3063
+ return resolve3(
3064
+ process.env.PJ_BMAD_PACK_ROOT?.trim() || join15(ctx.homeDir, "code", "skillex", "packs", "bmad", BMAD_PACK_VERSION)
3065
+ );
3066
+ }
3067
+ function canonicalBmadSkillEntries(ctx) {
3068
+ const root = bmadPackRoot(ctx);
3069
+ const trusted = validateTrustedBmadPack(root);
3070
+ return trusted.skillNames.map((name) => ({ name: validateSkillName(name), source: pathToFileURL(join15(root, name)).href }));
3071
+ }
3072
+ function skillManifestEntryName(entry) {
3073
+ if (typeof entry === "string") return entry;
3074
+ if (!entry || typeof entry !== "object") return void 0;
3075
+ const name = entry.name;
3076
+ return typeof name === "string" ? name : void 0;
3077
+ }
3078
+ function isPackManagedManifestEntry(entry, expectedNames, packRoot) {
3079
+ const name = skillManifestEntryName(entry);
3080
+ if (!name) return false;
3081
+ if (expectedNames.has(name)) return true;
3082
+ if (!entry || typeof entry !== "object") return false;
3083
+ const source = entry.source;
3084
+ if (typeof source !== "string" || !source.startsWith("file:")) return false;
3085
+ try {
3086
+ const sourcePath = resolve3(fileURLToPath4(source));
3087
+ return basename4(sourcePath) === name && isContainedBy(packRoot, sourcePath);
3088
+ } catch {
3089
+ return false;
3090
+ }
3091
+ }
3092
+ function canonicalSkillsManifest(ctx, current, packSkills = canonicalBmadSkillEntries(ctx)) {
3093
+ const existing = Array.isArray(current?.skills) ? current.skills : [];
3094
+ const expectedNames = new Set(packSkills.map((entry) => entry.name));
3095
+ const packRoot = bmadPackRoot(ctx);
2769
3096
  return `${JSON.stringify(
2770
3097
  {
3098
+ ...current ?? {},
2771
3099
  $schema: "https://raw.githubusercontent.com/skillex/schemas/main/skills.schema.json",
2772
3100
  inherit_global: true,
2773
3101
  registry: SKILLS_REGISTRY_URL,
2774
- skills: []
3102
+ skills: [
3103
+ ...existing.filter((entry) => !isPackManagedManifestEntry(entry, expectedNames, packRoot)),
3104
+ ...packSkills
3105
+ ]
2775
3106
  },
2776
3107
  null,
2777
3108
  2
2778
3109
  )}
2779
3110
  `;
2780
3111
  }
3112
+ function removeProjectEntry(path) {
3113
+ const stat = lstatIfPresent(path);
3114
+ if (!stat) return;
3115
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
3116
+ rmSync(path, { recursive: true, force: true });
3117
+ return;
3118
+ }
3119
+ try {
3120
+ unlinkSync3(path);
3121
+ } catch (error) {
3122
+ if (error.code !== "ENOENT") throw error;
3123
+ }
3124
+ }
3125
+ function normalizeExecutableTemplate(ctx, target, expected, changedFiles) {
3126
+ const stat = lstatIfPresent(target);
3127
+ if (stat && (!stat.isFile() || stat.isSymbolicLink())) {
3128
+ throw new Error(`Refusing non-regular managed executable target: ${target}`);
3129
+ }
3130
+ const contentChanged = !stat || safeReadText(target) !== expected;
3131
+ const modeChanged = !stat || (Number(stat.mode) & 73) === 0;
3132
+ if (!contentChanged && !modeChanged) return;
3133
+ if (!changedFiles.includes(target)) changedFiles.push(target);
3134
+ if (ctx.dryRun) return;
3135
+ if (contentChanged) {
3136
+ writeText(target, expected);
3137
+ }
3138
+ const beforeChmod = lstatIfPresent(target);
3139
+ if (!beforeChmod?.isFile() || beforeChmod.isSymbolicLink()) {
3140
+ throw new Error(`Refusing changed managed executable target: ${target}`);
3141
+ }
3142
+ chmodSync4(target, 493);
3143
+ }
3144
+ function atomicWriteBuffer(path, content, mode, temporary) {
3145
+ writeFileSync7(temporary, content, { flag: "wx" });
3146
+ chmodSync4(temporary, mode);
3147
+ renameSync2(temporary, path);
3148
+ }
3149
+ function provisionBmadSkills(ctx, preservedManifest, hooks = {}) {
3150
+ const packRoot = bmadPackRoot(ctx);
3151
+ let packSkills;
3152
+ try {
3153
+ packSkills = canonicalBmadSkillEntries(ctx);
3154
+ } catch (error) {
3155
+ return {
3156
+ ok: false,
3157
+ changedFiles: [],
3158
+ error: `BMAD Skillex pack ${BMAD_PACK_VERSION} is not trusted at ${packRoot}: ${error instanceof Error ? error.message : String(error)}`
3159
+ };
3160
+ }
3161
+ hooks.afterPreflight?.();
3162
+ const projectRoot = realpathSync(ctx.repoRoot);
3163
+ const agentsPath = join15(projectRoot, ".agents");
3164
+ const skillsPath = join15(agentsPath, "skills");
3165
+ const agentsExisted = Boolean(lstatIfPresent(agentsPath));
3166
+ const skillsExisted = Boolean(lstatIfPresent(skillsPath));
3167
+ let preflightDirs;
3168
+ try {
3169
+ preflightDirs = prepareSafeProjectSkillsDirs({ ...ctx, dryRun: true });
3170
+ } catch (error) {
3171
+ return { ok: false, changedFiles: [], error: error instanceof Error ? error.message : String(error) };
3172
+ }
3173
+ const manifestPath = join15(preflightDirs.agentsDir, "skills.json");
3174
+ const manifestStat = lstatIfPresent(manifestPath);
3175
+ if (manifestStat?.isSymbolicLink() || manifestStat && !manifestStat.isFile()) {
3176
+ return { ok: false, changedFiles: [], error: `Refusing unsafe skills manifest: ${manifestPath}` };
3177
+ }
3178
+ const manifestBytes = manifestStat ? readFileSync8(manifestPath) : null;
3179
+ const manifestMode = manifestStat ? Number(manifestStat.mode) & 511 : 420;
3180
+ let currentManifest = {};
3181
+ if (manifestBytes !== null) {
3182
+ try {
3183
+ const parsed = JSON.parse(manifestBytes.toString("utf8"));
3184
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("must contain a JSON object");
3185
+ currentManifest = parsed;
3186
+ if (currentManifest.skills !== void 0 && !Array.isArray(currentManifest.skills)) throw new Error("skills must be an array");
3187
+ } catch (error) {
3188
+ return { ok: false, changedFiles: [], error: `Invalid existing skills manifest ${manifestPath}: ${error instanceof Error ? error.message : String(error)}` };
3189
+ }
3190
+ }
3191
+ let safeDirs;
3192
+ try {
3193
+ safeDirs = prepareSafeProjectSkillsDirs(ctx);
3194
+ } catch (error) {
3195
+ return { ok: false, changedFiles: [], error: error instanceof Error ? error.message : String(error) };
3196
+ }
3197
+ const nextManifest = canonicalSkillsManifest(ctx, preservedManifest ?? currentManifest, packSkills);
3198
+ const skillsDir = safeDirs.skillsDir;
3199
+ const resolvedSkillsDir = ctx.dryRun && !existsSync10(skillsDir) ? skillsDir : realpathSync(skillsDir);
3200
+ const expected = new Map(packSkills.map((entry) => [entry.name, fileURLToPath4(entry.source)]));
3201
+ const expectedNames = new Set(expected.keys());
3202
+ const ownershipManifest = preservedManifest ?? currentManifest;
3203
+ const managedManifestNames = new Set(
3204
+ (Array.isArray(ownershipManifest.skills) ? ownershipManifest.skills : []).filter((entry) => isPackManagedManifestEntry(entry, expectedNames, packRoot)).map(skillManifestEntryName).filter((name) => Boolean(name))
3205
+ );
3206
+ const affected = /* @__PURE__ */ new Set();
3207
+ const staleManagedNames = /* @__PURE__ */ new Set();
3208
+ const originalCorrectLinks = /* @__PURE__ */ new Map();
3209
+ if (existsSync10(skillsDir)) {
3210
+ for (const name of readdirSync3(skillsDir)) {
3211
+ validateSkillName(name);
3212
+ if (dirname7(join15(resolvedSkillsDir, name)) !== resolvedSkillsDir) {
3213
+ return { ok: false, changedFiles: [], error: `BMAD skill path escapes project skills directory: ${name}` };
3214
+ }
3215
+ const entryPath = join15(skillsDir, name);
3216
+ let linkTargetsPack = false;
3217
+ try {
3218
+ linkTargetsPack = lstatSync2(entryPath).isSymbolicLink() && isContainedBy(packRoot, resolve3(dirname7(entryPath), readlinkSync(entryPath)));
3219
+ } catch {
3220
+ linkTargetsPack = false;
3221
+ }
3222
+ if (!expected.has(name) && !managedManifestNames.has(name) && !linkTargetsPack) continue;
3223
+ const target = expected.get(name);
3224
+ let correct = false;
3225
+ try {
3226
+ correct = Boolean(target) && lstatSync2(entryPath).isSymbolicLink() && resolve3(dirname7(entryPath), readlinkSync(entryPath)) === target;
3227
+ } catch {
3228
+ correct = false;
3229
+ }
3230
+ if (correct) originalCorrectLinks.set(name, readlinkSync(join15(skillsDir, name)));
3231
+ else {
3232
+ affected.add(name);
3233
+ if (!target) staleManagedNames.add(name);
3234
+ }
3235
+ }
3236
+ }
3237
+ for (const [name, target] of expected) {
3238
+ const link = join15(resolvedSkillsDir, validateSkillName(name));
3239
+ if (dirname7(link) !== resolvedSkillsDir) {
3240
+ return { ok: false, changedFiles: [], error: `BMAD skill path escapes project skills directory: ${name}` };
3241
+ }
3242
+ let correct = false;
3243
+ try {
3244
+ correct = lstatSync2(link).isSymbolicLink() && resolve3(dirname7(link), readlinkSync(link)) === target;
3245
+ } catch {
3246
+ correct = false;
3247
+ }
3248
+ if (!correct) affected.add(name);
3249
+ }
3250
+ const manifestChanged = manifestBytes?.toString("utf8") !== nextManifest;
3251
+ const changedFiles = [
3252
+ ...manifestChanged ? [manifestPath] : [],
3253
+ ...affected.size ? [skillsDir] : []
3254
+ ];
3255
+ if (ctx.dryRun || changedFiles.length === 0) {
3256
+ try {
3257
+ const postflight = validateTrustedBmadPack(packRoot);
3258
+ if (JSON.stringify(postflight.skillNames) !== JSON.stringify(packSkills.map((entry) => entry.name))) {
3259
+ throw new Error("BMAD pack inventory changed after preflight");
3260
+ }
3261
+ return { ok: true, changedFiles };
3262
+ } catch (error) {
3263
+ return { ok: false, changedFiles: [], error: error instanceof Error ? error.message : String(error) };
3264
+ }
3265
+ }
3266
+ const transaction = mkdtempSync(join15(safeDirs.agentsDir, ".bmad-transaction-"));
3267
+ const backup = join15(transaction, "entries");
3268
+ mkdirSync6(backup);
3269
+ const moved = [];
3270
+ const rollback = () => {
3271
+ const errors = [];
3272
+ try {
3273
+ for (const name of affected) {
3274
+ removeProjectEntry(join15(skillsDir, validateSkillName(name)));
3275
+ }
3276
+ for (const name of originalCorrectLinks.keys()) {
3277
+ removeProjectEntry(join15(skillsDir, validateSkillName(name)));
3278
+ }
3279
+ } catch (error) {
3280
+ errors.push(`remove applied projection: ${String(error)}`);
3281
+ }
3282
+ for (const name of [...moved].reverse()) {
3283
+ try {
3284
+ renameSync2(join15(backup, name), join15(skillsDir, name));
3285
+ } catch (error) {
3286
+ errors.push(`restore ${name}: ${String(error)}`);
3287
+ }
3288
+ }
3289
+ for (const [name, rawTarget] of originalCorrectLinks) {
3290
+ try {
3291
+ symlinkSync(rawTarget, join15(skillsDir, name), "dir");
3292
+ } catch (error) {
3293
+ errors.push(`restore ${name}: ${String(error)}`);
3294
+ }
3295
+ }
3296
+ try {
3297
+ if (manifestBytes === null) removeProjectEntry(manifestPath);
3298
+ else atomicWriteBuffer(manifestPath, manifestBytes, manifestMode, join15(transaction, "manifest.restore"));
3299
+ } catch (error) {
3300
+ errors.push(`restore manifest: ${String(error)}`);
3301
+ }
3302
+ rmSync(transaction, { recursive: true, force: true });
3303
+ try {
3304
+ if (!skillsExisted && existsSync10(skillsDir) && readdirSync3(skillsDir).length === 0) rmdirSync(skillsDir);
3305
+ if (!agentsExisted && existsSync10(safeDirs.agentsDir) && readdirSync3(safeDirs.agentsDir).length === 0) rmdirSync(safeDirs.agentsDir);
3306
+ } catch (error) {
3307
+ errors.push(`remove created directories: ${String(error)}`);
3308
+ }
3309
+ if (errors.length) throw new Error(`BMAD rollback was incomplete: ${errors.join("; ")}`);
3310
+ };
3311
+ try {
3312
+ for (const name of affected) {
3313
+ const entry = join15(skillsDir, name);
3314
+ if (lstatIfPresent(entry)) {
3315
+ renameSync2(entry, join15(backup, name));
3316
+ moved.push(name);
3317
+ }
3318
+ }
3319
+ let index = 0;
3320
+ for (const [name, target] of expected) {
3321
+ index += 1;
3322
+ const link = join15(skillsDir, name);
3323
+ let correct = false;
3324
+ try {
3325
+ correct = lstatSync2(link).isSymbolicLink() && resolve3(skillsDir, readlinkSync(link)) === target;
3326
+ } catch {
3327
+ correct = false;
3328
+ }
3329
+ if (correct) continue;
3330
+ if (hooks.createLink) hooks.createLink(target, link, index);
3331
+ else symlinkSync(target, link, "dir");
3332
+ }
3333
+ if (manifestChanged) {
3334
+ atomicWriteBuffer(manifestPath, Buffer.from(nextManifest), manifestMode, join15(transaction, "manifest.next"));
3335
+ }
3336
+ const postflight = validateTrustedBmadPack(packRoot);
3337
+ if (JSON.stringify(postflight.skillNames) !== JSON.stringify(packSkills.map((entry) => entry.name))) {
3338
+ throw new Error("BMAD pack inventory changed after preflight");
3339
+ }
3340
+ hooks.afterApply?.(manifestPath, skillsDir);
3341
+ for (const name of staleManagedNames) {
3342
+ if (lstatIfPresent(join15(skillsDir, name))) {
3343
+ throw new Error(`Applied BMAD projection retained stale managed entry: ${name}`);
3344
+ }
3345
+ }
3346
+ for (const [name, target] of expected) {
3347
+ const link = join15(skillsDir, name);
3348
+ let correct = false;
3349
+ try {
3350
+ correct = lstatSync2(link).isSymbolicLink() && resolve3(skillsDir, readlinkSync(link)) === target;
3351
+ } catch {
3352
+ correct = false;
3353
+ }
3354
+ if (!correct) throw new Error(`Applied BMAD projection link differs from plan: ${name}`);
3355
+ }
3356
+ const finalManifestStat = lstatIfPresent(manifestPath);
3357
+ if (!finalManifestStat || finalManifestStat.isSymbolicLink() || !finalManifestStat.isFile() || (Number(finalManifestStat.mode) & 511) !== manifestMode || readFileSync8(manifestPath).toString("utf8") !== nextManifest) {
3358
+ throw new Error("Applied BMAD skills manifest differs from planned bytes or mode");
3359
+ }
3360
+ const finalManifest = JSON.parse(readFileSync8(manifestPath, "utf8"));
3361
+ 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)) {
3362
+ throw new Error("Applied BMAD skills manifest schema differs from plan");
3363
+ }
3364
+ } catch (error) {
3365
+ try {
3366
+ rollback();
3367
+ } catch (rollbackError) {
3368
+ return { ok: false, changedFiles: [], error: `BMAD provisioning failed (${String(error)}); ${String(rollbackError)}` };
3369
+ }
3370
+ return { ok: false, changedFiles: [], error: error instanceof Error ? error.message : String(error) };
3371
+ }
3372
+ rmSync(transaction, { recursive: true });
3373
+ return { ok: true, changedFiles };
3374
+ }
2781
3375
  function templateVersionFilesConf(ctx, repoRoot) {
2782
- const packageJson = join14(repoRoot, "package.json");
3376
+ const packageJson = join15(repoRoot, "package.json");
2783
3377
  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";
2784
3378
  }
2785
3379
  function replaceOrAppendManagedBlock(text2, startMarker, block, beforePattern) {
@@ -2804,7 +3398,7 @@ var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/herm
2804
3398
  function requiredMisePathEntries(ctx) {
2805
3399
  const required = [...BASE_MISE_PATH_ENTRIES];
2806
3400
  for (const candidate of CONDITIONAL_HERMES_PATHS) {
2807
- if (existsSync10(join14(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
3401
+ if (existsSync10(join15(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
2808
3402
  }
2809
3403
  return required;
2810
3404
  }
@@ -2906,6 +3500,9 @@ function isManagedHookEntry(value) {
2906
3500
  const trimmed = value.trim();
2907
3501
  if (trimmed === OP_INJECT_SCRIPT) return true;
2908
3502
  if (trimmed === SYNC_SKILLS_SCRIPT) return true;
3503
+ if (trimmed === PROVISION_BMAD_SKILLS_SCRIPT) return true;
3504
+ if (/sync-skills(?:\.py)?["']?\s+--scope project/.test(trimmed)) return true;
3505
+ if (/provision-bmad-skills\.py/.test(trimmed)) return true;
2909
3506
  if (/link-project-skills-to-clis\.sh'?\s*$/.test(trimmed)) return true;
2910
3507
  if (/unlink-project-skills-from-clis\.sh'?\s*$/.test(trimmed)) return true;
2911
3508
  return /link-agentfiles\.sh'?\s*$/.test(trimmed);
@@ -3011,6 +3608,7 @@ function upsertLinkAgentfilesBlock(text2, ctx) {
3011
3608
  const withPath = upsertMisePath(text2, requiredMisePathEntries(ctx));
3012
3609
  let cleaned = removeTomlSection(withPath, /^\[tasks\.link-agentfiles\]$/, /link-agentfiles/, { includePrecedingComments: false });
3013
3610
  cleaned = removeTomlSection(cleaned, /^\[tasks\.skills-sync\]$/, void 0, { includePrecedingComments: false });
3611
+ cleaned = removeTomlSection(cleaned, /^\[tasks\.skills-provision-bmad\]$/, void 0, { includePrecedingComments: false });
3014
3612
  cleaned = removeTomlSection(cleaned, /^\[tasks\.link-project-skills-to-clis\]$/, void 0, { includePrecedingComments: false });
3015
3613
  cleaned = removeTomlSection(cleaned, /^\[tasks\.unlink-project-skills-from-clis\]$/, void 0, { includePrecedingComments: false });
3016
3614
  cleaned = removeTomlSection(cleaned, /^\[tasks\.skills-relink\]$/, void 0, { includePrecedingComments: false });
@@ -3020,7 +3618,7 @@ function upsertLinkAgentfilesBlock(text2, ctx) {
3020
3618
  return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
3021
3619
  }
3022
3620
  function readProjectJson(ctx) {
3023
- return tryParseJson(safeReadText(join14(ctx.repoRoot, ".project.json")));
3621
+ return tryParseJson(safeReadText(join15(ctx.repoRoot, ".project.json")));
3024
3622
  }
3025
3623
  function boolSetting(value, fallback) {
3026
3624
  if (typeof value === "boolean") return value;
@@ -3058,7 +3656,7 @@ function canonicalProjectJson(ctx) {
3058
3656
  role.agentId || `${slug}-${role.role}`,
3059
3657
  {
3060
3658
  role: role.role,
3061
- role_dir: relative(ctx.repoRoot, role.roleDir)
3659
+ role_dir: relative2(ctx.repoRoot, role.roleDir)
3062
3660
  }
3063
3661
  ])
3064
3662
  );
@@ -3095,8 +3693,8 @@ function canonicalProjectJson(ctx) {
3095
3693
  };
3096
3694
  }
3097
3695
  function projectJsonFinding(ctx) {
3098
- const projectPath = join14(ctx.repoRoot, ".project.json");
3099
- const planeJsonPath = join14(ctx.repoRoot, ".plane.json");
3696
+ const projectPath = join15(ctx.repoRoot, ".project.json");
3697
+ const planeJsonPath = join15(ctx.repoRoot, ".plane.json");
3100
3698
  const details = [];
3101
3699
  const data = readProjectJson(ctx);
3102
3700
  const roles = discoverRoles(ctx.repoRoot);
@@ -3118,8 +3716,8 @@ function projectJsonFinding(ctx) {
3118
3716
  continue;
3119
3717
  }
3120
3718
  if (agent.role !== role.role) details.push(`agents.${role.agentId}.role should be ${role.role}`);
3121
- if (agent.role_dir !== relative(ctx.repoRoot, role.roleDir)) {
3122
- details.push(`agents.${role.agentId}.role_dir should be ${relative(ctx.repoRoot, role.roleDir)}`);
3719
+ if (agent.role_dir !== relative2(ctx.repoRoot, role.roleDir)) {
3720
+ details.push(`agents.${role.agentId}.role_dir should be ${relative2(ctx.repoRoot, role.roleDir)}`);
3123
3721
  }
3124
3722
  }
3125
3723
  const ticketProvider = data.ticket_provider ?? {};
@@ -3148,8 +3746,7 @@ function projectJsonFinding(ctx) {
3148
3746
  function renderSoul(role) {
3149
3747
  const telegram = role.botHandle ? `@${role.botHandle}` : "(unwired)";
3150
3748
  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.";
3151
- 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.`;
3152
- const runtimeOwner = role.runtimeOwner || "delorenj";
3749
+ 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.`;
3153
3750
  return `# ${role.displayName || role.agentId}
3154
3751
 
3155
3752
  You are **${role.displayName || role.agentId}** \u2014 a Hermes agent provisioned to work inside the
@@ -3168,7 +3765,7 @@ You are **${role.displayName || role.agentId}** \u2014 a Hermes agent provisione
3168
3765
 
3169
3766
  ## Scope
3170
3767
 
3171
- 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.
3768
+ 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.
3172
3769
 
3173
3770
  ## Tone
3174
3771
 
@@ -3180,12 +3777,12 @@ ${roleSpecific}
3180
3777
 
3181
3778
  ## Memory hygiene
3182
3779
 
3183
- Your memory is the submodule at \`./runtime/memories/\`. Use durable memory deliberately and keep \`memories/MEMORY.md\` current.
3780
+ Your memory is stored locally at \`./runtime/memories/\`. Use durable memory deliberately and keep \`memories/MEMORY.md\` current.
3184
3781
  `;
3185
3782
  }
3186
3783
  function renderHermesWrapper(role) {
3187
3784
  return `#!/usr/bin/env bash
3188
- # Launcher for ${role.agentId}. Resolves HERMES_HOME to the runtime submodule.
3785
+ # Launcher for ${role.agentId}. Resolves HERMES_HOME to the local runtime.
3189
3786
 
3190
3787
  set -euo pipefail
3191
3788
 
@@ -3198,30 +3795,44 @@ if [[ -f "$FLEET_ENV" ]]; then
3198
3795
  source "$FLEET_ENV"
3199
3796
  fi
3200
3797
 
3201
- HERMES_BIN="{HERMES_BIN:-{HERMES_FLEET_BIN:-/home/delorenj/code/hermes-agent/.venv/bin/hermes}}"
3202
- HERMES_OAUTH_FILE="{HERMES_OAUTH_FILE:-{HERMES_FLEET_OAUTH_FILE:-$HOME/.hermes/auth.json}}"
3798
+ HERMES_BIN="{HERMES_BIN:-{HERMES_FLEET_BIN:-$HOME/.hermes/hermes-agent/.venv/bin/hermes}}"
3203
3799
  CODEX_HOME="{CODEX_HOME:-{HERMES_FLEET_CODEX_HOME:-$HOME/.codex}}"
3204
3800
 
3205
3801
  FLEET_HOME="{HERMES_FLEET_HOME:-$HOME/.hermes}"
3206
3802
  PROFILE_NAME="{HERMES_PROFILE_NAME:-${role.profileName || role.agentId}}"
3207
- HERMES_HOME="$RUNTIME_HOME"
3803
+
3804
+ # Singleton-runtime contract: HERMES_HOME MUST be the named profile dir, never
3805
+ # the raw runtime path. Hermes treats any HERMES_HOME that is neither under
3806
+ # ~/.hermes nor a child of a "profiles" dir as its own standalone root, which
3807
+ # makes get_active_profile_name() report "default" and _global_auth_file_path()
3808
+ # return None -- silently disabling shared fleet auth and giving every agent a
3809
+ # divergent config.yaml. The profile dir is a REAL dir whose shared entries
3810
+ # (config.yaml, .env, skills) symlink to the fleet root and whose person-owned
3811
+ # entries (memories, sessions, state.db, workspace) symlink into $RUNTIME_HOME.
3812
+ HERMES_HOME="$FLEET_HOME/profiles/$PROFILE_NAME"
3208
3813
 
3209
3814
  if [[ ! -d "$RUNTIME_HOME" ]]; then
3210
- echo "hermes: runtime submodule not initialized at $RUNTIME_HOME" >&2
3211
- echo " fix: git submodule update --init --recursive" >&2
3815
+ echo "hermes: local runtime not provisioned at $RUNTIME_HOME" >&2
3816
+ echo " fix: run the role's .scripts/20-runtime-repo.sh" >&2
3817
+ exit 1
3818
+ fi
3819
+
3820
+ if [[ ! -d "$HERMES_HOME" ]]; then
3821
+ echo "hermes: profile not provisioned at $HERMES_HOME" >&2
3822
+ echo " fix: pj migrate hermes.runtime-singleton" >&2
3212
3823
  exit 1
3213
3824
  fi
3214
3825
 
3215
- exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" HERMES_OAUTH_FILE="$HERMES_OAUTH_FILE" CODEX_HOME="$CODEX_HOME" "$HERMES_BIN" "$@"
3826
+ exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" CODEX_HOME="$CODEX_HOME" "$HERMES_BIN" "$@"
3216
3827
  `.replace(/\u0010/g, "$");
3217
3828
  }
3218
3829
  function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip) {
3219
3830
  if (!existsSync10(sourceDir)) return;
3220
3831
  mkdirSync6(targetDir, { recursive: true });
3221
- for (const entry of readdirSync2(sourceDir, { withFileTypes: true })) {
3222
- const sourcePath = join14(sourceDir, entry.name);
3832
+ for (const entry of readdirSync3(sourceDir, { withFileTypes: true })) {
3833
+ const sourcePath = join15(sourceDir, entry.name);
3223
3834
  if (skip?.(sourcePath)) continue;
3224
- const targetPath = join14(targetDir, entry.name);
3835
+ const targetPath = join15(targetDir, entry.name);
3225
3836
  if (entry.isDirectory()) {
3226
3837
  copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
3227
3838
  continue;
@@ -3234,21 +3845,78 @@ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip)
3234
3845
  }
3235
3846
  }
3236
3847
  }
3237
- function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
3238
- const gitmodulesPath = join14(repoRoot, ".gitmodules");
3239
- const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
3240
- const owner = role.runtimeOwner || "delorenj";
3241
- const block = `[submodule "agents/hermes/${role.role}/runtime"]
3242
- path = agents/hermes/${role.role}/runtime
3243
- url = git@github.com:${owner}/${repoName}.git
3244
- `;
3848
+ function runtimeSubmodulePath(repoRoot, role) {
3849
+ const rolePath = relative2(repoRoot, role.roleDir).replace(/\\/g, "/");
3850
+ if (!/^agents\/hermes\/[^/]+$/.test(rolePath)) return null;
3851
+ return `${rolePath}/runtime`;
3852
+ }
3853
+ function submoduleSectionHasPath(section, targetPath) {
3854
+ return section.split(/\r?\n/).some((line) => /^\s*path\s*=/.test(line) && line.replace(/^\s*path\s*=\s*/, "").trim() === targetPath);
3855
+ }
3856
+ function hasRuntimeSubmoduleMapping(repoRoot, role) {
3857
+ const gitmodulesPath = join15(repoRoot, ".gitmodules");
3858
+ const current = safeReadText(gitmodulesPath) ?? "";
3859
+ const sections = current.match(/^\[submodule "[^"\n]+"\][\s\S]*?(?=^\[submodule "|(?![\s\S]))/gm) ?? [];
3860
+ const targetPath = runtimeSubmodulePath(repoRoot, role);
3861
+ return Boolean(targetPath && sections.some((section) => submoduleSectionHasPath(section, targetPath)));
3862
+ }
3863
+ function removeRuntimeSubmoduleMapping(repoRoot, role, changedFiles, dryRun) {
3864
+ const gitmodulesPath = join15(repoRoot, ".gitmodules");
3245
3865
  const current = safeReadText(gitmodulesPath) ?? "";
3246
- const header = `[submodule "agents/hermes/${role.role}/runtime"]`;
3247
- if (current.includes(header)) return [];
3866
+ if (!hasRuntimeSubmoduleMapping(repoRoot, role)) return [];
3867
+ const targetPath = runtimeSubmodulePath(repoRoot, role);
3868
+ if (!targetPath) return [];
3869
+ const next = current.replace(/^\[submodule "[^"\n]+"\][\s\S]*?(?=^\[submodule "|(?![\s\S]))/gm, (section) => submoduleSectionHasPath(section, targetPath) ? "" : section).replace(/\n{3,}/g, "\n\n").trim();
3248
3870
  changedFiles.push(gitmodulesPath);
3249
- if (!dryRun) writeText(gitmodulesPath, `${current.replace(/\s*$/, "")}${current.trim() ? "\n" : ""}${block}`);
3871
+ if (!dryRun) writeText(gitmodulesPath, next ? `${next}
3872
+ ` : "");
3250
3873
  return [gitmodulesPath];
3251
3874
  }
3875
+ function retireRuntimeSubmodule(repoRoot, role, changedFiles, dryRun) {
3876
+ const runtimePath = runtimeSubmodulePath(repoRoot, role);
3877
+ if (!runtimePath) {
3878
+ return { ok: false, details: [], error: `refusing unsafe runtime path for ${role.roleDir}` };
3879
+ }
3880
+ const probe = spawnSync6("git", ["ls-files", "--stage", "--", runtimePath], {
3881
+ cwd: repoRoot,
3882
+ encoding: "utf8"
3883
+ });
3884
+ if (probe.status !== 0) {
3885
+ return { ok: false, details: [], error: `failed to inspect runtime index at ${runtimePath}: ${probe.stderr.trim() || `exit ${probe.status}`}` };
3886
+ }
3887
+ const details = [];
3888
+ if (probe.stdout.trim()) {
3889
+ details.push(`untrack ${runtimePath}`);
3890
+ if (dryRun) {
3891
+ changedFiles.push(runtimePath);
3892
+ } else {
3893
+ const removal = spawnSync6("git", ["rm", "--cached", "-r", "-f", "--", runtimePath], {
3894
+ cwd: repoRoot,
3895
+ encoding: "utf8"
3896
+ });
3897
+ if (removal.status !== 0) {
3898
+ return { ok: false, details, error: `failed to untrack ${runtimePath}: ${removal.stderr.trim() || `exit ${removal.status}`}` };
3899
+ }
3900
+ const verification = spawnSync6("git", ["ls-files", "--stage", "--", runtimePath], {
3901
+ cwd: repoRoot,
3902
+ encoding: "utf8"
3903
+ });
3904
+ if (verification.status !== 0 || verification.stdout.trim()) {
3905
+ return {
3906
+ ok: false,
3907
+ details,
3908
+ error: verification.status !== 0 ? `failed to verify untracked runtime ${runtimePath}: ${verification.stderr.trim() || `exit ${verification.status}`}` : `runtime remains tracked after index-only removal: ${runtimePath}`
3909
+ };
3910
+ }
3911
+ changedFiles.push(runtimePath);
3912
+ }
3913
+ }
3914
+ if (hasRuntimeSubmoduleMapping(repoRoot, role)) {
3915
+ details.push(`remove stale .gitmodules mapping for ${runtimePath}`);
3916
+ removeRuntimeSubmoduleMapping(repoRoot, role, changedFiles, dryRun);
3917
+ }
3918
+ return { ok: true, details };
3919
+ }
3252
3920
  function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
3253
3921
  const path = registryPath(homeDir);
3254
3922
  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";
@@ -3267,9 +3935,11 @@ function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
3267
3935
  project_id: ${ctxEscape(role.ticketProviderBoardId)}
3268
3936
  identifier: ${ctxEscape(role.ticketProviderIdentifier)}
3269
3937
  runtime_repo: ${ctxEscape(role.runtimeRepo)}
3938
+ bloodbank:
3939
+ gateway_scope: fleet
3940
+ target_agent_id: ${role.agentId}
3270
3941
  systemd:
3271
3942
  gateway_unit: hermes-${role.agentId}-gateway.service
3272
- consumer_unit: hermes-${role.agentId}-consumer.service
3273
3943
  heartbeat_timer: hermes-${role.agentId}-heartbeat.timer
3274
3944
  `;
3275
3945
  const next = current.includes("agents: {}") ? current.replace("agents: {}", `agents:
@@ -3333,6 +4003,7 @@ function checkUnit(unit) {
3333
4003
  var BMAD_NPM_PACKAGE = "bmad-method";
3334
4004
  var BMAD_TARGET_CHANNEL = "next";
3335
4005
  var BMAD_DIST_TAGS_TTL_MS = 60 * 60 * 1e3;
4006
+ var DEFAULT_BMAD_MODULES = ["bmm", "bmb", "cis"];
3336
4007
  var BMAD_INSTALL_TOOLS = [
3337
4008
  "claude-code",
3338
4009
  "codex",
@@ -3380,7 +4051,53 @@ var BMAD_INSTALL_TOOLS = [
3380
4051
  "windsurf",
3381
4052
  "zencoder"
3382
4053
  ];
3383
- function bmadInstallArgs(repoRoot) {
4054
+ function manifestBmadModules(repoRoot) {
4055
+ const manifestPath = join15(repoRoot, "_bmad", "_config", "manifest.yaml");
4056
+ const raw = safeReadText(manifestPath);
4057
+ if (raw === null) return { status: "absent" };
4058
+ try {
4059
+ const parsed = YAML3.parse(raw);
4060
+ if (!Array.isArray(parsed?.modules)) {
4061
+ return { status: "invalid", error: `${manifestPath} must define a modules array` };
4062
+ }
4063
+ const declared = [];
4064
+ for (const entry of parsed.modules) {
4065
+ const name = typeof entry === "string" ? entry : entry && typeof entry === "object" ? entry.name : void 0;
4066
+ if (typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) {
4067
+ return { status: "invalid", error: `${manifestPath} contains an invalid module entry` };
4068
+ }
4069
+ if (name !== "core" && name !== "custom") declared.push(name);
4070
+ }
4071
+ return { status: "valid", modules: Array.from(new Set(declared)) };
4072
+ } catch (error) {
4073
+ return {
4074
+ status: "invalid",
4075
+ error: `Could not parse ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`
4076
+ };
4077
+ }
4078
+ }
4079
+ function configuredBmadModules(repoRoot) {
4080
+ const raw = safeReadText(join15(repoRoot, "_bmad", "config.toml"));
4081
+ if (raw === null) return void 0;
4082
+ const modules = [...raw.matchAll(/^\[modules\.([A-Za-z0-9][A-Za-z0-9_-]*)\]\s*$/gm)].map((match) => match[1]);
4083
+ return Array.from(new Set(modules));
4084
+ }
4085
+ function selectedBmadModules(repoRoot) {
4086
+ const manifest = manifestBmadModules(repoRoot);
4087
+ if (manifest.status === "valid") return manifest.modules;
4088
+ if (manifest.status === "invalid") throw new Error(manifest.error);
4089
+ return configuredBmadModules(repoRoot) ?? [...DEFAULT_BMAD_MODULES];
4090
+ }
4091
+ function requiredBmadSentinels(repoRoot, modules = selectedBmadModules(repoRoot)) {
4092
+ return [
4093
+ join15("core", "config.yaml"),
4094
+ join15("config.toml"),
4095
+ join15("_config", "manifest.yaml"),
4096
+ ...modules.map((module) => join15(module, "config.yaml"))
4097
+ ];
4098
+ }
4099
+ function bmadInstallArgs(repoRoot, modules = selectedBmadModules(repoRoot)) {
4100
+ const installerModules = modules.length ? modules.join(",") : "core";
3384
4101
  return [
3385
4102
  "-y",
3386
4103
  `${BMAD_NPM_PACKAGE}@${BMAD_TARGET_CHANNEL}`,
@@ -3389,20 +4106,20 @@ function bmadInstallArgs(repoRoot) {
3389
4106
  "--directory",
3390
4107
  repoRoot,
3391
4108
  "--modules",
3392
- "bmm,bmb,cis",
4109
+ installerModules,
3393
4110
  "--tools",
3394
4111
  BMAD_INSTALL_TOOLS.join(",")
3395
4112
  ];
3396
4113
  }
3397
- function runBmadInstall(repoRoot) {
3398
- const result = spawnSync6("npx", bmadInstallArgs(repoRoot), { encoding: "utf8" });
4114
+ function runBmadInstall(repoRoot, modules = selectedBmadModules(repoRoot)) {
4115
+ const result = spawnSync6("npx", bmadInstallArgs(repoRoot, modules), { encoding: "utf8" });
3399
4116
  if (result.status !== 0) {
3400
4117
  return { ok: false, error: result.stderr || result.error?.message || "Unknown error" };
3401
4118
  }
3402
4119
  return { ok: true };
3403
4120
  }
3404
4121
  function readInstalledBmadVersion(repoRoot) {
3405
- const raw = safeReadText(join14(repoRoot, "_bmad", "_config", "manifest.yaml"));
4122
+ const raw = safeReadText(join15(repoRoot, "_bmad", "_config", "manifest.yaml"));
3406
4123
  if (!raw) return void 0;
3407
4124
  try {
3408
4125
  const parsed = YAML3.parse(raw);
@@ -3413,8 +4130,8 @@ function readInstalledBmadVersion(repoRoot) {
3413
4130
  }
3414
4131
  }
3415
4132
  function bmadCachePath(homeDir) {
3416
- const cacheRoot = process.env.XDG_CACHE_HOME?.trim() || join14(homeDir, ".cache");
3417
- return join14(cacheRoot, "pjangler", "bmad-dist-tags.json");
4133
+ const cacheRoot = process.env.XDG_CACHE_HOME?.trim() || join15(homeDir, ".cache");
4134
+ return join15(cacheRoot, "pjangler", "bmad-dist-tags.json");
3418
4135
  }
3419
4136
  function readBmadDistTagsCache(homeDir) {
3420
4137
  const raw = safeReadText(bmadCachePath(homeDir));
@@ -3497,18 +4214,232 @@ function compareBmadVersions(a, b) {
3497
4214
  }
3498
4215
  return 0;
3499
4216
  }
4217
+ var SHARED_PROFILE_ENTRIES = ["config.yaml", ".env", "skills"];
4218
+ var OWNED_PROFILE_ENTRIES = [
4219
+ "memories",
4220
+ "sessions",
4221
+ "workspace",
4222
+ "logs",
4223
+ "cron",
4224
+ "plans",
4225
+ "hooks",
4226
+ "pairing",
4227
+ "audio_cache",
4228
+ "image_cache"
4229
+ ];
4230
+ var OWNED_PROFILE_FILES = ["SOUL.md", "state.db", "kanban.db"];
4231
+ function fleetHome(ctx) {
4232
+ return process.env.HERMES_FLEET_HOME || join15(ctx.homeDir, ".hermes");
4233
+ }
4234
+ function fleetBinPath(ctx) {
4235
+ const candidates = [
4236
+ process.env.HERMES_FLEET_BIN,
4237
+ join15(fleetHome(ctx), "hermes-agent", ".venv", "bin", "hermes"),
4238
+ join15(fleetHome(ctx), "hermes-agent", "venv", "bin", "hermes"),
4239
+ join15(ctx.homeDir, ".local", "bin", "hermes")
4240
+ ].filter(Boolean);
4241
+ return candidates.find((candidate) => existsSync10(candidate)) ?? "";
4242
+ }
4243
+ function singletonPlan(ctx, role) {
4244
+ const fleetRoot = fleetHome(ctx);
4245
+ const profileName = role.profileName || role.agentId;
4246
+ const profileDir = join15(fleetRoot, "profiles", profileName);
4247
+ const runtimeDir = join15(role.roleDir, "runtime");
4248
+ const links = [];
4249
+ for (const entry of SHARED_PROFILE_ENTRIES) {
4250
+ links.push({ path: join15(profileDir, entry), target: join15(fleetRoot, entry), ensureTargetDir: entry === "skills" });
4251
+ }
4252
+ for (const entry of OWNED_PROFILE_ENTRIES) {
4253
+ links.push({ path: join15(profileDir, entry), target: join15(runtimeDir, entry), ensureTargetDir: true });
4254
+ }
4255
+ for (const entry of OWNED_PROFILE_FILES) {
4256
+ links.push({ path: join15(profileDir, entry), target: join15(runtimeDir, entry), ensureTargetDir: false });
4257
+ }
4258
+ const sharedSeeds = ["config.yaml", "auth.json", ".env"].map((entry) => ({
4259
+ rootPath: join15(fleetRoot, entry),
4260
+ runtimePath: join15(runtimeDir, entry)
4261
+ }));
4262
+ return { fleetRoot, profileDir, runtimeDir, links, sharedSeeds };
4263
+ }
4264
+ function isDanglingLink(path) {
4265
+ try {
4266
+ return lstatSync2(path).isSymbolicLink() && !existsSync10(path);
4267
+ } catch {
4268
+ return false;
4269
+ }
4270
+ }
4271
+ function linkState(path, target) {
4272
+ let stat;
4273
+ try {
4274
+ stat = lstatSync2(path);
4275
+ } catch {
4276
+ return "missing";
4277
+ }
4278
+ if (!stat.isSymbolicLink()) return "not-a-symlink";
4279
+ try {
4280
+ return readlinkSync(path) === target ? "ok" : "wrong-target";
4281
+ } catch {
4282
+ return "wrong-target";
4283
+ }
4284
+ }
4285
+ function realOrSelf(path) {
4286
+ try {
4287
+ return realpathSync(path);
4288
+ } catch {
4289
+ return path;
4290
+ }
4291
+ }
4292
+ function profileUnits(role) {
4293
+ return [
4294
+ `hermes-${role.agentId}-gateway.service`,
4295
+ `hermes-${role.agentId}-heartbeat.service`,
4296
+ `hermes-${role.agentId}-heartbeat.timer`,
4297
+ `hermes-${role.agentId}-checkpoint.service`
4298
+ ];
4299
+ }
4300
+ function readRegistry(registryPath2) {
4301
+ const raw = safeReadText(registryPath2);
4302
+ if (raw === null) return null;
4303
+ try {
4304
+ const doc = YAML3.parse(raw);
4305
+ return doc?.agents ?? {};
4306
+ } catch {
4307
+ return null;
4308
+ }
4309
+ }
4310
+ function declaredAgentIds(repoRoot) {
4311
+ return declaredAgentEntries(repoRoot).map(([agentId]) => agentId);
4312
+ }
4313
+ function declaredAgentEntries(repoRoot) {
4314
+ const raw = safeReadText(join15(repoRoot, ".project.json"));
4315
+ if (raw === null) return [];
4316
+ try {
4317
+ const doc = JSON.parse(raw);
4318
+ return Object.entries(doc.agents ?? {}).map(([agentId, entry]) => [agentId, entry ?? {}]);
4319
+ } catch {
4320
+ return [];
4321
+ }
4322
+ }
4323
+ function ownedRegistryEntries(registry, repoRoot) {
4324
+ const want = realOrSelf(repoRoot);
4325
+ const owned = [];
4326
+ for (const [agentId, raw] of Object.entries(registry)) {
4327
+ const entry = raw ?? {};
4328
+ const roleDir = String(entry.role_dir ?? "");
4329
+ if (!roleDir) continue;
4330
+ if (realOrSelf(dirname7(dirname7(dirname7(roleDir)))) !== want) continue;
4331
+ owned.push([agentId, entry]);
4332
+ }
4333
+ return owned;
4334
+ }
4335
+ function unprovisionedRoleAgents(registry, repoRoot, canonical) {
4336
+ const blockers = /* @__PURE__ */ new Map();
4337
+ const record = (agentId, roleDir, source) => {
4338
+ const current = blockers.get(agentId) ?? { roleDir, sources: /* @__PURE__ */ new Set() };
4339
+ if (!current.roleDir && roleDir) current.roleDir = roleDir;
4340
+ current.sources.add(source);
4341
+ blockers.set(agentId, current);
4342
+ };
4343
+ for (const [agentId, entry] of ownedRegistryEntries(registry, repoRoot)) {
4344
+ if (canonical.has(agentId)) continue;
4345
+ const roleDir = String(entry.role_dir ?? "");
4346
+ if (!roleDir || !existsSync10(join15(roleDir, "role.yaml"))) record(agentId, roleDir, "registry");
4347
+ }
4348
+ for (const [agentId, entry] of declaredAgentEntries(repoRoot)) {
4349
+ if (canonical.has(agentId)) continue;
4350
+ const configured = String(entry.role_dir ?? "");
4351
+ const roleDir = configured ? resolve3(repoRoot, configured) : "";
4352
+ if (!roleDir || !existsSync10(join15(roleDir, "role.yaml"))) record(agentId, roleDir, ".project.json");
4353
+ }
4354
+ return [...blockers.entries()].map(([agentId, value]) => ({
4355
+ agentId,
4356
+ roleDir: value.roleDir,
4357
+ sources: [...value.sources]
4358
+ }));
4359
+ }
4360
+ function dropDeclaredAgent(ctx, agentId, changedFiles, details) {
4361
+ const path = join15(ctx.repoRoot, ".project.json");
4362
+ const raw = safeReadText(path);
4363
+ if (raw === null) return;
4364
+ let doc;
4365
+ try {
4366
+ doc = JSON.parse(raw);
4367
+ } catch {
4368
+ return;
4369
+ }
4370
+ if (!doc.agents || !(agentId in doc.agents)) return;
4371
+ delete doc.agents[agentId];
4372
+ details.push(`drop agent "${agentId}" from .project.json`);
4373
+ changedFiles.push(path);
4374
+ if (!ctx.dryRun) writeText(path, `${JSON.stringify(doc, null, 2)}
4375
+ `);
4376
+ }
4377
+ function rewriteLauncher(text2) {
4378
+ let next = text2.replace(/^HERMES_HOME="\$RUNTIME_HOME"\s*$/m, 'HERMES_HOME="$FLEET_HOME/profiles/$PROFILE_NAME"');
4379
+ next = next.replace(/^HERMES_OAUTH_FILE=.*\n/m, "");
4380
+ next = next.replace(/\s*HERMES_OAUTH_FILE="\$HERMES_OAUTH_FILE"/g, "");
4381
+ next = next.replace(
4382
+ /^.*\/home\/delorenj\/code\/hermes-agent\/\.venv\/bin\/hermes.*$/m,
4383
+ (line) => line.replace("/home/delorenj/code/hermes-agent/.venv/bin/hermes", "$HOME/.hermes/hermes-agent/.venv/bin/hermes")
4384
+ );
4385
+ return next;
4386
+ }
4387
+ function isValidOpReference(value) {
4388
+ if (!value.startsWith("op://") || /[\[\]{}<>]/.test(value)) return false;
4389
+ const withoutScheme = value.slice("op://".length);
4390
+ const fragmentIndex = withoutScheme.indexOf("#");
4391
+ if (fragmentIndex >= 0) return false;
4392
+ const queryIndex = withoutScheme.indexOf("?");
4393
+ const pathPart = queryIndex >= 0 ? withoutScheme.slice(0, queryIndex) : withoutScheme;
4394
+ const queryPart = queryIndex >= 0 ? withoutScheme.slice(queryIndex + 1) : "";
4395
+ const parts = pathPart.split("/");
4396
+ if (parts.length < 3 || parts.length > 4 || parts.some((part) => !part)) return false;
4397
+ try {
4398
+ for (const part of parts) decodeURIComponent(part);
4399
+ } catch {
4400
+ return false;
4401
+ }
4402
+ if (queryIndex >= 0 && !/^attribute=[A-Za-z0-9._~-]+$/.test(queryPart)) return false;
4403
+ return true;
4404
+ }
4405
+ function malformedOpReferences(text2) {
4406
+ const occurrences = [];
4407
+ const lines = text2.split("\n");
4408
+ for (let index = 0; index < lines.length; index++) {
4409
+ const line = lines[index];
4410
+ for (const match of line.matchAll(/op:\/\/[^\s"'`]+/g)) {
4411
+ const value = match[0];
4412
+ if (!isValidOpReference(value)) {
4413
+ occurrences.push({ line: index + 1, value, commentOnly: line.trimStart().startsWith("#") });
4414
+ }
4415
+ }
4416
+ }
4417
+ return occurrences;
4418
+ }
4419
+ function removeMalformedCommentOpReferences(text2) {
4420
+ let changed = false;
4421
+ const lines = text2.split("\n").map((line) => {
4422
+ if (!line.trimStart().startsWith("#")) return line;
4423
+ return line.replace(/op:\/\/[^\s"'`]+/g, (value) => {
4424
+ if (isValidOpReference(value)) return value;
4425
+ changed = true;
4426
+ return "<invalid 1Password reference removed by pjangler>";
4427
+ });
4428
+ });
4429
+ return { text: lines.join("\n"), changed };
4430
+ }
3500
4431
  var RULES = [
3501
4432
  {
3502
4433
  id: "mise.config-root",
3503
4434
  title: "mise config_root + AGENTS link hooks",
3504
4435
  audit: (ctx) => {
3505
- const misePath = join14(ctx.repoRoot, "mise.toml");
4436
+ const misePath = join15(ctx.repoRoot, "mise.toml");
3506
4437
  if (!existsSync10(misePath)) {
3507
4438
  return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
3508
4439
  }
3509
4440
  const text2 = readText(misePath);
3510
4441
  const details = [];
3511
- const linkAgentfilesPath = join14(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
4442
+ const linkAgentfilesPath = join15(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
3512
4443
  if (!existsSync10(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
3513
4444
  const pathValues = [...(text2.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
3514
4445
  const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
@@ -3527,7 +4458,7 @@ var RULES = [
3527
4458
  };
3528
4459
  },
3529
4460
  migrate: (ctx, finding) => {
3530
- const path = join14(ctx.repoRoot, "mise.toml");
4461
+ const path = join15(ctx.repoRoot, "mise.toml");
3531
4462
  const changedFiles = [];
3532
4463
  const details = [];
3533
4464
  if (!existsSync10(path)) {
@@ -3546,7 +4477,7 @@ var RULES = [
3546
4477
  if (!ctx.dryRun) writeText(path, next);
3547
4478
  text2 = next;
3548
4479
  }
3549
- const linkAgentfilesPath = join14(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
4480
+ const linkAgentfilesPath = join15(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
3550
4481
  const expectedScript = templateLinkAgentfilesScript(ctx);
3551
4482
  if (expectedScript === void 0) {
3552
4483
  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: [] };
@@ -3573,9 +4504,9 @@ var RULES = [
3573
4504
  title: "managed mise versioning block",
3574
4505
  audit: (ctx) => {
3575
4506
  const details = [];
3576
- const misePath = join14(ctx.repoRoot, "mise.toml");
3577
- const versioningPath = join14(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
3578
- const manifestPath = join14(ctx.repoRoot, ".mise", "version-files.conf");
4507
+ const misePath = join15(ctx.repoRoot, "mise.toml");
4508
+ const versioningPath = join15(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
4509
+ const manifestPath = join15(ctx.repoRoot, ".mise", "version-files.conf");
3579
4510
  const text2 = safeReadText(misePath);
3580
4511
  if (!text2?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
3581
4512
  if (!existsSync10(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
@@ -3592,7 +4523,7 @@ var RULES = [
3592
4523
  migrate: (ctx, finding) => {
3593
4524
  const changedFiles = [];
3594
4525
  const details = [];
3595
- const misePath = join14(ctx.repoRoot, "mise.toml");
4526
+ const misePath = join15(ctx.repoRoot, "mise.toml");
3596
4527
  if (!existsSync10(misePath)) {
3597
4528
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
3598
4529
  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: [] };
@@ -3617,7 +4548,7 @@ var RULES = [
3617
4548
  if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
3618
4549
  if (!ctx.dryRun) writeText(misePath, nextMise);
3619
4550
  }
3620
- const versioningPath = join14(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
4551
+ const versioningPath = join15(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
3621
4552
  const expectedScript = templateVersioningScript(ctx);
3622
4553
  if (expectedScript === void 0) {
3623
4554
  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: [] };
@@ -3629,7 +4560,7 @@ var RULES = [
3629
4560
  chmodSync4(versioningPath, 493);
3630
4561
  }
3631
4562
  }
3632
- const manifestPath = join14(ctx.repoRoot, ".mise", "version-files.conf");
4563
+ const manifestPath = join15(ctx.repoRoot, ".mise", "version-files.conf");
3633
4564
  const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
3634
4565
  if (safeReadText(manifestPath) !== expectedManifest) {
3635
4566
  changedFiles.push(manifestPath);
@@ -3650,39 +4581,112 @@ var RULES = [
3650
4581
  title: "Skillex project skills manifest",
3651
4582
  audit: (ctx) => {
3652
4583
  const details = [];
3653
- const manifestPath = join14(ctx.repoRoot, ".agents", "skills.json");
3654
- const legacyDir = join14(ctx.repoRoot, ".agents", "skills");
3655
- const localExamplePath = join14(ctx.repoRoot, ".agents", "local.example.json");
3656
- const misePath = join14(ctx.repoRoot, "mise.toml");
4584
+ const manifestPath = join15(ctx.repoRoot, ".agents", "skills.json");
4585
+ const legacyDir = join15(ctx.repoRoot, ".agents", "skills");
4586
+ const localExamplePath = join15(ctx.repoRoot, ".agents", "local.example.json");
4587
+ const misePath = join15(ctx.repoRoot, "mise.toml");
3657
4588
  let fixable = true;
3658
- const manifest = tryParseJson(safeReadText(manifestPath));
3659
- if (!manifest) {
4589
+ let expectedBmad = [];
4590
+ try {
4591
+ expectedBmad = canonicalBmadSkillEntries(ctx);
4592
+ } catch (error) {
4593
+ details.push(
4594
+ `BMAD Skillex pack ${BMAD_PACK_VERSION} is not trusted at ${bmadPackRoot(ctx)}: ${error instanceof Error ? error.message : String(error)}`
4595
+ );
4596
+ fixable = false;
4597
+ }
4598
+ const expectedByName = new Map(expectedBmad.map((entry) => [entry.name, fileURLToPath4(entry.source)]));
4599
+ const expectedNames = new Set(expectedByName.keys());
4600
+ const packRoot = bmadPackRoot(ctx);
4601
+ const manifest = tryParseJson(safeReadText(manifestPath));
4602
+ if (!manifest) {
3660
4603
  details.push(".agents/skills.json missing or invalid JSON");
3661
4604
  } else {
3662
4605
  if (manifest.inherit_global !== true) details.push(".agents/skills.json should set inherit_global: true");
3663
4606
  if (manifest.registry !== SKILLS_REGISTRY_URL) details.push(`.agents/skills.json should set registry to ${SKILLS_REGISTRY_URL}`);
3664
- if (!Array.isArray(manifest.skills)) details.push(".agents/skills.json should define a skills array");
4607
+ if (!Array.isArray(manifest.skills)) {
4608
+ details.push(".agents/skills.json should define a skills array");
4609
+ } else {
4610
+ const actualBmad = new Map(
4611
+ manifest.skills.filter((entry) => Boolean(entry) && typeof entry === "object" && isPackManagedManifestEntry(entry, expectedNames, packRoot)).map((entry) => [String(entry.name), String(entry.source ?? "")])
4612
+ );
4613
+ const stale = expectedBmad.filter((entry) => actualBmad.get(entry.name) !== entry.source);
4614
+ if (stale.length > 0 || actualBmad.size !== expectedBmad.length) {
4615
+ details.push(`.agents/skills.json should record all ${expectedBmad.length} BMAD ${BMAD_PACK_VERSION} pack entries as file:// sources`);
4616
+ }
4617
+ }
3665
4618
  }
4619
+ const invalidBmadLinkNames = /* @__PURE__ */ new Set();
3666
4620
  if (existsSync10(legacyDir)) {
3667
- const entries = readdirSync2(legacyDir);
3668
- if (entries.length > 0) {
3669
- details.push(".agents/skills/ still contains legacy committed skills; map them into .agents/skills.json before migrating");
3670
- fixable = false;
3671
- } else {
3672
- details.push(".agents/skills/ legacy directory should be removed");
4621
+ for (const name of readdirSync3(legacyDir)) {
4622
+ const expected = expectedByName.get(name);
4623
+ const path = join15(legacyDir, name);
4624
+ let linkTargetsPack = false;
4625
+ try {
4626
+ linkTargetsPack = lstatSync2(path).isSymbolicLink() && isContainedBy(packRoot, resolve3(dirname7(path), readlinkSync(path)));
4627
+ } catch {
4628
+ linkTargetsPack = false;
4629
+ }
4630
+ if (!expected && !linkTargetsPack) continue;
4631
+ try {
4632
+ if (!expected || !lstatSync2(path).isSymbolicLink() || resolve3(dirname7(path), readlinkSync(path)) !== expected) invalidBmadLinkNames.add(name);
4633
+ } catch {
4634
+ invalidBmadLinkNames.add(name);
4635
+ }
3673
4636
  }
4637
+ for (const [name, expected] of expectedByName) {
4638
+ const path = join15(legacyDir, name);
4639
+ try {
4640
+ if (!lstatSync2(path).isSymbolicLink() || resolve3(dirname7(path), readlinkSync(path)) !== expected) invalidBmadLinkNames.add(name);
4641
+ } catch {
4642
+ invalidBmadLinkNames.add(name);
4643
+ }
4644
+ }
4645
+ } else {
4646
+ for (const name of expectedByName.keys()) invalidBmadLinkNames.add(name);
4647
+ }
4648
+ if (invalidBmadLinkNames.size > 0) {
4649
+ details.push(`${invalidBmadLinkNames.size} managed BMAD skill path(s) should be symlinks into the ${BMAD_PACK_VERSION} pack`);
3674
4650
  }
3675
4651
  for (const rel of [".mise/scripts/link-project-skills-to-clis.sh", ".mise/scripts/unlink-project-skills-from-clis.sh"]) {
3676
- if (existsSync10(join14(ctx.repoRoot, rel))) details.push(`${rel} is a legacy symlink-era script and should be removed`);
4652
+ if (existsSync10(join15(ctx.repoRoot, rel))) details.push(`${rel} is a legacy symlink-era script and should be removed`);
3677
4653
  }
3678
4654
  const localExample = tryParseJson(safeReadText(localExamplePath));
3679
4655
  if (localExample && Object.prototype.hasOwnProperty.call(localExample, "skills")) {
3680
4656
  details.push(".agents/local.example.json still documents legacy skills overrides; drop the skills section");
3681
4657
  }
3682
4658
  const mise = safeReadText(misePath);
3683
- if (!mise?.includes(SYNC_SKILLS_SCRIPT)) details.push("mise.toml should run sync-skills.py --scope project on enter");
4659
+ if (!mise?.includes(SYNC_SKILLS_SCRIPT)) details.push("mise.toml should run the shipped project-local sync-skills.py engine via config_root");
4660
+ if (!mise?.includes(PROVISION_BMAD_SKILLS_SCRIPT)) details.push("mise.toml should provision pinned BMAD pack links before syncing skills");
4661
+ if (mise?.includes(SYNC_SKILLS_SCRIPT) && mise.includes(PROVISION_BMAD_SKILLS_SCRIPT) && mise.indexOf(PROVISION_BMAD_SKILLS_SCRIPT) > mise.indexOf(SYNC_SKILLS_SCRIPT)) {
4662
+ details.push("mise.toml should run the BMAD provisioner before project skill sync");
4663
+ }
4664
+ if (mise?.includes('script = "sync-skills.py --scope project"') || mise?.includes('run = "sync-skills.py --scope project"')) {
4665
+ details.push("mise.toml still invokes the missing bare sync-skills.py executable");
4666
+ }
3684
4667
  if (!mise?.includes('patterns = [".agents/skills.json"]')) details.push("mise.toml should watch .agents/skills.json");
3685
4668
  if (!mise?.includes("[tasks.skills-sync]")) details.push("mise.toml should define a skills-sync task");
4669
+ if (!mise?.includes('depends = ["skills-provision-bmad"]')) details.push("skills-sync task should depend on skills-provision-bmad");
4670
+ for (const [rel, label] of [
4671
+ [".mise/scripts/provision-bmad-skills.py", "BMAD Skillex provisioning script"],
4672
+ [".mise/scripts/sync-skills.py", "Project-local skills sync engine"]
4673
+ ]) {
4674
+ const target = join15(ctx.repoRoot, rel);
4675
+ const expected = templateCommonProjectText(ctx, rel);
4676
+ const stat = lstatIfPresent(target);
4677
+ if (!stat || !stat.isFile() || stat.isSymbolicLink()) {
4678
+ details.push(`${label} is missing or unsafe`);
4679
+ if (stat) fixable = false;
4680
+ } else {
4681
+ if (expected === void 0 || safeReadText(target) !== expected) details.push(`${label} differs from the shipped template`);
4682
+ if ((Number(stat.mode) & 73) === 0) details.push(`${label} is not executable`);
4683
+ }
4684
+ }
4685
+ const topologyIssues = projectSkillTopologyIssues(ctx.repoRoot);
4686
+ if (topologyIssues.length) {
4687
+ details.push(...topologyIssues.map((issue) => `CLI skill topology: ${issue}`));
4688
+ fixable = false;
4689
+ }
3686
4690
  if (mise?.includes("link-project-skills-to-clis.sh") || mise?.includes("unlink-project-skills-from-clis.sh") || mise?.includes("[tasks.skills-relink]")) {
3687
4691
  details.push("mise.toml still contains legacy skill-link wiring");
3688
4692
  }
@@ -3698,33 +4702,66 @@ var RULES = [
3698
4702
  migrate: (ctx, finding) => {
3699
4703
  const changedFiles = [];
3700
4704
  const details = [];
3701
- const manifestPath = join14(ctx.repoRoot, ".agents", "skills.json");
3702
- const legacyDir = join14(ctx.repoRoot, ".agents", "skills");
3703
- const localExamplePath = join14(ctx.repoRoot, ".agents", "local.example.json");
3704
- const misePath = join14(ctx.repoRoot, "mise.toml");
3705
- if (existsSync10(legacyDir)) {
3706
- const entries = readdirSync2(legacyDir);
3707
- if (entries.length > 0) {
3708
- return {
3709
- id: finding.id,
3710
- title: finding.title,
3711
- status: "blocked",
3712
- summary: "Legacy .agents/skills/ still contains committed skills; migrate them into .agents/skills.json manually first",
3713
- changedFiles,
3714
- details: entries.map((entry) => `.agents/skills/${entry}`)
3715
- };
3716
- }
3717
- changedFiles.push(legacyDir);
3718
- if (!ctx.dryRun) rmSync(legacyDir, { recursive: true, force: true });
4705
+ const manifestPath = join15(ctx.repoRoot, ".agents", "skills.json");
4706
+ const localExamplePath = join15(ctx.repoRoot, ".agents", "local.example.json");
4707
+ const misePath = join15(ctx.repoRoot, "mise.toml");
4708
+ const provisionScriptPath = join15(ctx.repoRoot, ".mise", "scripts", "provision-bmad-skills.py");
4709
+ const syncScriptPath = join15(ctx.repoRoot, ".mise", "scripts", "sync-skills.py");
4710
+ const expectedProvisionScript = templateCommonProjectText(ctx, ".mise/scripts/provision-bmad-skills.py");
4711
+ const expectedSyncScript = templateCommonProjectText(ctx, ".mise/scripts/sync-skills.py");
4712
+ const topologyIssues = projectSkillTopologyIssues(ctx.repoRoot);
4713
+ if (topologyIssues.length) {
4714
+ return {
4715
+ id: finding.id,
4716
+ title: finding.title,
4717
+ status: "blocked",
4718
+ summary: "Unsafe project CLI skill topology must be repaired manually",
4719
+ changedFiles,
4720
+ details: topologyIssues
4721
+ };
3719
4722
  }
3720
- const expectedManifest = canonicalSkillsManifest();
3721
- if (safeReadText(manifestPath) !== expectedManifest) {
3722
- changedFiles.push(manifestPath);
3723
- if (!ctx.dryRun) writeText(manifestPath, expectedManifest);
3724
- details.push("Wrote canonical .agents/skills.json manifest");
4723
+ if (!expectedProvisionScript || !expectedSyncScript) {
4724
+ return {
4725
+ id: finding.id,
4726
+ title: finding.title,
4727
+ status: "blocked",
4728
+ summary: "pjangler install is missing a shipped skills executable",
4729
+ changedFiles,
4730
+ details: [
4731
+ ...!expectedProvisionScript ? ["Missing BMAD Skillex provisioning script template"] : [],
4732
+ ...!expectedSyncScript ? ["Missing project-local skills sync engine template"] : []
4733
+ ]
4734
+ };
3725
4735
  }
4736
+ const unsafeScriptTargets = [provisionScriptPath, syncScriptPath].filter((path) => {
4737
+ const stat = lstatIfPresent(path);
4738
+ return Boolean(stat && (!stat.isFile() || stat.isSymbolicLink()));
4739
+ });
4740
+ if (unsafeScriptTargets.length) {
4741
+ return {
4742
+ id: finding.id,
4743
+ title: finding.title,
4744
+ status: "blocked",
4745
+ summary: "Refusing non-regular managed skills executable target",
4746
+ changedFiles,
4747
+ details: unsafeScriptTargets.map((path) => `${path} must be removed or repaired manually`)
4748
+ };
4749
+ }
4750
+ const provisioned = provisionBmadSkills(ctx);
4751
+ if (!provisioned.ok) {
4752
+ return {
4753
+ id: finding.id,
4754
+ title: finding.title,
4755
+ status: "blocked",
4756
+ summary: `BMAD Skillex pack ${BMAD_PACK_VERSION} is unavailable`,
4757
+ changedFiles,
4758
+ details: [provisioned.error ?? "Unknown BMAD pack error"]
4759
+ };
4760
+ }
4761
+ changedFiles.push(...provisioned.changedFiles);
4762
+ if (provisioned.changedFiles.includes(manifestPath)) details.push(`Recorded BMAD pack ${BMAD_PACK_VERSION} in .agents/skills.json`);
3726
4763
  for (const rel of [".mise/scripts/link-project-skills-to-clis.sh", ".mise/scripts/unlink-project-skills-from-clis.sh"]) {
3727
- const path = join14(ctx.repoRoot, rel);
4764
+ const path = join15(ctx.repoRoot, rel);
3728
4765
  if (existsSync10(path)) {
3729
4766
  changedFiles.push(path);
3730
4767
  if (!ctx.dryRun) unlinkSync3(path);
@@ -3736,6 +4773,8 @@ var RULES = [
3736
4773
  changedFiles.push(localExamplePath);
3737
4774
  if (!ctx.dryRun) writeText(localExamplePath, templateLocalExample);
3738
4775
  }
4776
+ normalizeExecutableTemplate(ctx, provisionScriptPath, expectedProvisionScript, changedFiles);
4777
+ normalizeExecutableTemplate(ctx, syncScriptPath, expectedSyncScript, changedFiles);
3739
4778
  if (!existsSync10(misePath)) {
3740
4779
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
3741
4780
  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 };
@@ -3761,9 +4800,9 @@ var RULES = [
3761
4800
  id: "sot.agent-symlinks",
3762
4801
  title: "AGENTS/CLAUDE/GEMINI symlink contract",
3763
4802
  audit: (ctx) => {
3764
- const agentsPath = join14(ctx.repoRoot, "AGENTS.md");
4803
+ const agentsPath = join15(ctx.repoRoot, "AGENTS.md");
3765
4804
  if (!existsSync10(agentsPath)) {
3766
- const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync10(join14(ctx.repoRoot, file)));
4805
+ const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync10(join15(ctx.repoRoot, file)));
3767
4806
  if (fallbackSources.length === 0) {
3768
4807
  return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
3769
4808
  }
@@ -3778,7 +4817,7 @@ var RULES = [
3778
4817
  }
3779
4818
  const details = [];
3780
4819
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
3781
- const full = join14(ctx.repoRoot, file);
4820
+ const full = join15(ctx.repoRoot, file);
3782
4821
  const target = readSymlinkTarget(full);
3783
4822
  if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
3784
4823
  }
@@ -3802,7 +4841,7 @@ var RULES = [
3802
4841
  return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
3803
4842
  }
3804
4843
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
3805
- const full = join14(ctx.repoRoot, file);
4844
+ const full = join15(ctx.repoRoot, file);
3806
4845
  const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
3807
4846
  if (result.blocked) blockedDetails.push(result.blocked);
3808
4847
  if (result.changed) changedFiles.push(full);
@@ -3824,7 +4863,7 @@ var RULES = [
3824
4863
  migrate: (ctx, finding) => {
3825
4864
  const changedFiles = [];
3826
4865
  const details = [];
3827
- const path = join14(ctx.repoRoot, ".project.json");
4866
+ const path = join15(ctx.repoRoot, ".project.json");
3828
4867
  const existing = readProjectJson(ctx) ?? {};
3829
4868
  const canonical = canonicalProjectJson(ctx);
3830
4869
  const merged = { ...existing, ...canonical };
@@ -3834,11 +4873,11 @@ var RULES = [
3834
4873
  changedFiles.push(path);
3835
4874
  if (!ctx.dryRun) writeText(path, expected);
3836
4875
  }
3837
- const planeJson = join14(ctx.repoRoot, ".plane.json");
4876
+ const planeJson = join15(ctx.repoRoot, ".plane.json");
3838
4877
  if (existsSync10(planeJson)) {
3839
4878
  const backup = `${planeJson}.migrated-backup`;
3840
4879
  if (existsSync10(backup)) {
3841
- details.push(`cannot back up .plane.json because ${relative(ctx.repoRoot, backup)} already exists`);
4880
+ details.push(`cannot back up .plane.json because ${relative2(ctx.repoRoot, backup)} already exists`);
3842
4881
  } else {
3843
4882
  changedFiles.push(backup);
3844
4883
  if (!ctx.dryRun) renameSync2(planeJson, backup);
@@ -3859,17 +4898,21 @@ var RULES = [
3859
4898
  title: ".env.op + gitignore secrets contract",
3860
4899
  audit: (ctx) => {
3861
4900
  const details = [];
3862
- const envOp = safeReadText(join14(ctx.repoRoot, ".env.op"));
3863
- const gitignore = safeReadText(join14(ctx.repoRoot, ".gitignore"));
4901
+ const envOp = safeReadText(join15(ctx.repoRoot, ".env.op"));
4902
+ const gitignore = safeReadText(join15(ctx.repoRoot, ".gitignore"));
3864
4903
  if (!envOp) {
3865
4904
  details.push(".env.op missing");
3866
4905
  } else {
3867
- const invalidLines = envOp.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).filter((line) => {
4906
+ const malformed = malformedOpReferences(envOp);
4907
+ if (malformed.length) {
4908
+ details.push(`.env.op has malformed op:// reference(s) on line(s): ${Array.from(new Set(malformed.map((entry) => entry.line))).join(", ")}`);
4909
+ }
4910
+ const invalidLines = envOp.split("\n").map((line, index) => ({ line: line.trim(), number: index + 1 })).filter(({ line }) => line && !line.startsWith("#") && line.includes("=")).filter(({ line }) => {
3868
4911
  const value = line.slice(line.indexOf("=") + 1).trim();
3869
4912
  const quotedLiteral = /^"[^"\r\n]*"$/.test(value) || /^'[^'\r\n]*'$/.test(value);
3870
4913
  return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value) && !quotedLiteral;
3871
4914
  });
3872
- if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals: ${invalidLines.join(", ")}`);
4915
+ 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(", ")}`);
3873
4916
  }
3874
4917
  if (!gitignore?.includes(".env\n") && !gitignore?.includes(".env\r\n")) details.push(".gitignore should ignore .env");
3875
4918
  if (!gitignore?.includes(".env.*")) details.push(".gitignore should ignore .env.*");
@@ -3886,12 +4929,23 @@ var RULES = [
3886
4929
  migrate: (ctx, finding) => {
3887
4930
  const changedFiles = [];
3888
4931
  const details = [];
3889
- const envOpPath = join14(ctx.repoRoot, ".env.op");
4932
+ const envOpPath = join15(ctx.repoRoot, ".env.op");
3890
4933
  if (!existsSync10(envOpPath)) {
3891
4934
  changedFiles.push(envOpPath);
3892
- if (!ctx.dryRun) writeText(envOpPath, readText(join14(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
4935
+ if (!ctx.dryRun) writeText(envOpPath, readText(join15(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
4936
+ } else {
4937
+ const current = readText(envOpPath);
4938
+ const repaired = removeMalformedCommentOpReferences(current);
4939
+ if (repaired.changed) {
4940
+ changedFiles.push(envOpPath);
4941
+ if (!ctx.dryRun) writeText(envOpPath, repaired.text);
4942
+ }
4943
+ const remaining = malformedOpReferences(repaired.text).filter((entry) => !entry.commentOnly);
4944
+ if (remaining.length) {
4945
+ 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`);
4946
+ }
3893
4947
  }
3894
- const gitignorePath = join14(ctx.repoRoot, ".gitignore");
4948
+ const gitignorePath = join15(ctx.repoRoot, ".gitignore");
3895
4949
  const gitignore = safeReadText(gitignorePath) ?? "";
3896
4950
  const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
3897
4951
  # NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
@@ -3918,7 +4972,7 @@ var RULES = [
3918
4972
  title: ".copier-answers.yml provenance + drift report",
3919
4973
  audit: (ctx) => {
3920
4974
  const details = [];
3921
- const path = join14(ctx.repoRoot, ".copier-answers.yml");
4975
+ const path = join15(ctx.repoRoot, ".copier-answers.yml");
3922
4976
  const text2 = safeReadText(path);
3923
4977
  const project = readProjectJson(ctx);
3924
4978
  if (!text2) {
@@ -3949,12 +5003,12 @@ var RULES = [
3949
5003
  const changedFiles = [];
3950
5004
  const project = canonicalProjectJson(ctx);
3951
5005
  const text2 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
3952
- _src_path: ${join14(ctx.pjanglerRoot, "templates", "commonproject")}
5006
+ _src_path: ${join15(ctx.pjanglerRoot, "templates", "commonproject")}
3953
5007
  project_description: ${String(project.project_description)}
3954
5008
  project_name: ${String(project.project_name)}
3955
5009
  ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3956
5010
  `;
3957
- const path = join14(ctx.repoRoot, ".copier-answers.yml");
5011
+ const path = join15(ctx.repoRoot, ".copier-answers.yml");
3958
5012
  if (safeReadText(path) !== text2) {
3959
5013
  changedFiles.push(path);
3960
5014
  if (!ctx.dryRun) writeText(path, text2);
@@ -3973,14 +5027,21 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3973
5027
  id: "bmad.scaffold",
3974
5028
  title: "BMAD modules/docs scaffold",
3975
5029
  audit: (ctx) => {
3976
- const targetRoot = join14(ctx.repoRoot, "_bmad");
3977
- const sentinels = [
3978
- join14("core", "config.yaml"),
3979
- join14("config.toml"),
3980
- join14("_config", "manifest.yaml"),
3981
- join14("bmm", "config.yaml")
3982
- ];
3983
- const missing = sentinels.filter((file) => !existsSync10(join14(targetRoot, file)));
5030
+ const manifestSelection = manifestBmadModules(ctx.repoRoot);
5031
+ if (manifestSelection.status === "invalid") {
5032
+ return {
5033
+ id: "bmad.scaffold",
5034
+ title: "BMAD modules/docs scaffold",
5035
+ status: "fail",
5036
+ summary: "BMAD module manifest is invalid; refusing fallback module selection",
5037
+ details: [manifestSelection.error],
5038
+ fixable: false
5039
+ };
5040
+ }
5041
+ const targetRoot = join15(ctx.repoRoot, "_bmad");
5042
+ const selectedModules = manifestSelection.status === "valid" ? manifestSelection.modules : configuredBmadModules(ctx.repoRoot) ?? [...DEFAULT_BMAD_MODULES];
5043
+ const sentinels = requiredBmadSentinels(ctx.repoRoot, selectedModules);
5044
+ const missing = sentinels.filter((file) => !existsSync10(join15(targetRoot, file)));
3984
5045
  return {
3985
5046
  id: "bmad.scaffold",
3986
5047
  title: "BMAD modules/docs scaffold",
@@ -3992,9 +5053,21 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3992
5053
  },
3993
5054
  migrate: (ctx, finding) => {
3994
5055
  const changedFiles = [];
5056
+ const manifestSelection = manifestBmadModules(ctx.repoRoot);
5057
+ if (manifestSelection.status === "invalid") {
5058
+ return {
5059
+ id: finding.id,
5060
+ title: finding.title,
5061
+ status: "blocked",
5062
+ summary: "BMAD module manifest is invalid; refusing fallback module selection",
5063
+ changedFiles,
5064
+ details: [manifestSelection.error]
5065
+ };
5066
+ }
5067
+ const selectedModules = manifestSelection.status === "valid" ? manifestSelection.modules : configuredBmadModules(ctx.repoRoot) ?? [...DEFAULT_BMAD_MODULES];
3995
5068
  if (ctx.dryRun) {
3996
5069
  for (const detail of finding.details) {
3997
- changedFiles.push(join14(ctx.repoRoot, detail));
5070
+ changedFiles.push(join15(ctx.repoRoot, detail));
3998
5071
  }
3999
5072
  return {
4000
5073
  id: finding.id,
@@ -4003,11 +5076,14 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4003
5076
  summary: changedFiles.length ? "Would run non-interactive bmad-method install" : "No changes required",
4004
5077
  changedFiles,
4005
5078
  details: [
4006
- `Would run: npx ${bmadInstallArgs(ctx.repoRoot).join(" ").replace(BMAD_INSTALL_TOOLS.join(","), "...")}`
5079
+ `Would run: npx ${bmadInstallArgs(ctx.repoRoot, selectedModules).join(" ").replace(BMAD_INSTALL_TOOLS.join(","), "...")}`
4007
5080
  ]
4008
5081
  };
4009
5082
  }
4010
- const install = runBmadInstall(ctx.repoRoot);
5083
+ const preservedSkillsManifest = tryParseJson(
5084
+ safeReadText(join15(ctx.repoRoot, ".agents", "skills.json"))
5085
+ );
5086
+ const install = runBmadInstall(ctx.repoRoot, selectedModules);
4011
5087
  if (!install.ok) {
4012
5088
  return {
4013
5089
  id: finding.id,
@@ -4018,16 +5094,28 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4018
5094
  details: [install.error ?? "Unknown error"]
4019
5095
  };
4020
5096
  }
5097
+ const provisioned = provisionBmadSkills(ctx, preservedSkillsManifest);
5098
+ if (!provisioned.ok) {
5099
+ return {
5100
+ id: finding.id,
5101
+ title: finding.title,
5102
+ status: "blocked",
5103
+ summary: `BMAD installed but Skillex pack ${BMAD_PACK_VERSION} provisioning failed`,
5104
+ changedFiles: [],
5105
+ details: [provisioned.error ?? "Unknown BMAD pack error"]
5106
+ };
5107
+ }
4021
5108
  for (const detail of finding.details) {
4022
- if (existsSync10(join14(ctx.repoRoot, detail))) {
4023
- changedFiles.push(join14(ctx.repoRoot, detail));
5109
+ if (existsSync10(join15(ctx.repoRoot, detail))) {
5110
+ changedFiles.push(join15(ctx.repoRoot, detail));
4024
5111
  }
4025
5112
  }
5113
+ changedFiles.push(...provisioned.changedFiles);
4026
5114
  return {
4027
5115
  id: finding.id,
4028
5116
  title: finding.title,
4029
5117
  status: changedFiles.length ? "applied" : "noop",
4030
- summary: changedFiles.length ? "Installed BMAD scaffold via non-interactive installer" : "No changes required",
5118
+ summary: changedFiles.length ? `Installed BMAD scaffold with Skillex pack ${BMAD_PACK_VERSION} skills` : "No changes required",
4031
5119
  changedFiles,
4032
5120
  details: []
4033
5121
  };
@@ -4043,7 +5131,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4043
5131
  id: "bmad.version",
4044
5132
  title: "BMAD version currency",
4045
5133
  status: "skip",
4046
- summary: existsSync10(join14(ctx.repoRoot, "_bmad")) ? "BMAD installed but version manifest unreadable" : "No BMAD install present",
5134
+ summary: existsSync10(join15(ctx.repoRoot, "_bmad")) ? "BMAD installed but version manifest unreadable" : "No BMAD install present",
4047
5135
  details: [],
4048
5136
  fixable: false
4049
5137
  };
@@ -4098,7 +5186,19 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4098
5186
  }
4099
5187
  const installed = readInstalledBmadVersion(ctx.repoRoot);
4100
5188
  const available = resolveBmadDistTags(ctx.homeDir)?.distTags?.[BMAD_TARGET_CHANNEL];
4101
- const manifestPath = join14(ctx.repoRoot, "_bmad", "_config", "manifest.yaml");
5189
+ const manifestPath = join15(ctx.repoRoot, "_bmad", "_config", "manifest.yaml");
5190
+ const manifestSelection = manifestBmadModules(ctx.repoRoot);
5191
+ if (manifestSelection.status === "invalid") {
5192
+ return {
5193
+ id: finding.id,
5194
+ title: finding.title,
5195
+ status: "blocked",
5196
+ summary: "BMAD module manifest is invalid; refusing fallback module selection",
5197
+ changedFiles: [],
5198
+ details: [manifestSelection.error]
5199
+ };
5200
+ }
5201
+ const selectedModules = manifestSelection.status === "valid" ? manifestSelection.modules : configuredBmadModules(ctx.repoRoot) ?? [...DEFAULT_BMAD_MODULES];
4102
5202
  if (ctx.dryRun) {
4103
5203
  return {
4104
5204
  id: finding.id,
@@ -4107,11 +5207,14 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4107
5207
  summary: `Would upgrade BMAD ${installed ?? "?"} -> ${available ?? BMAD_TARGET_CHANNEL}`,
4108
5208
  changedFiles: [manifestPath],
4109
5209
  details: [
4110
- `Would run: npx ${bmadInstallArgs(ctx.repoRoot).join(" ").replace(BMAD_INSTALL_TOOLS.join(","), "...")}`
5210
+ `Would run: npx ${bmadInstallArgs(ctx.repoRoot, selectedModules).join(" ").replace(BMAD_INSTALL_TOOLS.join(","), "...")}`
4111
5211
  ]
4112
5212
  };
4113
5213
  }
4114
- const install = runBmadInstall(ctx.repoRoot);
5214
+ const preservedSkillsManifest = tryParseJson(
5215
+ safeReadText(join15(ctx.repoRoot, ".agents", "skills.json"))
5216
+ );
5217
+ const install = runBmadInstall(ctx.repoRoot, selectedModules);
4115
5218
  if (!install.ok) {
4116
5219
  return {
4117
5220
  id: finding.id,
@@ -4122,6 +5225,17 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4122
5225
  details: [install.error ?? "Unknown error"]
4123
5226
  };
4124
5227
  }
5228
+ const provisioned = provisionBmadSkills(ctx, preservedSkillsManifest);
5229
+ if (!provisioned.ok) {
5230
+ return {
5231
+ id: finding.id,
5232
+ title: finding.title,
5233
+ status: "blocked",
5234
+ summary: `BMAD upgraded but Skillex pack ${BMAD_PACK_VERSION} provisioning failed`,
5235
+ changedFiles: [],
5236
+ details: [provisioned.error ?? "Unknown BMAD pack error"]
5237
+ };
5238
+ }
4125
5239
  const nowInstalled = readInstalledBmadVersion(ctx.repoRoot);
4126
5240
  const upgraded = Boolean(nowInstalled && installed && compareBmadVersions(nowInstalled, installed) > 0);
4127
5241
  return {
@@ -4129,7 +5243,10 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4129
5243
  title: finding.title,
4130
5244
  status: upgraded ? "applied" : "noop",
4131
5245
  summary: upgraded ? `Upgraded BMAD ${installed} -> ${nowInstalled}` : `BMAD reinstalled (${nowInstalled ?? "?"})`,
4132
- changedFiles: upgraded ? [manifestPath] : [],
5246
+ changedFiles: Array.from(/* @__PURE__ */ new Set([
5247
+ ...upgraded ? [manifestPath] : [],
5248
+ ...provisioned.changedFiles
5249
+ ])),
4133
5250
  details: []
4134
5251
  };
4135
5252
  }
@@ -4144,12 +5261,13 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4144
5261
  return { id: "hermes.pm-scaffold", title: "Hermes PM scaffold parity", status: "skip", summary: "No pm role present", details: [], fixable: false };
4145
5262
  }
4146
5263
  const details = [];
4147
- 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"]) {
4148
- if (!existsSync10(join14(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join14(role.roleDir, rel))}`);
5264
+ 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"]) {
5265
+ if (!existsSync10(join15(role.roleDir, rel))) details.push(`missing ${relative2(ctx.repoRoot, join15(role.roleDir, rel))}`);
5266
+ }
5267
+ if (hasRuntimeSubmoduleMapping(ctx.repoRoot, role)) {
5268
+ details.push(".gitmodules contains retired pm runtime submodule mapping");
4149
5269
  }
4150
- const gitmodules = safeReadText(join14(ctx.repoRoot, ".gitmodules")) ?? "";
4151
- if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing pm runtime submodule entry");
4152
- if (!profileMetaInheritsDefault(join14(role.roleDir, "runtime", "profile.yaml"))) {
5270
+ if (!profileMetaInheritsDefault(join15(role.roleDir, "runtime", "profile.yaml"))) {
4153
5271
  details.push("runtime/profile.yaml missing inherited default config metadata");
4154
5272
  }
4155
5273
  const registry = safeReadText(registryPath(ctx.homeDir));
@@ -4170,21 +5288,32 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4170
5288
  if (!role) {
4171
5289
  return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
4172
5290
  }
4173
- const templateRoleDir = join14(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
4174
- writeIfDifferent(join14(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
4175
- writeIfDifferent(join14(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
4176
- writeIfDifferent(join14(role.roleDir, ".gitignore"), readText(join14(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
4177
- copyMissingRecursive(join14(templateRoleDir, ".runtime-scaffold"), join14(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
4178
- copyMissingRecursive(join14(templateRoleDir, ".runtime-scaffold"), join14(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
4179
- copyMissingRecursive(join14(templateRoleDir, ".scripts"), join14(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
4180
- const promptSource = join14(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
4181
- const promptTarget = join14(role.roleDir, ".scripts", "sentinel.prompt.md");
5291
+ const retirement = retireRuntimeSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
5292
+ details.push(...retirement.details);
5293
+ if (!retirement.ok) {
5294
+ return {
5295
+ id: finding.id,
5296
+ title: finding.title,
5297
+ status: "blocked",
5298
+ summary: "Failed to retire PM runtime submodule metadata safely",
5299
+ changedFiles,
5300
+ details: [retirement.error ?? "unknown runtime retirement failure"]
5301
+ };
5302
+ }
5303
+ const templateRoleDir = join15(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
5304
+ writeIfDifferent(join15(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
5305
+ writeIfDifferent(join15(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
5306
+ writeIfDifferent(join15(role.roleDir, ".gitignore"), readText(join15(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
5307
+ copyMissingRecursive(join15(templateRoleDir, ".runtime-scaffold"), join15(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
5308
+ copyMissingRecursive(join15(templateRoleDir, ".runtime-scaffold"), join15(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
5309
+ copyMissingRecursive(join15(templateRoleDir, ".scripts"), join15(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
5310
+ const promptSource = join15(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
5311
+ const promptTarget = join15(role.roleDir, ".scripts", "sentinel.prompt.md");
4182
5312
  if (existsSync10(promptSource) && !existsSync10(promptTarget)) {
4183
5313
  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);
4184
5314
  writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
4185
5315
  }
4186
- upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
4187
- const profileMetaUpdated = upsertInheritedProfileMeta(join14(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
5316
+ const profileMetaUpdated = upsertInheritedProfileMeta(join15(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
4188
5317
  if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
4189
5318
  const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
4190
5319
  if (registryUpdated) details.push(`updated ${registryUpdated}`);
@@ -4215,8 +5344,8 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4215
5344
  }
4216
5345
  const details = [];
4217
5346
  for (const role of roles) {
4218
- const roleRelDir = relative(ctx.repoRoot, role.roleDir);
4219
- const runtimeRelPath = join14(roleRelDir, "runtime");
5347
+ const roleRelDir = relative2(ctx.repoRoot, role.roleDir);
5348
+ const runtimeRelPath = join15(roleRelDir, "runtime");
4220
5349
  const lsResult = spawnSync6("git", ["ls-files", "--stage", runtimeRelPath], {
4221
5350
  cwd: ctx.repoRoot,
4222
5351
  encoding: "utf8"
@@ -4224,15 +5353,18 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4224
5353
  if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
4225
5354
  details.push(`submodule runtime is tracked in Git index at ${runtimeRelPath}`);
4226
5355
  }
4227
- const gitignorePath = join14(role.roleDir, ".gitignore");
5356
+ if (hasRuntimeSubmoduleMapping(ctx.repoRoot, role)) {
5357
+ details.push(`stale .gitmodules mapping exists for ${runtimeRelPath}`);
5358
+ }
5359
+ const gitignorePath = join15(role.roleDir, ".gitignore");
4228
5360
  if (existsSync10(gitignorePath)) {
4229
5361
  const content = safeReadText(gitignorePath) ?? "";
4230
5362
  const lines = content.split(/\r?\n/).map((line) => line.trim());
4231
5363
  if (!lines.includes("runtime/") && !lines.includes("runtime")) {
4232
- details.push(`.gitignore missing runtime/ ignore entry in ${relative(ctx.repoRoot, gitignorePath)}`);
5364
+ details.push(`.gitignore missing runtime/ ignore entry in ${relative2(ctx.repoRoot, gitignorePath)}`);
4233
5365
  }
4234
5366
  } else {
4235
- details.push(`.gitignore is missing in ${relative(ctx.repoRoot, gitignorePath)}`);
5367
+ details.push(`.gitignore is missing in ${relative2(ctx.repoRoot, gitignorePath)}`);
4236
5368
  }
4237
5369
  }
4238
5370
  return {
@@ -4249,23 +5381,19 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4249
5381
  const changedFiles = [];
4250
5382
  const details = [];
4251
5383
  for (const role of roles) {
4252
- const roleRelDir = relative(ctx.repoRoot, role.roleDir);
4253
- const runtimeRelPath = join14(roleRelDir, "runtime");
4254
- const lsResult = spawnSync6("git", ["ls-files", "--stage", runtimeRelPath], {
4255
- cwd: ctx.repoRoot,
4256
- encoding: "utf8"
4257
- });
4258
- if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
4259
- details.push(`untrack ${runtimeRelPath}`);
4260
- changedFiles.push(runtimeRelPath);
4261
- if (!ctx.dryRun) {
4262
- spawnSync6("git", ["rm", "--cached", "-r", runtimeRelPath], {
4263
- cwd: ctx.repoRoot,
4264
- encoding: "utf8"
4265
- });
4266
- }
5384
+ const retirement = retireRuntimeSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
5385
+ details.push(...retirement.details);
5386
+ if (!retirement.ok) {
5387
+ return {
5388
+ id: finding.id,
5389
+ title: finding.title,
5390
+ status: "blocked",
5391
+ summary: "Failed to retire Hermes runtime submodule metadata safely",
5392
+ changedFiles,
5393
+ details: [retirement.error ?? "unknown runtime retirement failure"]
5394
+ };
4267
5395
  }
4268
- const gitignorePath = join14(role.roleDir, ".gitignore");
5396
+ const gitignorePath = join15(role.roleDir, ".gitignore");
4269
5397
  let content = "";
4270
5398
  let isIgnored = false;
4271
5399
  if (existsSync10(gitignorePath)) {
@@ -4274,7 +5402,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4274
5402
  isIgnored = lines.includes("runtime/") || lines.includes("runtime");
4275
5403
  }
4276
5404
  if (!isIgnored) {
4277
- details.push(`ignore runtime/ in ${relative(ctx.repoRoot, gitignorePath)}`);
5405
+ details.push(`ignore runtime/ in ${relative2(ctx.repoRoot, gitignorePath)}`);
4278
5406
  changedFiles.push(gitignorePath);
4279
5407
  if (!ctx.dryRun) {
4280
5408
  if (content && !content.endsWith("\n")) {
@@ -4309,7 +5437,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4309
5437
  }
4310
5438
  const details = [];
4311
5439
  for (const role of roles) {
4312
- for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
5440
+ for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
4313
5441
  const state = checkUnit(unit);
4314
5442
  if (!state.enabled || !state.active) details.push(`${unit} should be enabled+active`);
4315
5443
  }
@@ -4335,9 +5463,9 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4335
5463
  return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
4336
5464
  }
4337
5465
  for (const role of roles) {
4338
- const sysDir = join14(ctx.homeDir, ".config", "systemd", "user");
4339
- const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
4340
- const allUnitsPresent = units.every((unit) => existsSync10(join14(sysDir, unit)));
5466
+ const sysDir = join15(ctx.homeDir, ".config", "systemd", "user");
5467
+ const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-heartbeat.timer`];
5468
+ const allUnitsPresent = units.every((unit) => existsSync10(join15(sysDir, unit)));
4341
5469
  if (allUnitsPresent) {
4342
5470
  if (ctx.dryRun) {
4343
5471
  details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
@@ -4349,7 +5477,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4349
5477
  }
4350
5478
  continue;
4351
5479
  }
4352
- for (const script of [join14(role.roleDir, ".scripts", "70-systemd.sh")]) {
5480
+ for (const script of [join15(role.roleDir, ".scripts", "70-systemd.sh")]) {
4353
5481
  if (!script || !existsSync10(script)) continue;
4354
5482
  if (ctx.dryRun) {
4355
5483
  details.push(`would run: bash ${script}`);
@@ -4368,6 +5496,349 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4368
5496
  details
4369
5497
  };
4370
5498
  }
5499
+ },
5500
+ {
5501
+ id: "hermes.runtime-singleton",
5502
+ title: "Hermes singleton runtime (shared config/auth, per-agent memory)",
5503
+ audit: (ctx) => {
5504
+ const roles = discoverRoles(ctx.repoRoot);
5505
+ if (!roles.length) {
5506
+ 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 };
5507
+ }
5508
+ const details = [];
5509
+ for (const role of roles) {
5510
+ const plan = singletonPlan(ctx, role);
5511
+ if (!existsSync10(plan.fleetRoot)) {
5512
+ details.push(`fleet root missing at ${plan.fleetRoot}`);
5513
+ continue;
5514
+ }
5515
+ if (!existsSync10(plan.profileDir)) {
5516
+ details.push(`profile dir missing: ${plan.profileDir}`);
5517
+ } else if (lstatSync2(plan.profileDir).isSymbolicLink()) {
5518
+ details.push(`profile dir is a symlink (must be a real dir): ${plan.profileDir}`);
5519
+ }
5520
+ for (const link of plan.links) {
5521
+ const state = linkState(link.path, link.target);
5522
+ if (state !== "ok") details.push(`${state}: ${link.path} -> ${link.target}`);
5523
+ }
5524
+ }
5525
+ return {
5526
+ id: "hermes.runtime-singleton",
5527
+ title: "Hermes singleton runtime (shared config/auth, per-agent memory)",
5528
+ status: details.length === 0 ? "pass" : "fail",
5529
+ summary: details.length === 0 ? "Singleton runtime contract satisfied" : `${details.length} singleton-runtime issue(s) detected`,
5530
+ details,
5531
+ fixable: true
5532
+ };
5533
+ },
5534
+ migrate: (ctx, finding) => {
5535
+ const roles = discoverRoles(ctx.repoRoot);
5536
+ const changedFiles = [];
5537
+ const details = [];
5538
+ for (const role of roles) {
5539
+ const plan = singletonPlan(ctx, role);
5540
+ if (!existsSync10(plan.fleetRoot)) {
5541
+ details.push(`blocked: fleet root missing at ${plan.fleetRoot}`);
5542
+ continue;
5543
+ }
5544
+ for (const shared of plan.sharedSeeds) {
5545
+ if (existsSync10(shared.rootPath)) continue;
5546
+ const donor = existsSync10(shared.runtimePath) ? shared.runtimePath : null;
5547
+ if (!donor) continue;
5548
+ details.push(`seed fleet ${basename4(shared.rootPath)} from ${donor}`);
5549
+ changedFiles.push(shared.rootPath);
5550
+ if (!ctx.dryRun) copyFileSync(donor, shared.rootPath);
5551
+ }
5552
+ if (existsSync10(plan.profileDir) && lstatSync2(plan.profileDir).isSymbolicLink()) {
5553
+ details.push(`convert profile symlink to real dir: ${plan.profileDir}`);
5554
+ changedFiles.push(plan.profileDir);
5555
+ if (!ctx.dryRun) unlinkSync3(plan.profileDir);
5556
+ }
5557
+ if (!existsSync10(plan.profileDir)) {
5558
+ details.push(`create profile dir: ${plan.profileDir}`);
5559
+ changedFiles.push(plan.profileDir);
5560
+ if (!ctx.dryRun) mkdirSync6(plan.profileDir, { recursive: true });
5561
+ }
5562
+ for (const link of plan.links) {
5563
+ const state = linkState(link.path, link.target);
5564
+ if (state === "ok") continue;
5565
+ if (link.ensureTargetDir && !existsSync10(link.target) && !ctx.dryRun) {
5566
+ mkdirSync6(link.target, { recursive: true });
5567
+ }
5568
+ details.push(`link ${link.path} -> ${link.target}`);
5569
+ changedFiles.push(link.path);
5570
+ if (ctx.dryRun) continue;
5571
+ if (existsSync10(link.path) || isDanglingLink(link.path)) {
5572
+ const lst = lstatSync2(link.path);
5573
+ if (lst.isSymbolicLink()) {
5574
+ unlinkSync3(link.path);
5575
+ } else {
5576
+ const parked = `${link.path}.pre-singleton`;
5577
+ renameSync2(link.path, parked);
5578
+ details.push(`parked pre-existing ${link.path} at ${parked}`);
5579
+ }
5580
+ }
5581
+ ensureParent(link.path);
5582
+ symlinkSync(link.target, link.path);
5583
+ }
5584
+ }
5585
+ return {
5586
+ id: finding.id,
5587
+ title: finding.title,
5588
+ status: details.some((d) => d.startsWith("blocked:")) ? "blocked" : changedFiles.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
5589
+ summary: changedFiles.length ? ctx.dryRun ? "Planned singleton-runtime wiring" : "Singleton runtime wired" : "No changes required",
5590
+ changedFiles,
5591
+ details
5592
+ };
5593
+ }
5594
+ },
5595
+ {
5596
+ id: "hermes.profile-wiring",
5597
+ title: "Launcher + systemd HERMES_HOME points at the named profile",
5598
+ audit: (ctx) => {
5599
+ const roles = discoverRoles(ctx.repoRoot);
5600
+ if (!roles.length) {
5601
+ 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 };
5602
+ }
5603
+ const details = [];
5604
+ for (const role of roles) {
5605
+ const plan = singletonPlan(ctx, role);
5606
+ const launcher = join15(role.roleDir, "hermes");
5607
+ const text2 = safeReadText(launcher);
5608
+ if (text2 === null) {
5609
+ details.push(`launcher missing: ${relative2(ctx.repoRoot, launcher)}`);
5610
+ } else {
5611
+ if (/^HERMES_HOME="\$RUNTIME_HOME"\s*$/m.test(text2)) {
5612
+ details.push(`launcher sets HERMES_HOME to the raw runtime path (disables shared auth + profile identity): ${relative2(ctx.repoRoot, launcher)}`);
5613
+ }
5614
+ if (/HERMES_OAUTH_FILE/.test(text2)) {
5615
+ details.push(`launcher exports HERMES_OAUTH_FILE, which Hermes does not implement (dead config): ${relative2(ctx.repoRoot, launcher)}`);
5616
+ }
5617
+ }
5618
+ for (const unit of profileUnits(role)) {
5619
+ const unitPath = join15(ctx.homeDir, ".config", "systemd", "user", unit);
5620
+ const unitText = safeReadText(unitPath);
5621
+ if (unitText === null) continue;
5622
+ const current = /^Environment=HERMES_HOME=(.*)$/m.exec(unitText)?.[1]?.trim();
5623
+ if (current && current !== plan.profileDir) {
5624
+ details.push(`${unit} HERMES_HOME=${current} (expected ${plan.profileDir})`);
5625
+ }
5626
+ if (/^Environment=HERMES_OAUTH_FILE=/m.test(unitText)) {
5627
+ details.push(`${unit} sets HERMES_OAUTH_FILE (dead config)`);
5628
+ }
5629
+ }
5630
+ }
5631
+ return {
5632
+ id: "hermes.profile-wiring",
5633
+ title: "Launcher + systemd HERMES_HOME points at the named profile",
5634
+ status: details.length === 0 ? "pass" : "fail",
5635
+ summary: details.length === 0 ? "HERMES_HOME wiring is in parity" : `${details.length} HERMES_HOME wiring issue(s) detected`,
5636
+ details,
5637
+ fixable: true
5638
+ };
5639
+ },
5640
+ migrate: (ctx, finding) => {
5641
+ const roles = discoverRoles(ctx.repoRoot);
5642
+ const changedFiles = [];
5643
+ const details = [];
5644
+ let unitsTouched = false;
5645
+ for (const role of roles) {
5646
+ const plan = singletonPlan(ctx, role);
5647
+ const launcher = join15(role.roleDir, "hermes");
5648
+ const text2 = safeReadText(launcher);
5649
+ if (text2 !== null) {
5650
+ const rewritten = rewriteLauncher(text2);
5651
+ if (rewritten !== text2) {
5652
+ details.push(`rewrite launcher HERMES_HOME -> profile path: ${relative2(ctx.repoRoot, launcher)}`);
5653
+ writeIfDifferent(launcher, rewritten, ctx.dryRun, changedFiles, 493);
5654
+ }
5655
+ }
5656
+ for (const unit of profileUnits(role)) {
5657
+ const unitPath = join15(ctx.homeDir, ".config", "systemd", "user", unit);
5658
+ const unitText = safeReadText(unitPath);
5659
+ if (unitText === null) continue;
5660
+ let next = unitText.replace(/^Environment=HERMES_HOME=.*$/m, `Environment=HERMES_HOME=${plan.profileDir}`);
5661
+ next = next.replace(/^Environment=HERMES_OAUTH_FILE=.*\n/m, "");
5662
+ if (next !== unitText) {
5663
+ details.push(`repoint ${unit} HERMES_HOME -> ${plan.profileDir}`);
5664
+ writeIfDifferent(unitPath, next, ctx.dryRun, changedFiles);
5665
+ unitsTouched = true;
5666
+ }
5667
+ }
5668
+ }
5669
+ if (unitsTouched && !ctx.dryRun) {
5670
+ systemctlUser(["daemon-reload"]);
5671
+ details.push("systemctl --user daemon-reload (restart units to pick up the new HERMES_HOME)");
5672
+ }
5673
+ return {
5674
+ id: finding.id,
5675
+ title: finding.title,
5676
+ status: changedFiles.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
5677
+ summary: changedFiles.length ? ctx.dryRun ? "Planned HERMES_HOME rewiring" : "HERMES_HOME rewired to named profiles" : "No changes required",
5678
+ changedFiles,
5679
+ details
5680
+ };
5681
+ }
5682
+ },
5683
+ {
5684
+ id: "hermes.registry-parity",
5685
+ title: "Fleet registry matches .project.json (no duplicate or stale agents)",
5686
+ audit: (ctx) => {
5687
+ const roles = discoverRoles(ctx.repoRoot);
5688
+ const details = [];
5689
+ const registryPath2 = join15(ctx.homeDir, ".hermes", "agents-registry.yaml");
5690
+ const registry = readRegistry(registryPath2);
5691
+ if (!registry) {
5692
+ if (!roles.length && declaredAgentIds(ctx.repoRoot).length === 0) {
5693
+ 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 };
5694
+ }
5695
+ 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 };
5696
+ }
5697
+ const canonical = new Set(roles.map((role) => role.agentId).filter(Boolean));
5698
+ const owned = ownedRegistryEntries(registry, ctx.repoRoot);
5699
+ const unprovisioned = unprovisionedRoleAgents(registry, ctx.repoRoot, canonical);
5700
+ if (unprovisioned.length) {
5701
+ return {
5702
+ id: "hermes.registry-parity",
5703
+ title: "Fleet registry matches .project.json (no duplicate or stale agents)",
5704
+ status: "fail",
5705
+ summary: `${unprovisioned.length} unprovisioned Hermes role blocker(s) detected`,
5706
+ details: unprovisioned.map(
5707
+ ({ 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`
5708
+ ),
5709
+ fixable: false
5710
+ };
5711
+ }
5712
+ if (canonical.size === 0) {
5713
+ 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 };
5714
+ }
5715
+ for (const [agentId, entry] of owned) {
5716
+ const roleDir = String(entry?.role_dir ?? "");
5717
+ if (!canonical.has(agentId)) {
5718
+ details.push(`stale/duplicate registry agent "${agentId}" for ${roleDir} (role.yaml declares ${[...canonical].join(", ")})`);
5719
+ }
5720
+ }
5721
+ for (const extra of declaredAgentIds(ctx.repoRoot).filter((id) => !canonical.has(id))) {
5722
+ details.push(`.project.json declares agent "${extra}" that no role.yaml claims`);
5723
+ }
5724
+ for (const role of roles) {
5725
+ const entry = registry[role.agentId];
5726
+ if (!entry) {
5727
+ details.push(`registry is missing an entry for ${role.agentId}`);
5728
+ continue;
5729
+ }
5730
+ const entryRoleDir = String(entry.role_dir ?? "");
5731
+ if (entryRoleDir && realOrSelf(entryRoleDir) !== realOrSelf(role.roleDir)) {
5732
+ details.push(`registry role_dir for ${role.agentId} is ${entryRoleDir} (expected ${role.roleDir})`);
5733
+ }
5734
+ const bin = String(entry.hermes?.bin ?? "");
5735
+ if (bin && !existsSync10(bin)) {
5736
+ details.push(`registry hermes.bin for ${role.agentId} does not exist: ${bin}`);
5737
+ }
5738
+ }
5739
+ return {
5740
+ id: "hermes.registry-parity",
5741
+ title: "Fleet registry matches .project.json (no duplicate or stale agents)",
5742
+ status: details.length === 0 ? "pass" : "fail",
5743
+ summary: details.length === 0 ? "Fleet registry is in parity" : `${details.length} registry parity issue(s) detected`,
5744
+ details,
5745
+ fixable: true
5746
+ };
5747
+ },
5748
+ migrate: (ctx, finding) => {
5749
+ const changedFiles = [];
5750
+ const details = [];
5751
+ const registryPath2 = join15(ctx.homeDir, ".hermes", "agents-registry.yaml");
5752
+ const raw = safeReadText(registryPath2);
5753
+ if (raw === null) {
5754
+ return { id: finding.id, title: finding.title, status: "blocked", summary: `registry unreadable at ${registryPath2}`, changedFiles, details };
5755
+ }
5756
+ let doc;
5757
+ try {
5758
+ doc = YAML3.parse(raw);
5759
+ } catch {
5760
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "registry is not valid YAML", changedFiles, details };
5761
+ }
5762
+ const agents = doc?.agents ?? {};
5763
+ const roles = discoverRoles(ctx.repoRoot);
5764
+ const canonical = new Set(roles.map((role) => role.agentId).filter(Boolean));
5765
+ const unprovisioned = unprovisionedRoleAgents(agents, ctx.repoRoot, canonical);
5766
+ if (unprovisioned.length) {
5767
+ return {
5768
+ id: finding.id,
5769
+ title: finding.title,
5770
+ status: "blocked",
5771
+ summary: "Registry parity is blocked by an unprovisioned Hermes role",
5772
+ changedFiles,
5773
+ details: unprovisioned.map(
5774
+ ({ agentId, roleDir, sources }) => `blocked: "${agentId}" (${sources.join(" + ")}) has no role.yaml${roleDir ? ` at ${roleDir}` : ""}; provision or restore the role without pruning registry/declaration state`
5775
+ )
5776
+ };
5777
+ }
5778
+ const fleetBin = fleetBinPath(ctx);
5779
+ let dirty = false;
5780
+ if (canonical.size === 0) {
5781
+ for (const [agentId] of ownedRegistryEntries(agents, ctx.repoRoot)) {
5782
+ details.push(`blocked: "${agentId}" has no role.yaml; provision the role instead of pruning the registry`);
5783
+ }
5784
+ for (const agentId of declaredAgentIds(ctx.repoRoot)) {
5785
+ if (!details.some((detail) => detail.includes(`"${agentId}"`))) {
5786
+ details.push(`blocked: "${agentId}" is declared but has no role.yaml; provision or restore the role`);
5787
+ }
5788
+ }
5789
+ if (details.length) {
5790
+ return {
5791
+ id: finding.id,
5792
+ title: finding.title,
5793
+ status: "blocked",
5794
+ summary: "Registry parity is blocked by an unprovisioned Hermes role",
5795
+ changedFiles,
5796
+ details
5797
+ };
5798
+ }
5799
+ }
5800
+ for (const [agentId, entry] of ownedRegistryEntries(agents, ctx.repoRoot)) {
5801
+ if (canonical.size > 0 && !canonical.has(agentId)) {
5802
+ details.push(`drop stale/duplicate registry agent "${agentId}"`);
5803
+ delete agents[agentId];
5804
+ dropDeclaredAgent(ctx, agentId, changedFiles, details);
5805
+ dirty = true;
5806
+ continue;
5807
+ }
5808
+ const hermes = entry.hermes ?? {};
5809
+ if (fleetBin && String(hermes.bin ?? "") !== fleetBin && !existsSync10(String(hermes.bin ?? ""))) {
5810
+ details.push(`repoint ${agentId} hermes.bin -> ${fleetBin}`);
5811
+ hermes.bin = fleetBin;
5812
+ entry.hermes = hermes;
5813
+ dirty = true;
5814
+ }
5815
+ if (hermes.oauth_file) {
5816
+ details.push(`drop dead hermes.oauth_file from ${agentId}`);
5817
+ delete hermes.oauth_file;
5818
+ dirty = true;
5819
+ }
5820
+ }
5821
+ if (canonical.size > 0) {
5822
+ for (const extra of declaredAgentIds(ctx.repoRoot).filter((id) => !canonical.has(id))) {
5823
+ dropDeclaredAgent(ctx, extra, changedFiles, details);
5824
+ }
5825
+ }
5826
+ if (dirty) {
5827
+ changedFiles.push(registryPath2);
5828
+ if (!ctx.dryRun) {
5829
+ doc.agents = agents;
5830
+ writeText(registryPath2, YAML3.stringify(doc));
5831
+ }
5832
+ }
5833
+ return {
5834
+ id: finding.id,
5835
+ title: finding.title,
5836
+ status: changedFiles.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
5837
+ summary: changedFiles.length ? ctx.dryRun ? "Planned registry repair" : "Fleet registry repaired" : "No changes required",
5838
+ changedFiles,
5839
+ details
5840
+ };
5841
+ }
4371
5842
  }
4372
5843
  ];
4373
5844
  function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
@@ -4386,7 +5857,7 @@ function getParityRuleIds() {
4386
5857
  function runAudit(repoArg) {
4387
5858
  const pjanglerRoot = resolvePjanglerRoot2();
4388
5859
  const ctx = {
4389
- repoRoot: resolve2(repoArg ?? process.cwd()),
5860
+ repoRoot: resolve3(repoArg ?? process.cwd()),
4390
5861
  dryRun: true,
4391
5862
  pjanglerRoot,
4392
5863
  homeDir: homedir5()
@@ -4402,7 +5873,7 @@ function runAudit(repoArg) {
4402
5873
  function runMigrationForRules(ruleIds, repoArg, dryRun) {
4403
5874
  const pjanglerRoot = resolvePjanglerRoot2();
4404
5875
  const ctx = {
4405
- repoRoot: resolve2(repoArg ?? process.cwd()),
5876
+ repoRoot: resolve3(repoArg ?? process.cwd()),
4406
5877
  dryRun,
4407
5878
  pjanglerRoot,
4408
5879
  homeDir: homedir5()
@@ -4436,7 +5907,22 @@ function runMigrationForRules(ruleIds, repoArg, dryRun) {
4436
5907
  };
4437
5908
  }
4438
5909
  function runMigration(selector, repoArg, dryRun, all) {
4439
- const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
5910
+ if (all) {
5911
+ const audit = runAudit(repoArg);
5912
+ const ruleIds2 = audit.rules.filter((finding) => finding.fixable && (finding.status === "fail" || finding.status === "warn")).map((finding) => finding.id);
5913
+ if (ruleIds2.length === 0) {
5914
+ return {
5915
+ repo: audit.repo,
5916
+ dryRun,
5917
+ ok: true,
5918
+ selectedRules: [],
5919
+ results: [],
5920
+ changedFiles: []
5921
+ };
5922
+ }
5923
+ return runMigrationForRules(ruleIds2, repoArg, dryRun);
5924
+ }
5925
+ const ruleIds = selector ? [selector] : [];
4440
5926
  return runMigrationForRules(ruleIds, repoArg, dryRun);
4441
5927
  }
4442
5928
  function prettyTimestamp(iso) {
@@ -4473,7 +5959,7 @@ var server = new McpServer({
4473
5959
  });
4474
5960
  var TICKET_PROVIDER_SCHEMA = z.enum(["plane", "trello"]);
4475
5961
  function resolveTargetDir(targetDir) {
4476
- const dir = resolve3(targetDir ?? process.cwd());
5962
+ const dir = resolve4(targetDir ?? process.cwd());
4477
5963
  if (!existsSync11(dir)) {
4478
5964
  throw new Error(`Target directory does not exist: ${dir}`);
4479
5965
  }
@@ -4485,7 +5971,7 @@ function resolveTargetDir(targetDir) {
4485
5971
  function resolvePjanglerRoot3() {
4486
5972
  let dir = dirname8(fileURLToPath5(import.meta.url));
4487
5973
  while (dir !== dirname8(dir)) {
4488
- if (existsSync11(join15(dir, "package.json")) && existsSync11(join15(dir, "templates", "commonproject", "copier.yml"))) {
5974
+ if (existsSync11(join16(dir, "package.json")) && existsSync11(join16(dir, "templates", "commonproject", "copier.yml"))) {
4489
5975
  return dir;
4490
5976
  }
4491
5977
  dir = dirname8(dir);
@@ -4686,9 +6172,9 @@ server.registerTool(
4686
6172
  try {
4687
6173
  const pjanglerRoot = resolvePjanglerRoot3();
4688
6174
  const projectSlug = input.projectSlug ?? slugify(input.projectName);
4689
- const parentDir = resolve3(input.parentDir ?? process.cwd());
6175
+ const parentDir = resolve4(input.parentDir ?? process.cwd());
4690
6176
  if (!existsSync11(parentDir) || !statSync2(parentDir).isDirectory()) throw new Error(`Parent directory does not exist: ${parentDir}`);
4691
- const targetDir = resolve3(input.targetDir ?? join15(parentDir, projectSlug));
6177
+ const targetDir = resolve4(input.targetDir ?? join16(parentDir, projectSlug));
4692
6178
  const overwrite = input.overwrite ?? input.force ?? false;
4693
6179
  const dryRun = input.dryRun ?? true;
4694
6180
  const local = input.local ?? true;
@@ -4931,7 +6417,7 @@ server.registerTool(
4931
6417
  targetDir: resolvedTarget,
4932
6418
  yes: true,
4933
6419
  local,
4934
- targetRepo: input.targetRepo ?? basename4(resolvedTarget),
6420
+ targetRepo: input.targetRepo ?? basename5(resolvedTarget),
4935
6421
  role: input.role,
4936
6422
  agentPurpose: input.agentPurpose,
4937
6423
  soulTone: input.soulTone,