@delorenj/pjangler 1.2.19 → 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 (72) hide show
  1. package/dist/index.js +2353 -518
  2. package/dist/mcp-server.js +2331 -494
  3. package/package.json +14 -3
  4. package/templates/commonproject/copier.yml +6 -4
  5. package/templates/commonproject/template/.agents/hooks/README.md +13 -26
  6. package/templates/commonproject/template/.agents/hooks/lib/local-config.sh +4 -14
  7. package/templates/commonproject/template/.agents/hooks/sync.py +5 -6
  8. package/templates/commonproject/template/.agents/local.example.json +2 -8
  9. package/templates/commonproject/template/.agents/skills.json +6 -0
  10. package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
  11. package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
  12. package/templates/commonproject/template/mise.toml.jinja +16 -16
  13. package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
  14. package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
  15. package/templates/hermes-agent/README.md +70 -29
  16. package/templates/hermes-agent/copier.yml +9 -6
  17. package/templates/hermes-agent/docs/architecture.md +75 -51
  18. package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
  19. package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
  20. package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
  21. package/templates/hermes-agent/docs/operations.md +179 -81
  22. package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
  23. package/templates/hermes-agent/docs/sentinel/README.md +10 -10
  24. package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
  25. package/templates/hermes-agent/docs/sentinel/development.md +12 -15
  26. package/templates/hermes-agent/docs/sentinel.md +1 -1
  27. package/templates/hermes-agent/install-local.sh +41 -4
  28. package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
  29. package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
  30. package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
  31. package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
  32. package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
  33. package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
  34. package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
  35. package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
  36. package/templates/hermes-agent/template/.gitignore.jinja +5 -2
  37. package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
  38. package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
  39. package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
  40. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
  41. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
  42. package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
  43. package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
  44. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
  45. package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
  46. package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
  47. package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
  48. package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
  49. package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
  50. package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
  51. package/templates/hermes-agent/template/.scripts/heartbeat.sh +12 -1
  52. package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +137 -0
  53. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +21 -0
  54. package/templates/hermes-agent/template/SOUL.md.jinja +36 -15
  55. package/templates/hermes-agent/template/hermes.jinja +6 -3
  56. package/templates/hermes-agent/template/role.yaml.jinja +24 -13
  57. package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
  58. package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
  59. package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
  60. package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
  61. package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
  62. package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
  63. package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
  64. package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
  65. package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
  66. package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
  67. package/templates/commonproject/template/.mise/scripts/link-project-skills-to-clis.sh +0 -110
  68. package/templates/commonproject/template/.mise/scripts/unlink-project-skills-from-clis.sh +0 -45
  69. package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
  70. package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
  71. package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
  72. package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
package/dist/index.js CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { spawnSync as spawnSync7 } from "node:child_process";
5
- import { existsSync as existsSync10, readFileSync as readFileSync7, statSync as statSync2 } from "node:fs";
6
- import { basename as basename4, join as join13, resolve as resolve3 } from "node:path";
5
+ import { existsSync as existsSync11, readFileSync as readFileSync9, statSync as statSync2 } from "node:fs";
6
+ import { basename as basename5, join as join16, resolve as resolve4 } from "node:path";
7
7
  import { Command as Command3 } from "commander";
8
8
 
9
9
  // src/commands/hermes/types.ts
@@ -19,7 +19,7 @@ function deriveProfileName(repo, role) {
19
19
  // src/commands/hermes/EnsureTemplateConfig.ts
20
20
  import { homedir, platform } from "node:os";
21
21
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
22
- import { join as join2, dirname as dirname2 } from "node:path";
22
+ import { join as join3, dirname as dirname2 } from "node:path";
23
23
 
24
24
  // src/commands/Command.ts
25
25
  import { existsSync, writeFileSync, mkdirSync } from "fs";
@@ -57,19 +57,149 @@ var Command = class {
57
57
  }
58
58
  };
59
59
 
60
+ // src/parity/bmadPack.ts
61
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync } from "node:fs";
62
+ import { createHash } from "node:crypto";
63
+ import { basename, join as join2, relative, resolve, sep } from "node:path";
64
+ var BMAD_PACK_VERSION = "6.10.1-next.31";
65
+ var BMAD_PACK_CHECKSUMS_SHA256 = "a8bc005612ac60e3ec775fff5a11eafe38be6acdae96efa3d770b48322cb3224";
66
+ var BMAD_PACK_SKILL_COUNT = 76;
67
+ var BMAD_PACK_PAYLOAD_FILES = 1072;
68
+ function sha256(content) {
69
+ return createHash("sha256").update(content).digest("hex");
70
+ }
71
+ function readRegularFile(path) {
72
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
73
+ try {
74
+ if (!fstatSync(fd).isFile()) throw new Error(`BMAD pack entry is not a regular file: ${path}`);
75
+ return readFileSync(fd);
76
+ } finally {
77
+ closeSync(fd);
78
+ }
79
+ }
80
+ function safeRelativePath(value) {
81
+ if (!value || value.includes("\\") || value.startsWith("/") || value.split("/").some((part) => part === "" || part === "." || part === "..")) {
82
+ throw new Error(`Unsafe BMAD checksum path: ${JSON.stringify(value)}`);
83
+ }
84
+ return value;
85
+ }
86
+ function parsePackToml(content) {
87
+ const required = [
88
+ /^name = "bmad"$/m,
89
+ new RegExp(`^version = "${BMAD_PACK_VERSION.replaceAll(".", "\\.")}"$`, "m"),
90
+ /^upstream = "bmad-method"$/m,
91
+ new RegExp(`^upstream_version = "${BMAD_PACK_VERSION.replaceAll(".", "\\.")}"$`, "m"),
92
+ /^rendered_from = "\.agent\/skills"$/m,
93
+ /^immutable = true$/m,
94
+ /^project_projection = "symlink"$/m
95
+ ];
96
+ if (required.some((pattern) => !pattern.test(content))) {
97
+ throw new Error(`BMAD pack.toml does not declare the trusted ${BMAD_PACK_VERSION} contract`);
98
+ }
99
+ const payloadMatch = content.match(/^payload_files = (\d+)$/m);
100
+ const skillsMatch = content.match(/^skills = \[\n([\s\S]*?)^\]$/m);
101
+ if (!payloadMatch || !skillsMatch) throw new Error("BMAD pack.toml is missing payload inventory metadata");
102
+ const skillNames = [...skillsMatch[1].matchAll(/^\s+"([^"]+)",$/gm)].map((match) => match[1]);
103
+ const payloadFiles = Number.parseInt(payloadMatch[1], 10);
104
+ if (skillNames.length !== BMAD_PACK_SKILL_COUNT || new Set(skillNames).size !== skillNames.length) {
105
+ throw new Error(`BMAD pack.toml must declare exactly ${BMAD_PACK_SKILL_COUNT} unique skills`);
106
+ }
107
+ if (payloadFiles !== BMAD_PACK_PAYLOAD_FILES) {
108
+ throw new Error(`BMAD pack.toml must declare exactly ${BMAD_PACK_PAYLOAD_FILES} payload files`);
109
+ }
110
+ for (const name of skillNames) {
111
+ if (!name.startsWith("bmad-") || basename(name) !== name) throw new Error(`Unsafe BMAD skill identity: ${name}`);
112
+ }
113
+ return { skillNames, payloadFiles };
114
+ }
115
+ function walkRegularTree(root) {
116
+ const files = /* @__PURE__ */ new Map();
117
+ const directories = /* @__PURE__ */ new Set();
118
+ const visit = (directory) => {
119
+ for (const name of readdirSync(directory).sort()) {
120
+ const path = join2(directory, name);
121
+ const stat = lstatSync(path);
122
+ if (stat.isSymbolicLink()) throw new Error(`BMAD pack may not contain symlinks: ${path}`);
123
+ if (stat.isDirectory()) {
124
+ directories.add(relative(root, path).split(sep).join("/"));
125
+ visit(path);
126
+ } else if (stat.isFile()) files.set(relative(root, path).split(sep).join("/"), readRegularFile(path));
127
+ else throw new Error(`BMAD pack may contain only regular files/directories: ${path}`);
128
+ }
129
+ };
130
+ visit(root);
131
+ return { files, directories };
132
+ }
133
+ function validateTrustedBmadPack(packRoot) {
134
+ const root = resolve(packRoot);
135
+ const rootStat = lstatSync(root);
136
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
137
+ throw new Error(`BMAD pack root must be a real directory: ${root}`);
138
+ }
139
+ const checksumsContent = readRegularFile(join2(root, "SHA256SUMS"));
140
+ if (sha256(checksumsContent) !== BMAD_PACK_CHECKSUMS_SHA256) {
141
+ throw new Error(`BMAD pack checksum manifest is not the trusted ${BMAD_PACK_VERSION} manifest`);
142
+ }
143
+ const expected = /* @__PURE__ */ new Map();
144
+ for (const line of checksumsContent.toString("utf8").split("\n")) {
145
+ if (!line) continue;
146
+ const match = line.match(/^([0-9a-f]{64}) (.+)$/);
147
+ if (!match) throw new Error(`Invalid BMAD SHA256SUMS entry: ${line}`);
148
+ const path = safeRelativePath(match[2]);
149
+ if (expected.has(path)) throw new Error(`Duplicate BMAD SHA256SUMS entry: ${path}`);
150
+ expected.set(path, match[1]);
151
+ }
152
+ const walked = walkRegularTree(root);
153
+ const actual = walked.files;
154
+ actual.delete("SHA256SUMS");
155
+ const missing = [...expected.keys()].filter((path) => !actual.has(path));
156
+ const extra = [...actual.keys()].filter((path) => !expected.has(path));
157
+ if (missing.length || extra.length) {
158
+ throw new Error(`BMAD checksum coverage mismatch; missing=${JSON.stringify(missing)} extra=${JSON.stringify(extra)}`);
159
+ }
160
+ for (const [path, digest] of expected) {
161
+ const content = actual.get(path);
162
+ if (!content || sha256(content) !== digest) throw new Error(`BMAD pack digest mismatch: ${path}`);
163
+ }
164
+ for (const directory of walked.directories) {
165
+ if (![...actual.keys()].some((path) => path.startsWith(`${directory}/`))) {
166
+ throw new Error(`BMAD pack contains an unauthenticated empty directory: ${directory}`);
167
+ }
168
+ }
169
+ const packToml = actual.get("pack.toml");
170
+ if (!packToml) throw new Error("BMAD pack.toml is missing");
171
+ const metadata = parsePackToml(packToml.toString("utf8"));
172
+ const skillSet = new Set(metadata.skillNames);
173
+ const topLevelDirectories = readdirSync(root).filter((name) => lstatSync(join2(root, name)).isDirectory()).sort();
174
+ if (topLevelDirectories.length !== skillSet.size || topLevelDirectories.some((name) => !skillSet.has(name))) {
175
+ throw new Error("BMAD pack directory inventory differs from authenticated pack.toml skills");
176
+ }
177
+ for (const name of metadata.skillNames) {
178
+ const skillMd = join2(root, name, "SKILL.md");
179
+ if (!lstatSync(skillMd).isFile() || lstatSync(skillMd).isSymbolicLink()) {
180
+ throw new Error(`BMAD skill is missing a regular SKILL.md: ${name}`);
181
+ }
182
+ }
183
+ const payloadCount = [...actual.keys()].filter((path) => skillSet.has(path.split("/", 1)[0])).length;
184
+ if (payloadCount !== metadata.payloadFiles) {
185
+ throw new Error(`BMAD payload inventory mismatch: ${payloadCount} != ${metadata.payloadFiles}`);
186
+ }
187
+ return { root, skillNames: metadata.skillNames, payloadFiles: metadata.payloadFiles };
188
+ }
189
+
60
190
  // src/commands/hermes/EnsureTemplateConfig.ts
61
191
  function resolveTemplateConfigPath() {
62
192
  const fromEnv = process.env.HERMES_TEMPLATE_CONFIG;
63
193
  if (fromEnv && fromEnv.trim()) return fromEnv.trim();
64
194
  const xdg = process.env.XDG_CONFIG_HOME?.trim();
65
- const base = xdg && xdg.length ? xdg : join2(homedir(), ".config");
66
- return join2(base, "hermes-agent-template", "config.toml");
195
+ const base = xdg && xdg.length ? xdg : join3(homedir(), ".config");
196
+ return join3(base, "hermes-agent-template", "config.toml");
67
197
  }
