@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
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/mcp-server.ts
4
- import { existsSync as existsSync10, statSync as statSync2 } from "node:fs";
5
- import { basename as basename4, dirname as dirname8, join as join13, resolve as resolve3 } from "node:path";
4
+ import { existsSync as existsSync11, statSync as statSync2 } from "node:fs";
5
+ import { basename as basename5, dirname as dirname8, join as join16, resolve as resolve4 } from "node:path";
6
6
  import { fileURLToPath as fileURLToPath5 } from "node:url";
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -285,97 +285,284 @@ var AddMiseCodegraphScript = class extends Command {
285
285
  };
286
286
  }
287
287
  const content = `#!/usr/bin/env bash
288
- # Mise enter hook: ensure the CodeGraph CLI is available and initialize the
289
- # project index. If \`codegraph\` is not installed, this script installs it
290
- # non-interactively into the project-local .mise/bin directory and retries.
291
- #
292
- # This is intended to run from a mise enter hook so onboarding a new host is
293
- # fully automatic.
288
+ # Auto-generated by pjangler
289
+
290
+ REPO_ROOT="$(pwd)"
291
+ PROJECT_NAME="$(basename "$REPO_ROOT")"
292
+ CONTAINER_NAME="codegraph-mcp-$PROJECT_NAME"
293
+ CACHE_DIR="$REPO_ROOT/.codegraph"
294
+
295
+ # Deterministically generate a port based on the repository path
296
+ PORT=$(echo -n "$REPO_ROOT" | md5sum | awk '{print $1}' | tr -d 'a-f' | cut -c1-4)
297
+ # Ensure port is > 1024
298
+ PORT=$(( (PORT % 60000) + 1025 ))
299
+
300
+ if ! docker ps --format '{{.Names}}' | grep -q "^$CONTAINER_NAME$"; then
301
+ echo "\u{1F680} Starting CodeGraph MCP Server on port $PORT..."
302
+
303
+ # Ensure the container isn't lingering in a stopped state
304
+ docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
305
+
306
+ # Run the true Colby McHenry CodeGraph Docker image
307
+ # which we built locally as colbymchenry-codegraph-mcp:latest
308
+ docker run -d \\
309
+ --name "$CONTAINER_NAME" \\
310
+ --restart unless-stopped \\
311
+ -p "$PORT:8045" \\
312
+ -v "$REPO_ROOT:/repo" \\
313
+ colbymchenry-codegraph-mcp:latest >/dev/null
314
+
315
+ echo "\u2705 CodeGraph MCP running in background. SSE URL: http://localhost:$PORT/sse"
316
+ fi
317
+
318
+ # Run init inside the container to ensure the index is bootstrapped.
319
+ # We run this using the standard codegraph CLI inside the container
320
+ docker exec "$CONTAINER_NAME" codegraph init -i /repo >/dev/null 2>&1 || true
321
+
322
+ # Wire up the MCP server to local agents
323
+ WIRE_SCRIPT="$(dirname "$0")/codegraph-wire.sh"
324
+ if [ -x "$WIRE_SCRIPT" ]; then
325
+ "$WIRE_SCRIPT"
326
+ fi
327
+ `;
328
+ this.writeFile(filePath, content);
329
+ if (!this.context.dryRun) {
330
+ chmodSync(join2(this.context.targetDir, filePath), 493);
331
+ }
332
+ return {
333
+ success: true,
334
+ message: this.formatMessage(this.context.dryRun ? "Would create .mise/scripts/codegraph.sh" : "\u2705 Created .mise/scripts/codegraph.sh"),
335
+ filePath
336
+ };
337
+ }
338
+ };
339
+
340
+ // src/commands/AddMiseCodegraphWireScript.ts
341
+ import { chmodSync as chmodSync2 } from "fs";
342
+ import { join as join3 } from "path";
343
+ var AddMiseCodegraphWireScript = class extends Command {
344
+ async invoke() {
345
+ const filePath = ".mise/scripts/codegraph-wire.sh";
346
+ if (this.fileExists(filePath) && !this.context.force) {
347
+ return {
348
+ success: false,
349
+ message: this.formatMessage("\u26A0\uFE0F .mise/scripts/codegraph-wire.sh already exists"),
350
+ filePath
351
+ };
352
+ }
353
+ const content = `#!/usr/bin/env bash
354
+ # Mise enter hook: Auto-wire CodeGraph MCP Server to local agents.
355
+ # Detects Claude Code, Codex, Gemini, Kimi, and OpenCode and configures
356
+ # them to use the local SSE endpoint for the current project.
294
357
 
295
358
  set -euo pipefail
296
359
 
297
360
  REPO_ROOT="\${MISE_PROJECT_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}"
298
- PROJECT_BIN_DIR="$REPO_ROOT/.mise/bin"
299
- mkdir -p "$PROJECT_BIN_DIR"
300
-
301
- # Install the CodeGraph CLI into the project-local bin directory.
302
- install_codegraph() {
303
- echo "[mise] codegraph not found. Installing non-interactively..."
304
- export CODEGRAPH_BIN_DIR="$PROJECT_BIN_DIR"
305
- curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh
306
-
307
- if [ -x "$PROJECT_BIN_DIR/codegraph" ]; then
308
- export PATH="$PROJECT_BIN_DIR:$PATH"
309
- else
310
- echo "[mise] codegraph install did not place a binary at $PROJECT_BIN_DIR/codegraph" >&2
311
- return 1
361
+
362
+ # Compute the same port as the container script
363
+ PORT_HASH=$(echo -n "$REPO_ROOT" | md5sum | awk '{print $1}')
364
+ PORT_DEC=$(printf "%d" "0x\${PORT_HASH:0:4}")
365
+ PORT=$(( 8045 + (PORT_DEC % 955) ))
366
+
367
+ SSE_URL="http://localhost:$PORT/sse"
368
+
369
+ inject_sse() {
370
+ local target="$1"
371
+ local agent="$2"
372
+
373
+ if [ ! -f "$target" ]; then
374
+ mkdir -p "$(dirname "$target")"
375
+ echo '{"mcpServers": {}}' > "$target"
312
376
  fi
377
+
378
+ # Ensure the file is valid JSON (fail gracefully if it's garbled)
379
+ if ! jq . "$target" >/dev/null 2>&1; then
380
+ echo "[mise] WARNING: $target is not valid JSON, skipping $agent wiring" >&2
381
+ return
382
+ fi
383
+
384
+ # Inject or update the codegraph server
385
+ jq --arg url "$SSE_URL" '.mcpServers.codegraph = {"type": "sse", "url": $url}' "$target" > "$target.tmp" && mv "$target.tmp" "$target"
386
+ echo "[mise] Wired CodeGraph SSE for $agent -> $target"
313
387
  }
314
388
 
315
- # Ensure a codegraph binary is available on PATH.
316
- ensure_codegraph() {
317
- if command -v codegraph >/dev/null 2>&1; then
318
- return 0
319
- fi
389
+ # 1. Claude Code (Project-scoped)
390
+ inject_sse "$REPO_ROOT/.claude.json" "Claude Code"
320
391
 
321
- # Check the project-local bin dir first (previous install from this hook).
322
- if [ -x "$PROJECT_BIN_DIR/codegraph" ]; then
323
- export PATH="$PROJECT_BIN_DIR:$PATH"
324
- return 0
325
- fi
392
+ # 2. Codex (Global)
393
+ inject_sse "$HOME/.codex/mcp.json" "Codex"
326
394
 
327
- # Check typical user-level install locations before fetching anything.
328
- for d in "$HOME/.local/bin" "$HOME/.codegraph/current/bin"; do
329
- if [ -x "$d/codegraph" ]; then
330
- export PATH="$d:$PATH"
331
- return 0
332
- fi
333
- done
395
+ # 3. Gemini (Global)
396
+ inject_sse "$HOME/.gemini/config/mcp.json" "Gemini"
334
397
 
335
- install_codegraph
336
- }
398
+ # 4. Kimi (Global)
399
+ inject_sse "$HOME/.kimi-code/mcp.json" "Kimi"
337
400
 
338
- # Attempt to initialize the project graph. If the command is missing, install
339
- # it and retry once.
340
- init_project() {
341
- local err_file
342
- err_file="$(mktemp)"
343
- trap 'rm -f "$err_file"' RETURN
401
+ # 5. OpenCode (Global)
402
+ inject_sse "$HOME/.opencode/mcp.json" "OpenCode"
344
403
 
345
- if codegraph init -i "$REPO_ROOT" 2>"$err_file"; then
346
- return 0
347
- fi
404
+ # 6. Cursor (Global)
405
+ inject_sse "$HOME/.cursor/mcp.json" "Cursor"
348
406
 
349
- # If the failure looks like a missing binary, install and retry.
350
- if grep -qiE 'command not found|not installed|No such file|executable file not found' "$err_file" 2>/dev/null; then
351
- ensure_codegraph
352
- codegraph init -i "$REPO_ROOT"
353
- return 0
354
- fi
407
+ # 7. VSCode native MCP (Global)
408
+ inject_sse "$HOME/.vscode/mcp.json" "VSCode"
355
409
 
356
- cat "$err_file" >&2
357
- return 1
358
- }
410
+ # 8. Cline (VSCode extension)
411
+ inject_sse "$HOME/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json" "Cline"
359
412
 
360
- init_project
413
+ # 9. Roo (VSCode extension)
414
+ inject_sse "$HOME/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json" "Roo Code"
361
415
  `;
362
416
  this.writeFile(filePath, content);
363
417
  if (!this.context.dryRun) {
364
- chmodSync(join2(this.context.targetDir, filePath), 493);
418
+ chmodSync2(join3(this.context.targetDir, filePath), 493);
365
419
  }
366
420
  return {
367
421
  success: true,
368
- message: this.formatMessage(this.context.dryRun ? "Would create .mise/scripts/codegraph.sh" : "\u2705 Created .mise/scripts/codegraph.sh"),
422
+ message: this.formatMessage(this.context.dryRun ? "Would create .mise/scripts/codegraph-wire.sh" : "\u2705 Created .mise/scripts/codegraph-wire.sh"),
369
423
  filePath
370
424
  };
371
425
  }
372
426
  };
373
427
 