68
198
  function detectHermesBin(home) {
69
199
  const candidates = [
70
- join2(home, "code", "hermes-agent", "venv", "bin", "hermes"),
71
- join2(home, "code", "hermes-agent", ".venv", "bin", "hermes"),
72
- join2(home, ".local", "bin", "hermes")
200
+ join3(home, "code", "hermes-agent", "venv", "bin", "hermes"),
201
+ join3(home, "code", "hermes-agent", ".venv", "bin", "hermes"),
202
+ join3(home, ".local", "bin", "hermes")
73
203
  ];
74
204
  for (const c of candidates) {
75
205
  if (existsSync2(c)) return c;
@@ -79,11 +209,11 @@ function detectHermesBin(home) {
79
209
  function renderHostConfig() {
80
210
  const home = homedir();
81
211
  const hermesBin = detectHermesBin(home);
82
- const hermesRepo = join2(home, "code", "hermes-agent");
83
- const scaffoldDir = join2(home, "code", "hermes-agent-template", "runtime-scaffold");
84
- const skillsDir = join2(home, ".agents", "skills");
85
- const pmExternalSkillGlobalDir = join2(home, "code", "skillex", "skill-sets", "global", ".system");
86
- const pmExternalSkillBmadDir = join2(home, "code", "skillex", "packs", "bmad", "6.10.2");
212
+ const hermesRepo = join3(home, "code", "hermes-agent");
213
+ const scaffoldDir = join3(home, "code", "hermes-agent-template", "runtime-scaffold");
214
+ const skillsDir = join3(home, ".agents", "skills");
215
+ const pmExternalSkillGlobalDir = join3(home, "code", "skillex", "skill-sets", "global", ".system");
216
+ const pmExternalSkillBmadDir = join3(home, "code", "skillex", "packs", "bmad", BMAD_PACK_VERSION);
87
217
  return `# hermes-agent-template \u2014 host configuration
88
218
  # Bootstrapped by \`pjangler config bootstrap\` for $HOME=${home} (platform=${platform()}).
89
219
  #
@@ -398,7 +528,7 @@ if __name__ == "__main__":
398
528
 
399
529
  // src/commands/AddMiseCodegraphScript.ts
400
530
  import { chmodSync } from "fs";
401
- import { join as join3 } from "path";
531
+ import { join as join4 } from "path";
402
532
  var AddMiseCodegraphScript = class extends Command {
403
533
  async invoke() {
404
534
  const filePath = ".mise/scripts/codegraph.sh";
@@ -410,97 +540,284 @@ var AddMiseCodegraphScript = class extends Command {
410
540
  };
411
541
  }
412
542
  const content = `#!/usr/bin/env bash
413
- # Mise enter hook: ensure the CodeGraph CLI is available and initialize the
414
- # project index. If \`codegraph\` is not installed, this script installs it
415
- # non-interactively into the project-local .mise/bin directory and retries.
416
- #
417
- # This is intended to run from a mise enter hook so onboarding a new host is
418
- # fully automatic.
543
+ # Auto-generated by pjangler
544
+
545
+ REPO_ROOT="$(pwd)"
546
+ PROJECT_NAME="$(basename "$REPO_ROOT")"
547
+ CONTAINER_NAME="codegraph-mcp-$PROJECT_NAME"
548
+ CACHE_DIR="$REPO_ROOT/.codegraph"
549
+
550
+ # Deterministically generate a port based on the repository path
551
+ PORT=$(echo -n "$REPO_ROOT" | md5sum | awk '{print $1}' | tr -d 'a-f' | cut -c1-4)
552
+ # Ensure port is > 1024
553
+ PORT=$(( (PORT % 60000) + 1025 ))
554
+
555
+ if ! docker ps --format '{{.Names}}' | grep -q "^$CONTAINER_NAME$"; then
556
+ echo "\u{1F680} Starting CodeGraph MCP Server on port $PORT..."
557
+
558
+ # Ensure the container isn't lingering in a stopped state
559
+ docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
560
+
561
+ # Run the true Colby McHenry CodeGraph Docker image
562
+ # which we built locally as colbymchenry-codegraph-mcp:latest
563
+ docker run -d \\
564
+ --name "$CONTAINER_NAME" \\
565
+ --restart unless-stopped \\
566
+ -p "$PORT:8045" \\
567
+ -v "$REPO_ROOT:/repo" \\
568
+ colbymchenry-codegraph-mcp:latest >/dev/null
569
+
570
+ echo "\u2705 CodeGraph MCP running in background. SSE URL: http://localhost:$PORT/sse"
571
+ fi
572
+
573
+ # Run init inside the container to ensure the index is bootstrapped.
574
+ # We run this using the standard codegraph CLI inside the container
575
+ docker exec "$CONTAINER_NAME" codegraph init -i /repo >/dev/null 2>&1 || true
576
+
577
+ # Wire up the MCP server to local agents
578
+ WIRE_SCRIPT="$(dirname "$0")/codegraph-wire.sh"
579
+ if [ -x "$WIRE_SCRIPT" ]; then
580
+ "$WIRE_SCRIPT"
581
+ fi
582
+ `;
583
+ this.writeFile(filePath, content);
584
+ if (!this.context.dryRun) {
585
+ chmodSync(join4(this.context.targetDir, filePath), 493);
586
+ }
587
+ return {
588
+ success: true,
589
+ message: this.formatMessage(this.context.dryRun ? "Would create .mise/scripts/codegraph.sh" : "\u2705 Created .mise/scripts/codegraph.sh"),
590
+ filePath
591
+ };
592
+ }
593
+ };
594
+
595
+ // src/commands/AddMiseCodegraphWireScript.ts
596
+ import { chmodSync as chmodSync2 } from "fs";
597
+ import { join as join5 } from "path";
598
+ var AddMiseCodegraphWireScript = class extends Command {
599
+ async invoke() {
600
+ const filePath = ".mise/scripts/codegraph-wire.sh";
601
+ if (this.fileExists(filePath) && !this.context.force) {
602
+ return {
603
+ success: false,
604
+ message: this.formatMessage("\u26A0\uFE0F .mise/scripts/codegraph-wire.sh already exists"),
605
+ filePath
606
+ };
607
+ }
608
+ const content = `#!/usr/bin/env bash
609
+ # Mise enter hook: Auto-wire CodeGraph MCP Server to local agents.
610
+ # Detects Claude Code, Codex, Gemini, Kimi, and OpenCode and configures
611
+ # them to use the local SSE endpoint for the current project.
419
612
 
420
613
  set -euo pipefail
421
614
 
422
615
  REPO_ROOT="\${MISE_PROJECT_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}"
423
- PROJECT_BIN_DIR="$REPO_ROOT/.mise/bin"
424
- mkdir -p "$PROJECT_BIN_DIR"
425
-
426
- # Install the CodeGraph CLI into the project-local bin directory.
427
- install_codegraph() {
428
- echo "[mise] codegraph not found. Installing non-interactively..."
429
- export CODEGRAPH_BIN_DIR="$PROJECT_BIN_DIR"
430
- curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh
431
-
432
- if [ -x "$PROJECT_BIN_DIR/codegraph" ]; then
433
- export PATH="$PROJECT_BIN_DIR:$PATH"
434
- else
435
- echo "[mise] codegraph install did not place a binary at $PROJECT_BIN_DIR/codegraph" >&2
436
- return 1
616
+
617
+ # Compute the same port as the container script
618
+ PORT_HASH=$(echo -n "$REPO_ROOT" | md5sum | awk '{print $1}')
619
+ PORT_DEC=$(printf "%d" "0x\${PORT_HASH:0:4}")
620
+ PORT=$(( 8045 + (PORT_DEC % 955) ))
621
+
622
+ SSE_URL="http://localhost:$PORT/sse"
623
+
624
+ inject_sse() {
625
+ local target="$1"
626
+ local agent="$2"
627
+
628
+ if [ ! -f "$target" ]; then
629
+ mkdir -p "$(dirname "$target")"
630
+ echo '{"mcpServers": {}}' > "$target"
631
+ fi
632
+
633
+ # Ensure the file is valid JSON (fail gracefully if it's garbled)
634
+ if ! jq . "$target" >/dev/null 2>&1; then
635
+ echo "[mise] WARNING: $target is not valid JSON, skipping $agent wiring" >&2
636
+ return
437
637
  fi
638
+
639
+ # Inject or update the codegraph server
640
+ jq --arg url "$SSE_URL" '.mcpServers.codegraph = {"type": "sse", "url": $url}' "$target" > "$target.tmp" && mv "$target.tmp" "$target"
641
+ echo "[mise] Wired CodeGraph SSE for $agent -> $target"
438
642
  }
439
643
 
440
- # Ensure a codegraph binary is available on PATH.
441
- ensure_codegraph() {
442
- if command -v codegraph >/dev/null 2>&1; then
443
- return 0
444
- fi
644
+ # 1. Claude Code (Project-scoped)
645
+ inject_sse "$REPO_ROOT/.claude.json" "Claude Code"
445
646
 
446
- # Check the project-local bin dir first (previous install from this hook).
447
- if [ -x "$PROJECT_BIN_DIR/codegraph" ]; then
448
- export PATH="$PROJECT_BIN_DIR:$PATH"
449
- return 0
450
- fi
647
+ # 2. Codex (Global)
648
+ inject_sse "$HOME/.codex/mcp.json" "Codex"
451
649
 
452
- # Check typical user-level install locations before fetching anything.
453
- for d in "$HOME/.local/bin" "$HOME/.codegraph/current/bin"; do
454
- if [ -x "$d/codegraph" ]; then
455
- export PATH="$d:$PATH"
456
- return 0
457
- fi
458
- done
650
+ # 3. Gemini (Global)
651
+ inject_sse "$HOME/.gemini/config/mcp.json" "Gemini"
459
652
 
460
- install_codegraph
461
- }
653
+ # 4. Kimi (Global)
654
+ inject_sse "$HOME/.kimi-code/mcp.json" "Kimi"
462
655
 
463
- # Attempt to initialize the project graph. If the command is missing, install
464
- # it and retry once.
465
- init_project() {
466
- local err_file
467
- err_file="$(mktemp)"
468
- trap 'rm -f "$err_file"' RETURN
656
+ # 5. OpenCode (Global)
657
+ inject_sse "$HOME/.opencode/mcp.json" "OpenCode"
469
658
 
470
- if codegraph init -i "$REPO_ROOT" 2>"$err_file"; then
471
- return 0
472
- fi
659
+ # 6. Cursor (Global)
660
+ inject_sse "$HOME/.cursor/mcp.json" "Cursor"
473
661
 
474
- # If the failure looks like a missing binary, install and retry.
475
- if grep -qiE 'command not found|not installed|No such file|executable file not found' "$err_file" 2>/dev/null; then
476
- ensure_codegraph
477
- codegraph init -i "$REPO_ROOT"
478
- return 0
479
- fi
662
+ # 7. VSCode native MCP (Global)
663
+ inject_sse "$HOME/.vscode/mcp.json" "VSCode"
480
664
 
481
- cat "$err_file" >&2
482
- return 1
483
- }
665
+ # 8. Cline (VSCode extension)
666
+ inject_sse "$HOME/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json" "Cline"
484
667
 
485
- init_project
668
+ # 9. Roo (VSCode extension)
669
+ inject_sse "$HOME/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json" "Roo Code"
486
670
  `;
487
671
  this.writeFile(filePath, content);
488
672
  if (!this.context.dryRun) {
489
- chmodSync(join3(this.context.targetDir, filePath), 493);
673
+ chmodSync2(join5(this.context.targetDir, filePath), 493);
490
674
  }
491
675
  return {
492
676
  success: true,
493
- message: this.formatMessage(this.context.dryRun ? "Would create .mise/scripts/codegraph.sh" : "\u2705 Created .mise/scripts/codegraph.sh"),
677
+ message: this.formatMessage(this.context.dryRun ? "Would create .mise/scripts/codegraph-wire.sh" : "\u2705 Created .mise/scripts/codegraph-wire.sh"),
494
678
  filePath
495
679
  };
496
680
  }
497
681
  };
498
682
 
683
+ // src/commands/WireMiseOpInject.ts
684
+ import { join as join6 } from "node:path";
685
+ import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3, chmodSync as chmodSync3 } from "node:fs";
686
+ var WireMiseOpInject = class _WireMiseOpInject extends Command {
687
+ static MARKER = "# pjangler:op-inject";
688
+ static CR = "{{config_root}}";
689
+ async invoke() {
690
+ const scriptPath = ".mise/scripts/inject-op-secrets.sh";
691
+ const fullScriptPath = join6(this.context.targetDir, scriptPath);
692
+ const scriptContent = `#!/usr/bin/env bash
693
+ # Resolve 1Password \`op://\` references in .env.op into .env.secrets, which mise
694
+ # loads via \`_.file\`.
695
+ #
696
+ # Runs from the mise enter hook: fail-open and non-interactive.
697
+ # No .env.op means no 1Password, so this exits before spending anything.
698
+
699
+ set -euo pipefail
700
+
701
+ REPO_ROOT="\${MISE_PROJECT_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}"
702
+ SRC="$REPO_ROOT/.env.op"
703
+ OUT="$REPO_ROOT/.env.secrets"
704
+
705
+ TTL_HOURS="\${OP_INJECT_TTL_HOURS:-12}"
706
+
707
+ [[ -f "$SRC" ]] || exit 0
708
+ command -v op >/dev/null 2>&1 || exit 0
709
+
710
+ if [[ -f "$OUT" && "$SRC" -ot "$OUT" ]]; then
711
+ if [[ "$TTL_HOURS" != "0" ]] && [[ -z "$(find "$OUT" -mmin "+$((TTL_HOURS * 60))" 2>/dev/null)" ]]; then
712
+ exit 0
713
+ fi
714
+ fi
715
+
716
+ op account list >/dev/null 2>&1 || exit 0
717
+
718
+ tmp="$(mktemp "\${OUT}.XXXXXX")"
719
+ trap 'rm -f "$tmp"' EXIT
720
+ chmod 600 "$tmp"
721
+
722
+ if op inject -i "$SRC" -o "$tmp" --force >/dev/null 2>&1; then
723
+ mv "$tmp" "$OUT"
724
+ trap - EXIT
725
+ else
726
+ echo "mise: op inject from .env.op failed \u2014 $(basename "$OUT") unchanged." >&2
727
+ echo " Check 'op signin', or that .env.op's op:// items are readable." >&2
728
+ echo " To disable: rm .env.op" >&2
729
+ fi
730
+ `;
731
+ this.writeFile(scriptPath, scriptContent);
732
+ if (!this.context.dryRun) {
733
+ chmodSync3(fullScriptPath, 493);
734
+ }
735
+ const misePath = join6(this.context.targetDir, "mise.toml");
736
+ if (!existsSync3(misePath)) {
737
+ return {
738
+ success: false,
739
+ message: "\u26A0\uFE0F No mise.toml found \u2014 run `pj init mise` first, then re-run."
740
+ };
741
+ }
742
+ let content = readFileSync2(misePath, "utf8");
743
+ if (content.includes(_WireMiseOpInject.MARKER)) {
744
+ return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for op-inject") };
745
+ }
746
+ const cr = _WireMiseOpInject.CR;
747
+ const envRe = /\[env\]\s*\n([\s\S]*?(?=\n\[|$))/;
748
+ if (envRe.test(content)) {
749
+ content = content.replace(envRe, (m) => {
750
+ let block = m;
751
+ if (!block.includes("_.file")) {
752
+ block += `
753
+ _.file = ['.env', '.env.secrets']
754
+ `;
755
+ } else if (!block.includes(".env.secrets")) {
756
+ block = block.replace(/(_\.file\s*=\s*\[)([^\]]*?)(\])/, (m2, prefix, files, suffix) => {
757
+ const added = files.trim() ? `${files}, '.env.secrets'` : `'.env.secrets'`;
758
+ return `${prefix}${added}${suffix}`;
759
+ });
760
+ }
761
+ return block;
762
+ });
763
+ } else {
764
+ const envBlock = `[env]
765
+ _.file = ['.env', '.env.secrets']
766
+
767
+ `;
768
+ if (content.includes("[tools]")) {
769
+ content = content.replace(/(\[tools\][\s\S]*?(?=\n\[|$))/, `$1
770
+ ${envBlock}`);
771
+ } else {
772
+ content = envBlock + content;
773
+ }
774
+ }
775
+ const enterAdds = ` '${cr}/.mise/scripts/inject-op-secrets.sh',`;
776
+ const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
777
+ if (enterRe.test(content)) {
778
+ content = content.replace(enterRe, (_m, head, close) => {
779
+ const sep2 = /[,[]\s*$/.test(head) ? "" : ",";
780
+ return `${head}${sep2}
781
+ ${enterAdds}${close}`;
782
+ });
783
+ } else {
784
+ if (content.includes("[hooks]")) {
785
+ content = content.replace(/\[hooks\]/, `[hooks]
786
+ enter = [
787
+ ${enterAdds}
788
+ ]`);
789
+ } else {
790
+ content += `
791
+ [hooks]
792
+ enter = [
793
+ ${enterAdds}
794
+ ]
795
+ `;
796
+ }
797
+ }
798
+ const appended = [
799
+ "",
800
+ _WireMiseOpInject.MARKER,
801
+ "[tasks.secrets-inject]",
802
+ 'description = "Re-resolve .env.op secrets from 1Password into .env.secrets"',
803
+ `run = "OP_INJECT_TTL_HOURS=0 .mise/scripts/inject-op-secrets.sh"`,
804
+ _WireMiseOpInject.MARKER + ":end",
805
+ ""
806
+ ].join("\n");
807
+ content = content.replace(/\n*$/, "\n") + appended;
808
+ if (!this.context.dryRun) writeFileSync3(misePath, content);
809
+ return {
810
+ success: true,
811
+ message: this.formatMessage("\u2705 Wired mise.toml for op-inject (_.file, [hooks] enter, tasks.secrets-inject)")
812
+ };
813
+ }
814
+ };
815
+
499
816
  // src/recipes/MiseRecipe.ts
500
817
  var MiseRecipe = class extends Recipe {
501
818
  constructor(context) {
502
819
  super(context);
503
- this.addIngredient(AddMiseToml).addIngredient(AddDotenv).addIngredient(AddMiseTasksStructure).addIngredient(AddMiseBaseToml).addIngredient(AddMiseBaseScript).addIngredient(AddMiseCodegraphScript);
820
+ this.addIngredient(AddMiseToml).addIngredient(AddDotenv).addIngredient(AddMiseTasksStructure).addIngredient(AddMiseBaseToml).addIngredient(AddMiseBaseScript).addIngredient(AddMiseCodegraphScript).addIngredient(AddMiseCodegraphWireScript).addIngredient(WireMiseOpInject);
504
821
  }
505
822
  printNextSteps() {
506
823
  console.log("\u{1F389} Mise subsystem initialized successfully!");
@@ -728,12 +1045,12 @@ var NodeRecipe = class extends Recipe {
728
1045
  };
729
1046
 
730
1047
  // src/commands/hermes/PromptForAgentConfig.ts
731
- import { basename, join as join4 } from "node:path";
732
- import { readFileSync } from "node:fs";
1048
+ import { basename as basename2, join as join7 } from "node:path";
1049
+ import { readFileSync as readFileSync3 } from "node:fs";
733
1050
  import * as p from "@clack/prompts";
734
1051
  function detectTicketProvider(targetDir) {
735
1052
  try {
736
- const t = JSON.parse(readFileSync(join4(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
1053
+ const t = JSON.parse(readFileSync3(join7(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
737
1054
  return t === "plane" || t === "trello" ? t : void 0;
738
1055
  } catch {
739
1056
  return void 0;
@@ -742,7 +1059,7 @@ function detectTicketProvider(targetDir) {
742
1059
  var PromptForAgentConfig = class extends Command {
743
1060
  async invoke() {
744
1061
  const ctx = this.context;
745
- const defaultRepo = basename(ctx.targetDir).toLowerCase();
1062
+ const defaultRepo = basename2(ctx.targetDir).toLowerCase();
746
1063
  ctx.targetRepo = (ctx.targetRepo ?? defaultRepo).toLowerCase();
747
1064
  ctx.role ??= "pm";
748
1065
  ctx.agentPurpose ??= `${ctx.role} agent for ${ctx.targetRepo}`;
@@ -791,8 +1108,8 @@ var PromptForAgentConfig = class extends Command {
791
1108
  // src/commands/hermes/RunCopierTemplate.ts
792
1109
  import { spawnSync } from "node:child_process";
793
1110
  import { homedir as homedir2 } from "node:os";
794
- import { join as join5, dirname as dirname3 } from "node:path";
795
- import { existsSync as existsSync3, mkdirSync as mkdirSync3 } from "node:fs";
1111
+ import { join as join8, dirname as dirname3 } from "node:path";
1112
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3 } from "node:fs";
796
1113
  import { fileURLToPath } from "node:url";
797
1114
  import * as p2 from "@clack/prompts";
798
1115
  function resolveVendoredTemplate(name) {
@@ -803,8 +1120,8 @@ function resolveVendoredTemplate(name) {
803
1120
  return void 0;
804
1121
  }
805
1122
  for (let i = 0; i < 8; i++) {
806
- const candidate = join5(dir, "templates", name);
807
- if (existsSync3(join5(candidate, "copier.yml"))) return candidate;
1123
+ const candidate = join8(dir, "templates", name);
1124
+ if (existsSync4(join8(candidate, "copier.yml"))) return candidate;
808
1125
  const parent = dirname3(dir);
809
1126
  if (parent === dir) break;
810
1127
  dir = parent;
@@ -823,7 +1140,7 @@ var RunCopierTemplate = class extends Command {
823
1140
  message: "PromptForAgentConfig must run before RunCopierTemplate (targetRepo/role unset)"
824
1141
  };
825
1142
  }
826
- const roleDir = join5(ctx.targetDir, "agents", "hermes", role);
1143
+ const roleDir = join8(ctx.targetDir, "agents", "hermes", role);
827
1144
  ctx.roleDir = roleDir;
828
1145
  ctx.runtimeRepo = `delorenj/agent-hm-${targetRepo}-${role}`;
829
1146
  const which = spawnSync("which", ["copier"], { encoding: "utf8" });
@@ -833,7 +1150,7 @@ var RunCopierTemplate = class extends Command {
833
1150
  message: "\u2717 copier not found on PATH. Install with: `uv tool install copier` or `pip install copier`"
834
1151
  };
835
1152
  }
836
- if (existsSync3(join5(roleDir, "role.yaml")) && !ctx.force) {
1153
+ if (existsSync4(join8(roleDir, "role.yaml")) && !ctx.force) {
837
1154
  if (ctx.yes) {
838
1155
  ctx.force = true;
839
1156
  } else {
@@ -854,15 +1171,16 @@ var RunCopierTemplate = class extends Command {
854
1171
  ...process.env,
855
1172
  SKIP_TELEGRAM: "1",
856
1173
  SKIP_EMAIL: "1",
857
- // We DO want copier to run runtime-repo + plane + bloodbank + systemd.
1174
+ // Bloodbank is a fleet-shared Hermes gateway. Never provision the legacy
1175
+ // per-profile file consumer, even when an older template still exposes it.
858
1176
  SKIP_RUNTIME_REPO: ctx.skipRuntimeRepo ? "1" : "0",
859
1177
  SKIP_PLANE: ctx.skipPlane ? "1" : "0",
860
- SKIP_BLOODBANK: ctx.skipBloodbank ? "1" : "0",
1178
+ SKIP_BLOODBANK: "1",
861
1179
  SKIP_SYSTEMD: ctx.skipSystemd ? "1" : "0"
862
1180
  };
863
- const LOCAL_TEMPLATE = join5(homedir2(), "code", "hermes-agent-template");
1181
+ const LOCAL_TEMPLATE = join8(homedir2(), "code", "hermes-agent-template");
864
1182
  const vendored = resolveVendoredTemplate("hermes-agent");
865
- const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync3(join5(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
1183
+ const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync4(join8(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
866
1184
  const args = [
867
1185
  "copy",
868
1186
  templateSrc,
@@ -893,7 +1211,7 @@ var RunCopierTemplate = class extends Command {
893
1211
  message: this.formatMessage(`Would run: copier ${args.join(" ")}`)
894
1212
  };
895
1213
  }
896
- mkdirSync3(join5(ctx.targetDir, "agents", "hermes"), { recursive: true });
1214
+ mkdirSync3(join8(ctx.targetDir, "agents", "hermes"), { recursive: true });
897
1215
  const spinner4 = p2.spinner();
898
1216
  spinner4.start(`Running copier copy (target: agents/hermes/${role})`);
899
1217
  const result = spawnSync("copier", args, {
@@ -917,20 +1235,26 @@ var RunCopierTemplate = class extends Command {
917
1235
  };
918
1236
 
919
1237
  // src/commands/hermes/UntrackHermesRuntimes.ts
920
- import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync3, readdirSync } from "fs";
921
- import { join as join6 } from "path";
1238
+ import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4, readdirSync as readdirSync2 } from "fs";
1239
+ import { join as join9 } from "path";
922
1240
  import { spawnSync as spawnSync2 } from "node:child_process";
1241
+ function sectionHasPath(section, targetPath) {
1242
+ return section.split(/\r?\n/).some((line) => /^\s*path\s*=/.test(line) && line.replace(/^\s*path\s*=\s*/, "").trim() === targetPath);
1243
+ }
1244
+ function removeSubmodulePath(content, targetPath) {
1245
+ return content.replace(/^\[submodule "[^"\n]+"\][\s\S]*?(?=^\[submodule "|(?![\s\S]))/gm, (section) => sectionHasPath(section, targetPath) ? "" : section).replace(/\n{3,}/g, "\n\n").trim();
1246
+ }
923
1247
  var UntrackHermesRuntimes = class extends Command {
924
1248
  async invoke() {
925
1249
  const targetDir = this.context.targetDir;
926
- const rolesDir = join6(targetDir, "agents", "hermes");
927
- if (!existsSync4(rolesDir)) {
1250
+ const rolesDir = join9(targetDir, "agents", "hermes");
1251
+ if (!existsSync5(rolesDir)) {
928
1252
  return {
929
1253
  success: true,
930
1254
  message: "No Hermes agents found (no agents/hermes directory)."
931
1255
  };
932
1256
  }
933
- const roles = readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
1257
+ const roles = readdirSync2(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
934
1258
  if (roles.length === 0) {
935
1259
  return {
936
1260
  success: true,
@@ -940,53 +1264,85 @@ var UntrackHermesRuntimes = class extends Command {
940
1264
  let modifiedAny = false;
941
1265
  const details = [];
942
1266
  for (const role of roles) {
943
- const roleDir = join6("agents", "hermes", role);
944
- const runtimePath = join6(roleDir, "runtime");
945
- const gitignorePath = join6(roleDir, ".gitignore");
1267
+ const roleDir = join9("agents", "hermes", role);
1268
+ const runtimePath = join9(roleDir, "runtime");
1269
+ const gitignorePath = join9(roleDir, ".gitignore");
1270
+ const gitmodulesPath = join9(targetDir, ".gitmodules");
946
1271
  let isTracked = false;
947
- const lsResult = spawnSync2("git", ["ls-files", "--stage", runtimePath], {
1272
+ const lsResult = spawnSync2("git", ["ls-files", "--stage", "--", runtimePath], {
948
1273
  cwd: targetDir,
949
1274
  encoding: "utf8"
950
1275
  });
1276
+ if (lsResult.status !== 0) {
1277
+ return {
1278
+ success: false,
1279
+ message: `\u2717 Failed to inspect runtime index at ${runtimePath}: ${lsResult.stderr.trim() || `exit ${lsResult.status}`}`
1280
+ };
1281
+ }
951
1282
  if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
952
1283
  isTracked = true;
953
1284
  }
1285
+ let hasStaleMapping = false;
1286
+ let gitmodulesContent = "";
1287
+ if (existsSync5(gitmodulesPath)) {
1288
+ gitmodulesContent = readFileSync4(gitmodulesPath, "utf8");
1289
+ const sections = gitmodulesContent.match(/^\[submodule "[^"\n]+"\][\s\S]*?(?=^\[submodule "|(?![\s\S]))/gm) ?? [];
1290
+ hasStaleMapping = sections.some((section) => sectionHasPath(section, runtimePath));
1291
+ }
954
1292
  let isIgnored = false;
955
- const fullGitignorePath = join6(targetDir, gitignorePath);
956
- if (existsSync4(fullGitignorePath)) {
957
- const content = readFileSync2(fullGitignorePath, "utf8");
1293
+ const fullGitignorePath = join9(targetDir, gitignorePath);
1294
+ if (existsSync5(fullGitignorePath)) {
1295
+ const content = readFileSync4(fullGitignorePath, "utf8");
958
1296
  const lines = content.split(/\r?\n/).map((line) => line.trim());
959
1297
  isIgnored = lines.includes("runtime/") || lines.includes("runtime");
960
1298
  }
961
- if (isTracked || !isIgnored) {
1299
+ if (isTracked || hasStaleMapping || !isIgnored) {
962
1300
  modifiedAny = true;
963
1301
  if (isTracked) {
964
1302
  details.push(`untrack agents/hermes/${role}/runtime`);
965
1303
  if (!this.context.dryRun) {
966
- const rmResult = spawnSync2("git", ["rm", "--cached", "-r", runtimePath], {
1304
+ const rmResult = spawnSync2("git", ["rm", "--cached", "-r", "-f", "--", runtimePath], {
967
1305
  cwd: targetDir,
968
1306
  encoding: "utf8"
969
1307
  });
970
1308
  if (rmResult.status !== 0) {
971
1309
  return {
972
1310
  success: false,
973
- message: `Failed to untrack agents/hermes/${role}/runtime: ${rmResult.stderr}`
1311
+ message: `\u2717 Failed to untrack ${runtimePath}: ${rmResult.stderr.trim() || `exit ${rmResult.status}`}`
1312
+ };
1313
+ }
1314
+ const verifyResult = spawnSync2("git", ["ls-files", "--stage", "--", runtimePath], {
1315
+ cwd: targetDir,
1316
+ encoding: "utf8"
1317
+ });
1318
+ if (verifyResult.status !== 0 || verifyResult.stdout.trim()) {
1319
+ return {
1320
+ success: false,
1321
+ message: verifyResult.status !== 0 ? `\u2717 Failed to verify untracked runtime ${runtimePath}: ${verifyResult.stderr.trim() || `exit ${verifyResult.status}`}` : `\u2717 Runtime remains tracked after index-only removal: ${runtimePath}`
974
1322
  };
975
1323
  }
976
1324
  }
977
1325
  }
1326
+ if (hasStaleMapping) {
1327
+ details.push(`remove stale .gitmodules mapping for ${runtimePath}`);
1328
+ if (!this.context.dryRun) {
1329
+ const next = removeSubmodulePath(gitmodulesContent, runtimePath);
1330
+ writeFileSync4(gitmodulesPath, next ? `${next}
1331
+ ` : "", "utf8");
1332
+ }
1333
+ }
978
1334
  if (!isIgnored) {
979
1335
  details.push(`ignore runtime/ in agents/hermes/${role}/.gitignore`);
980
1336
  if (!this.context.dryRun) {
981
1337
  let content = "";
982
- if (existsSync4(fullGitignorePath)) {
983
- content = readFileSync2(fullGitignorePath, "utf8");
1338
+ if (existsSync5(fullGitignorePath)) {
1339
+ content = readFileSync4(fullGitignorePath, "utf8");
984
1340
  }
985
1341
  if (content && !content.endsWith("\n")) {
986
1342
  content += "\n";
987
1343
  }
988
1344
  content += "runtime/\n";
989
- writeFileSync3(fullGitignorePath, content, "utf8");
1345
+ writeFileSync4(fullGitignorePath, content, "utf8");
990
1346
  }
991
1347
  }
992
1348
  }
@@ -1008,8 +1364,8 @@ ${details.map((d) => ` - ${d}`).join("\n")}`
1008
1364
 
1009
1365
  // src/commands/hermes/WireTelegram.ts
1010
1366
  import { spawnSync as spawnSync3 } from "node:child_process";
1011
- import { join as join7 } from "node:path";
1012
- import { existsSync as existsSync5, unlinkSync } from "node:fs";
1367
+ import { join as join10 } from "node:path";
1368
+ import { existsSync as existsSync6, unlinkSync } from "node:fs";
1013
1369
  import * as p3 from "@clack/prompts";
1014
1370
  var WireTelegram = class extends Command {
1015
1371
  async invoke() {
@@ -1097,15 +1453,15 @@ var WireTelegram = class extends Command {
1097
1453
  if (p3.isCancel(allowedAnswer)) {
1098
1454
  return { success: false, message: "\u2717 Aborted; Telegram step deferred." };
1099
1455
  }
1100
- const script = join7(roleDir, ".scripts", "30-telegram.sh");
1101
- if (!existsSync5(script)) {
1456
+ const script = join10(roleDir, ".scripts", "30-telegram.sh");
1457
+ if (!existsSync6(script)) {
1102
1458
  return {
1103
1459
  success: false,
1104
1460
  message: `\u2717 ${script} not found. Did copier finish? Re-run with --skip-runtime-repo=0 if you skipped it.`
1105
1461
  };
1106
1462
  }
1107
- const marker = join7(roleDir, ".scripts", ".done-30-telegram");
1108
- if (existsSync5(marker)) unlinkSync(marker);
1463
+ const marker = join10(roleDir, ".scripts", ".done-30-telegram");
1464
+ if (existsSync6(marker)) unlinkSync(marker);
1109
1465
  const spinner4 = p3.spinner();
1110
1466
  spinner4.start("Verifying token + wiring profile");
1111
1467
  const result = spawnSync3("bash", [script], {
@@ -1132,8 +1488,8 @@ function cap(s) {
1132
1488
 
1133
1489
  // src/commands/hermes/WireEmail.ts
1134
1490
  import { spawnSync as spawnSync4 } from "node:child_process";
1135
- import { join as join8 } from "node:path";
1136
- import { existsSync as existsSync6, unlinkSync as unlinkSync2 } from "node:fs";
1491
+ import { join as join11 } from "node:path";
1492
+ import { existsSync as existsSync7, unlinkSync as unlinkSync2 } from "node:fs";
1137
1493
  import * as p4 from "@clack/prompts";
1138
1494
  var WireEmail = class extends Command {
1139
1495
  async invoke() {
@@ -1148,8 +1504,8 @@ var WireEmail = class extends Command {
1148
1504
  if (!targetRepo || !role || !roleDir) {
1149
1505
  return { success: false, message: "Cannot wire email: missing target_repo/role/roleDir" };
1150
1506
  }
1151
- const script = join8(roleDir, ".scripts", "50-email.sh");
1152
- if (!existsSync6(script)) {
1507
+ const script = join11(roleDir, ".scripts", "50-email.sh");
1508
+ if (!existsSync7(script)) {
1153
1509
  return { success: false, message: `\u2717 ${script} not found` };
1154
1510
  }
1155
1511
  let token = process.env.CF_EMAIL_ROUTING_TOKEN;
@@ -1211,8 +1567,8 @@ var WireEmail = class extends Command {
1211
1567
  }
1212
1568
  }
1213
1569
  }
1214
- const marker = join8(roleDir, ".scripts", ".done-50-email");
1215
- if (existsSync6(marker)) unlinkSync2(marker);
1570
+ const marker = join11(roleDir, ".scripts", ".done-50-email");
1571
+ if (existsSync7(marker)) unlinkSync2(marker);
1216
1572
  const spinner4 = p4.spinner();
1217
1573
  spinner4.start("Creating Cloudflare Email Routing rule");
1218
1574
  const result = spawnSync4("bash", [script], {
@@ -1240,7 +1596,6 @@ var PrintHermesSummary = class extends Command {
1240
1596
  const botHandle = `${targetRepo?.toLowerCase().replace(/-/g, "_")}_${role?.toLowerCase()}_bot`;
1241
1597
  const email = `${targetRepo}-${role}@delo.sh`;
1242
1598
  const gw = `hermes-${agentId}-gateway.service`;
1243
- const csm = `hermes-${agentId}-consumer.service`;
1244
1599
  const hb = `hermes-${agentId}-heartbeat.timer`;
1245
1600
  const lines = [];
1246
1601
  lines.push(`agent_id ${agentId}`);
@@ -1250,13 +1605,13 @@ var PrintHermesSummary = class extends Command {
1250
1605
  if (!skipEmail) lines.push(`email ${email}`);
1251
1606
  lines.push("");
1252
1607
  lines.push("Start daemons:");
1253
- lines.push(` systemctl --user start ${csm}`);
1254
1608
  lines.push(` systemctl --user start ${hb}`);
1255
1609
  if (!skipTelegram) {
1256
1610
  lines.push(` systemctl --user start ${gw}`);
1257
1611
  } else {
1258
1612
  lines.push(` # gateway needs Telegram wired first (re-run with --skip-telegram=0)`);
1259
1613
  }
1614
+ lines.push(" # Bloodbank commands arrive through the fleet-shared Hermes gateway");
1260
1615
  lines.push("");
1261
1616
  lines.push("Talk locally:");
1262
1617
  lines.push(` ${ctx.roleDir}/hermes chat "status"`);
@@ -1299,15 +1654,15 @@ var HermesAgentRecipe = class extends Recipe {
1299
1654
 
1300
1655
  // src/commands/AgentHooksCommands.ts
1301
1656
  import { homedir as homedir4 } from "node:os";
1302
- import { join as join10, dirname as dirname5 } from "node:path";
1303
- import { existsSync as existsSync8, cpSync, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "node:fs";
1657
+ import { join as join13, dirname as dirname5 } from "node:path";
1658
+ import { existsSync as existsSync9, cpSync, mkdirSync as mkdirSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "node:fs";
1304
1659
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1305
1660
 
1306
1661
  // src/project/index.ts
1307
1662
  import { spawnSync as spawnSync5 } from "node:child_process";
1308
- import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync3, renameSync, statSync, writeFileSync as writeFileSync4 } from "node:fs";
1663
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync5, renameSync, statSync, writeFileSync as writeFileSync5 } from "node:fs";
1309
1664
  import { homedir as homedir3 } from "node:os";
1310
- import { basename as basename2, delimiter, dirname as dirname4, join as join9, resolve } from "node:path";
1665
+ import { basename as basename3, delimiter, dirname as dirname4, join as join12, resolve as resolve2 } from "node:path";
1311
1666
  import YAML2 from "yaml";
1312
1667
 
1313
1668
  // src/project/RegistryStore.ts
@@ -1542,18 +1897,18 @@ var PROJECT_SOURCE_SKILL_ROOTS_ENV = "PJ_SOURCE_SKILL_ROOTS";
1542
1897
  var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
1543
1898
  var DEFAULT_SOURCE_SKILL_ROOTS = [
1544
1899
  "/home/delorenj/code/skillex/all-skills",
1545
- join9(homedir3(), ".agents", "skills"),
1546
- join9(homedir3(), ".codex", "skills")
1900
+ join12(homedir3(), ".agents", "skills"),
1901
+ join12(homedir3(), ".codex", "skills")
1547
1902
  ];
1548
1903
  function projectRegistryPath(env2 = process.env) {
1549
- return expandHome(env2[PROJECT_REGISTRY_ENV] || join9(homedir3(), ".config", "pjangler", "projects.yaml"));
1904
+ return expandHome(env2[PROJECT_REGISTRY_ENV] || join12(homedir3(), ".config", "pjangler", "projects.yaml"));
1550
1905
  }
1551
1906
  function emptyProjectRegistry() {
1552
1907
  return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
1553
1908
  }
1554
1909
  function loadProjectRegistry(path = projectRegistryPath()) {
1555
- if (!existsSync7(path)) return emptyProjectRegistry();
1556
- const raw = YAML2.parse(readFileSync3(path, "utf8"));
1910
+ if (!existsSync8(path)) return emptyProjectRegistry();
1911
+ const raw = YAML2.parse(readFileSync5(path, "utf8"));
1557
1912
  if (raw == null) return emptyProjectRegistry();
1558
1913
  if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
1559
1914
  const registry = raw;
@@ -1568,7 +1923,7 @@ function saveProjectRegistry(registry, path = projectRegistryPath()) {
1568
1923
  validateProjectRegistry(registry);
1569
1924
  mkdirSync4(dirname4(path), { recursive: true });
1570
1925
  const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
1571
- writeFileSync4(temp, YAML2.stringify(registry, { lineWidth: 0 }), "utf8");
1926
+ writeFileSync5(temp, YAML2.stringify(registry, { lineWidth: 0 }), "utf8");
1572
1927
  renameSync(temp, path);
1573
1928
  }
1574
1929
  function validateProjectRegistry(registry) {
@@ -1583,7 +1938,7 @@ function validateProjectRegistry(registry) {
1583
1938
  validateProjectRecord(project, slug);
1584
1939
  if (slugs.has(project.slug)) throw new Error(`Duplicate project slug: ${project.slug}`);
1585
1940
  slugs.add(project.slug);
1586
- const repoKey = resolve(project.repo_path);
1941
+ const repoKey = resolve2(project.repo_path);
1587
1942
  const existingRepoSlug = repoPaths.get(repoKey);
1588
1943
  if (existingRepoSlug && existingRepoSlug !== slug) {
1589
1944
  throw new Error(`Duplicate project repo_path: ${project.repo_path} used by ${existingRepoSlug} and ${slug}`);
@@ -1650,7 +2005,7 @@ function resolveAgentHooksLayer(input, env2 = process.env) {
1650
2005
  const override = env2.PJ_AGENT_HOOKS_LAYER;
1651
2006
  if (override === "0" || override === "false") return false;
1652
2007
  if (override === "1" || override === "true") return true;
1653
- return !existsSync7(join9(homedir3(), ".agents", "hooks"));
2008
+ return !existsSync8(join12(homedir3(), ".agents", "hooks"));
1654
2009
  }
1655
2010
  function jsonStable(value) {
1656
2011
  return JSON.stringify(value);
@@ -1663,14 +2018,14 @@ function projectRecordEquivalent(a, b) {
1663
2018
  }
1664
2019
  function defaultProjectTargetDir(name, cwd = process.cwd()) {
1665
2020
  const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
1666
- return resolve(dirname4(resolve(cwd)), compactName);
2021
+ return resolve2(dirname4(resolve2(cwd)), compactName);
1667
2022
  }
1668
2023
  function sourceSkillRoots(env2 = process.env) {
1669
2024
  const configuredRoots = (env2[PROJECT_SOURCE_SKILL_ROOTS_ENV] || "").split(delimiter).map((root) => root.trim()).filter(Boolean);
1670
2025
  const seen = /* @__PURE__ */ new Set();
1671
2026
  const roots = [];
1672
2027
  for (const root of [...DEFAULT_SOURCE_SKILL_ROOTS, ...configuredRoots]) {
1673
- const normalized = resolve(expandHome(root));
2028
+ const normalized = resolve2(expandHome(root));
1674
2029
  if (seen.has(normalized)) continue;
1675
2030
  seen.add(normalized);
1676
2031
  roots.push(normalized);
@@ -1680,13 +2035,13 @@ function sourceSkillRoots(env2 = process.env) {
1680
2035
  function resolveSourceSkillPath(sourceSkill, env2 = process.env) {
1681
2036
  if (!sourceSkill) return void 0;
1682
2037
  const expanded = expandHome(sourceSkill);
1683
- const direct = resolve(expanded);
1684
- if (existsSync7(direct)) return direct;
1685
- const name = basename2(sourceSkill);
2038
+ const direct = resolve2(expanded);
2039
+ if (existsSync8(direct)) return direct;
2040
+ const name = basename3(sourceSkill);
1686
2041
  const roots = sourceSkillRoots(env2);
1687
2042
  for (const root of roots) {
1688
- const candidate = join9(root, name);
1689
- if (existsSync7(candidate)) return candidate;
2043
+ const candidate = join12(root, name);
2044
+ if (existsSync8(candidate)) return candidate;
1690
2045
  }
1691
2046
  const searched = roots.length ? ` Searched roots: ${roots.join(", ")}.` : "";
1692
2047
  const hint = `${searched} Add project-specific roots with ${PROJECT_SOURCE_SKILL_ROOTS_ENV}.`;
@@ -1694,11 +2049,11 @@ function resolveSourceSkillPath(sourceSkill, env2 = process.env) {
1694
2049
  }
1695
2050
  function planProjectInit(input) {
1696
2051
  if (!input.name.trim()) throw new Error("Project name is required");
1697
- const registryPath2 = resolve(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
2052
+ const registryPath2 = resolve2(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
1698
2053
  const registry = loadProjectRegistry(registryPath2);
1699
2054
  const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
1700
2055
  const slug = input.projectSlug ?? slugifyProjectName(input.name);
1701
- const targetDir = resolve(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
2056
+ const targetDir = resolve2(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
1702
2057
  const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
1703
2058
  const existing = registry.projects[slug];
1704
2059
  const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
@@ -1741,7 +2096,7 @@ function planProjectInit(input) {
1741
2096
  updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
1742
2097
  };
1743
2098
  validateNoDuplicateProject(registry, project, overwrite);
1744
- const pjanglerRoot = resolve(input.pjanglerRoot ?? resolvePjanglerRoot());
2099
+ const pjanglerRoot = resolve2(input.pjanglerRoot ?? resolvePjanglerRoot());
1745
2100
  const manifest = projectManifestFromRegistryProject(project);
1746
2101
  const apply = input.apply ?? false;
1747
2102
  const live = input.live ?? false;
@@ -1767,7 +2122,7 @@ function planProjectInit(input) {
1767
2122
  }));
1768
2123
  }
1769
2124
  actions.push(
1770
- { kind: "project.write-manifest", path: join9(targetDir, ".project.json"), manifest },
2125
+ { kind: "project.write-manifest", path: join12(targetDir, ".project.json"), manifest },
1771
2126
  {
1772
2127
  kind: "ticket-provider.create-or-link",
1773
2128
  enabled: live,
@@ -1820,7 +2175,7 @@ async function executeProjectInitPlan(plan) {
1820
2175
  }
1821
2176
  if (result.status !== 0) {
1822
2177
  errors.push(`copier exited with status ${result.status ?? "unknown"}`);
1823
- if (existsSync7(action.targetDir)) changedFiles.push(action.targetDir);
2178
+ if (existsSync8(action.targetDir)) changedFiles.push(action.targetDir);
1824
2179
  break;
1825
2180
  }
1826
2181
  changedFiles.push(action.targetDir);
@@ -1828,9 +2183,9 @@ async function executeProjectInitPlan(plan) {
1828
2183
  mkdirSync4(dirname4(action.path), { recursive: true });
1829
2184
  const next = `${JSON.stringify(action.manifest, null, 2)}
1830
2185
  `;
1831
- const current = existsSync7(action.path) ? readFileSync3(action.path, "utf8") : void 0;
2186
+ const current = existsSync8(action.path) ? readFileSync5(action.path, "utf8") : void 0;
1832
2187
  if (current !== next) {
1833
- writeFileSync4(action.path, next, "utf8");
2188
+ writeFileSync5(action.path, next, "utf8");
1834
2189
  changedFiles.push(action.path);
1835
2190
  }
1836
2191
  } else if (action.kind === "registry.upsert") {
@@ -1933,16 +2288,16 @@ function doctorProjectRegistry(registryPath2 = projectRegistryPath(), slug) {
1933
2288
  const registry = loadProjectRegistry(registryPath2);
1934
2289
  const projects = slug ? [[slug, getProject(registry, slug)]] : Object.entries(registry.projects);
1935
2290
  for (const [projectSlug, project] of projects) {
1936
- if (!existsSync7(project.repo_path)) {
2291
+ if (!existsSync8(project.repo_path)) {
1937
2292
  issues.push({ level: "warn", slug: projectSlug, message: `repo_path does not exist: ${project.repo_path}` });
1938
2293
  } else if (!statSync(project.repo_path).isDirectory()) {
1939
2294
  issues.push({ level: "error", slug: projectSlug, message: `repo_path is not a directory: ${project.repo_path}` });
1940
2295
  } else {
1941
- const manifestPath = join9(project.repo_path, ".project.json");
1942
- if (!existsSync7(manifestPath)) issues.push({ level: "warn", slug: projectSlug, message: ".project.json is missing" });
2296
+ const manifestPath = join12(project.repo_path, ".project.json");
2297
+ if (!existsSync8(manifestPath)) issues.push({ level: "warn", slug: projectSlug, message: ".project.json is missing" });
1943
2298
  }
1944
2299
  for (const artifact of project.source_artifacts) {
1945
- if (artifact.path && !existsSync7(artifact.path)) {
2300
+ if (artifact.path && !existsSync8(artifact.path)) {
1946
2301
  issues.push({ level: "warn", slug: projectSlug, message: `source artifact missing: ${artifact.path}` });
1947
2302
  }
1948
2303
  }
@@ -1955,7 +2310,7 @@ function doctorProjectRegistry(registryPath2 = projectRegistryPath(), slug) {
1955
2310
  };
1956
2311
  }
1957
2312
  function buildCommonProjectCopierAction(input) {
1958
- const templateDir = join9(input.pjanglerRoot, "templates", "commonproject");
2313
+ const templateDir = join12(input.pjanglerRoot, "templates", "commonproject");
1959
2314
  const data = {
1960
2315
  project_name: input.projectName,
1961
2316
  project_description: input.projectDescription ?? "",
@@ -1984,19 +2339,19 @@ function buildCommonProjectCopierAction(input) {
1984
2339
  function resolvePjanglerRoot() {
1985
2340
  let dir = dirname4(new URL(import.meta.url).pathname);
1986
2341
  while (dir !== dirname4(dir)) {
1987
- if (existsSync7(join9(dir, "package.json")) && existsSync7(join9(dir, "templates", "commonproject", "copier.yml"))) return dir;
2342
+ if (existsSync8(join12(dir, "package.json")) && existsSync8(join12(dir, "templates", "commonproject", "copier.yml"))) return dir;
1988
2343
  dir = dirname4(dir);
1989
2344
  }
1990
- return resolve(process.cwd());
2345
+ return resolve2(process.cwd());
1991
2346
  }
1992
2347
  function validateNoDuplicateProject(registry, project, overwrite) {
1993
2348
  const existingSameSlug = registry.projects[project.slug];
1994
- if (existingSameSlug && !overwrite && resolve(existingSameSlug.repo_path) !== resolve(project.repo_path)) {
2349
+ if (existingSameSlug && !overwrite && resolve2(existingSameSlug.repo_path) !== resolve2(project.repo_path)) {
1995
2350
  throw new Error(`Project slug already exists in registry: ${project.slug}`);
1996
2351
  }
1997
2352
  for (const [slug, existing] of Object.entries(registry.projects)) {
1998
2353
  if (slug === project.slug) continue;
1999
- if (resolve(existing.repo_path) === resolve(project.repo_path)) {
2354
+ if (resolve2(existing.repo_path) === resolve2(project.repo_path)) {
2000
2355
  throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
2001
2356
  }
2002
2357
  if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
@@ -2016,7 +2371,7 @@ function validateProjectRecord(project, key) {
2016
2371
  }
2017
2372
  function expandHome(path) {
2018
2373
  if (path === "~") return homedir3();
2019
- if (path.startsWith("~/")) return join9(homedir3(), path.slice(2));
2374
+ if (path.startsWith("~/")) return join12(homedir3(), path.slice(2));
2020
2375
  return path;
2021
2376
  }
2022
2377
  function isRecord(value) {
@@ -2033,16 +2388,16 @@ function resolveTemplateRoot() {
2033
2388
  try {
2034
2389
  let dir = dirname5(fileURLToPath2(import.meta.url));
2035
2390
  for (let i = 0; i < 8; i++) {
2036
- candidates.push(join10(dir, "templates", "commonproject", "template"));
2391
+ candidates.push(join13(dir, "templates", "commonproject", "template"));
2037
2392
  const parent = dirname5(dir);
2038
2393
  if (parent === dir) break;
2039
2394
  dir = parent;
2040
2395
  }
2041
2396
  } catch {
2042
2397
  }
2043
- candidates.push(join10(homedir4(), "code", "pjangler", "templates", "commonproject", "template"));
2398
+ candidates.push(join13(homedir4(), "code", "pjangler", "templates", "commonproject", "template"));
2044
2399
  for (const c of candidates) {
2045
- if (existsSync8(join10(c, ".agents", "hooks", "hooks.master.json"))) return c;
2400
+ if (existsSync9(join13(c, ".agents", "hooks", "hooks.master.json"))) return c;
2046
2401
  }
2047
2402
  throw new Error(
2048
2403
  "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
@@ -2061,18 +2416,17 @@ var CopyAgentHooksTree = class extends Command {
2061
2416
  }
2062
2417
  const items = [
2063
2418
  { rel: ".agents/hooks", dir: true },
2419
+ { rel: ".agents/skills.json", dir: false },
2064
2420
  { rel: ".agents/local.example.json", dir: false },
2065
- { rel: ".mise/scripts/link-project-skills-to-clis.sh", dir: false },
2066
- { rel: ".mise/scripts/unlink-project-skills-from-clis.sh", dir: false },
2067
2421
  { rel: ".mise/scripts/hindsight-setup.sh", dir: false }
2068
2422
  ];
2069
2423
  const created = [];
2070
2424
  const skipped = [];
2071
2425
  for (const { rel, dir } of items) {
2072
- const src = join10(templateRoot, rel);
2073
- const dest = join10(this.context.targetDir, rel);
2074
- if (!existsSync8(src)) continue;
2075
- if (existsSync8(dest) && !this.context.force) {
2426
+ const src = join13(templateRoot, rel);
2427
+ const dest = join13(this.context.targetDir, rel);
2428
+ if (!existsSync9(src)) continue;
2429
+ if (existsSync9(dest) && !this.context.force) {
2076
2430
  skipped.push(rel);
2077
2431
  continue;
2078
2432
  }
@@ -2098,25 +2452,21 @@ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
2098
2452
  if (!resolveAgentHooksLayer()) {
2099
2453
  return { success: true, message: this.formatMessage(AGENT_HOOKS_SKIP_MESSAGE) };
2100
2454
  }
2101
- const misePath = join10(this.context.targetDir, "mise.toml");
2102
- if (!existsSync8(misePath)) {
2455
+ const misePath = join13(this.context.targetDir, "mise.toml");
2456
+ if (!existsSync9(misePath)) {
2103
2457
  return {
2104
2458
  success: false,
2105
2459
  message: "\u26A0\uFE0F No mise.toml found \u2014 run `pjangler init mise` first, then re-run."
2106
2460
  };
2107
2461
  }
2108
- let content = readFileSync4(misePath, "utf8");
2462
+ let content = readFileSync6(misePath, "utf8");
2109
2463
  if (content.includes(_WireMiseAgentHooks.MARKER)) {
2110
2464
  return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for agent-hooks") };
2111
2465
  }
2112
2466
  const cr = _WireMiseAgentHooks.CR;
2113
- const enterAdds = [
2114
- ` "${cr}/.mise/scripts/link-project-skills-to-clis.sh",`,
2115
- ` "${cr}/.agents/hooks/sync.py --install --quiet",`
2116
- ].join("\n");
2467
+ const enterAdds = [` "sync-skills.py --scope project",`, ` "${cr}/.agents/hooks/sync.py --install --quiet",`].join("\n");
2117
2468
  const leaveBlock = [
2118
2469
  "leave = [",
2119
- ` "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",`,
2120
2470
  ` "${cr}/.agents/hooks/sync.py --uninstall --quiet",`,
2121
2471
  "]"
2122
2472
  ].join("\n");
@@ -2124,16 +2474,15 @@ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
2124
2474
  const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
2125
2475
  if (enterRe.test(content)) {
2126
2476
  content = content.replace(enterRe, (_m, head, close) => {
2127
- const sep = /[,[]\s*$/.test(head) ? "" : ",";
2128
- return `${head}${sep}
2477
+ const sep2 = /[,[]\s*$/.test(head) ? "" : ",";
2478
+ return `${head}${sep2}
2129
2479
  ${enterAdds}${close}`;
2130
2480
  });
2131
2481
  const leaveRe = /(leave\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
2132
2482
  if (leaveRe.test(content)) {
2133
2483
  content = content.replace(leaveRe, (_m, head, close) => {
2134
- const sep = /[,[]\s*$/.test(head) ? "" : ",";
2135
- return `${head}${sep}
2136
- "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",
2484
+ const sep2 = /[,[]\s*$/.test(head) ? "" : ",";
2485
+ return `${head}${sep2}
2137
2486
  "${cr}/.agents/hooks/sync.py --uninstall --quiet",${close}`;
2138
2487
  });
2139
2488
  } else {
@@ -2146,6 +2495,14 @@ ${leaveBlock}`);
2146
2495
  "",
2147
2496
  _WireMiseAgentHooks.MARKER + " (generated \u2014 see .agents/hooks/README.md)",
2148
2497
  "[[watch_files]]",
2498
+ 'patterns = [".agents/skills.json"]',
2499
+ 'task = "skills-sync"',
2500
+ "",
2501
+ "[tasks.skills-sync]",
2502
+ 'description = "Sync skills from manifest to local CLI dirs"',
2503
+ 'run = "sync-skills.py --scope project"',
2504
+ "",
2505
+ "[[watch_files]]",
2149
2506
  'patterns = [".agents/hooks/hooks.master.json"]',
2150
2507
  'task = "hooks-sync"',
2151
2508
  "",
@@ -2161,18 +2518,6 @@ ${leaveBlock}`);
2161
2518
  'description = "Remove per-user agent-hook injections (codex/kimi/hermes)"',
2162
2519
  `run = "${cr}/.agents/hooks/sync.py --uninstall"`,
2163
2520
  "",
2164
- "[tasks.link-project-skills-to-clis]",
2165
- 'description = "Fan .agents/skills out to each agent CLI (honors local.json)"',
2166
- `run = "${cr}/.mise/scripts/link-project-skills-to-clis.sh"`,
2167
- "",
2168
- "[tasks.unlink-project-skills-from-clis]",
2169
- 'description = "Remove project skill symlinks from shared per-CLI dirs"',
2170
- `run = "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh"`,
2171
- "",
2172
- "[tasks.skills-relink]",
2173
- 'description = "Re-fan the project skill set to all CLIs"',
2174
- `run = "${cr}/.mise/scripts/link-project-skills-to-clis.sh"`,
2175
- "",
2176
2521
  "[tasks.hindsight-setup]",
2177
2522
  `description = "Provision this dev's shared project Hindsight key from 1Password into .env"`,
2178
2523
  `run = "${cr}/.mise/scripts/hindsight-setup.sh"`,
@@ -2181,7 +2526,7 @@ ${leaveBlock}`);
2181
2526
  ""
2182
2527
  ].join("\n");
2183
2528
  content = content.replace(/\n*$/, "\n") + appended;
2184
- if (!this.context.dryRun) writeFileSync5(misePath, content);
2529
+ if (!this.context.dryRun) writeFileSync6(misePath, content);
2185
2530
  if (wiredHooks) {
2186
2531
  return { success: true, message: this.formatMessage("\u2705 Wired mise.toml ([hooks] enter/leave + tasks)") };
2187
2532
  }
@@ -2190,8 +2535,8 @@ ${leaveBlock}`);
2190
2535
  message: this.formatMessage(
2191
2536
  `\u2705 Added agent-hooks tasks to mise.toml.
2192
2537
  \u26A0\uFE0F Could not find a [hooks].enter array to extend \u2014 add these to your [hooks] block manually:
2193
- enter += "${cr}/.mise/scripts/link-project-skills-to-clis.sh", "${cr}/.agents/hooks/sync.py --install --quiet"
2194
- leave += "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh", "${cr}/.agents/hooks/sync.py --uninstall --quiet"`
2538
+ enter += "sync-skills.py --scope project", "${cr}/.agents/hooks/sync.py --install --quiet"
2539
+ leave += "${cr}/.agents/hooks/sync.py --uninstall --quiet"`
2195
2540
  )
2196
2541
  };
2197
2542
  }
@@ -2206,10 +2551,25 @@ var AgentHooksRecipe = class extends Recipe {
2206
2551
  printNextSteps() {
2207
2552
  console.log("\u{1FA9D} Agent-hooks layer installed!");
2208
2553
  console.log(" Next steps:");
2209
- console.log(" 1. mise run hooks-sync # generate .claude/settings.json + inject codex/kimi/hermes");
2210
- console.log(" 2. git add .claude/settings.json .agents/hooks && commit (codex/kimi/hermes are per-dev)");
2211
- console.log(" 3. mise run hindsight-setup # set HINDSIGHT_OP_KEY_REF to your 1Password item first");
2212
- console.log(` 4. If you run a global agent system: echo '{"skills":{"defer_to_global":true}}' > .agents/local.json`);
2554
+ console.log(" 1. mise run skills-sync # sync .agents/skills.json into local CLI dirs");
2555
+ console.log(" 2. mise run hooks-sync # generate .claude/settings.json + inject codex/kimi/hermes");
2556
+ console.log(" 3. git add .claude/settings.json .agents/hooks .agents/skills.json && commit (codex/kimi/hermes are per-dev)");
2557
+ console.log(" 4. mise run hindsight-setup # set HINDSIGHT_OP_KEY_REF to your 1Password item first");
2558
+ console.log(" 5. Optional per-dev hook opt-out: copy .agents/local.example.json -> .agents/local.json");
2559
+ }
2560
+ };
2561
+
2562
+ // src/recipes/MiseOpInjectRecipe.ts
2563
+ var MiseOpInjectRecipe = class extends Recipe {
2564
+ constructor(context) {
2565
+ super(context);
2566
+ this.addIngredient(WireMiseOpInject);
2567
+ }
2568
+ printNextSteps() {
2569
+ console.log("\u{1F389} Wired up .env.op 1Password resolution via mise!");
2570
+ console.log(" Next steps:");
2571
+ console.log(" 1. Create .env.op with your op:// secret references");
2572
+ console.log(" 2. Run `mise run secrets-inject` or simply cd out and back in to trigger the hook");
2213
2573
  }
2214
2574
  };
2215
2575
 
@@ -2252,6 +2612,12 @@ var RECIPE_REGISTRY = {
2252
2612
  description: "Retrofit the project-scoped agent-hooks + skill fan-out layer (Claude/Codex/Kimi/Hermes hooks via mise enter/leave)",
2253
2613
  class: AgentHooksRecipe,
2254
2614
  commands: ["CopyAgentHooksTree", "WireMiseAgentHooks"]
2615
+ },
2616
+ "mise-op-inject": {
2617
+ name: "mise-op-inject",
2618
+ description: "Wire up op-inject script to mise.toml for 1Password secret resolution",
2619
+ class: MiseOpInjectRecipe,
2620
+ commands: ["WireMiseOpInject"]
2255
2621
  }
2256
2622
  };
2257
2623
  var COMMAND_REGISTRY = {
@@ -2320,6 +2686,12 @@ var COMMAND_REGISTRY = {
2320
2686
  description: "Create .env.example file",
2321
2687
  group: "environment",
2322
2688
  class: AddDotenv
2689
+ },
2690
+ WireMiseOpInject: {
2691
+ name: "WireMiseOpInject",
2692
+ description: "Wire up op-inject script to mise.toml for 1Password secret resolution",
2693
+ group: "mise",
2694
+ class: WireMiseOpInject
2323
2695
  }
2324
2696
  };
2325
2697
  function getRecipeNames() {
@@ -2354,29 +2726,61 @@ function createRecipe(name, context) {
2354
2726
  import { cancel as cancel2, multiselect, text as text2, isCancel as isCancel5 } from "@clack/prompts";
2355
2727
 
2356
2728
  // src/parity/index.ts
2357
- import { existsSync as existsSync9, lstatSync, mkdirSync as mkdirSync6, readFileSync as readFileSync5, readlinkSync, readdirSync as readdirSync2, renameSync as renameSync2, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync6, chmodSync as chmodSync2, copyFileSync } from "node:fs";
2358
- import { basename as basename3, dirname as dirname6, join as join11, relative, resolve as resolve2 } from "node:path";
2359
- import { fileURLToPath as fileURLToPath3 } from "node:url";
2729
+ import { existsSync as existsSync10, lstatSync as lstatSync2, mkdirSync as mkdirSync6, mkdtempSync, readFileSync as readFileSync7, readlinkSync, readdirSync as readdirSync3, realpathSync, renameSync as renameSync2, rmdirSync, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync7, chmodSync as chmodSync4, copyFileSync, rmSync } from "node:fs";
2730
+ import { basename as basename4, dirname as dirname6, join as join14, relative as relative2, resolve as resolve3 } from "node:path";
2731
+ import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
2360
2732
  import { homedir as homedir5 } from "node:os";
2361
2733
  import { spawnSync as spawnSync6 } from "node:child_process";
2362
2734
  import YAML3 from "yaml";
2363
2735
  var LINK_AGENTFILES_SCRIPT = "'{{config_root}}/.mise/scripts/link-agentfiles.sh'";
2364
2736
  var OP_INJECT_SCRIPT = "op inject -i .env.op > .env";
2737
+ var PROVISION_BMAD_SKILLS_SCRIPT = "python3 '{{config_root}}/.mise/scripts/provision-bmad-skills.py'";
2738
+ var SYNC_SKILLS_SCRIPT = "python3 '{{config_root}}/.mise/scripts/sync-skills.py' --scope project";
2365
2739
  var CODEGRAPH_SCRIPT = "[ -f '{{config_root}}/.mise/scripts/codegraph.sh' ] && '{{config_root}}/.mise/scripts/codegraph.sh' || true";
2740
+ var SKILLS_REGISTRY_URL = "https://github.com/delorenj/skillex.git";
2741
+ var PROJECT_CLI_SKILL_DIRS = [
2742
+ ".gemini/skills",
2743
+ ".codex/skills",
2744
+ ".kimi/skills",
2745
+ ".augment/skills",
2746
+ ".config/opencode/skills",
2747
+ ".hermes/skills",
2748
+ ".claude/skills",
2749
+ ".openclaw/skills"
2750
+ ];
2751
+ var CANONICAL_CLAUDE_SKILLS_ALIAS = "../.agents/skills";
2366
2752
  var HOOKS_COMMENT_HEADER = `# This block will handle the linking of
2367
2753
  # agent files to the main AGENTS.md file.
2368
2754
  #
2369
2755
  # TODO: Ensure this works for all levels of nesting.
2370
2756
  # i.e. All linked agent files MUST be siblings at
2371
2757
  # any given level of nesting.`;
2372
- var LINK_AGENTFILES_HOOK_ENTRIES = [LINK_AGENTFILES_SCRIPT, OP_INJECT_SCRIPT];
2758
+ var LINK_AGENTFILES_HOOK_ENTRIES = [
2759
+ LINK_AGENTFILES_SCRIPT,
2760
+ OP_INJECT_SCRIPT,
2761
+ PROVISION_BMAD_SKILLS_SCRIPT,
2762
+ SYNC_SKILLS_SCRIPT
2763
+ ];
2373
2764
  var LINK_AGENTFILES_WATCH_TASK_BLOCK = `[[watch_files]]
2374
2765
  patterns = ["AGENTS.md"]
2375
2766
  task = "link-agentfiles"
2376
2767
 
2768
+ [[watch_files]]
2769
+ patterns = [".agents/skills.json"]
2770
+ task = "skills-sync"
2771
+
2377
2772
  [tasks.link-agentfiles]
2378
2773
  description = "Symlink all agent files to AGENTS.md"
2379
- run = "'{{config_root}}/.mise/scripts/link-agentfiles.sh'"`;
2774
+ run = "'{{config_root}}/.mise/scripts/link-agentfiles.sh'"
2775
+
2776
+ [tasks.skills-sync]
2777
+ description = "Sync skills from manifest to local CLI dirs"
2778
+ depends = ["skills-provision-bmad"]
2779
+ run = "python3 '{{config_root}}/.mise/scripts/sync-skills.py' --scope project"
2780
+
2781
+ [tasks.skills-provision-bmad]
2782
+ description = "Provision pinned BMAD skills from the Skillex pack"
2783
+ run = "python3 '{{config_root}}/.mise/scripts/provision-bmad-skills.py'"`;
2380
2784
  var VERSIONING_BLOCK = `# >>> mise-versioning >>> (managed block \u2014 do not edit by hand; re-run init to update)
2381
2785
  [tasks."version"]
2382
2786
  description = "Print the current version (vX.Y.Z)"
@@ -2406,7 +2810,7 @@ run = "'{{config_root}}/.mise/scripts/versioning.sh' sync"
2406
2810
  function resolvePjanglerRoot2() {
2407
2811
  let dir = dirname6(fileURLToPath3(import.meta.url));
2408
2812
  while (dir !== dirname6(dir)) {
2409
- if (existsSync9(join11(dir, "package.json")) && existsSync9(join11(dir, "templates", "commonproject", "copier.yml"))) {
2813
+ if (existsSync10(join14(dir, "package.json")) && existsSync10(join14(dir, "templates", "commonproject", "copier.yml"))) {
2410
2814
  return dir;
2411
2815
  }
2412
2816
  dir = dirname6(dir);
@@ -2417,17 +2821,17 @@ function normalizeNewlines(value) {
2417
2821
  return value.replace(/\r\n/g, "\n");
2418
2822
  }
2419
2823
  function readText(path) {
2420
- return normalizeNewlines(readFileSync5(path, "utf8"));
2824
+ return normalizeNewlines(readFileSync7(path, "utf8"));
2421
2825
  }
2422
2826
  function safeReadText(path) {
2423
- return existsSync9(path) ? readText(path) : null;
2827
+ return existsSync10(path) ? readText(path) : null;
2424
2828
  }
2425
2829
  function ensureParent(path) {
2426
2830
  mkdirSync6(dirname6(path), { recursive: true });
2427
2831
  }
2428
2832
  function writeText(path, content) {
2429
2833
  ensureParent(path);
2430
- writeFileSync6(path, content);
2834
+ writeFileSync7(path, content);
2431
2835
  }
2432
2836
  function tryParseJson(text3) {
2433
2837
  if (!text3) return null;
@@ -2444,7 +2848,7 @@ function titleCaseSlug(slug) {
2444
2848
  return slug.split(/[-_]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
2445
2849
  }
2446
2850
  function readSymlinkTarget(path) {
2447
- if (!existsSync9(path)) return null;
2851
+ if (!existsSync10(path)) return null;
2448
2852
  try {
2449
2853
  return readlinkSync(path);
2450
2854
  } catch {
@@ -2452,8 +2856,8 @@ function readSymlinkTarget(path) {
2452
2856
  }
2453
2857
  }
2454
2858
  function ensureSymlink(path, target, dryRun) {
2455
- if (existsSync9(path)) {
2456
- const stat = lstatSync(path);
2859
+ if (existsSync10(path)) {
2860
+ const stat = lstatSync2(path);
2457
2861
  if (stat.isSymbolicLink()) {
2458
2862
  const current = readSymlinkTarget(path);
2459
2863
  if (current === target) return { changed: false };
@@ -2463,18 +2867,18 @@ function ensureSymlink(path, target, dryRun) {
2463
2867
  }
2464
2868
  return { changed: true };
2465
2869
  }
2466
- return { changed: false, blocked: `${relative(process.cwd(), path) || path} exists and is not a symlink` };
2870
+ return { changed: false, blocked: `${relative2(process.cwd(), path) || path} exists and is not a symlink` };
2467
2871
  }
2468
2872
  if (!dryRun) symlinkSync(target, path);
2469
2873
  return { changed: true };
2470
2874
  }
2471
2875
  function bootstrapAgentsFile(repoRoot, dryRun) {
2472
- const agentsPath = join11(repoRoot, "AGENTS.md");
2473
- if (existsSync9(agentsPath)) return { changedFiles: [], details: [] };
2876
+ const agentsPath = join14(repoRoot, "AGENTS.md");
2877
+ if (existsSync10(agentsPath)) return { changedFiles: [], details: [] };
2474
2878
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2475
- const source = join11(repoRoot, file);
2476
- if (!existsSync9(source)) continue;
2477
- const stat = lstatSync(source);
2879
+ const source = join14(repoRoot, file);
2880
+ if (!existsSync10(source)) continue;
2881
+ const stat = lstatSync2(source);
2478
2882
  if (stat.isSymbolicLink()) continue;
2479
2883
  if (stat.isFile()) {
2480
2884
  if (!dryRun) renameSync2(source, agentsPath);
@@ -2482,9 +2886,9 @@ function bootstrapAgentsFile(repoRoot, dryRun) {
2482
2886
  }
2483
2887
  return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
2484
2888
  }
2485
- const readmePath = join11(repoRoot, "README.md");
2486
- if (existsSync9(readmePath)) {
2487
- const stat = lstatSync(readmePath);
2889
+ const readmePath = join14(repoRoot, "README.md");
2890
+ if (existsSync10(readmePath)) {
2891
+ const stat = lstatSync2(readmePath);
2488
2892
  if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
2489
2893
  if (!dryRun) copyFileSync(readmePath, agentsPath);
2490
2894
  return { changedFiles: [agentsPath], details: ["Copied README.md to AGENTS.md before wiring agent-file symlinks"] };
@@ -2522,12 +2926,12 @@ function yamlGet(text3, keyPath) {
2522
2926
  return "";
2523
2927
  }
2524
2928
  function discoverRoles(repoRoot) {
2525
- const rolesDir = join11(repoRoot, "agents", "hermes");
2526
- if (!existsSync9(rolesDir)) return [];
2527
- return readdirSync2(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
2528
- const roleDir = join11(rolesDir, entry.name);
2529
- const roleYamlPath = join11(roleDir, "role.yaml");
2530
- if (!existsSync9(roleYamlPath)) return null;
2929
+ const rolesDir = join14(repoRoot, "agents", "hermes");
2930
+ if (!existsSync10(rolesDir)) return [];
2931
+ return readdirSync3(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
2932
+ const roleDir = join14(rolesDir, entry.name);
2933
+ const roleYamlPath = join14(roleDir, "role.yaml");
2934
+ if (!existsSync10(roleYamlPath)) return null;
2531
2935
  const text3 = readText(roleYamlPath);
2532
2936
  const runtimeRepoRaw = yamlGet(text3, "runtime.github_repo");
2533
2937
  return {
@@ -2555,7 +2959,7 @@ function discoverRoles(repoRoot) {
2555
2959
  }).filter((value) => Boolean(value));
2556
2960
  }
2557
2961
  function registryPath(homeDir) {
2558
- return join11(homeDir, ".hermes", "agents-registry.yaml");
2962
+ return join14(homeDir, ".hermes", "agents-registry.yaml");
2559
2963
  }
2560
2964
  function systemctlUser(args) {
2561
2965
  const result = spawnSync6("systemctl", ["--user", ...args], { encoding: "utf8" });
@@ -2566,8 +2970,8 @@ function systemctlUser(args) {
2566
2970
  };
2567
2971
  }
2568
2972
  function templateScript(ctx, name) {
2569
- const source = join11(ctx.pjanglerRoot, ".mise", "scripts", name);
2570
- return existsSync9(source) ? readText(source) : void 0;
2973
+ const source = join14(ctx.pjanglerRoot, ".mise", "scripts", name);
2974
+ return existsSync10(source) ? readText(source) : void 0;
2571
2975
  }
2572
2976
  function templateVersioningScript(ctx) {
2573
2977
  return templateScript(ctx, "versioning.sh");
@@ -2579,8 +2983,8 @@ function resolveAgentHooksLayer2(ctx) {
2579
2983
  const override = process.env.PJ_AGENT_HOOKS_LAYER;
2580
2984
  if (override === "0" || override === "false") return false;
2581
2985
  if (override === "1" || override === "true") return true;
2582
- if (existsSync9(join11(ctx.repoRoot, ".agents", "hooks", "sync.py"))) return true;
2583
- return !existsSync9(join11(ctx.homeDir, ".agents", "hooks"));
2986
+ if (existsSync10(join14(ctx.repoRoot, ".agents", "hooks", "sync.py"))) return true;
2987
+ return !existsSync10(join14(ctx.homeDir, ".agents", "hooks"));
2584
2988
  }
2585
2989
  function evaluateMiseConditionals(template, agentHooksLayer) {
2586
2990
  const out = [];
@@ -2606,121 +3010,540 @@ function evaluateMiseConditionals(template, agentHooksLayer) {
2606
3010
  }
2607
3011
  function renderGeneratedProjectMiseToml(ctx, template) {
2608
3012
  const project = readProjectJson(ctx);
2609
- const projectName = String(project?.project_name ?? basename3(ctx.repoRoot) ?? "project");
3013
+ const projectName = String(project?.project_name ?? basename4(ctx.repoRoot) ?? "project");
2610
3014
  return evaluateMiseConditionals(template, resolveAgentHooksLayer2(ctx)).replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
2611
3015
  }
2612
3016
  function ensureMiseTomlFromTemplate(ctx, changedFiles) {
2613
- const targetPath = join11(ctx.repoRoot, "mise.toml");
2614
- if (existsSync9(targetPath)) return false;
2615
- const sourcePath = join11(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
2616
- if (!existsSync9(sourcePath)) return false;
3017
+ const targetPath = join14(ctx.repoRoot, "mise.toml");
3018
+ if (existsSync10(targetPath)) return false;
3019
+ const sourcePath = join14(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
3020
+ if (!existsSync10(sourcePath)) return false;
2617
3021
  changedFiles.push(targetPath);
2618
3022
  if (!ctx.dryRun) {
2619
3023
  writeText(targetPath, renderGeneratedProjectMiseToml(ctx, readText(sourcePath)));
2620
3024
  }
2621
3025
  return true;
2622
3026
  }
2623
- function templateVersionFilesConf(ctx, repoRoot) {
2624
- const packageJson = join11(repoRoot, "package.json");
2625
- return existsSync9(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";
3027
+ function templateCommonProjectText(ctx, rel) {
3028
+ const path = join14(ctx.pjanglerRoot, "templates", "commonproject", "template", rel);
3029
+ return existsSync10(path) ? readText(path) : void 0;
2626
3030
  }
2627
- function replaceOrAppendManagedBlock(text3, startMarker, block, beforePattern) {
2628
- if (startMarker.test(text3)) {
2629
- return text3.replace(/# >>> mise-versioning >>>[\s\S]*?# <<< mise-versioning <<</, block);
2630
- }
2631
- if (beforePattern) {
2632
- const match = text3.match(beforePattern);
2633
- if (match && typeof match.index === "number") {
2634
- return `${text3.slice(0, match.index).replace(/\s*$/, "\n\n")}${block}
2635
-
2636
- ${text3.slice(match.index)}`;
2637
- }
3031
+ function validateSkillName(name) {
3032
+ if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\") || basename4(name) !== name) {
3033
+ throw new Error(`Unsafe skill name: ${JSON.stringify(name)}`);
2638
3034
  }
2639
- return `${text3.replace(/\s*$/, "")}
2640
-
2641
- ${block}
2642
- `;
3035
+ return name;
2643
3036
  }
2644
- var BASE_MISE_PATH_ENTRIES = [".mise/scripts", "agents/hermes/pm"];
2645
- var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/hermes"];
2646
- function requiredMisePathEntries(ctx) {
2647
- const required = [...BASE_MISE_PATH_ENTRIES];
2648
- for (const candidate of CONDITIONAL_HERMES_PATHS) {
2649
- if (existsSync9(join11(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
3037
+ function lstatIfPresent(path) {
3038
+ try {
3039
+ return lstatSync2(path);
3040
+ } catch (error) {
3041
+ if (error.code === "ENOENT") return void 0;
3042
+ throw error;
2650
3043
  }
2651
- return required;
2652
3044
  }
2653
- function upsertMisePath(text3, required = BASE_MISE_PATH_ENTRIES) {
2654
- const render = (values) => `_.path = [${values.map((value) => JSON.stringify(value)).join(", ")}]`;
2655
- const envMatch = text3.match(/(^|\n)(\[env\][\s\S]*?)(?=\n\[[^\]]+\]|$)/);
2656
- if (!envMatch || typeof envMatch.index !== "number") {
2657
- return `[env]
2658
- ${render(required)}
2659
-
2660
- ${text3.replace(/^\s+/, "")}`;
2661
- }
2662
- const prefix = text3.slice(0, envMatch.index + envMatch[1].length);
2663
- const section = envMatch[2];
2664
- const suffix = text3.slice(envMatch.index + envMatch[1].length + section.length);
2665
- const pathLine = section.match(/^_\.path\s*=\s*\[([^\]]*)\]\s*$/m);
2666
- if (!pathLine) {
2667
- return `${prefix}${section.replace(/\n?$/, "\n")}${render(required)}${suffix}`;
3045
+ function isContainedBy(root, target) {
3046
+ const rel = relative2(root, target);
3047
+ return rel === "" || rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\");
3048
+ }
3049
+ function prepareSafeProjectSkillsDirs(ctx) {
3050
+ const projectRoot = realpathSync(ctx.repoRoot);
3051
+ const agentsDir = join14(projectRoot, ".agents");
3052
+ const skillsDir = join14(agentsDir, "skills");
3053
+ for (const path of [agentsDir, skillsDir]) {
3054
+ if (!isContainedBy(projectRoot, path)) throw new Error(`Project skills path escapes repository: ${path}`);
3055
+ const stat = lstatIfPresent(path);
3056
+ if (stat?.isSymbolicLink()) throw new Error(`Refusing symlinked project skills directory: ${path}`);
3057
+ if (stat && !stat.isDirectory()) throw new Error(`Project skills path is not a directory: ${path}`);
2668
3058
  }
2669
- const current = [...pathLine[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2670
- const merged = [...current];
2671
- for (const value of required) {
2672
- if (!merged.includes(value)) merged.push(value);
3059
+ if (!ctx.dryRun) {
3060
+ if (!existsSync10(agentsDir)) mkdirSync6(agentsDir, { recursive: false });
3061
+ if (!existsSync10(skillsDir)) mkdirSync6(skillsDir, { recursive: false });
3062
+ for (const path of [agentsDir, skillsDir]) {
3063
+ if (lstatSync2(path).isSymbolicLink() || !lstatSync2(path).isDirectory()) {
3064
+ throw new Error(`Unsafe project skills directory after creation: ${path}`);
3065
+ }
3066
+ if (!isContainedBy(projectRoot, realpathSync(path))) {
3067
+ throw new Error(`Resolved project skills directory escapes repository: ${path}`);
3068
+ }
3069
+ }
2673
3070
  }
2674
- const nextLine = render(merged);
2675
- if (pathLine[0] === nextLine) return text3;
2676
- return `${prefix}${section.replace(pathLine[0], nextLine)}${suffix}`;
3071
+ return { agentsDir, skillsDir };
2677
3072
  }
2678
- function removeTomlSection(text3, headerPattern, marker, options) {
2679
- const lines = text3.split("\n");
2680
- let start = -1;
2681
- let end = -1;
2682
- for (let i = 0; i < lines.length; i++) {
2683
- if (!headerPattern.test(lines[i])) continue;
2684
- if (marker) {
2685
- let hasMarker = false;
2686
- for (let j = i + 1; j < lines.length && !/^\[[^\]]+\]/.test(lines[j]); j++) {
2687
- if (marker.test(lines[j])) {
2688
- hasMarker = true;
2689
- break;
3073
+ function projectSkillTopologyIssues(repoRoot) {
3074
+ const issues = [];
3075
+ let projectRoot;
3076
+ try {
3077
+ projectRoot = realpathSync(repoRoot);
3078
+ } catch (error) {
3079
+ return [`Project root is not a readable real directory: ${error instanceof Error ? error.message : String(error)}`];
3080
+ }
3081
+ const managedSkills = join14(projectRoot, ".agents", "skills");
3082
+ for (const rel of PROJECT_CLI_SKILL_DIRS) {
3083
+ const cliDir = join14(projectRoot, rel);
3084
+ const parent = dirname6(cliDir);
3085
+ const parentStat = lstatIfPresent(parent);
3086
+ if (!parentStat) continue;
3087
+ if (parentStat.isSymbolicLink() || !parentStat.isDirectory()) {
3088
+ issues.push(`${rel} has an unsafe symlinked/non-directory parent`);
3089
+ continue;
3090
+ }
3091
+ if (!isContainedBy(projectRoot, realOrSelf(parent))) {
3092
+ issues.push(`${rel} parent resolves outside the project`);
3093
+ continue;
3094
+ }
3095
+ const stat = lstatIfPresent(cliDir);
3096
+ if (!stat) continue;
3097
+ if (stat.isSymbolicLink()) {
3098
+ let rawTarget = "";
3099
+ try {
3100
+ rawTarget = readlinkSync(cliDir);
3101
+ } catch {
3102
+ issues.push(`${rel} is an unreadable skills directory symlink`);
3103
+ continue;
3104
+ }
3105
+ if (rel !== ".claude/skills" || rawTarget !== CANONICAL_CLAUDE_SKILLS_ALIAS) {
3106
+ issues.push(`${rel} is an unsupported skills directory symlink`);
3107
+ continue;
3108
+ }
3109
+ const managedStat = lstatIfPresent(managedSkills);
3110
+ if (!managedStat || managedStat.isSymbolicLink() || !managedStat.isDirectory()) {
3111
+ issues.push(`${rel} canonical alias target .agents/skills is missing or unsafe`);
3112
+ continue;
3113
+ }
3114
+ try {
3115
+ if (realpathSync(cliDir) !== realpathSync(managedSkills)) {
3116
+ issues.push(`${rel} canonical alias resolves outside .agents/skills`);
2690
3117
  }
3118
+ } catch {
3119
+ issues.push(`${rel} canonical alias is broken`);
2691
3120
  }
2692
- if (!hasMarker) continue;
3121
+ continue;
2693
3122
  }
2694
- start = i;
2695
- for (let j = i + 1; j < lines.length; j++) {
2696
- if (/^\[[^\]]+\]/.test(lines[j])) {
2697
- end = j;
2698
- break;
2699
- }
3123
+ if (!stat.isDirectory()) {
3124
+ issues.push(`${rel} is not a directory`);
3125
+ continue;
2700
3126
  }
2701
- if (end === -1) end = lines.length;
2702
- break;
2703
- }
2704
- if (start === -1) return text3;
2705
- while (end > start + 1 && (lines[end - 1].trim() === "" || lines[end - 1].trim().startsWith("#"))) {
2706
- end--;
2707
- }
2708
- if (options?.includePrecedingComments) {
2709
- while (start > 0 && lines[start - 1].trim().startsWith("#")) {
2710
- start--;
3127
+ if (!isContainedBy(projectRoot, realOrSelf(cliDir))) {
3128
+ issues.push(`${rel} resolves outside the project`);
2711
3129
  }
2712
3130
  }
2713
- const result = lines.slice(0, start).concat(lines.slice(end)).join("\n");
2714
- return result.replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
3131
+ return issues;
2715
3132
  }
2716
- function insertTomlBlockBeforeVersioning(text3, block) {
2717
- const versioningIndex = text3.indexOf("# >>> mise-versioning >>>");
2718
- if (versioningIndex >= 0) {
2719
- return `${text3.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${block}
2720
-
2721
- ${text3.slice(versioningIndex)}`;
3133
+ function bmadPackRoot(ctx) {
3134
+ return resolve3(
3135
+ process.env.PJ_BMAD_PACK_ROOT?.trim() || join14(ctx.homeDir, "code", "skillex", "packs", "bmad", BMAD_PACK_VERSION)
3136
+ );
3137
+ }
3138
+ function canonicalBmadSkillEntries(ctx) {
3139
+ const root = bmadPackRoot(ctx);
3140
+ const trusted = validateTrustedBmadPack(root);
3141
+ return trusted.skillNames.map((name) => ({ name: validateSkillName(name), source: pathToFileURL(join14(root, name)).href }));
3142
+ }
3143
+ function skillManifestEntryName(entry) {
3144
+ if (typeof entry === "string") return entry;
3145
+ if (!entry || typeof entry !== "object") return void 0;
3146
+ const name = entry.name;
3147
+ return typeof name === "string" ? name : void 0;
3148
+ }
3149
+ function isPackManagedManifestEntry(entry, expectedNames, packRoot) {
3150
+ const name = skillManifestEntryName(entry);
3151
+ if (!name) return false;
3152
+ if (expectedNames.has(name)) return true;
3153
+ if (!entry || typeof entry !== "object") return false;
3154
+ const source = entry.source;
3155
+ if (typeof source !== "string" || !source.startsWith("file:")) return false;
3156
+ try {
3157
+ const sourcePath = resolve3(fileURLToPath3(source));
3158
+ return basename4(sourcePath) === name && isContainedBy(packRoot, sourcePath);
3159
+ } catch {
3160
+ return false;
2722
3161
  }
2723
- return `${text3.replace(/\s*$/, "")}
3162
+ }
3163
+ function canonicalSkillsManifest(ctx, current, packSkills = canonicalBmadSkillEntries(ctx)) {
3164
+ const existing = Array.isArray(current?.skills) ? current.skills : [];
3165
+ const expectedNames = new Set(packSkills.map((entry) => entry.name));
3166
+ const packRoot = bmadPackRoot(ctx);
3167
+ return `${JSON.stringify(
3168
+ {
3169
+ ...current ?? {},
3170
+ $schema: "https://raw.githubusercontent.com/skillex/schemas/main/skills.schema.json",
3171
+ inherit_global: true,
3172
+ registry: SKILLS_REGISTRY_URL,
3173
+ skills: [
3174
+ ...existing.filter((entry) => !isPackManagedManifestEntry(entry, expectedNames, packRoot)),
3175
+ ...packSkills
3176
+ ]
3177
+ },
3178
+ null,
3179
+ 2
3180
+ )}
3181
+ `;
3182
+ }
3183
+ function removeProjectEntry(path) {
3184
+ const stat = lstatIfPresent(path);
3185
+ if (!stat) return;
3186
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
3187
+ rmSync(path, { recursive: true, force: true });
3188
+ return;
3189
+ }
3190
+ try {
3191
+ unlinkSync3(path);
3192
+ } catch (error) {
3193
+ if (error.code !== "ENOENT") throw error;
3194
+ }
3195
+ }
3196
+ function normalizeExecutableTemplate(ctx, target, expected, changedFiles) {
3197
+ const stat = lstatIfPresent(target);
3198
+ if (stat && (!stat.isFile() || stat.isSymbolicLink())) {
3199
+ throw new Error(`Refusing non-regular managed executable target: ${target}`);
3200
+ }
3201
+ const contentChanged = !stat || safeReadText(target) !== expected;
3202
+ const modeChanged = !stat || (Number(stat.mode) & 73) === 0;
3203
+ if (!contentChanged && !modeChanged) return;
3204
+ if (!changedFiles.includes(target)) changedFiles.push(target);
3205
+ if (ctx.dryRun) return;
3206
+ if (contentChanged) {
3207
+ writeText(target, expected);
3208
+ }
3209
+ const beforeChmod = lstatIfPresent(target);
3210
+ if (!beforeChmod?.isFile() || beforeChmod.isSymbolicLink()) {
3211
+ throw new Error(`Refusing changed managed executable target: ${target}`);
3212
+ }
3213
+ chmodSync4(target, 493);
3214
+ }
3215
+ function atomicWriteBuffer(path, content, mode, temporary) {
3216
+ writeFileSync7(temporary, content, { flag: "wx" });
3217
+ chmodSync4(temporary, mode);
3218
+ renameSync2(temporary, path);
3219
+ }
3220
+ function provisionBmadSkills(ctx, preservedManifest, hooks = {}) {
3221
+ const packRoot = bmadPackRoot(ctx);
3222
+ let packSkills;
3223
+ try {
3224
+ packSkills = canonicalBmadSkillEntries(ctx);
3225
+ } catch (error) {
3226
+ return {
3227
+ ok: false,
3228
+ changedFiles: [],
3229
+ error: `BMAD Skillex pack ${BMAD_PACK_VERSION} is not trusted at ${packRoot}: ${error instanceof Error ? error.message : String(error)}`
3230
+ };
3231
+ }
3232
+ hooks.afterPreflight?.();
3233
+ const projectRoot = realpathSync(ctx.repoRoot);
3234
+ const agentsPath = join14(projectRoot, ".agents");
3235
+ const skillsPath = join14(agentsPath, "skills");
3236
+ const agentsExisted = Boolean(lstatIfPresent(agentsPath));
3237
+ const skillsExisted = Boolean(lstatIfPresent(skillsPath));
3238
+ let preflightDirs;
3239
+ try {
3240
+ preflightDirs = prepareSafeProjectSkillsDirs({ ...ctx, dryRun: true });
3241
+ } catch (error) {
3242
+ return { ok: false, changedFiles: [], error: error instanceof Error ? error.message : String(error) };
3243
+ }
3244
+ const manifestPath = join14(preflightDirs.agentsDir, "skills.json");
3245
+ const manifestStat = lstatIfPresent(manifestPath);
3246
+ if (manifestStat?.isSymbolicLink() || manifestStat && !manifestStat.isFile()) {
3247
+ return { ok: false, changedFiles: [], error: `Refusing unsafe skills manifest: ${manifestPath}` };
3248
+ }
3249
+ const manifestBytes = manifestStat ? readFileSync7(manifestPath) : null;
3250
+ const manifestMode = manifestStat ? Number(manifestStat.mode) & 511 : 420;
3251
+ let currentManifest = {};
3252
+ if (manifestBytes !== null) {
3253
+ try {
3254
+ const parsed = JSON.parse(manifestBytes.toString("utf8"));
3255
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("must contain a JSON object");
3256
+ currentManifest = parsed;
3257
+ if (currentManifest.skills !== void 0 && !Array.isArray(currentManifest.skills)) throw new Error("skills must be an array");
3258
+ } catch (error) {
3259
+ return { ok: false, changedFiles: [], error: `Invalid existing skills manifest ${manifestPath}: ${error instanceof Error ? error.message : String(error)}` };
3260
+ }
3261
+ }
3262
+ let safeDirs;
3263
+ try {
3264
+ safeDirs = prepareSafeProjectSkillsDirs(ctx);
3265
+ } catch (error) {
3266
+ return { ok: false, changedFiles: [], error: error instanceof Error ? error.message : String(error) };
3267
+ }
3268
+ const nextManifest = canonicalSkillsManifest(ctx, preservedManifest ?? currentManifest, packSkills);
3269
+ const skillsDir = safeDirs.skillsDir;
3270
+ const resolvedSkillsDir = ctx.dryRun && !existsSync10(skillsDir) ? skillsDir : realpathSync(skillsDir);
3271
+ const expected = new Map(packSkills.map((entry) => [entry.name, fileURLToPath3(entry.source)]));
3272
+ const expectedNames = new Set(expected.keys());
3273
+ const ownershipManifest = preservedManifest ?? currentManifest;
3274
+ const managedManifestNames = new Set(
3275
+ (Array.isArray(ownershipManifest.skills) ? ownershipManifest.skills : []).filter((entry) => isPackManagedManifestEntry(entry, expectedNames, packRoot)).map(skillManifestEntryName).filter((name) => Boolean(name))
3276
+ );
3277
+ const affected = /* @__PURE__ */ new Set();
3278
+ const staleManagedNames = /* @__PURE__ */ new Set();
3279
+ const originalCorrectLinks = /* @__PURE__ */ new Map();
3280
+ if (existsSync10(skillsDir)) {
3281
+ for (const name of readdirSync3(skillsDir)) {
3282
+ validateSkillName(name);
3283
+ if (dirname6(join14(resolvedSkillsDir, name)) !== resolvedSkillsDir) {
3284
+ return { ok: false, changedFiles: [], error: `BMAD skill path escapes project skills directory: ${name}` };
3285
+ }
3286
+ const entryPath = join14(skillsDir, name);
3287
+ let linkTargetsPack = false;
3288
+ try {
3289
+ linkTargetsPack = lstatSync2(entryPath).isSymbolicLink() && isContainedBy(packRoot, resolve3(dirname6(entryPath), readlinkSync(entryPath)));
3290
+ } catch {
3291
+ linkTargetsPack = false;
3292
+ }
3293
+ if (!expected.has(name) && !managedManifestNames.has(name) && !linkTargetsPack) continue;
3294
+ const target = expected.get(name);
3295
+ let correct = false;
3296
+ try {
3297
+ correct = Boolean(target) && lstatSync2(entryPath).isSymbolicLink() && resolve3(dirname6(entryPath), readlinkSync(entryPath)) === target;
3298
+ } catch {
3299
+ correct = false;
3300
+ }
3301
+ if (correct) originalCorrectLinks.set(name, readlinkSync(join14(skillsDir, name)));
3302
+ else {
3303
+ affected.add(name);
3304
+ if (!target) staleManagedNames.add(name);
3305
+ }
3306
+ }
3307
+ }
3308
+ for (const [name, target] of expected) {
3309
+ const link = join14(resolvedSkillsDir, validateSkillName(name));
3310
+ if (dirname6(link) !== resolvedSkillsDir) {
3311
+ return { ok: false, changedFiles: [], error: `BMAD skill path escapes project skills directory: ${name}` };
3312
+ }
3313
+ let correct = false;
3314
+ try {
3315
+ correct = lstatSync2(link).isSymbolicLink() && resolve3(dirname6(link), readlinkSync(link)) === target;
3316
+ } catch {
3317
+ correct = false;
3318
+ }
3319
+ if (!correct) affected.add(name);
3320
+ }
3321
+ const manifestChanged = manifestBytes?.toString("utf8") !== nextManifest;
3322
+ const changedFiles = [
3323
+ ...manifestChanged ? [manifestPath] : [],
3324
+ ...affected.size ? [skillsDir] : []
3325
+ ];
3326
+ if (ctx.dryRun || changedFiles.length === 0) {
3327
+ try {
3328
+ const postflight = validateTrustedBmadPack(packRoot);
3329
+ if (JSON.stringify(postflight.skillNames) !== JSON.stringify(packSkills.map((entry) => entry.name))) {
3330
+ throw new Error("BMAD pack inventory changed after preflight");
3331
+ }
3332
+ return { ok: true, changedFiles };
3333
+ } catch (error) {
3334
+ return { ok: false, changedFiles: [], error: error instanceof Error ? error.message : String(error) };
3335
+ }
3336
+ }
3337
+ const transaction = mkdtempSync(join14(safeDirs.agentsDir, ".bmad-transaction-"));
3338
+ const backup = join14(transaction, "entries");
3339
+ mkdirSync6(backup);
3340
+ const moved = [];
3341
+ const rollback = () => {
3342
+ const errors = [];
3343
+ try {
3344
+ for (const name of affected) {
3345
+ removeProjectEntry(join14(skillsDir, validateSkillName(name)));
3346
+ }
3347
+ for (const name of originalCorrectLinks.keys()) {
3348
+ removeProjectEntry(join14(skillsDir, validateSkillName(name)));
3349
+ }
3350
+ } catch (error) {
3351
+ errors.push(`remove applied projection: ${String(error)}`);
3352
+ }
3353
+ for (const name of [...moved].reverse()) {
3354
+ try {
3355
+ renameSync2(join14(backup, name), join14(skillsDir, name));
3356
+ } catch (error) {
3357
+ errors.push(`restore ${name}: ${String(error)}`);
3358
+ }
3359
+ }
3360
+ for (const [name, rawTarget] of originalCorrectLinks) {
3361
+ try {
3362
+ symlinkSync(rawTarget, join14(skillsDir, name), "dir");
3363
+ } catch (error) {
3364
+ errors.push(`restore ${name}: ${String(error)}`);
3365
+ }
3366
+ }
3367
+ try {
3368
+ if (manifestBytes === null) removeProjectEntry(manifestPath);
3369
+ else atomicWriteBuffer(manifestPath, manifestBytes, manifestMode, join14(transaction, "manifest.restore"));
3370
+ } catch (error) {
3371
+ errors.push(`restore manifest: ${String(error)}`);
3372
+ }
3373
+ rmSync(transaction, { recursive: true, force: true });
3374
+ try {
3375
+ if (!skillsExisted && existsSync10(skillsDir) && readdirSync3(skillsDir).length === 0) rmdirSync(skillsDir);
3376
+ if (!agentsExisted && existsSync10(safeDirs.agentsDir) && readdirSync3(safeDirs.agentsDir).length === 0) rmdirSync(safeDirs.agentsDir);
3377
+ } catch (error) {
3378
+ errors.push(`remove created directories: ${String(error)}`);
3379
+ }
3380
+ if (errors.length) throw new Error(`BMAD rollback was incomplete: ${errors.join("; ")}`);
3381
+ };
3382
+ try {
3383
+ for (const name of affected) {
3384
+ const entry = join14(skillsDir, name);
3385
+ if (lstatIfPresent(entry)) {
3386
+ renameSync2(entry, join14(backup, name));
3387
+ moved.push(name);
3388
+ }
3389
+ }
3390
+ let index = 0;
3391
+ for (const [name, target] of expected) {
3392
+ index += 1;
3393
+ const link = join14(skillsDir, name);
3394
+ let correct = false;
3395
+ try {
3396
+ correct = lstatSync2(link).isSymbolicLink() && resolve3(skillsDir, readlinkSync(link)) === target;
3397
+ } catch {
3398
+ correct = false;
3399
+ }
3400
+ if (correct) continue;
3401
+ if (hooks.createLink) hooks.createLink(target, link, index);
3402
+ else symlinkSync(target, link, "dir");
3403
+ }
3404
+ if (manifestChanged) {
3405
+ atomicWriteBuffer(manifestPath, Buffer.from(nextManifest), manifestMode, join14(transaction, "manifest.next"));
3406
+ }
3407
+ const postflight = validateTrustedBmadPack(packRoot);
3408
+ if (JSON.stringify(postflight.skillNames) !== JSON.stringify(packSkills.map((entry) => entry.name))) {
3409
+ throw new Error("BMAD pack inventory changed after preflight");
3410
+ }
3411
+ hooks.afterApply?.(manifestPath, skillsDir);
3412
+ for (const name of staleManagedNames) {
3413
+ if (lstatIfPresent(join14(skillsDir, name))) {
3414
+ throw new Error(`Applied BMAD projection retained stale managed entry: ${name}`);
3415
+ }
3416
+ }
3417
+ for (const [name, target] of expected) {
3418
+ const link = join14(skillsDir, name);
3419
+ let correct = false;
3420
+ try {
3421
+ correct = lstatSync2(link).isSymbolicLink() && resolve3(skillsDir, readlinkSync(link)) === target;
3422
+ } catch {
3423
+ correct = false;
3424
+ }
3425
+ if (!correct) throw new Error(`Applied BMAD projection link differs from plan: ${name}`);
3426
+ }
3427
+ const finalManifestStat = lstatIfPresent(manifestPath);
3428
+ if (!finalManifestStat || finalManifestStat.isSymbolicLink() || !finalManifestStat.isFile() || (Number(finalManifestStat.mode) & 511) !== manifestMode || readFileSync7(manifestPath).toString("utf8") !== nextManifest) {
3429
+ throw new Error("Applied BMAD skills manifest differs from planned bytes or mode");
3430
+ }
3431
+ const finalManifest = JSON.parse(readFileSync7(manifestPath, "utf8"));
3432
+ if (finalManifest.$schema !== "https://raw.githubusercontent.com/skillex/schemas/main/skills.schema.json" || finalManifest.inherit_global !== true || finalManifest.registry !== SKILLS_REGISTRY_URL || !Array.isArray(finalManifest.skills)) {
3433
+ throw new Error("Applied BMAD skills manifest schema differs from plan");
3434
+ }
3435
+ } catch (error) {
3436
+ try {
3437
+ rollback();
3438
+ } catch (rollbackError) {
3439
+ return { ok: false, changedFiles: [], error: `BMAD provisioning failed (${String(error)}); ${String(rollbackError)}` };
3440
+ }
3441
+ return { ok: false, changedFiles: [], error: error instanceof Error ? error.message : String(error) };
3442
+ }
3443
+ rmSync(transaction, { recursive: true });
3444
+ return { ok: true, changedFiles };
3445
+ }
3446
+ function templateVersionFilesConf(ctx, repoRoot) {
3447
+ const packageJson = join14(repoRoot, "package.json");
3448
+ return existsSync10(packageJson) ? "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\njson package.json\ngittag .\n" : "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\ngittag .\n";
3449
+ }
3450
+ function replaceOrAppendManagedBlock(text3, startMarker, block, beforePattern) {
3451
+ if (startMarker.test(text3)) {
3452
+ return text3.replace(/# >>> mise-versioning >>>[\s\S]*?# <<< mise-versioning <<</, block);
3453
+ }
3454
+ if (beforePattern) {
3455
+ const match = text3.match(beforePattern);
3456
+ if (match && typeof match.index === "number") {
3457
+ return `${text3.slice(0, match.index).replace(/\s*$/, "\n\n")}${block}
3458
+
3459
+ ${text3.slice(match.index)}`;
3460
+ }
3461
+ }
3462
+ return `${text3.replace(/\s*$/, "")}
3463
+
3464
+ ${block}
3465
+ `;
3466
+ }
3467
+ var BASE_MISE_PATH_ENTRIES = [".mise/scripts", "agents/hermes/pm"];
3468
+ var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/hermes"];
3469
+ function requiredMisePathEntries(ctx) {
3470
+ const required = [...BASE_MISE_PATH_ENTRIES];
3471
+ for (const candidate of CONDITIONAL_HERMES_PATHS) {
3472
+ if (existsSync10(join14(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
3473
+ }
3474
+ return required;
3475
+ }
3476
+ function upsertMisePath(text3, required = BASE_MISE_PATH_ENTRIES) {
3477
+ const render = (values) => `_.path = [${values.map((value) => JSON.stringify(value)).join(", ")}]`;
3478
+ const envMatch = text3.match(/(^|\n)(\[env\][\s\S]*?)(?=\n\[[^\]]+\]|$)/);
3479
+ if (!envMatch || typeof envMatch.index !== "number") {
3480
+ return `[env]
3481
+ ${render(required)}
3482
+
3483
+ ${text3.replace(/^\s+/, "")}`;
3484
+ }
3485
+ const prefix = text3.slice(0, envMatch.index + envMatch[1].length);
3486
+ const section = envMatch[2];
3487
+ const suffix = text3.slice(envMatch.index + envMatch[1].length + section.length);
3488
+ const pathLine = section.match(/^_\.path\s*=\s*\[([^\]]*)\]\s*$/m);
3489
+ if (!pathLine) {
3490
+ return `${prefix}${section.replace(/\n?$/, "\n")}${render(required)}${suffix}`;
3491
+ }
3492
+ const current = [...pathLine[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
3493
+ const merged = [...current];
3494
+ for (const value of required) {
3495
+ if (!merged.includes(value)) merged.push(value);
3496
+ }
3497
+ const nextLine = render(merged);
3498
+ if (pathLine[0] === nextLine) return text3;
3499
+ return `${prefix}${section.replace(pathLine[0], nextLine)}${suffix}`;
3500
+ }
3501
+ function removeTomlSection(text3, headerPattern, marker, options) {
3502
+ const lines = text3.split("\n");
3503
+ let start = -1;
3504
+ let end = -1;
3505
+ for (let i = 0; i < lines.length; i++) {
3506
+ if (!headerPattern.test(lines[i])) continue;
3507
+ if (marker) {
3508
+ let hasMarker = false;
3509
+ for (let j = i + 1; j < lines.length && !/^\[[^\]]+\]/.test(lines[j]); j++) {
3510
+ if (marker.test(lines[j])) {
3511
+ hasMarker = true;
3512
+ break;
3513
+ }
3514
+ }
3515
+ if (!hasMarker) continue;
3516
+ }
3517
+ start = i;
3518
+ for (let j = i + 1; j < lines.length; j++) {
3519
+ if (/^\[[^\]]+\]/.test(lines[j])) {
3520
+ end = j;
3521
+ break;
3522
+ }
3523
+ }
3524
+ if (end === -1) end = lines.length;
3525
+ break;
3526
+ }
3527
+ if (start === -1) return text3;
3528
+ while (end > start + 1 && (lines[end - 1].trim() === "" || lines[end - 1].trim().startsWith("#"))) {
3529
+ end--;
3530
+ }
3531
+ if (options?.includePrecedingComments) {
3532
+ while (start > 0 && lines[start - 1].trim().startsWith("#")) {
3533
+ start--;
3534
+ }
3535
+ }
3536
+ const result = lines.slice(0, start).concat(lines.slice(end)).join("\n");
3537
+ return result.replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
3538
+ }
3539
+ function insertTomlBlockBeforeVersioning(text3, block) {
3540
+ const versioningIndex = text3.indexOf("# >>> mise-versioning >>>");
3541
+ if (versioningIndex >= 0) {
3542
+ return `${text3.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${block}
3543
+
3544
+ ${text3.slice(versioningIndex)}`;
3545
+ }
3546
+ return `${text3.replace(/\s*$/, "")}
2724
3547
 
2725
3548
  ${block}
2726
3549
  `;
@@ -2747,6 +3570,12 @@ function stripTomlStringsAndComments(line) {
2747
3570
  function isManagedHookEntry(value) {
2748
3571
  const trimmed = value.trim();
2749
3572
  if (trimmed === OP_INJECT_SCRIPT) return true;
3573
+ if (trimmed === SYNC_SKILLS_SCRIPT) return true;
3574
+ if (trimmed === PROVISION_BMAD_SKILLS_SCRIPT) return true;
3575
+ if (/sync-skills(?:\.py)?["']?\s+--scope project/.test(trimmed)) return true;
3576
+ if (/provision-bmad-skills\.py/.test(trimmed)) return true;
3577
+ if (/link-project-skills-to-clis\.sh'?\s*$/.test(trimmed)) return true;
3578
+ if (/unlink-project-skills-from-clis\.sh'?\s*$/.test(trimmed)) return true;
2750
3579
  return /link-agentfiles\.sh'?\s*$/.test(trimmed);
2751
3580
  }
2752
3581
  function normalizeHookScript(script) {
@@ -2849,12 +3678,18 @@ function upsertLinkAgentfilesHooks(text3) {
2849
3678
  function upsertLinkAgentfilesBlock(text3, ctx) {
2850
3679
  const withPath = upsertMisePath(text3, requiredMisePathEntries(ctx));
2851
3680
  let cleaned = removeTomlSection(withPath, /^\[tasks\.link-agentfiles\]$/, /link-agentfiles/, { includePrecedingComments: false });
3681
+ cleaned = removeTomlSection(cleaned, /^\[tasks\.skills-sync\]$/, void 0, { includePrecedingComments: false });
3682
+ cleaned = removeTomlSection(cleaned, /^\[tasks\.skills-provision-bmad\]$/, void 0, { includePrecedingComments: false });
3683
+ cleaned = removeTomlSection(cleaned, /^\[tasks\.link-project-skills-to-clis\]$/, void 0, { includePrecedingComments: false });
3684
+ cleaned = removeTomlSection(cleaned, /^\[tasks\.unlink-project-skills-from-clis\]$/, void 0, { includePrecedingComments: false });
3685
+ cleaned = removeTomlSection(cleaned, /^\[tasks\.skills-relink\]$/, void 0, { includePrecedingComments: false });
2852
3686
  cleaned = removeTomlSection(cleaned, /^\[\[watch_files\]\]$/, /AGENTS\.md/, { includePrecedingComments: false });
3687
+ cleaned = removeTomlSection(cleaned, /^\[\[watch_files\]\]$/, /\.agents\/skills\.json/, { includePrecedingComments: false });
2853
3688
  cleaned = upsertLinkAgentfilesHooks(cleaned);
2854
3689
  return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
2855
3690
  }
2856
3691
  function readProjectJson(ctx) {
2857
- return tryParseJson(safeReadText(join11(ctx.repoRoot, ".project.json")));
3692
+ return tryParseJson(safeReadText(join14(ctx.repoRoot, ".project.json")));
2858
3693
  }
2859
3694
  function boolSetting(value, fallback) {
2860
3695
  if (typeof value === "boolean") return value;
@@ -2892,7 +3727,7 @@ function canonicalProjectJson(ctx) {
2892
3727
  role.agentId || `${slug}-${role.role}`,
2893
3728
  {
2894
3729
  role: role.role,
2895
- role_dir: relative(ctx.repoRoot, role.roleDir)
3730
+ role_dir: relative2(ctx.repoRoot, role.roleDir)
2896
3731
  }
2897
3732
  ])
2898
3733
  );
@@ -2929,12 +3764,12 @@ function canonicalProjectJson(ctx) {
2929
3764
  };
2930
3765
  }
2931
3766
  function projectJsonFinding(ctx) {
2932
- const projectPath = join11(ctx.repoRoot, ".project.json");
2933
- const planeJsonPath = join11(ctx.repoRoot, ".plane.json");
3767
+ const projectPath = join14(ctx.repoRoot, ".project.json");
3768
+ const planeJsonPath = join14(ctx.repoRoot, ".plane.json");
2934
3769
  const details = [];
2935
3770
  const data = readProjectJson(ctx);
2936
3771
  const roles = discoverRoles(ctx.repoRoot);
2937
- if (!existsSync9(projectPath)) {
3772
+ if (!existsSync10(projectPath)) {
2938
3773
  return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json missing", details: [], fixable: true };
2939
3774
  }
2940
3775
  if (!data) {
@@ -2952,8 +3787,8 @@ function projectJsonFinding(ctx) {
2952
3787
  continue;
2953
3788
  }
2954
3789
  if (agent.role !== role.role) details.push(`agents.${role.agentId}.role should be ${role.role}`);
2955
- if (agent.role_dir !== relative(ctx.repoRoot, role.roleDir)) {
2956
- details.push(`agents.${role.agentId}.role_dir should be ${relative(ctx.repoRoot, role.roleDir)}`);
3790
+ if (agent.role_dir !== relative2(ctx.repoRoot, role.roleDir)) {
3791
+ details.push(`agents.${role.agentId}.role_dir should be ${relative2(ctx.repoRoot, role.roleDir)}`);
2957
3792
  }
2958
3793
  }
2959
3794
  const ticketProvider = data.ticket_provider ?? {};
@@ -2969,7 +3804,7 @@ function projectJsonFinding(ctx) {
2969
3804
  for (const key of ["enabled", "grace_hours", "auto_review"]) {
2970
3805
  if (!(key in reconcile)) details.push(`automation.reconcile.${key} missing`);
2971
3806
  }
2972
- if (existsSync9(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
3807
+ if (existsSync10(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
2973
3808
  return {
2974
3809
  id: "sot.project-json",
2975
3810
  title: "Canonical .project.json",
@@ -2982,8 +3817,7 @@ function projectJsonFinding(ctx) {
2982
3817
  function renderSoul(role) {
2983
3818
  const telegram = role.botHandle ? `@${role.botHandle}` : "(unwired)";
2984
3819
  const tone = role.role === "pm" ? `Direct and brief. Decision-forward. No throat-clearing, no apologies, no "I'll help you with that" preambles.` : "Direct and brief.";
2985
- 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.`;
2986
- const runtimeOwner = role.runtimeOwner || "delorenj";
3820
+ const roleSpecific = role.role === "pm" ? `You are the project manager. You triage incoming work, create or refine tickets, and delegate implementation. You do not ship product code. A systemd heartbeat checks runtime health; when this repo opts into reconciliation (\`automation.reconcile.enabled\` in repo-root \`.project.json\`), the same heartbeat also runs your continuous board-reconciliation pass out-of-band (\`.scripts/sentinel.prompt.md\`, \`--source cron\`), kept separate from your interactive session memory.` : `You operate as the ${role.role} agent for this repo.`;
2987
3821
  return `# ${role.displayName || role.agentId}
2988
3822
 
2989
3823
  You are **${role.displayName || role.agentId}** \u2014 a Hermes agent provisioned to work inside the
@@ -3002,7 +3836,7 @@ You are **${role.displayName || role.agentId}** \u2014 a Hermes agent provisione
3002
3836
 
3003
3837
  ## Scope
3004
3838
 
3005
- You operate only within the working directory of \`${role.repo}\`. Your HERMES_HOME is the runtime submodule at \`./runtime/\` (repo \`${runtimeOwner}/${role.runtimeRepo}\`), which \`~/.hermes/profiles/${role.profileName || role.agentId}\` symlinks to (so \`--profile\` invocations resolve here too); Hermes loads its \`config.yaml\` directly. Secrets, SOUL, memories, skills, sessions, gateway state, and runtime files all live local to that runtime.
3839
+ You operate only within the working directory of \`${role.repo}\`. Your HERMES_HOME is the ignored local directory at \`./runtime/\`, which \`~/.hermes/profiles/${role.profileName || role.agentId}\` projects into (so \`--profile\` invocations resolve here too). Secrets, SOUL, memories, skills, sessions, gateway state, and runtime files stay local to that runtime and are never project gitlinks.
3006
3840
 
3007
3841
  ## Tone
3008
3842
 
@@ -3014,12 +3848,12 @@ ${roleSpecific}
3014
3848
 
3015
3849
  ## Memory hygiene
3016
3850
 
3017
- Your memory is the submodule at \`./runtime/memories/\`. Use durable memory deliberately and keep \`memories/MEMORY.md\` current.
3851
+ Your memory is stored locally at \`./runtime/memories/\`. Use durable memory deliberately and keep \`memories/MEMORY.md\` current.
3018
3852
  `;
3019
3853
  }
3020
3854
  function renderHermesWrapper(role) {
3021
3855
  return `#!/usr/bin/env bash
3022
- # Launcher for ${role.agentId}. Resolves HERMES_HOME to the runtime submodule.
3856
+ # Launcher for ${role.agentId}. Resolves HERMES_HOME to the local runtime.
3023
3857
 
3024
3858
  set -euo pipefail
3025
3859
 
@@ -3032,35 +3866,49 @@ if [[ -f "$FLEET_ENV" ]]; then
3032
3866
  source "$FLEET_ENV"
3033
3867
  fi
3034
3868
 
3035
- HERMES_BIN="{HERMES_BIN:-{HERMES_FLEET_BIN:-/home/delorenj/code/hermes-agent/.venv/bin/hermes}}"
3036
- HERMES_OAUTH_FILE="{HERMES_OAUTH_FILE:-{HERMES_FLEET_OAUTH_FILE:-$HOME/.hermes/auth.json}}"
3869
+ HERMES_BIN="{HERMES_BIN:-{HERMES_FLEET_BIN:-$HOME/.hermes/hermes-agent/.venv/bin/hermes}}"
3037
3870
  CODEX_HOME="{CODEX_HOME:-{HERMES_FLEET_CODEX_HOME:-$HOME/.codex}}"
3038
3871
 
3039
3872
  FLEET_HOME="{HERMES_FLEET_HOME:-$HOME/.hermes}"
3040
3873
  PROFILE_NAME="{HERMES_PROFILE_NAME:-${role.profileName || role.agentId}}"
3041
- HERMES_HOME="$RUNTIME_HOME"
3874
+
3875
+ # Singleton-runtime contract: HERMES_HOME MUST be the named profile dir, never
3876
+ # the raw runtime path. Hermes treats any HERMES_HOME that is neither under
3877
+ # ~/.hermes nor a child of a "profiles" dir as its own standalone root, which
3878
+ # makes get_active_profile_name() report "default" and _global_auth_file_path()
3879
+ # return None -- silently disabling shared fleet auth and giving every agent a
3880
+ # divergent config.yaml. The profile dir is a REAL dir whose shared entries
3881
+ # (config.yaml, .env, skills) symlink to the fleet root and whose person-owned
3882
+ # entries (memories, sessions, state.db, workspace) symlink into $RUNTIME_HOME.
3883
+ HERMES_HOME="$FLEET_HOME/profiles/$PROFILE_NAME"
3042
3884
 
3043
3885
  if [[ ! -d "$RUNTIME_HOME" ]]; then
3044
- echo "hermes: runtime submodule not initialized at $RUNTIME_HOME" >&2
3045
- echo " fix: git submodule update --init --recursive" >&2
3886
+ echo "hermes: local runtime not provisioned at $RUNTIME_HOME" >&2
3887
+ echo " fix: run the role's .scripts/20-runtime-repo.sh" >&2
3888
+ exit 1
3889
+ fi
3890
+
3891
+ if [[ ! -d "$HERMES_HOME" ]]; then
3892
+ echo "hermes: profile not provisioned at $HERMES_HOME" >&2
3893
+ echo " fix: pj migrate hermes.runtime-singleton" >&2
3046
3894
  exit 1
3047
3895
  fi
3048
3896
 
3049
- exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" HERMES_OAUTH_FILE="$HERMES_OAUTH_FILE" CODEX_HOME="$CODEX_HOME" "$HERMES_BIN" "$@"
3897
+ exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" CODEX_HOME="$CODEX_HOME" "$HERMES_BIN" "$@"
3050
3898
  `.replace(/\u0010/g, "$");
3051
3899
  }
3052
3900
  function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip) {
3053
- if (!existsSync9(sourceDir)) return;
3901
+ if (!existsSync10(sourceDir)) return;
3054
3902
  mkdirSync6(targetDir, { recursive: true });
3055
- for (const entry of readdirSync2(sourceDir, { withFileTypes: true })) {
3056
- const sourcePath = join11(sourceDir, entry.name);
3903
+ for (const entry of readdirSync3(sourceDir, { withFileTypes: true })) {
3904
+ const sourcePath = join14(sourceDir, entry.name);
3057
3905
  if (skip?.(sourcePath)) continue;
3058
- const targetPath = join11(targetDir, entry.name);
3906
+ const targetPath = join14(targetDir, entry.name);
3059
3907
  if (entry.isDirectory()) {
3060
3908
  copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
3061
3909
  continue;
3062
3910
  }
3063
- if (existsSync9(targetPath)) continue;
3911
+ if (existsSync10(targetPath)) continue;
3064
3912
  changedFiles.push(targetPath);
3065
3913
  if (!dryRun) {
3066
3914
  ensureParent(targetPath);
@@ -3068,21 +3916,78 @@ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip)
3068
3916
  }
3069
3917
  }
3070
3918
  }
3071
- function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
3072
- const gitmodulesPath = join11(repoRoot, ".gitmodules");
3073
- const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
3074
- const owner = role.runtimeOwner || "delorenj";
3075
- const block = `[submodule "agents/hermes/${role.role}/runtime"]
3076
- path = agents/hermes/${role.role}/runtime
3077
- url = git@github.com:${owner}/${repoName}.git
3078
- `;
3919
+ function runtimeSubmodulePath(repoRoot, role) {
3920
+ const rolePath = relative2(repoRoot, role.roleDir).replace(/\\/g, "/");
3921
+ if (!/^agents\/hermes\/[^/]+$/.test(rolePath)) return null;
3922
+ return `${rolePath}/runtime`;
3923
+ }
3924
+ function submoduleSectionHasPath(section, targetPath) {
3925
+ return section.split(/\r?\n/).some((line) => /^\s*path\s*=/.test(line) && line.replace(/^\s*path\s*=\s*/, "").trim() === targetPath);
3926
+ }
3927
+ function hasRuntimeSubmoduleMapping(repoRoot, role) {
3928
+ const gitmodulesPath = join14(repoRoot, ".gitmodules");
3929
+ const current = safeReadText(gitmodulesPath) ?? "";
3930
+ const sections = current.match(/^\[submodule "[^"\n]+"\][\s\S]*?(?=^\[submodule "|(?![\s\S]))/gm) ?? [];
3931
+ const targetPath = runtimeSubmodulePath(repoRoot, role);
3932
+ return Boolean(targetPath && sections.some((section) => submoduleSectionHasPath(section, targetPath)));
3933
+ }
3934
+ function removeRuntimeSubmoduleMapping(repoRoot, role, changedFiles, dryRun) {
3935
+ const gitmodulesPath = join14(repoRoot, ".gitmodules");
3079
3936
  const current = safeReadText(gitmodulesPath) ?? "";
3080
- const header = `[submodule "agents/hermes/${role.role}/runtime"]`;
3081
- if (current.includes(header)) return [];
3937
+ if (!hasRuntimeSubmoduleMapping(repoRoot, role)) return [];
3938
+ const targetPath = runtimeSubmodulePath(repoRoot, role);
3939
+ if (!targetPath) return [];
3940
+ const next = current.replace(/^\[submodule "[^"\n]+"\][\s\S]*?(?=^\[submodule "|(?![\s\S]))/gm, (section) => submoduleSectionHasPath(section, targetPath) ? "" : section).replace(/\n{3,}/g, "\n\n").trim();
3082
3941
  changedFiles.push(gitmodulesPath);
3083
- if (!dryRun) writeText(gitmodulesPath, `${current.replace(/\s*$/, "")}${current.trim() ? "\n" : ""}${block}`);
3942
+ if (!dryRun) writeText(gitmodulesPath, next ? `${next}
3943
+ ` : "");
3084
3944
  return [gitmodulesPath];
3085
3945
  }
3946
+ function retireRuntimeSubmodule(repoRoot, role, changedFiles, dryRun) {
3947
+ const runtimePath = runtimeSubmodulePath(repoRoot, role);
3948
+ if (!runtimePath) {
3949
+ return { ok: false, details: [], error: `refusing unsafe runtime path for ${role.roleDir}` };
3950
+ }
3951
+ const probe = spawnSync6("git", ["ls-files", "--stage", "--", runtimePath], {
3952
+ cwd: repoRoot,
3953
+ encoding: "utf8"
3954
+ });
3955
+ if (probe.status !== 0) {
3956
+ return { ok: false, details: [], error: `failed to inspect runtime index at ${runtimePath}: ${probe.stderr.trim() || `exit ${probe.status}`}` };
3957
+ }
3958
+ const details = [];
3959
+ if (probe.stdout.trim()) {
3960
+ details.push(`untrack ${runtimePath}`);
3961
+ if (dryRun) {
3962
+ changedFiles.push(runtimePath);
3963
+ } else {
3964
+ const removal = spawnSync6("git", ["rm", "--cached", "-r", "-f", "--", runtimePath], {
3965
+ cwd: repoRoot,
3966
+ encoding: "utf8"
3967
+ });
3968
+ if (removal.status !== 0) {
3969
+ return { ok: false, details, error: `failed to untrack ${runtimePath}: ${removal.stderr.trim() || `exit ${removal.status}`}` };
3970
+ }
3971
+ const verification = spawnSync6("git", ["ls-files", "--stage", "--", runtimePath], {
3972
+ cwd: repoRoot,
3973
+ encoding: "utf8"
3974
+ });
3975
+ if (verification.status !== 0 || verification.stdout.trim()) {
3976
+ return {
3977
+ ok: false,
3978
+ details,
3979
+ error: verification.status !== 0 ? `failed to verify untracked runtime ${runtimePath}: ${verification.stderr.trim() || `exit ${verification.status}`}` : `runtime remains tracked after index-only removal: ${runtimePath}`
3980
+ };
3981
+ }
3982
+ changedFiles.push(runtimePath);
3983
+ }
3984
+ }
3985
+ if (hasRuntimeSubmoduleMapping(repoRoot, role)) {
3986
+ details.push(`remove stale .gitmodules mapping for ${runtimePath}`);
3987
+ removeRuntimeSubmoduleMapping(repoRoot, role, changedFiles, dryRun);
3988
+ }
3989
+ return { ok: true, details };
3990
+ }
3086
3991
  function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
3087
3992
  const path = registryPath(homeDir);
3088
3993
  const current = safeReadText(path) ?? "# Hermes agent fleet registry.\n# One entry per provisioned agent. Managed by hermes-agent-template/.scripts/80-registry.sh.\nschema_version: 1\nagents: {}\n";
@@ -3101,9 +4006,11 @@ function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
3101
4006
  project_id: ${ctxEscape(role.ticketProviderBoardId)}
3102
4007
  identifier: ${ctxEscape(role.ticketProviderIdentifier)}
3103
4008
  runtime_repo: ${ctxEscape(role.runtimeRepo)}
4009
+ bloodbank:
4010
+ gateway_scope: fleet
4011
+ target_agent_id: ${role.agentId}
3104
4012
  systemd:
3105
4013
  gateway_unit: hermes-${role.agentId}-gateway.service
3106
- consumer_unit: hermes-${role.agentId}-consumer.service
3107
4014
  heartbeat_timer: hermes-${role.agentId}-heartbeat.timer
3108
4015
  `;
3109
4016
  const next = current.includes("agents: {}") ? current.replace("agents: {}", `agents:
@@ -3167,6 +4074,7 @@ function checkUnit(unit) {
3167
4074
  var BMAD_NPM_PACKAGE = "bmad-method";
3168
4075
  var BMAD_TARGET_CHANNEL = "next";
3169
4076
  var BMAD_DIST_TAGS_TTL_MS = 60 * 60 * 1e3;
4077
+ var DEFAULT_BMAD_MODULES = ["bmm", "bmb", "cis"];
3170
4078
  var BMAD_INSTALL_TOOLS = [
3171
4079
  "claude-code",
3172
4080
  "codex",
@@ -3214,7 +4122,53 @@ var BMAD_INSTALL_TOOLS = [
3214
4122
  "windsurf",
3215
4123
  "zencoder"
3216
4124
  ];
3217
- function bmadInstallArgs(repoRoot) {
4125
+ function manifestBmadModules(repoRoot) {
4126
+ const manifestPath = join14(repoRoot, "_bmad", "_config", "manifest.yaml");
4127
+ const raw = safeReadText(manifestPath);
4128
+ if (raw === null) return { status: "absent" };
4129
+ try {
4130
+ const parsed = YAML3.parse(raw);
4131
+ if (!Array.isArray(parsed?.modules)) {
4132
+ return { status: "invalid", error: `${manifestPath} must define a modules array` };
4133
+ }
4134
+ const declared = [];
4135
+ for (const entry of parsed.modules) {
4136
+ const name = typeof entry === "string" ? entry : entry && typeof entry === "object" ? entry.name : void 0;
4137
+ if (typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) {
4138
+ return { status: "invalid", error: `${manifestPath} contains an invalid module entry` };
4139
+ }
4140
+ if (name !== "core" && name !== "custom") declared.push(name);
4141
+ }
4142
+ return { status: "valid", modules: Array.from(new Set(declared)) };
4143
+ } catch (error) {
4144
+ return {
4145
+ status: "invalid",
4146
+ error: `Could not parse ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`
4147
+ };
4148
+ }
4149
+ }
4150
+ function configuredBmadModules(repoRoot) {
4151
+ const raw = safeReadText(join14(repoRoot, "_bmad", "config.toml"));
4152
+ if (raw === null) return void 0;
4153
+ const modules = [...raw.matchAll(/^\[modules\.([A-Za-z0-9][A-Za-z0-9_-]*)\]\s*$/gm)].map((match) => match[1]);
4154
+ return Array.from(new Set(modules));
4155
+ }
4156
+ function selectedBmadModules(repoRoot) {
4157
+ const manifest = manifestBmadModules(repoRoot);
4158
+ if (manifest.status === "valid") return manifest.modules;
4159
+ if (manifest.status === "invalid") throw new Error(manifest.error);
4160
+ return configuredBmadModules(repoRoot) ?? [...DEFAULT_BMAD_MODULES];
4161
+ }
4162
+ function requiredBmadSentinels(repoRoot, modules = selectedBmadModules(repoRoot)) {
4163
+ return [
4164
+ join14("core", "config.yaml"),
4165
+ join14("config.toml"),
4166
+ join14("_config", "manifest.yaml"),
4167
+ ...modules.map((module) => join14(module, "config.yaml"))
4168
+ ];
4169
+ }
4170
+ function bmadInstallArgs(repoRoot, modules = selectedBmadModules(repoRoot)) {
4171
+ const installerModules = modules.length ? modules.join(",") : "core";
3218
4172
  return [
3219
4173
  "-y",
3220
4174
  `${BMAD_NPM_PACKAGE}@${BMAD_TARGET_CHANNEL}`,
@@ -3223,20 +4177,20 @@ function bmadInstallArgs(repoRoot) {
3223
4177
  "--directory",
3224
4178
  repoRoot,
3225
4179
  "--modules",
3226
- "bmm,bmb,cis",
4180
+ installerModules,
3227
4181
  "--tools",
3228
4182
  BMAD_INSTALL_TOOLS.join(",")
3229
4183
  ];
3230
4184
  }
3231
- function runBmadInstall(repoRoot) {
3232
- const result = spawnSync6("npx", bmadInstallArgs(repoRoot), { encoding: "utf8" });
4185
+ function runBmadInstall(repoRoot, modules = selectedBmadModules(repoRoot)) {
4186
+ const result = spawnSync6("npx", bmadInstallArgs(repoRoot, modules), { encoding: "utf8" });
3233
4187
  if (result.status !== 0) {
3234
4188
  return { ok: false, error: result.stderr || result.error?.message || "Unknown error" };
3235
4189
  }
3236
4190
  return { ok: true };
3237
4191
  }
3238
4192
  function readInstalledBmadVersion(repoRoot) {
3239
- const raw = safeReadText(join11(repoRoot, "_bmad", "_config", "manifest.yaml"));
4193
+ const raw = safeReadText(join14(repoRoot, "_bmad", "_config", "manifest.yaml"));
3240
4194
  if (!raw) return void 0;
3241
4195
  try {
3242
4196
  const parsed = YAML3.parse(raw);
@@ -3247,8 +4201,8 @@ function readInstalledBmadVersion(repoRoot) {
3247
4201
  }
3248
4202
  }
3249
4203
  function bmadCachePath(homeDir) {
3250
- const cacheRoot = process.env.XDG_CACHE_HOME?.trim() || join11(homeDir, ".cache");
3251
- return join11(cacheRoot, "pjangler", "bmad-dist-tags.json");
4204
+ const cacheRoot = process.env.XDG_CACHE_HOME?.trim() || join14(homeDir, ".cache");
4205
+ return join14(cacheRoot, "pjangler", "bmad-dist-tags.json");
3252
4206
  }
3253
4207
  function readBmadDistTagsCache(homeDir) {
3254
4208
  const raw = safeReadText(bmadCachePath(homeDir));
@@ -3291,7 +4245,7 @@ function resolveBmadDistTags(homeDir) {
3291
4245
  try {
3292
4246
  const path = bmadCachePath(homeDir);
3293
4247
  mkdirSync6(dirname6(path), { recursive: true });
3294
- writeFileSync6(path, JSON.stringify({ fetchedAt: Date.now(), distTags: fetched }, null, 2));
4248
+ writeFileSync7(path, JSON.stringify({ fetchedAt: Date.now(), distTags: fetched }, null, 2));
3295
4249
  } catch {
3296
4250
  }
3297
4251
  return { distTags: fetched, stale: false };
@@ -3331,19 +4285,233 @@ function compareBmadVersions(a, b) {
3331
4285
  }
3332
4286
  return 0;
3333
4287
  }
4288
+ var SHARED_PROFILE_ENTRIES = ["config.yaml", ".env", "skills"];
4289
+ var OWNED_PROFILE_ENTRIES = [
4290
+ "memories",
4291
+ "sessions",
4292
+ "workspace",
4293
+ "logs",
4294
+ "cron",
4295
+ "plans",
4296
+ "hooks",
4297
+ "pairing",
4298
+ "audio_cache",
4299
+ "image_cache"
4300
+ ];
4301
+ var OWNED_PROFILE_FILES = ["SOUL.md", "state.db", "kanban.db"];
4302
+ function fleetHome(ctx) {
4303
+ return process.env.HERMES_FLEET_HOME || join14(ctx.homeDir, ".hermes");
4304
+ }
4305
+ function fleetBinPath(ctx) {
4306
+ const candidates = [
4307
+ process.env.HERMES_FLEET_BIN,
4308
+ join14(fleetHome(ctx), "hermes-agent", ".venv", "bin", "hermes"),
4309
+ join14(fleetHome(ctx), "hermes-agent", "venv", "bin", "hermes"),
4310
+ join14(ctx.homeDir, ".local", "bin", "hermes")
4311
+ ].filter(Boolean);
4312
+ return candidates.find((candidate) => existsSync10(candidate)) ?? "";
4313
+ }
4314
+ function singletonPlan(ctx, role) {
4315
+ const fleetRoot = fleetHome(ctx);
4316
+ const profileName = role.profileName || role.agentId;
4317
+ const profileDir = join14(fleetRoot, "profiles", profileName);
4318
+ const runtimeDir = join14(role.roleDir, "runtime");
4319
+ const links = [];
4320
+ for (const entry of SHARED_PROFILE_ENTRIES) {
4321
+ links.push({ path: join14(profileDir, entry), target: join14(fleetRoot, entry), ensureTargetDir: entry === "skills" });
4322
+ }
4323
+ for (const entry of OWNED_PROFILE_ENTRIES) {
4324
+ links.push({ path: join14(profileDir, entry), target: join14(runtimeDir, entry), ensureTargetDir: true });
4325
+ }
4326
+ for (const entry of OWNED_PROFILE_FILES) {
4327
+ links.push({ path: join14(profileDir, entry), target: join14(runtimeDir, entry), ensureTargetDir: false });
4328
+ }
4329
+ const sharedSeeds = ["config.yaml", "auth.json", ".env"].map((entry) => ({
4330
+ rootPath: join14(fleetRoot, entry),
4331
+ runtimePath: join14(runtimeDir, entry)
4332
+ }));
4333
+ return { fleetRoot, profileDir, runtimeDir, links, sharedSeeds };
4334
+ }
4335
+ function isDanglingLink(path) {
4336
+ try {
4337
+ return lstatSync2(path).isSymbolicLink() && !existsSync10(path);
4338
+ } catch {
4339
+ return false;
4340
+ }
4341
+ }
4342
+ function linkState(path, target) {
4343
+ let stat;
4344
+ try {
4345
+ stat = lstatSync2(path);
4346
+ } catch {
4347
+ return "missing";
4348
+ }
4349
+ if (!stat.isSymbolicLink()) return "not-a-symlink";
4350
+ try {
4351
+ return readlinkSync(path) === target ? "ok" : "wrong-target";
4352
+ } catch {
4353
+ return "wrong-target";
4354
+ }
4355
+ }
4356
+ function realOrSelf(path) {
4357
+ try {
4358
+ return realpathSync(path);
4359
+ } catch {
4360
+ return path;
4361
+ }
4362
+ }
4363
+ function profileUnits(role) {
4364
+ return [
4365
+ `hermes-${role.agentId}-gateway.service`,
4366
+ `hermes-${role.agentId}-heartbeat.service`,
4367
+ `hermes-${role.agentId}-heartbeat.timer`,
4368
+ `hermes-${role.agentId}-checkpoint.service`
4369
+ ];
4370
+ }
4371
+ function readRegistry(registryPath2) {
4372
+ const raw = safeReadText(registryPath2);
4373
+ if (raw === null) return null;
4374
+ try {
4375
+ const doc = YAML3.parse(raw);
4376
+ return doc?.agents ?? {};
4377
+ } catch {
4378
+ return null;
4379
+ }
4380
+ }
4381
+ function declaredAgentIds(repoRoot) {
4382
+ return declaredAgentEntries(repoRoot).map(([agentId]) => agentId);
4383
+ }
4384
+ function declaredAgentEntries(repoRoot) {
4385
+ const raw = safeReadText(join14(repoRoot, ".project.json"));
4386
+ if (raw === null) return [];
4387
+ try {
4388
+ const doc = JSON.parse(raw);
4389
+ return Object.entries(doc.agents ?? {}).map(([agentId, entry]) => [agentId, entry ?? {}]);
4390
+ } catch {
4391
+ return [];
4392
+ }
4393
+ }
4394
+ function ownedRegistryEntries(registry, repoRoot) {
4395
+ const want = realOrSelf(repoRoot);
4396
+ const owned = [];
4397
+ for (const [agentId, raw] of Object.entries(registry)) {
4398
+ const entry = raw ?? {};
4399
+ const roleDir = String(entry.role_dir ?? "");
4400
+ if (!roleDir) continue;
4401
+ if (realOrSelf(dirname6(dirname6(dirname6(roleDir)))) !== want) continue;
4402
+ owned.push([agentId, entry]);
4403
+ }
4404
+ return owned;
4405
+ }
4406
+ function unprovisionedRoleAgents(registry, repoRoot, canonical) {
4407
+ const blockers = /* @__PURE__ */ new Map();
4408
+ const record = (agentId, roleDir, source) => {
4409
+ const current = blockers.get(agentId) ?? { roleDir, sources: /* @__PURE__ */ new Set() };
4410
+ if (!current.roleDir && roleDir) current.roleDir = roleDir;
4411
+ current.sources.add(source);
4412
+ blockers.set(agentId, current);
4413
+ };
4414
+ for (const [agentId, entry] of ownedRegistryEntries(registry, repoRoot)) {
4415
+ if (canonical.has(agentId)) continue;
4416
+ const roleDir = String(entry.role_dir ?? "");
4417
+ if (!roleDir || !existsSync10(join14(roleDir, "role.yaml"))) record(agentId, roleDir, "registry");
4418
+ }
4419
+ for (const [agentId, entry] of declaredAgentEntries(repoRoot)) {
4420
+ if (canonical.has(agentId)) continue;
4421
+ const configured = String(entry.role_dir ?? "");
4422
+ const roleDir = configured ? resolve3(repoRoot, configured) : "";
4423
+ if (!roleDir || !existsSync10(join14(roleDir, "role.yaml"))) record(agentId, roleDir, ".project.json");
4424
+ }
4425
+ return [...blockers.entries()].map(([agentId, value]) => ({
4426
+ agentId,
4427
+ roleDir: value.roleDir,
4428
+ sources: [...value.sources]
4429
+ }));
4430
+ }
4431
+ function dropDeclaredAgent(ctx, agentId, changedFiles, details) {
4432
+ const path = join14(ctx.repoRoot, ".project.json");
4433
+ const raw = safeReadText(path);
4434
+ if (raw === null) return;
4435
+ let doc;
4436
+ try {
4437
+ doc = JSON.parse(raw);
4438
+ } catch {
4439
+ return;
4440
+ }
4441
+ if (!doc.agents || !(agentId in doc.agents)) return;
4442
+ delete doc.agents[agentId];
4443
+ details.push(`drop agent "${agentId}" from .project.json`);
4444
+ changedFiles.push(path);
4445
+ if (!ctx.dryRun) writeText(path, `${JSON.stringify(doc, null, 2)}
4446
+ `);
4447
+ }
4448
+ function rewriteLauncher(text3) {
4449
+ let next = text3.replace(/^HERMES_HOME="\$RUNTIME_HOME"\s*$/m, 'HERMES_HOME="$FLEET_HOME/profiles/$PROFILE_NAME"');
4450
+ next = next.replace(/^HERMES_OAUTH_FILE=.*\n/m, "");
4451
+ next = next.replace(/\s*HERMES_OAUTH_FILE="\$HERMES_OAUTH_FILE"/g, "");
4452
+ next = next.replace(
4453
+ /^.*\/home\/delorenj\/code\/hermes-agent\/\.venv\/bin\/hermes.*$/m,
4454
+ (line) => line.replace("/home/delorenj/code/hermes-agent/.venv/bin/hermes", "$HOME/.hermes/hermes-agent/.venv/bin/hermes")
4455
+ );
4456
+ return next;
4457
+ }
4458
+ function isValidOpReference(value) {
4459
+ if (!value.startsWith("op://") || /[\[\]{}<>]/.test(value)) return false;
4460
+ const withoutScheme = value.slice("op://".length);
4461
+ const fragmentIndex = withoutScheme.indexOf("#");
4462
+ if (fragmentIndex >= 0) return false;
4463
+ const queryIndex = withoutScheme.indexOf("?");
4464
+ const pathPart = queryIndex >= 0 ? withoutScheme.slice(0, queryIndex) : withoutScheme;
4465
+ const queryPart = queryIndex >= 0 ? withoutScheme.slice(queryIndex + 1) : "";
4466
+ const parts = pathPart.split("/");
4467
+ if (parts.length < 3 || parts.length > 4 || parts.some((part) => !part)) return false;
4468
+ try {
4469
+ for (const part of parts) decodeURIComponent(part);
4470
+ } catch {
4471
+ return false;
4472
+ }
4473
+ if (queryIndex >= 0 && !/^attribute=[A-Za-z0-9._~-]+$/.test(queryPart)) return false;
4474
+ return true;
4475
+ }
4476
+ function malformedOpReferences(text3) {
4477
+ const occurrences = [];
4478
+ const lines = text3.split("\n");
4479
+ for (let index = 0; index < lines.length; index++) {
4480
+ const line = lines[index];
4481
+ for (const match of line.matchAll(/op:\/\/[^\s"'`]+/g)) {
4482
+ const value = match[0];
4483
+ if (!isValidOpReference(value)) {
4484
+ occurrences.push({ line: index + 1, value, commentOnly: line.trimStart().startsWith("#") });
4485
+ }
4486
+ }
4487
+ }
4488
+ return occurrences;
4489
+ }
4490
+ function removeMalformedCommentOpReferences(text3) {
4491
+ let changed = false;
4492
+ const lines = text3.split("\n").map((line) => {
4493
+ if (!line.trimStart().startsWith("#")) return line;
4494
+ return line.replace(/op:\/\/[^\s"'`]+/g, (value) => {
4495
+ if (isValidOpReference(value)) return value;
4496
+ changed = true;
4497
+ return "<invalid 1Password reference removed by pjangler>";
4498
+ });
4499
+ });
4500
+ return { text: lines.join("\n"), changed };
4501
+ }
3334
4502
  var RULES = [
3335
4503
  {
3336
4504
  id: "mise.config-root",
3337
4505
  title: "mise config_root + AGENTS link hooks",
3338
4506
  audit: (ctx) => {
3339
- const misePath = join11(ctx.repoRoot, "mise.toml");
3340
- if (!existsSync9(misePath)) {
4507
+ const misePath = join14(ctx.repoRoot, "mise.toml");
4508
+ if (!existsSync10(misePath)) {
3341
4509
  return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
3342
4510
  }
3343
4511
  const text3 = readText(misePath);
3344
4512
  const details = [];
3345
- const linkAgentfilesPath = join11(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
3346
- if (!existsSync9(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
4513
+ const linkAgentfilesPath = join14(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
4514
+ if (!existsSync10(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
3347
4515
  const pathValues = [...(text3.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
3348
4516
  const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
3349
4517
  if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
@@ -3361,10 +4529,10 @@ var RULES = [
3361
4529
  };
3362
4530
  },
3363
4531
  migrate: (ctx, finding) => {
3364
- const path = join11(ctx.repoRoot, "mise.toml");
4532
+ const path = join14(ctx.repoRoot, "mise.toml");
3365
4533
  const changedFiles = [];
3366
4534
  const details = [];
3367
- if (!existsSync9(path)) {
4535
+ if (!existsSync10(path)) {
3368
4536
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
3369
4537
  return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing and no generated-project mise template available to initialize from", changedFiles, details: [] };
3370
4538
  }
@@ -3380,7 +4548,7 @@ var RULES = [
3380
4548
  if (!ctx.dryRun) writeText(path, next);
3381
4549
  text3 = next;
3382
4550
  }
3383
- const linkAgentfilesPath = join11(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
4551
+ const linkAgentfilesPath = join14(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
3384
4552
  const expectedScript = templateLinkAgentfilesScript(ctx);
3385
4553
  if (expectedScript === void 0) {
3386
4554
  return { id: finding.id, title: finding.title, status: "blocked", summary: "pjangler install is missing .mise/scripts/link-agentfiles.sh \u2014 update @delorenj/pjangler (broken package)", changedFiles, details: [] };
@@ -3389,7 +4557,7 @@ var RULES = [
3389
4557
  changedFiles.push(linkAgentfilesPath);
3390
4558
  if (!ctx.dryRun) {
3391
4559
  writeText(linkAgentfilesPath, expectedScript);
3392
- chmodSync2(linkAgentfilesPath, 493);
4560
+ chmodSync4(linkAgentfilesPath, 493);
3393
4561
  }
3394
4562
  }
3395
4563
  return {
@@ -3407,13 +4575,13 @@ var RULES = [
3407
4575
  title: "managed mise versioning block",
3408
4576
  audit: (ctx) => {
3409
4577
  const details = [];
3410
- const misePath = join11(ctx.repoRoot, "mise.toml");
3411
- const versioningPath = join11(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
3412
- const manifestPath = join11(ctx.repoRoot, ".mise", "version-files.conf");
4578
+ const misePath = join14(ctx.repoRoot, "mise.toml");
4579
+ const versioningPath = join14(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
4580
+ const manifestPath = join14(ctx.repoRoot, ".mise", "version-files.conf");
3413
4581
  const text3 = safeReadText(misePath);
3414
4582
  if (!text3?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
3415
- if (!existsSync9(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
3416
- if (!existsSync9(manifestPath)) details.push(".mise/version-files.conf missing");
4583
+ if (!existsSync10(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
4584
+ if (!existsSync10(manifestPath)) details.push(".mise/version-files.conf missing");
3417
4585
  return {
3418
4586
  id: "mise.versioning",
3419
4587
  title: "managed mise versioning block",
@@ -3426,8 +4594,8 @@ var RULES = [
3426
4594
  migrate: (ctx, finding) => {
3427
4595
  const changedFiles = [];
3428
4596
  const details = [];
3429
- const misePath = join11(ctx.repoRoot, "mise.toml");
3430
- if (!existsSync9(misePath)) {
4597
+ const misePath = join14(ctx.repoRoot, "mise.toml");
4598
+ if (!existsSync10(misePath)) {
3431
4599
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
3432
4600
  return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing and no generated-project mise template available to initialize from", changedFiles, details: [] };
3433
4601
  }
@@ -3436,46 +4604,266 @@ var RULES = [
3436
4604
  return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
3437
4605
  }
3438
4606
  }
3439
- const currentMise = readText(misePath);
3440
- let cleanedMise = currentMise;
3441
- if (!currentMise.includes("# >>> mise-versioning >>>")) {
3442
- const taskNames = ["version", "version:bump", "version:bump-patch", "version:bump-minor", "version:bump-major", "version:check", "version:sync"];
3443
- for (const taskName of taskNames) {
3444
- const escaped = taskName.replace(/:/g, "\\:");
3445
- const headerPattern = new RegExp(`^\\[tasks\\.(?:"${escaped}"|'${escaped}'|${escaped})\\]$`);
3446
- cleanedMise = removeTomlSection(cleanedMise, headerPattern);
4607
+ const currentMise = readText(misePath);
4608
+ let cleanedMise = currentMise;
4609
+ if (!currentMise.includes("# >>> mise-versioning >>>")) {
4610
+ const taskNames = ["version", "version:bump", "version:bump-patch", "version:bump-minor", "version:bump-major", "version:check", "version:sync"];
4611
+ for (const taskName of taskNames) {
4612
+ const escaped = taskName.replace(/:/g, "\\:");
4613
+ const headerPattern = new RegExp(`^\\[tasks\\.(?:"${escaped}"|'${escaped}'|${escaped})\\]$`);
4614
+ cleanedMise = removeTomlSection(cleanedMise, headerPattern);
4615
+ }
4616
+ }
4617
+ const nextMise = replaceOrAppendManagedBlock(cleanedMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
4618
+ if (nextMise !== currentMise) {
4619
+ if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
4620
+ if (!ctx.dryRun) writeText(misePath, nextMise);
4621
+ }
4622
+ const versioningPath = join14(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
4623
+ const expectedScript = templateVersioningScript(ctx);
4624
+ if (expectedScript === void 0) {
4625
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "pjangler install is missing .mise/scripts/versioning.sh \u2014 update @delorenj/pjangler (broken package)", changedFiles, details: [] };
4626
+ }
4627
+ if (safeReadText(versioningPath) !== expectedScript) {
4628
+ changedFiles.push(versioningPath);
4629
+ if (!ctx.dryRun) {
4630
+ writeText(versioningPath, expectedScript);
4631
+ chmodSync4(versioningPath, 493);
4632
+ }
4633
+ }
4634
+ const manifestPath = join14(ctx.repoRoot, ".mise", "version-files.conf");
4635
+ const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
4636
+ if (safeReadText(manifestPath) !== expectedManifest) {
4637
+ changedFiles.push(manifestPath);
4638
+ if (!ctx.dryRun) writeText(manifestPath, expectedManifest);
4639
+ }
4640
+ return {
4641
+ id: finding.id,
4642
+ title: finding.title,
4643
+ status: changedFiles.length ? "applied" : "noop",
4644
+ summary: changedFiles.length ? "Versioning block/script/manifest normalized" : "No changes required",
4645
+ changedFiles,
4646
+ details: []
4647
+ };
4648
+ }
4649
+ },
4650
+ {
4651
+ id: "skills.project-manifest",
4652
+ title: "Skillex project skills manifest",
4653
+ audit: (ctx) => {
4654
+ const details = [];
4655
+ const manifestPath = join14(ctx.repoRoot, ".agents", "skills.json");
4656
+ const legacyDir = join14(ctx.repoRoot, ".agents", "skills");
4657
+ const localExamplePath = join14(ctx.repoRoot, ".agents", "local.example.json");
4658
+ const misePath = join14(ctx.repoRoot, "mise.toml");
4659
+ let fixable = true;
4660
+ let expectedBmad = [];
4661
+ try {
4662
+ expectedBmad = canonicalBmadSkillEntries(ctx);
4663
+ } catch (error) {
4664
+ details.push(
4665
+ `BMAD Skillex pack ${BMAD_PACK_VERSION} is not trusted at ${bmadPackRoot(ctx)}: ${error instanceof Error ? error.message : String(error)}`
4666
+ );
4667
+ fixable = false;
4668
+ }
4669
+ const expectedByName = new Map(expectedBmad.map((entry) => [entry.name, fileURLToPath3(entry.source)]));
4670
+ const expectedNames = new Set(expectedByName.keys());
4671
+ const packRoot = bmadPackRoot(ctx);
4672
+ const manifest = tryParseJson(safeReadText(manifestPath));
4673
+ if (!manifest) {
4674
+ details.push(".agents/skills.json missing or invalid JSON");
4675
+ } else {
4676
+ if (manifest.inherit_global !== true) details.push(".agents/skills.json should set inherit_global: true");
4677
+ if (manifest.registry !== SKILLS_REGISTRY_URL) details.push(`.agents/skills.json should set registry to ${SKILLS_REGISTRY_URL}`);
4678
+ if (!Array.isArray(manifest.skills)) {
4679
+ details.push(".agents/skills.json should define a skills array");
4680
+ } else {
4681
+ const actualBmad = new Map(
4682
+ manifest.skills.filter((entry) => Boolean(entry) && typeof entry === "object" && isPackManagedManifestEntry(entry, expectedNames, packRoot)).map((entry) => [String(entry.name), String(entry.source ?? "")])
4683
+ );
4684
+ const stale = expectedBmad.filter((entry) => actualBmad.get(entry.name) !== entry.source);
4685
+ if (stale.length > 0 || actualBmad.size !== expectedBmad.length) {
4686
+ details.push(`.agents/skills.json should record all ${expectedBmad.length} BMAD ${BMAD_PACK_VERSION} pack entries as file:// sources`);
4687
+ }
4688
+ }
4689
+ }
4690
+ const invalidBmadLinkNames = /* @__PURE__ */ new Set();
4691
+ if (existsSync10(legacyDir)) {
4692
+ for (const name of readdirSync3(legacyDir)) {
4693
+ const expected = expectedByName.get(name);
4694
+ const path = join14(legacyDir, name);
4695
+ let linkTargetsPack = false;
4696
+ try {
4697
+ linkTargetsPack = lstatSync2(path).isSymbolicLink() && isContainedBy(packRoot, resolve3(dirname6(path), readlinkSync(path)));
4698
+ } catch {
4699
+ linkTargetsPack = false;
4700
+ }
4701
+ if (!expected && !linkTargetsPack) continue;
4702
+ try {
4703
+ if (!expected || !lstatSync2(path).isSymbolicLink() || resolve3(dirname6(path), readlinkSync(path)) !== expected) invalidBmadLinkNames.add(name);
4704
+ } catch {
4705
+ invalidBmadLinkNames.add(name);
4706
+ }
4707
+ }
4708
+ for (const [name, expected] of expectedByName) {
4709
+ const path = join14(legacyDir, name);
4710
+ try {
4711
+ if (!lstatSync2(path).isSymbolicLink() || resolve3(dirname6(path), readlinkSync(path)) !== expected) invalidBmadLinkNames.add(name);
4712
+ } catch {
4713
+ invalidBmadLinkNames.add(name);
4714
+ }
4715
+ }
4716
+ } else {
4717
+ for (const name of expectedByName.keys()) invalidBmadLinkNames.add(name);
4718
+ }
4719
+ if (invalidBmadLinkNames.size > 0) {
4720
+ details.push(`${invalidBmadLinkNames.size} managed BMAD skill path(s) should be symlinks into the ${BMAD_PACK_VERSION} pack`);
4721
+ }
4722
+ for (const rel of [".mise/scripts/link-project-skills-to-clis.sh", ".mise/scripts/unlink-project-skills-from-clis.sh"]) {
4723
+ if (existsSync10(join14(ctx.repoRoot, rel))) details.push(`${rel} is a legacy symlink-era script and should be removed`);
4724
+ }
4725
+ const localExample = tryParseJson(safeReadText(localExamplePath));
4726
+ if (localExample && Object.prototype.hasOwnProperty.call(localExample, "skills")) {
4727
+ details.push(".agents/local.example.json still documents legacy skills overrides; drop the skills section");
4728
+ }
4729
+ const mise = safeReadText(misePath);
4730
+ if (!mise?.includes(SYNC_SKILLS_SCRIPT)) details.push("mise.toml should run the shipped project-local sync-skills.py engine via config_root");
4731
+ if (!mise?.includes(PROVISION_BMAD_SKILLS_SCRIPT)) details.push("mise.toml should provision pinned BMAD pack links before syncing skills");
4732
+ if (mise?.includes(SYNC_SKILLS_SCRIPT) && mise.includes(PROVISION_BMAD_SKILLS_SCRIPT) && mise.indexOf(PROVISION_BMAD_SKILLS_SCRIPT) > mise.indexOf(SYNC_SKILLS_SCRIPT)) {
4733
+ details.push("mise.toml should run the BMAD provisioner before project skill sync");
4734
+ }
4735
+ if (mise?.includes('script = "sync-skills.py --scope project"') || mise?.includes('run = "sync-skills.py --scope project"')) {
4736
+ details.push("mise.toml still invokes the missing bare sync-skills.py executable");
4737
+ }
4738
+ if (!mise?.includes('patterns = [".agents/skills.json"]')) details.push("mise.toml should watch .agents/skills.json");
4739
+ if (!mise?.includes("[tasks.skills-sync]")) details.push("mise.toml should define a skills-sync task");
4740
+ if (!mise?.includes('depends = ["skills-provision-bmad"]')) details.push("skills-sync task should depend on skills-provision-bmad");
4741
+ for (const [rel, label] of [
4742
+ [".mise/scripts/provision-bmad-skills.py", "BMAD Skillex provisioning script"],
4743
+ [".mise/scripts/sync-skills.py", "Project-local skills sync engine"]
4744
+ ]) {
4745
+ const target = join14(ctx.repoRoot, rel);
4746
+ const expected = templateCommonProjectText(ctx, rel);
4747
+ const stat = lstatIfPresent(target);
4748
+ if (!stat || !stat.isFile() || stat.isSymbolicLink()) {
4749
+ details.push(`${label} is missing or unsafe`);
4750
+ if (stat) fixable = false;
4751
+ } else {
4752
+ if (expected === void 0 || safeReadText(target) !== expected) details.push(`${label} differs from the shipped template`);
4753
+ if ((Number(stat.mode) & 73) === 0) details.push(`${label} is not executable`);
4754
+ }
4755
+ }
4756
+ const topologyIssues = projectSkillTopologyIssues(ctx.repoRoot);
4757
+ if (topologyIssues.length) {
4758
+ details.push(...topologyIssues.map((issue) => `CLI skill topology: ${issue}`));
4759
+ fixable = false;
4760
+ }
4761
+ if (mise?.includes("link-project-skills-to-clis.sh") || mise?.includes("unlink-project-skills-from-clis.sh") || mise?.includes("[tasks.skills-relink]")) {
4762
+ details.push("mise.toml still contains legacy skill-link wiring");
4763
+ }
4764
+ return {
4765
+ id: "skills.project-manifest",
4766
+ title: "Skillex project skills manifest",
4767
+ status: details.length === 0 ? "pass" : "fail",
4768
+ summary: details.length === 0 ? "Skillex skills manifest parity verified" : `${details.length} Skillex migration issue(s) detected`,
4769
+ details,
4770
+ fixable
4771
+ };
4772
+ },
4773
+ migrate: (ctx, finding) => {
4774
+ const changedFiles = [];
4775
+ const details = [];
4776
+ const manifestPath = join14(ctx.repoRoot, ".agents", "skills.json");
4777
+ const localExamplePath = join14(ctx.repoRoot, ".agents", "local.example.json");
4778
+ const misePath = join14(ctx.repoRoot, "mise.toml");
4779
+ const provisionScriptPath = join14(ctx.repoRoot, ".mise", "scripts", "provision-bmad-skills.py");
4780
+ const syncScriptPath = join14(ctx.repoRoot, ".mise", "scripts", "sync-skills.py");
4781
+ const expectedProvisionScript = templateCommonProjectText(ctx, ".mise/scripts/provision-bmad-skills.py");
4782
+ const expectedSyncScript = templateCommonProjectText(ctx, ".mise/scripts/sync-skills.py");
4783
+ const topologyIssues = projectSkillTopologyIssues(ctx.repoRoot);
4784
+ if (topologyIssues.length) {
4785
+ return {
4786
+ id: finding.id,
4787
+ title: finding.title,
4788
+ status: "blocked",
4789
+ summary: "Unsafe project CLI skill topology must be repaired manually",
4790
+ changedFiles,
4791
+ details: topologyIssues
4792
+ };
4793
+ }
4794
+ if (!expectedProvisionScript || !expectedSyncScript) {
4795
+ return {
4796
+ id: finding.id,
4797
+ title: finding.title,
4798
+ status: "blocked",
4799
+ summary: "pjangler install is missing a shipped skills executable",
4800
+ changedFiles,
4801
+ details: [
4802
+ ...!expectedProvisionScript ? ["Missing BMAD Skillex provisioning script template"] : [],
4803
+ ...!expectedSyncScript ? ["Missing project-local skills sync engine template"] : []
4804
+ ]
4805
+ };
4806
+ }
4807
+ const unsafeScriptTargets = [provisionScriptPath, syncScriptPath].filter((path) => {
4808
+ const stat = lstatIfPresent(path);
4809
+ return Boolean(stat && (!stat.isFile() || stat.isSymbolicLink()));
4810
+ });
4811
+ if (unsafeScriptTargets.length) {
4812
+ return {
4813
+ id: finding.id,
4814
+ title: finding.title,
4815
+ status: "blocked",
4816
+ summary: "Refusing non-regular managed skills executable target",
4817
+ changedFiles,
4818
+ details: unsafeScriptTargets.map((path) => `${path} must be removed or repaired manually`)
4819
+ };
4820
+ }
4821
+ const provisioned = provisionBmadSkills(ctx);
4822
+ if (!provisioned.ok) {
4823
+ return {
4824
+ id: finding.id,
4825
+ title: finding.title,
4826
+ status: "blocked",
4827
+ summary: `BMAD Skillex pack ${BMAD_PACK_VERSION} is unavailable`,
4828
+ changedFiles,
4829
+ details: [provisioned.error ?? "Unknown BMAD pack error"]
4830
+ };
4831
+ }
4832
+ changedFiles.push(...provisioned.changedFiles);
4833
+ if (provisioned.changedFiles.includes(manifestPath)) details.push(`Recorded BMAD pack ${BMAD_PACK_VERSION} in .agents/skills.json`);
4834
+ for (const rel of [".mise/scripts/link-project-skills-to-clis.sh", ".mise/scripts/unlink-project-skills-from-clis.sh"]) {
4835
+ const path = join14(ctx.repoRoot, rel);
4836
+ if (existsSync10(path)) {
4837
+ changedFiles.push(path);
4838
+ if (!ctx.dryRun) unlinkSync3(path);
4839
+ }
4840
+ }
4841
+ const templateLocalExample = templateCommonProjectText(ctx, ".agents/local.example.json");
4842
+ const currentLocalExample = safeReadText(localExamplePath);
4843
+ if (templateLocalExample && currentLocalExample && currentLocalExample !== templateLocalExample) {
4844
+ changedFiles.push(localExamplePath);
4845
+ if (!ctx.dryRun) writeText(localExamplePath, templateLocalExample);
4846
+ }
4847
+ normalizeExecutableTemplate(ctx, provisionScriptPath, expectedProvisionScript, changedFiles);
4848
+ normalizeExecutableTemplate(ctx, syncScriptPath, expectedSyncScript, changedFiles);
4849
+ if (!existsSync10(misePath)) {
4850
+ if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
4851
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing and no generated-project mise template available to initialize from", changedFiles, details };
3447
4852
  }
3448
4853
  }
3449
- const nextMise = replaceOrAppendManagedBlock(cleanedMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
4854
+ const currentMise = readText(misePath);
4855
+ const nextMise = upsertLinkAgentfilesBlock(currentMise, ctx);
3450
4856
  if (nextMise !== currentMise) {
3451
4857
  if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
3452
4858
  if (!ctx.dryRun) writeText(misePath, nextMise);
3453
4859
  }
3454
- const versioningPath = join11(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
3455
- const expectedScript = templateVersioningScript(ctx);
3456
- if (expectedScript === void 0) {
3457
- 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: [] };
3458
- }
3459
- if (safeReadText(versioningPath) !== expectedScript) {
3460
- changedFiles.push(versioningPath);
3461
- if (!ctx.dryRun) {
3462
- writeText(versioningPath, expectedScript);
3463
- chmodSync2(versioningPath, 493);
3464
- }
3465
- }
3466
- const manifestPath = join11(ctx.repoRoot, ".mise", "version-files.conf");
3467
- const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
3468
- if (safeReadText(manifestPath) !== expectedManifest) {
3469
- changedFiles.push(manifestPath);
3470
- if (!ctx.dryRun) writeText(manifestPath, expectedManifest);
3471
- }
3472
4860
  return {
3473
4861
  id: finding.id,
3474
4862
  title: finding.title,
3475
4863
  status: changedFiles.length ? "applied" : "noop",
3476
- summary: changedFiles.length ? "Versioning block/script/manifest normalized" : "No changes required",
4864
+ summary: changedFiles.length ? "Skillex skills manifest contract normalized" : "No changes required",
3477
4865
  changedFiles,
3478
- details: []
4866
+ details
3479
4867
  };
3480
4868
  }
3481
4869
  },
@@ -3483,9 +4871,9 @@ var RULES = [
3483
4871
  id: "sot.agent-symlinks",
3484
4872
  title: "AGENTS/CLAUDE/GEMINI symlink contract",
3485
4873
  audit: (ctx) => {
3486
- const agentsPath = join11(ctx.repoRoot, "AGENTS.md");
3487
- if (!existsSync9(agentsPath)) {
3488
- const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync9(join11(ctx.repoRoot, file)));
4874
+ const agentsPath = join14(ctx.repoRoot, "AGENTS.md");
4875
+ if (!existsSync10(agentsPath)) {
4876
+ const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync10(join14(ctx.repoRoot, file)));
3489
4877
  if (fallbackSources.length === 0) {
3490
4878
  return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
3491
4879
  }
@@ -3500,7 +4888,7 @@ var RULES = [
3500
4888
  }
3501
4889
  const details = [];
3502
4890
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
3503
- const full = join11(ctx.repoRoot, file);
4891
+ const full = join14(ctx.repoRoot, file);
3504
4892
  const target = readSymlinkTarget(full);
3505
4893
  if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
3506
4894
  }
@@ -3524,7 +4912,7 @@ var RULES = [
3524
4912
  return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
3525
4913
  }
3526
4914
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
3527
- const full = join11(ctx.repoRoot, file);
4915
+ const full = join14(ctx.repoRoot, file);
3528
4916
  const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
3529
4917
  if (result.blocked) blockedDetails.push(result.blocked);
3530
4918
  if (result.changed) changedFiles.push(full);
@@ -3546,7 +4934,7 @@ var RULES = [
3546
4934
  migrate: (ctx, finding) => {
3547
4935
  const changedFiles = [];
3548
4936
  const details = [];
3549
- const path = join11(ctx.repoRoot, ".project.json");
4937
+ const path = join14(ctx.repoRoot, ".project.json");
3550
4938
  const existing = readProjectJson(ctx) ?? {};
3551
4939
  const canonical = canonicalProjectJson(ctx);
3552
4940
  const merged = { ...existing, ...canonical };
@@ -3556,11 +4944,11 @@ var RULES = [
3556
4944
  changedFiles.push(path);
3557
4945
  if (!ctx.dryRun) writeText(path, expected);
3558
4946
  }
3559
- const planeJson = join11(ctx.repoRoot, ".plane.json");
3560
- if (existsSync9(planeJson)) {
4947
+ const planeJson = join14(ctx.repoRoot, ".plane.json");
4948
+ if (existsSync10(planeJson)) {
3561
4949
  const backup = `${planeJson}.migrated-backup`;
3562
- if (existsSync9(backup)) {
3563
- details.push(`cannot back up .plane.json because ${relative(ctx.repoRoot, backup)} already exists`);
4950
+ if (existsSync10(backup)) {
4951
+ details.push(`cannot back up .plane.json because ${relative2(ctx.repoRoot, backup)} already exists`);
3564
4952
  } else {
3565
4953
  changedFiles.push(backup);
3566
4954
  if (!ctx.dryRun) renameSync2(planeJson, backup);
@@ -3581,17 +4969,21 @@ var RULES = [
3581
4969
  title: ".env.op + gitignore secrets contract",
3582
4970
  audit: (ctx) => {
3583
4971
  const details = [];
3584
- const envOp = safeReadText(join11(ctx.repoRoot, ".env.op"));
3585
- const gitignore = safeReadText(join11(ctx.repoRoot, ".gitignore"));
4972
+ const envOp = safeReadText(join14(ctx.repoRoot, ".env.op"));
4973
+ const gitignore = safeReadText(join14(ctx.repoRoot, ".gitignore"));
3586
4974
  if (!envOp) {
3587
4975
  details.push(".env.op missing");
3588
4976
  } else {
3589
- const invalidLines = envOp.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).filter((line) => {
4977
+ const malformed = malformedOpReferences(envOp);
4978
+ if (malformed.length) {
4979
+ details.push(`.env.op has malformed op:// reference(s) on line(s): ${Array.from(new Set(malformed.map((entry) => entry.line))).join(", ")}`);
4980
+ }
4981
+ const invalidLines = envOp.split("\n").map((line, index) => ({ line: line.trim(), number: index + 1 })).filter(({ line }) => line && !line.startsWith("#") && line.includes("=")).filter(({ line }) => {
3590
4982
  const value = line.slice(line.indexOf("=") + 1).trim();
3591
4983
  const quotedLiteral = /^"[^"\r\n]*"$/.test(value) || /^'[^'\r\n]*'$/.test(value);
3592
4984
  return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value) && !quotedLiteral;
3593
4985
  });
3594
- if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals: ${invalidLines.join(", ")}`);
4986
+ if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals on line(s): ${invalidLines.map((entry) => entry.number).join(", ")}`);
3595
4987
  }
3596
4988
  if (!gitignore?.includes(".env\n") && !gitignore?.includes(".env\r\n")) details.push(".gitignore should ignore .env");
3597
4989
  if (!gitignore?.includes(".env.*")) details.push(".gitignore should ignore .env.*");
@@ -3608,12 +5000,23 @@ var RULES = [
3608
5000
  migrate: (ctx, finding) => {
3609
5001
  const changedFiles = [];
3610
5002
  const details = [];
3611
- const envOpPath = join11(ctx.repoRoot, ".env.op");
3612
- if (!existsSync9(envOpPath)) {
5003
+ const envOpPath = join14(ctx.repoRoot, ".env.op");
5004
+ if (!existsSync10(envOpPath)) {
3613
5005
  changedFiles.push(envOpPath);
3614
- if (!ctx.dryRun) writeText(envOpPath, readText(join11(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
5006
+ if (!ctx.dryRun) writeText(envOpPath, readText(join14(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
5007
+ } else {
5008
+ const current = readText(envOpPath);
5009
+ const repaired = removeMalformedCommentOpReferences(current);
5010
+ if (repaired.changed) {
5011
+ changedFiles.push(envOpPath);
5012
+ if (!ctx.dryRun) writeText(envOpPath, repaired.text);
5013
+ }
5014
+ const remaining = malformedOpReferences(repaired.text).filter((entry) => !entry.commentOnly);
5015
+ if (remaining.length) {
5016
+ details.push(`Malformed active op:// reference(s) remain on line(s) ${Array.from(new Set(remaining.map((entry) => entry.line))).join(", ")}; repair them manually without replacing valid user references`);
5017
+ }
3615
5018
  }
3616
- const gitignorePath = join11(ctx.repoRoot, ".gitignore");
5019
+ const gitignorePath = join14(ctx.repoRoot, ".gitignore");
3617
5020
  const gitignore = safeReadText(gitignorePath) ?? "";
3618
5021
  const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
3619
5022
  # NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
@@ -3640,7 +5043,7 @@ var RULES = [
3640
5043
  title: ".copier-answers.yml provenance + drift report",
3641
5044
  audit: (ctx) => {
3642
5045
  const details = [];
3643
- const path = join11(ctx.repoRoot, ".copier-answers.yml");
5046
+ const path = join14(ctx.repoRoot, ".copier-answers.yml");
3644
5047
  const text3 = safeReadText(path);
3645
5048
  const project = readProjectJson(ctx);
3646
5049
  if (!text3) {
@@ -3671,12 +5074,12 @@ var RULES = [
3671
5074
  const changedFiles = [];
3672
5075
  const project = canonicalProjectJson(ctx);
3673
5076
  const text3 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
3674
- _src_path: ${join11(ctx.pjanglerRoot, "templates", "commonproject")}
5077
+ _src_path: ${join14(ctx.pjanglerRoot, "templates", "commonproject")}
3675
5078
  project_description: ${String(project.project_description)}
3676
5079
  project_name: ${String(project.project_name)}
3677
5080
  ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3678
5081
  `;
3679
- const path = join11(ctx.repoRoot, ".copier-answers.yml");
5082
+ const path = join14(ctx.repoRoot, ".copier-answers.yml");
3680
5083
  if (safeReadText(path) !== text3) {
3681
5084
  changedFiles.push(path);
3682
5085
  if (!ctx.dryRun) writeText(path, text3);
@@ -3695,14 +5098,21 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3695
5098
  id: "bmad.scaffold",
3696
5099
  title: "BMAD modules/docs scaffold",
3697
5100
  audit: (ctx) => {
3698
- const targetRoot = join11(ctx.repoRoot, "_bmad");
3699
- const sentinels = [
3700
- join11("core", "config.yaml"),
3701
- join11("config.toml"),
3702
- join11("_config", "manifest.yaml"),
3703
- join11("bmm", "config.yaml")
3704
- ];
3705
- const missing = sentinels.filter((file) => !existsSync9(join11(targetRoot, file)));
5101
+ const manifestSelection = manifestBmadModules(ctx.repoRoot);
5102
+ if (manifestSelection.status === "invalid") {
5103
+ return {
5104
+ id: "bmad.scaffold",
5105
+ title: "BMAD modules/docs scaffold",
5106
+ status: "fail",
5107
+ summary: "BMAD module manifest is invalid; refusing fallback module selection",
5108
+ details: [manifestSelection.error],
5109
+ fixable: false
5110
+ };
5111
+ }
5112
+ const targetRoot = join14(ctx.repoRoot, "_bmad");
5113
+ const selectedModules = manifestSelection.status === "valid" ? manifestSelection.modules : configuredBmadModules(ctx.repoRoot) ?? [...DEFAULT_BMAD_MODULES];
5114
+ const sentinels = requiredBmadSentinels(ctx.repoRoot, selectedModules);
5115
+ const missing = sentinels.filter((file) => !existsSync10(join14(targetRoot, file)));
3706
5116
  return {
3707
5117
  id: "bmad.scaffold",
3708
5118
  title: "BMAD modules/docs scaffold",
@@ -3714,9 +5124,21 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3714
5124
  },
3715
5125
  migrate: (ctx, finding) => {
3716
5126
  const changedFiles = [];
5127
+ const manifestSelection = manifestBmadModules(ctx.repoRoot);
5128
+ if (manifestSelection.status === "invalid") {
5129
+ return {
5130
+ id: finding.id,
5131
+ title: finding.title,
5132
+ status: "blocked",
5133
+ summary: "BMAD module manifest is invalid; refusing fallback module selection",
5134
+ changedFiles,
5135
+ details: [manifestSelection.error]
5136
+ };
5137
+ }
5138
+ const selectedModules = manifestSelection.status === "valid" ? manifestSelection.modules : configuredBmadModules(ctx.repoRoot) ?? [...DEFAULT_BMAD_MODULES];
3717
5139
  if (ctx.dryRun) {
3718
5140
  for (const detail of finding.details) {
3719
- changedFiles.push(join11(ctx.repoRoot, detail));
5141
+ changedFiles.push(join14(ctx.repoRoot, detail));
3720
5142
  }
3721
5143
  return {
3722
5144
  id: finding.id,
@@ -3725,11 +5147,14 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3725
5147
  summary: changedFiles.length ? "Would run non-interactive bmad-method install" : "No changes required",
3726
5148
  changedFiles,
3727
5149
  details: [
3728
- `Would run: npx ${bmadInstallArgs(ctx.repoRoot).join(" ").replace(BMAD_INSTALL_TOOLS.join(","), "...")}`
5150
+ `Would run: npx ${bmadInstallArgs(ctx.repoRoot, selectedModules).join(" ").replace(BMAD_INSTALL_TOOLS.join(","), "...")}`
3729
5151
  ]
3730
5152
  };
3731
5153
  }
3732
- const install = runBmadInstall(ctx.repoRoot);
5154
+ const preservedSkillsManifest = tryParseJson(
5155
+ safeReadText(join14(ctx.repoRoot, ".agents", "skills.json"))
5156
+ );
5157
+ const install = runBmadInstall(ctx.repoRoot, selectedModules);
3733
5158
  if (!install.ok) {
3734
5159
  return {
3735
5160
  id: finding.id,
@@ -3740,16 +5165,28 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3740
5165
  details: [install.error ?? "Unknown error"]
3741
5166
  };
3742
5167
  }
5168
+ const provisioned = provisionBmadSkills(ctx, preservedSkillsManifest);
5169
+ if (!provisioned.ok) {
5170
+ return {
5171
+ id: finding.id,
5172
+ title: finding.title,
5173
+ status: "blocked",
5174
+ summary: `BMAD installed but Skillex pack ${BMAD_PACK_VERSION} provisioning failed`,
5175
+ changedFiles: [],
5176
+ details: [provisioned.error ?? "Unknown BMAD pack error"]
5177
+ };
5178
+ }
3743
5179
  for (const detail of finding.details) {
3744
- if (existsSync9(join11(ctx.repoRoot, detail))) {
3745
- changedFiles.push(join11(ctx.repoRoot, detail));
5180
+ if (existsSync10(join14(ctx.repoRoot, detail))) {
5181
+ changedFiles.push(join14(ctx.repoRoot, detail));
3746
5182
  }
3747
5183
  }
5184
+ changedFiles.push(...provisioned.changedFiles);
3748
5185
  return {
3749
5186
  id: finding.id,
3750
5187
  title: finding.title,
3751
5188
  status: changedFiles.length ? "applied" : "noop",
3752
- summary: changedFiles.length ? "Installed BMAD scaffold via non-interactive installer" : "No changes required",
5189
+ summary: changedFiles.length ? `Installed BMAD scaffold with Skillex pack ${BMAD_PACK_VERSION} skills` : "No changes required",
3753
5190
  changedFiles,
3754
5191
  details: []
3755
5192
  };
@@ -3765,7 +5202,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3765
5202
  id: "bmad.version",
3766
5203
  title: "BMAD version currency",
3767
5204
  status: "skip",
3768
- summary: existsSync9(join11(ctx.repoRoot, "_bmad")) ? "BMAD installed but version manifest unreadable" : "No BMAD install present",
5205
+ summary: existsSync10(join14(ctx.repoRoot, "_bmad")) ? "BMAD installed but version manifest unreadable" : "No BMAD install present",
3769
5206
  details: [],
3770
5207
  fixable: false
3771
5208
  };
@@ -3820,7 +5257,19 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3820
5257
  }
3821
5258
  const installed = readInstalledBmadVersion(ctx.repoRoot);
3822
5259
  const available = resolveBmadDistTags(ctx.homeDir)?.distTags?.[BMAD_TARGET_CHANNEL];
3823
- const manifestPath = join11(ctx.repoRoot, "_bmad", "_config", "manifest.yaml");
5260
+ const manifestPath = join14(ctx.repoRoot, "_bmad", "_config", "manifest.yaml");
5261
+ const manifestSelection = manifestBmadModules(ctx.repoRoot);
5262
+ if (manifestSelection.status === "invalid") {
5263
+ return {
5264
+ id: finding.id,
5265
+ title: finding.title,
5266
+ status: "blocked",
5267
+ summary: "BMAD module manifest is invalid; refusing fallback module selection",
5268
+ changedFiles: [],
5269
+ details: [manifestSelection.error]
5270
+ };
5271
+ }
5272
+ const selectedModules = manifestSelection.status === "valid" ? manifestSelection.modules : configuredBmadModules(ctx.repoRoot) ?? [...DEFAULT_BMAD_MODULES];
3824
5273
  if (ctx.dryRun) {
3825
5274
  return {
3826
5275
  id: finding.id,
@@ -3829,11 +5278,14 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3829
5278
  summary: `Would upgrade BMAD ${installed ?? "?"} -> ${available ?? BMAD_TARGET_CHANNEL}`,
3830
5279
  changedFiles: [manifestPath],
3831
5280
  details: [
3832
- `Would run: npx ${bmadInstallArgs(ctx.repoRoot).join(" ").replace(BMAD_INSTALL_TOOLS.join(","), "...")}`
5281
+ `Would run: npx ${bmadInstallArgs(ctx.repoRoot, selectedModules).join(" ").replace(BMAD_INSTALL_TOOLS.join(","), "...")}`
3833
5282
  ]
3834
5283
  };
3835
5284
  }
3836
- const install = runBmadInstall(ctx.repoRoot);
5285
+ const preservedSkillsManifest = tryParseJson(
5286
+ safeReadText(join14(ctx.repoRoot, ".agents", "skills.json"))
5287
+ );
5288
+ const install = runBmadInstall(ctx.repoRoot, selectedModules);
3837
5289
  if (!install.ok) {
3838
5290
  return {
3839
5291
  id: finding.id,
@@ -3844,6 +5296,17 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3844
5296
  details: [install.error ?? "Unknown error"]
3845
5297
  };
3846
5298
  }
5299
+ const provisioned = provisionBmadSkills(ctx, preservedSkillsManifest);
5300
+ if (!provisioned.ok) {
5301
+ return {
5302
+ id: finding.id,
5303
+ title: finding.title,
5304
+ status: "blocked",
5305
+ summary: `BMAD upgraded but Skillex pack ${BMAD_PACK_VERSION} provisioning failed`,
5306
+ changedFiles: [],
5307
+ details: [provisioned.error ?? "Unknown BMAD pack error"]
5308
+ };
5309
+ }
3847
5310
  const nowInstalled = readInstalledBmadVersion(ctx.repoRoot);
3848
5311
  const upgraded = Boolean(nowInstalled && installed && compareBmadVersions(nowInstalled, installed) > 0);
3849
5312
  return {
@@ -3851,7 +5314,10 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3851
5314
  title: finding.title,
3852
5315
  status: upgraded ? "applied" : "noop",
3853
5316
  summary: upgraded ? `Upgraded BMAD ${installed} -> ${nowInstalled}` : `BMAD reinstalled (${nowInstalled ?? "?"})`,
3854
- changedFiles: upgraded ? [manifestPath] : [],
5317
+ changedFiles: Array.from(/* @__PURE__ */ new Set([
5318
+ ...upgraded ? [manifestPath] : [],
5319
+ ...provisioned.changedFiles
5320
+ ])),
3855
5321
  details: []
3856
5322
  };
3857
5323
  }
@@ -3866,12 +5332,13 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3866
5332
  return { id: "hermes.pm-scaffold", title: "Hermes PM scaffold parity", status: "skip", summary: "No pm role present", details: [], fixable: false };
3867
5333
  }
3868
5334
  const details = [];
3869
- 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"]) {
3870
- if (!existsSync9(join11(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join11(role.roleDir, rel))}`);
5335
+ for (const rel of ["role.yaml", "SOUL.md", "hermes", ".gitignore", ".scripts/70-systemd.sh", ".scripts/heartbeat.sh", ".scripts/checkpoint.sh", ".runtime-scaffold/README.md", "runtime/memories/MEMORY.md"]) {
5336
+ if (!existsSync10(join14(role.roleDir, rel))) details.push(`missing ${relative2(ctx.repoRoot, join14(role.roleDir, rel))}`);
3871
5337
  }
3872
- const gitmodules = safeReadText(join11(ctx.repoRoot, ".gitmodules")) ?? "";
3873
- if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing pm runtime submodule entry");
3874
- if (!profileMetaInheritsDefault(join11(role.roleDir, "runtime", "profile.yaml"))) {
5338
+ if (hasRuntimeSubmoduleMapping(ctx.repoRoot, role)) {
5339
+ details.push(".gitmodules contains retired pm runtime submodule mapping");
5340
+ }
5341
+ if (!profileMetaInheritsDefault(join14(role.roleDir, "runtime", "profile.yaml"))) {
3875
5342
  details.push("runtime/profile.yaml missing inherited default config metadata");
3876
5343
  }
3877
5344
  const registry = safeReadText(registryPath(ctx.homeDir));
@@ -3892,21 +5359,32 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3892
5359
  if (!role) {
3893
5360
  return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
3894
5361
  }
3895
- const templateRoleDir = join11(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
3896
- writeIfDifferent(join11(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
3897
- writeIfDifferent(join11(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
3898
- writeIfDifferent(join11(role.roleDir, ".gitignore"), readText(join11(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
3899
- copyMissingRecursive(join11(templateRoleDir, ".runtime-scaffold"), join11(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
3900
- copyMissingRecursive(join11(templateRoleDir, ".runtime-scaffold"), join11(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
3901
- copyMissingRecursive(join11(templateRoleDir, ".scripts"), join11(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
3902
- const promptSource = join11(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
3903
- const promptTarget = join11(role.roleDir, ".scripts", "sentinel.prompt.md");
3904
- if (existsSync9(promptSource) && !existsSync9(promptTarget)) {
5362
+ const retirement = retireRuntimeSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
5363
+ details.push(...retirement.details);
5364
+ if (!retirement.ok) {
5365
+ return {
5366
+ id: finding.id,
5367
+ title: finding.title,
5368
+ status: "blocked",
5369
+ summary: "Failed to retire PM runtime submodule metadata safely",
5370
+ changedFiles,
5371
+ details: [retirement.error ?? "unknown runtime retirement failure"]
5372
+ };
5373
+ }
5374
+ const templateRoleDir = join14(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
5375
+ writeIfDifferent(join14(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
5376
+ writeIfDifferent(join14(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
5377
+ writeIfDifferent(join14(role.roleDir, ".gitignore"), readText(join14(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
5378
+ copyMissingRecursive(join14(templateRoleDir, ".runtime-scaffold"), join14(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
5379
+ copyMissingRecursive(join14(templateRoleDir, ".runtime-scaffold"), join14(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
5380
+ copyMissingRecursive(join14(templateRoleDir, ".scripts"), join14(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
5381
+ const promptSource = join14(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
5382
+ const promptTarget = join14(role.roleDir, ".scripts", "sentinel.prompt.md");
5383
+ if (existsSync10(promptSource) && !existsSync10(promptTarget)) {
3905
5384
  const prompt = readText(promptSource).replace(/\{\{ agent_id \}\}/g, role.agentId).replace(/\{\{ role \}\}/g, role.role).replace(/\{\{ target_repo \}\}/g, role.repo).replace(/\{\{ display_name \}\}/g, role.displayName || role.agentId);
3906
5385
  writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
3907
5386
  }
3908
- upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
3909
- const profileMetaUpdated = upsertInheritedProfileMeta(join11(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
5387
+ const profileMetaUpdated = upsertInheritedProfileMeta(join14(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
3910
5388
  if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
3911
5389
  const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
3912
5390
  if (registryUpdated) details.push(`updated ${registryUpdated}`);
@@ -3937,8 +5415,8 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3937
5415
  }
3938
5416
  const details = [];
3939
5417
  for (const role of roles) {
3940
- const roleRelDir = relative(ctx.repoRoot, role.roleDir);
3941
- const runtimeRelPath = join11(roleRelDir, "runtime");
5418
+ const roleRelDir = relative2(ctx.repoRoot, role.roleDir);
5419
+ const runtimeRelPath = join14(roleRelDir, "runtime");
3942
5420
  const lsResult = spawnSync6("git", ["ls-files", "--stage", runtimeRelPath], {
3943
5421
  cwd: ctx.repoRoot,
3944
5422
  encoding: "utf8"
@@ -3946,15 +5424,18 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3946
5424
  if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
3947
5425
  details.push(`submodule runtime is tracked in Git index at ${runtimeRelPath}`);
3948
5426
  }
3949
- const gitignorePath = join11(role.roleDir, ".gitignore");
3950
- if (existsSync9(gitignorePath)) {
5427
+ if (hasRuntimeSubmoduleMapping(ctx.repoRoot, role)) {
5428
+ details.push(`stale .gitmodules mapping exists for ${runtimeRelPath}`);
5429
+ }
5430
+ const gitignorePath = join14(role.roleDir, ".gitignore");
5431
+ if (existsSync10(gitignorePath)) {
3951
5432
  const content = safeReadText(gitignorePath) ?? "";
3952
5433
  const lines = content.split(/\r?\n/).map((line) => line.trim());
3953
5434
  if (!lines.includes("runtime/") && !lines.includes("runtime")) {
3954
- details.push(`.gitignore missing runtime/ ignore entry in ${relative(ctx.repoRoot, gitignorePath)}`);
5435
+ details.push(`.gitignore missing runtime/ ignore entry in ${relative2(ctx.repoRoot, gitignorePath)}`);
3955
5436
  }
3956
5437
  } else {
3957
- details.push(`.gitignore is missing in ${relative(ctx.repoRoot, gitignorePath)}`);
5438
+ details.push(`.gitignore is missing in ${relative2(ctx.repoRoot, gitignorePath)}`);
3958
5439
  }
3959
5440
  }
3960
5441
  return {
@@ -3971,32 +5452,28 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3971
5452
  const changedFiles = [];
3972
5453
  const details = [];
3973
5454
  for (const role of roles) {
3974
- const roleRelDir = relative(ctx.repoRoot, role.roleDir);
3975
- const runtimeRelPath = join11(roleRelDir, "runtime");
3976
- const lsResult = spawnSync6("git", ["ls-files", "--stage", runtimeRelPath], {
3977
- cwd: ctx.repoRoot,
3978
- encoding: "utf8"
3979
- });
3980
- if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
3981
- details.push(`untrack ${runtimeRelPath}`);
3982
- changedFiles.push(runtimeRelPath);
3983
- if (!ctx.dryRun) {
3984
- spawnSync6("git", ["rm", "--cached", "-r", runtimeRelPath], {
3985
- cwd: ctx.repoRoot,
3986
- encoding: "utf8"
3987
- });
3988
- }
5455
+ const retirement = retireRuntimeSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
5456
+ details.push(...retirement.details);
5457
+ if (!retirement.ok) {
5458
+ return {
5459
+ id: finding.id,
5460
+ title: finding.title,
5461
+ status: "blocked",
5462
+ summary: "Failed to retire Hermes runtime submodule metadata safely",
5463
+ changedFiles,
5464
+ details: [retirement.error ?? "unknown runtime retirement failure"]
5465
+ };
3989
5466
  }
3990
- const gitignorePath = join11(role.roleDir, ".gitignore");
5467
+ const gitignorePath = join14(role.roleDir, ".gitignore");
3991
5468
  let content = "";
3992
5469
  let isIgnored = false;
3993
- if (existsSync9(gitignorePath)) {
5470
+ if (existsSync10(gitignorePath)) {
3994
5471
  content = safeReadText(gitignorePath) ?? "";
3995
5472
  const lines = content.split(/\r?\n/).map((line) => line.trim());
3996
5473
  isIgnored = lines.includes("runtime/") || lines.includes("runtime");
3997
5474
  }
3998
5475
  if (!isIgnored) {
3999
- details.push(`ignore runtime/ in ${relative(ctx.repoRoot, gitignorePath)}`);
5476
+ details.push(`ignore runtime/ in ${relative2(ctx.repoRoot, gitignorePath)}`);
4000
5477
  changedFiles.push(gitignorePath);
4001
5478
  if (!ctx.dryRun) {
4002
5479
  if (content && !content.endsWith("\n")) {
@@ -4031,7 +5508,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4031
5508
  }
4032
5509
  const details = [];
4033
5510
  for (const role of roles) {
4034
- for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
5511
+ for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
4035
5512
  const state = checkUnit(unit);
4036
5513
  if (!state.enabled || !state.active) details.push(`${unit} should be enabled+active`);
4037
5514
  }
@@ -4057,9 +5534,9 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4057
5534
  return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
4058
5535
  }
4059
5536
  for (const role of roles) {
4060
- const sysDir = join11(ctx.homeDir, ".config", "systemd", "user");
4061
- const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
4062
- const allUnitsPresent = units.every((unit) => existsSync9(join11(sysDir, unit)));
5537
+ const sysDir = join14(ctx.homeDir, ".config", "systemd", "user");
5538
+ const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-heartbeat.timer`];
5539
+ const allUnitsPresent = units.every((unit) => existsSync10(join14(sysDir, unit)));
4063
5540
  if (allUnitsPresent) {
4064
5541
  if (ctx.dryRun) {
4065
5542
  details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
@@ -4071,8 +5548,8 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4071
5548
  }
4072
5549
  continue;
4073
5550
  }
4074
- for (const script of [join11(role.roleDir, ".scripts", "70-systemd.sh")]) {
4075
- if (!script || !existsSync9(script)) continue;
5551
+ for (const script of [join14(role.roleDir, ".scripts", "70-systemd.sh")]) {
5552
+ if (!script || !existsSync10(script)) continue;
4076
5553
  if (ctx.dryRun) {
4077
5554
  details.push(`would run: bash ${script}`);
4078
5555
  } else {
@@ -4090,6 +5567,349 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4090
5567
  details
4091
5568
  };
4092
5569
  }
5570
+ },
5571
+ {
5572
+ id: "hermes.runtime-singleton",
5573
+ title: "Hermes singleton runtime (shared config/auth, per-agent memory)",
5574
+ audit: (ctx) => {
5575
+ const roles = discoverRoles(ctx.repoRoot);
5576
+ if (!roles.length) {
5577
+ return { id: "hermes.runtime-singleton", title: "Hermes singleton runtime (shared config/auth, per-agent memory)", status: "skip", summary: "No Hermes roles present", details: [], fixable: false };
5578
+ }
5579
+ const details = [];
5580
+ for (const role of roles) {
5581
+ const plan = singletonPlan(ctx, role);
5582
+ if (!existsSync10(plan.fleetRoot)) {
5583
+ details.push(`fleet root missing at ${plan.fleetRoot}`);
5584
+ continue;
5585
+ }
5586
+ if (!existsSync10(plan.profileDir)) {
5587
+ details.push(`profile dir missing: ${plan.profileDir}`);
5588
+ } else if (lstatSync2(plan.profileDir).isSymbolicLink()) {
5589
+ details.push(`profile dir is a symlink (must be a real dir): ${plan.profileDir}`);
5590
+ }
5591
+ for (const link of plan.links) {
5592
+ const state = linkState(link.path, link.target);
5593
+ if (state !== "ok") details.push(`${state}: ${link.path} -> ${link.target}`);
5594
+ }
5595
+ }
5596
+ return {
5597
+ id: "hermes.runtime-singleton",
5598
+ title: "Hermes singleton runtime (shared config/auth, per-agent memory)",
5599
+ status: details.length === 0 ? "pass" : "fail",
5600
+ summary: details.length === 0 ? "Singleton runtime contract satisfied" : `${details.length} singleton-runtime issue(s) detected`,
5601
+ details,
5602
+ fixable: true
5603
+ };
5604
+ },
5605
+ migrate: (ctx, finding) => {
5606
+ const roles = discoverRoles(ctx.repoRoot);
5607
+ const changedFiles = [];
5608
+ const details = [];
5609
+ for (const role of roles) {
5610
+ const plan = singletonPlan(ctx, role);
5611
+ if (!existsSync10(plan.fleetRoot)) {
5612
+ details.push(`blocked: fleet root missing at ${plan.fleetRoot}`);
5613
+ continue;
5614
+ }
5615
+ for (const shared of plan.sharedSeeds) {
5616
+ if (existsSync10(shared.rootPath)) continue;
5617
+ const donor = existsSync10(shared.runtimePath) ? shared.runtimePath : null;
5618
+ if (!donor) continue;
5619
+ details.push(`seed fleet ${basename4(shared.rootPath)} from ${donor}`);
5620
+ changedFiles.push(shared.rootPath);
5621
+ if (!ctx.dryRun) copyFileSync(donor, shared.rootPath);
5622
+ }
5623
+ if (existsSync10(plan.profileDir) && lstatSync2(plan.profileDir).isSymbolicLink()) {
5624
+ details.push(`convert profile symlink to real dir: ${plan.profileDir}`);
5625
+ changedFiles.push(plan.profileDir);
5626
+ if (!ctx.dryRun) unlinkSync3(plan.profileDir);
5627
+ }
5628
+ if (!existsSync10(plan.profileDir)) {
5629
+ details.push(`create profile dir: ${plan.profileDir}`);
5630
+ changedFiles.push(plan.profileDir);
5631
+ if (!ctx.dryRun) mkdirSync6(plan.profileDir, { recursive: true });
5632
+ }
5633
+ for (const link of plan.links) {
5634
+ const state = linkState(link.path, link.target);
5635
+ if (state === "ok") continue;
5636
+ if (link.ensureTargetDir && !existsSync10(link.target) && !ctx.dryRun) {
5637
+ mkdirSync6(link.target, { recursive: true });
5638
+ }
5639
+ details.push(`link ${link.path} -> ${link.target}`);
5640
+ changedFiles.push(link.path);
5641
+ if (ctx.dryRun) continue;
5642
+ if (existsSync10(link.path) || isDanglingLink(link.path)) {
5643
+ const lst = lstatSync2(link.path);
5644
+ if (lst.isSymbolicLink()) {
5645
+ unlinkSync3(link.path);
5646
+ } else {
5647
+ const parked = `${link.path}.pre-singleton`;
5648
+ renameSync2(link.path, parked);
5649
+ details.push(`parked pre-existing ${link.path} at ${parked}`);
5650
+ }
5651
+ }
5652
+ ensureParent(link.path);
5653
+ symlinkSync(link.target, link.path);
5654
+ }
5655
+ }
5656
+ return {
5657
+ id: finding.id,
5658
+ title: finding.title,
5659
+ status: details.some((d) => d.startsWith("blocked:")) ? "blocked" : changedFiles.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
5660
+ summary: changedFiles.length ? ctx.dryRun ? "Planned singleton-runtime wiring" : "Singleton runtime wired" : "No changes required",
5661
+ changedFiles,
5662
+ details
5663
+ };
5664
+ }
5665
+ },
5666
+ {
5667
+ id: "hermes.profile-wiring",
5668
+ title: "Launcher + systemd HERMES_HOME points at the named profile",
5669
+ audit: (ctx) => {
5670
+ const roles = discoverRoles(ctx.repoRoot);
5671
+ if (!roles.length) {
5672
+ return { id: "hermes.profile-wiring", title: "Launcher + systemd HERMES_HOME points at the named profile", status: "skip", summary: "No Hermes roles present", details: [], fixable: false };
5673
+ }
5674
+ const details = [];
5675
+ for (const role of roles) {
5676
+ const plan = singletonPlan(ctx, role);
5677
+ const launcher = join14(role.roleDir, "hermes");
5678
+ const text3 = safeReadText(launcher);
5679
+ if (text3 === null) {
5680
+ details.push(`launcher missing: ${relative2(ctx.repoRoot, launcher)}`);
5681
+ } else {
5682
+ if (/^HERMES_HOME="\$RUNTIME_HOME"\s*$/m.test(text3)) {
5683
+ details.push(`launcher sets HERMES_HOME to the raw runtime path (disables shared auth + profile identity): ${relative2(ctx.repoRoot, launcher)}`);
5684
+ }
5685
+ if (/HERMES_OAUTH_FILE/.test(text3)) {
5686
+ details.push(`launcher exports HERMES_OAUTH_FILE, which Hermes does not implement (dead config): ${relative2(ctx.repoRoot, launcher)}`);
5687
+ }
5688
+ }
5689
+ for (const unit of profileUnits(role)) {
5690
+ const unitPath = join14(ctx.homeDir, ".config", "systemd", "user", unit);
5691
+ const unitText = safeReadText(unitPath);
5692
+ if (unitText === null) continue;
5693
+ const current = /^Environment=HERMES_HOME=(.*)$/m.exec(unitText)?.[1]?.trim();
5694
+ if (current && current !== plan.profileDir) {
5695
+ details.push(`${unit} HERMES_HOME=${current} (expected ${plan.profileDir})`);
5696
+ }
5697
+ if (/^Environment=HERMES_OAUTH_FILE=/m.test(unitText)) {
5698
+ details.push(`${unit} sets HERMES_OAUTH_FILE (dead config)`);
5699
+ }
5700
+ }
5701
+ }
5702
+ return {
5703
+ id: "hermes.profile-wiring",
5704
+ title: "Launcher + systemd HERMES_HOME points at the named profile",
5705
+ status: details.length === 0 ? "pass" : "fail",
5706
+ summary: details.length === 0 ? "HERMES_HOME wiring is in parity" : `${details.length} HERMES_HOME wiring issue(s) detected`,
5707
+ details,
5708
+ fixable: true
5709
+ };
5710
+ },
5711
+ migrate: (ctx, finding) => {
5712
+ const roles = discoverRoles(ctx.repoRoot);
5713
+ const changedFiles = [];
5714
+ const details = [];
5715
+ let unitsTouched = false;
5716
+ for (const role of roles) {
5717
+ const plan = singletonPlan(ctx, role);
5718
+ const launcher = join14(role.roleDir, "hermes");
5719
+ const text3 = safeReadText(launcher);
5720
+ if (text3 !== null) {
5721
+ const rewritten = rewriteLauncher(text3);
5722
+ if (rewritten !== text3) {
5723
+ details.push(`rewrite launcher HERMES_HOME -> profile path: ${relative2(ctx.repoRoot, launcher)}`);
5724
+ writeIfDifferent(launcher, rewritten, ctx.dryRun, changedFiles, 493);
5725
+ }
5726
+ }
5727
+ for (const unit of profileUnits(role)) {
5728
+ const unitPath = join14(ctx.homeDir, ".config", "systemd", "user", unit);
5729
+ const unitText = safeReadText(unitPath);
5730
+ if (unitText === null) continue;
5731
+ let next = unitText.replace(/^Environment=HERMES_HOME=.*$/m, `Environment=HERMES_HOME=${plan.profileDir}`);
5732
+ next = next.replace(/^Environment=HERMES_OAUTH_FILE=.*\n/m, "");
5733
+ if (next !== unitText) {
5734
+ details.push(`repoint ${unit} HERMES_HOME -> ${plan.profileDir}`);
5735
+ writeIfDifferent(unitPath, next, ctx.dryRun, changedFiles);
5736
+ unitsTouched = true;
5737
+ }
5738
+ }
5739
+ }
5740
+ if (unitsTouched && !ctx.dryRun) {
5741
+ systemctlUser(["daemon-reload"]);
5742
+ details.push("systemctl --user daemon-reload (restart units to pick up the new HERMES_HOME)");
5743
+ }
5744
+ return {
5745
+ id: finding.id,
5746
+ title: finding.title,
5747
+ status: changedFiles.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
5748
+ summary: changedFiles.length ? ctx.dryRun ? "Planned HERMES_HOME rewiring" : "HERMES_HOME rewired to named profiles" : "No changes required",
5749
+ changedFiles,
5750
+ details
5751
+ };
5752
+ }
5753
+ },
5754
+ {
5755
+ id: "hermes.registry-parity",
5756
+ title: "Fleet registry matches .project.json (no duplicate or stale agents)",
5757
+ audit: (ctx) => {
5758
+ const roles = discoverRoles(ctx.repoRoot);
5759
+ const details = [];
5760
+ const registryPath2 = join14(ctx.homeDir, ".hermes", "agents-registry.yaml");
5761
+ const registry = readRegistry(registryPath2);
5762
+ if (!registry) {
5763
+ if (!roles.length && declaredAgentIds(ctx.repoRoot).length === 0) {
5764
+ return { id: "hermes.registry-parity", title: "Fleet registry matches .project.json (no duplicate or stale agents)", status: "skip", summary: "No Hermes roles or declared agents present", details: [], fixable: false };
5765
+ }
5766
+ return { id: "hermes.registry-parity", title: "Fleet registry matches .project.json (no duplicate or stale agents)", status: "warn", summary: `registry unreadable at ${registryPath2}`, details: [], fixable: false };
5767
+ }
5768
+ const canonical = new Set(roles.map((role) => role.agentId).filter(Boolean));
5769
+ const owned = ownedRegistryEntries(registry, ctx.repoRoot);
5770
+ const unprovisioned = unprovisionedRoleAgents(registry, ctx.repoRoot, canonical);
5771
+ if (unprovisioned.length) {
5772
+ return {
5773
+ id: "hermes.registry-parity",
5774
+ title: "Fleet registry matches .project.json (no duplicate or stale agents)",
5775
+ status: "fail",
5776
+ summary: `${unprovisioned.length} unprovisioned Hermes role blocker(s) detected`,
5777
+ details: unprovisioned.map(
5778
+ ({ agentId, roleDir, sources }) => `agent "${agentId}" (${sources.join(" + ")}) has no role.yaml${roleDir ? ` at ${roleDir}` : ""}; provision or restore the role, do not delete its registry/declaration`
5779
+ ),
5780
+ fixable: false
5781
+ };
5782
+ }
5783
+ if (canonical.size === 0) {
5784
+ return { id: "hermes.registry-parity", title: "Fleet registry matches .project.json (no duplicate or stale agents)", status: "skip", summary: "No Hermes roles, declarations, or registry entries present", details: [], fixable: false };
5785
+ }
5786
+ for (const [agentId, entry] of owned) {
5787
+ const roleDir = String(entry?.role_dir ?? "");
5788
+ if (!canonical.has(agentId)) {
5789
+ details.push(`stale/duplicate registry agent "${agentId}" for ${roleDir} (role.yaml declares ${[...canonical].join(", ")})`);
5790
+ }
5791
+ }
5792
+ for (const extra of declaredAgentIds(ctx.repoRoot).filter((id) => !canonical.has(id))) {
5793
+ details.push(`.project.json declares agent "${extra}" that no role.yaml claims`);
5794
+ }
5795
+ for (const role of roles) {
5796
+ const entry = registry[role.agentId];
5797
+ if (!entry) {
5798
+ details.push(`registry is missing an entry for ${role.agentId}`);
5799
+ continue;
5800
+ }
5801
+ const entryRoleDir = String(entry.role_dir ?? "");
5802
+ if (entryRoleDir && realOrSelf(entryRoleDir) !== realOrSelf(role.roleDir)) {
5803
+ details.push(`registry role_dir for ${role.agentId} is ${entryRoleDir} (expected ${role.roleDir})`);
5804
+ }
5805
+ const bin = String(entry.hermes?.bin ?? "");
5806
+ if (bin && !existsSync10(bin)) {
5807
+ details.push(`registry hermes.bin for ${role.agentId} does not exist: ${bin}`);
5808
+ }
5809
+ }
5810
+ return {
5811
+ id: "hermes.registry-parity",
5812
+ title: "Fleet registry matches .project.json (no duplicate or stale agents)",
5813
+ status: details.length === 0 ? "pass" : "fail",
5814
+ summary: details.length === 0 ? "Fleet registry is in parity" : `${details.length} registry parity issue(s) detected`,
5815
+ details,
5816
+ fixable: true
5817
+ };
5818
+ },
5819
+ migrate: (ctx, finding) => {
5820
+ const changedFiles = [];
5821
+ const details = [];
5822
+ const registryPath2 = join14(ctx.homeDir, ".hermes", "agents-registry.yaml");
5823
+ const raw = safeReadText(registryPath2);
5824
+ if (raw === null) {
5825
+ return { id: finding.id, title: finding.title, status: "blocked", summary: `registry unreadable at ${registryPath2}`, changedFiles, details };
5826
+ }
5827
+ let doc;
5828
+ try {
5829
+ doc = YAML3.parse(raw);
5830
+ } catch {
5831
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "registry is not valid YAML", changedFiles, details };
5832
+ }
5833
+ const agents = doc?.agents ?? {};
5834
+ const roles = discoverRoles(ctx.repoRoot);
5835
+ const canonical = new Set(roles.map((role) => role.agentId).filter(Boolean));
5836
+ const unprovisioned = unprovisionedRoleAgents(agents, ctx.repoRoot, canonical);
5837
+ if (unprovisioned.length) {
5838
+ return {
5839
+ id: finding.id,
5840
+ title: finding.title,
5841
+ status: "blocked",
5842
+ summary: "Registry parity is blocked by an unprovisioned Hermes role",
5843
+ changedFiles,
5844
+ details: unprovisioned.map(
5845
+ ({ agentId, roleDir, sources }) => `blocked: "${agentId}" (${sources.join(" + ")}) has no role.yaml${roleDir ? ` at ${roleDir}` : ""}; provision or restore the role without pruning registry/declaration state`
5846
+ )
5847
+ };
5848
+ }
5849
+ const fleetBin = fleetBinPath(ctx);
5850
+ let dirty = false;
5851
+ if (canonical.size === 0) {
5852
+ for (const [agentId] of ownedRegistryEntries(agents, ctx.repoRoot)) {
5853
+ details.push(`blocked: "${agentId}" has no role.yaml; provision the role instead of pruning the registry`);
5854
+ }
5855
+ for (const agentId of declaredAgentIds(ctx.repoRoot)) {
5856
+ if (!details.some((detail) => detail.includes(`"${agentId}"`))) {
5857
+ details.push(`blocked: "${agentId}" is declared but has no role.yaml; provision or restore the role`);
5858
+ }
5859
+ }
5860
+ if (details.length) {
5861
+ return {
5862
+ id: finding.id,
5863
+ title: finding.title,
5864
+ status: "blocked",
5865
+ summary: "Registry parity is blocked by an unprovisioned Hermes role",
5866
+ changedFiles,
5867
+ details
5868
+ };
5869
+ }
5870
+ }
5871
+ for (const [agentId, entry] of ownedRegistryEntries(agents, ctx.repoRoot)) {
5872
+ if (canonical.size > 0 && !canonical.has(agentId)) {
5873
+ details.push(`drop stale/duplicate registry agent "${agentId}"`);
5874
+ delete agents[agentId];
5875
+ dropDeclaredAgent(ctx, agentId, changedFiles, details);
5876
+ dirty = true;
5877
+ continue;
5878
+ }
5879
+ const hermes = entry.hermes ?? {};
5880
+ if (fleetBin && String(hermes.bin ?? "") !== fleetBin && !existsSync10(String(hermes.bin ?? ""))) {
5881
+ details.push(`repoint ${agentId} hermes.bin -> ${fleetBin}`);
5882
+ hermes.bin = fleetBin;
5883
+ entry.hermes = hermes;
5884
+ dirty = true;
5885
+ }
5886
+ if (hermes.oauth_file) {
5887
+ details.push(`drop dead hermes.oauth_file from ${agentId}`);
5888
+ delete hermes.oauth_file;
5889
+ dirty = true;
5890
+ }
5891
+ }
5892
+ if (canonical.size > 0) {
5893
+ for (const extra of declaredAgentIds(ctx.repoRoot).filter((id) => !canonical.has(id))) {
5894
+ dropDeclaredAgent(ctx, extra, changedFiles, details);
5895
+ }
5896
+ }
5897
+ if (dirty) {
5898
+ changedFiles.push(registryPath2);
5899
+ if (!ctx.dryRun) {
5900
+ doc.agents = agents;
5901
+ writeText(registryPath2, YAML3.stringify(doc));
5902
+ }
5903
+ }
5904
+ return {
5905
+ id: finding.id,
5906
+ title: finding.title,
5907
+ status: changedFiles.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
5908
+ summary: changedFiles.length ? ctx.dryRun ? "Planned registry repair" : "Fleet registry repaired" : "No changes required",
5909
+ changedFiles,
5910
+ details
5911
+ };
5912
+ }
4093
5913
  }
4094
5914
  ];
4095
5915
  function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
@@ -4099,7 +5919,7 @@ function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
4099
5919
  changedFiles.push(path);
4100
5920
  if (!dryRun) {
4101
5921
  writeText(path, normalized);
4102
- if (mode) chmodSync2(path, mode);
5922
+ if (mode) chmodSync4(path, mode);
4103
5923
  }
4104
5924
  }
4105
5925
  function getParityRuleIds() {
@@ -4108,7 +5928,7 @@ function getParityRuleIds() {
4108
5928
  function runAudit(repoArg) {
4109
5929
  const pjanglerRoot = resolvePjanglerRoot2();
4110
5930
  const ctx = {
4111
- repoRoot: resolve2(repoArg ?? process.cwd()),
5931
+ repoRoot: resolve3(repoArg ?? process.cwd()),
4112
5932
  dryRun: true,
4113
5933
  pjanglerRoot,
4114
5934
  homeDir: homedir5()
@@ -4124,7 +5944,7 @@ function runAudit(repoArg) {
4124
5944
  function runMigrationForRules(ruleIds, repoArg, dryRun) {
4125
5945
  const pjanglerRoot = resolvePjanglerRoot2();
4126
5946
  const ctx = {
4127
- repoRoot: resolve2(repoArg ?? process.cwd()),
5947
+ repoRoot: resolve3(repoArg ?? process.cwd()),
4128
5948
  dryRun,
4129
5949
  pjanglerRoot,
4130
5950
  homeDir: homedir5()
@@ -4158,7 +5978,22 @@ function runMigrationForRules(ruleIds, repoArg, dryRun) {
4158
5978
  };
4159
5979
  }
4160
5980
  function runMigration(selector, repoArg, dryRun, all) {
4161
- const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
5981
+ if (all) {
5982
+ const audit = runAudit(repoArg);
5983
+ const ruleIds2 = audit.rules.filter((finding) => finding.fixable && (finding.status === "fail" || finding.status === "warn")).map((finding) => finding.id);
5984
+ if (ruleIds2.length === 0) {
5985
+ return {
5986
+ repo: audit.repo,
5987
+ dryRun,
5988
+ ok: true,
5989
+ selectedRules: [],
5990
+ results: [],
5991
+ changedFiles: []
5992
+ };
5993
+ }
5994
+ return runMigrationForRules(ruleIds2, repoArg, dryRun);
5995
+ }
5996
+ const ruleIds = selector ? [selector] : [];
4162
5997
  return runMigrationForRules(ruleIds, repoArg, dryRun);
4163
5998
  }
4164
5999
  function prettyTimestamp(iso) {
@@ -4211,15 +6046,15 @@ function formatMigrationReport(report) {
4211
6046
  }
4212
6047
 
4213
6048
  // src/utils/version.ts
4214
- import { readFileSync as readFileSync6 } from "node:fs";
4215
- import { dirname as dirname7, join as join12 } from "node:path";
6049
+ import { readFileSync as readFileSync8 } from "node:fs";
6050
+ import { dirname as dirname7, join as join15 } from "node:path";
4216
6051
  import { fileURLToPath as fileURLToPath4 } from "node:url";
4217
6052
  var PJANGLER_VERSION = (() => {
4218
6053
  try {
4219
6054
  let dir = dirname7(fileURLToPath4(import.meta.url));
4220
6055
  for (let i = 0; i < 4; i++) {
4221
6056
  try {
4222
- const raw = readFileSync6(join12(dir, "package.json"), "utf8");
6057
+ const raw = readFileSync8(join15(dir, "package.json"), "utf8");
4223
6058
  return JSON.parse(raw).version ?? "0.0.0";
4224
6059
  } catch {
4225
6060
  const parent = dirname7(dir);
@@ -4262,9 +6097,9 @@ async function promptForRuleIds(rules) {
4262
6097
  return selected;
4263
6098
  }
4264
6099
  function readJson(path) {
4265
- if (!existsSync10(path)) return void 0;
6100
+ if (!existsSync11(path)) return void 0;
4266
6101
  try {
4267
- const parsed = JSON.parse(readFileSync7(path, "utf8"));
6102
+ const parsed = JSON.parse(readFileSync9(path, "utf8"));
4268
6103
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
4269
6104
  } catch {
4270
6105
  return void 0;
@@ -4273,7 +6108,7 @@ function readJson(path) {
4273
6108
  function findGitRoot(cwd) {
4274
6109
  const result = spawnSync7("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
4275
6110
  if (result.status !== 0) return void 0;
4276
- return resolve3(result.stdout.trim());
6111
+ return resolve4(result.stdout.trim());
4277
6112
  }
4278
6113
  function packageNameToProjectName(value) {
4279
6114
  if (!value) return void 0;
@@ -4281,9 +6116,9 @@ function packageNameToProjectName(value) {
4281
6116
  return name.replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()).trim();
4282
6117
  }
4283
6118
  function deriveProjectDefaults(targetDir) {
4284
- const manifest = readJson(join13(targetDir, ".project.json"));
4285
- const pkg = readJson(join13(targetDir, "package.json"));
4286
- const name = String(manifest?.project_name ?? "").trim() || packageNameToProjectName(typeof pkg?.name === "string" ? pkg.name : void 0) || packageNameToProjectName(basename4(targetDir)) || "Project";
6119
+ const manifest = readJson(join16(targetDir, ".project.json"));
6120
+ const pkg = readJson(join16(targetDir, "package.json"));
6121
+ const name = String(manifest?.project_name ?? "").trim() || packageNameToProjectName(typeof pkg?.name === "string" ? pkg.name : void 0) || packageNameToProjectName(basename5(targetDir)) || "Project";
4287
6122
  const ticketProvider = manifest?.ticket_provider && typeof manifest.ticket_provider === "object" ? manifest.ticket_provider : {};
4288
6123
  return {
4289
6124
  name,
@@ -4338,7 +6173,7 @@ function actionNeedsRun(plan, kind, syncMode) {
4338
6173
  if (!action || action.kind !== "project.write-manifest") return false;
4339
6174
  const next = `${JSON.stringify(action.manifest, null, 2)}
4340
6175
  `;
4341
- return !existsSync10(action.path) || readFileSync7(action.path, "utf8") !== next;
6176
+ return !existsSync11(action.path) || readFileSync9(action.path, "utf8") !== next;
4342
6177
  }
4343
6178
  if (kind === "copier.copy.commonproject") return true;
4344
6179
  if (kind === "ticket-provider.create-or-link") return plan.actions.some((action) => action.kind === kind && action.enabled);
@@ -4386,26 +6221,26 @@ async function resolveProjectInitTarget(name, options) {
4386
6221
  const interactive = isInteractiveProjectInit(options);
4387
6222
  const cwd = process.cwd();
4388
6223
  const cwdGitRoot = findGitRoot(cwd);
4389
- let targetDir = options.targetDir ? resolve3(options.targetDir) : void 0;
6224
+ let targetDir = options.targetDir ? resolve4(options.targetDir) : void 0;
4390
6225
  if (!targetDir && cwdGitRoot) {
4391
6226
  targetDir = cwdGitRoot;
4392
6227
  }
4393
6228
  if (!targetDir && interactive) {
4394
- const defaultName = name ?? basename4(cwd);
6229
+ const defaultName = name ?? basename5(cwd);
4395
6230
  const promptedName = name ?? await promptTextValue("Project name", packageNameToProjectName(defaultName));
4396
- const defaultDir = join13(cwd, promptedName.replace(/[^A-Za-z0-9._-]/g, "") || promptedName.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
6231
+ const defaultDir = join16(cwd, promptedName.replace(/[^A-Za-z0-9._-]/g, "") || promptedName.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
4397
6232
  targetDir = await promptTextValue("Project directory", defaultDir);
4398
6233
  name = promptedName;
4399
6234
  }
4400
6235
  if (!targetDir) {
4401
6236
  if (!name) throw new Error("Project name or --target-dir is required when project init is not run inside a git repo");
4402
- targetDir = resolve3(process.cwd(), name.replace(/[^A-Za-z0-9._-]/g, "") || name.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
6237
+ targetDir = resolve4(process.cwd(), name.replace(/[^A-Za-z0-9._-]/g, "") || name.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
4403
6238
  }
4404
- const targetExists = existsSync10(targetDir);
6239
+ const targetExists = existsSync11(targetDir);
4405
6240
  if (targetExists && !statSync2(targetDir).isDirectory()) throw new Error(`Target path is not a directory: ${targetDir}`);
4406
6241
  const targetGitRoot = targetExists ? findGitRoot(targetDir) : void 0;
4407
- const syncMode = Boolean(targetGitRoot && resolve3(targetGitRoot) === resolve3(targetDir));
4408
- const defaults = targetExists ? deriveProjectDefaults(targetDir) : { name: packageNameToProjectName(basename4(targetDir)) ?? "Project", description: "" };
6242
+ const syncMode = Boolean(targetGitRoot && resolve4(targetGitRoot) === resolve4(targetDir));
6243
+ const defaults = targetExists ? deriveProjectDefaults(targetDir) : { name: packageNameToProjectName(basename5(targetDir)) ?? "Project", description: "" };
4409
6244
  if (!name && interactive && !syncMode) {
4410
6245
  name = await promptTextValue("Project name", defaults.name);
4411
6246
  }
@@ -4789,7 +6624,7 @@ program.command("migrate").argument("[rule-id]", "Rule ID to migrate (omit to op
4789
6624
  process.exit(1);
4790
6625
  }
4791
6626
  });
4792
- program.command("hermes-agent").alias("hermes").description("Provision the PM agent for the current repo (defaults everything; only asks about Telegram)").option("-y, --yes", "Non-interactive: accept all defaults (also skips the Telegram prompt)").option("--target-repo <name>", "Target repo name (default: basename of cwd)").option("--role <role>", "Agent role override (default: pm \u2014 the only role in the fleet)").option("--purpose <text>", 'One-line agent purpose (default: "pm agent for <repo>")').option(`--tone <tone>`, `Personality tone (default: direct; ${SOUL_TONES.join(" | ")})`).option("--model-provider <name>", 'Inference provider override ("" = inherit shared default profile)').option("--model-name <name>", 'Model name override ("" = inherit shared default profile)').option("--skip-telegram", "Skip the Telegram wire-up (no BotFather prompt)").option("--email", "Also provision the delo.sh email address (off by default; never prompted)").option("--skip-runtime-repo", "Skip creating the per-agent runtime GH repo").option("--skip-plane", "Skip creating or linking the ticket board").option("--skip-bloodbank", "Skip installing the Bloodbank NATS consumer").option("--skip-systemd", "Skip installing systemd --user units").option("--local", "Local-only: skip runtime repo, ticket-board creation, Bloodbank, and systemd (safe for laptops/macOS/non-technical operators)").option("--force-config", "Regenerate ~/.config/hermes-agent-template/config.toml even if it exists").option("--dry-run", "Preview what would run; don't execute copier").option("-f, --force", "Re-render even if agents/hermes/<role>/role.yaml already exists").action(async (options) => {
6627
+ program.command("hermes-agent").alias("hermes").description("Provision the PM agent for the current repo (defaults everything; only asks about Telegram)").option("-y, --yes", "Non-interactive: accept all defaults (also skips the Telegram prompt)").option("--target-repo <name>", "Target repo name (default: basename of cwd)").option("--role <role>", "Agent role override (default: pm \u2014 the only role in the fleet)").option("--purpose <text>", 'One-line agent purpose (default: "pm agent for <repo>")').option(`--tone <tone>`, `Personality tone (default: direct; ${SOUL_TONES.join(" | ")})`).option("--model-provider <name>", 'Inference provider override ("" = inherit shared default profile)').option("--model-name <name>", 'Model name override ("" = inherit shared default profile)').option("--skip-telegram", "Skip the Telegram wire-up (no BotFather prompt)").option("--email", "Also provision the delo.sh email address (off by default; never prompted)").option("--skip-runtime-repo", "Skip creating the per-agent runtime GH repo").option("--skip-plane", "Skip creating or linking the ticket board").option("--skip-bloodbank", "Deprecated compatibility flag; Bloodbank now uses one fleet-shared Hermes gateway").option("--skip-systemd", "Skip installing systemd --user units").option("--local", "Local-only: skip runtime repo, ticket-board creation, Bloodbank, and systemd (safe for laptops/macOS/non-technical operators)").option("--force-config", "Regenerate ~/.config/hermes-agent-template/config.toml even if it exists").option("--dry-run", "Preview what would run; don't execute copier").option("-f, --force", "Re-render even if agents/hermes/<role>/role.yaml already exists").action(async (options) => {
4793
6628
  const isDarwin = process.platform === "darwin";
4794
6629
  const local = options.local ?? false;
4795
6630
  const context = {