428
+ // src/commands/WireMiseOpInject.ts
429
+ import { join as join4 } from "node:path";
430
+ import { existsSync as existsSync2, readFileSync, writeFileSync as writeFileSync2, chmodSync as chmodSync3 } from "node:fs";
431
+ var WireMiseOpInject = class _WireMiseOpInject extends Command {
432
+ static MARKER = "# pjangler:op-inject";
433
+ static CR = "{{config_root}}";
434
+ async invoke() {
435
+ const scriptPath = ".mise/scripts/inject-op-secrets.sh";
436
+ const fullScriptPath = join4(this.context.targetDir, scriptPath);
437
+ const scriptContent = `#!/usr/bin/env bash
438
+ # Resolve 1Password \`op://\` references in .env.op into .env.secrets, which mise
439
+ # loads via \`_.file\`.
440
+ #
441
+ # Runs from the mise enter hook: fail-open and non-interactive.
442
+ # No .env.op means no 1Password, so this exits before spending anything.
443
+
444
+ set -euo pipefail
445
+
446
+ REPO_ROOT="\${MISE_PROJECT_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}"
447
+ SRC="$REPO_ROOT/.env.op"
448
+ OUT="$REPO_ROOT/.env.secrets"
449
+
450
+ TTL_HOURS="\${OP_INJECT_TTL_HOURS:-12}"
451
+
452
+ [[ -f "$SRC" ]] || exit 0
453
+ command -v op >/dev/null 2>&1 || exit 0
454
+
455
+ if [[ -f "$OUT" && "$SRC" -ot "$OUT" ]]; then
456
+ if [[ "$TTL_HOURS" != "0" ]] && [[ -z "$(find "$OUT" -mmin "+$((TTL_HOURS * 60))" 2>/dev/null)" ]]; then
457
+ exit 0
458
+ fi
459
+ fi
460
+
461
+ op account list >/dev/null 2>&1 || exit 0
462
+
463
+ tmp="$(mktemp "\${OUT}.XXXXXX")"
464
+ trap 'rm -f "$tmp"' EXIT
465
+ chmod 600 "$tmp"
466
+
467
+ if op inject -i "$SRC" -o "$tmp" --force >/dev/null 2>&1; then
468
+ mv "$tmp" "$OUT"
469
+ trap - EXIT
470
+ else
471
+ echo "mise: op inject from .env.op failed \u2014 $(basename "$OUT") unchanged." >&2
472
+ echo " Check 'op signin', or that .env.op's op:// items are readable." >&2
473
+ echo " To disable: rm .env.op" >&2
474
+ fi
475
+ `;
476
+ this.writeFile(scriptPath, scriptContent);
477
+ if (!this.context.dryRun) {
478
+ chmodSync3(fullScriptPath, 493);
479
+ }
480
+ const misePath = join4(this.context.targetDir, "mise.toml");
481
+ if (!existsSync2(misePath)) {
482
+ return {
483
+ success: false,
484
+ message: "\u26A0\uFE0F No mise.toml found \u2014 run `pj init mise` first, then re-run."
485
+ };
486
+ }
487
+ let content = readFileSync(misePath, "utf8");
488
+ if (content.includes(_WireMiseOpInject.MARKER)) {
489
+ return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for op-inject") };
490
+ }
491
+ const cr = _WireMiseOpInject.CR;
492
+ const envRe = /\[env\]\s*\n([\s\S]*?(?=\n\[|$))/;
493
+ if (envRe.test(content)) {
494
+ content = content.replace(envRe, (m) => {
495
+ let block = m;
496
+ if (!block.includes("_.file")) {
497
+ block += `
498
+ _.file = ['.env', '.env.secrets']
499
+ `;
500
+ } else if (!block.includes(".env.secrets")) {
501
+ block = block.replace(/(_\.file\s*=\s*\[)([^\]]*?)(\])/, (m2, prefix, files, suffix) => {
502
+ const added = files.trim() ? `${files}, '.env.secrets'` : `'.env.secrets'`;
503
+ return `${prefix}${added}${suffix}`;
504
+ });
505
+ }
506
+ return block;
507
+ });
508
+ } else {
509
+ const envBlock = `[env]
510
+ _.file = ['.env', '.env.secrets']
511
+
512
+ `;
513
+ if (content.includes("[tools]")) {
514
+ content = content.replace(/(\[tools\][\s\S]*?(?=\n\[|$))/, `$1
515
+ ${envBlock}`);
516
+ } else {
517
+ content = envBlock + content;
518
+ }
519
+ }
520
+ const enterAdds = ` '${cr}/.mise/scripts/inject-op-secrets.sh',`;
521
+ const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
522
+ if (enterRe.test(content)) {
523
+ content = content.replace(enterRe, (_m, head, close) => {
524
+ const sep2 = /[,[]\s*$/.test(head) ? "" : ",";
525
+ return `${head}${sep2}
526
+ ${enterAdds}${close}`;
527
+ });
528
+ } else {
529
+ if (content.includes("[hooks]")) {
530
+ content = content.replace(/\[hooks\]/, `[hooks]
531
+ enter = [
532
+ ${enterAdds}
533
+ ]`);
534
+ } else {
535
+ content += `
536
+ [hooks]
537
+ enter = [
538
+ ${enterAdds}
539
+ ]
540
+ `;
541
+ }
542
+ }
543
+ const appended = [
544
+ "",
545
+ _WireMiseOpInject.MARKER,
546
+ "[tasks.secrets-inject]",
547
+ 'description = "Re-resolve .env.op secrets from 1Password into .env.secrets"',
548
+ `run = "OP_INJECT_TTL_HOURS=0 .mise/scripts/inject-op-secrets.sh"`,
549
+ _WireMiseOpInject.MARKER + ":end",
550
+ ""
551
+ ].join("\n");
552
+ content = content.replace(/\n*$/, "\n") + appended;
553
+ if (!this.context.dryRun) writeFileSync2(misePath, content);
554
+ return {
555
+ success: true,
556
+ message: this.formatMessage("\u2705 Wired mise.toml for op-inject (_.file, [hooks] enter, tasks.secrets-inject)")
557
+ };
558
+ }
559
+ };
560
+
374
561
  // src/recipes/MiseRecipe.ts
375
562
  var MiseRecipe = class extends Recipe {
376
563
  constructor(context) {
377
564
  super(context);
378
- this.addIngredient(AddMiseToml).addIngredient(AddDotenv).addIngredient(AddMiseTasksStructure).addIngredient(AddMiseBaseToml).addIngredient(AddMiseBaseScript).addIngredient(AddMiseCodegraphScript);
565
+ this.addIngredient(AddMiseToml).addIngredient(AddDotenv).addIngredient(AddMiseTasksStructure).addIngredient(AddMiseBaseToml).addIngredient(AddMiseBaseScript).addIngredient(AddMiseCodegraphScript).addIngredient(AddMiseCodegraphWireScript).addIngredient(WireMiseOpInject);
379
566
  }
380
567
  printNextSteps() {
381
568
  console.log("\u{1F389} Mise subsystem initialized successfully!");
@@ -604,34 +791,166 @@ var NodeRecipe = class extends Recipe {
604
791
 
605
792
  // src/commands/hermes/EnsureTemplateConfig.ts
606
793
  import { homedir, platform } from "node:os";
607
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
608
- import { join as join3, dirname as dirname2 } from "node:path";
794
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "node:fs";
795
+ import { join as join6, dirname as dirname2 } from "node:path";
796
+
797
+ // src/parity/bmadPack.ts
798
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync as readFileSync2, readdirSync } from "node:fs";
799
+ import { createHash } from "node:crypto";
800
+ import { basename, join as join5, relative, resolve, sep } from "node:path";
801
+ var BMAD_PACK_VERSION = "6.10.1-next.31";
802
+ var BMAD_PACK_CHECKSUMS_SHA256 = "a8bc005612ac60e3ec775fff5a11eafe38be6acdae96efa3d770b48322cb3224";
803
+ var BMAD_PACK_SKILL_COUNT = 76;
804
+ var BMAD_PACK_PAYLOAD_FILES = 1072;
805
+ function sha256(content) {
806
+ return createHash("sha256").update(content).digest("hex");
807
+ }
808
+ function readRegularFile(path) {
809
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
810
+ try {
811
+ if (!fstatSync(fd).isFile()) throw new Error(`BMAD pack entry is not a regular file: ${path}`);
812
+ return readFileSync2(fd);
813
+ } finally {
814
+ closeSync(fd);
815
+ }
816
+ }
817
+ function safeRelativePath(value) {
818
+ if (!value || value.includes("\\") || value.startsWith("/") || value.split("/").some((part) => part === "" || part === "." || part === "..")) {
819
+ throw new Error(`Unsafe BMAD checksum path: ${JSON.stringify(value)}`);
820
+ }
821
+ return value;
822
+ }
823
+ function parsePackToml(content) {
824
+ const required = [
825
+ /^name = "bmad"$/m,
826
+ new RegExp(`^version = "${BMAD_PACK_VERSION.replaceAll(".", "\\.")}"$`, "m"),
827
+ /^upstream = "bmad-method"$/m,
828
+ new RegExp(`^upstream_version = "${BMAD_PACK_VERSION.replaceAll(".", "\\.")}"$`, "m"),
829
+ /^rendered_from = "\.agent\/skills"$/m,
830
+ /^immutable = true$/m,
831
+ /^project_projection = "symlink"$/m
832
+ ];
833
+ if (required.some((pattern) => !pattern.test(content))) {
834
+ throw new Error(`BMAD pack.toml does not declare the trusted ${BMAD_PACK_VERSION} contract`);
835
+ }
836
+ const payloadMatch = content.match(/^payload_files = (\d+)$/m);
837
+ const skillsMatch = content.match(/^skills = \[\n([\s\S]*?)^\]$/m);
838
+ if (!payloadMatch || !skillsMatch) throw new Error("BMAD pack.toml is missing payload inventory metadata");
839
+ const skillNames = [...skillsMatch[1].matchAll(/^\s+"([^"]+)",$/gm)].map((match) => match[1]);
840
+ const payloadFiles = Number.parseInt(payloadMatch[1], 10);
841
+ if (skillNames.length !== BMAD_PACK_SKILL_COUNT || new Set(skillNames).size !== skillNames.length) {
842
+ throw new Error(`BMAD pack.toml must declare exactly ${BMAD_PACK_SKILL_COUNT} unique skills`);
843
+ }
844
+ if (payloadFiles !== BMAD_PACK_PAYLOAD_FILES) {
845
+ throw new Error(`BMAD pack.toml must declare exactly ${BMAD_PACK_PAYLOAD_FILES} payload files`);
846
+ }
847
+ for (const name of skillNames) {
848
+ if (!name.startsWith("bmad-") || basename(name) !== name) throw new Error(`Unsafe BMAD skill identity: ${name}`);
849
+ }
850
+ return { skillNames, payloadFiles };
851
+ }
852
+ function walkRegularTree(root) {
853
+ const files = /* @__PURE__ */ new Map();
854
+ const directories = /* @__PURE__ */ new Set();
855
+ const visit = (directory) => {
856
+ for (const name of readdirSync(directory).sort()) {
857
+ const path = join5(directory, name);
858
+ const stat = lstatSync(path);
859
+ if (stat.isSymbolicLink()) throw new Error(`BMAD pack may not contain symlinks: ${path}`);
860
+ if (stat.isDirectory()) {
861
+ directories.add(relative(root, path).split(sep).join("/"));
862
+ visit(path);
863
+ } else if (stat.isFile()) files.set(relative(root, path).split(sep).join("/"), readRegularFile(path));
864
+ else throw new Error(`BMAD pack may contain only regular files/directories: ${path}`);
865
+ }
866
+ };
867
+ visit(root);
868
+ return { files, directories };
869
+ }
870
+ function validateTrustedBmadPack(packRoot) {
871
+ const root = resolve(packRoot);
872
+ const rootStat = lstatSync(root);
873
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
874
+ throw new Error(`BMAD pack root must be a real directory: ${root}`);
875
+ }
876
+ const checksumsContent = readRegularFile(join5(root, "SHA256SUMS"));
877
+ if (sha256(checksumsContent) !== BMAD_PACK_CHECKSUMS_SHA256) {
878
+ throw new Error(`BMAD pack checksum manifest is not the trusted ${BMAD_PACK_VERSION} manifest`);
879
+ }
880
+ const expected = /* @__PURE__ */ new Map();
881
+ for (const line of checksumsContent.toString("utf8").split("\n")) {
882
+ if (!line) continue;
883
+ const match = line.match(/^([0-9a-f]{64}) (.+)$/);
884
+ if (!match) throw new Error(`Invalid BMAD SHA256SUMS entry: ${line}`);
885
+ const path = safeRelativePath(match[2]);
886
+ if (expected.has(path)) throw new Error(`Duplicate BMAD SHA256SUMS entry: ${path}`);
887
+ expected.set(path, match[1]);
888
+ }
889
+ const walked = walkRegularTree(root);
890
+ const actual = walked.files;
891
+ actual.delete("SHA256SUMS");
892
+ const missing = [...expected.keys()].filter((path) => !actual.has(path));
893
+ const extra = [...actual.keys()].filter((path) => !expected.has(path));
894
+ if (missing.length || extra.length) {
895
+ throw new Error(`BMAD checksum coverage mismatch; missing=${JSON.stringify(missing)} extra=${JSON.stringify(extra)}`);
896
+ }
897
+ for (const [path, digest] of expected) {
898
+ const content = actual.get(path);
899
+ if (!content || sha256(content) !== digest) throw new Error(`BMAD pack digest mismatch: ${path}`);
900
+ }
901
+ for (const directory of walked.directories) {
902
+ if (![...actual.keys()].some((path) => path.startsWith(`${directory}/`))) {
903
+ throw new Error(`BMAD pack contains an unauthenticated empty directory: ${directory}`);
904
+ }
905
+ }
906
+ const packToml = actual.get("pack.toml");
907
+ if (!packToml) throw new Error("BMAD pack.toml is missing");
908
+ const metadata = parsePackToml(packToml.toString("utf8"));
909
+ const skillSet = new Set(metadata.skillNames);
910
+ const topLevelDirectories = readdirSync(root).filter((name) => lstatSync(join5(root, name)).isDirectory()).sort();
911
+ if (topLevelDirectories.length !== skillSet.size || topLevelDirectories.some((name) => !skillSet.has(name))) {
912
+ throw new Error("BMAD pack directory inventory differs from authenticated pack.toml skills");
913
+ }
914
+ for (const name of metadata.skillNames) {
915
+ const skillMd = join5(root, name, "SKILL.md");
916
+ if (!lstatSync(skillMd).isFile() || lstatSync(skillMd).isSymbolicLink()) {
917
+ throw new Error(`BMAD skill is missing a regular SKILL.md: ${name}`);
918
+ }
919
+ }
920
+ const payloadCount = [...actual.keys()].filter((path) => skillSet.has(path.split("/", 1)[0])).length;
921
+ if (payloadCount !== metadata.payloadFiles) {
922
+ throw new Error(`BMAD payload inventory mismatch: ${payloadCount} != ${metadata.payloadFiles}`);
923
+ }
924
+ return { root, skillNames: metadata.skillNames, payloadFiles: metadata.payloadFiles };
925
+ }
926
+
927
+ // src/commands/hermes/EnsureTemplateConfig.ts
609
928
  function resolveTemplateConfigPath() {
610
929
  const fromEnv = process.env.HERMES_TEMPLATE_CONFIG;
611
930
  if (fromEnv && fromEnv.trim()) return fromEnv.trim();
612
931
  const xdg = process.env.XDG_CONFIG_HOME?.trim();
613
- const base = xdg && xdg.length ? xdg : join3(homedir(), ".config");
614
- return join3(base, "hermes-agent-template", "config.toml");
932
+ const base = xdg && xdg.length ? xdg : join6(homedir(), ".config");
933
+ return join6(base, "hermes-agent-template", "config.toml");
615
934
  }
616
935
  function detectHermesBin(home) {
617
936
  const candidates = [
618
- join3(home, "code", "hermes-agent", "venv", "bin", "hermes"),
619
- join3(home, "code", "hermes-agent", ".venv", "bin", "hermes"),
620
- join3(home, ".local", "bin", "hermes")
937
+ join6(home, "code", "hermes-agent", "venv", "bin", "hermes"),
938
+ join6(home, "code", "hermes-agent", ".venv", "bin", "hermes"),
939
+ join6(home, ".local", "bin", "hermes")
621
940
  ];
622
941
  for (const c of candidates) {
623
- if (existsSync2(c)) return c;
942
+ if (existsSync3(c)) return c;
624
943
  }
625
944
  return candidates[0];
626
945
  }
627
946
  function renderHostConfig() {
628
947
  const home = homedir();
629
948
  const hermesBin = detectHermesBin(home);
630
- const hermesRepo = join3(home, "code", "hermes-agent");
631
- const scaffoldDir = join3(home, "code", "hermes-agent-template", "runtime-scaffold");
632
- const skillsDir = join3(home, ".agents", "skills");
633
- const pmExternalSkillGlobalDir = join3(home, "code", "skillex", "skill-sets", "global", ".system");
634
- const pmExternalSkillBmadDir = join3(home, "code", "skillex", "packs", "bmad", "6.10.2");
949
+ const hermesRepo = join6(home, "code", "hermes-agent");
950
+ const scaffoldDir = join6(home, "code", "hermes-agent-template", "runtime-scaffold");
951
+ const skillsDir = join6(home, ".agents", "skills");
952
+ const pmExternalSkillGlobalDir = join6(home, "code", "skillex", "skill-sets", "global", ".system");
953
+ const pmExternalSkillBmadDir = join6(home, "code", "skillex", "packs", "bmad", BMAD_PACK_VERSION);
635
954
  return `# hermes-agent-template \u2014 host configuration
636
955
  # Bootstrapped by \`pjangler config bootstrap\` for $HOME=${home} (platform=${platform()}).
637
956
  #
@@ -680,7 +999,7 @@ var EnsureTemplateConfig = class extends Command {
680
999
  const ctx = this.context;
681
1000
  const force = ctx.forceConfig === true || process.env.PJANGLER_FORCE_CONFIG === "1";
682
1001
  const path = resolveTemplateConfigPath();
683
- const exists = existsSync2(path);
1002
+ const exists = existsSync3(path);
684
1003
  if (exists && !force) {
685
1004
  console.log(`\u2713 Config present: ${path}`);
686
1005
  return { success: true, message: "" };
@@ -691,7 +1010,7 @@ var EnsureTemplateConfig = class extends Command {
691
1010
  }
692
1011
  try {
693
1012
  mkdirSync2(dirname2(path), { recursive: true });
694
- writeFileSync2(path, renderHostConfig());
1013
+ writeFileSync3(path, renderHostConfig());
695
1014
  } catch (err) {
696
1015
  const msg = err instanceof Error ? err.message : String(err);
697
1016
  return { success: false, message: `\u2717 Failed to write ${path}: ${msg}` };
@@ -703,8 +1022,8 @@ var EnsureTemplateConfig = class extends Command {
703
1022
  };
704
1023
 
705
1024
  // src/commands/hermes/PromptForAgentConfig.ts
706
- import { basename, join as join4 } from "node:path";
707
- import { readFileSync } from "node:fs";
1025
+ import { basename as basename2, join as join7 } from "node:path";
1026
+ import { readFileSync as readFileSync3 } from "node:fs";
708
1027
  import * as p from "@clack/prompts";
709
1028
 
710
1029
  // src/commands/hermes/types.ts
@@ -719,7 +1038,7 @@ function deriveProfileName(repo, role) {
719
1038
  // src/commands/hermes/PromptForAgentConfig.ts
720
1039
  function detectTicketProvider(targetDir) {
721
1040
  try {
722
- const t = JSON.parse(readFileSync(join4(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
1041
+ const t = JSON.parse(readFileSync3(join7(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
723
1042
  return t === "plane" || t === "trello" ? t : void 0;
724
1043
  } catch {
725
1044
  return void 0;
@@ -728,7 +1047,7 @@ function detectTicketProvider(targetDir) {
728
1047
  var PromptForAgentConfig = class extends Command {
729
1048
  async invoke() {
730
1049
  const ctx = this.context;
731
- const defaultRepo = basename(ctx.targetDir).toLowerCase();
1050
+ const defaultRepo = basename2(ctx.targetDir).toLowerCase();
732
1051
  ctx.targetRepo = (ctx.targetRepo ?? defaultRepo).toLowerCase();
733
1052
  ctx.role ??= "pm";
734
1053
  ctx.agentPurpose ??= `${ctx.role} agent for ${ctx.targetRepo}`;
@@ -777,8 +1096,8 @@ var PromptForAgentConfig = class extends Command {
777
1096
  // src/commands/hermes/RunCopierTemplate.ts
778
1097
  import { spawnSync } from "node:child_process";
779
1098
  import { homedir as homedir2 } from "node:os";
780
- import { join as join5, dirname as dirname3 } from "node:path";
781
- import { existsSync as existsSync3, mkdirSync as mkdirSync3 } from "node:fs";
1099
+ import { join as join8, dirname as dirname3 } from "node:path";
1100
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3 } from "node:fs";
782
1101
  import { fileURLToPath } from "node:url";
783
1102
  import * as p2 from "@clack/prompts";
784
1103
  function resolveVendoredTemplate(name) {
@@ -789,8 +1108,8 @@ function resolveVendoredTemplate(name) {
789
1108
  return void 0;
790
1109
  }
791
1110
  for (let i = 0; i < 8; i++) {
792
- const candidate = join5(dir, "templates", name);
793
- if (existsSync3(join5(candidate, "copier.yml"))) return candidate;
1111
+ const candidate = join8(dir, "templates", name);
1112
+ if (existsSync4(join8(candidate, "copier.yml"))) return candidate;
794
1113
  const parent = dirname3(dir);
795
1114
  if (parent === dir) break;
796
1115
  dir = parent;
@@ -809,7 +1128,7 @@ var RunCopierTemplate = class extends Command {
809
1128
  message: "PromptForAgentConfig must run before RunCopierTemplate (targetRepo/role unset)"
810
1129
  };
811
1130
  }
812
- const roleDir = join5(ctx.targetDir, "agents", "hermes", role);
1131
+ const roleDir = join8(ctx.targetDir, "agents", "hermes", role);
813
1132
  ctx.roleDir = roleDir;
814
1133
  ctx.runtimeRepo = `delorenj/agent-hm-${targetRepo}-${role}`;
815
1134
  const which = spawnSync("which", ["copier"], { encoding: "utf8" });
@@ -819,7 +1138,7 @@ var RunCopierTemplate = class extends Command {
819
1138
  message: "\u2717 copier not found on PATH. Install with: `uv tool install copier` or `pip install copier`"
820
1139
  };
821
1140
  }
822
- if (existsSync3(join5(roleDir, "role.yaml")) && !ctx.force) {
1141
+ if (existsSync4(join8(roleDir, "role.yaml")) && !ctx.force) {
823
1142
  if (ctx.yes) {
824
1143
  ctx.force = true;
825
1144
  } else {
@@ -840,15 +1159,16 @@ var RunCopierTemplate = class extends Command {
840
1159
  ...process.env,
841
1160
  SKIP_TELEGRAM: "1",
842
1161
  SKIP_EMAIL: "1",
843
- // We DO want copier to run runtime-repo + plane + bloodbank + systemd.
1162
+ // Bloodbank is a fleet-shared Hermes gateway. Never provision the legacy
1163
+ // per-profile file consumer, even when an older template still exposes it.
844
1164
  SKIP_RUNTIME_REPO: ctx.skipRuntimeRepo ? "1" : "0",
845
1165
  SKIP_PLANE: ctx.skipPlane ? "1" : "0",
846
- SKIP_BLOODBANK: ctx.skipBloodbank ? "1" : "0",
1166
+ SKIP_BLOODBANK: "1",
847
1167
  SKIP_SYSTEMD: ctx.skipSystemd ? "1" : "0"
848
1168
  };
849
- const LOCAL_TEMPLATE = join5(homedir2(), "code", "hermes-agent-template");
1169
+ const LOCAL_TEMPLATE = join8(homedir2(), "code", "hermes-agent-template");
850
1170
  const vendored = resolveVendoredTemplate("hermes-agent");
851
- const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync3(join5(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
1171
+ const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync4(join8(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
852
1172
  const args = [
853
1173
  "copy",
854
1174
  templateSrc,
@@ -879,7 +1199,7 @@ var RunCopierTemplate = class extends Command {
879
1199
  message: this.formatMessage(`Would run: copier ${args.join(" ")}`)
880
1200
  };
881
1201
  }
882
- mkdirSync3(join5(ctx.targetDir, "agents", "hermes"), { recursive: true });
1202
+ mkdirSync3(join8(ctx.targetDir, "agents", "hermes"), { recursive: true });
883
1203
  const spinner4 = p2.spinner();
884
1204
  spinner4.start(`Running copier copy (target: agents/hermes/${role})`);
885
1205
  const result = spawnSync("copier", args, {
@@ -903,20 +1223,26 @@ var RunCopierTemplate = class extends Command {
903
1223
  };
904
1224
 
905
1225
  // src/commands/hermes/UntrackHermesRuntimes.ts
906
- import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync3, readdirSync } from "fs";
907
- import { join as join6 } from "path";
1226
+ import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4, readdirSync as readdirSync2 } from "fs";
1227
+ import { join as join9 } from "path";
908
1228
  import { spawnSync as spawnSync2 } from "node:child_process";
1229
+ function sectionHasPath(section, targetPath) {
1230
+ return section.split(/\r?\n/).some((line) => /^\s*path\s*=/.test(line) && line.replace(/^\s*path\s*=\s*/, "").trim() === targetPath);
1231
+ }
1232
+ function removeSubmodulePath(content, targetPath) {
1233
+ return content.replace(/^\[submodule "[^"\n]+"\][\s\S]*?(?=^\[submodule "|(?![\s\S]))/gm, (section) => sectionHasPath(section, targetPath) ? "" : section).replace(/\n{3,}/g, "\n\n").trim();
1234
+ }
909
1235
  var UntrackHermesRuntimes = class extends Command {
910
1236
  async invoke() {
911
1237
  const targetDir = this.context.targetDir;
912
- const rolesDir = join6(targetDir, "agents", "hermes");
913
- if (!existsSync4(rolesDir)) {
1238
+ const rolesDir = join9(targetDir, "agents", "hermes");
1239
+ if (!existsSync5(rolesDir)) {
914
1240
  return {
915
1241
  success: true,
916
1242
  message: "No Hermes agents found (no agents/hermes directory)."
917
1243
  };
918
1244
  }
919
- const roles = readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
1245
+ const roles = readdirSync2(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
920
1246
  if (roles.length === 0) {
921
1247
  return {
922
1248
  success: true,
@@ -926,53 +1252,85 @@ var UntrackHermesRuntimes = class extends Command {
926
1252
  let modifiedAny = false;
927
1253
  const details = [];
928
1254
  for (const role of roles) {
929
- const roleDir = join6("agents", "hermes", role);
930
- const runtimePath = join6(roleDir, "runtime");
931
- const gitignorePath = join6(roleDir, ".gitignore");
1255
+ const roleDir = join9("agents", "hermes", role);
1256
+ const runtimePath = join9(roleDir, "runtime");
1257
+ const gitignorePath = join9(roleDir, ".gitignore");
1258
+ const gitmodulesPath = join9(targetDir, ".gitmodules");
932
1259
  let isTracked = false;
933
- const lsResult = spawnSync2("git", ["ls-files", "--stage", runtimePath], {
1260
+ const lsResult = spawnSync2("git", ["ls-files", "--stage", "--", runtimePath], {
934
1261
  cwd: targetDir,
935
1262
  encoding: "utf8"
936
1263
  });
1264
+ if (lsResult.status !== 0) {
1265
+ return {
1266
+ success: false,
1267
+ message: `\u2717 Failed to inspect runtime index at ${runtimePath}: ${lsResult.stderr.trim() || `exit ${lsResult.status}`}`
1268
+ };
1269
+ }
937
1270
  if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
938
1271
  isTracked = true;
939
1272
  }
1273
+ let hasStaleMapping = false;
1274
+ let gitmodulesContent = "";
1275
+ if (existsSync5(gitmodulesPath)) {
1276
+ gitmodulesContent = readFileSync4(gitmodulesPath, "utf8");
1277
+ const sections = gitmodulesContent.match(/^\[submodule "[^"\n]+"\][\s\S]*?(?=^\[submodule "|(?![\s\S]))/gm) ?? [];
1278
+ hasStaleMapping = sections.some((section) => sectionHasPath(section, runtimePath));
1279
+ }
940
1280
  let isIgnored = false;
941
- const fullGitignorePath = join6(targetDir, gitignorePath);
942
- if (existsSync4(fullGitignorePath)) {
943
- const content = readFileSync2(fullGitignorePath, "utf8");
1281
+ const fullGitignorePath = join9(targetDir, gitignorePath);
1282
+ if (existsSync5(fullGitignorePath)) {
1283
+ const content = readFileSync4(fullGitignorePath, "utf8");
944
1284
  const lines = content.split(/\r?\n/).map((line) => line.trim());
945
1285
  isIgnored = lines.includes("runtime/") || lines.includes("runtime");
946
1286
  }
947
- if (isTracked || !isIgnored) {
1287
+ if (isTracked || hasStaleMapping || !isIgnored) {
948
1288
  modifiedAny = true;
949
1289
  if (isTracked) {
950
1290
  details.push(`untrack agents/hermes/${role}/runtime`);
951
1291
  if (!this.context.dryRun) {
952
- const rmResult = spawnSync2("git", ["rm", "--cached", "-r", runtimePath], {
1292
+ const rmResult = spawnSync2("git", ["rm", "--cached", "-r", "-f", "--", runtimePath], {
953
1293
  cwd: targetDir,
954
1294
  encoding: "utf8"
955
1295
  });
956
1296
  if (rmResult.status !== 0) {
957
1297
  return {
958
1298
  success: false,
959
- message: `Failed to untrack agents/hermes/${role}/runtime: ${rmResult.stderr}`
1299
+ message: `\u2717 Failed to untrack ${runtimePath}: ${rmResult.stderr.trim() || `exit ${rmResult.status}`}`
1300
+ };
1301
+ }
1302
+ const verifyResult = spawnSync2("git", ["ls-files", "--stage", "--", runtimePath], {
1303
+ cwd: targetDir,
1304
+ encoding: "utf8"
1305
+ });
1306
+ if (verifyResult.status !== 0 || verifyResult.stdout.trim()) {
1307
+ return {
1308
+ success: false,
1309
+ message: verifyResult.status !== 0 ? `\u2717 Failed to verify untracked runtime ${runtimePath}: ${verifyResult.stderr.trim() || `exit ${verifyResult.status}`}` : `\u2717 Runtime remains tracked after index-only removal: ${runtimePath}`
960
1310
  };
961
1311
  }
962
1312
  }
963
1313
  }
1314
+ if (hasStaleMapping) {
1315
+ details.push(`remove stale .gitmodules mapping for ${runtimePath}`);
1316
+ if (!this.context.dryRun) {
1317
+ const next = removeSubmodulePath(gitmodulesContent, runtimePath);
1318
+ writeFileSync4(gitmodulesPath, next ? `${next}
1319
+ ` : "", "utf8");
1320
+ }
1321
+ }
964
1322
  if (!isIgnored) {
965
1323
  details.push(`ignore runtime/ in agents/hermes/${role}/.gitignore`);
966
1324
  if (!this.context.dryRun) {
967
1325
  let content = "";
968
- if (existsSync4(fullGitignorePath)) {
969
- content = readFileSync2(fullGitignorePath, "utf8");
1326
+ if (existsSync5(fullGitignorePath)) {
1327
+ content = readFileSync4(fullGitignorePath, "utf8");
970
1328
  }
971
1329
  if (content && !content.endsWith("\n")) {
972
1330
  content += "\n";
973
1331
  }
974
1332
  content += "runtime/\n";
975
- writeFileSync3(fullGitignorePath, content, "utf8");
1333
+ writeFileSync4(fullGitignorePath, content, "utf8");
976
1334
  }
977
1335
  }
978
1336
  }
@@ -994,8 +1352,8 @@ ${details.map((d) => ` - ${d}`).join("\n")}`
994
1352
 
995
1353
  // src/commands/hermes/WireTelegram.ts
996
1354
  import { spawnSync as spawnSync3 } from "node:child_process";
997
- import { join as join7 } from "node:path";
998
- import { existsSync as existsSync5, unlinkSync } from "node:fs";
1355
+ import { join as join10 } from "node:path";
1356
+ import { existsSync as existsSync6, unlinkSync } from "node:fs";
999
1357
  import * as p3 from "@clack/prompts";
1000
1358
  var WireTelegram = class extends Command {
1001
1359
  async invoke() {
@@ -1083,15 +1441,15 @@ var WireTelegram = class extends Command {
1083
1441
  if (p3.isCancel(allowedAnswer)) {
1084
1442
  return { success: false, message: "\u2717 Aborted; Telegram step deferred." };
1085
1443
  }
1086
- const script = join7(roleDir, ".scripts", "30-telegram.sh");
1087
- if (!existsSync5(script)) {
1444
+ const script = join10(roleDir, ".scripts", "30-telegram.sh");
1445
+ if (!existsSync6(script)) {
1088
1446
  return {
1089
1447
  success: false,
1090
1448
  message: `\u2717 ${script} not found. Did copier finish? Re-run with --skip-runtime-repo=0 if you skipped it.`
1091
1449
  };
1092
1450
  }
1093
- const marker = join7(roleDir, ".scripts", ".done-30-telegram");
1094
- if (existsSync5(marker)) unlinkSync(marker);
1451
+ const marker = join10(roleDir, ".scripts", ".done-30-telegram");
1452
+ if (existsSync6(marker)) unlinkSync(marker);
1095
1453
  const spinner4 = p3.spinner();
1096
1454
  spinner4.start("Verifying token + wiring profile");
1097
1455
  const result = spawnSync3("bash", [script], {
@@ -1118,8 +1476,8 @@ function cap(s) {
1118
1476
 
1119
1477
  // src/commands/hermes/WireEmail.ts
1120
1478
  import { spawnSync as spawnSync4 } from "node:child_process";
1121
- import { join as join8 } from "node:path";
1122
- import { existsSync as existsSync6, unlinkSync as unlinkSync2 } from "node:fs";
1479
+ import { join as join11 } from "node:path";
1480
+ import { existsSync as existsSync7, unlinkSync as unlinkSync2 } from "node:fs";
1123
1481
  import * as p4 from "@clack/prompts";
1124
1482
  var WireEmail = class extends Command {
1125
1483
  async invoke() {
@@ -1134,8 +1492,8 @@ var WireEmail = class extends Command {
1134
1492
  if (!targetRepo || !role || !roleDir) {
1135
1493
  return { success: false, message: "Cannot wire email: missing target_repo/role/roleDir" };
1136
1494
  }
1137
- const script = join8(roleDir, ".scripts", "50-email.sh");
1138
- if (!existsSync6(script)) {
1495
+ const script = join11(roleDir, ".scripts", "50-email.sh");
1496
+ if (!existsSync7(script)) {
1139
1497
  return { success: false, message: `\u2717 ${script} not found` };
1140
1498
  }
1141
1499
  let token = process.env.CF_EMAIL_ROUTING_TOKEN;
@@ -1197,8 +1555,8 @@ var WireEmail = class extends Command {
1197
1555
  }
1198
1556
  }
1199
1557
  }
1200
- const marker = join8(roleDir, ".scripts", ".done-50-email");
1201
- if (existsSync6(marker)) unlinkSync2(marker);
1558
+ const marker = join11(roleDir, ".scripts", ".done-50-email");
1559
+ if (existsSync7(marker)) unlinkSync2(marker);
1202
1560
  const spinner4 = p4.spinner();
1203
1561
  spinner4.start("Creating Cloudflare Email Routing rule");
1204
1562
  const result = spawnSync4("bash", [script], {
@@ -1226,7 +1584,6 @@ var PrintHermesSummary = class extends Command {
1226
1584
  const botHandle = `${targetRepo?.toLowerCase().replace(/-/g, "_")}_${role?.toLowerCase()}_bot`;
1227
1585
  const email = `${targetRepo}-${role}@delo.sh`;
1228
1586
  const gw = `hermes-${agentId}-gateway.service`;
1229
- const csm = `hermes-${agentId}-consumer.service`;
1230
1587
  const hb = `hermes-${agentId}-heartbeat.timer`;
1231
1588
  const lines = [];
1232
1589
  lines.push(`agent_id ${agentId}`);
@@ -1236,13 +1593,13 @@ var PrintHermesSummary = class extends Command {
1236
1593
  if (!skipEmail) lines.push(`email ${email}`);
1237
1594
  lines.push("");
1238
1595
  lines.push("Start daemons:");
1239
- lines.push(` systemctl --user start ${csm}`);
1240
1596
  lines.push(` systemctl --user start ${hb}`);
1241
1597
  if (!skipTelegram) {
1242
1598
  lines.push(` systemctl --user start ${gw}`);
1243
1599
  } else {
1244
1600
  lines.push(` # gateway needs Telegram wired first (re-run with --skip-telegram=0)`);
1245
1601
  }
1602
+ lines.push(" # Bloodbank commands arrive through the fleet-shared Hermes gateway");
1246
1603
  lines.push("");
1247
1604
  lines.push("Talk locally:");
1248
1605
  lines.push(` ${ctx.roleDir}/hermes chat "status"`);
@@ -1285,15 +1642,15 @@ var HermesAgentRecipe = class extends Recipe {
1285
1642
 
1286
1643
  // src/commands/AgentHooksCommands.ts
1287
1644
  import { homedir as homedir4 } from "node:os";
1288
- import { join as join10, dirname as dirname5 } from "node:path";
1289
- import { existsSync as existsSync8, cpSync, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "node:fs";
1645
+ import { join as join13, dirname as dirname5 } from "node:path";
1646
+ import { existsSync as existsSync9, cpSync, mkdirSync as mkdirSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "node:fs";
1290
1647
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1291
1648
 
1292
1649
  // src/project/index.ts
1293
1650
  import { spawnSync as spawnSync5 } from "node:child_process";
1294
- import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync3, renameSync, statSync, writeFileSync as writeFileSync4 } from "node:fs";
1651
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync5, renameSync, statSync, writeFileSync as writeFileSync5 } from "node:fs";
1295
1652
  import { homedir as homedir3 } from "node:os";
1296
- import { basename as basename2, delimiter, dirname as dirname4, join as join9, resolve } from "node:path";
1653
+ import { basename as basename3, delimiter, dirname as dirname4, join as join12, resolve as resolve2 } from "node:path";
1297
1654
  import YAML2 from "yaml";
1298
1655
 
1299
1656
  // src/project/RegistryStore.ts
@@ -1528,18 +1885,18 @@ var PROJECT_SOURCE_SKILL_ROOTS_ENV = "PJ_SOURCE_SKILL_ROOTS";
1528
1885
  var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
1529
1886
  var DEFAULT_SOURCE_SKILL_ROOTS = [
1530
1887
  "/home/delorenj/code/skillex/all-skills",
1531
- join9(homedir3(), ".agents", "skills"),
1532
- join9(homedir3(), ".codex", "skills")
1888
+ join12(homedir3(), ".agents", "skills"),
1889
+ join12(homedir3(), ".codex", "skills")
1533
1890
  ];
1534
1891
  function projectRegistryPath(env2 = process.env) {
1535
- return expandHome(env2[PROJECT_REGISTRY_ENV] || join9(homedir3(), ".config", "pjangler", "projects.yaml"));
1892
+ return expandHome(env2[PROJECT_REGISTRY_ENV] || join12(homedir3(), ".config", "pjangler", "projects.yaml"));
1536
1893
  }
1537
1894
  function emptyProjectRegistry() {
1538
1895
  return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
1539
1896
  }
1540
1897
  function loadProjectRegistry(path = projectRegistryPath()) {
1541
- if (!existsSync7(path)) return emptyProjectRegistry();
1542
- const raw = YAML2.parse(readFileSync3(path, "utf8"));
1898
+ if (!existsSync8(path)) return emptyProjectRegistry();
1899
+ const raw = YAML2.parse(readFileSync5(path, "utf8"));
1543
1900
  if (raw == null) return emptyProjectRegistry();
1544
1901
  if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
1545
1902
  const registry = raw;
@@ -1554,7 +1911,7 @@ function saveProjectRegistry(registry, path = projectRegistryPath()) {
1554
1911
  validateProjectRegistry(registry);
1555
1912
  mkdirSync4(dirname4(path), { recursive: true });
1556
1913
  const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
1557
- writeFileSync4(temp, YAML2.stringify(registry, { lineWidth: 0 }), "utf8");
1914
+ writeFileSync5(temp, YAML2.stringify(registry, { lineWidth: 0 }), "utf8");
1558
1915
  renameSync(temp, path);
1559
1916
  }
1560
1917
  function validateProjectRegistry(registry) {
@@ -1569,7 +1926,7 @@ function validateProjectRegistry(registry) {
1569
1926
  validateProjectRecord(project, slug);
1570
1927
  if (slugs.has(project.slug)) throw new Error(`Duplicate project slug: ${project.slug}`);
1571
1928
  slugs.add(project.slug);
1572
- const repoKey = resolve(project.repo_path);
1929
+ const repoKey = resolve2(project.repo_path);
1573
1930
  const existingRepoSlug = repoPaths.get(repoKey);
1574
1931
  if (existingRepoSlug && existingRepoSlug !== slug) {
1575
1932
  throw new Error(`Duplicate project repo_path: ${project.repo_path} used by ${existingRepoSlug} and ${slug}`);
@@ -1636,7 +1993,7 @@ function resolveAgentHooksLayer(input, env2 = process.env) {
1636
1993
  const override = env2.PJ_AGENT_HOOKS_LAYER;
1637
1994
  if (override === "0" || override === "false") return false;
1638
1995
  if (override === "1" || override === "true") return true;
1639
- return !existsSync7(join9(homedir3(), ".agents", "hooks"));
1996
+ return !existsSync8(join12(homedir3(), ".agents", "hooks"));
1640
1997
  }
1641
1998
  function jsonStable(value) {
1642
1999
  return JSON.stringify(value);
@@ -1649,14 +2006,14 @@ function projectRecordEquivalent(a, b) {
1649
2006
  }
1650
2007
  function defaultProjectTargetDir(name, cwd = process.cwd()) {
1651
2008
  const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
1652
- return resolve(dirname4(resolve(cwd)), compactName);
2009
+ return resolve2(dirname4(resolve2(cwd)), compactName);
1653
2010
  }
1654
2011
  function sourceSkillRoots(env2 = process.env) {
1655
2012
  const configuredRoots = (env2[PROJECT_SOURCE_SKILL_ROOTS_ENV] || "").split(delimiter).map((root) => root.trim()).filter(Boolean);
1656
2013
  const seen = /* @__PURE__ */ new Set();
1657
2014
  const roots = [];
1658
2015
  for (const root of [...DEFAULT_SOURCE_SKILL_ROOTS, ...configuredRoots]) {
1659
- const normalized = resolve(expandHome(root));
2016
+ const normalized = resolve2(expandHome(root));
1660
2017
  if (seen.has(normalized)) continue;
1661
2018
  seen.add(normalized);
1662
2019
  roots.push(normalized);
@@ -1666,13 +2023,13 @@ function sourceSkillRoots(env2 = process.env) {
1666
2023
  function resolveSourceSkillPath(sourceSkill, env2 = process.env) {
1667
2024
  if (!sourceSkill) return void 0;
1668
2025
  const expanded = expandHome(sourceSkill);
1669
- const direct = resolve(expanded);
1670
- if (existsSync7(direct)) return direct;
1671
- const name = basename2(sourceSkill);
2026
+ const direct = resolve2(expanded);
2027
+ if (existsSync8(direct)) return direct;
2028
+ const name = basename3(sourceSkill);
1672
2029
  const roots = sourceSkillRoots(env2);
1673
2030
  for (const root of roots) {
1674
- const candidate = join9(root, name);
1675
- if (existsSync7(candidate)) return candidate;
2031
+ const candidate = join12(root, name);
2032
+ if (existsSync8(candidate)) return candidate;
1676
2033
  }
1677
2034
  const searched = roots.length ? ` Searched roots: ${roots.join(", ")}.` : "";
1678
2035
  const hint = `${searched} Add project-specific roots with ${PROJECT_SOURCE_SKILL_ROOTS_ENV}.`;
@@ -1680,11 +2037,11 @@ function resolveSourceSkillPath(sourceSkill, env2 = process.env) {
1680
2037
  }
1681
2038
  function planProjectInit(input) {
1682
2039
  if (!input.name.trim()) throw new Error("Project name is required");
1683
- const registryPath2 = resolve(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
2040
+ const registryPath2 = resolve2(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
1684
2041
  const registry = loadProjectRegistry(registryPath2);
1685
2042
  const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
1686
2043
  const slug = input.projectSlug ?? slugifyProjectName(input.name);
1687
- const targetDir = resolve(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
2044
+ const targetDir = resolve2(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
1688
2045
  const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
1689
2046
  const existing = registry.projects[slug];
1690
2047
  const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
@@ -1727,7 +2084,7 @@ function planProjectInit(input) {
1727
2084
  updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
1728
2085
  };
1729
2086
  validateNoDuplicateProject(registry, project, overwrite);
1730
- const pjanglerRoot = resolve(input.pjanglerRoot ?? resolvePjanglerRoot());
2087
+ const pjanglerRoot = resolve2(input.pjanglerRoot ?? resolvePjanglerRoot());
1731
2088
  const manifest = projectManifestFromRegistryProject(project);
1732
2089
  const apply = input.apply ?? false;
1733
2090
  const live = input.live ?? false;
@@ -1753,7 +2110,7 @@ function planProjectInit(input) {
1753
2110
  }));
1754
2111
  }
1755
2112
  actions.push(
1756
- { kind: "project.write-manifest", path: join9(targetDir, ".project.json"), manifest },
2113
+ { kind: "project.write-manifest", path: join12(targetDir, ".project.json"), manifest },
1757
2114
  {
1758
2115
  kind: "ticket-provider.create-or-link",
1759
2116
  enabled: live,
@@ -1806,7 +2163,7 @@ async function executeProjectInitPlan(plan) {
1806
2163
  }
1807
2164
  if (result.status !== 0) {
1808
2165
  errors.push(`copier exited with status ${result.status ?? "unknown"}`);
1809
- if (existsSync7(action.targetDir)) changedFiles.push(action.targetDir);
2166
+ if (existsSync8(action.targetDir)) changedFiles.push(action.targetDir);
1810
2167
  break;
1811
2168
  }
1812
2169
  changedFiles.push(action.targetDir);
@@ -1814,9 +2171,9 @@ async function executeProjectInitPlan(plan) {
1814
2171
  mkdirSync4(dirname4(action.path), { recursive: true });
1815
2172
  const next = `${JSON.stringify(action.manifest, null, 2)}
1816
2173
  `;
1817
- const current = existsSync7(action.path) ? readFileSync3(action.path, "utf8") : void 0;
2174
+ const current = existsSync8(action.path) ? readFileSync5(action.path, "utf8") : void 0;
1818
2175
  if (current !== next) {
1819
- writeFileSync4(action.path, next, "utf8");
2176
+ writeFileSync5(action.path, next, "utf8");
1820
2177
  changedFiles.push(action.path);
1821
2178
  }
1822
2179
  } else if (action.kind === "registry.upsert") {
@@ -1879,7 +2236,7 @@ function getProject(registry, slug) {
1879
2236
  return project;
1880
2237
  }
1881
2238
  function buildCommonProjectCopierAction(input) {
1882
- const templateDir = join9(input.pjanglerRoot, "templates", "commonproject");
2239
+ const templateDir = join12(input.pjanglerRoot, "templates", "commonproject");
1883
2240
  const data = {
1884
2241
  project_name: input.projectName,
1885
2242
  project_description: input.projectDescription ?? "",
@@ -1908,19 +2265,19 @@ function buildCommonProjectCopierAction(input) {
1908
2265
  function resolvePjanglerRoot() {
1909
2266
  let dir = dirname4(new URL(import.meta.url).pathname);
1910
2267
  while (dir !== dirname4(dir)) {
1911
- if (existsSync7(join9(dir, "package.json")) && existsSync7(join9(dir, "templates", "commonproject", "copier.yml"))) return dir;
2268
+ if (existsSync8(join12(dir, "package.json")) && existsSync8(join12(dir, "templates", "commonproject", "copier.yml"))) return dir;
1912
2269
  dir = dirname4(dir);
1913
2270
  }
1914
- return resolve(process.cwd());
2271
+ return resolve2(process.cwd());
1915
2272
  }
1916
2273
  function validateNoDuplicateProject(registry, project, overwrite) {
1917
2274
  const existingSameSlug = registry.projects[project.slug];
1918
- if (existingSameSlug && !overwrite && resolve(existingSameSlug.repo_path) !== resolve(project.repo_path)) {
2275
+ if (existingSameSlug && !overwrite && resolve2(existingSameSlug.repo_path) !== resolve2(project.repo_path)) {
1919
2276
  throw new Error(`Project slug already exists in registry: ${project.slug}`);
1920
2277
  }
1921
2278
  for (const [slug, existing] of Object.entries(registry.projects)) {
1922
2279
  if (slug === project.slug) continue;
1923
- if (resolve(existing.repo_path) === resolve(project.repo_path)) {
2280
+ if (resolve2(existing.repo_path) === resolve2(project.repo_path)) {
1924
2281
  throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
1925
2282
  }
1926
2283
  if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
@@ -1940,7 +2297,7 @@ function validateProjectRecord(project, key) {
1940
2297
  }
1941
2298
  function expandHome(path) {
1942
2299
  if (path === "~") return homedir3();
1943
- if (path.startsWith("~/")) return join9(homedir3(), path.slice(2));
2300
+ if (path.startsWith("~/")) return join12(homedir3(), path.slice(2));
1944
2301
  return path;
1945
2302
  }
1946
2303
  function isRecord(value) {
@@ -1957,16 +2314,16 @@ function resolveTemplateRoot() {
1957
2314
  try {
1958
2315
  let dir = dirname5(fileURLToPath2(import.meta.url));
1959
2316
  for (let i = 0; i < 8; i++) {
1960
- candidates.push(join10(dir, "templates", "commonproject", "template"));
2317
+ candidates.push(join13(dir, "templates", "commonproject", "template"));
1961
2318
  const parent = dirname5(dir);
1962
2319
  if (parent === dir) break;
1963
2320
  dir = parent;
1964
2321
  }
1965
2322
  } catch {
1966
2323
  }
1967
- candidates.push(join10(homedir4(), "code", "pjangler", "templates", "commonproject", "template"));
2324
+ candidates.push(join13(homedir4(), "code", "pjangler", "templates", "commonproject", "template"));
1968
2325
  for (const c of candidates) {
1969
- if (existsSync8(join10(c, ".agents", "hooks", "hooks.master.json"))) return c;
2326
+ if (existsSync9(join13(c, ".agents", "hooks", "hooks.master.json"))) return c;
1970
2327
  }
1971
2328
  throw new Error(
1972
2329
  "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
@@ -1985,18 +2342,17 @@ var CopyAgentHooksTree = class extends Command {
1985
2342
  }
1986
2343
  const items = [
1987
2344
  { rel: ".agents/hooks", dir: true },
2345
+ { rel: ".agents/skills.json", dir: false },
1988
2346
  { rel: ".agents/local.example.json", dir: false },
1989
- { rel: ".mise/scripts/link-project-skills-to-clis.sh", dir: false },
1990
- { rel: ".mise/scripts/unlink-project-skills-from-clis.sh", dir: false },
1991
2347
  { rel: ".mise/scripts/hindsight-setup.sh", dir: false }
1992
2348
  ];
1993
2349
  const created = [];
1994
2350
  const skipped = [];
1995
2351
  for (const { rel, dir } of items) {
1996
- const src = join10(templateRoot, rel);
1997
- const dest = join10(this.context.targetDir, rel);
1998
- if (!existsSync8(src)) continue;
1999
- if (existsSync8(dest) && !this.context.force) {
2352
+ const src = join13(templateRoot, rel);
2353
+ const dest = join13(this.context.targetDir, rel);
2354
+ if (!existsSync9(src)) continue;
2355
+ if (existsSync9(dest) && !this.context.force) {
2000
2356
  skipped.push(rel);
2001
2357
  continue;
2002
2358
  }
@@ -2022,25 +2378,21 @@ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
2022
2378
  if (!resolveAgentHooksLayer()) {
2023
2379
  return { success: true, message: this.formatMessage(AGENT_HOOKS_SKIP_MESSAGE) };
2024
2380
  }
2025
- const misePath = join10(this.context.targetDir, "mise.toml");
2026
- if (!existsSync8(misePath)) {
2381
+ const misePath = join13(this.context.targetDir, "mise.toml");
2382
+ if (!existsSync9(misePath)) {
2027
2383
  return {
2028
2384
  success: false,
2029
2385
  message: "\u26A0\uFE0F No mise.toml found \u2014 run `pjangler init mise` first, then re-run."
2030
2386
  };
2031
2387
  }
2032
- let content = readFileSync4(misePath, "utf8");
2388
+ let content = readFileSync6(misePath, "utf8");
2033
2389
  if (content.includes(_WireMiseAgentHooks.MARKER)) {
2034
2390
  return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for agent-hooks") };
2035
2391
  }
2036
2392
  const cr = _WireMiseAgentHooks.CR;
2037
- const enterAdds = [
2038
- ` "${cr}/.mise/scripts/link-project-skills-to-clis.sh",`,
2039
- ` "${cr}/.agents/hooks/sync.py --install --quiet",`
2040
- ].join("\n");
2393
+ const enterAdds = [` "sync-skills.py --scope project",`, ` "${cr}/.agents/hooks/sync.py --install --quiet",`].join("\n");
2041
2394
  const leaveBlock = [
2042
2395
  "leave = [",
2043
- ` "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",`,
2044
2396
  ` "${cr}/.agents/hooks/sync.py --uninstall --quiet",`,
2045
2397
  "]"
2046
2398
  ].join("\n");
@@ -2048,16 +2400,15 @@ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
2048
2400
  const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
2049
2401
  if (enterRe.test(content)) {
2050
2402
  content = content.replace(enterRe, (_m, head, close) => {
2051
- const sep = /[,[]\s*$/.test(head) ? "" : ",";
2052
- return `${head}${sep}
2403
+ const sep2 = /[,[]\s*$/.test(head) ? "" : ",";
2404
+ return `${head}${sep2}
2053
2405
  ${enterAdds}${close}`;
2054
2406
  });
2055
2407
  const leaveRe = /(leave\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
2056
2408
  if (leaveRe.test(content)) {
2057
2409
  content = content.replace(leaveRe, (_m, head, close) => {
2058
- const sep = /[,[]\s*$/.test(head) ? "" : ",";
2059
- return `${head}${sep}
2060
- "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",
2410
+ const sep2 = /[,[]\s*$/.test(head) ? "" : ",";
2411
+ return `${head}${sep2}
2061
2412
  "${cr}/.agents/hooks/sync.py --uninstall --quiet",${close}`;
2062
2413
  });
2063
2414
  } else {
@@ -2070,6 +2421,14 @@ ${leaveBlock}`);
2070
2421
  "",
2071
2422
  _WireMiseAgentHooks.MARKER + " (generated \u2014 see .agents/hooks/README.md)",
2072
2423
  "[[watch_files]]",
2424
+ 'patterns = [".agents/skills.json"]',
2425
+ 'task = "skills-sync"',
2426
+ "",
2427
+ "[tasks.skills-sync]",
2428
+ 'description = "Sync skills from manifest to local CLI dirs"',
2429
+ 'run = "sync-skills.py --scope project"',
2430
+ "",
2431
+ "[[watch_files]]",
2073
2432
  'patterns = [".agents/hooks/hooks.master.json"]',
2074
2433
  'task = "hooks-sync"',
2075
2434
  "",
@@ -2085,18 +2444,6 @@ ${leaveBlock}`);
2085
2444
  'description = "Remove per-user agent-hook injections (codex/kimi/hermes)"',
2086
2445
  `run = "${cr}/.agents/hooks/sync.py --uninstall"`,
2087
2446
  "",
2088
- "[tasks.link-project-skills-to-clis]",
2089
- 'description = "Fan .agents/skills out to each agent CLI (honors local.json)"',
2090
- `run = "${cr}/.mise/scripts/link-project-skills-to-clis.sh"`,
2091
- "",
2092
- "[tasks.unlink-project-skills-from-clis]",
2093
- 'description = "Remove project skill symlinks from shared per-CLI dirs"',
2094
- `run = "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh"`,
2095
- "",
2096
- "[tasks.skills-relink]",
2097
- 'description = "Re-fan the project skill set to all CLIs"',
2098
- `run = "${cr}/.mise/scripts/link-project-skills-to-clis.sh"`,
2099
- "",
2100
2447
  "[tasks.hindsight-setup]",
2101
2448
  `description = "Provision this dev's shared project Hindsight key from 1Password into .env"`,
2102
2449
  `run = "${cr}/.mise/scripts/hindsight-setup.sh"`,
@@ -2105,7 +2452,7 @@ ${leaveBlock}`);
2105
2452
  ""
2106
2453
  ].join("\n");
2107
2454
  content = content.replace(/\n*$/, "\n") + appended;
2108
- if (!this.context.dryRun) writeFileSync5(misePath, content);
2455
+ if (!this.context.dryRun) writeFileSync6(misePath, content);
2109
2456
  if (wiredHooks) {
2110
2457
  return { success: true, message: this.formatMessage("\u2705 Wired mise.toml ([hooks] enter/leave + tasks)") };
2111
2458
  }
@@ -2114,8 +2461,8 @@ ${leaveBlock}`);
2114
2461
  message: this.formatMessage(
2115
2462
  `\u2705 Added agent-hooks tasks to mise.toml.
2116
2463
  \u26A0\uFE0F Could not find a [hooks].enter array to extend \u2014 add these to your [hooks] block manually:
2117
- enter += "${cr}/.mise/scripts/link-project-skills-to-clis.sh", "${cr}/.agents/hooks/sync.py --install --quiet"
2118
- leave += "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh", "${cr}/.agents/hooks/sync.py --uninstall --quiet"`
2464
+ enter += "sync-skills.py --scope project", "${cr}/.agents/hooks/sync.py --install --quiet"
2465
+ leave += "${cr}/.agents/hooks/sync.py --uninstall --quiet"`
2119
2466
  )
2120
2467
  };
2121
2468
  }
@@ -2130,10 +2477,25 @@ var AgentHooksRecipe = class extends Recipe {
2130
2477
  printNextSteps() {
2131
2478
  console.log("\u{1FA9D} Agent-hooks layer installed!");
2132
2479
  console.log(" Next steps:");
2133
- console.log(" 1. mise run hooks-sync # generate .claude/settings.json + inject codex/kimi/hermes");
2134
- console.log(" 2. git add .claude/settings.json .agents/hooks && commit (codex/kimi/hermes are per-dev)");
2135
- console.log(" 3. mise run hindsight-setup # set HINDSIGHT_OP_KEY_REF to your 1Password item first");
2136
- console.log(` 4. If you run a global agent system: echo '{"skills":{"defer_to_global":true}}' > .agents/local.json`);
2480
+ console.log(" 1. mise run skills-sync # sync .agents/skills.json into local CLI dirs");
2481
+ console.log(" 2. mise run hooks-sync # generate .claude/settings.json + inject codex/kimi/hermes");
2482
+ console.log(" 3. git add .claude/settings.json .agents/hooks .agents/skills.json && commit (codex/kimi/hermes are per-dev)");
2483
+ console.log(" 4. mise run hindsight-setup # set HINDSIGHT_OP_KEY_REF to your 1Password item first");
2484
+ console.log(" 5. Optional per-dev hook opt-out: copy .agents/local.example.json -> .agents/local.json");
2485
+ }
2486
+ };
2487
+
2488
+ // src/recipes/MiseOpInjectRecipe.ts
2489
+ var MiseOpInjectRecipe = class extends Recipe {
2490
+ constructor(context) {
2491
+ super(context);
2492
+ this.addIngredient(WireMiseOpInject);
2493
+ }
2494
+ printNextSteps() {
2495
+ console.log("\u{1F389} Wired up .env.op 1Password resolution via mise!");
2496
+ console.log(" Next steps:");
2497
+ console.log(" 1. Create .env.op with your op:// secret references");
2498
+ console.log(" 2. Run `mise run secrets-inject` or simply cd out and back in to trigger the hook");
2137
2499
  }
2138
2500
  };
2139
2501
 
@@ -2176,6 +2538,12 @@ var RECIPE_REGISTRY = {
2176
2538
  description: "Retrofit the project-scoped agent-hooks + skill fan-out layer (Claude/Codex/Kimi/Hermes hooks via mise enter/leave)",
2177
2539
  class: AgentHooksRecipe,
2178
2540
  commands: ["CopyAgentHooksTree", "WireMiseAgentHooks"]
2541
+ },
2542
+ "mise-op-inject": {
2543
+ name: "mise-op-inject",
2544
+ description: "Wire up op-inject script to mise.toml for 1Password secret resolution",
2545
+ class: MiseOpInjectRecipe,
2546
+ commands: ["WireMiseOpInject"]
2179
2547
  }
2180
2548
  };
2181
2549
  var COMMAND_REGISTRY = {
@@ -2244,6 +2612,12 @@ var COMMAND_REGISTRY = {
2244
2612
  description: "Create .env.example file",
2245
2613
  group: "environment",
2246
2614
  class: AddDotenv
2615
+ },
2616
+ WireMiseOpInject: {
2617
+ name: "WireMiseOpInject",
2618
+ description: "Wire up op-inject script to mise.toml for 1Password secret resolution",
2619
+ group: "mise",
2620
+ class: WireMiseOpInject
2247
2621
  }
2248
2622
  };
2249
2623
  function getRecipeNames() {
@@ -2259,15 +2633,15 @@ function createRecipe(name, context) {
2259
2633
  }
2260
2634
 
2261
2635
  // src/utils/version.ts
2262
- import { readFileSync as readFileSync5 } from "node:fs";
2263
- import { dirname as dirname6, join as join11 } from "node:path";
2636
+ import { readFileSync as readFileSync7 } from "node:fs";
2637
+ import { dirname as dirname6, join as join14 } from "node:path";
2264
2638
  import { fileURLToPath as fileURLToPath3 } from "node:url";
2265
2639
  var PJANGLER_VERSION = (() => {
2266
2640
  try {
2267
2641
  let dir = dirname6(fileURLToPath3(import.meta.url));
2268
2642
  for (let i = 0; i < 4; i++) {
2269
2643
  try {
2270
- const raw = readFileSync5(join11(dir, "package.json"), "utf8");
2644
+ const raw = readFileSync7(join14(dir, "package.json"), "utf8");
2271
2645
  return JSON.parse(raw).version ?? "0.0.0";
2272
2646
  } catch {
2273
2647
  const parent = dirname6(dir);
@@ -2281,29 +2655,61 @@ var PJANGLER_VERSION = (() => {
2281
2655
  })();
2282
2656
 
2283
2657
  // src/parity/index.ts
2284
- import { existsSync as existsSync9, lstatSync, mkdirSync as mkdirSync6, readFileSync as readFileSync6, readlinkSync, readdirSync as readdirSync2, renameSync as renameSync2, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync6, chmodSync as chmodSync2, copyFileSync } from "node:fs";
2285
- import { basename as basename3, dirname as dirname7, join as join12, relative, resolve as resolve2 } from "node:path";
2286
- import { fileURLToPath as fileURLToPath4 } from "node:url";
2658
+ import { existsSync as existsSync10, lstatSync as lstatSync2, mkdirSync as mkdirSync6, mkdtempSync, readFileSync as readFileSync8, readlinkSync, readdirSync as readdirSync3, realpathSync, renameSync as renameSync2, rmdirSync, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync7, chmodSync as chmodSync4, copyFileSync, rmSync } from "node:fs";
2659
+ import { basename as basename4, dirname as dirname7, join as join15, relative as relative2, resolve as resolve3 } from "node:path";
2660
+ import { fileURLToPath as fileURLToPath4, pathToFileURL } from "node:url";
2287
2661
  import { homedir as homedir5 } from "node:os";
2288
2662
  import { spawnSync as spawnSync6 } from "node:child_process";
2289
2663
  import YAML3 from "yaml";
2290
2664
  var LINK_AGENTFILES_SCRIPT = "'{{config_root}}/.mise/scripts/link-agentfiles.sh'";
2291
2665
  var OP_INJECT_SCRIPT = "op inject -i .env.op > .env";
2666
+ var PROVISION_BMAD_SKILLS_SCRIPT = "python3 '{{config_root}}/.mise/scripts/provision-bmad-skills.py'";
2667
+ var SYNC_SKILLS_SCRIPT = "python3 '{{config_root}}/.mise/scripts/sync-skills.py' --scope project";
2292
2668
  var CODEGRAPH_SCRIPT = "[ -f '{{config_root}}/.mise/scripts/codegraph.sh' ] && '{{config_root}}/.mise/scripts/codegraph.sh' || true";
2669
+ var SKILLS_REGISTRY_URL = "https://github.com/delorenj/skillex.git";
2670
+ var PROJECT_CLI_SKILL_DIRS = [
2671
+ ".gemini/skills",
2672
+ ".codex/skills",
2673
+ ".kimi/skills",
2674
+ ".augment/skills",
2675
+ ".config/opencode/skills",
2676
+ ".hermes/skills",
2677
+ ".claude/skills",
2678
+ ".openclaw/skills"
2679
+ ];
2680
+ var CANONICAL_CLAUDE_SKILLS_ALIAS = "../.agents/skills";
2293
2681
  var HOOKS_COMMENT_HEADER = `# This block will handle the linking of
2294
2682
  # agent files to the main AGENTS.md file.
2295
2683
  #
2296
2684
  # TODO: Ensure this works for all levels of nesting.
2297
2685
  # i.e. All linked agent files MUST be siblings at
2298
2686
  # any given level of nesting.`;
2299
- var LINK_AGENTFILES_HOOK_ENTRIES = [LINK_AGENTFILES_SCRIPT, OP_INJECT_SCRIPT];
2687
+ var LINK_AGENTFILES_HOOK_ENTRIES = [
2688
+ LINK_AGENTFILES_SCRIPT,
2689
+ OP_INJECT_SCRIPT,
2690
+ PROVISION_BMAD_SKILLS_SCRIPT,
2691
+ SYNC_SKILLS_SCRIPT
2692
+ ];
2300
2693
  var LINK_AGENTFILES_WATCH_TASK_BLOCK = `[[watch_files]]
2301
2694
  patterns = ["AGENTS.md"]
2302
2695
  task = "link-agentfiles"
2303
2696
 
2697
+ [[watch_files]]
2698
+ patterns = [".agents/skills.json"]
2699
+ task = "skills-sync"
2700
+
2304
2701
  [tasks.link-agentfiles]
2305
2702
  description = "Symlink all agent files to AGENTS.md"
2306
- run = "'{{config_root}}/.mise/scripts/link-agentfiles.sh'"`;
2703
+ run = "'{{config_root}}/.mise/scripts/link-agentfiles.sh'"
2704
+
2705
+ [tasks.skills-sync]
2706
+ description = "Sync skills from manifest to local CLI dirs"
2707
+ depends = ["skills-provision-bmad"]
2708
+ run = "python3 '{{config_root}}/.mise/scripts/sync-skills.py' --scope project"
2709
+
2710
+ [tasks.skills-provision-bmad]
2711
+ description = "Provision pinned BMAD skills from the Skillex pack"
2712
+ run = "python3 '{{config_root}}/.mise/scripts/provision-bmad-skills.py'"`;
2307
2713
  var VERSIONING_BLOCK = `# >>> mise-versioning >>> (managed block \u2014 do not edit by hand; re-run init to update)
2308
2714
  [tasks."version"]
2309
2715
  description = "Print the current version (vX.Y.Z)"
@@ -2333,7 +2739,7 @@ run = "'{{config_root}}/.mise/scripts/versioning.sh' sync"
2333
2739
  function resolvePjanglerRoot2() {
2334
2740
  let dir = dirname7(fileURLToPath4(import.meta.url));
2335
2741
  while (dir !== dirname7(dir)) {
2336
- if (existsSync9(join12(dir, "package.json")) && existsSync9(join12(dir, "templates", "commonproject", "copier.yml"))) {
2742
+ if (existsSync10(join15(dir, "package.json")) && existsSync10(join15(dir, "templates", "commonproject", "copier.yml"))) {
2337
2743
  return dir;
2338
2744
  }
2339
2745
  dir = dirname7(dir);
@@ -2344,17 +2750,17 @@ function normalizeNewlines(value) {
2344
2750
  return value.replace(/\r\n/g, "\n");
2345
2751
  }
2346
2752
  function readText(path) {
2347
- return normalizeNewlines(readFileSync6(path, "utf8"));
2753
+ return normalizeNewlines(readFileSync8(path, "utf8"));
2348
2754
  }
2349
2755
  function safeReadText(path) {
2350
- return existsSync9(path) ? readText(path) : null;
2756
+ return existsSync10(path) ? readText(path) : null;
2351
2757
  }
2352
2758
  function ensureParent(path) {
2353
2759
  mkdirSync6(dirname7(path), { recursive: true });
2354
2760
  }
2355
2761
  function writeText(path, content) {
2356
2762
  ensureParent(path);
2357
- writeFileSync6(path, content);
2763
+ writeFileSync7(path, content);
2358
2764
  }
2359
2765
  function tryParseJson(text2) {
2360
2766
  if (!text2) return null;
@@ -2371,7 +2777,7 @@ function titleCaseSlug(slug) {
2371
2777
  return slug.split(/[-_]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
2372
2778
  }
2373
2779
  function readSymlinkTarget(path) {
2374
- if (!existsSync9(path)) return null;
2780
+ if (!existsSync10(path)) return null;
2375
2781
  try {
2376
2782
  return readlinkSync(path);
2377
2783
  } catch {
@@ -2379,8 +2785,8 @@ function readSymlinkTarget(path) {
2379
2785
  }
2380
2786
  }
2381
2787
  function ensureSymlink(path, target, dryRun) {
2382
- if (existsSync9(path)) {
2383
- const stat = lstatSync(path);
2788
+ if (existsSync10(path)) {
2789
+ const stat = lstatSync2(path);
2384
2790
  if (stat.isSymbolicLink()) {
2385
2791
  const current = readSymlinkTarget(path);
2386
2792
  if (current === target) return { changed: false };
@@ -2390,18 +2796,18 @@ function ensureSymlink(path, target, dryRun) {
2390
2796
  }
2391
2797
  return { changed: true };
2392
2798
  }
2393
- return { changed: false, blocked: `${relative(process.cwd(), path) || path} exists and is not a symlink` };
2799
+ return { changed: false, blocked: `${relative2(process.cwd(), path) || path} exists and is not a symlink` };
2394
2800
  }
2395
2801
  if (!dryRun) symlinkSync(target, path);
2396
2802
  return { changed: true };
2397
2803
  }
2398
2804
  function bootstrapAgentsFile(repoRoot, dryRun) {
2399
- const agentsPath = join12(repoRoot, "AGENTS.md");
2400
- if (existsSync9(agentsPath)) return { changedFiles: [], details: [] };
2805
+ const agentsPath = join15(repoRoot, "AGENTS.md");
2806
+ if (existsSync10(agentsPath)) return { changedFiles: [], details: [] };
2401
2807
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2402
- const source = join12(repoRoot, file);
2403
- if (!existsSync9(source)) continue;
2404
- const stat = lstatSync(source);
2808
+ const source = join15(repoRoot, file);
2809
+ if (!existsSync10(source)) continue;
2810
+ const stat = lstatSync2(source);
2405
2811
  if (stat.isSymbolicLink()) continue;
2406
2812
  if (stat.isFile()) {
2407
2813
  if (!dryRun) renameSync2(source, agentsPath);
@@ -2409,9 +2815,9 @@ function bootstrapAgentsFile(repoRoot, dryRun) {
2409
2815
  }
2410
2816
  return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
2411
2817
  }
2412
- const readmePath = join12(repoRoot, "README.md");
2413
- if (existsSync9(readmePath)) {
2414
- const stat = lstatSync(readmePath);
2818
+ const readmePath = join15(repoRoot, "README.md");
2819
+ if (existsSync10(readmePath)) {
2820
+ const stat = lstatSync2(readmePath);
2415
2821
  if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
2416
2822
  if (!dryRun) copyFileSync(readmePath, agentsPath);
2417
2823
  return { changedFiles: [agentsPath], details: ["Copied README.md to AGENTS.md before wiring agent-file symlinks"] };
@@ -2449,12 +2855,12 @@ function yamlGet(text2, keyPath) {
2449
2855
  return "";
2450
2856
  }
2451
2857
  function discoverRoles(repoRoot) {
2452
- const rolesDir = join12(repoRoot, "agents", "hermes");
2453
- if (!existsSync9(rolesDir)) return [];
2454
- return readdirSync2(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
2455
- const roleDir = join12(rolesDir, entry.name);
2456
- const roleYamlPath = join12(roleDir, "role.yaml");
2457
- if (!existsSync9(roleYamlPath)) return null;
2858
+ const rolesDir = join15(repoRoot, "agents", "hermes");
2859
+ if (!existsSync10(rolesDir)) return [];
2860
+ return readdirSync3(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
2861
+ const roleDir = join15(rolesDir, entry.name);
2862
+ const roleYamlPath = join15(roleDir, "role.yaml");
2863
+ if (!existsSync10(roleYamlPath)) return null;
2458
2864
  const text2 = readText(roleYamlPath);
2459
2865
  const runtimeRepoRaw = yamlGet(text2, "runtime.github_repo");
2460
2866
  return {
@@ -2482,7 +2888,7 @@ function discoverRoles(repoRoot) {
2482
2888
  }).filter((value) => Boolean(value));
2483
2889
  }
2484
2890
  function registryPath(homeDir) {
2485
- return join12(homeDir, ".hermes", "agents-registry.yaml");
2891
+ return join15(homeDir, ".hermes", "agents-registry.yaml");
2486
2892
  }
2487
2893
  function systemctlUser(args) {
2488
2894
  const result = spawnSync6("systemctl", ["--user", ...args], { encoding: "utf8" });
@@ -2493,8 +2899,8 @@ function systemctlUser(args) {
2493
2899
  };
2494
2900
  }
2495
2901
  function templateScript(ctx, name) {
2496
- const source = join12(ctx.pjanglerRoot, ".mise", "scripts", name);
2497
- return existsSync9(source) ? readText(source) : void 0;
2902
+ const source = join15(ctx.pjanglerRoot, ".mise", "scripts", name);
2903
+ return existsSync10(source) ? readText(source) : void 0;
2498
2904
  }
2499
2905
  function templateVersioningScript(ctx) {
2500
2906
  return templateScript(ctx, "versioning.sh");
@@ -2506,8 +2912,8 @@ function resolveAgentHooksLayer2(ctx) {
2506
2912
  const override = process.env.PJ_AGENT_HOOKS_LAYER;
2507
2913
  if (override === "0" || override === "false") return false;
2508
2914
  if (override === "1" || override === "true") return true;
2509
- if (existsSync9(join12(ctx.repoRoot, ".agents", "hooks", "sync.py"))) return true;
2510
- return !existsSync9(join12(ctx.homeDir, ".agents", "hooks"));
2915
+ if (existsSync10(join15(ctx.repoRoot, ".agents", "hooks", "sync.py"))) return true;
2916
+ return !existsSync10(join15(ctx.homeDir, ".agents", "hooks"));
2511
2917
  }
2512
2918
  function evaluateMiseConditionals(template, agentHooksLayer) {
2513
2919
  const out = [];
@@ -2533,102 +2939,521 @@ function evaluateMiseConditionals(template, agentHooksLayer) {
2533
2939
  }
2534
2940
  function renderGeneratedProjectMiseToml(ctx, template) {
2535
2941
  const project = readProjectJson(ctx);
2536
- const projectName = String(project?.project_name ?? basename3(ctx.repoRoot) ?? "project");
2942
+ const projectName = String(project?.project_name ?? basename4(ctx.repoRoot) ?? "project");
2537
2943
  return evaluateMiseConditionals(template, resolveAgentHooksLayer2(ctx)).replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
2538
2944
  }
2539
2945
  function ensureMiseTomlFromTemplate(ctx, changedFiles) {
2540
- const targetPath = join12(ctx.repoRoot, "mise.toml");
2541
- if (existsSync9(targetPath)) return false;
2542
- const sourcePath = join12(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
2543
- if (!existsSync9(sourcePath)) return false;
2946
+ const targetPath = join15(ctx.repoRoot, "mise.toml");
2947
+ if (existsSync10(targetPath)) return false;
2948
+ const sourcePath = join15(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
2949
+ if (!existsSync10(sourcePath)) return false;
2544
2950
  changedFiles.push(targetPath);
2545
2951
  if (!ctx.dryRun) {
2546
2952
  writeText(targetPath, renderGeneratedProjectMiseToml(ctx, readText(sourcePath)));
2547
2953
  }
2548
2954
  return true;
2549
2955
  }
2550
- function templateVersionFilesConf(ctx, repoRoot) {
2551
- const packageJson = join12(repoRoot, "package.json");
2552
- 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";
2956
+ function templateCommonProjectText(ctx, rel) {
2957
+ const path = join15(ctx.pjanglerRoot, "templates", "commonproject", "template", rel);
2958
+ return existsSync10(path) ? readText(path) : void 0;
2553
2959
  }
2554
- function replaceOrAppendManagedBlock(text2, startMarker, block, beforePattern) {
2555
- if (startMarker.test(text2)) {
2556
- return text2.replace(/# >>> mise-versioning >>>[\s\S]*?# <<< mise-versioning <<</, block);
2557
- }
2558
- if (beforePattern) {
2559
- const match = text2.match(beforePattern);
2560
- if (match && typeof match.index === "number") {
2561
- return `${text2.slice(0, match.index).replace(/\s*$/, "\n\n")}${block}
2562
-
2563
- ${text2.slice(match.index)}`;
2564
- }
2960
+ function validateSkillName(name) {
2961
+ if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\") || basename4(name) !== name) {
2962
+ throw new Error(`Unsafe skill name: ${JSON.stringify(name)}`);
2565
2963
  }
2566
- return `${text2.replace(/\s*$/, "")}
2567
-
2568
- ${block}
2569
- `;
2964
+ return name;
2570
2965
  }
2571
- var BASE_MISE_PATH_ENTRIES = [".mise/scripts", "agents/hermes/pm"];
2572
- var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/hermes"];
2573
- function requiredMisePathEntries(ctx) {
2574
- const required = [...BASE_MISE_PATH_ENTRIES];
2575
- for (const candidate of CONDITIONAL_HERMES_PATHS) {
2576
- if (existsSync9(join12(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
2966
+ function lstatIfPresent(path) {
2967
+ try {
2968
+ return lstatSync2(path);
2969
+ } catch (error) {
2970
+ if (error.code === "ENOENT") return void 0;
2971
+ throw error;
2577
2972
  }
2578
- return required;
2579
2973
  }
2580
- function upsertMisePath(text2, required = BASE_MISE_PATH_ENTRIES) {
2581
- const render = (values) => `_.path = [${values.map((value) => JSON.stringify(value)).join(", ")}]`;
2582
- const envMatch = text2.match(/(^|\n)(\[env\][\s\S]*?)(?=\n\[[^\]]+\]|$)/);
2583
- if (!envMatch || typeof envMatch.index !== "number") {
2584
- return `[env]
2585
- ${render(required)}
2586
-
2587
- ${text2.replace(/^\s+/, "")}`;
2588
- }
2589
- const prefix = text2.slice(0, envMatch.index + envMatch[1].length);
2590
- const section = envMatch[2];
2591
- const suffix = text2.slice(envMatch.index + envMatch[1].length + section.length);
2592
- const pathLine = section.match(/^_\.path\s*=\s*\[([^\]]*)\]\s*$/m);
2593
- if (!pathLine) {
2594
- return `${prefix}${section.replace(/\n?$/, "\n")}${render(required)}${suffix}`;
2974
+ function isContainedBy(root, target) {
2975
+ const rel = relative2(root, target);
2976
+ return rel === "" || rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\");
2977
+ }
2978
+ function prepareSafeProjectSkillsDirs(ctx) {
2979
+ const projectRoot = realpathSync(ctx.repoRoot);
2980
+ const agentsDir = join15(projectRoot, ".agents");
2981
+ const skillsDir = join15(agentsDir, "skills");
2982
+ for (const path of [agentsDir, skillsDir]) {
2983
+ if (!isContainedBy(projectRoot, path)) throw new Error(`Project skills path escapes repository: ${path}`);
2984
+ const stat = lstatIfPresent(path);
2985
+ if (stat?.isSymbolicLink()) throw new Error(`Refusing symlinked project skills directory: ${path}`);
2986
+ if (stat && !stat.isDirectory()) throw new Error(`Project skills path is not a directory: ${path}`);
2595
2987
  }
2596
- const current = [...pathLine[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2597
- const merged = [...current];
2598
- for (const value of required) {
2599
- if (!merged.includes(value)) merged.push(value);
2988
+ if (!ctx.dryRun) {
2989
+ if (!existsSync10(agentsDir)) mkdirSync6(agentsDir, { recursive: false });
2990
+ if (!existsSync10(skillsDir)) mkdirSync6(skillsDir, { recursive: false });
2991
+ for (const path of [agentsDir, skillsDir]) {
2992
+ if (lstatSync2(path).isSymbolicLink() || !lstatSync2(path).isDirectory()) {
2993
+ throw new Error(`Unsafe project skills directory after creation: ${path}`);
2994
+ }
2995
+ if (!isContainedBy(projectRoot, realpathSync(path))) {
2996
+ throw new Error(`Resolved project skills directory escapes repository: ${path}`);
2997
+ }
2998
+ }
2600
2999
  }
2601
- const nextLine = render(merged);
2602
- if (pathLine[0] === nextLine) return text2;
2603
- return `${prefix}${section.replace(pathLine[0], nextLine)}${suffix}`;
3000
+ return { agentsDir, skillsDir };
2604
3001
  }
2605
- function removeTomlSection(text2, headerPattern, marker, options) {
2606
- const lines = text2.split("\n");
2607
- let start = -1;
2608
- let end = -1;
2609
- for (let i = 0; i < lines.length; i++) {
2610
- if (!headerPattern.test(lines[i])) continue;
2611
- if (marker) {
2612
- let hasMarker = false;
2613
- for (let j = i + 1; j < lines.length && !/^\[[^\]]+\]/.test(lines[j]); j++) {
2614
- if (marker.test(lines[j])) {
2615
- hasMarker = true;
2616
- break;
3002
+ function projectSkillTopologyIssues(repoRoot) {
3003
+ const issues = [];
3004
+ let projectRoot;
3005
+ try {
3006
+ projectRoot = realpathSync(repoRoot);
3007
+ } catch (error) {
3008
+ return [`Project root is not a readable real directory: ${error instanceof Error ? error.message : String(error)}`];
3009
+ }
3010
+ const managedSkills = join15(projectRoot, ".agents", "skills");
3011
+ for (const rel of PROJECT_CLI_SKILL_DIRS) {
3012
+ const cliDir = join15(projectRoot, rel);
3013
+ const parent = dirname7(cliDir);
3014
+ const parentStat = lstatIfPresent(parent);
3015
+ if (!parentStat) continue;
3016
+ if (parentStat.isSymbolicLink() || !parentStat.isDirectory()) {
3017
+ issues.push(`${rel} has an unsafe symlinked/non-directory parent`);
3018
+ continue;
3019
+ }
3020
+ if (!isContainedBy(projectRoot, realOrSelf(parent))) {
3021
+ issues.push(`${rel} parent resolves outside the project`);
3022
+ continue;
3023
+ }
3024
+ const stat = lstatIfPresent(cliDir);
3025
+ if (!stat) continue;
3026
+ if (stat.isSymbolicLink()) {
3027
+ let rawTarget = "";
3028
+ try {
3029
+ rawTarget = readlinkSync(cliDir);
3030
+ } catch {
3031
+ issues.push(`${rel} is an unreadable skills directory symlink`);
3032
+ continue;
3033
+ }
3034
+ if (rel !== ".claude/skills" || rawTarget !== CANONICAL_CLAUDE_SKILLS_ALIAS) {
3035
+ issues.push(`${rel} is an unsupported skills directory symlink`);
3036
+ continue;
3037
+ }
3038
+ const managedStat = lstatIfPresent(managedSkills);
3039
+ if (!managedStat || managedStat.isSymbolicLink() || !managedStat.isDirectory()) {
3040
+ issues.push(`${rel} canonical alias target .agents/skills is missing or unsafe`);
3041
+ continue;
3042
+ }
3043
+ try {
3044
+ if (realpathSync(cliDir) !== realpathSync(managedSkills)) {
3045
+ issues.push(`${rel} canonical alias resolves outside .agents/skills`);
2617
3046
  }
3047
+ } catch {
3048
+ issues.push(`${rel} canonical alias is broken`);
2618
3049
  }
2619
- if (!hasMarker) continue;
3050
+ continue;
2620
3051
  }
2621
- start = i;
2622
- for (let j = i + 1; j < lines.length; j++) {
2623
- if (/^\[[^\]]+\]/.test(lines[j])) {
2624
- end = j;
2625
- break;
2626
- }
3052
+ if (!stat.isDirectory()) {
3053
+ issues.push(`${rel} is not a directory`);
3054
+ continue;
3055
+ }
3056
+ if (!isContainedBy(projectRoot, realOrSelf(cliDir))) {
3057
+ issues.push(`${rel} resolves outside the project`);
2627
3058
  }
2628
- if (end === -1) end = lines.length;
2629
- break;
2630
3059
  }
2631
- if (start === -1) return text2;
3060
+ return issues;
3061
+ }
3062
+ function bmadPackRoot(ctx) {
3063
+ return resolve3(
3064
+ process.env.PJ_BMAD_PACK_ROOT?.trim() || join15(ctx.homeDir, "code", "skillex", "packs", "bmad", BMAD_PACK_VERSION)
3065
+ );
3066
+ }
3067
+ function canonicalBmadSkillEntries(ctx) {
3068
+ const root = bmadPackRoot(ctx);
3069
+ const trusted = validateTrustedBmadPack(root);
3070
+ return trusted.skillNames.map((name) => ({ name: validateSkillName(name), source: pathToFileURL(join15(root, name)).href }));
3071
+ }
3072
+ function skillManifestEntryName(entry) {
3073
+ if (typeof entry === "string") return entry;
3074
+ if (!entry || typeof entry !== "object") return void 0;
3075
+ const name = entry.name;
3076
+ return typeof name === "string" ? name : void 0;
3077
+ }
3078
+ function isPackManagedManifestEntry(entry, expectedNames, packRoot) {
3079
+ const name = skillManifestEntryName(entry);
3080
+ if (!name) return false;
3081
+ if (expectedNames.has(name)) return true;
3082
+ if (!entry || typeof entry !== "object") return false;
3083
+ const source = entry.source;
3084
+ if (typeof source !== "string" || !source.startsWith("file:")) return false;
3085
+ try {
3086
+ const sourcePath = resolve3(fileURLToPath4(source));
3087
+ return basename4(sourcePath) === name && isContainedBy(packRoot, sourcePath);
3088
+ } catch {
3089
+ return false;
3090
+ }
3091
+ }
3092
+ function canonicalSkillsManifest(ctx, current, packSkills = canonicalBmadSkillEntries(ctx)) {
3093
+ const existing = Array.isArray(current?.skills) ? current.skills : [];
3094
+ const expectedNames = new Set(packSkills.map((entry) => entry.name));
3095
+ const packRoot = bmadPackRoot(ctx);
3096
+ return `${JSON.stringify(
3097
+ {
3098
+ ...current ?? {},
3099
+ $schema: "https://raw.githubusercontent.com/skillex/schemas/main/skills.schema.json",
3100
+ inherit_global: true,
3101
+ registry: SKILLS_REGISTRY_URL,
3102
+ skills: [
3103
+ ...existing.filter((entry) => !isPackManagedManifestEntry(entry, expectedNames, packRoot)),
3104
+ ...packSkills
3105
+ ]
3106
+ },
3107
+ null,
3108
+ 2
3109
+ )}
3110
+ `;
3111
+ }
3112
+ function removeProjectEntry(path) {
3113
+ const stat = lstatIfPresent(path);
3114
+ if (!stat) return;
3115
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
3116
+ rmSync(path, { recursive: true, force: true });
3117
+ return;
3118
+ }
3119
+ try {
3120
+ unlinkSync3(path);
3121
+ } catch (error) {
3122
+ if (error.code !== "ENOENT") throw error;
3123
+ }
3124
+ }
3125
+ function normalizeExecutableTemplate(ctx, target, expected, changedFiles) {
3126
+ const stat = lstatIfPresent(target);
3127
+ if (stat && (!stat.isFile() || stat.isSymbolicLink())) {
3128
+ throw new Error(`Refusing non-regular managed executable target: ${target}`);
3129
+ }
3130
+ const contentChanged = !stat || safeReadText(target) !== expected;
3131
+ const modeChanged = !stat || (Number(stat.mode) & 73) === 0;
3132
+ if (!contentChanged && !modeChanged) return;
3133
+ if (!changedFiles.includes(target)) changedFiles.push(target);
3134
+ if (ctx.dryRun) return;
3135
+ if (contentChanged) {
3136
+ writeText(target, expected);
3137
+ }
3138
+ const beforeChmod = lstatIfPresent(target);
3139
+ if (!beforeChmod?.isFile() || beforeChmod.isSymbolicLink()) {
3140
+ throw new Error(`Refusing changed managed executable target: ${target}`);
3141
+ }
3142
+ chmodSync4(target, 493);
3143
+ }
3144
+ function atomicWriteBuffer(path, content, mode, temporary) {
3145
+ writeFileSync7(temporary, content, { flag: "wx" });
3146
+ chmodSync4(temporary, mode);
3147
+ renameSync2(temporary, path);
3148
+ }
3149
+ function provisionBmadSkills(ctx, preservedManifest, hooks = {}) {
3150
+ const packRoot = bmadPackRoot(ctx);
3151
+ let packSkills;
3152
+ try {
3153
+ packSkills = canonicalBmadSkillEntries(ctx);
3154
+ } catch (error) {
3155
+ return {
3156
+ ok: false,
3157
+ changedFiles: [],
3158
+ error: `BMAD Skillex pack ${BMAD_PACK_VERSION} is not trusted at ${packRoot}: ${error instanceof Error ? error.message : String(error)}`
3159
+ };
3160
+ }
3161
+ hooks.afterPreflight?.();
3162
+ const projectRoot = realpathSync(ctx.repoRoot);
3163
+ const agentsPath = join15(projectRoot, ".agents");
3164
+ const skillsPath = join15(agentsPath, "skills");
3165
+ const agentsExisted = Boolean(lstatIfPresent(agentsPath));
3166
+ const skillsExisted = Boolean(lstatIfPresent(skillsPath));
3167
+ let preflightDirs;
3168
+ try {
3169
+ preflightDirs = prepareSafeProjectSkillsDirs({ ...ctx, dryRun: true });
3170
+ } catch (error) {
3171
+ return { ok: false, changedFiles: [], error: error instanceof Error ? error.message : String(error) };
3172
+ }
3173
+ const manifestPath = join15(preflightDirs.agentsDir, "skills.json");
3174
+ const manifestStat = lstatIfPresent(manifestPath);
3175
+ if (manifestStat?.isSymbolicLink() || manifestStat && !manifestStat.isFile()) {
3176
+ return { ok: false, changedFiles: [], error: `Refusing unsafe skills manifest: ${manifestPath}` };
3177
+ }
3178
+ const manifestBytes = manifestStat ? readFileSync8(manifestPath) : null;
3179
+ const manifestMode = manifestStat ? Number(manifestStat.mode) & 511 : 420;
3180
+ let currentManifest = {};
3181
+ if (manifestBytes !== null) {
3182
+ try {
3183
+ const parsed = JSON.parse(manifestBytes.toString("utf8"));
3184
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("must contain a JSON object");
3185
+ currentManifest = parsed;
3186
+ if (currentManifest.skills !== void 0 && !Array.isArray(currentManifest.skills)) throw new Error("skills must be an array");
3187
+ } catch (error) {
3188
+ return { ok: false, changedFiles: [], error: `Invalid existing skills manifest ${manifestPath}: ${error instanceof Error ? error.message : String(error)}` };
3189
+ }
3190
+ }
3191
+ let safeDirs;
3192
+ try {
3193
+ safeDirs = prepareSafeProjectSkillsDirs(ctx);
3194
+ } catch (error) {
3195
+ return { ok: false, changedFiles: [], error: error instanceof Error ? error.message : String(error) };
3196
+ }
3197
+ const nextManifest = canonicalSkillsManifest(ctx, preservedManifest ?? currentManifest, packSkills);
3198
+ const skillsDir = safeDirs.skillsDir;
3199
+ const resolvedSkillsDir = ctx.dryRun && !existsSync10(skillsDir) ? skillsDir : realpathSync(skillsDir);
3200
+ const expected = new Map(packSkills.map((entry) => [entry.name, fileURLToPath4(entry.source)]));
3201
+ const expectedNames = new Set(expected.keys());
3202
+ const ownershipManifest = preservedManifest ?? currentManifest;
3203
+ const managedManifestNames = new Set(
3204
+ (Array.isArray(ownershipManifest.skills) ? ownershipManifest.skills : []).filter((entry) => isPackManagedManifestEntry(entry, expectedNames, packRoot)).map(skillManifestEntryName).filter((name) => Boolean(name))
3205
+ );
3206
+ const affected = /* @__PURE__ */ new Set();
3207
+ const staleManagedNames = /* @__PURE__ */ new Set();
3208
+ const originalCorrectLinks = /* @__PURE__ */ new Map();
3209
+ if (existsSync10(skillsDir)) {
3210
+ for (const name of readdirSync3(skillsDir)) {
3211
+ validateSkillName(name);
3212
+ if (dirname7(join15(resolvedSkillsDir, name)) !== resolvedSkillsDir) {
3213
+ return { ok: false, changedFiles: [], error: `BMAD skill path escapes project skills directory: ${name}` };
3214
+ }
3215
+ const entryPath = join15(skillsDir, name);
3216
+ let linkTargetsPack = false;
3217
+ try {
3218
+ linkTargetsPack = lstatSync2(entryPath).isSymbolicLink() && isContainedBy(packRoot, resolve3(dirname7(entryPath), readlinkSync(entryPath)));
3219
+ } catch {
3220
+ linkTargetsPack = false;
3221
+ }
3222
+ if (!expected.has(name) && !managedManifestNames.has(name) && !linkTargetsPack) continue;
3223
+ const target = expected.get(name);
3224
+ let correct = false;
3225
+ try {
3226
+ correct = Boolean(target) && lstatSync2(entryPath).isSymbolicLink() && resolve3(dirname7(entryPath), readlinkSync(entryPath)) === target;
3227
+ } catch {
3228
+ correct = false;
3229
+ }
3230
+ if (correct) originalCorrectLinks.set(name, readlinkSync(join15(skillsDir, name)));
3231
+ else {
3232
+ affected.add(name);
3233
+ if (!target) staleManagedNames.add(name);
3234
+ }
3235
+ }
3236
+ }
3237
+ for (const [name, target] of expected) {
3238
+ const link = join15(resolvedSkillsDir, validateSkillName(name));
3239
+ if (dirname7(link) !== resolvedSkillsDir) {
3240
+ return { ok: false, changedFiles: [], error: `BMAD skill path escapes project skills directory: ${name}` };
3241
+ }
3242
+ let correct = false;
3243
+ try {
3244
+ correct = lstatSync2(link).isSymbolicLink() && resolve3(dirname7(link), readlinkSync(link)) === target;
3245
+ } catch {
3246
+ correct = false;
3247
+ }
3248
+ if (!correct) affected.add(name);
3249
+ }
3250
+ const manifestChanged = manifestBytes?.toString("utf8") !== nextManifest;
3251
+ const changedFiles = [
3252
+ ...manifestChanged ? [manifestPath] : [],
3253
+ ...affected.size ? [skillsDir] : []
3254
+ ];
3255
+ if (ctx.dryRun || changedFiles.length === 0) {
3256
+ try {
3257
+ const postflight = validateTrustedBmadPack(packRoot);
3258
+ if (JSON.stringify(postflight.skillNames) !== JSON.stringify(packSkills.map((entry) => entry.name))) {
3259
+ throw new Error("BMAD pack inventory changed after preflight");
3260
+ }
3261
+ return { ok: true, changedFiles };
3262
+ } catch (error) {
3263
+ return { ok: false, changedFiles: [], error: error instanceof Error ? error.message : String(error) };
3264
+ }
3265
+ }
3266
+ const transaction = mkdtempSync(join15(safeDirs.agentsDir, ".bmad-transaction-"));
3267
+ const backup = join15(transaction, "entries");
3268
+ mkdirSync6(backup);
3269
+ const moved = [];
3270
+ const rollback = () => {
3271
+ const errors = [];
3272
+ try {
3273
+ for (const name of affected) {
3274
+ removeProjectEntry(join15(skillsDir, validateSkillName(name)));
3275
+ }
3276
+ for (const name of originalCorrectLinks.keys()) {
3277
+ removeProjectEntry(join15(skillsDir, validateSkillName(name)));
3278
+ }
3279
+ } catch (error) {
3280
+ errors.push(`remove applied projection: ${String(error)}`);
3281
+ }
3282
+ for (const name of [...moved].reverse()) {
3283
+ try {
3284
+ renameSync2(join15(backup, name), join15(skillsDir, name));
3285
+ } catch (error) {
3286
+ errors.push(`restore ${name}: ${String(error)}`);
3287
+ }
3288
+ }
3289
+ for (const [name, rawTarget] of originalCorrectLinks) {
3290
+ try {
3291
+ symlinkSync(rawTarget, join15(skillsDir, name), "dir");
3292
+ } catch (error) {
3293
+ errors.push(`restore ${name}: ${String(error)}`);
3294
+ }
3295
+ }
3296
+ try {
3297
+ if (manifestBytes === null) removeProjectEntry(manifestPath);
3298
+ else atomicWriteBuffer(manifestPath, manifestBytes, manifestMode, join15(transaction, "manifest.restore"));
3299
+ } catch (error) {
3300
+ errors.push(`restore manifest: ${String(error)}`);
3301
+ }
3302
+ rmSync(transaction, { recursive: true, force: true });
3303
+ try {
3304
+ if (!skillsExisted && existsSync10(skillsDir) && readdirSync3(skillsDir).length === 0) rmdirSync(skillsDir);
3305
+ if (!agentsExisted && existsSync10(safeDirs.agentsDir) && readdirSync3(safeDirs.agentsDir).length === 0) rmdirSync(safeDirs.agentsDir);
3306
+ } catch (error) {
3307
+ errors.push(`remove created directories: ${String(error)}`);
3308
+ }
3309
+ if (errors.length) throw new Error(`BMAD rollback was incomplete: ${errors.join("; ")}`);
3310
+ };
3311
+ try {
3312
+ for (const name of affected) {
3313
+ const entry = join15(skillsDir, name);
3314
+ if (lstatIfPresent(entry)) {
3315
+ renameSync2(entry, join15(backup, name));
3316
+ moved.push(name);
3317
+ }
3318
+ }
3319
+ let index = 0;
3320
+ for (const [name, target] of expected) {
3321
+ index += 1;
3322
+ const link = join15(skillsDir, name);
3323
+ let correct = false;
3324
+ try {
3325
+ correct = lstatSync2(link).isSymbolicLink() && resolve3(skillsDir, readlinkSync(link)) === target;
3326
+ } catch {
3327
+ correct = false;
3328
+ }
3329
+ if (correct) continue;
3330
+ if (hooks.createLink) hooks.createLink(target, link, index);
3331
+ else symlinkSync(target, link, "dir");
3332
+ }
3333
+ if (manifestChanged) {
3334
+ atomicWriteBuffer(manifestPath, Buffer.from(nextManifest), manifestMode, join15(transaction, "manifest.next"));
3335
+ }
3336
+ const postflight = validateTrustedBmadPack(packRoot);
3337
+ if (JSON.stringify(postflight.skillNames) !== JSON.stringify(packSkills.map((entry) => entry.name))) {
3338
+ throw new Error("BMAD pack inventory changed after preflight");
3339
+ }
3340
+ hooks.afterApply?.(manifestPath, skillsDir);
3341
+ for (const name of staleManagedNames) {
3342
+ if (lstatIfPresent(join15(skillsDir, name))) {
3343
+ throw new Error(`Applied BMAD projection retained stale managed entry: ${name}`);
3344
+ }
3345
+ }
3346
+ for (const [name, target] of expected) {
3347
+ const link = join15(skillsDir, name);
3348
+ let correct = false;
3349
+ try {
3350
+ correct = lstatSync2(link).isSymbolicLink() && resolve3(skillsDir, readlinkSync(link)) === target;
3351
+ } catch {
3352
+ correct = false;
3353
+ }
3354
+ if (!correct) throw new Error(`Applied BMAD projection link differs from plan: ${name}`);
3355
+ }
3356
+ const finalManifestStat = lstatIfPresent(manifestPath);
3357
+ if (!finalManifestStat || finalManifestStat.isSymbolicLink() || !finalManifestStat.isFile() || (Number(finalManifestStat.mode) & 511) !== manifestMode || readFileSync8(manifestPath).toString("utf8") !== nextManifest) {
3358
+ throw new Error("Applied BMAD skills manifest differs from planned bytes or mode");
3359
+ }
3360
+ const finalManifest = JSON.parse(readFileSync8(manifestPath, "utf8"));
3361
+ if (finalManifest.$schema !== "https://raw.githubusercontent.com/skillex/schemas/main/skills.schema.json" || finalManifest.inherit_global !== true || finalManifest.registry !== SKILLS_REGISTRY_URL || !Array.isArray(finalManifest.skills)) {
3362
+ throw new Error("Applied BMAD skills manifest schema differs from plan");
3363
+ }
3364
+ } catch (error) {
3365
+ try {
3366
+ rollback();
3367
+ } catch (rollbackError) {
3368
+ return { ok: false, changedFiles: [], error: `BMAD provisioning failed (${String(error)}); ${String(rollbackError)}` };
3369
+ }
3370
+ return { ok: false, changedFiles: [], error: error instanceof Error ? error.message : String(error) };
3371
+ }
3372
+ rmSync(transaction, { recursive: true });
3373
+ return { ok: true, changedFiles };
3374
+ }
3375
+ function templateVersionFilesConf(ctx, repoRoot) {
3376
+ const packageJson = join15(repoRoot, "package.json");
3377
+ return existsSync10(packageJson) ? "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\njson package.json\ngittag .\n" : "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\ngittag .\n";
3378
+ }
3379
+ function replaceOrAppendManagedBlock(text2, startMarker, block, beforePattern) {
3380
+ if (startMarker.test(text2)) {
3381
+ return text2.replace(/# >>> mise-versioning >>>[\s\S]*?# <<< mise-versioning <<</, block);
3382
+ }
3383
+ if (beforePattern) {
3384
+ const match = text2.match(beforePattern);
3385
+ if (match && typeof match.index === "number") {
3386
+ return `${text2.slice(0, match.index).replace(/\s*$/, "\n\n")}${block}
3387
+
3388
+ ${text2.slice(match.index)}`;
3389
+ }
3390
+ }
3391
+ return `${text2.replace(/\s*$/, "")}
3392
+
3393
+ ${block}
3394
+ `;
3395
+ }
3396
+ var BASE_MISE_PATH_ENTRIES = [".mise/scripts", "agents/hermes/pm"];
3397
+ var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/hermes"];
3398
+ function requiredMisePathEntries(ctx) {
3399
+ const required = [...BASE_MISE_PATH_ENTRIES];
3400
+ for (const candidate of CONDITIONAL_HERMES_PATHS) {
3401
+ if (existsSync10(join15(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
3402
+ }
3403
+ return required;
3404
+ }
3405
+ function upsertMisePath(text2, required = BASE_MISE_PATH_ENTRIES) {
3406
+ const render = (values) => `_.path = [${values.map((value) => JSON.stringify(value)).join(", ")}]`;
3407
+ const envMatch = text2.match(/(^|\n)(\[env\][\s\S]*?)(?=\n\[[^\]]+\]|$)/);
3408
+ if (!envMatch || typeof envMatch.index !== "number") {
3409
+ return `[env]
3410
+ ${render(required)}
3411
+
3412
+ ${text2.replace(/^\s+/, "")}`;
3413
+ }
3414
+ const prefix = text2.slice(0, envMatch.index + envMatch[1].length);
3415
+ const section = envMatch[2];
3416
+ const suffix = text2.slice(envMatch.index + envMatch[1].length + section.length);
3417
+ const pathLine = section.match(/^_\.path\s*=\s*\[([^\]]*)\]\s*$/m);
3418
+ if (!pathLine) {
3419
+ return `${prefix}${section.replace(/\n?$/, "\n")}${render(required)}${suffix}`;
3420
+ }
3421
+ const current = [...pathLine[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
3422
+ const merged = [...current];
3423
+ for (const value of required) {
3424
+ if (!merged.includes(value)) merged.push(value);
3425
+ }
3426
+ const nextLine = render(merged);
3427
+ if (pathLine[0] === nextLine) return text2;
3428
+ return `${prefix}${section.replace(pathLine[0], nextLine)}${suffix}`;
3429
+ }
3430
+ function removeTomlSection(text2, headerPattern, marker, options) {
3431
+ const lines = text2.split("\n");
3432
+ let start = -1;
3433
+ let end = -1;
3434
+ for (let i = 0; i < lines.length; i++) {
3435
+ if (!headerPattern.test(lines[i])) continue;
3436
+ if (marker) {
3437
+ let hasMarker = false;
3438
+ for (let j = i + 1; j < lines.length && !/^\[[^\]]+\]/.test(lines[j]); j++) {
3439
+ if (marker.test(lines[j])) {
3440
+ hasMarker = true;
3441
+ break;
3442
+ }
3443
+ }
3444
+ if (!hasMarker) continue;
3445
+ }
3446
+ start = i;
3447
+ for (let j = i + 1; j < lines.length; j++) {
3448
+ if (/^\[[^\]]+\]/.test(lines[j])) {
3449
+ end = j;
3450
+ break;
3451
+ }
3452
+ }
3453
+ if (end === -1) end = lines.length;
3454
+ break;
3455
+ }
3456
+ if (start === -1) return text2;
2632
3457
  while (end > start + 1 && (lines[end - 1].trim() === "" || lines[end - 1].trim().startsWith("#"))) {
2633
3458
  end--;
2634
3459
  }
@@ -2674,6 +3499,12 @@ function stripTomlStringsAndComments(line) {
2674
3499
  function isManagedHookEntry(value) {
2675
3500
  const trimmed = value.trim();
2676
3501
  if (trimmed === OP_INJECT_SCRIPT) return true;
3502
+ if (trimmed === SYNC_SKILLS_SCRIPT) return true;
3503
+ if (trimmed === PROVISION_BMAD_SKILLS_SCRIPT) return true;
3504
+ if (/sync-skills(?:\.py)?["']?\s+--scope project/.test(trimmed)) return true;
3505
+ if (/provision-bmad-skills\.py/.test(trimmed)) return true;
3506
+ if (/link-project-skills-to-clis\.sh'?\s*$/.test(trimmed)) return true;
3507
+ if (/unlink-project-skills-from-clis\.sh'?\s*$/.test(trimmed)) return true;
2677
3508
  return /link-agentfiles\.sh'?\s*$/.test(trimmed);
2678
3509
  }
2679
3510
  function normalizeHookScript(script) {
@@ -2776,12 +3607,18 @@ function upsertLinkAgentfilesHooks(text2) {
2776
3607
  function upsertLinkAgentfilesBlock(text2, ctx) {
2777
3608
  const withPath = upsertMisePath(text2, requiredMisePathEntries(ctx));
2778
3609
  let cleaned = removeTomlSection(withPath, /^\[tasks\.link-agentfiles\]$/, /link-agentfiles/, { includePrecedingComments: false });
3610
+ cleaned = removeTomlSection(cleaned, /^\[tasks\.skills-sync\]$/, void 0, { includePrecedingComments: false });
3611
+ cleaned = removeTomlSection(cleaned, /^\[tasks\.skills-provision-bmad\]$/, void 0, { includePrecedingComments: false });
3612
+ cleaned = removeTomlSection(cleaned, /^\[tasks\.link-project-skills-to-clis\]$/, void 0, { includePrecedingComments: false });
3613
+ cleaned = removeTomlSection(cleaned, /^\[tasks\.unlink-project-skills-from-clis\]$/, void 0, { includePrecedingComments: false });
3614
+ cleaned = removeTomlSection(cleaned, /^\[tasks\.skills-relink\]$/, void 0, { includePrecedingComments: false });
2779
3615
  cleaned = removeTomlSection(cleaned, /^\[\[watch_files\]\]$/, /AGENTS\.md/, { includePrecedingComments: false });
3616
+ cleaned = removeTomlSection(cleaned, /^\[\[watch_files\]\]$/, /\.agents\/skills\.json/, { includePrecedingComments: false });
2780
3617
  cleaned = upsertLinkAgentfilesHooks(cleaned);
2781
3618
  return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
2782
3619
  }
2783
3620
  function readProjectJson(ctx) {
2784
- return tryParseJson(safeReadText(join12(ctx.repoRoot, ".project.json")));
3621
+ return tryParseJson(safeReadText(join15(ctx.repoRoot, ".project.json")));
2785
3622
  }
2786
3623
  function boolSetting(value, fallback) {
2787
3624
  if (typeof value === "boolean") return value;
@@ -2819,7 +3656,7 @@ function canonicalProjectJson(ctx) {
2819
3656
  role.agentId || `${slug}-${role.role}`,
2820
3657
  {
2821
3658
  role: role.role,
2822
- role_dir: relative(ctx.repoRoot, role.roleDir)
3659
+ role_dir: relative2(ctx.repoRoot, role.roleDir)
2823
3660
  }
2824
3661
  ])
2825
3662
  );
@@ -2856,12 +3693,12 @@ function canonicalProjectJson(ctx) {
2856
3693
  };
2857
3694
  }
2858
3695
  function projectJsonFinding(ctx) {
2859
- const projectPath = join12(ctx.repoRoot, ".project.json");
2860
- const planeJsonPath = join12(ctx.repoRoot, ".plane.json");
3696
+ const projectPath = join15(ctx.repoRoot, ".project.json");
3697
+ const planeJsonPath = join15(ctx.repoRoot, ".plane.json");
2861
3698
  const details = [];
2862
3699
  const data = readProjectJson(ctx);
2863
3700
  const roles = discoverRoles(ctx.repoRoot);
2864
- if (!existsSync9(projectPath)) {
3701
+ if (!existsSync10(projectPath)) {
2865
3702
  return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json missing", details: [], fixable: true };
2866
3703
  }
2867
3704
  if (!data) {
@@ -2879,8 +3716,8 @@ function projectJsonFinding(ctx) {
2879
3716
  continue;
2880
3717
  }
2881
3718
  if (agent.role !== role.role) details.push(`agents.${role.agentId}.role should be ${role.role}`);
2882
- if (agent.role_dir !== relative(ctx.repoRoot, role.roleDir)) {
2883
- details.push(`agents.${role.agentId}.role_dir should be ${relative(ctx.repoRoot, role.roleDir)}`);
3719
+ if (agent.role_dir !== relative2(ctx.repoRoot, role.roleDir)) {
3720
+ details.push(`agents.${role.agentId}.role_dir should be ${relative2(ctx.repoRoot, role.roleDir)}`);
2884
3721
  }
2885
3722
  }
2886
3723
  const ticketProvider = data.ticket_provider ?? {};
@@ -2896,7 +3733,7 @@ function projectJsonFinding(ctx) {
2896
3733
  for (const key of ["enabled", "grace_hours", "auto_review"]) {
2897
3734
  if (!(key in reconcile)) details.push(`automation.reconcile.${key} missing`);
2898
3735
  }
2899
- if (existsSync9(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
3736
+ if (existsSync10(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
2900
3737
  return {
2901
3738
  id: "sot.project-json",
2902
3739
  title: "Canonical .project.json",
@@ -2909,8 +3746,7 @@ function projectJsonFinding(ctx) {
2909
3746
  function renderSoul(role) {
2910
3747
  const telegram = role.botHandle ? `@${role.botHandle}` : "(unwired)";
2911
3748
  const tone = role.role === "pm" ? `Direct and brief. Decision-forward. No throat-clearing, no apologies, no "I'll help you with that" preambles.` : "Direct and brief.";
2912
- 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.`;
2913
- const runtimeOwner = role.runtimeOwner || "delorenj";
3749
+ const roleSpecific = role.role === "pm" ? `You are the project manager. You triage incoming work, create or refine tickets, and delegate implementation. You do not ship product code. A systemd heartbeat checks runtime health; when this repo opts into reconciliation (\`automation.reconcile.enabled\` in repo-root \`.project.json\`), the same heartbeat also runs your continuous board-reconciliation pass out-of-band (\`.scripts/sentinel.prompt.md\`, \`--source cron\`), kept separate from your interactive session memory.` : `You operate as the ${role.role} agent for this repo.`;
2914
3750
  return `# ${role.displayName || role.agentId}
2915
3751
 
2916
3752
  You are **${role.displayName || role.agentId}** \u2014 a Hermes agent provisioned to work inside the
@@ -2929,7 +3765,7 @@ You are **${role.displayName || role.agentId}** \u2014 a Hermes agent provisione
2929
3765
 
2930
3766
  ## Scope
2931
3767
 
2932
- You operate only within the working directory of \`${role.repo}\`. Your HERMES_HOME is the runtime submodule at \`./runtime/\` (repo \`${runtimeOwner}/${role.runtimeRepo}\`), which \`~/.hermes/profiles/${role.profileName || role.agentId}\` symlinks to (so \`--profile\` invocations resolve here too); Hermes loads its \`config.yaml\` directly. Secrets, SOUL, memories, skills, sessions, gateway state, and runtime files all live local to that runtime.
3768
+ You operate only within the working directory of \`${role.repo}\`. Your HERMES_HOME is the ignored local directory at \`./runtime/\`, which \`~/.hermes/profiles/${role.profileName || role.agentId}\` projects into (so \`--profile\` invocations resolve here too). Secrets, SOUL, memories, skills, sessions, gateway state, and runtime files stay local to that runtime and are never project gitlinks.
2933
3769
 
2934
3770
  ## Tone
2935
3771
 
@@ -2941,12 +3777,12 @@ ${roleSpecific}
2941
3777
 
2942
3778
  ## Memory hygiene
2943
3779
 
2944
- Your memory is the submodule at \`./runtime/memories/\`. Use durable memory deliberately and keep \`memories/MEMORY.md\` current.
3780
+ Your memory is stored locally at \`./runtime/memories/\`. Use durable memory deliberately and keep \`memories/MEMORY.md\` current.
2945
3781
  `;
2946
3782
  }
2947
3783
  function renderHermesWrapper(role) {
2948
3784
  return `#!/usr/bin/env bash
2949
- # Launcher for ${role.agentId}. Resolves HERMES_HOME to the runtime submodule.
3785
+ # Launcher for ${role.agentId}. Resolves HERMES_HOME to the local runtime.
2950
3786
 
2951
3787
  set -euo pipefail
2952
3788
 
@@ -2959,35 +3795,49 @@ if [[ -f "$FLEET_ENV" ]]; then
2959
3795
  source "$FLEET_ENV"
2960
3796
  fi
2961
3797
 
2962
- HERMES_BIN="{HERMES_BIN:-{HERMES_FLEET_BIN:-/home/delorenj/code/hermes-agent/.venv/bin/hermes}}"
2963
- HERMES_OAUTH_FILE="{HERMES_OAUTH_FILE:-{HERMES_FLEET_OAUTH_FILE:-$HOME/.hermes/auth.json}}"
3798
+ HERMES_BIN="{HERMES_BIN:-{HERMES_FLEET_BIN:-$HOME/.hermes/hermes-agent/.venv/bin/hermes}}"
2964
3799
  CODEX_HOME="{CODEX_HOME:-{HERMES_FLEET_CODEX_HOME:-$HOME/.codex}}"
2965
3800
 
2966
3801
  FLEET_HOME="{HERMES_FLEET_HOME:-$HOME/.hermes}"
2967
3802
  PROFILE_NAME="{HERMES_PROFILE_NAME:-${role.profileName || role.agentId}}"
2968
- HERMES_HOME="$RUNTIME_HOME"
3803
+
3804
+ # Singleton-runtime contract: HERMES_HOME MUST be the named profile dir, never
3805
+ # the raw runtime path. Hermes treats any HERMES_HOME that is neither under
3806
+ # ~/.hermes nor a child of a "profiles" dir as its own standalone root, which
3807
+ # makes get_active_profile_name() report "default" and _global_auth_file_path()
3808
+ # return None -- silently disabling shared fleet auth and giving every agent a
3809
+ # divergent config.yaml. The profile dir is a REAL dir whose shared entries
3810
+ # (config.yaml, .env, skills) symlink to the fleet root and whose person-owned
3811
+ # entries (memories, sessions, state.db, workspace) symlink into $RUNTIME_HOME.
3812
+ HERMES_HOME="$FLEET_HOME/profiles/$PROFILE_NAME"
2969
3813
 
2970
3814
  if [[ ! -d "$RUNTIME_HOME" ]]; then
2971
- echo "hermes: runtime submodule not initialized at $RUNTIME_HOME" >&2
2972
- echo " fix: git submodule update --init --recursive" >&2
3815
+ echo "hermes: local runtime not provisioned at $RUNTIME_HOME" >&2
3816
+ echo " fix: run the role's .scripts/20-runtime-repo.sh" >&2
2973
3817
  exit 1
2974
3818
  fi
2975
3819
 
2976
- exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" HERMES_OAUTH_FILE="$HERMES_OAUTH_FILE" CODEX_HOME="$CODEX_HOME" "$HERMES_BIN" "$@"
3820
+ if [[ ! -d "$HERMES_HOME" ]]; then
3821
+ echo "hermes: profile not provisioned at $HERMES_HOME" >&2
3822
+ echo " fix: pj migrate hermes.runtime-singleton" >&2
3823
+ exit 1
3824
+ fi
3825
+
3826
+ exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" CODEX_HOME="$CODEX_HOME" "$HERMES_BIN" "$@"
2977
3827
  `.replace(/\u0010/g, "$");
2978
3828
  }
2979
3829
  function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip) {
2980
- if (!existsSync9(sourceDir)) return;
3830
+ if (!existsSync10(sourceDir)) return;
2981
3831
  mkdirSync6(targetDir, { recursive: true });
2982
- for (const entry of readdirSync2(sourceDir, { withFileTypes: true })) {
2983
- const sourcePath = join12(sourceDir, entry.name);
3832
+ for (const entry of readdirSync3(sourceDir, { withFileTypes: true })) {
3833
+ const sourcePath = join15(sourceDir, entry.name);
2984
3834
  if (skip?.(sourcePath)) continue;
2985
- const targetPath = join12(targetDir, entry.name);
3835
+ const targetPath = join15(targetDir, entry.name);
2986
3836
  if (entry.isDirectory()) {
2987
3837
  copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
2988
3838
  continue;
2989
3839
  }
2990
- if (existsSync9(targetPath)) continue;
3840
+ if (existsSync10(targetPath)) continue;
2991
3841
  changedFiles.push(targetPath);
2992
3842
  if (!dryRun) {
2993
3843
  ensureParent(targetPath);
@@ -2995,21 +3845,78 @@ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip)
2995
3845
  }
2996
3846
  }
2997
3847
  }
2998
- function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
2999
- const gitmodulesPath = join12(repoRoot, ".gitmodules");
3000
- const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
3001
- const owner = role.runtimeOwner || "delorenj";
3002
- const block = `[submodule "agents/hermes/${role.role}/runtime"]
3003
- path = agents/hermes/${role.role}/runtime
3004
- url = git@github.com:${owner}/${repoName}.git
3005
- `;
3848
+ function runtimeSubmodulePath(repoRoot, role) {
3849
+ const rolePath = relative2(repoRoot, role.roleDir).replace(/\\/g, "/");
3850
+ if (!/^agents\/hermes\/[^/]+$/.test(rolePath)) return null;
3851
+ return `${rolePath}/runtime`;
3852
+ }
3853
+ function submoduleSectionHasPath(section, targetPath) {
3854
+ return section.split(/\r?\n/).some((line) => /^\s*path\s*=/.test(line) && line.replace(/^\s*path\s*=\s*/, "").trim() === targetPath);
3855
+ }
3856
+ function hasRuntimeSubmoduleMapping(repoRoot, role) {
3857
+ const gitmodulesPath = join15(repoRoot, ".gitmodules");
3006
3858
  const current = safeReadText(gitmodulesPath) ?? "";
3007
- const header = `[submodule "agents/hermes/${role.role}/runtime"]`;
3008
- if (current.includes(header)) return [];
3859
+ const sections = current.match(/^\[submodule "[^"\n]+"\][\s\S]*?(?=^\[submodule "|(?![\s\S]))/gm) ?? [];
3860
+ const targetPath = runtimeSubmodulePath(repoRoot, role);
3861
+ return Boolean(targetPath && sections.some((section) => submoduleSectionHasPath(section, targetPath)));
3862
+ }
3863
+ function removeRuntimeSubmoduleMapping(repoRoot, role, changedFiles, dryRun) {
3864
+ const gitmodulesPath = join15(repoRoot, ".gitmodules");
3865
+ const current = safeReadText(gitmodulesPath) ?? "";
3866
+ if (!hasRuntimeSubmoduleMapping(repoRoot, role)) return [];
3867
+ const targetPath = runtimeSubmodulePath(repoRoot, role);
3868
+ if (!targetPath) return [];
3869
+ const next = current.replace(/^\[submodule "[^"\n]+"\][\s\S]*?(?=^\[submodule "|(?![\s\S]))/gm, (section) => submoduleSectionHasPath(section, targetPath) ? "" : section).replace(/\n{3,}/g, "\n\n").trim();
3009
3870
  changedFiles.push(gitmodulesPath);
3010
- if (!dryRun) writeText(gitmodulesPath, `${current.replace(/\s*$/, "")}${current.trim() ? "\n" : ""}${block}`);
3871
+ if (!dryRun) writeText(gitmodulesPath, next ? `${next}
3872
+ ` : "");
3011
3873
  return [gitmodulesPath];
3012
3874
  }
3875
+ function retireRuntimeSubmodule(repoRoot, role, changedFiles, dryRun) {
3876
+ const runtimePath = runtimeSubmodulePath(repoRoot, role);
3877
+ if (!runtimePath) {
3878
+ return { ok: false, details: [], error: `refusing unsafe runtime path for ${role.roleDir}` };
3879
+ }
3880
+ const probe = spawnSync6("git", ["ls-files", "--stage", "--", runtimePath], {
3881
+ cwd: repoRoot,
3882
+ encoding: "utf8"
3883
+ });
3884
+ if (probe.status !== 0) {
3885
+ return { ok: false, details: [], error: `failed to inspect runtime index at ${runtimePath}: ${probe.stderr.trim() || `exit ${probe.status}`}` };
3886
+ }
3887
+ const details = [];
3888
+ if (probe.stdout.trim()) {
3889
+ details.push(`untrack ${runtimePath}`);
3890
+ if (dryRun) {
3891
+ changedFiles.push(runtimePath);
3892
+ } else {
3893
+ const removal = spawnSync6("git", ["rm", "--cached", "-r", "-f", "--", runtimePath], {
3894
+ cwd: repoRoot,
3895
+ encoding: "utf8"
3896
+ });
3897
+ if (removal.status !== 0) {
3898
+ return { ok: false, details, error: `failed to untrack ${runtimePath}: ${removal.stderr.trim() || `exit ${removal.status}`}` };
3899
+ }
3900
+ const verification = spawnSync6("git", ["ls-files", "--stage", "--", runtimePath], {
3901
+ cwd: repoRoot,
3902
+ encoding: "utf8"
3903
+ });
3904
+ if (verification.status !== 0 || verification.stdout.trim()) {
3905
+ return {
3906
+ ok: false,
3907
+ details,
3908
+ error: verification.status !== 0 ? `failed to verify untracked runtime ${runtimePath}: ${verification.stderr.trim() || `exit ${verification.status}`}` : `runtime remains tracked after index-only removal: ${runtimePath}`
3909
+ };
3910
+ }
3911
+ changedFiles.push(runtimePath);
3912
+ }
3913
+ }
3914
+ if (hasRuntimeSubmoduleMapping(repoRoot, role)) {
3915
+ details.push(`remove stale .gitmodules mapping for ${runtimePath}`);
3916
+ removeRuntimeSubmoduleMapping(repoRoot, role, changedFiles, dryRun);
3917
+ }
3918
+ return { ok: true, details };
3919
+ }
3013
3920
  function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
3014
3921
  const path = registryPath(homeDir);
3015
3922
  const current = safeReadText(path) ?? "# Hermes agent fleet registry.\n# One entry per provisioned agent. Managed by hermes-agent-template/.scripts/80-registry.sh.\nschema_version: 1\nagents: {}\n";
@@ -3028,9 +3935,11 @@ function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
3028
3935
  project_id: ${ctxEscape(role.ticketProviderBoardId)}
3029
3936
  identifier: ${ctxEscape(role.ticketProviderIdentifier)}
3030
3937
  runtime_repo: ${ctxEscape(role.runtimeRepo)}
3938
+ bloodbank:
3939
+ gateway_scope: fleet
3940
+ target_agent_id: ${role.agentId}
3031
3941
  systemd:
3032
3942
  gateway_unit: hermes-${role.agentId}-gateway.service
3033
- consumer_unit: hermes-${role.agentId}-consumer.service
3034
3943
  heartbeat_timer: hermes-${role.agentId}-heartbeat.timer
3035
3944
  `;
3036
3945
  const next = current.includes("agents: {}") ? current.replace("agents: {}", `agents:
@@ -3094,6 +4003,7 @@ function checkUnit(unit) {
3094
4003
  var BMAD_NPM_PACKAGE = "bmad-method";
3095
4004
  var BMAD_TARGET_CHANNEL = "next";
3096
4005
  var BMAD_DIST_TAGS_TTL_MS = 60 * 60 * 1e3;
4006
+ var DEFAULT_BMAD_MODULES = ["bmm", "bmb", "cis"];
3097
4007
  var BMAD_INSTALL_TOOLS = [
3098
4008
  "claude-code",
3099
4009
  "codex",
@@ -3141,7 +4051,53 @@ var BMAD_INSTALL_TOOLS = [
3141
4051
  "windsurf",
3142
4052
  "zencoder"
3143
4053
  ];
3144
- function bmadInstallArgs(repoRoot) {
4054
+ function manifestBmadModules(repoRoot) {
4055
+ const manifestPath = join15(repoRoot, "_bmad", "_config", "manifest.yaml");
4056
+ const raw = safeReadText(manifestPath);
4057
+ if (raw === null) return { status: "absent" };
4058
+ try {
4059
+ const parsed = YAML3.parse(raw);
4060
+ if (!Array.isArray(parsed?.modules)) {
4061
+ return { status: "invalid", error: `${manifestPath} must define a modules array` };
4062
+ }
4063
+ const declared = [];
4064
+ for (const entry of parsed.modules) {
4065
+ const name = typeof entry === "string" ? entry : entry && typeof entry === "object" ? entry.name : void 0;
4066
+ if (typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) {
4067
+ return { status: "invalid", error: `${manifestPath} contains an invalid module entry` };
4068
+ }
4069
+ if (name !== "core" && name !== "custom") declared.push(name);
4070
+ }
4071
+ return { status: "valid", modules: Array.from(new Set(declared)) };
4072
+ } catch (error) {
4073
+ return {
4074
+ status: "invalid",
4075
+ error: `Could not parse ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`
4076
+ };
4077
+ }
4078
+ }
4079
+ function configuredBmadModules(repoRoot) {
4080
+ const raw = safeReadText(join15(repoRoot, "_bmad", "config.toml"));
4081
+ if (raw === null) return void 0;
4082
+ const modules = [...raw.matchAll(/^\[modules\.([A-Za-z0-9][A-Za-z0-9_-]*)\]\s*$/gm)].map((match) => match[1]);
4083
+ return Array.from(new Set(modules));
4084
+ }
4085
+ function selectedBmadModules(repoRoot) {
4086
+ const manifest = manifestBmadModules(repoRoot);
4087
+ if (manifest.status === "valid") return manifest.modules;
4088
+ if (manifest.status === "invalid") throw new Error(manifest.error);
4089
+ return configuredBmadModules(repoRoot) ?? [...DEFAULT_BMAD_MODULES];
4090
+ }
4091
+ function requiredBmadSentinels(repoRoot, modules = selectedBmadModules(repoRoot)) {
4092
+ return [
4093
+ join15("core", "config.yaml"),
4094
+ join15("config.toml"),
4095
+ join15("_config", "manifest.yaml"),
4096
+ ...modules.map((module) => join15(module, "config.yaml"))
4097
+ ];
4098
+ }
4099
+ function bmadInstallArgs(repoRoot, modules = selectedBmadModules(repoRoot)) {
4100
+ const installerModules = modules.length ? modules.join(",") : "core";
3145
4101
  return [
3146
4102
  "-y",
3147
4103
  `${BMAD_NPM_PACKAGE}@${BMAD_TARGET_CHANNEL}`,
@@ -3150,20 +4106,20 @@ function bmadInstallArgs(repoRoot) {
3150
4106
  "--directory",
3151
4107
  repoRoot,
3152
4108
  "--modules",
3153
- "bmm,bmb,cis",
4109
+ installerModules,
3154
4110
  "--tools",
3155
4111
  BMAD_INSTALL_TOOLS.join(",")
3156
4112
  ];
3157
4113
  }
3158
- function runBmadInstall(repoRoot) {
3159
- const result = spawnSync6("npx", bmadInstallArgs(repoRoot), { encoding: "utf8" });
4114
+ function runBmadInstall(repoRoot, modules = selectedBmadModules(repoRoot)) {
4115
+ const result = spawnSync6("npx", bmadInstallArgs(repoRoot, modules), { encoding: "utf8" });
3160
4116
  if (result.status !== 0) {
3161
4117
  return { ok: false, error: result.stderr || result.error?.message || "Unknown error" };
3162
4118
  }
3163
4119
  return { ok: true };
3164
4120
  }
3165
4121
  function readInstalledBmadVersion(repoRoot) {
3166
- const raw = safeReadText(join12(repoRoot, "_bmad", "_config", "manifest.yaml"));
4122
+ const raw = safeReadText(join15(repoRoot, "_bmad", "_config", "manifest.yaml"));
3167
4123
  if (!raw) return void 0;
3168
4124
  try {
3169
4125
  const parsed = YAML3.parse(raw);
@@ -3174,8 +4130,8 @@ function readInstalledBmadVersion(repoRoot) {
3174
4130
  }
3175
4131
  }
3176
4132
  function bmadCachePath(homeDir) {
3177
- const cacheRoot = process.env.XDG_CACHE_HOME?.trim() || join12(homeDir, ".cache");
3178
- return join12(cacheRoot, "pjangler", "bmad-dist-tags.json");
4133
+ const cacheRoot = process.env.XDG_CACHE_HOME?.trim() || join15(homeDir, ".cache");
4134
+ return join15(cacheRoot, "pjangler", "bmad-dist-tags.json");
3179
4135
  }
3180
4136
  function readBmadDistTagsCache(homeDir) {
3181
4137
  const raw = safeReadText(bmadCachePath(homeDir));
@@ -3218,7 +4174,7 @@ function resolveBmadDistTags(homeDir) {
3218
4174
  try {
3219
4175
  const path = bmadCachePath(homeDir);
3220
4176
  mkdirSync6(dirname7(path), { recursive: true });
3221
- writeFileSync6(path, JSON.stringify({ fetchedAt: Date.now(), distTags: fetched }, null, 2));
4177
+ writeFileSync7(path, JSON.stringify({ fetchedAt: Date.now(), distTags: fetched }, null, 2));
3222
4178
  } catch {
3223
4179
  }
3224
4180
  return { distTags: fetched, stale: false };
@@ -3258,19 +4214,233 @@ function compareBmadVersions(a, b) {
3258
4214
  }
3259
4215
  return 0;
3260
4216
  }
4217
+ var SHARED_PROFILE_ENTRIES = ["config.yaml", ".env", "skills"];
4218
+ var OWNED_PROFILE_ENTRIES = [
4219
+ "memories",
4220
+ "sessions",
4221
+ "workspace",
4222
+ "logs",
4223
+ "cron",
4224
+ "plans",
4225
+ "hooks",
4226
+ "pairing",
4227
+ "audio_cache",
4228
+ "image_cache"
4229
+ ];
4230
+ var OWNED_PROFILE_FILES = ["SOUL.md", "state.db", "kanban.db"];
4231
+ function fleetHome(ctx) {
4232
+ return process.env.HERMES_FLEET_HOME || join15(ctx.homeDir, ".hermes");
4233
+ }
4234
+ function fleetBinPath(ctx) {
4235
+ const candidates = [
4236
+ process.env.HERMES_FLEET_BIN,
4237
+ join15(fleetHome(ctx), "hermes-agent", ".venv", "bin", "hermes"),
4238
+ join15(fleetHome(ctx), "hermes-agent", "venv", "bin", "hermes"),
4239
+ join15(ctx.homeDir, ".local", "bin", "hermes")
4240
+ ].filter(Boolean);
4241
+ return candidates.find((candidate) => existsSync10(candidate)) ?? "";
4242
+ }
4243
+ function singletonPlan(ctx, role) {
4244
+ const fleetRoot = fleetHome(ctx);
4245
+ const profileName = role.profileName || role.agentId;
4246
+ const profileDir = join15(fleetRoot, "profiles", profileName);
4247
+ const runtimeDir = join15(role.roleDir, "runtime");
4248
+ const links = [];
4249
+ for (const entry of SHARED_PROFILE_ENTRIES) {
4250
+ links.push({ path: join15(profileDir, entry), target: join15(fleetRoot, entry), ensureTargetDir: entry === "skills" });
4251
+ }
4252
+ for (const entry of OWNED_PROFILE_ENTRIES) {
4253
+ links.push({ path: join15(profileDir, entry), target: join15(runtimeDir, entry), ensureTargetDir: true });
4254
+ }
4255
+ for (const entry of OWNED_PROFILE_FILES) {
4256
+ links.push({ path: join15(profileDir, entry), target: join15(runtimeDir, entry), ensureTargetDir: false });
4257
+ }
4258
+ const sharedSeeds = ["config.yaml", "auth.json", ".env"].map((entry) => ({
4259
+ rootPath: join15(fleetRoot, entry),
4260
+ runtimePath: join15(runtimeDir, entry)
4261
+ }));
4262
+ return { fleetRoot, profileDir, runtimeDir, links, sharedSeeds };
4263
+ }
4264
+ function isDanglingLink(path) {
4265
+ try {
4266
+ return lstatSync2(path).isSymbolicLink() && !existsSync10(path);
4267
+ } catch {
4268
+ return false;
4269
+ }
4270
+ }
4271
+ function linkState(path, target) {
4272
+ let stat;
4273
+ try {
4274
+ stat = lstatSync2(path);
4275
+ } catch {
4276
+ return "missing";
4277
+ }
4278
+ if (!stat.isSymbolicLink()) return "not-a-symlink";
4279
+ try {
4280
+ return readlinkSync(path) === target ? "ok" : "wrong-target";
4281
+ } catch {
4282
+ return "wrong-target";
4283
+ }
4284
+ }
4285
+ function realOrSelf(path) {
4286
+ try {
4287
+ return realpathSync(path);
4288
+ } catch {
4289
+ return path;
4290
+ }
4291
+ }
4292
+ function profileUnits(role) {
4293
+ return [
4294
+ `hermes-${role.agentId}-gateway.service`,
4295
+ `hermes-${role.agentId}-heartbeat.service`,
4296
+ `hermes-${role.agentId}-heartbeat.timer`,
4297
+ `hermes-${role.agentId}-checkpoint.service`
4298
+ ];
4299
+ }
4300
+ function readRegistry(registryPath2) {
4301
+ const raw = safeReadText(registryPath2);
4302
+ if (raw === null) return null;
4303
+ try {
4304
+ const doc = YAML3.parse(raw);
4305
+ return doc?.agents ?? {};
4306
+ } catch {
4307
+ return null;
4308
+ }
4309
+ }
4310
+ function declaredAgentIds(repoRoot) {
4311
+ return declaredAgentEntries(repoRoot).map(([agentId]) => agentId);
4312
+ }
4313
+ function declaredAgentEntries(repoRoot) {
4314
+ const raw = safeReadText(join15(repoRoot, ".project.json"));
4315
+ if (raw === null) return [];
4316
+ try {
4317
+ const doc = JSON.parse(raw);
4318
+ return Object.entries(doc.agents ?? {}).map(([agentId, entry]) => [agentId, entry ?? {}]);
4319
+ } catch {
4320
+ return [];
4321
+ }
4322
+ }
4323
+ function ownedRegistryEntries(registry, repoRoot) {
4324
+ const want = realOrSelf(repoRoot);
4325
+ const owned = [];
4326
+ for (const [agentId, raw] of Object.entries(registry)) {
4327
+ const entry = raw ?? {};
4328
+ const roleDir = String(entry.role_dir ?? "");
4329
+ if (!roleDir) continue;
4330
+ if (realOrSelf(dirname7(dirname7(dirname7(roleDir)))) !== want) continue;
4331
+ owned.push([agentId, entry]);
4332
+ }
4333
+ return owned;
4334
+ }
4335
+ function unprovisionedRoleAgents(registry, repoRoot, canonical) {
4336
+ const blockers = /* @__PURE__ */ new Map();
4337
+ const record = (agentId, roleDir, source) => {
4338
+ const current = blockers.get(agentId) ?? { roleDir, sources: /* @__PURE__ */ new Set() };
4339
+ if (!current.roleDir && roleDir) current.roleDir = roleDir;
4340
+ current.sources.add(source);
4341
+ blockers.set(agentId, current);
4342
+ };
4343
+ for (const [agentId, entry] of ownedRegistryEntries(registry, repoRoot)) {
4344
+ if (canonical.has(agentId)) continue;
4345
+ const roleDir = String(entry.role_dir ?? "");
4346
+ if (!roleDir || !existsSync10(join15(roleDir, "role.yaml"))) record(agentId, roleDir, "registry");
4347
+ }
4348
+ for (const [agentId, entry] of declaredAgentEntries(repoRoot)) {
4349
+ if (canonical.has(agentId)) continue;
4350
+ const configured = String(entry.role_dir ?? "");
4351
+ const roleDir = configured ? resolve3(repoRoot, configured) : "";
4352
+ if (!roleDir || !existsSync10(join15(roleDir, "role.yaml"))) record(agentId, roleDir, ".project.json");
4353
+ }
4354
+ return [...blockers.entries()].map(([agentId, value]) => ({
4355
+ agentId,
4356
+ roleDir: value.roleDir,
4357
+ sources: [...value.sources]
4358
+ }));
4359
+ }
4360
+ function dropDeclaredAgent(ctx, agentId, changedFiles, details) {
4361
+ const path = join15(ctx.repoRoot, ".project.json");
4362
+ const raw = safeReadText(path);
4363
+ if (raw === null) return;
4364
+ let doc;
4365
+ try {
4366
+ doc = JSON.parse(raw);
4367
+ } catch {
4368
+ return;
4369
+ }
4370
+ if (!doc.agents || !(agentId in doc.agents)) return;
4371
+ delete doc.agents[agentId];
4372
+ details.push(`drop agent "${agentId}" from .project.json`);
4373
+ changedFiles.push(path);
4374
+ if (!ctx.dryRun) writeText(path, `${JSON.stringify(doc, null, 2)}
4375
+ `);
4376
+ }
4377
+ function rewriteLauncher(text2) {
4378
+ let next = text2.replace(/^HERMES_HOME="\$RUNTIME_HOME"\s*$/m, 'HERMES_HOME="$FLEET_HOME/profiles/$PROFILE_NAME"');
4379
+ next = next.replace(/^HERMES_OAUTH_FILE=.*\n/m, "");
4380
+ next = next.replace(/\s*HERMES_OAUTH_FILE="\$HERMES_OAUTH_FILE"/g, "");
4381
+ next = next.replace(
4382
+ /^.*\/home\/delorenj\/code\/hermes-agent\/\.venv\/bin\/hermes.*$/m,
4383
+ (line) => line.replace("/home/delorenj/code/hermes-agent/.venv/bin/hermes", "$HOME/.hermes/hermes-agent/.venv/bin/hermes")
4384
+ );
4385
+ return next;
4386
+ }
4387
+ function isValidOpReference(value) {
4388
+ if (!value.startsWith("op://") || /[\[\]{}<>]/.test(value)) return false;
4389
+ const withoutScheme = value.slice("op://".length);
4390
+ const fragmentIndex = withoutScheme.indexOf("#");
4391
+ if (fragmentIndex >= 0) return false;
4392
+ const queryIndex = withoutScheme.indexOf("?");
4393
+ const pathPart = queryIndex >= 0 ? withoutScheme.slice(0, queryIndex) : withoutScheme;
4394
+ const queryPart = queryIndex >= 0 ? withoutScheme.slice(queryIndex + 1) : "";
4395
+ const parts = pathPart.split("/");
4396
+ if (parts.length < 3 || parts.length > 4 || parts.some((part) => !part)) return false;
4397
+ try {
4398
+ for (const part of parts) decodeURIComponent(part);
4399
+ } catch {
4400
+ return false;
4401
+ }
4402
+ if (queryIndex >= 0 && !/^attribute=[A-Za-z0-9._~-]+$/.test(queryPart)) return false;
4403
+ return true;
4404
+ }
4405
+ function malformedOpReferences(text2) {
4406
+ const occurrences = [];
4407
+ const lines = text2.split("\n");
4408
+ for (let index = 0; index < lines.length; index++) {
4409
+ const line = lines[index];
4410
+ for (const match of line.matchAll(/op:\/\/[^\s"'`]+/g)) {
4411
+ const value = match[0];
4412
+ if (!isValidOpReference(value)) {
4413
+ occurrences.push({ line: index + 1, value, commentOnly: line.trimStart().startsWith("#") });
4414
+ }
4415
+ }
4416
+ }
4417
+ return occurrences;
4418
+ }
4419
+ function removeMalformedCommentOpReferences(text2) {
4420
+ let changed = false;
4421
+ const lines = text2.split("\n").map((line) => {
4422
+ if (!line.trimStart().startsWith("#")) return line;
4423
+ return line.replace(/op:\/\/[^\s"'`]+/g, (value) => {
4424
+ if (isValidOpReference(value)) return value;
4425
+ changed = true;
4426
+ return "<invalid 1Password reference removed by pjangler>";
4427
+ });
4428
+ });
4429
+ return { text: lines.join("\n"), changed };
4430
+ }
3261
4431
  var RULES = [
3262
4432
  {
3263
4433
  id: "mise.config-root",
3264
4434
  title: "mise config_root + AGENTS link hooks",
3265
4435
  audit: (ctx) => {
3266
- const misePath = join12(ctx.repoRoot, "mise.toml");
3267
- if (!existsSync9(misePath)) {
4436
+ const misePath = join15(ctx.repoRoot, "mise.toml");
4437
+ if (!existsSync10(misePath)) {
3268
4438
  return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
3269
4439
  }
3270
4440
  const text2 = readText(misePath);
3271
4441
  const details = [];
3272
- const linkAgentfilesPath = join12(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
3273
- if (!existsSync9(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
4442
+ const linkAgentfilesPath = join15(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
4443
+ if (!existsSync10(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
3274
4444
  const pathValues = [...(text2.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
3275
4445
  const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
3276
4446
  if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
@@ -3288,10 +4458,10 @@ var RULES = [
3288
4458
  };
3289
4459
  },
3290
4460
  migrate: (ctx, finding) => {
3291
- const path = join12(ctx.repoRoot, "mise.toml");
4461
+ const path = join15(ctx.repoRoot, "mise.toml");
3292
4462
  const changedFiles = [];
3293
4463
  const details = [];
3294
- if (!existsSync9(path)) {
4464
+ if (!existsSync10(path)) {
3295
4465
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
3296
4466
  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: [] };
3297
4467
  }
@@ -3307,7 +4477,7 @@ var RULES = [
3307
4477
  if (!ctx.dryRun) writeText(path, next);
3308
4478
  text2 = next;
3309
4479
  }
3310
- const linkAgentfilesPath = join12(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
4480
+ const linkAgentfilesPath = join15(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
3311
4481
  const expectedScript = templateLinkAgentfilesScript(ctx);
3312
4482
  if (expectedScript === void 0) {
3313
4483
  return { id: finding.id, title: finding.title, status: "blocked", summary: "pjangler install is missing .mise/scripts/link-agentfiles.sh \u2014 update @delorenj/pjangler (broken package)", changedFiles, details: [] };
@@ -3316,7 +4486,7 @@ var RULES = [
3316
4486
  changedFiles.push(linkAgentfilesPath);
3317
4487
  if (!ctx.dryRun) {
3318
4488
  writeText(linkAgentfilesPath, expectedScript);
3319
- chmodSync2(linkAgentfilesPath, 493);
4489
+ chmodSync4(linkAgentfilesPath, 493);
3320
4490
  }
3321
4491
  }
3322
4492
  return {
@@ -3334,13 +4504,13 @@ var RULES = [
3334
4504
  title: "managed mise versioning block",
3335
4505
  audit: (ctx) => {
3336
4506
  const details = [];
3337
- const misePath = join12(ctx.repoRoot, "mise.toml");
3338
- const versioningPath = join12(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
3339
- const manifestPath = join12(ctx.repoRoot, ".mise", "version-files.conf");
4507
+ const misePath = join15(ctx.repoRoot, "mise.toml");
4508
+ const versioningPath = join15(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
4509
+ const manifestPath = join15(ctx.repoRoot, ".mise", "version-files.conf");
3340
4510
  const text2 = safeReadText(misePath);
3341
4511
  if (!text2?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
3342
- if (!existsSync9(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
3343
- if (!existsSync9(manifestPath)) details.push(".mise/version-files.conf missing");
4512
+ if (!existsSync10(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
4513
+ if (!existsSync10(manifestPath)) details.push(".mise/version-files.conf missing");
3344
4514
  return {
3345
4515
  id: "mise.versioning",
3346
4516
  title: "managed mise versioning block",
@@ -3353,8 +4523,8 @@ var RULES = [
3353
4523
  migrate: (ctx, finding) => {
3354
4524
  const changedFiles = [];
3355
4525
  const details = [];
3356
- const misePath = join12(ctx.repoRoot, "mise.toml");
3357
- if (!existsSync9(misePath)) {
4526
+ const misePath = join15(ctx.repoRoot, "mise.toml");
4527
+ if (!existsSync10(misePath)) {
3358
4528
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
3359
4529
  return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing and no generated-project mise template available to initialize from", changedFiles, details: [] };
3360
4530
  }
@@ -3363,46 +4533,266 @@ var RULES = [
3363
4533
  return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
3364
4534
  }
3365
4535
  }
3366
- const currentMise = readText(misePath);
3367
- let cleanedMise = currentMise;
3368
- if (!currentMise.includes("# >>> mise-versioning >>>")) {
3369
- const taskNames = ["version", "version:bump", "version:bump-patch", "version:bump-minor", "version:bump-major", "version:check", "version:sync"];
3370
- for (const taskName of taskNames) {
3371
- const escaped = taskName.replace(/:/g, "\\:");
3372
- const headerPattern = new RegExp(`^\\[tasks\\.(?:"${escaped}"|'${escaped}'|${escaped})\\]$`);
3373
- cleanedMise = removeTomlSection(cleanedMise, headerPattern);
4536
+ const currentMise = readText(misePath);
4537
+ let cleanedMise = currentMise;
4538
+ if (!currentMise.includes("# >>> mise-versioning >>>")) {
4539
+ const taskNames = ["version", "version:bump", "version:bump-patch", "version:bump-minor", "version:bump-major", "version:check", "version:sync"];
4540
+ for (const taskName of taskNames) {
4541
+ const escaped = taskName.replace(/:/g, "\\:");
4542
+ const headerPattern = new RegExp(`^\\[tasks\\.(?:"${escaped}"|'${escaped}'|${escaped})\\]$`);
4543
+ cleanedMise = removeTomlSection(cleanedMise, headerPattern);
4544
+ }
4545
+ }
4546
+ const nextMise = replaceOrAppendManagedBlock(cleanedMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
4547
+ if (nextMise !== currentMise) {
4548
+ if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
4549
+ if (!ctx.dryRun) writeText(misePath, nextMise);
4550
+ }
4551
+ const versioningPath = join15(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
4552
+ const expectedScript = templateVersioningScript(ctx);
4553
+ if (expectedScript === void 0) {
4554
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "pjangler install is missing .mise/scripts/versioning.sh \u2014 update @delorenj/pjangler (broken package)", changedFiles, details: [] };
4555
+ }
4556
+ if (safeReadText(versioningPath) !== expectedScript) {
4557
+ changedFiles.push(versioningPath);
4558
+ if (!ctx.dryRun) {
4559
+ writeText(versioningPath, expectedScript);
4560
+ chmodSync4(versioningPath, 493);
4561
+ }
4562
+ }
4563
+ const manifestPath = join15(ctx.repoRoot, ".mise", "version-files.conf");
4564
+ const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
4565
+ if (safeReadText(manifestPath) !== expectedManifest) {
4566
+ changedFiles.push(manifestPath);
4567
+ if (!ctx.dryRun) writeText(manifestPath, expectedManifest);
4568
+ }
4569
+ return {
4570
+ id: finding.id,
4571
+ title: finding.title,
4572
+ status: changedFiles.length ? "applied" : "noop",
4573
+ summary: changedFiles.length ? "Versioning block/script/manifest normalized" : "No changes required",
4574
+ changedFiles,
4575
+ details: []
4576
+ };
4577
+ }
4578
+ },
4579
+ {
4580
+ id: "skills.project-manifest",
4581
+ title: "Skillex project skills manifest",
4582
+ audit: (ctx) => {
4583
+ const details = [];
4584
+ const manifestPath = join15(ctx.repoRoot, ".agents", "skills.json");
4585
+ const legacyDir = join15(ctx.repoRoot, ".agents", "skills");
4586
+ const localExamplePath = join15(ctx.repoRoot, ".agents", "local.example.json");
4587
+ const misePath = join15(ctx.repoRoot, "mise.toml");
4588
+ let fixable = true;
4589
+ let expectedBmad = [];
4590
+ try {
4591
+ expectedBmad = canonicalBmadSkillEntries(ctx);
4592
+ } catch (error) {
4593
+ details.push(
4594
+ `BMAD Skillex pack ${BMAD_PACK_VERSION} is not trusted at ${bmadPackRoot(ctx)}: ${error instanceof Error ? error.message : String(error)}`
4595
+ );
4596
+ fixable = false;
4597
+ }
4598
+ const expectedByName = new Map(expectedBmad.map((entry) => [entry.name, fileURLToPath4(entry.source)]));
4599
+ const expectedNames = new Set(expectedByName.keys());
4600
+ const packRoot = bmadPackRoot(ctx);
4601
+ const manifest = tryParseJson(safeReadText(manifestPath));
4602
+ if (!manifest) {
4603
+ details.push(".agents/skills.json missing or invalid JSON");
4604
+ } else {
4605
+ if (manifest.inherit_global !== true) details.push(".agents/skills.json should set inherit_global: true");
4606
+ if (manifest.registry !== SKILLS_REGISTRY_URL) details.push(`.agents/skills.json should set registry to ${SKILLS_REGISTRY_URL}`);
4607
+ if (!Array.isArray(manifest.skills)) {
4608
+ details.push(".agents/skills.json should define a skills array");
4609
+ } else {
4610
+ const actualBmad = new Map(
4611
+ manifest.skills.filter((entry) => Boolean(entry) && typeof entry === "object" && isPackManagedManifestEntry(entry, expectedNames, packRoot)).map((entry) => [String(entry.name), String(entry.source ?? "")])
4612
+ );
4613
+ const stale = expectedBmad.filter((entry) => actualBmad.get(entry.name) !== entry.source);
4614
+ if (stale.length > 0 || actualBmad.size !== expectedBmad.length) {
4615
+ details.push(`.agents/skills.json should record all ${expectedBmad.length} BMAD ${BMAD_PACK_VERSION} pack entries as file:// sources`);
4616
+ }
4617
+ }
4618
+ }
4619
+ const invalidBmadLinkNames = /* @__PURE__ */ new Set();
4620
+ if (existsSync10(legacyDir)) {
4621
+ for (const name of readdirSync3(legacyDir)) {
4622
+ const expected = expectedByName.get(name);
4623
+ const path = join15(legacyDir, name);
4624
+ let linkTargetsPack = false;
4625
+ try {
4626
+ linkTargetsPack = lstatSync2(path).isSymbolicLink() && isContainedBy(packRoot, resolve3(dirname7(path), readlinkSync(path)));
4627
+ } catch {
4628
+ linkTargetsPack = false;
4629
+ }
4630
+ if (!expected && !linkTargetsPack) continue;
4631
+ try {
4632
+ if (!expected || !lstatSync2(path).isSymbolicLink() || resolve3(dirname7(path), readlinkSync(path)) !== expected) invalidBmadLinkNames.add(name);
4633
+ } catch {
4634
+ invalidBmadLinkNames.add(name);
4635
+ }
4636
+ }
4637
+ for (const [name, expected] of expectedByName) {
4638
+ const path = join15(legacyDir, name);
4639
+ try {
4640
+ if (!lstatSync2(path).isSymbolicLink() || resolve3(dirname7(path), readlinkSync(path)) !== expected) invalidBmadLinkNames.add(name);
4641
+ } catch {
4642
+ invalidBmadLinkNames.add(name);
4643
+ }
4644
+ }
4645
+ } else {
4646
+ for (const name of expectedByName.keys()) invalidBmadLinkNames.add(name);
4647
+ }
4648
+ if (invalidBmadLinkNames.size > 0) {
4649
+ details.push(`${invalidBmadLinkNames.size} managed BMAD skill path(s) should be symlinks into the ${BMAD_PACK_VERSION} pack`);
4650
+ }
4651
+ for (const rel of [".mise/scripts/link-project-skills-to-clis.sh", ".mise/scripts/unlink-project-skills-from-clis.sh"]) {
4652
+ if (existsSync10(join15(ctx.repoRoot, rel))) details.push(`${rel} is a legacy symlink-era script and should be removed`);
4653
+ }
4654
+ const localExample = tryParseJson(safeReadText(localExamplePath));
4655
+ if (localExample && Object.prototype.hasOwnProperty.call(localExample, "skills")) {
4656
+ details.push(".agents/local.example.json still documents legacy skills overrides; drop the skills section");
4657
+ }
4658
+ const mise = safeReadText(misePath);
4659
+ if (!mise?.includes(SYNC_SKILLS_SCRIPT)) details.push("mise.toml should run the shipped project-local sync-skills.py engine via config_root");
4660
+ if (!mise?.includes(PROVISION_BMAD_SKILLS_SCRIPT)) details.push("mise.toml should provision pinned BMAD pack links before syncing skills");
4661
+ if (mise?.includes(SYNC_SKILLS_SCRIPT) && mise.includes(PROVISION_BMAD_SKILLS_SCRIPT) && mise.indexOf(PROVISION_BMAD_SKILLS_SCRIPT) > mise.indexOf(SYNC_SKILLS_SCRIPT)) {
4662
+ details.push("mise.toml should run the BMAD provisioner before project skill sync");
4663
+ }
4664
+ if (mise?.includes('script = "sync-skills.py --scope project"') || mise?.includes('run = "sync-skills.py --scope project"')) {
4665
+ details.push("mise.toml still invokes the missing bare sync-skills.py executable");
4666
+ }
4667
+ if (!mise?.includes('patterns = [".agents/skills.json"]')) details.push("mise.toml should watch .agents/skills.json");
4668
+ if (!mise?.includes("[tasks.skills-sync]")) details.push("mise.toml should define a skills-sync task");
4669
+ if (!mise?.includes('depends = ["skills-provision-bmad"]')) details.push("skills-sync task should depend on skills-provision-bmad");
4670
+ for (const [rel, label] of [
4671
+ [".mise/scripts/provision-bmad-skills.py", "BMAD Skillex provisioning script"],
4672
+ [".mise/scripts/sync-skills.py", "Project-local skills sync engine"]
4673
+ ]) {
4674
+ const target = join15(ctx.repoRoot, rel);
4675
+ const expected = templateCommonProjectText(ctx, rel);
4676
+ const stat = lstatIfPresent(target);
4677
+ if (!stat || !stat.isFile() || stat.isSymbolicLink()) {
4678
+ details.push(`${label} is missing or unsafe`);
4679
+ if (stat) fixable = false;
4680
+ } else {
4681
+ if (expected === void 0 || safeReadText(target) !== expected) details.push(`${label} differs from the shipped template`);
4682
+ if ((Number(stat.mode) & 73) === 0) details.push(`${label} is not executable`);
4683
+ }
4684
+ }
4685
+ const topologyIssues = projectSkillTopologyIssues(ctx.repoRoot);
4686
+ if (topologyIssues.length) {
4687
+ details.push(...topologyIssues.map((issue) => `CLI skill topology: ${issue}`));
4688
+ fixable = false;
4689
+ }
4690
+ if (mise?.includes("link-project-skills-to-clis.sh") || mise?.includes("unlink-project-skills-from-clis.sh") || mise?.includes("[tasks.skills-relink]")) {
4691
+ details.push("mise.toml still contains legacy skill-link wiring");
4692
+ }
4693
+ return {
4694
+ id: "skills.project-manifest",
4695
+ title: "Skillex project skills manifest",
4696
+ status: details.length === 0 ? "pass" : "fail",
4697
+ summary: details.length === 0 ? "Skillex skills manifest parity verified" : `${details.length} Skillex migration issue(s) detected`,
4698
+ details,
4699
+ fixable
4700
+ };
4701
+ },
4702
+ migrate: (ctx, finding) => {
4703
+ const changedFiles = [];
4704
+ const details = [];
4705
+ const manifestPath = join15(ctx.repoRoot, ".agents", "skills.json");
4706
+ const localExamplePath = join15(ctx.repoRoot, ".agents", "local.example.json");
4707
+ const misePath = join15(ctx.repoRoot, "mise.toml");
4708
+ const provisionScriptPath = join15(ctx.repoRoot, ".mise", "scripts", "provision-bmad-skills.py");
4709
+ const syncScriptPath = join15(ctx.repoRoot, ".mise", "scripts", "sync-skills.py");
4710
+ const expectedProvisionScript = templateCommonProjectText(ctx, ".mise/scripts/provision-bmad-skills.py");
4711
+ const expectedSyncScript = templateCommonProjectText(ctx, ".mise/scripts/sync-skills.py");
4712
+ const topologyIssues = projectSkillTopologyIssues(ctx.repoRoot);
4713
+ if (topologyIssues.length) {
4714
+ return {
4715
+ id: finding.id,
4716
+ title: finding.title,
4717
+ status: "blocked",
4718
+ summary: "Unsafe project CLI skill topology must be repaired manually",
4719
+ changedFiles,
4720
+ details: topologyIssues
4721
+ };
4722
+ }
4723
+ if (!expectedProvisionScript || !expectedSyncScript) {
4724
+ return {
4725
+ id: finding.id,
4726
+ title: finding.title,
4727
+ status: "blocked",
4728
+ summary: "pjangler install is missing a shipped skills executable",
4729
+ changedFiles,
4730
+ details: [
4731
+ ...!expectedProvisionScript ? ["Missing BMAD Skillex provisioning script template"] : [],
4732
+ ...!expectedSyncScript ? ["Missing project-local skills sync engine template"] : []
4733
+ ]
4734
+ };
4735
+ }
4736
+ const unsafeScriptTargets = [provisionScriptPath, syncScriptPath].filter((path) => {
4737
+ const stat = lstatIfPresent(path);
4738
+ return Boolean(stat && (!stat.isFile() || stat.isSymbolicLink()));
4739
+ });
4740
+ if (unsafeScriptTargets.length) {
4741
+ return {
4742
+ id: finding.id,
4743
+ title: finding.title,
4744
+ status: "blocked",
4745
+ summary: "Refusing non-regular managed skills executable target",
4746
+ changedFiles,
4747
+ details: unsafeScriptTargets.map((path) => `${path} must be removed or repaired manually`)
4748
+ };
4749
+ }
4750
+ const provisioned = provisionBmadSkills(ctx);
4751
+ if (!provisioned.ok) {
4752
+ return {
4753
+ id: finding.id,
4754
+ title: finding.title,
4755
+ status: "blocked",
4756
+ summary: `BMAD Skillex pack ${BMAD_PACK_VERSION} is unavailable`,
4757
+ changedFiles,
4758
+ details: [provisioned.error ?? "Unknown BMAD pack error"]
4759
+ };
4760
+ }
4761
+ changedFiles.push(...provisioned.changedFiles);
4762
+ if (provisioned.changedFiles.includes(manifestPath)) details.push(`Recorded BMAD pack ${BMAD_PACK_VERSION} in .agents/skills.json`);
4763
+ for (const rel of [".mise/scripts/link-project-skills-to-clis.sh", ".mise/scripts/unlink-project-skills-from-clis.sh"]) {
4764
+ const path = join15(ctx.repoRoot, rel);
4765
+ if (existsSync10(path)) {
4766
+ changedFiles.push(path);
4767
+ if (!ctx.dryRun) unlinkSync3(path);
4768
+ }
4769
+ }
4770
+ const templateLocalExample = templateCommonProjectText(ctx, ".agents/local.example.json");
4771
+ const currentLocalExample = safeReadText(localExamplePath);
4772
+ if (templateLocalExample && currentLocalExample && currentLocalExample !== templateLocalExample) {
4773
+ changedFiles.push(localExamplePath);
4774
+ if (!ctx.dryRun) writeText(localExamplePath, templateLocalExample);
4775
+ }
4776
+ normalizeExecutableTemplate(ctx, provisionScriptPath, expectedProvisionScript, changedFiles);
4777
+ normalizeExecutableTemplate(ctx, syncScriptPath, expectedSyncScript, changedFiles);
4778
+ if (!existsSync10(misePath)) {
4779
+ if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
4780
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing and no generated-project mise template available to initialize from", changedFiles, details };
3374
4781
  }
3375
4782
  }
3376
- const nextMise = replaceOrAppendManagedBlock(cleanedMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
4783
+ const currentMise = readText(misePath);
4784
+ const nextMise = upsertLinkAgentfilesBlock(currentMise, ctx);
3377
4785
  if (nextMise !== currentMise) {
3378
4786
  if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
3379
4787
  if (!ctx.dryRun) writeText(misePath, nextMise);
3380
4788
  }
3381
- const versioningPath = join12(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
3382
- const expectedScript = templateVersioningScript(ctx);
3383
- if (expectedScript === void 0) {
3384
- 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: [] };
3385
- }
3386
- if (safeReadText(versioningPath) !== expectedScript) {
3387
- changedFiles.push(versioningPath);
3388
- if (!ctx.dryRun) {
3389
- writeText(versioningPath, expectedScript);
3390
- chmodSync2(versioningPath, 493);
3391
- }
3392
- }
3393
- const manifestPath = join12(ctx.repoRoot, ".mise", "version-files.conf");
3394
- const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
3395
- if (safeReadText(manifestPath) !== expectedManifest) {
3396
- changedFiles.push(manifestPath);
3397
- if (!ctx.dryRun) writeText(manifestPath, expectedManifest);
3398
- }
3399
4789
  return {
3400
4790
  id: finding.id,
3401
4791
  title: finding.title,
3402
4792
  status: changedFiles.length ? "applied" : "noop",
3403
- summary: changedFiles.length ? "Versioning block/script/manifest normalized" : "No changes required",
4793
+ summary: changedFiles.length ? "Skillex skills manifest contract normalized" : "No changes required",
3404
4794
  changedFiles,
3405
- details: []
4795
+ details
3406
4796
  };
3407
4797
  }
3408
4798
  },
@@ -3410,9 +4800,9 @@ var RULES = [
3410
4800
  id: "sot.agent-symlinks",
3411
4801
  title: "AGENTS/CLAUDE/GEMINI symlink contract",
3412
4802
  audit: (ctx) => {
3413
- const agentsPath = join12(ctx.repoRoot, "AGENTS.md");
3414
- if (!existsSync9(agentsPath)) {
3415
- const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync9(join12(ctx.repoRoot, file)));
4803
+ const agentsPath = join15(ctx.repoRoot, "AGENTS.md");
4804
+ if (!existsSync10(agentsPath)) {
4805
+ const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync10(join15(ctx.repoRoot, file)));
3416
4806
  if (fallbackSources.length === 0) {
3417
4807
  return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
3418
4808
  }
@@ -3427,7 +4817,7 @@ var RULES = [
3427
4817
  }
3428
4818
  const details = [];
3429
4819
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
3430
- const full = join12(ctx.repoRoot, file);
4820
+ const full = join15(ctx.repoRoot, file);
3431
4821
  const target = readSymlinkTarget(full);
3432
4822
  if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
3433
4823
  }
@@ -3451,7 +4841,7 @@ var RULES = [
3451
4841
  return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
3452
4842
  }
3453
4843
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
3454
- const full = join12(ctx.repoRoot, file);
4844
+ const full = join15(ctx.repoRoot, file);
3455
4845
  const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
3456
4846
  if (result.blocked) blockedDetails.push(result.blocked);
3457
4847
  if (result.changed) changedFiles.push(full);
@@ -3473,7 +4863,7 @@ var RULES = [
3473
4863
  migrate: (ctx, finding) => {
3474
4864
  const changedFiles = [];
3475
4865
  const details = [];
3476
- const path = join12(ctx.repoRoot, ".project.json");
4866
+ const path = join15(ctx.repoRoot, ".project.json");
3477
4867
  const existing = readProjectJson(ctx) ?? {};
3478
4868
  const canonical = canonicalProjectJson(ctx);
3479
4869
  const merged = { ...existing, ...canonical };
@@ -3483,11 +4873,11 @@ var RULES = [
3483
4873
  changedFiles.push(path);
3484
4874
  if (!ctx.dryRun) writeText(path, expected);
3485
4875
  }
3486
- const planeJson = join12(ctx.repoRoot, ".plane.json");
3487
- if (existsSync9(planeJson)) {
4876
+ const planeJson = join15(ctx.repoRoot, ".plane.json");
4877
+ if (existsSync10(planeJson)) {
3488
4878
  const backup = `${planeJson}.migrated-backup`;
3489
- if (existsSync9(backup)) {
3490
- details.push(`cannot back up .plane.json because ${relative(ctx.repoRoot, backup)} already exists`);
4879
+ if (existsSync10(backup)) {
4880
+ details.push(`cannot back up .plane.json because ${relative2(ctx.repoRoot, backup)} already exists`);
3491
4881
  } else {
3492
4882
  changedFiles.push(backup);
3493
4883
  if (!ctx.dryRun) renameSync2(planeJson, backup);
@@ -3508,17 +4898,21 @@ var RULES = [
3508
4898
  title: ".env.op + gitignore secrets contract",
3509
4899
  audit: (ctx) => {
3510
4900
  const details = [];
3511
- const envOp = safeReadText(join12(ctx.repoRoot, ".env.op"));
3512
- const gitignore = safeReadText(join12(ctx.repoRoot, ".gitignore"));
4901
+ const envOp = safeReadText(join15(ctx.repoRoot, ".env.op"));
4902
+ const gitignore = safeReadText(join15(ctx.repoRoot, ".gitignore"));
3513
4903
  if (!envOp) {
3514
4904
  details.push(".env.op missing");
3515
4905
  } else {
3516
- const invalidLines = envOp.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).filter((line) => {
4906
+ const malformed = malformedOpReferences(envOp);
4907
+ if (malformed.length) {
4908
+ details.push(`.env.op has malformed op:// reference(s) on line(s): ${Array.from(new Set(malformed.map((entry) => entry.line))).join(", ")}`);
4909
+ }
4910
+ const invalidLines = envOp.split("\n").map((line, index) => ({ line: line.trim(), number: index + 1 })).filter(({ line }) => line && !line.startsWith("#") && line.includes("=")).filter(({ line }) => {
3517
4911
  const value = line.slice(line.indexOf("=") + 1).trim();
3518
4912
  const quotedLiteral = /^"[^"\r\n]*"$/.test(value) || /^'[^'\r\n]*'$/.test(value);
3519
4913
  return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value) && !quotedLiteral;
3520
4914
  });
3521
- if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals: ${invalidLines.join(", ")}`);
4915
+ if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals on line(s): ${invalidLines.map((entry) => entry.number).join(", ")}`);
3522
4916
  }
3523
4917
  if (!gitignore?.includes(".env\n") && !gitignore?.includes(".env\r\n")) details.push(".gitignore should ignore .env");
3524
4918
  if (!gitignore?.includes(".env.*")) details.push(".gitignore should ignore .env.*");
@@ -3535,12 +4929,23 @@ var RULES = [
3535
4929
  migrate: (ctx, finding) => {
3536
4930
  const changedFiles = [];
3537
4931
  const details = [];
3538
- const envOpPath = join12(ctx.repoRoot, ".env.op");
3539
- if (!existsSync9(envOpPath)) {
4932
+ const envOpPath = join15(ctx.repoRoot, ".env.op");
4933
+ if (!existsSync10(envOpPath)) {
3540
4934
  changedFiles.push(envOpPath);
3541
- if (!ctx.dryRun) writeText(envOpPath, readText(join12(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
4935
+ if (!ctx.dryRun) writeText(envOpPath, readText(join15(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
4936
+ } else {
4937
+ const current = readText(envOpPath);
4938
+ const repaired = removeMalformedCommentOpReferences(current);
4939
+ if (repaired.changed) {
4940
+ changedFiles.push(envOpPath);
4941
+ if (!ctx.dryRun) writeText(envOpPath, repaired.text);
4942
+ }
4943
+ const remaining = malformedOpReferences(repaired.text).filter((entry) => !entry.commentOnly);
4944
+ if (remaining.length) {
4945
+ details.push(`Malformed active op:// reference(s) remain on line(s) ${Array.from(new Set(remaining.map((entry) => entry.line))).join(", ")}; repair them manually without replacing valid user references`);
4946
+ }
3542
4947
  }
3543
- const gitignorePath = join12(ctx.repoRoot, ".gitignore");
4948
+ const gitignorePath = join15(ctx.repoRoot, ".gitignore");
3544
4949
  const gitignore = safeReadText(gitignorePath) ?? "";
3545
4950
  const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
3546
4951
  # NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
@@ -3567,7 +4972,7 @@ var RULES = [
3567
4972
  title: ".copier-answers.yml provenance + drift report",
3568
4973
  audit: (ctx) => {
3569
4974
  const details = [];
3570
- const path = join12(ctx.repoRoot, ".copier-answers.yml");
4975
+ const path = join15(ctx.repoRoot, ".copier-answers.yml");
3571
4976
  const text2 = safeReadText(path);
3572
4977
  const project = readProjectJson(ctx);
3573
4978
  if (!text2) {
@@ -3598,12 +5003,12 @@ var RULES = [
3598
5003
  const changedFiles = [];
3599
5004
  const project = canonicalProjectJson(ctx);
3600
5005
  const text2 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
3601
- _src_path: ${join12(ctx.pjanglerRoot, "templates", "commonproject")}
5006
+ _src_path: ${join15(ctx.pjanglerRoot, "templates", "commonproject")}
3602
5007
  project_description: ${String(project.project_description)}
3603
5008
  project_name: ${String(project.project_name)}
3604
5009
  ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3605
5010
  `;
3606
- const path = join12(ctx.repoRoot, ".copier-answers.yml");
5011
+ const path = join15(ctx.repoRoot, ".copier-answers.yml");
3607
5012
  if (safeReadText(path) !== text2) {
3608
5013
  changedFiles.push(path);
3609
5014
  if (!ctx.dryRun) writeText(path, text2);
@@ -3622,14 +5027,21 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3622
5027
  id: "bmad.scaffold",
3623
5028
  title: "BMAD modules/docs scaffold",
3624
5029
  audit: (ctx) => {
3625
- const targetRoot = join12(ctx.repoRoot, "_bmad");
3626
- const sentinels = [
3627
- join12("core", "config.yaml"),
3628
- join12("config.toml"),
3629
- join12("_config", "manifest.yaml"),
3630
- join12("bmm", "config.yaml")
3631
- ];
3632
- const missing = sentinels.filter((file) => !existsSync9(join12(targetRoot, file)));
5030
+ const manifestSelection = manifestBmadModules(ctx.repoRoot);
5031
+ if (manifestSelection.status === "invalid") {
5032
+ return {
5033
+ id: "bmad.scaffold",
5034
+ title: "BMAD modules/docs scaffold",
5035
+ status: "fail",
5036
+ summary: "BMAD module manifest is invalid; refusing fallback module selection",
5037
+ details: [manifestSelection.error],
5038
+ fixable: false
5039
+ };
5040
+ }
5041
+ const targetRoot = join15(ctx.repoRoot, "_bmad");
5042
+ const selectedModules = manifestSelection.status === "valid" ? manifestSelection.modules : configuredBmadModules(ctx.repoRoot) ?? [...DEFAULT_BMAD_MODULES];
5043
+ const sentinels = requiredBmadSentinels(ctx.repoRoot, selectedModules);
5044
+ const missing = sentinels.filter((file) => !existsSync10(join15(targetRoot, file)));
3633
5045
  return {
3634
5046
  id: "bmad.scaffold",
3635
5047
  title: "BMAD modules/docs scaffold",
@@ -3641,9 +5053,21 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3641
5053
  },
3642
5054
  migrate: (ctx, finding) => {
3643
5055
  const changedFiles = [];
5056
+ const manifestSelection = manifestBmadModules(ctx.repoRoot);
5057
+ if (manifestSelection.status === "invalid") {
5058
+ return {
5059
+ id: finding.id,
5060
+ title: finding.title,
5061
+ status: "blocked",
5062
+ summary: "BMAD module manifest is invalid; refusing fallback module selection",
5063
+ changedFiles,
5064
+ details: [manifestSelection.error]
5065
+ };
5066
+ }
5067
+ const selectedModules = manifestSelection.status === "valid" ? manifestSelection.modules : configuredBmadModules(ctx.repoRoot) ?? [...DEFAULT_BMAD_MODULES];
3644
5068
  if (ctx.dryRun) {
3645
5069
  for (const detail of finding.details) {
3646
- changedFiles.push(join12(ctx.repoRoot, detail));
5070
+ changedFiles.push(join15(ctx.repoRoot, detail));
3647
5071
  }
3648
5072
  return {
3649
5073
  id: finding.id,
@@ -3652,11 +5076,14 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3652
5076
  summary: changedFiles.length ? "Would run non-interactive bmad-method install" : "No changes required",
3653
5077
  changedFiles,
3654
5078
  details: [
3655
- `Would run: npx ${bmadInstallArgs(ctx.repoRoot).join(" ").replace(BMAD_INSTALL_TOOLS.join(","), "...")}`
5079
+ `Would run: npx ${bmadInstallArgs(ctx.repoRoot, selectedModules).join(" ").replace(BMAD_INSTALL_TOOLS.join(","), "...")}`
3656
5080
  ]
3657
5081
  };
3658
5082
  }
3659
- const install = runBmadInstall(ctx.repoRoot);
5083
+ const preservedSkillsManifest = tryParseJson(
5084
+ safeReadText(join15(ctx.repoRoot, ".agents", "skills.json"))
5085
+ );
5086
+ const install = runBmadInstall(ctx.repoRoot, selectedModules);
3660
5087
  if (!install.ok) {
3661
5088
  return {
3662
5089
  id: finding.id,
@@ -3667,16 +5094,28 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3667
5094
  details: [install.error ?? "Unknown error"]
3668
5095
  };
3669
5096
  }
5097
+ const provisioned = provisionBmadSkills(ctx, preservedSkillsManifest);
5098
+ if (!provisioned.ok) {
5099
+ return {
5100
+ id: finding.id,
5101
+ title: finding.title,
5102
+ status: "blocked",
5103
+ summary: `BMAD installed but Skillex pack ${BMAD_PACK_VERSION} provisioning failed`,
5104
+ changedFiles: [],
5105
+ details: [provisioned.error ?? "Unknown BMAD pack error"]
5106
+ };
5107
+ }
3670
5108
  for (const detail of finding.details) {
3671
- if (existsSync9(join12(ctx.repoRoot, detail))) {
3672
- changedFiles.push(join12(ctx.repoRoot, detail));
5109
+ if (existsSync10(join15(ctx.repoRoot, detail))) {
5110
+ changedFiles.push(join15(ctx.repoRoot, detail));
3673
5111
  }
3674
5112
  }
5113
+ changedFiles.push(...provisioned.changedFiles);
3675
5114
  return {
3676
5115
  id: finding.id,
3677
5116
  title: finding.title,
3678
5117
  status: changedFiles.length ? "applied" : "noop",
3679
- summary: changedFiles.length ? "Installed BMAD scaffold via non-interactive installer" : "No changes required",
5118
+ summary: changedFiles.length ? `Installed BMAD scaffold with Skillex pack ${BMAD_PACK_VERSION} skills` : "No changes required",
3680
5119
  changedFiles,
3681
5120
  details: []
3682
5121
  };
@@ -3692,7 +5131,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3692
5131
  id: "bmad.version",
3693
5132
  title: "BMAD version currency",
3694
5133
  status: "skip",
3695
- summary: existsSync9(join12(ctx.repoRoot, "_bmad")) ? "BMAD installed but version manifest unreadable" : "No BMAD install present",
5134
+ summary: existsSync10(join15(ctx.repoRoot, "_bmad")) ? "BMAD installed but version manifest unreadable" : "No BMAD install present",
3696
5135
  details: [],
3697
5136
  fixable: false
3698
5137
  };
@@ -3747,7 +5186,19 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3747
5186
  }
3748
5187
  const installed = readInstalledBmadVersion(ctx.repoRoot);
3749
5188
  const available = resolveBmadDistTags(ctx.homeDir)?.distTags?.[BMAD_TARGET_CHANNEL];
3750
- const manifestPath = join12(ctx.repoRoot, "_bmad", "_config", "manifest.yaml");
5189
+ const manifestPath = join15(ctx.repoRoot, "_bmad", "_config", "manifest.yaml");
5190
+ const manifestSelection = manifestBmadModules(ctx.repoRoot);
5191
+ if (manifestSelection.status === "invalid") {
5192
+ return {
5193
+ id: finding.id,
5194
+ title: finding.title,
5195
+ status: "blocked",
5196
+ summary: "BMAD module manifest is invalid; refusing fallback module selection",
5197
+ changedFiles: [],
5198
+ details: [manifestSelection.error]
5199
+ };
5200
+ }
5201
+ const selectedModules = manifestSelection.status === "valid" ? manifestSelection.modules : configuredBmadModules(ctx.repoRoot) ?? [...DEFAULT_BMAD_MODULES];
3751
5202
  if (ctx.dryRun) {
3752
5203
  return {
3753
5204
  id: finding.id,
@@ -3756,11 +5207,14 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3756
5207
  summary: `Would upgrade BMAD ${installed ?? "?"} -> ${available ?? BMAD_TARGET_CHANNEL}`,
3757
5208
  changedFiles: [manifestPath],
3758
5209
  details: [
3759
- `Would run: npx ${bmadInstallArgs(ctx.repoRoot).join(" ").replace(BMAD_INSTALL_TOOLS.join(","), "...")}`
5210
+ `Would run: npx ${bmadInstallArgs(ctx.repoRoot, selectedModules).join(" ").replace(BMAD_INSTALL_TOOLS.join(","), "...")}`
3760
5211
  ]
3761
5212
  };
3762
5213
  }
3763
- const install = runBmadInstall(ctx.repoRoot);
5214
+ const preservedSkillsManifest = tryParseJson(
5215
+ safeReadText(join15(ctx.repoRoot, ".agents", "skills.json"))
5216
+ );
5217
+ const install = runBmadInstall(ctx.repoRoot, selectedModules);
3764
5218
  if (!install.ok) {
3765
5219
  return {
3766
5220
  id: finding.id,
@@ -3771,6 +5225,17 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3771
5225
  details: [install.error ?? "Unknown error"]
3772
5226
  };
3773
5227
  }
5228
+ const provisioned = provisionBmadSkills(ctx, preservedSkillsManifest);
5229
+ if (!provisioned.ok) {
5230
+ return {
5231
+ id: finding.id,
5232
+ title: finding.title,
5233
+ status: "blocked",
5234
+ summary: `BMAD upgraded but Skillex pack ${BMAD_PACK_VERSION} provisioning failed`,
5235
+ changedFiles: [],
5236
+ details: [provisioned.error ?? "Unknown BMAD pack error"]
5237
+ };
5238
+ }
3774
5239
  const nowInstalled = readInstalledBmadVersion(ctx.repoRoot);
3775
5240
  const upgraded = Boolean(nowInstalled && installed && compareBmadVersions(nowInstalled, installed) > 0);
3776
5241
  return {
@@ -3778,7 +5243,10 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3778
5243
  title: finding.title,
3779
5244
  status: upgraded ? "applied" : "noop",
3780
5245
  summary: upgraded ? `Upgraded BMAD ${installed} -> ${nowInstalled}` : `BMAD reinstalled (${nowInstalled ?? "?"})`,
3781
- changedFiles: upgraded ? [manifestPath] : [],
5246
+ changedFiles: Array.from(/* @__PURE__ */ new Set([
5247
+ ...upgraded ? [manifestPath] : [],
5248
+ ...provisioned.changedFiles
5249
+ ])),
3782
5250
  details: []
3783
5251
  };
3784
5252
  }
@@ -3793,12 +5261,13 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3793
5261
  return { id: "hermes.pm-scaffold", title: "Hermes PM scaffold parity", status: "skip", summary: "No pm role present", details: [], fixable: false };
3794
5262
  }
3795
5263
  const details = [];
3796
- 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"]) {
3797
- if (!existsSync9(join12(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join12(role.roleDir, rel))}`);
5264
+ for (const rel of ["role.yaml", "SOUL.md", "hermes", ".gitignore", ".scripts/70-systemd.sh", ".scripts/heartbeat.sh", ".scripts/checkpoint.sh", ".runtime-scaffold/README.md", "runtime/memories/MEMORY.md"]) {
5265
+ if (!existsSync10(join15(role.roleDir, rel))) details.push(`missing ${relative2(ctx.repoRoot, join15(role.roleDir, rel))}`);
3798
5266
  }
3799
- const gitmodules = safeReadText(join12(ctx.repoRoot, ".gitmodules")) ?? "";
3800
- if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing pm runtime submodule entry");
3801
- if (!profileMetaInheritsDefault(join12(role.roleDir, "runtime", "profile.yaml"))) {
5267
+ if (hasRuntimeSubmoduleMapping(ctx.repoRoot, role)) {
5268
+ details.push(".gitmodules contains retired pm runtime submodule mapping");
5269
+ }
5270
+ if (!profileMetaInheritsDefault(join15(role.roleDir, "runtime", "profile.yaml"))) {
3802
5271
  details.push("runtime/profile.yaml missing inherited default config metadata");
3803
5272
  }
3804
5273
  const registry = safeReadText(registryPath(ctx.homeDir));
@@ -3819,21 +5288,32 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3819
5288
  if (!role) {
3820
5289
  return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
3821
5290
  }
3822
- const templateRoleDir = join12(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
3823
- writeIfDifferent(join12(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
3824
- writeIfDifferent(join12(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
3825
- writeIfDifferent(join12(role.roleDir, ".gitignore"), readText(join12(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
3826
- copyMissingRecursive(join12(templateRoleDir, ".runtime-scaffold"), join12(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
3827
- copyMissingRecursive(join12(templateRoleDir, ".runtime-scaffold"), join12(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
3828
- copyMissingRecursive(join12(templateRoleDir, ".scripts"), join12(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
3829
- const promptSource = join12(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
3830
- const promptTarget = join12(role.roleDir, ".scripts", "sentinel.prompt.md");
3831
- if (existsSync9(promptSource) && !existsSync9(promptTarget)) {
5291
+ const retirement = retireRuntimeSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
5292
+ details.push(...retirement.details);
5293
+ if (!retirement.ok) {
5294
+ return {
5295
+ id: finding.id,
5296
+ title: finding.title,
5297
+ status: "blocked",
5298
+ summary: "Failed to retire PM runtime submodule metadata safely",
5299
+ changedFiles,
5300
+ details: [retirement.error ?? "unknown runtime retirement failure"]
5301
+ };
5302
+ }
5303
+ const templateRoleDir = join15(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
5304
+ writeIfDifferent(join15(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
5305
+ writeIfDifferent(join15(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
5306
+ writeIfDifferent(join15(role.roleDir, ".gitignore"), readText(join15(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
5307
+ copyMissingRecursive(join15(templateRoleDir, ".runtime-scaffold"), join15(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
5308
+ copyMissingRecursive(join15(templateRoleDir, ".runtime-scaffold"), join15(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
5309
+ copyMissingRecursive(join15(templateRoleDir, ".scripts"), join15(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
5310
+ const promptSource = join15(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
5311
+ const promptTarget = join15(role.roleDir, ".scripts", "sentinel.prompt.md");
5312
+ if (existsSync10(promptSource) && !existsSync10(promptTarget)) {
3832
5313
  const prompt = readText(promptSource).replace(/\{\{ agent_id \}\}/g, role.agentId).replace(/\{\{ role \}\}/g, role.role).replace(/\{\{ target_repo \}\}/g, role.repo).replace(/\{\{ display_name \}\}/g, role.displayName || role.agentId);
3833
5314
  writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
3834
5315
  }
3835
- upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
3836
- const profileMetaUpdated = upsertInheritedProfileMeta(join12(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
5316
+ const profileMetaUpdated = upsertInheritedProfileMeta(join15(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
3837
5317
  if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
3838
5318
  const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
3839
5319
  if (registryUpdated) details.push(`updated ${registryUpdated}`);
@@ -3864,8 +5344,8 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3864
5344
  }
3865
5345
  const details = [];
3866
5346
  for (const role of roles) {
3867
- const roleRelDir = relative(ctx.repoRoot, role.roleDir);
3868
- const runtimeRelPath = join12(roleRelDir, "runtime");
5347
+ const roleRelDir = relative2(ctx.repoRoot, role.roleDir);
5348
+ const runtimeRelPath = join15(roleRelDir, "runtime");
3869
5349
  const lsResult = spawnSync6("git", ["ls-files", "--stage", runtimeRelPath], {
3870
5350
  cwd: ctx.repoRoot,
3871
5351
  encoding: "utf8"
@@ -3873,15 +5353,18 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3873
5353
  if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
3874
5354
  details.push(`submodule runtime is tracked in Git index at ${runtimeRelPath}`);
3875
5355
  }
3876
- const gitignorePath = join12(role.roleDir, ".gitignore");
3877
- if (existsSync9(gitignorePath)) {
5356
+ if (hasRuntimeSubmoduleMapping(ctx.repoRoot, role)) {
5357
+ details.push(`stale .gitmodules mapping exists for ${runtimeRelPath}`);
5358
+ }
5359
+ const gitignorePath = join15(role.roleDir, ".gitignore");
5360
+ if (existsSync10(gitignorePath)) {
3878
5361
  const content = safeReadText(gitignorePath) ?? "";
3879
5362
  const lines = content.split(/\r?\n/).map((line) => line.trim());
3880
5363
  if (!lines.includes("runtime/") && !lines.includes("runtime")) {
3881
- details.push(`.gitignore missing runtime/ ignore entry in ${relative(ctx.repoRoot, gitignorePath)}`);
5364
+ details.push(`.gitignore missing runtime/ ignore entry in ${relative2(ctx.repoRoot, gitignorePath)}`);
3882
5365
  }
3883
5366
  } else {
3884
- details.push(`.gitignore is missing in ${relative(ctx.repoRoot, gitignorePath)}`);
5367
+ details.push(`.gitignore is missing in ${relative2(ctx.repoRoot, gitignorePath)}`);
3885
5368
  }
3886
5369
  }
3887
5370
  return {
@@ -3898,32 +5381,28 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3898
5381
  const changedFiles = [];
3899
5382
  const details = [];
3900
5383
  for (const role of roles) {
3901
- const roleRelDir = relative(ctx.repoRoot, role.roleDir);
3902
- const runtimeRelPath = join12(roleRelDir, "runtime");
3903
- const lsResult = spawnSync6("git", ["ls-files", "--stage", runtimeRelPath], {
3904
- cwd: ctx.repoRoot,
3905
- encoding: "utf8"
3906
- });
3907
- if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
3908
- details.push(`untrack ${runtimeRelPath}`);
3909
- changedFiles.push(runtimeRelPath);
3910
- if (!ctx.dryRun) {
3911
- spawnSync6("git", ["rm", "--cached", "-r", runtimeRelPath], {
3912
- cwd: ctx.repoRoot,
3913
- encoding: "utf8"
3914
- });
3915
- }
5384
+ const retirement = retireRuntimeSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
5385
+ details.push(...retirement.details);
5386
+ if (!retirement.ok) {
5387
+ return {
5388
+ id: finding.id,
5389
+ title: finding.title,
5390
+ status: "blocked",
5391
+ summary: "Failed to retire Hermes runtime submodule metadata safely",
5392
+ changedFiles,
5393
+ details: [retirement.error ?? "unknown runtime retirement failure"]
5394
+ };
3916
5395
  }
3917
- const gitignorePath = join12(role.roleDir, ".gitignore");
5396
+ const gitignorePath = join15(role.roleDir, ".gitignore");
3918
5397
  let content = "";
3919
5398
  let isIgnored = false;
3920
- if (existsSync9(gitignorePath)) {
5399
+ if (existsSync10(gitignorePath)) {
3921
5400
  content = safeReadText(gitignorePath) ?? "";
3922
5401
  const lines = content.split(/\r?\n/).map((line) => line.trim());
3923
5402
  isIgnored = lines.includes("runtime/") || lines.includes("runtime");
3924
5403
  }
3925
5404
  if (!isIgnored) {
3926
- details.push(`ignore runtime/ in ${relative(ctx.repoRoot, gitignorePath)}`);
5405
+ details.push(`ignore runtime/ in ${relative2(ctx.repoRoot, gitignorePath)}`);
3927
5406
  changedFiles.push(gitignorePath);
3928
5407
  if (!ctx.dryRun) {
3929
5408
  if (content && !content.endsWith("\n")) {
@@ -3958,7 +5437,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3958
5437
  }
3959
5438
  const details = [];
3960
5439
  for (const role of roles) {
3961
- for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
5440
+ for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
3962
5441
  const state = checkUnit(unit);
3963
5442
  if (!state.enabled || !state.active) details.push(`${unit} should be enabled+active`);
3964
5443
  }
@@ -3984,9 +5463,9 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3984
5463
  return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
3985
5464
  }
3986
5465
  for (const role of roles) {
3987
- const sysDir = join12(ctx.homeDir, ".config", "systemd", "user");
3988
- const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
3989
- const allUnitsPresent = units.every((unit) => existsSync9(join12(sysDir, unit)));
5466
+ const sysDir = join15(ctx.homeDir, ".config", "systemd", "user");
5467
+ const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-heartbeat.timer`];
5468
+ const allUnitsPresent = units.every((unit) => existsSync10(join15(sysDir, unit)));
3990
5469
  if (allUnitsPresent) {
3991
5470
  if (ctx.dryRun) {
3992
5471
  details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
@@ -3998,8 +5477,8 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3998
5477
  }
3999
5478
  continue;
4000
5479
  }
4001
- for (const script of [join12(role.roleDir, ".scripts", "70-systemd.sh")]) {
4002
- if (!script || !existsSync9(script)) continue;
5480
+ for (const script of [join15(role.roleDir, ".scripts", "70-systemd.sh")]) {
5481
+ if (!script || !existsSync10(script)) continue;
4003
5482
  if (ctx.dryRun) {
4004
5483
  details.push(`would run: bash ${script}`);
4005
5484
  } else {
@@ -4017,6 +5496,349 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4017
5496
  details
4018
5497
  };
4019
5498
  }
5499
+ },
5500
+ {
5501
+ id: "hermes.runtime-singleton",
5502
+ title: "Hermes singleton runtime (shared config/auth, per-agent memory)",
5503
+ audit: (ctx) => {
5504
+ const roles = discoverRoles(ctx.repoRoot);
5505
+ if (!roles.length) {
5506
+ return { id: "hermes.runtime-singleton", title: "Hermes singleton runtime (shared config/auth, per-agent memory)", status: "skip", summary: "No Hermes roles present", details: [], fixable: false };
5507
+ }
5508
+ const details = [];
5509
+ for (const role of roles) {
5510
+ const plan = singletonPlan(ctx, role);
5511
+ if (!existsSync10(plan.fleetRoot)) {
5512
+ details.push(`fleet root missing at ${plan.fleetRoot}`);
5513
+ continue;
5514
+ }
5515
+ if (!existsSync10(plan.profileDir)) {
5516
+ details.push(`profile dir missing: ${plan.profileDir}`);
5517
+ } else if (lstatSync2(plan.profileDir).isSymbolicLink()) {
5518
+ details.push(`profile dir is a symlink (must be a real dir): ${plan.profileDir}`);
5519
+ }
5520
+ for (const link of plan.links) {
5521
+ const state = linkState(link.path, link.target);
5522
+ if (state !== "ok") details.push(`${state}: ${link.path} -> ${link.target}`);
5523
+ }
5524
+ }
5525
+ return {
5526
+ id: "hermes.runtime-singleton",
5527
+ title: "Hermes singleton runtime (shared config/auth, per-agent memory)",
5528
+ status: details.length === 0 ? "pass" : "fail",
5529
+ summary: details.length === 0 ? "Singleton runtime contract satisfied" : `${details.length} singleton-runtime issue(s) detected`,
5530
+ details,
5531
+ fixable: true
5532
+ };
5533
+ },
5534
+ migrate: (ctx, finding) => {
5535
+ const roles = discoverRoles(ctx.repoRoot);
5536
+ const changedFiles = [];
5537
+ const details = [];
5538
+ for (const role of roles) {
5539
+ const plan = singletonPlan(ctx, role);
5540
+ if (!existsSync10(plan.fleetRoot)) {
5541
+ details.push(`blocked: fleet root missing at ${plan.fleetRoot}`);
5542
+ continue;
5543
+ }
5544
+ for (const shared of plan.sharedSeeds) {
5545
+ if (existsSync10(shared.rootPath)) continue;
5546
+ const donor = existsSync10(shared.runtimePath) ? shared.runtimePath : null;
5547
+ if (!donor) continue;
5548
+ details.push(`seed fleet ${basename4(shared.rootPath)} from ${donor}`);
5549
+ changedFiles.push(shared.rootPath);
5550
+ if (!ctx.dryRun) copyFileSync(donor, shared.rootPath);
5551
+ }
5552
+ if (existsSync10(plan.profileDir) && lstatSync2(plan.profileDir).isSymbolicLink()) {
5553
+ details.push(`convert profile symlink to real dir: ${plan.profileDir}`);
5554
+ changedFiles.push(plan.profileDir);
5555
+ if (!ctx.dryRun) unlinkSync3(plan.profileDir);
5556
+ }
5557
+ if (!existsSync10(plan.profileDir)) {
5558
+ details.push(`create profile dir: ${plan.profileDir}`);
5559
+ changedFiles.push(plan.profileDir);
5560
+ if (!ctx.dryRun) mkdirSync6(plan.profileDir, { recursive: true });
5561
+ }
5562
+ for (const link of plan.links) {
5563
+ const state = linkState(link.path, link.target);
5564
+ if (state === "ok") continue;
5565
+ if (link.ensureTargetDir && !existsSync10(link.target) && !ctx.dryRun) {
5566
+ mkdirSync6(link.target, { recursive: true });
5567
+ }
5568
+ details.push(`link ${link.path} -> ${link.target}`);
5569
+ changedFiles.push(link.path);
5570
+ if (ctx.dryRun) continue;
5571
+ if (existsSync10(link.path) || isDanglingLink(link.path)) {
5572
+ const lst = lstatSync2(link.path);
5573
+ if (lst.isSymbolicLink()) {
5574
+ unlinkSync3(link.path);
5575
+ } else {
5576
+ const parked = `${link.path}.pre-singleton`;
5577
+ renameSync2(link.path, parked);
5578
+ details.push(`parked pre-existing ${link.path} at ${parked}`);
5579
+ }
5580
+ }
5581
+ ensureParent(link.path);
5582
+ symlinkSync(link.target, link.path);
5583
+ }
5584
+ }
5585
+ return {
5586
+ id: finding.id,
5587
+ title: finding.title,
5588
+ status: details.some((d) => d.startsWith("blocked:")) ? "blocked" : changedFiles.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
5589
+ summary: changedFiles.length ? ctx.dryRun ? "Planned singleton-runtime wiring" : "Singleton runtime wired" : "No changes required",
5590
+ changedFiles,
5591
+ details
5592
+ };
5593
+ }
5594
+ },
5595
+ {
5596
+ id: "hermes.profile-wiring",
5597
+ title: "Launcher + systemd HERMES_HOME points at the named profile",
5598
+ audit: (ctx) => {
5599
+ const roles = discoverRoles(ctx.repoRoot);
5600
+ if (!roles.length) {
5601
+ return { id: "hermes.profile-wiring", title: "Launcher + systemd HERMES_HOME points at the named profile", status: "skip", summary: "No Hermes roles present", details: [], fixable: false };
5602
+ }
5603
+ const details = [];
5604
+ for (const role of roles) {
5605
+ const plan = singletonPlan(ctx, role);
5606
+ const launcher = join15(role.roleDir, "hermes");
5607
+ const text2 = safeReadText(launcher);
5608
+ if (text2 === null) {
5609
+ details.push(`launcher missing: ${relative2(ctx.repoRoot, launcher)}`);
5610
+ } else {
5611
+ if (/^HERMES_HOME="\$RUNTIME_HOME"\s*$/m.test(text2)) {
5612
+ details.push(`launcher sets HERMES_HOME to the raw runtime path (disables shared auth + profile identity): ${relative2(ctx.repoRoot, launcher)}`);
5613
+ }
5614
+ if (/HERMES_OAUTH_FILE/.test(text2)) {
5615
+ details.push(`launcher exports HERMES_OAUTH_FILE, which Hermes does not implement (dead config): ${relative2(ctx.repoRoot, launcher)}`);
5616
+ }
5617
+ }
5618
+ for (const unit of profileUnits(role)) {
5619
+ const unitPath = join15(ctx.homeDir, ".config", "systemd", "user", unit);
5620
+ const unitText = safeReadText(unitPath);
5621
+ if (unitText === null) continue;
5622
+ const current = /^Environment=HERMES_HOME=(.*)$/m.exec(unitText)?.[1]?.trim();
5623
+ if (current && current !== plan.profileDir) {
5624
+ details.push(`${unit} HERMES_HOME=${current} (expected ${plan.profileDir})`);
5625
+ }
5626
+ if (/^Environment=HERMES_OAUTH_FILE=/m.test(unitText)) {
5627
+ details.push(`${unit} sets HERMES_OAUTH_FILE (dead config)`);
5628
+ }
5629
+ }
5630
+ }
5631
+ return {
5632
+ id: "hermes.profile-wiring",
5633
+ title: "Launcher + systemd HERMES_HOME points at the named profile",
5634
+ status: details.length === 0 ? "pass" : "fail",
5635
+ summary: details.length === 0 ? "HERMES_HOME wiring is in parity" : `${details.length} HERMES_HOME wiring issue(s) detected`,
5636
+ details,
5637
+ fixable: true
5638
+ };
5639
+ },
5640
+ migrate: (ctx, finding) => {
5641
+ const roles = discoverRoles(ctx.repoRoot);
5642
+ const changedFiles = [];
5643
+ const details = [];
5644
+ let unitsTouched = false;
5645
+ for (const role of roles) {
5646
+ const plan = singletonPlan(ctx, role);
5647
+ const launcher = join15(role.roleDir, "hermes");
5648
+ const text2 = safeReadText(launcher);
5649
+ if (text2 !== null) {
5650
+ const rewritten = rewriteLauncher(text2);
5651
+ if (rewritten !== text2) {
5652
+ details.push(`rewrite launcher HERMES_HOME -> profile path: ${relative2(ctx.repoRoot, launcher)}`);
5653
+ writeIfDifferent(launcher, rewritten, ctx.dryRun, changedFiles, 493);
5654
+ }
5655
+ }
5656
+ for (const unit of profileUnits(role)) {
5657
+ const unitPath = join15(ctx.homeDir, ".config", "systemd", "user", unit);
5658
+ const unitText = safeReadText(unitPath);
5659
+ if (unitText === null) continue;
5660
+ let next = unitText.replace(/^Environment=HERMES_HOME=.*$/m, `Environment=HERMES_HOME=${plan.profileDir}`);
5661
+ next = next.replace(/^Environment=HERMES_OAUTH_FILE=.*\n/m, "");
5662
+ if (next !== unitText) {
5663
+ details.push(`repoint ${unit} HERMES_HOME -> ${plan.profileDir}`);
5664
+ writeIfDifferent(unitPath, next, ctx.dryRun, changedFiles);
5665
+ unitsTouched = true;
5666
+ }
5667
+ }
5668
+ }
5669
+ if (unitsTouched && !ctx.dryRun) {
5670
+ systemctlUser(["daemon-reload"]);
5671
+ details.push("systemctl --user daemon-reload (restart units to pick up the new HERMES_HOME)");
5672
+ }
5673
+ return {
5674
+ id: finding.id,
5675
+ title: finding.title,
5676
+ status: changedFiles.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
5677
+ summary: changedFiles.length ? ctx.dryRun ? "Planned HERMES_HOME rewiring" : "HERMES_HOME rewired to named profiles" : "No changes required",
5678
+ changedFiles,
5679
+ details
5680
+ };
5681
+ }
5682
+ },
5683
+ {
5684
+ id: "hermes.registry-parity",
5685
+ title: "Fleet registry matches .project.json (no duplicate or stale agents)",
5686
+ audit: (ctx) => {
5687
+ const roles = discoverRoles(ctx.repoRoot);
5688
+ const details = [];
5689
+ const registryPath2 = join15(ctx.homeDir, ".hermes", "agents-registry.yaml");
5690
+ const registry = readRegistry(registryPath2);
5691
+ if (!registry) {
5692
+ if (!roles.length && declaredAgentIds(ctx.repoRoot).length === 0) {
5693
+ return { id: "hermes.registry-parity", title: "Fleet registry matches .project.json (no duplicate or stale agents)", status: "skip", summary: "No Hermes roles or declared agents present", details: [], fixable: false };
5694
+ }
5695
+ return { id: "hermes.registry-parity", title: "Fleet registry matches .project.json (no duplicate or stale agents)", status: "warn", summary: `registry unreadable at ${registryPath2}`, details: [], fixable: false };
5696
+ }
5697
+ const canonical = new Set(roles.map((role) => role.agentId).filter(Boolean));
5698
+ const owned = ownedRegistryEntries(registry, ctx.repoRoot);
5699
+ const unprovisioned = unprovisionedRoleAgents(registry, ctx.repoRoot, canonical);
5700
+ if (unprovisioned.length) {
5701
+ return {
5702
+ id: "hermes.registry-parity",
5703
+ title: "Fleet registry matches .project.json (no duplicate or stale agents)",
5704
+ status: "fail",
5705
+ summary: `${unprovisioned.length} unprovisioned Hermes role blocker(s) detected`,
5706
+ details: unprovisioned.map(
5707
+ ({ agentId, roleDir, sources }) => `agent "${agentId}" (${sources.join(" + ")}) has no role.yaml${roleDir ? ` at ${roleDir}` : ""}; provision or restore the role, do not delete its registry/declaration`
5708
+ ),
5709
+ fixable: false
5710
+ };
5711
+ }
5712
+ if (canonical.size === 0) {
5713
+ return { id: "hermes.registry-parity", title: "Fleet registry matches .project.json (no duplicate or stale agents)", status: "skip", summary: "No Hermes roles, declarations, or registry entries present", details: [], fixable: false };
5714
+ }
5715
+ for (const [agentId, entry] of owned) {
5716
+ const roleDir = String(entry?.role_dir ?? "");
5717
+ if (!canonical.has(agentId)) {
5718
+ details.push(`stale/duplicate registry agent "${agentId}" for ${roleDir} (role.yaml declares ${[...canonical].join(", ")})`);
5719
+ }
5720
+ }
5721
+ for (const extra of declaredAgentIds(ctx.repoRoot).filter((id) => !canonical.has(id))) {
5722
+ details.push(`.project.json declares agent "${extra}" that no role.yaml claims`);
5723
+ }
5724
+ for (const role of roles) {
5725
+ const entry = registry[role.agentId];
5726
+ if (!entry) {
5727
+ details.push(`registry is missing an entry for ${role.agentId}`);
5728
+ continue;
5729
+ }
5730
+ const entryRoleDir = String(entry.role_dir ?? "");
5731
+ if (entryRoleDir && realOrSelf(entryRoleDir) !== realOrSelf(role.roleDir)) {
5732
+ details.push(`registry role_dir for ${role.agentId} is ${entryRoleDir} (expected ${role.roleDir})`);
5733
+ }
5734
+ const bin = String(entry.hermes?.bin ?? "");
5735
+ if (bin && !existsSync10(bin)) {
5736
+ details.push(`registry hermes.bin for ${role.agentId} does not exist: ${bin}`);
5737
+ }
5738
+ }
5739
+ return {
5740
+ id: "hermes.registry-parity",
5741
+ title: "Fleet registry matches .project.json (no duplicate or stale agents)",
5742
+ status: details.length === 0 ? "pass" : "fail",
5743
+ summary: details.length === 0 ? "Fleet registry is in parity" : `${details.length} registry parity issue(s) detected`,
5744
+ details,
5745
+ fixable: true
5746
+ };
5747
+ },
5748
+ migrate: (ctx, finding) => {
5749
+ const changedFiles = [];
5750
+ const details = [];
5751
+ const registryPath2 = join15(ctx.homeDir, ".hermes", "agents-registry.yaml");
5752
+ const raw = safeReadText(registryPath2);
5753
+ if (raw === null) {
5754
+ return { id: finding.id, title: finding.title, status: "blocked", summary: `registry unreadable at ${registryPath2}`, changedFiles, details };
5755
+ }
5756
+ let doc;
5757
+ try {
5758
+ doc = YAML3.parse(raw);
5759
+ } catch {
5760
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "registry is not valid YAML", changedFiles, details };
5761
+ }
5762
+ const agents = doc?.agents ?? {};
5763
+ const roles = discoverRoles(ctx.repoRoot);
5764
+ const canonical = new Set(roles.map((role) => role.agentId).filter(Boolean));
5765
+ const unprovisioned = unprovisionedRoleAgents(agents, ctx.repoRoot, canonical);
5766
+ if (unprovisioned.length) {
5767
+ return {
5768
+ id: finding.id,
5769
+ title: finding.title,
5770
+ status: "blocked",
5771
+ summary: "Registry parity is blocked by an unprovisioned Hermes role",
5772
+ changedFiles,
5773
+ details: unprovisioned.map(
5774
+ ({ agentId, roleDir, sources }) => `blocked: "${agentId}" (${sources.join(" + ")}) has no role.yaml${roleDir ? ` at ${roleDir}` : ""}; provision or restore the role without pruning registry/declaration state`
5775
+ )
5776
+ };
5777
+ }
5778
+ const fleetBin = fleetBinPath(ctx);
5779
+ let dirty = false;
5780
+ if (canonical.size === 0) {
5781
+ for (const [agentId] of ownedRegistryEntries(agents, ctx.repoRoot)) {
5782
+ details.push(`blocked: "${agentId}" has no role.yaml; provision the role instead of pruning the registry`);
5783
+ }
5784
+ for (const agentId of declaredAgentIds(ctx.repoRoot)) {
5785
+ if (!details.some((detail) => detail.includes(`"${agentId}"`))) {
5786
+ details.push(`blocked: "${agentId}" is declared but has no role.yaml; provision or restore the role`);
5787
+ }
5788
+ }
5789
+ if (details.length) {
5790
+ return {
5791
+ id: finding.id,
5792
+ title: finding.title,
5793
+ status: "blocked",
5794
+ summary: "Registry parity is blocked by an unprovisioned Hermes role",
5795
+ changedFiles,
5796
+ details
5797
+ };
5798
+ }
5799
+ }
5800
+ for (const [agentId, entry] of ownedRegistryEntries(agents, ctx.repoRoot)) {
5801
+ if (canonical.size > 0 && !canonical.has(agentId)) {
5802
+ details.push(`drop stale/duplicate registry agent "${agentId}"`);
5803
+ delete agents[agentId];
5804
+ dropDeclaredAgent(ctx, agentId, changedFiles, details);
5805
+ dirty = true;
5806
+ continue;
5807
+ }
5808
+ const hermes = entry.hermes ?? {};
5809
+ if (fleetBin && String(hermes.bin ?? "") !== fleetBin && !existsSync10(String(hermes.bin ?? ""))) {
5810
+ details.push(`repoint ${agentId} hermes.bin -> ${fleetBin}`);
5811
+ hermes.bin = fleetBin;
5812
+ entry.hermes = hermes;
5813
+ dirty = true;
5814
+ }
5815
+ if (hermes.oauth_file) {
5816
+ details.push(`drop dead hermes.oauth_file from ${agentId}`);
5817
+ delete hermes.oauth_file;
5818
+ dirty = true;
5819
+ }
5820
+ }
5821
+ if (canonical.size > 0) {
5822
+ for (const extra of declaredAgentIds(ctx.repoRoot).filter((id) => !canonical.has(id))) {
5823
+ dropDeclaredAgent(ctx, extra, changedFiles, details);
5824
+ }
5825
+ }
5826
+ if (dirty) {
5827
+ changedFiles.push(registryPath2);
5828
+ if (!ctx.dryRun) {
5829
+ doc.agents = agents;
5830
+ writeText(registryPath2, YAML3.stringify(doc));
5831
+ }
5832
+ }
5833
+ return {
5834
+ id: finding.id,
5835
+ title: finding.title,
5836
+ status: changedFiles.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
5837
+ summary: changedFiles.length ? ctx.dryRun ? "Planned registry repair" : "Fleet registry repaired" : "No changes required",
5838
+ changedFiles,
5839
+ details
5840
+ };
5841
+ }
4020
5842
  }
4021
5843
  ];
4022
5844
  function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
@@ -4026,7 +5848,7 @@ function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
4026
5848
  changedFiles.push(path);
4027
5849
  if (!dryRun) {
4028
5850
  writeText(path, normalized);
4029
- if (mode) chmodSync2(path, mode);
5851
+ if (mode) chmodSync4(path, mode);
4030
5852
  }
4031
5853
  }
4032
5854
  function getParityRuleIds() {
@@ -4035,7 +5857,7 @@ function getParityRuleIds() {
4035
5857
  function runAudit(repoArg) {
4036
5858
  const pjanglerRoot = resolvePjanglerRoot2();
4037
5859
  const ctx = {
4038
- repoRoot: resolve2(repoArg ?? process.cwd()),
5860
+ repoRoot: resolve3(repoArg ?? process.cwd()),
4039
5861
  dryRun: true,
4040
5862
  pjanglerRoot,
4041
5863
  homeDir: homedir5()
@@ -4051,7 +5873,7 @@ function runAudit(repoArg) {
4051
5873
  function runMigrationForRules(ruleIds, repoArg, dryRun) {
4052
5874
  const pjanglerRoot = resolvePjanglerRoot2();
4053
5875
  const ctx = {
4054
- repoRoot: resolve2(repoArg ?? process.cwd()),
5876
+ repoRoot: resolve3(repoArg ?? process.cwd()),
4055
5877
  dryRun,
4056
5878
  pjanglerRoot,
4057
5879
  homeDir: homedir5()
@@ -4085,7 +5907,22 @@ function runMigrationForRules(ruleIds, repoArg, dryRun) {
4085
5907
  };
4086
5908
  }
4087
5909
  function runMigration(selector, repoArg, dryRun, all) {
4088
- const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
5910
+ if (all) {
5911
+ const audit = runAudit(repoArg);
5912
+ const ruleIds2 = audit.rules.filter((finding) => finding.fixable && (finding.status === "fail" || finding.status === "warn")).map((finding) => finding.id);
5913
+ if (ruleIds2.length === 0) {
5914
+ return {
5915
+ repo: audit.repo,
5916
+ dryRun,
5917
+ ok: true,
5918
+ selectedRules: [],
5919
+ results: [],
5920
+ changedFiles: []
5921
+ };
5922
+ }
5923
+ return runMigrationForRules(ruleIds2, repoArg, dryRun);
5924
+ }
5925
+ const ruleIds = selector ? [selector] : [];
4089
5926
  return runMigrationForRules(ruleIds, repoArg, dryRun);
4090
5927
  }
4091
5928
  function prettyTimestamp(iso) {
@@ -4122,8 +5959,8 @@ var server = new McpServer({
4122
5959
  });
4123
5960
  var TICKET_PROVIDER_SCHEMA = z.enum(["plane", "trello"]);
4124
5961
  function resolveTargetDir(targetDir) {
4125
- const dir = resolve3(targetDir ?? process.cwd());
4126
- if (!existsSync10(dir)) {
5962
+ const dir = resolve4(targetDir ?? process.cwd());
5963
+ if (!existsSync11(dir)) {
4127
5964
  throw new Error(`Target directory does not exist: ${dir}`);
4128
5965
  }
4129
5966
  if (!statSync2(dir).isDirectory()) {
@@ -4134,7 +5971,7 @@ function resolveTargetDir(targetDir) {
4134
5971
  function resolvePjanglerRoot3() {
4135
5972
  let dir = dirname8(fileURLToPath5(import.meta.url));
4136
5973
  while (dir !== dirname8(dir)) {
4137
- if (existsSync10(join13(dir, "package.json")) && existsSync10(join13(dir, "templates", "commonproject", "copier.yml"))) {
5974
+ if (existsSync11(join16(dir, "package.json")) && existsSync11(join16(dir, "templates", "commonproject", "copier.yml"))) {
4138
5975
  return dir;
4139
5976
  }
4140
5977
  dir = dirname8(dir);
@@ -4335,9 +6172,9 @@ server.registerTool(
4335
6172
  try {
4336
6173
  const pjanglerRoot = resolvePjanglerRoot3();
4337
6174
  const projectSlug = input.projectSlug ?? slugify(input.projectName);
4338
- const parentDir = resolve3(input.parentDir ?? process.cwd());
4339
- if (!existsSync10(parentDir) || !statSync2(parentDir).isDirectory()) throw new Error(`Parent directory does not exist: ${parentDir}`);
4340
- const targetDir = resolve3(input.targetDir ?? join13(parentDir, projectSlug));
6175
+ const parentDir = resolve4(input.parentDir ?? process.cwd());
6176
+ if (!existsSync11(parentDir) || !statSync2(parentDir).isDirectory()) throw new Error(`Parent directory does not exist: ${parentDir}`);
6177
+ const targetDir = resolve4(input.targetDir ?? join16(parentDir, projectSlug));
4341
6178
  const overwrite = input.overwrite ?? input.force ?? false;
4342
6179
  const dryRun = input.dryRun ?? true;
4343
6180
  const local = input.local ?? true;
@@ -4347,7 +6184,7 @@ server.registerTool(
4347
6184
  if (!skipPlane && ticketProvider === "plane" && !boardId) {
4348
6185
  throw new Error("boardId or planeProjectId is required when skipPlane=false for Plane; keep skipPlane=true for safe local bootstrap");
4349
6186
  }
4350
- if (!dryRun && existsSync10(targetDir) && !overwrite) throw new Error(`Target already exists: ${targetDir} (set force/overwrite=true to re-render)`);
6187
+ if (!dryRun && existsSync11(targetDir) && !overwrite) throw new Error(`Target already exists: ${targetDir} (set force/overwrite=true to re-render)`);
4351
6188
  const plan = planProjectInit({
4352
6189
  name: input.projectName,
4353
6190
  description: input.projectDescription,
@@ -4580,7 +6417,7 @@ server.registerTool(
4580
6417
  targetDir: resolvedTarget,
4581
6418
  yes: true,
4582
6419
  local,
4583
- targetRepo: input.targetRepo ?? basename4(resolvedTarget),
6420
+ targetRepo: input.targetRepo ?? basename5(resolvedTarget),
4584
6421
  role: input.role,
4585
6422
  agentPurpose: input.agentPurpose,
4586
6423
  soulTone: input.soulTone,