@delorenj/pjangler 1.1.4 → 1.2.1

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 (140) hide show
  1. package/.mise/scripts/link-agentfiles.sh +9 -0
  2. package/.mise/scripts/versioning.sh +236 -0
  3. package/dist/index.js +498 -277
  4. package/dist/mcp-server.js +1706 -163
  5. package/package.json +4 -2
  6. package/templates/commonproject/.mise/scripts/create-plane-project.sh +26 -0
  7. package/templates/commonproject/copier.yml +2 -0
  8. package/templates/commonproject/template/.agents/hooks/README.md +151 -0
  9. package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +63 -0
  10. package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-journal-write.sh +327 -0
  11. package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-recall.sh +180 -0
  12. package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-retain.sh +74 -0
  13. package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-session-end.sh +57 -0
  14. package/templates/commonproject/template/.agents/hooks/hindsight/lib/hindsight-bank.sh +57 -0
  15. package/templates/commonproject/template/.agents/hooks/hindsight/lib/hindsight-journal.sh +48 -0
  16. package/templates/commonproject/template/.agents/hooks/hooks.master.json +97 -0
  17. package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +30 -0
  18. package/templates/commonproject/template/.agents/hooks/lib/local-config.sh +51 -0
  19. package/templates/commonproject/template/.agents/hooks/reminder-for-skill-check.sh +20 -0
  20. package/templates/commonproject/template/.agents/hooks/sync.py +618 -0
  21. package/templates/commonproject/template/.agents/local.example.json +15 -0
  22. package/templates/commonproject/template/.mise/scripts/hindsight-setup.sh +62 -0
  23. package/templates/commonproject/template/.mise/scripts/link-project-skills-to-clis.sh +110 -0
  24. package/templates/commonproject/template/.mise/scripts/unlink-project-skills-from-clis.sh +45 -0
  25. package/templates/commonproject/template/mise.toml.jinja +42 -0
  26. package/templates/hermes-agent/.codegraph/daemon.pid +6 -0
  27. package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +10 -0
  28. package/templates/hermes-agent/README.md +16 -14
  29. package/templates/hermes-agent/copier.yml +7 -25
  30. package/templates/hermes-agent/docs/architecture.md +16 -18
  31. package/templates/hermes-agent/docs/fleet-control-plane/README.md +35 -0
  32. package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +253 -0
  33. package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +280 -0
  34. package/templates/hermes-agent/docs/fleet-control-plane/implementation-readiness-report-2026-06-27.md +118 -0
  35. package/templates/hermes-agent/docs/fleet-control-plane/prd.md +141 -0
  36. package/templates/hermes-agent/docs/operations.md +17 -19
  37. package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +17 -13
  38. package/templates/hermes-agent/docs/{scrum-master → sentinel}/README.md +47 -46
  39. package/templates/hermes-agent/docs/{scrum-master → sentinel}/architecture.md +39 -38
  40. package/templates/hermes-agent/docs/{scrum-master → sentinel}/development.md +63 -99
  41. package/templates/hermes-agent/docs/{scrum-master → sentinel}/providers.md +2 -2
  42. package/templates/hermes-agent/docs/sentinel.md +18 -0
  43. package/templates/hermes-agent/install-local.sh +22 -20
  44. package/templates/hermes-agent/runtime-scaffold/README.md +10 -9
  45. package/templates/hermes-agent/scripts/backfill-fleet-sot.sh +12 -21
  46. package/templates/hermes-agent/scripts/fleet-sync.sh +369 -0
  47. package/templates/hermes-agent/scripts/migrate-unify.sh +252 -0
  48. package/templates/hermes-agent/scripts/unify-pm.sh +148 -0
  49. package/templates/hermes-agent/template/.runtime-scaffold/README.md +10 -9
  50. package/templates/hermes-agent/template/.scripts/01-config.sh +2 -3
  51. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -2
  52. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +47 -19
  53. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +71 -27
  54. package/templates/hermes-agent/template/.scripts/30-telegram.sh +3 -3
  55. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +3 -3
  56. package/templates/hermes-agent/template/.scripts/70-systemd.sh +48 -43
  57. package/templates/hermes-agent/template/.scripts/80-registry.sh +2 -2
  58. package/templates/hermes-agent/template/.scripts/99-summary.sh +3 -4
  59. package/templates/hermes-agent/template/.scripts/_lib.sh +4 -19
  60. package/templates/hermes-agent/template/.scripts/checkpoint.sh +36 -0
  61. package/templates/hermes-agent/template/.scripts/config.example.toml +11 -5
  62. package/templates/hermes-agent/template/.scripts/{scrum-master/continuous-ticket-sentinel.sh → heartbeat.sh} +84 -31
  63. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +1 -1
  64. package/templates/hermes-agent/template/.scripts/providers/plane.sh +9 -3
  65. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/bin/emit-event.py +5 -5
  66. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/bin/issue-autonomous-review.sh +6 -6
  67. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/bin/issue-close-gate.sh +2 -2
  68. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/docs/autonomous-delegated-review.md +7 -7
  69. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/docs/bloodbank-events.md +3 -3
  70. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/docs/continuous-ticket-orchestration.md +6 -6
  71. package/templates/hermes-agent/template/.scripts/{scrum-master/continuous-ticket-sentinel.prompt.md.jinja → sentinel.prompt.md.jinja} +5 -4
  72. package/templates/hermes-agent/template/SOUL.md.jinja +3 -6
  73. package/templates/hermes-agent/template/hermes.jinja +6 -15
  74. package/templates/hermes-agent/template/role.yaml.jinja +9 -8
  75. package/templates/commonproject/.claude/commands/bmad/bmb/agents/agent-builder.md +0 -14
  76. package/templates/commonproject/.claude/commands/bmad/bmb/agents/module-builder.md +0 -14
  77. package/templates/commonproject/.claude/commands/bmad/bmb/agents/workflow-builder.md +0 -14
  78. package/templates/commonproject/.claude/commands/bmad/bmb/workflows/agent.md +0 -5
  79. package/templates/commonproject/.claude/commands/bmad/bmb/workflows/module.md +0 -5
  80. package/templates/commonproject/.claude/commands/bmad/bmb/workflows/workflow.md +0 -5
  81. package/templates/commonproject/.claude/commands/bmad/bmm/agents/analyst.md +0 -14
  82. package/templates/commonproject/.claude/commands/bmad/bmm/agents/architect.md +0 -14
  83. package/templates/commonproject/.claude/commands/bmad/bmm/agents/dev.md +0 -14
  84. package/templates/commonproject/.claude/commands/bmad/bmm/agents/pm.md +0 -14
  85. package/templates/commonproject/.claude/commands/bmad/bmm/agents/quick-flow-solo-dev.md +0 -14
  86. package/templates/commonproject/.claude/commands/bmad/bmm/agents/sm.md +0 -14
  87. package/templates/commonproject/.claude/commands/bmad/bmm/agents/tea.md +0 -14
  88. package/templates/commonproject/.claude/commands/bmad/bmm/agents/tech-writer.md +0 -14
  89. package/templates/commonproject/.claude/commands/bmad/bmm/agents/ux-designer.md +0 -14
  90. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/check-implementation-readiness.md +0 -5
  91. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/code-review.md +0 -13
  92. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/correct-course.md +0 -13
  93. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-architecture.md +0 -5
  94. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-epics-and-stories.md +0 -5
  95. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-dataflow.md +0 -13
  96. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-diagram.md +0 -13
  97. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-flowchart.md +0 -13
  98. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-wireframe.md +0 -13
  99. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-product-brief.md +0 -5
  100. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-story.md +0 -13
  101. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-ux-design.md +0 -5
  102. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/dev-story.md +0 -13
  103. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/document-project.md +0 -13
  104. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/generate-project-context.md +0 -5
  105. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/prd.md +0 -5
  106. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/quick-dev.md +0 -5
  107. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/quick-spec.md +0 -5
  108. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/research.md +0 -5
  109. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/retrospective.md +0 -13
  110. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/sprint-planning.md +0 -13
  111. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/sprint-status.md +0 -13
  112. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-atdd.md +0 -13
  113. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-automate.md +0 -13
  114. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-ci.md +0 -13
  115. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-framework.md +0 -13
  116. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-nfr.md +0 -13
  117. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-test-design.md +0 -13
  118. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-test-review.md +0 -13
  119. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-trace.md +0 -13
  120. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/workflow-init.md +0 -13
  121. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/workflow-status.md +0 -13
  122. package/templates/commonproject/.claude/commands/bmad/cis/agents/brainstorming-coach.md +0 -14
  123. package/templates/commonproject/.claude/commands/bmad/cis/agents/creative-problem-solver.md +0 -14
  124. package/templates/commonproject/.claude/commands/bmad/cis/agents/design-thinking-coach.md +0 -14
  125. package/templates/commonproject/.claude/commands/bmad/cis/agents/innovation-strategist.md +0 -14
  126. package/templates/commonproject/.claude/commands/bmad/cis/agents/presentation-master.md +0 -14
  127. package/templates/commonproject/.claude/commands/bmad/cis/agents/storyteller.md +0 -14
  128. package/templates/commonproject/.claude/commands/bmad/cis/workflows/design-thinking.md +0 -13
  129. package/templates/commonproject/.claude/commands/bmad/cis/workflows/innovation-strategy.md +0 -13
  130. package/templates/commonproject/.claude/commands/bmad/cis/workflows/problem-solving.md +0 -13
  131. package/templates/commonproject/.claude/commands/bmad/cis/workflows/storytelling.md +0 -13
  132. package/templates/commonproject/.claude/commands/bmad/core/agents/bmad-master.md +0 -14
  133. package/templates/commonproject/.claude/commands/bmad/core/tasks/index-docs.md +0 -9
  134. package/templates/commonproject/.claude/commands/bmad/core/tasks/shard-doc.md +0 -9
  135. package/templates/commonproject/.claude/commands/bmad/core/workflows/brainstorming.md +0 -5
  136. package/templates/commonproject/.claude/commands/bmad/core/workflows/party-mode.md +0 -5
  137. package/templates/commonproject/.claude/commands/bmad/custom/workflows/ticket-lifecycle.md +0 -5
  138. package/templates/hermes-agent/docs/scrum-master-sentinel.md +0 -17
  139. package/templates/hermes-agent/template/.scripts/75-scrum-master.sh +0 -117
  140. package/templates/hermes-agent/template/.scripts/90-chain-scrum-master.sh +0 -45
@@ -1,19 +1,17 @@
1
1
  #!/usr/bin/env node
2
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
- }) : x)(function(x) {
5
- if (typeof require !== "undefined") return require.apply(this, arguments);
6
- throw Error('Dynamic require of "' + x + '" is not supported');
7
- });
8
2
 
9
3
  // src/mcp-server.ts
10
- import { resolve } from "node:path";
11
- import { existsSync as existsSync5, statSync } from "node:fs";
4
+ import { spawnSync as spawnSync5 } from "node:child_process";
5
+ import { existsSync as existsSync8, mkdirSync as mkdirSync6, statSync } from "node:fs";
6
+ import { basename as basename2, dirname as dirname7, join as join10, resolve as resolve2 } from "node:path";
7
+ import { fileURLToPath as fileURLToPath5 } from "node:url";
12
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
10
  import { z } from "zod";
15
11
 
16
12
  // src/commands/Command.ts
13
+ import { existsSync, writeFileSync, mkdirSync } from "fs";
14
+ import { join, dirname } from "path";
17
15
  var Command = class {
18
16
  context;
19
17
  constructor(context) {
@@ -26,30 +24,24 @@ var Command = class {
26
24
  return this.context.dryRun ? `[DRY RUN] ${message}` : message;
27
25
  }
28
26
  fileExists(filePath) {
29
- const { existsSync: existsSync6 } = __require("fs");
30
- const { join: join7 } = __require("path");
31
- const fullPath = join7(this.context.targetDir, filePath);
32
- return existsSync6(fullPath);
27
+ const fullPath = join(this.context.targetDir, filePath);
28
+ return existsSync(fullPath);
33
29
  }
34
30
  writeFile(filePath, content) {
35
31
  if (this.context.dryRun) {
36
32
  return;
37
33
  }
38
- const { writeFileSync: writeFileSync2, mkdirSync: mkdirSync3 } = __require("fs");
39
- const { join: join7, dirname: dirname4 } = __require("path");
40
- const fullPath = join7(this.context.targetDir, filePath);
41
- const dir = dirname4(fullPath);
42
- mkdirSync3(dir, { recursive: true });
43
- writeFileSync2(fullPath, content);
34
+ const fullPath = join(this.context.targetDir, filePath);
35
+ const dir = dirname(fullPath);
36
+ mkdirSync(dir, { recursive: true });
37
+ writeFileSync(fullPath, content);
44
38
  }
45
39
  createDirectory(dirPath) {
46
40
  if (this.context.dryRun) {
47
41
  return;
48
42
  }
49
- const { mkdirSync: mkdirSync3 } = __require("fs");
50
- const { join: join7 } = __require("path");
51
- const fullPath = join7(this.context.targetDir, dirPath);
52
- mkdirSync3(fullPath, { recursive: true });
43
+ const fullPath = join(this.context.targetDir, dirPath);
44
+ mkdirSync(fullPath, { recursive: true });
53
45
  }
54
46
  };
55
47
 
@@ -460,32 +452,32 @@ var NodeRecipe = class extends Recipe {
460
452
 
461
453
  // src/commands/hermes/EnsureTemplateConfig.ts
462
454
  import { homedir, platform } from "node:os";
463
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
464
- import { join, dirname } from "node:path";
455
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
456
+ import { join as join2, dirname as dirname2 } from "node:path";
465
457
  function resolveTemplateConfigPath() {
466
458
  const fromEnv = process.env.HERMES_TEMPLATE_CONFIG;
467
459
  if (fromEnv && fromEnv.trim()) return fromEnv.trim();
468
460
  const xdg = process.env.XDG_CONFIG_HOME?.trim();
469
- const base = xdg && xdg.length ? xdg : join(homedir(), ".config");
470
- return join(base, "hermes-agent-template", "config.toml");
461
+ const base = xdg && xdg.length ? xdg : join2(homedir(), ".config");
462
+ return join2(base, "hermes-agent-template", "config.toml");
471
463
  }
472
464
  function detectHermesBin(home) {
473
465
  const candidates = [
474
- join(home, "code", "hermes-agent", "venv", "bin", "hermes"),
475
- join(home, "code", "hermes-agent", ".venv", "bin", "hermes"),
476
- join(home, ".local", "bin", "hermes")
466
+ join2(home, "code", "hermes-agent", "venv", "bin", "hermes"),
467
+ join2(home, "code", "hermes-agent", ".venv", "bin", "hermes"),
468
+ join2(home, ".local", "bin", "hermes")
477
469
  ];
478
470
  for (const c of candidates) {
479
- if (existsSync(c)) return c;
471
+ if (existsSync2(c)) return c;
480
472
  }
481
473
  return candidates[0];
482
474
  }
483
475
  function renderHostConfig() {
484
476
  const home = homedir();
485
477
  const hermesBin = detectHermesBin(home);
486
- const hermesRepo = join(home, "code", "hermes-agent");
487
- const scaffoldDir = join(home, "code", "hermes-agent-template", "runtime-scaffold");
488
- const skillsDir = join(home, ".agents", "skills");
478
+ const hermesRepo = join2(home, "code", "hermes-agent");
479
+ const scaffoldDir = join2(home, "code", "hermes-agent-template", "runtime-scaffold");
480
+ const skillsDir = join2(home, ".agents", "skills");
489
481
  return `# hermes-agent-template \u2014 host configuration
490
482
  # Bootstrapped by \`pjangler config bootstrap\` for $HOME=${home} (platform=${platform()}).
491
483
  #
@@ -529,7 +521,7 @@ var EnsureTemplateConfig = class extends Command {
529
521
  const ctx = this.context;
530
522
  const force = ctx.forceConfig === true || process.env.PJANGLER_FORCE_CONFIG === "1";
531
523
  const path = resolveTemplateConfigPath();
532
- const exists = existsSync(path);
524
+ const exists = existsSync2(path);
533
525
  if (exists && !force) {
534
526
  console.log(`\u2713 Config present: ${path}`);
535
527
  return { success: true, message: "" };
@@ -539,8 +531,8 @@ var EnsureTemplateConfig = class extends Command {
539
531
  return { success: true, message: "" };
540
532
  }
541
533
  try {
542
- mkdirSync(dirname(path), { recursive: true });
543
- writeFileSync(path, renderHostConfig());
534
+ mkdirSync2(dirname2(path), { recursive: true });
535
+ writeFileSync2(path, renderHostConfig());
544
536
  } catch (err) {
545
537
  const msg = err instanceof Error ? err.message : String(err);
546
538
  return { success: false, message: `\u2717 Failed to write ${path}: ${msg}` };
@@ -552,7 +544,7 @@ var EnsureTemplateConfig = class extends Command {
552
544
  };
553
545
 
554
546
  // src/commands/hermes/PromptForAgentConfig.ts
555
- import { basename, join as join2 } from "node:path";
547
+ import { basename, join as join3 } from "node:path";
556
548
  import { readFileSync } from "node:fs";
557
549
  import * as p from "@clack/prompts";
558
550
 
@@ -560,12 +552,7 @@ import * as p from "@clack/prompts";
560
552
  var HERMES_AGENT_TEMPLATE = "gh:delorenj/hermes-agent-template";
561
553
  var SOUL_TONES = ["direct", "playful", "formal", "terse"];
562
554
  var ROLE_CHOICES = [
563
- { value: "pm", label: "Project Manager (pm)", hint: "triage, planning, ticket authorship" },
564
- {
565
- value: "scrum-master",
566
- label: "Scrum Master (Ticket Sentinel)",
567
- hint: "continuous ticket sentinel + autonomous delegated review"
568
- },
555
+ { value: "pm", label: "Project Manager (pm)", hint: "triage, planning, ticket authorship, board reconciliation" },
569
556
  { value: "dev", label: "Developer (dev)", hint: "implements tickets" },
570
557
  { value: "review", label: "Reviewer (review)", hint: "adversarial code review" },
571
558
  { value: "ops", label: "Ops (ops)", hint: "deploy / infra" },
@@ -586,7 +573,7 @@ function deriveProfileName(repo, role) {
586
573
  // src/commands/hermes/PromptForAgentConfig.ts
587
574
  function detectTicketProvider(targetDir) {
588
575
  try {
589
- const t = JSON.parse(readFileSync(join2(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
576
+ const t = JSON.parse(readFileSync(join3(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
590
577
  return t === "plane" || t === "linear" || t === "trello" ? t : void 0;
591
578
  } catch {
592
579
  return void 0;
@@ -605,7 +592,6 @@ var PromptForAgentConfig = class extends Command {
605
592
  ctx.modelProvider ??= "";
606
593
  ctx.modelName ??= "";
607
594
  ctx.ticketProvider ??= detectTicketProvider(ctx.targetDir) ?? "plane";
608
- ctx.withScrumMaster ??= false;
609
595
  ctx.skipTelegram ??= true;
610
596
  ctx.skipEmail ??= true;
611
597
  ctx.agentId = deriveAgentId(ctx.targetRepo, ctx.role);
@@ -651,14 +637,6 @@ var PromptForAgentConfig = class extends Command {
651
637
  if (p.isCancel(answer)) return this.cancelled();
652
638
  ctx.ticketProvider = answer;
653
639
  }
654
- if (ctx.role === "pm" && ctx.withScrumMaster === void 0) {
655
- const answer = await p.confirm({
656
- message: "Also provision the paired Scrum Master (Ticket Sentinel) for this repo?",
657
- initialValue: true
658
- });
659
- if (p.isCancel(answer)) return this.cancelled();
660
- ctx.withScrumMaster = answer === true;
661
- }
662
640
  if (!ctx.agentPurpose) {
663
641
  const answer = await p.text({
664
642
  message: "One-line purpose",
@@ -731,21 +709,21 @@ var PromptForAgentConfig = class extends Command {
731
709
  // src/commands/hermes/RunCopierTemplate.ts
732
710
  import { spawnSync } from "node:child_process";
733
711
  import { homedir as homedir2 } from "node:os";
734
- import { join as join3, dirname as dirname2 } from "node:path";
735
- import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "node:fs";
712
+ import { join as join4, dirname as dirname3 } from "node:path";
713
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3 } from "node:fs";
736
714
  import { fileURLToPath } from "node:url";
737
715
  import * as p2 from "@clack/prompts";
738
716
  function resolveVendoredTemplate(name) {
739
717
  let dir;
740
718
  try {
741
- dir = dirname2(fileURLToPath(import.meta.url));
719
+ dir = dirname3(fileURLToPath(import.meta.url));
742
720
  } catch {
743
721
  return void 0;
744
722
  }
745
723
  for (let i = 0; i < 8; i++) {
746
- const candidate = join3(dir, "templates", name);
747
- if (existsSync2(join3(candidate, "copier.yml"))) return candidate;
748
- const parent = dirname2(dir);
724
+ const candidate = join4(dir, "templates", name);
725
+ if (existsSync3(join4(candidate, "copier.yml"))) return candidate;
726
+ const parent = dirname3(dir);
749
727
  if (parent === dir) break;
750
728
  dir = parent;
751
729
  }
@@ -757,14 +735,13 @@ var RunCopierTemplate = class extends Command {
757
735
  const { targetRepo, role, agentPurpose, soulTone, modelProvider, modelName } = ctx;
758
736
  const ticketProvider = ctx.ticketProvider ?? "plane";
759
737
  const profileName = ctx.profileName ?? (targetRepo && role ? deriveProfileName(targetRepo, role) : void 0);
760
- const withScrumMaster = role === "pm" && ctx.withScrumMaster === true;
761
738
  if (!targetRepo || !role) {
762
739
  return {
763
740
  success: false,
764
741
  message: "PromptForAgentConfig must run before RunCopierTemplate (targetRepo/role unset)"
765
742
  };
766
743
  }
767
- const roleDir = join3(ctx.targetDir, "agents", "hermes", role);
744
+ const roleDir = join4(ctx.targetDir, "agents", "hermes", role);
768
745
  ctx.roleDir = roleDir;
769
746
  ctx.runtimeRepo = `delorenj/agent-hm-${targetRepo}-${role}`;
770
747
  const which = spawnSync("which", ["copier"], { encoding: "utf8" });
@@ -774,7 +751,7 @@ var RunCopierTemplate = class extends Command {
774
751
  message: "\u2717 copier not found on PATH. Install with: `uv tool install copier` or `pip install copier`"
775
752
  };
776
753
  }
777
- if (existsSync2(join3(roleDir, "role.yaml")) && !ctx.force) {
754
+ if (existsSync3(join4(roleDir, "role.yaml")) && !ctx.force) {
778
755
  if (ctx.yes) {
779
756
  ctx.force = true;
780
757
  } else {
@@ -801,9 +778,9 @@ var RunCopierTemplate = class extends Command {
801
778
  SKIP_BLOODBANK: ctx.skipBloodbank ? "1" : "0",
802
779
  SKIP_SYSTEMD: ctx.skipSystemd ? "1" : "0"
803
780
  };
804
- const LOCAL_TEMPLATE = join3(homedir2(), "code", "hermes-agent-template");
781
+ const LOCAL_TEMPLATE = join4(homedir2(), "code", "hermes-agent-template");
805
782
  const vendored = resolveVendoredTemplate("hermes-agent");
806
- const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync2(join3(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
783
+ const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync3(join4(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
807
784
  const args = [
808
785
  "copy",
809
786
  templateSrc,
@@ -824,8 +801,6 @@ var RunCopierTemplate = class extends Command {
824
801
  `soul_tone=${soulTone ?? "direct"}`,
825
802
  "--data",
826
803
  `ticket_provider=${ticketProvider}`,
827
- "--data",
828
- `with_scrum_master=${withScrumMaster}`,
829
804
  "--trust",
830
805
  "--vcs-ref=HEAD"
831
806
  ];
@@ -836,7 +811,7 @@ var RunCopierTemplate = class extends Command {
836
811
  message: this.formatMessage(`Would run: copier ${args.join(" ")}`)
837
812
  };
838
813
  }
839
- mkdirSync2(join3(ctx.targetDir, "agents", "hermes"), { recursive: true });
814
+ mkdirSync3(join4(ctx.targetDir, "agents", "hermes"), { recursive: true });
840
815
  const spinner4 = p2.spinner();
841
816
  spinner4.start(`Running copier copy (target: agents/hermes/${role})`);
842
817
  const result = spawnSync("copier", args, {
@@ -861,8 +836,8 @@ var RunCopierTemplate = class extends Command {
861
836
 
862
837
  // src/commands/hermes/WireTelegram.ts
863
838
  import { spawnSync as spawnSync2 } from "node:child_process";
864
- import { join as join4 } from "node:path";
865
- import { existsSync as existsSync3, unlinkSync } from "node:fs";
839
+ import { join as join5 } from "node:path";
840
+ import { existsSync as existsSync4, unlinkSync } from "node:fs";
866
841
  import * as p3 from "@clack/prompts";
867
842
  var WireTelegram = class extends Command {
868
843
  async invoke() {
@@ -950,15 +925,15 @@ var WireTelegram = class extends Command {
950
925
  if (p3.isCancel(allowedAnswer)) {
951
926
  return { success: false, message: "\u2717 Aborted; Telegram step deferred." };
952
927
  }
953
- const script = join4(roleDir, ".scripts", "30-telegram.sh");
954
- if (!existsSync3(script)) {
928
+ const script = join5(roleDir, ".scripts", "30-telegram.sh");
929
+ if (!existsSync4(script)) {
955
930
  return {
956
931
  success: false,
957
932
  message: `\u2717 ${script} not found. Did copier finish? Re-run with --skip-runtime-repo=0 if you skipped it.`
958
933
  };
959
934
  }
960
- const marker = join4(roleDir, ".scripts", ".done-30-telegram");
961
- if (existsSync3(marker)) unlinkSync(marker);
935
+ const marker = join5(roleDir, ".scripts", ".done-30-telegram");
936
+ if (existsSync4(marker)) unlinkSync(marker);
962
937
  const spinner4 = p3.spinner();
963
938
  spinner4.start("Verifying token + wiring profile");
964
939
  const result = spawnSync2("bash", [script], {
@@ -985,8 +960,8 @@ function cap(s) {
985
960
 
986
961
  // src/commands/hermes/WireEmail.ts
987
962
  import { spawnSync as spawnSync3 } from "node:child_process";
988
- import { join as join5 } from "node:path";
989
- import { existsSync as existsSync4, unlinkSync as unlinkSync2 } from "node:fs";
963
+ import { join as join6 } from "node:path";
964
+ import { existsSync as existsSync5, unlinkSync as unlinkSync2 } from "node:fs";
990
965
  import * as p4 from "@clack/prompts";
991
966
  var WireEmail = class extends Command {
992
967
  async invoke() {
@@ -1001,8 +976,8 @@ var WireEmail = class extends Command {
1001
976
  if (!targetRepo || !role || !roleDir) {
1002
977
  return { success: false, message: "Cannot wire email: missing target_repo/role/roleDir" };
1003
978
  }
1004
- const script = join5(roleDir, ".scripts", "50-email.sh");
1005
- if (!existsSync4(script)) {
979
+ const script = join6(roleDir, ".scripts", "50-email.sh");
980
+ if (!existsSync5(script)) {
1006
981
  return { success: false, message: `\u2717 ${script} not found` };
1007
982
  }
1008
983
  let token = process.env.CF_EMAIL_ROUTING_TOKEN;
@@ -1064,8 +1039,8 @@ var WireEmail = class extends Command {
1064
1039
  }
1065
1040
  }
1066
1041
  }
1067
- const marker = join5(roleDir, ".scripts", ".done-50-email");
1068
- if (existsSync4(marker)) unlinkSync2(marker);
1042
+ const marker = join6(roleDir, ".scripts", ".done-50-email");
1043
+ if (existsSync5(marker)) unlinkSync2(marker);
1069
1044
  const spinner4 = p4.spinner();
1070
1045
  spinner4.start("Creating Cloudflare Email Routing rule");
1071
1046
  const result = spawnSync3("bash", [script], {
@@ -1094,7 +1069,7 @@ var PrintHermesSummary = class extends Command {
1094
1069
  const email = `${targetRepo}-${role}@delo.sh`;
1095
1070
  const gw = `hermes-${agentId}-gateway.service`;
1096
1071
  const csm = `hermes-${agentId}-consumer.service`;
1097
- const ckpt = `hermes-${agentId}-checkpoint.timer`;
1072
+ const hb = `hermes-${agentId}-heartbeat.timer`;
1098
1073
  const lines = [];
1099
1074
  lines.push(`agent_id ${agentId}`);
1100
1075
  lines.push(`role dir ${ctx.roleDir}`);
@@ -1104,7 +1079,7 @@ var PrintHermesSummary = class extends Command {
1104
1079
  lines.push("");
1105
1080
  lines.push("Start daemons:");
1106
1081
  lines.push(` systemctl --user start ${csm}`);
1107
- lines.push(` systemctl --user start ${ckpt}`);
1082
+ lines.push(` systemctl --user start ${hb}`);
1108
1083
  if (!skipTelegram) {
1109
1084
  lines.push(` systemctl --user start ${gw}`);
1110
1085
  } else {
@@ -1151,6 +1126,193 @@ var HermesAgentRecipe = class extends Recipe {
1151
1126
  }
1152
1127
  };
1153
1128
 
1129
+ // src/commands/AgentHooksCommands.ts
1130
+ import { homedir as homedir3 } from "node:os";
1131
+ import { join as join7, dirname as dirname4 } from "node:path";
1132
+ import { existsSync as existsSync6, cpSync, mkdirSync as mkdirSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "node:fs";
1133
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1134
+ function resolveTemplateRoot() {
1135
+ const candidates = [];
1136
+ if (process.env.PJANGLER_COMMONPROJECT_TEMPLATE) {
1137
+ candidates.push(process.env.PJANGLER_COMMONPROJECT_TEMPLATE);
1138
+ }
1139
+ try {
1140
+ let dir = dirname4(fileURLToPath2(import.meta.url));
1141
+ for (let i = 0; i < 8; i++) {
1142
+ candidates.push(join7(dir, "templates", "commonproject", "template"));
1143
+ const parent = dirname4(dir);
1144
+ if (parent === dir) break;
1145
+ dir = parent;
1146
+ }
1147
+ } catch {
1148
+ }
1149
+ candidates.push(join7(homedir3(), "code", "pjangler", "templates", "commonproject", "template"));
1150
+ for (const c of candidates) {
1151
+ if (existsSync6(join7(c, ".agents", "hooks", "hooks.master.json"))) return c;
1152
+ }
1153
+ throw new Error(
1154
+ "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
1155
+ );
1156
+ }
1157
+ var CopyAgentHooksTree = class extends Command {
1158
+ async invoke() {
1159
+ let templateRoot;
1160
+ try {
1161
+ templateRoot = resolveTemplateRoot();
1162
+ } catch (e) {
1163
+ return { success: false, message: `\u26A0\uFE0F ${e.message}` };
1164
+ }
1165
+ const items = [
1166
+ { rel: ".agents/hooks", dir: true },
1167
+ { rel: ".agents/local.example.json", dir: false },
1168
+ { rel: ".mise/scripts/link-project-skills-to-clis.sh", dir: false },
1169
+ { rel: ".mise/scripts/unlink-project-skills-from-clis.sh", dir: false },
1170
+ { rel: ".mise/scripts/hindsight-setup.sh", dir: false }
1171
+ ];
1172
+ const created = [];
1173
+ const skipped = [];
1174
+ for (const { rel, dir } of items) {
1175
+ const src = join7(templateRoot, rel);
1176
+ const dest = join7(this.context.targetDir, rel);
1177
+ if (!existsSync6(src)) continue;
1178
+ if (existsSync6(dest) && !this.context.force) {
1179
+ skipped.push(rel);
1180
+ continue;
1181
+ }
1182
+ if (!this.context.dryRun) {
1183
+ mkdirSync4(dirname4(dest), { recursive: true });
1184
+ cpSync(src, dest, { recursive: dir, force: true });
1185
+ }
1186
+ created.push(rel);
1187
+ }
1188
+ const verb = this.context.dryRun ? "Would copy" : "Copied";
1189
+ const tail = skipped.length ? ` (${skipped.length} already present \u2014 use --force to overwrite)` : "";
1190
+ return {
1191
+ success: created.length > 0,
1192
+ message: this.formatMessage(`\u2705 ${verb} ${created.length} agent-hooks path(s)${tail}`)
1193
+ };
1194
+ }
1195
+ };
1196
+ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
1197
+ static MARKER = "# pjangler:agent-hooks";
1198
+ static CR = "{{config_root}}";
1199
+ // mise's own runtime var — emitted literally
1200
+ async invoke() {
1201
+ const misePath = join7(this.context.targetDir, "mise.toml");
1202
+ if (!existsSync6(misePath)) {
1203
+ return {
1204
+ success: false,
1205
+ message: "\u26A0\uFE0F No mise.toml found \u2014 run `pjangler init mise` first, then re-run."
1206
+ };
1207
+ }
1208
+ let content = readFileSync2(misePath, "utf8");
1209
+ if (content.includes(_WireMiseAgentHooks.MARKER)) {
1210
+ return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for agent-hooks") };
1211
+ }
1212
+ const cr = _WireMiseAgentHooks.CR;
1213
+ const enterAdds = [
1214
+ ` "${cr}/.mise/scripts/link-project-skills-to-clis.sh",`,
1215
+ ` "${cr}/.agents/hooks/sync.py --install --quiet",`
1216
+ ].join("\n");
1217
+ const leaveBlock = [
1218
+ "leave = [",
1219
+ ` "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",`,
1220
+ ` "${cr}/.agents/hooks/sync.py --uninstall --quiet",`,
1221
+ "]"
1222
+ ].join("\n");
1223
+ let wiredHooks = false;
1224
+ const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
1225
+ if (enterRe.test(content)) {
1226
+ content = content.replace(enterRe, (_m, head, close) => {
1227
+ const sep = /[,[]\s*$/.test(head) ? "" : ",";
1228
+ return `${head}${sep}
1229
+ ${enterAdds}${close}`;
1230
+ });
1231
+ const leaveRe = /(leave\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
1232
+ if (leaveRe.test(content)) {
1233
+ content = content.replace(leaveRe, (_m, head, close) => {
1234
+ const sep = /[,[]\s*$/.test(head) ? "" : ",";
1235
+ return `${head}${sep}
1236
+ "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",
1237
+ "${cr}/.agents/hooks/sync.py --uninstall --quiet",${close}`;
1238
+ });
1239
+ } else {
1240
+ content = content.replace(enterRe, (m) => `${m}
1241
+ ${leaveBlock}`);
1242
+ }
1243
+ wiredHooks = true;
1244
+ }
1245
+ const appended = [
1246
+ "",
1247
+ _WireMiseAgentHooks.MARKER + " (generated \u2014 see .agents/hooks/README.md)",
1248
+ "[[watch_files]]",
1249
+ 'patterns = [".agents/hooks/hooks.master.json"]',
1250
+ 'task = "hooks-sync"',
1251
+ "",
1252
+ "[tasks.hooks-sync]",
1253
+ 'description = "Fan out hooks.master.json to each agent CLI (claude/codex/kimi/hermes)"',
1254
+ `run = "${cr}/.agents/hooks/sync.py --install"`,
1255
+ "",
1256
+ "[tasks.hooks-check]",
1257
+ 'description = "Drift gate: verify generated hook configs match hooks.master.json"',
1258
+ `run = "${cr}/.agents/hooks/sync.py --check"`,
1259
+ "",
1260
+ "[tasks.hooks-uninstall]",
1261
+ 'description = "Remove per-user agent-hook injections (codex/kimi/hermes)"',
1262
+ `run = "${cr}/.agents/hooks/sync.py --uninstall"`,
1263
+ "",
1264
+ "[tasks.link-project-skills-to-clis]",
1265
+ 'description = "Fan .agents/skills out to each agent CLI (honors local.json)"',
1266
+ `run = "${cr}/.mise/scripts/link-project-skills-to-clis.sh"`,
1267
+ "",
1268
+ "[tasks.unlink-project-skills-from-clis]",
1269
+ 'description = "Remove project skill symlinks from shared per-CLI dirs"',
1270
+ `run = "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh"`,
1271
+ "",
1272
+ "[tasks.skills-relink]",
1273
+ 'description = "Re-fan the project skill set to all CLIs"',
1274
+ `run = "${cr}/.mise/scripts/link-project-skills-to-clis.sh"`,
1275
+ "",
1276
+ "[tasks.hindsight-setup]",
1277
+ `description = "Provision this dev's shared project Hindsight key from 1Password into .env"`,
1278
+ `run = "${cr}/.mise/scripts/hindsight-setup.sh"`,
1279
+ "",
1280
+ _WireMiseAgentHooks.MARKER + ":end",
1281
+ ""
1282
+ ].join("\n");
1283
+ content = content.replace(/\n*$/, "\n") + appended;
1284
+ if (!this.context.dryRun) writeFileSync3(misePath, content);
1285
+ if (wiredHooks) {
1286
+ return { success: true, message: this.formatMessage("\u2705 Wired mise.toml ([hooks] enter/leave + tasks)") };
1287
+ }
1288
+ return {
1289
+ success: true,
1290
+ message: this.formatMessage(
1291
+ `\u2705 Added agent-hooks tasks to mise.toml.
1292
+ \u26A0\uFE0F Could not find a [hooks].enter array to extend \u2014 add these to your [hooks] block manually:
1293
+ enter += "${cr}/.mise/scripts/link-project-skills-to-clis.sh", "${cr}/.agents/hooks/sync.py --install --quiet"
1294
+ leave += "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh", "${cr}/.agents/hooks/sync.py --uninstall --quiet"`
1295
+ )
1296
+ };
1297
+ }
1298
+ };
1299
+
1300
+ // src/recipes/AgentHooksRecipe.ts
1301
+ var AgentHooksRecipe = class extends Recipe {
1302
+ constructor(context) {
1303
+ super(context);
1304
+ this.addIngredient(CopyAgentHooksTree).addIngredient(WireMiseAgentHooks);
1305
+ }
1306
+ printNextSteps() {
1307
+ console.log("\u{1FA9D} Agent-hooks layer installed!");
1308
+ console.log(" Next steps:");
1309
+ console.log(" 1. mise run hooks-sync # generate .claude/settings.json + inject codex/kimi/hermes");
1310
+ console.log(" 2. git add .claude/settings.json .agents/hooks && commit (codex/kimi/hermes are per-dev)");
1311
+ console.log(" 3. mise run hindsight-setup # set HINDSIGHT_OP_KEY_REF to your 1Password item first");
1312
+ console.log(` 4. If you run a global agent system: echo '{"skills":{"defer_to_global":true}}' > .agents/local.json`);
1313
+ }
1314
+ };
1315
+
1154
1316
  // src/utils/registry.ts
1155
1317
  var RECIPE_REGISTRY = {
1156
1318
  mise: {
@@ -1184,9 +1346,27 @@ var RECIPE_REGISTRY = {
1184
1346
  "WireEmail",
1185
1347
  "PrintHermesSummary"
1186
1348
  ]
1349
+ },
1350
+ "agent-hooks": {
1351
+ name: "agent-hooks",
1352
+ description: "Retrofit the project-scoped agent-hooks + skill fan-out layer (Claude/Codex/Kimi/Hermes hooks via mise enter/leave)",
1353
+ class: AgentHooksRecipe,
1354
+ commands: ["CopyAgentHooksTree", "WireMiseAgentHooks"]
1187
1355
  }
1188
1356
  };
1189
1357
  var COMMAND_REGISTRY = {
1358
+ CopyAgentHooksTree: {
1359
+ name: "CopyAgentHooksTree",
1360
+ description: "Copy the generic agent-hooks tree (hooks SSOT + sync engine + scripts) from the CommonProject template",
1361
+ group: "agent-hooks",
1362
+ class: CopyAgentHooksTree
1363
+ },
1364
+ WireMiseAgentHooks: {
1365
+ name: "WireMiseAgentHooks",
1366
+ description: "Merge agent-hooks enter/leave + tasks into an existing mise.toml (idempotent)",
1367
+ group: "agent-hooks",
1368
+ class: WireMiseAgentHooks
1369
+ },
1190
1370
  AddDockerfile: {
1191
1371
  name: "AddDockerfile",
1192
1372
  description: "Create Dockerfile for containerization",
@@ -1249,18 +1429,18 @@ function createRecipe(name, context) {
1249
1429
  }
1250
1430
 
1251
1431
  // src/utils/version.ts
1252
- import { readFileSync as readFileSync2 } from "node:fs";
1253
- import { dirname as dirname3, join as join6 } from "node:path";
1254
- import { fileURLToPath as fileURLToPath2 } from "node:url";
1432
+ import { readFileSync as readFileSync3 } from "node:fs";
1433
+ import { dirname as dirname5, join as join8 } from "node:path";
1434
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
1255
1435
  var PJANGLER_VERSION = (() => {
1256
1436
  try {
1257
- let dir = dirname3(fileURLToPath2(import.meta.url));
1437
+ let dir = dirname5(fileURLToPath3(import.meta.url));
1258
1438
  for (let i = 0; i < 4; i++) {
1259
1439
  try {
1260
- const raw = readFileSync2(join6(dir, "package.json"), "utf8");
1440
+ const raw = readFileSync3(join8(dir, "package.json"), "utf8");
1261
1441
  return JSON.parse(raw).version ?? "0.0.0";
1262
1442
  } catch {
1263
- const parent = dirname3(dir);
1443
+ const parent = dirname5(dir);
1264
1444
  if (parent === dir) break;
1265
1445
  dir = parent;
1266
1446
  }
@@ -1270,14 +1450,1150 @@ var PJANGLER_VERSION = (() => {
1270
1450
  return "0.0.0";
1271
1451
  })();
1272
1452
 
1453
+ // src/parity/index.ts
1454
+ import { existsSync as existsSync7, lstatSync, mkdirSync as mkdirSync5, readFileSync as readFileSync4, readlinkSync, readdirSync, renameSync, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4, chmodSync, copyFileSync } from "node:fs";
1455
+ import { dirname as dirname6, join as join9, relative, resolve } from "node:path";
1456
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
1457
+ import { homedir as homedir4 } from "node:os";
1458
+ import { spawnSync as spawnSync4 } from "node:child_process";
1459
+ var LINK_AGENTFILES_BLOCK = `# This block will handle the linking of
1460
+ # agent files to the main AGENTS.md file.
1461
+ #
1462
+ # TODO: Ensure this works for all levels of nesting.
1463
+ # i.e. All linked agent files MUST be siblings at
1464
+ # any given level of nesting.
1465
+ [hooks]
1466
+ enter = [
1467
+ "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1468
+ "op inject -i .env.op > .env",
1469
+ ]
1470
+
1471
+ [[watch_files]]
1472
+ patterns = ["AGENTS.md"]
1473
+ task = "link-agentfiles"
1474
+
1475
+ [tasks.link-agentfiles]
1476
+ description = "Symlink all agent files to AGENTS.md"
1477
+ run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
1478
+ var VERSIONING_BLOCK = `# >>> mise-versioning >>> (managed block \u2014 do not edit by hand; re-run init to update)
1479
+ [tasks."version"]
1480
+ description = "Print the current version (vX.Y.Z)"
1481
+ run = "{{config_root}}/.mise/scripts/versioning.sh current"
1482
+
1483
+ [tasks."version:bump"]
1484
+ description = "Bump patch version: vX.Y.Z -> vX.Y.(Z+1)"
1485
+ alias = "version:bump-patch"
1486
+ run = "{{config_root}}/.mise/scripts/versioning.sh bump patch"
1487
+
1488
+ [tasks."version:bump-minor"]
1489
+ description = "Bump minor version: vX.Y.Z -> vX.(Y+1).0"
1490
+ run = "{{config_root}}/.mise/scripts/versioning.sh bump minor"
1491
+
1492
+ [tasks."version:bump-major"]
1493
+ description = "Bump major version: vX.Y.Z -> v(X+1).0.0"
1494
+ run = "{{config_root}}/.mise/scripts/versioning.sh bump major"
1495
+
1496
+ [tasks."version:check"]
1497
+ description = "Verify every versioned file is in parity"
1498
+ run = "{{config_root}}/.mise/scripts/versioning.sh check"
1499
+
1500
+ [tasks."version:sync"]
1501
+ description = "Force every versioned file up to the highest version"
1502
+ run = "{{config_root}}/.mise/scripts/versioning.sh sync"
1503
+ # <<< mise-versioning <<<`;
1504
+ function resolvePjanglerRoot() {
1505
+ let dir = dirname6(fileURLToPath4(import.meta.url));
1506
+ while (dir !== dirname6(dir)) {
1507
+ if (existsSync7(join9(dir, "package.json")) && existsSync7(join9(dir, "templates", "commonproject", "copier.yml"))) {
1508
+ return dir;
1509
+ }
1510
+ dir = dirname6(dir);
1511
+ }
1512
+ throw new Error("Unable to resolve pjangler root");
1513
+ }
1514
+ function normalizeNewlines(value) {
1515
+ return value.replace(/\r\n/g, "\n");
1516
+ }
1517
+ function readText(path) {
1518
+ return normalizeNewlines(readFileSync4(path, "utf8"));
1519
+ }
1520
+ function safeReadText(path) {
1521
+ return existsSync7(path) ? readText(path) : null;
1522
+ }
1523
+ function ensureParent(path) {
1524
+ mkdirSync5(dirname6(path), { recursive: true });
1525
+ }
1526
+ function writeText(path, content) {
1527
+ ensureParent(path);
1528
+ writeFileSync4(path, content);
1529
+ }
1530
+ function tryParseJson(text3) {
1531
+ if (!text3) return null;
1532
+ try {
1533
+ return JSON.parse(text3);
1534
+ } catch {
1535
+ return null;
1536
+ }
1537
+ }
1538
+ function slugifyRepoName(name) {
1539
+ return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
1540
+ }
1541
+ function titleCaseSlug(slug) {
1542
+ return slug.split(/[-_]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
1543
+ }
1544
+ function readSymlinkTarget(path) {
1545
+ if (!existsSync7(path)) return null;
1546
+ try {
1547
+ return readlinkSync(path);
1548
+ } catch {
1549
+ return null;
1550
+ }
1551
+ }
1552
+ function ensureSymlink(path, target, dryRun) {
1553
+ if (existsSync7(path)) {
1554
+ const stat = lstatSync(path);
1555
+ if (stat.isSymbolicLink()) {
1556
+ const current = readSymlinkTarget(path);
1557
+ if (current === target) return { changed: false };
1558
+ if (!dryRun) {
1559
+ unlinkSync3(path);
1560
+ symlinkSync(target, path);
1561
+ }
1562
+ return { changed: true };
1563
+ }
1564
+ return { changed: false, blocked: `${relative(process.cwd(), path) || path} exists and is not a symlink` };
1565
+ }
1566
+ if (!dryRun) symlinkSync(target, path);
1567
+ return { changed: true };
1568
+ }
1569
+ function bootstrapAgentsFile(repoRoot, dryRun) {
1570
+ const agentsPath = join9(repoRoot, "AGENTS.md");
1571
+ if (existsSync7(agentsPath)) return { changedFiles: [], details: [] };
1572
+ for (const file of ["CLAUDE.md", "GEMINI.md"]) {
1573
+ const source = join9(repoRoot, file);
1574
+ if (!existsSync7(source)) continue;
1575
+ const stat = lstatSync(source);
1576
+ if (stat.isSymbolicLink()) continue;
1577
+ if (stat.isFile()) {
1578
+ if (!dryRun) renameSync(source, agentsPath);
1579
+ return { changedFiles: [agentsPath], details: [`Moved ${file} to AGENTS.md before wiring agent-file symlinks`] };
1580
+ }
1581
+ return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
1582
+ }
1583
+ const readmePath = join9(repoRoot, "README.md");
1584
+ if (existsSync7(readmePath)) {
1585
+ const stat = lstatSync(readmePath);
1586
+ if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
1587
+ if (!dryRun) copyFileSync(readmePath, agentsPath);
1588
+ return { changedFiles: [agentsPath], details: ["Copied README.md to AGENTS.md before wiring agent-file symlinks"] };
1589
+ }
1590
+ return { changedFiles: [], details: [], blocked: "AGENTS.md missing and no CLAUDE.md, GEMINI.md, or README.md source exists" };
1591
+ }
1592
+ function yamlGet(text3, keyPath) {
1593
+ const parts = keyPath.split(".");
1594
+ const lines = text3.split("\n");
1595
+ let start = 0;
1596
+ let indent = 0;
1597
+ for (let idx = 0; idx < parts.length; idx += 1) {
1598
+ const key = parts[idx];
1599
+ let found = false;
1600
+ for (let i = start; i < lines.length; i += 1) {
1601
+ const line = lines[i];
1602
+ if (!line.trim() || line.trim().startsWith("#")) continue;
1603
+ const match = line.match(/^(\s*)([^:#]+):\s*(.*)$/);
1604
+ if (!match) continue;
1605
+ const currentIndent = match[1].length;
1606
+ const currentKey = match[2].trim();
1607
+ const rest = match[3].trim();
1608
+ if (idx > 0 && currentIndent < indent) break;
1609
+ if (currentIndent !== indent || currentKey !== key) continue;
1610
+ found = true;
1611
+ if (idx === parts.length - 1) {
1612
+ return rest.replace(/^['"]|['"]$/g, "").trim();
1613
+ }
1614
+ start = i + 1;
1615
+ indent = currentIndent + 2;
1616
+ break;
1617
+ }
1618
+ if (!found) return "";
1619
+ }
1620
+ return "";
1621
+ }
1622
+ function discoverRoles(repoRoot) {
1623
+ const rolesDir = join9(repoRoot, "agents", "hermes");
1624
+ if (!existsSync7(rolesDir)) return [];
1625
+ return readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
1626
+ const roleDir = join9(rolesDir, entry.name);
1627
+ const roleYamlPath = join9(roleDir, "role.yaml");
1628
+ if (!existsSync7(roleYamlPath)) return null;
1629
+ const text3 = readText(roleYamlPath);
1630
+ const runtimeRepoRaw = yamlGet(text3, "runtime.github_repo");
1631
+ return {
1632
+ role: yamlGet(text3, "role") || entry.name,
1633
+ roleDir,
1634
+ roleYamlPath,
1635
+ repo: yamlGet(text3, "repo"),
1636
+ agentId: yamlGet(text3, "agent_id"),
1637
+ profileName: yamlGet(text3, "profile") || yamlGet(text3, "agent_id"),
1638
+ displayName: yamlGet(text3, "display_name"),
1639
+ purpose: yamlGet(text3, "purpose"),
1640
+ botHandle: yamlGet(text3, "telegram.bot_username"),
1641
+ runtimeRepo: runtimeRepoRaw.includes("/") ? runtimeRepoRaw.split("/").slice(-1)[0] ?? runtimeRepoRaw : runtimeRepoRaw,
1642
+ runtimeOwner: yamlGet(text3, "runtime.github_owner"),
1643
+ planeWorkspace: yamlGet(text3, "ticket_provider.workspace") || yamlGet(text3, "plane.workspace"),
1644
+ ticketProviderName: yamlGet(text3, "ticket_provider.name"),
1645
+ ticketProviderBoardId: yamlGet(text3, "ticket_provider.board_id"),
1646
+ ticketProviderBoardUrl: yamlGet(text3, "ticket_provider.board_url"),
1647
+ ticketProviderIdentifier: yamlGet(text3, "plane.identifier")
1648
+ };
1649
+ }).filter((value) => Boolean(value));
1650
+ }
1651
+ function registryPath(homeDir) {
1652
+ return join9(homeDir, ".hermes", "agents-registry.yaml");
1653
+ }
1654
+ function systemctlUser(args) {
1655
+ const result = spawnSync4("systemctl", ["--user", ...args], { encoding: "utf8" });
1656
+ return {
1657
+ ok: result.status === 0,
1658
+ stdout: result.stdout.trim(),
1659
+ stderr: result.stderr.trim()
1660
+ };
1661
+ }
1662
+ function templateScript(ctx, name) {
1663
+ const source = join9(ctx.pjanglerRoot, ".mise", "scripts", name);
1664
+ return existsSync7(source) ? readText(source) : void 0;
1665
+ }
1666
+ function templateVersioningScript(ctx) {
1667
+ return templateScript(ctx, "versioning.sh");
1668
+ }
1669
+ function templateLinkAgentfilesScript(ctx) {
1670
+ return templateScript(ctx, "link-agentfiles.sh");
1671
+ }
1672
+ function templateVersionFilesConf(ctx, repoRoot) {
1673
+ const packageJson = join9(repoRoot, "package.json");
1674
+ return existsSync7(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";
1675
+ }
1676
+ function replaceOrAppendManagedBlock(text3, startMarker, block, beforePattern) {
1677
+ if (startMarker.test(text3)) {
1678
+ return text3.replace(/# >>> mise-versioning >>>[\s\S]*?# <<< mise-versioning <<</, block);
1679
+ }
1680
+ if (beforePattern) {
1681
+ const match = text3.match(beforePattern);
1682
+ if (match && typeof match.index === "number") {
1683
+ return `${text3.slice(0, match.index).replace(/\s*$/, "\n\n")}${block}
1684
+
1685
+ ${text3.slice(match.index)}`;
1686
+ }
1687
+ }
1688
+ return `${text3.replace(/\s*$/, "")}
1689
+
1690
+ ${block}
1691
+ `;
1692
+ }
1693
+ var BASE_MISE_PATH_ENTRIES = [".mise/scripts", "agents/hermes/pm"];
1694
+ var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/hermes"];
1695
+ function requiredMisePathEntries(ctx) {
1696
+ const required = [...BASE_MISE_PATH_ENTRIES];
1697
+ for (const candidate of CONDITIONAL_HERMES_PATHS) {
1698
+ if (existsSync7(join9(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
1699
+ }
1700
+ return required;
1701
+ }
1702
+ function upsertMisePath(text3, required = BASE_MISE_PATH_ENTRIES) {
1703
+ const render = (values) => `_.path = [${values.map((value) => JSON.stringify(value)).join(", ")}]`;
1704
+ const envMatch = text3.match(/(^|\n)(\[env\][\s\S]*?)(?=\n\[[^\]]+\]|$)/);
1705
+ if (!envMatch || typeof envMatch.index !== "number") {
1706
+ return `[env]
1707
+ ${render(required)}
1708
+
1709
+ ${text3.replace(/^\s+/, "")}`;
1710
+ }
1711
+ const prefix = text3.slice(0, envMatch.index + envMatch[1].length);
1712
+ const section = envMatch[2];
1713
+ const suffix = text3.slice(envMatch.index + envMatch[1].length + section.length);
1714
+ const pathLine = section.match(/^_\.path\s*=\s*\[([^\]]*)\]\s*$/m);
1715
+ if (!pathLine) {
1716
+ return `${prefix}${section.replace(/\n?$/, "\n")}${render(required)}${suffix}`;
1717
+ }
1718
+ const current = [...pathLine[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
1719
+ const merged = [...current];
1720
+ for (const value of required) {
1721
+ if (!merged.includes(value)) merged.push(value);
1722
+ }
1723
+ const nextLine = render(merged);
1724
+ if (pathLine[0] === nextLine) return text3;
1725
+ return `${prefix}${section.replace(pathLine[0], nextLine)}${suffix}`;
1726
+ }
1727
+ function upsertLinkAgentfilesBlock(text3, ctx) {
1728
+ const withPath = upsertMisePath(text3, requiredMisePathEntries(ctx));
1729
+ const existing = /# This block will handle the linking of[\s\S]*?\[tasks\.link-agentfiles\][\s\S]*?run = "\{\{config_root\}\}\/\.mise\/scripts\/link-agentfiles\.sh"/;
1730
+ if (existing.test(withPath)) {
1731
+ return withPath.replace(existing, LINK_AGENTFILES_BLOCK);
1732
+ }
1733
+ const versioningIndex = withPath.indexOf("# >>> mise-versioning >>>");
1734
+ if (versioningIndex >= 0) {
1735
+ return `${withPath.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${LINK_AGENTFILES_BLOCK}
1736
+
1737
+ ${withPath.slice(versioningIndex)}`;
1738
+ }
1739
+ return `${withPath.replace(/\s*$/, "")}
1740
+
1741
+ ${LINK_AGENTFILES_BLOCK}
1742
+ `;
1743
+ }
1744
+ function readProjectJson(ctx) {
1745
+ return tryParseJson(safeReadText(join9(ctx.repoRoot, ".project.json")));
1746
+ }
1747
+ function canonicalProjectJson(ctx) {
1748
+ const roles = discoverRoles(ctx.repoRoot);
1749
+ const existing = readProjectJson(ctx) ?? {};
1750
+ const slug = String(existing.project_slug ?? slugifyRepoName(dirname6(ctx.repoRoot) === ctx.repoRoot ? ctx.repoRoot.split("/").pop() ?? "project" : ctx.repoRoot.split("/").pop() ?? "project"));
1751
+ const firstRole = roles[0];
1752
+ const ticketProvider = {
1753
+ type: String((existing.ticket_provider?.type ?? firstRole?.ticketProviderName ?? "plane") || "plane"),
1754
+ workspace: String((existing.ticket_provider?.workspace ?? firstRole?.planeWorkspace ?? "") || ""),
1755
+ identifier: String((existing.ticket_provider?.identifier ?? firstRole?.ticketProviderIdentifier ?? "") || ""),
1756
+ board_id: String((existing.ticket_provider?.board_id ?? firstRole?.ticketProviderBoardId ?? "") || ""),
1757
+ board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || "")
1758
+ };
1759
+ return {
1760
+ project_name: String(existing.project_name ?? titleCaseSlug(slug)),
1761
+ project_description: String(existing.project_description ?? ""),
1762
+ project_slug: slug,
1763
+ repo_path: ctx.repoRoot,
1764
+ ticket_provider: ticketProvider,
1765
+ agents: Object.fromEntries(
1766
+ roles.map((role) => [
1767
+ role.agentId || `${slug}-${role.role}`,
1768
+ {
1769
+ role: role.role,
1770
+ role_dir: relative(ctx.repoRoot, role.roleDir)
1771
+ }
1772
+ ])
1773
+ )
1774
+ };
1775
+ }
1776
+ function projectJsonFinding(ctx) {
1777
+ const projectPath = join9(ctx.repoRoot, ".project.json");
1778
+ const planeJsonPath = join9(ctx.repoRoot, ".plane.json");
1779
+ const details = [];
1780
+ const data = readProjectJson(ctx);
1781
+ const roles = discoverRoles(ctx.repoRoot);
1782
+ if (!existsSync7(projectPath)) {
1783
+ return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json missing", details: [], fixable: true };
1784
+ }
1785
+ if (!data) {
1786
+ return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json is not valid JSON", details: [], fixable: true };
1787
+ }
1788
+ for (const key of ["project_name", "project_description", "project_slug", "repo_path", "ticket_provider", "agents"]) {
1789
+ if (!(key in data)) details.push(`missing key: ${key}`);
1790
+ }
1791
+ if (data.repo_path !== ctx.repoRoot) details.push(`repo_path should be ${ctx.repoRoot}`);
1792
+ const agents = data.agents ?? {};
1793
+ for (const role of roles) {
1794
+ const agent = agents[role.agentId];
1795
+ if (!agent) {
1796
+ details.push(`agents.${role.agentId} missing`);
1797
+ continue;
1798
+ }
1799
+ if (agent.role !== role.role) details.push(`agents.${role.agentId}.role should be ${role.role}`);
1800
+ if (agent.role_dir !== relative(ctx.repoRoot, role.roleDir)) {
1801
+ details.push(`agents.${role.agentId}.role_dir should be ${relative(ctx.repoRoot, role.roleDir)}`);
1802
+ }
1803
+ }
1804
+ const ticketProvider = data.ticket_provider ?? {};
1805
+ for (const key of ["type", "workspace", "identifier", "board_id", "board_url"]) {
1806
+ if (!(key in ticketProvider)) details.push(`ticket_provider.${key} missing`);
1807
+ }
1808
+ if (existsSync7(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
1809
+ return {
1810
+ id: "sot.project-json",
1811
+ title: "Canonical .project.json",
1812
+ status: details.length === 0 ? "pass" : "fail",
1813
+ summary: details.length === 0 ? ".project.json matches canonical parity contract" : `${details.length} parity issue(s) detected`,
1814
+ details,
1815
+ fixable: true
1816
+ };
1817
+ }
1818
+ function renderSoul(role) {
1819
+ const telegram = role.botHandle ? `@${role.botHandle}` : "(unwired)";
1820
+ 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.";
1821
+ 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 (\`reconcile.enabled\` in role.yaml), 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.`;
1822
+ const runtimeOwner = role.runtimeOwner || "delorenj";
1823
+ return `# ${role.displayName || role.agentId}
1824
+
1825
+ You are **${role.displayName || role.agentId}** \u2014 a Hermes agent provisioned to work inside the
1826
+ \`${role.repo}\` repository.
1827
+
1828
+ ## Identity
1829
+
1830
+ | | |
1831
+ | --- | --- |
1832
+ | Agent ID | \`${role.agentId}\` |
1833
+ | Profile | \`${role.profileName || role.agentId}\` |
1834
+ | Repo | \`${role.repo}\` |
1835
+ | Role | \`${role.role}\` |
1836
+ | Telegram | \`${telegram}\` |
1837
+ | Purpose | ${role.purpose || `${role.role} agent for ${role.repo}`} |
1838
+
1839
+ ## Scope
1840
+
1841
+ 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.
1842
+
1843
+ ## Tone
1844
+
1845
+ ${tone}
1846
+
1847
+ ## Role-specific behavior
1848
+
1849
+ ${roleSpecific}
1850
+
1851
+ ## Memory hygiene
1852
+
1853
+ Your memory is the submodule at \`./runtime/memories/\`. Use durable memory deliberately and keep \`memories/MEMORY.md\` current.
1854
+ `;
1855
+ }
1856
+ function renderHermesWrapper(role) {
1857
+ return `#!/usr/bin/env bash
1858
+ # Launcher for ${role.agentId}. Resolves HERMES_HOME to the runtime submodule.
1859
+
1860
+ set -euo pipefail
1861
+
1862
+ ROLE_DIR="$(cd "$(dirname "$0")" && pwd)"
1863
+ RUNTIME_HOME="$ROLE_DIR/runtime"
1864
+
1865
+ FLEET_ENV="{HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
1866
+ if [[ -f "$FLEET_ENV" ]]; then
1867
+ # shellcheck disable=SC1090
1868
+ source "$FLEET_ENV"
1869
+ fi
1870
+
1871
+ HERMES_BIN="{HERMES_BIN:-{HERMES_FLEET_BIN:-/home/delorenj/code/hermes-agent/.venv/bin/hermes}}"
1872
+ HERMES_OAUTH_FILE="{HERMES_OAUTH_FILE:-{HERMES_FLEET_OAUTH_FILE:-$HOME/.hermes/auth.json}}"
1873
+ CODEX_HOME="{CODEX_HOME:-{HERMES_FLEET_CODEX_HOME:-$HOME/.codex}}"
1874
+
1875
+ FLEET_HOME="{HERMES_FLEET_HOME:-$HOME/.hermes}"
1876
+ PROFILE_NAME="{HERMES_PROFILE_NAME:-${role.profileName || role.agentId}}"
1877
+ HERMES_HOME="$RUNTIME_HOME"
1878
+
1879
+ if [[ ! -d "$RUNTIME_HOME" ]]; then
1880
+ echo "hermes: runtime submodule not initialized at $RUNTIME_HOME" >&2
1881
+ echo " fix: git submodule update --init --recursive" >&2
1882
+ exit 1
1883
+ fi
1884
+
1885
+ exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" HERMES_OAUTH_FILE="$HERMES_OAUTH_FILE" CODEX_HOME="$CODEX_HOME" "$HERMES_BIN" "$@"
1886
+ `.replace(/\u0010/g, "$");
1887
+ }
1888
+ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip) {
1889
+ if (!existsSync7(sourceDir)) return;
1890
+ mkdirSync5(targetDir, { recursive: true });
1891
+ for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
1892
+ const sourcePath = join9(sourceDir, entry.name);
1893
+ if (skip?.(sourcePath)) continue;
1894
+ const targetPath = join9(targetDir, entry.name);
1895
+ if (entry.isDirectory()) {
1896
+ copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
1897
+ continue;
1898
+ }
1899
+ if (existsSync7(targetPath)) continue;
1900
+ changedFiles.push(targetPath);
1901
+ if (!dryRun) {
1902
+ ensureParent(targetPath);
1903
+ copyFileSync(sourcePath, targetPath);
1904
+ }
1905
+ }
1906
+ }
1907
+ function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
1908
+ const gitmodulesPath = join9(repoRoot, ".gitmodules");
1909
+ const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
1910
+ const owner = role.runtimeOwner || "delorenj";
1911
+ const block = `[submodule "agents/hermes/${role.role}/runtime"]
1912
+ path = agents/hermes/${role.role}/runtime
1913
+ url = git@github.com:${owner}/${repoName}.git
1914
+ `;
1915
+ const current = safeReadText(gitmodulesPath) ?? "";
1916
+ const header = `[submodule "agents/hermes/${role.role}/runtime"]`;
1917
+ if (current.includes(header)) return [];
1918
+ changedFiles.push(gitmodulesPath);
1919
+ if (!dryRun) writeText(gitmodulesPath, `${current.replace(/\s*$/, "")}${current.trim() ? "\n" : ""}${block}`);
1920
+ return [gitmodulesPath];
1921
+ }
1922
+ function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
1923
+ const path = registryPath(homeDir);
1924
+ 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";
1925
+ if (current.includes(`${role.agentId}:`)) return null;
1926
+ const block = ` ${role.agentId}:
1927
+ repo: ${role.repo}
1928
+ role: ${role.role}
1929
+ display_name: ${JSON.stringify(role.displayName || role.agentId)}
1930
+ project_path: ${ctxEscape(role.roleDir ? dirname6(dirname6(dirname6(role.roleDir))) : "")}
1931
+ role_dir: ${ctxEscape(role.roleDir)}
1932
+ profile_name: ${role.profileName || role.agentId}
1933
+ telegram:
1934
+ bot_username: ${ctxEscape(role.botHandle)}
1935
+ plane:
1936
+ workspace: ${ctxEscape(role.planeWorkspace)}
1937
+ project_id: ${ctxEscape(role.ticketProviderBoardId)}
1938
+ identifier: ${ctxEscape(role.ticketProviderIdentifier)}
1939
+ runtime_repo: ${ctxEscape(role.runtimeRepo)}
1940
+ systemd:
1941
+ gateway_unit: hermes-${role.agentId}-gateway.service
1942
+ consumer_unit: hermes-${role.agentId}-consumer.service
1943
+ heartbeat_timer: hermes-${role.agentId}-heartbeat.timer
1944
+ `;
1945
+ const next = current.includes("agents: {}") ? current.replace("agents: {}", `agents:
1946
+ ${block}`) : `${current.replace(/\s*$/, "\n")}${block}`;
1947
+ changedFiles.push(path);
1948
+ if (!dryRun) writeText(path, next);
1949
+ return path;
1950
+ }
1951
+ function profileMetaInheritsDefault(path) {
1952
+ const text3 = safeReadText(path);
1953
+ return Boolean(
1954
+ text3 && /^config:\s*$/m.test(text3) && /^\s+inherit_from:\s*default\s*$/m.test(text3) && /^\s+save_mode:\s*delta\s*$/m.test(text3)
1955
+ );
1956
+ }
1957
+ function upsertInheritedProfileMeta(path, changedFiles, dryRun) {
1958
+ const current = safeReadText(path) ?? "";
1959
+ const lines = current.split("\n");
1960
+ let next;
1961
+ const start = lines.findIndex((line) => /^config:\s*$/.test(line));
1962
+ if (!current.trim()) {
1963
+ next = "config:\n inherit_from: default\n save_mode: delta\n";
1964
+ } else if (start === -1) {
1965
+ next = `${current.replace(/\s*$/, "\n")}config:
1966
+ inherit_from: default
1967
+ save_mode: delta
1968
+ `;
1969
+ } else {
1970
+ let end = start + 1;
1971
+ while (end < lines.length && !/^[^#\s][^:]*:\s*/.test(lines[end] ?? "")) end++;
1972
+ let hasInherit = false;
1973
+ let hasSave = false;
1974
+ for (let idx = start + 1; idx < end; idx++) {
1975
+ if (/^\s+inherit_from:\s*/.test(lines[idx] ?? "")) {
1976
+ lines[idx] = " inherit_from: default";
1977
+ hasInherit = true;
1978
+ } else if (/^\s+save_mode:\s*/.test(lines[idx] ?? "")) {
1979
+ lines[idx] = " save_mode: delta";
1980
+ hasSave = true;
1981
+ }
1982
+ }
1983
+ const inserts = [];
1984
+ if (!hasInherit) inserts.push(" inherit_from: default");
1985
+ if (!hasSave) inserts.push(" save_mode: delta");
1986
+ if (inserts.length) lines.splice(end, 0, ...inserts);
1987
+ next = lines.join("\n");
1988
+ if (!next.endsWith("\n")) next += "\n";
1989
+ }
1990
+ if (next === current) return null;
1991
+ changedFiles.push(path);
1992
+ if (!dryRun) writeText(path, next);
1993
+ return path;
1994
+ }
1995
+ function ctxEscape(value) {
1996
+ return JSON.stringify(value || "");
1997
+ }
1998
+ function checkUnit(unit) {
1999
+ const enabled = systemctlUser(["is-enabled", unit]).ok;
2000
+ const active = systemctlUser(["is-active", unit]).ok;
2001
+ return { enabled, active };
2002
+ }
2003
+ var RULES = [
2004
+ {
2005
+ id: "mise.config-root",
2006
+ title: "mise config_root + AGENTS link hooks",
2007
+ audit: (ctx) => {
2008
+ const misePath = join9(ctx.repoRoot, "mise.toml");
2009
+ if (!existsSync7(misePath)) {
2010
+ return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
2011
+ }
2012
+ const text3 = readText(misePath);
2013
+ const details = [];
2014
+ const linkAgentfilesPath = join9(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2015
+ if (!existsSync7(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2016
+ const pathValues = [...(text3.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2017
+ const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
2018
+ if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
2019
+ if (!text3.includes('"{{config_root}}/.mise/scripts/link-agentfiles.sh"')) details.push("link-agentfiles must use raw {{config_root}} guard");
2020
+ if (!text3.includes("op inject -i .env.op > .env")) details.push("[hooks].enter must materialize .env from .env.op");
2021
+ if (!text3.includes('patterns = ["AGENTS.md"]')) details.push("watch_files must monitor AGENTS.md");
2022
+ if (!text3.includes('task = "link-agentfiles"')) details.push("watch_files must dispatch link-agentfiles task");
2023
+ return {
2024
+ id: "mise.config-root",
2025
+ title: "mise config_root + AGENTS link hooks",
2026
+ status: details.length === 0 ? "pass" : "fail",
2027
+ summary: details.length === 0 ? "mise AGENTS-linking parity verified" : `${details.length} issue(s) detected in mise AGENTS-linking contract`,
2028
+ details,
2029
+ fixable: true
2030
+ };
2031
+ },
2032
+ migrate: (ctx, finding) => {
2033
+ const path = join9(ctx.repoRoot, "mise.toml");
2034
+ const changedFiles = [];
2035
+ if (!existsSync7(path)) {
2036
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing; initialize mise first", changedFiles, details: [] };
2037
+ }
2038
+ let text3 = readText(path);
2039
+ const next = upsertLinkAgentfilesBlock(text3, ctx);
2040
+ if (next !== text3) {
2041
+ changedFiles.push(path);
2042
+ if (!ctx.dryRun) writeText(path, next);
2043
+ text3 = next;
2044
+ }
2045
+ const linkAgentfilesPath = join9(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2046
+ const expectedScript = templateLinkAgentfilesScript(ctx);
2047
+ if (expectedScript === void 0) {
2048
+ 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: [] };
2049
+ }
2050
+ if (safeReadText(linkAgentfilesPath) !== expectedScript) {
2051
+ changedFiles.push(linkAgentfilesPath);
2052
+ if (!ctx.dryRun) {
2053
+ writeText(linkAgentfilesPath, expectedScript);
2054
+ chmodSync(linkAgentfilesPath, 493);
2055
+ }
2056
+ }
2057
+ return {
2058
+ id: finding.id,
2059
+ title: finding.title,
2060
+ status: changedFiles.length ? "applied" : "noop",
2061
+ summary: changedFiles.length ? "Updated mise AGENTS-linking contract" : "No changes required",
2062
+ changedFiles,
2063
+ details: changedFiles.length ? ["Normalized hooks/watch_files/tasks.link-agentfiles block and script"] : []
2064
+ };
2065
+ }
2066
+ },
2067
+ {
2068
+ id: "mise.versioning",
2069
+ title: "managed mise versioning block",
2070
+ audit: (ctx) => {
2071
+ const details = [];
2072
+ const misePath = join9(ctx.repoRoot, "mise.toml");
2073
+ const versioningPath = join9(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2074
+ const manifestPath = join9(ctx.repoRoot, ".mise", "version-files.conf");
2075
+ const text3 = safeReadText(misePath);
2076
+ if (!text3?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
2077
+ if (!existsSync7(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
2078
+ if (!existsSync7(manifestPath)) details.push(".mise/version-files.conf missing");
2079
+ return {
2080
+ id: "mise.versioning",
2081
+ title: "managed mise versioning block",
2082
+ status: details.length === 0 ? "pass" : "fail",
2083
+ summary: details.length === 0 ? "mise versioning parity verified" : `${details.length} versioning issue(s) detected`,
2084
+ details,
2085
+ fixable: true
2086
+ };
2087
+ },
2088
+ migrate: (ctx, finding) => {
2089
+ const changedFiles = [];
2090
+ const misePath = join9(ctx.repoRoot, "mise.toml");
2091
+ if (!existsSync7(misePath)) {
2092
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing; cannot inject versioning block", changedFiles, details: [] };
2093
+ }
2094
+ const currentMise = readText(misePath);
2095
+ const nextMise = replaceOrAppendManagedBlock(currentMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
2096
+ if (nextMise !== currentMise) {
2097
+ changedFiles.push(misePath);
2098
+ if (!ctx.dryRun) writeText(misePath, nextMise);
2099
+ }
2100
+ const versioningPath = join9(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2101
+ const expectedScript = templateVersioningScript(ctx);
2102
+ if (expectedScript === void 0) {
2103
+ 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: [] };
2104
+ }
2105
+ if (safeReadText(versioningPath) !== expectedScript) {
2106
+ changedFiles.push(versioningPath);
2107
+ if (!ctx.dryRun) {
2108
+ writeText(versioningPath, expectedScript);
2109
+ chmodSync(versioningPath, 493);
2110
+ }
2111
+ }
2112
+ const manifestPath = join9(ctx.repoRoot, ".mise", "version-files.conf");
2113
+ const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
2114
+ if (safeReadText(manifestPath) !== expectedManifest) {
2115
+ changedFiles.push(manifestPath);
2116
+ if (!ctx.dryRun) writeText(manifestPath, expectedManifest);
2117
+ }
2118
+ return {
2119
+ id: finding.id,
2120
+ title: finding.title,
2121
+ status: changedFiles.length ? "applied" : "noop",
2122
+ summary: changedFiles.length ? "Versioning block/script/manifest normalized" : "No changes required",
2123
+ changedFiles,
2124
+ details: []
2125
+ };
2126
+ }
2127
+ },
2128
+ {
2129
+ id: "sot.agent-symlinks",
2130
+ title: "AGENTS/CLAUDE/GEMINI symlink contract",
2131
+ audit: (ctx) => {
2132
+ const agentsPath = join9(ctx.repoRoot, "AGENTS.md");
2133
+ if (!existsSync7(agentsPath)) {
2134
+ const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync7(join9(ctx.repoRoot, file)));
2135
+ if (fallbackSources.length === 0) {
2136
+ return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
2137
+ }
2138
+ return {
2139
+ id: "sot.agent-symlinks",
2140
+ title: "AGENTS/CLAUDE/GEMINI symlink contract",
2141
+ status: "fail",
2142
+ summary: "AGENTS.md missing but can be derived from existing project documentation",
2143
+ details: [`AGENTS.md can be created from ${fallbackSources[0]}`],
2144
+ fixable: true
2145
+ };
2146
+ }
2147
+ const details = [];
2148
+ for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2149
+ const full = join9(ctx.repoRoot, file);
2150
+ const target = readSymlinkTarget(full);
2151
+ if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
2152
+ }
2153
+ return {
2154
+ id: "sot.agent-symlinks",
2155
+ title: "AGENTS/CLAUDE/GEMINI symlink contract",
2156
+ status: details.length === 0 ? "pass" : "fail",
2157
+ summary: details.length === 0 ? "Agent documentation symlinks are in parity" : `${details.length} symlink issue(s) detected`,
2158
+ details,
2159
+ fixable: true
2160
+ };
2161
+ },
2162
+ migrate: (ctx, finding) => {
2163
+ const changedFiles = [];
2164
+ const details = [];
2165
+ const blockedDetails = [];
2166
+ const bootstrap = bootstrapAgentsFile(ctx.repoRoot, ctx.dryRun);
2167
+ changedFiles.push(...bootstrap.changedFiles);
2168
+ details.push(...bootstrap.details);
2169
+ if (bootstrap.blocked) {
2170
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
2171
+ }
2172
+ for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2173
+ const full = join9(ctx.repoRoot, file);
2174
+ const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
2175
+ if (result.blocked) blockedDetails.push(result.blocked);
2176
+ if (result.changed) changedFiles.push(full);
2177
+ }
2178
+ return {
2179
+ id: finding.id,
2180
+ title: finding.title,
2181
+ status: blockedDetails.length ? "blocked" : changedFiles.length ? "applied" : "noop",
2182
+ summary: blockedDetails.length ? "One or more files could not be replaced safely" : changedFiles.length ? "Symlink contract repaired" : "No changes required",
2183
+ changedFiles,
2184
+ details: [...details, ...blockedDetails]
2185
+ };
2186
+ }
2187
+ },
2188
+ {
2189
+ id: "sot.project-json",
2190
+ title: "Canonical .project.json",
2191
+ audit: projectJsonFinding,
2192
+ migrate: (ctx, finding) => {
2193
+ const changedFiles = [];
2194
+ const details = [];
2195
+ const path = join9(ctx.repoRoot, ".project.json");
2196
+ const existing = readProjectJson(ctx) ?? {};
2197
+ const canonical = canonicalProjectJson(ctx);
2198
+ const merged = { ...existing, ...canonical };
2199
+ const expected = `${JSON.stringify(merged, null, 2)}
2200
+ `;
2201
+ if (safeReadText(path) !== expected) {
2202
+ changedFiles.push(path);
2203
+ if (!ctx.dryRun) writeText(path, expected);
2204
+ }
2205
+ const planeJson = join9(ctx.repoRoot, ".plane.json");
2206
+ if (existsSync7(planeJson)) {
2207
+ const backup = `${planeJson}.migrated-backup`;
2208
+ if (existsSync7(backup)) {
2209
+ details.push(`cannot back up .plane.json because ${relative(ctx.repoRoot, backup)} already exists`);
2210
+ } else {
2211
+ changedFiles.push(backup);
2212
+ if (!ctx.dryRun) renameSync(planeJson, backup);
2213
+ }
2214
+ }
2215
+ return {
2216
+ id: finding.id,
2217
+ title: finding.title,
2218
+ status: details.length ? "blocked" : changedFiles.length ? "applied" : "noop",
2219
+ summary: details.length ? "Project SOT partially blocked" : changedFiles.length ? "Canonical .project.json written" : "No changes required",
2220
+ changedFiles,
2221
+ details
2222
+ };
2223
+ }
2224
+ },
2225
+ {
2226
+ id: "secrets.env-op",
2227
+ title: ".env.op + gitignore secrets contract",
2228
+ audit: (ctx) => {
2229
+ const details = [];
2230
+ const envOp = safeReadText(join9(ctx.repoRoot, ".env.op"));
2231
+ const gitignore = safeReadText(join9(ctx.repoRoot, ".gitignore"));
2232
+ if (!envOp) {
2233
+ details.push(".env.op missing");
2234
+ } else {
2235
+ const invalidLines = envOp.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).filter((line) => {
2236
+ const value = line.slice(line.indexOf("=") + 1).trim();
2237
+ return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value);
2238
+ });
2239
+ if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals: ${invalidLines.join(", ")}`);
2240
+ }
2241
+ if (!gitignore?.includes(".env\n") && !gitignore?.includes(".env\r\n")) details.push(".gitignore should ignore .env");
2242
+ if (!gitignore?.includes(".env.*")) details.push(".gitignore should ignore .env.*");
2243
+ if (!gitignore?.includes("!.env.op")) details.push(".gitignore should unignore .env.op");
2244
+ return {
2245
+ id: "secrets.env-op",
2246
+ title: ".env.op + gitignore secrets contract",
2247
+ status: details.length === 0 ? "pass" : "fail",
2248
+ summary: details.length === 0 ? "Secret reference file and ignore rules are in parity" : `${details.length} env parity issue(s) detected`,
2249
+ details,
2250
+ fixable: true
2251
+ };
2252
+ },
2253
+ migrate: (ctx, finding) => {
2254
+ const changedFiles = [];
2255
+ const details = [];
2256
+ const envOpPath = join9(ctx.repoRoot, ".env.op");
2257
+ if (!existsSync7(envOpPath)) {
2258
+ changedFiles.push(envOpPath);
2259
+ if (!ctx.dryRun) writeText(envOpPath, readText(join9(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2260
+ }
2261
+ const gitignorePath = join9(ctx.repoRoot, ".gitignore");
2262
+ const gitignore = safeReadText(gitignorePath) ?? "";
2263
+ const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
2264
+ # NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
2265
+ .env
2266
+ .env.*
2267
+ !.env.op
2268
+ `;
2269
+ if (!gitignore.includes("!.env.op") || !gitignore.includes(".env.*")) {
2270
+ changedFiles.push(gitignorePath);
2271
+ if (!ctx.dryRun) writeText(gitignorePath, `${gitignore.replace(/\s*$/, "")}${gitignore.trim() ? "\n\n" : ""}${requiredBlock}`);
2272
+ }
2273
+ return {
2274
+ id: finding.id,
2275
+ title: finding.title,
2276
+ status: details.length ? "blocked" : changedFiles.length ? "applied" : "noop",
2277
+ summary: details.length ? "Manual cleanup still required" : changedFiles.length ? "Wrote .env.op/gitignore parity files" : "No changes required",
2278
+ changedFiles,
2279
+ details
2280
+ };
2281
+ }
2282
+ },
2283
+ {
2284
+ id: "provenance.copier",
2285
+ title: ".copier-answers.yml provenance + drift report",
2286
+ audit: (ctx) => {
2287
+ const details = [];
2288
+ const path = join9(ctx.repoRoot, ".copier-answers.yml");
2289
+ const text3 = safeReadText(path);
2290
+ const project = readProjectJson(ctx);
2291
+ if (!text3) {
2292
+ details.push(".copier-answers.yml missing");
2293
+ } else {
2294
+ if (!text3.startsWith("# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY")) details.push("missing Copier overwrite warning header");
2295
+ if (!text3.includes("_src_path:")) details.push("_src_path missing");
2296
+ if (project?.project_name) {
2297
+ const nameMatch = text3.match(/project_name:\s*(.+)/);
2298
+ if (!nameMatch || nameMatch[1]?.trim() !== String(project.project_name)) details.push("project_name drift between .copier-answers.yml and .project.json");
2299
+ }
2300
+ if (project?.project_description) {
2301
+ const descMatch = text3.match(/project_description:\s*([\s\S]*?)(?=\n\w|$)/);
2302
+ const yamlDesc = descMatch?.[1]?.replace(/\n\s+/g, " ").trim() ?? "";
2303
+ if (yamlDesc !== String(project.project_description)) details.push("project_description drift between .copier-answers.yml and .project.json");
2304
+ }
2305
+ }
2306
+ return {
2307
+ id: "provenance.copier",
2308
+ title: ".copier-answers.yml provenance + drift report",
2309
+ status: details.length === 0 ? "pass" : "fail",
2310
+ summary: details.length === 0 ? "Copier provenance is in parity" : `${details.length} provenance issue(s) detected`,
2311
+ details,
2312
+ fixable: true
2313
+ };
2314
+ },
2315
+ migrate: (ctx, finding) => {
2316
+ const changedFiles = [];
2317
+ const project = canonicalProjectJson(ctx);
2318
+ const text3 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
2319
+ _src_path: ${join9(ctx.pjanglerRoot, "templates", "commonproject")}
2320
+ project_description: ${String(project.project_description)}
2321
+ project_name: ${String(project.project_name)}
2322
+ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2323
+ `;
2324
+ const path = join9(ctx.repoRoot, ".copier-answers.yml");
2325
+ if (safeReadText(path) !== text3) {
2326
+ changedFiles.push(path);
2327
+ if (!ctx.dryRun) writeText(path, text3);
2328
+ }
2329
+ return {
2330
+ id: finding.id,
2331
+ title: finding.title,
2332
+ status: changedFiles.length ? "applied" : "noop",
2333
+ summary: changedFiles.length ? "Copier provenance file refreshed" : "No changes required",
2334
+ changedFiles,
2335
+ details: []
2336
+ };
2337
+ }
2338
+ },
2339
+ {
2340
+ id: "bmad.scaffold",
2341
+ title: "BMAD modules/docs scaffold",
2342
+ audit: (ctx) => {
2343
+ const sourceRoot = join9(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
2344
+ const targetRoot = join9(ctx.repoRoot, "_bmad");
2345
+ const sentinels = [
2346
+ join9("core", "config.yaml"),
2347
+ join9("custom", "config.yaml"),
2348
+ join9("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
2349
+ join9("bmm", "workflows", "workflow-status", "workflow.yaml")
2350
+ ];
2351
+ const missing = sentinels.filter((file) => existsSync7(join9(sourceRoot, file)) && !existsSync7(join9(targetRoot, file)));
2352
+ return {
2353
+ id: "bmad.scaffold",
2354
+ title: "BMAD modules/docs scaffold",
2355
+ status: missing.length === 0 ? "pass" : "fail",
2356
+ summary: missing.length === 0 ? "BMAD scaffold parity verified" : `${missing.length} BMAD sentinel file(s) missing`,
2357
+ details: missing.map((file) => `_bmad/${file}`),
2358
+ fixable: true
2359
+ };
2360
+ },
2361
+ migrate: (ctx, finding) => {
2362
+ const changedFiles = [];
2363
+ copyMissingRecursive(join9(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join9(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
2364
+ return {
2365
+ id: finding.id,
2366
+ title: finding.title,
2367
+ status: changedFiles.length ? "applied" : "noop",
2368
+ summary: changedFiles.length ? "Copied missing BMAD scaffold files" : "No changes required",
2369
+ changedFiles,
2370
+ details: []
2371
+ };
2372
+ }
2373
+ },
2374
+ {
2375
+ id: "hermes.pm-scaffold",
2376
+ title: "Hermes PM scaffold parity",
2377
+ audit: (ctx) => {
2378
+ const roles = discoverRoles(ctx.repoRoot);
2379
+ const role = roles.find((item) => item.role === "pm");
2380
+ if (!role) {
2381
+ return { id: "hermes.pm-scaffold", title: "Hermes PM scaffold parity", status: "skip", summary: "No pm role present", details: [], fixable: false };
2382
+ }
2383
+ const details = [];
2384
+ 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"]) {
2385
+ if (!existsSync7(join9(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join9(role.roleDir, rel))}`);
2386
+ }
2387
+ const gitmodules = safeReadText(join9(ctx.repoRoot, ".gitmodules")) ?? "";
2388
+ if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing pm runtime submodule entry");
2389
+ if (!profileMetaInheritsDefault(join9(role.roleDir, "runtime", "profile.yaml"))) {
2390
+ details.push("runtime/profile.yaml missing inherited default config metadata");
2391
+ }
2392
+ const registry = safeReadText(registryPath(ctx.homeDir));
2393
+ if (!registry?.includes(`${role.agentId}:`)) details.push(`fleet registry missing ${role.agentId}`);
2394
+ return {
2395
+ id: "hermes.pm-scaffold",
2396
+ title: "Hermes PM scaffold parity",
2397
+ status: details.length === 0 ? "pass" : "fail",
2398
+ summary: details.length === 0 ? "PM scaffold parity verified" : `${details.length} PM scaffold issue(s) detected`,
2399
+ details,
2400
+ fixable: true
2401
+ };
2402
+ },
2403
+ migrate: (ctx, finding) => {
2404
+ const role = discoverRoles(ctx.repoRoot).find((item) => item.role === "pm");
2405
+ const changedFiles = [];
2406
+ const details = [];
2407
+ if (!role) {
2408
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
2409
+ }
2410
+ const templateRoleDir = join9(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
2411
+ writeIfDifferent(join9(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
2412
+ writeIfDifferent(join9(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
2413
+ writeIfDifferent(join9(role.roleDir, ".gitignore"), readText(join9(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
2414
+ copyMissingRecursive(join9(templateRoleDir, ".runtime-scaffold"), join9(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
2415
+ copyMissingRecursive(join9(templateRoleDir, ".runtime-scaffold"), join9(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
2416
+ copyMissingRecursive(join9(templateRoleDir, ".scripts"), join9(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
2417
+ const promptSource = join9(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
2418
+ const promptTarget = join9(role.roleDir, ".scripts", "sentinel.prompt.md");
2419
+ if (existsSync7(promptSource) && !existsSync7(promptTarget)) {
2420
+ 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);
2421
+ writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
2422
+ }
2423
+ upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
2424
+ const profileMetaUpdated = upsertInheritedProfileMeta(join9(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
2425
+ if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
2426
+ const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
2427
+ if (registryUpdated) details.push(`updated ${registryUpdated}`);
2428
+ return {
2429
+ id: finding.id,
2430
+ title: finding.title,
2431
+ status: changedFiles.length ? "applied" : "noop",
2432
+ summary: changedFiles.length ? "PM scaffold normalized" : "No changes required",
2433
+ changedFiles,
2434
+ details
2435
+ };
2436
+ }
2437
+ },
2438
+ {
2439
+ id: "systemd.sentinel",
2440
+ title: "Hermes systemd/sentinel units enabled + active",
2441
+ audit: (ctx) => {
2442
+ const roles = discoverRoles(ctx.repoRoot);
2443
+ if (!roles.length) {
2444
+ return { id: "systemd.sentinel", title: "Hermes systemd/sentinel units enabled + active", status: "skip", summary: "No Hermes roles present", details: [], fixable: false };
2445
+ }
2446
+ const probe = systemctlUser(["is-system-running"]);
2447
+ if (!probe.ok && !/running|degraded|starting|maintenance/.test(`${probe.stdout} ${probe.stderr}`)) {
2448
+ return { id: "systemd.sentinel", title: "Hermes systemd/sentinel units enabled + active", status: "warn", summary: "systemd --user unavailable; unit state not auditable here", details: [], fixable: false };
2449
+ }
2450
+ const details = [];
2451
+ for (const role of roles) {
2452
+ for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
2453
+ const state = checkUnit(unit);
2454
+ if (!state.enabled || !state.active) details.push(`${unit} should be enabled+active`);
2455
+ }
2456
+ }
2457
+ return {
2458
+ id: "systemd.sentinel",
2459
+ title: "Hermes systemd/sentinel units enabled + active",
2460
+ status: details.length === 0 ? "pass" : "fail",
2461
+ summary: details.length === 0 ? "Hermes user units are enabled and active" : `${details.length} systemd parity issue(s) detected`,
2462
+ details,
2463
+ fixable: true
2464
+ };
2465
+ },
2466
+ migrate: (ctx, finding) => {
2467
+ const roles = discoverRoles(ctx.repoRoot);
2468
+ const changedFiles = [];
2469
+ const details = [];
2470
+ if (!roles.length) {
2471
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "No Hermes roles present", changedFiles, details };
2472
+ }
2473
+ const probe = systemctlUser(["is-system-running"]);
2474
+ if (!probe.ok && !/running|degraded|starting|maintenance/.test(`${probe.stdout} ${probe.stderr}`)) {
2475
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
2476
+ }
2477
+ for (const role of roles) {
2478
+ const sysDir = join9(ctx.homeDir, ".config", "systemd", "user");
2479
+ const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
2480
+ const allUnitsPresent = units.every((unit) => existsSync7(join9(sysDir, unit)));
2481
+ if (allUnitsPresent) {
2482
+ if (ctx.dryRun) {
2483
+ details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
2484
+ } else {
2485
+ systemctlUser(["daemon-reload"]);
2486
+ for (const unit of units) {
2487
+ systemctlUser(["enable", "--now", unit]);
2488
+ }
2489
+ }
2490
+ continue;
2491
+ }
2492
+ for (const script of [join9(role.roleDir, ".scripts", "70-systemd.sh")]) {
2493
+ if (!script || !existsSync7(script)) continue;
2494
+ if (ctx.dryRun) {
2495
+ details.push(`would run: bash ${script}`);
2496
+ } else {
2497
+ const result = spawnSync4("bash", [script], { cwd: role.roleDir, encoding: "utf8" });
2498
+ if (result.status !== 0) details.push(`script failed: ${script}: ${result.stderr.trim() || result.stdout.trim()}`);
2499
+ }
2500
+ }
2501
+ }
2502
+ return {
2503
+ id: finding.id,
2504
+ title: finding.title,
2505
+ status: details.some((detail) => detail.includes("failed:")) ? "blocked" : details.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
2506
+ summary: details.length ? ctx.dryRun ? "Planned systemd remediation commands" : "Attempted systemd remediation" : "No changes required",
2507
+ changedFiles,
2508
+ details
2509
+ };
2510
+ }
2511
+ }
2512
+ ];
2513
+ function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
2514
+ const normalized = content.endsWith("\n") ? content : `${content}
2515
+ `;
2516
+ if (safeReadText(path) === normalized) return;
2517
+ changedFiles.push(path);
2518
+ if (!dryRun) {
2519
+ writeText(path, normalized);
2520
+ if (mode) chmodSync(path, mode);
2521
+ }
2522
+ }
2523
+ function getParityRuleIds() {
2524
+ return RULES.map((rule) => rule.id);
2525
+ }
2526
+ function runAudit(repoArg) {
2527
+ const pjanglerRoot = resolvePjanglerRoot();
2528
+ const ctx = {
2529
+ repoRoot: resolve(repoArg ?? process.cwd()),
2530
+ dryRun: true,
2531
+ pjanglerRoot,
2532
+ homeDir: homedir4()
2533
+ };
2534
+ const rules = RULES.map((rule) => rule.audit(ctx));
2535
+ return {
2536
+ repo: ctx.repoRoot,
2537
+ ok: rules.every((rule) => rule.status === "pass" || rule.status === "skip"),
2538
+ auditedAt: (/* @__PURE__ */ new Date()).toISOString(),
2539
+ rules
2540
+ };
2541
+ }
2542
+ function runMigration(selector, repoArg, dryRun, all) {
2543
+ const pjanglerRoot = resolvePjanglerRoot();
2544
+ const ctx = {
2545
+ repoRoot: resolve(repoArg ?? process.cwd()),
2546
+ dryRun,
2547
+ pjanglerRoot,
2548
+ homeDir: homedir4()
2549
+ };
2550
+ const selected = all ? RULES : RULES.filter((rule) => rule.id === selector);
2551
+ if (!selected.length) {
2552
+ throw new Error(`Unknown parity rule: ${selector}`);
2553
+ }
2554
+ const results = selected.map((rule) => {
2555
+ try {
2556
+ return rule.migrate(ctx, rule.audit(ctx));
2557
+ } catch (err) {
2558
+ return {
2559
+ id: rule.id,
2560
+ title: rule.title,
2561
+ status: "blocked",
2562
+ summary: `migrate threw: ${err instanceof Error ? err.message : String(err)}`,
2563
+ changedFiles: [],
2564
+ details: []
2565
+ };
2566
+ }
2567
+ });
2568
+ const changedFiles = Array.from(new Set(results.flatMap((result) => result.changedFiles))).sort();
2569
+ return {
2570
+ repo: ctx.repoRoot,
2571
+ dryRun,
2572
+ ok: results.every((result) => result.status !== "blocked"),
2573
+ selectedRules: selected.map((rule) => rule.id),
2574
+ results,
2575
+ changedFiles
2576
+ };
2577
+ }
2578
+ function formatAuditReport(report) {
2579
+ const lines = [`repo: ${report.repo}`, `ok: ${report.ok}`, `audited_at: ${report.auditedAt}`, "rules:"];
2580
+ for (const rule of report.rules) {
2581
+ lines.push(`- ${rule.id} [${rule.status}] ${rule.summary}`);
2582
+ for (const detail of rule.details) lines.push(` - ${detail}`);
2583
+ }
2584
+ return `${lines.join("\n")}
2585
+ `;
2586
+ }
2587
+
1273
2588
  // src/mcp-server.ts
1274
2589
  var server = new McpServer({
1275
2590
  name: "pjangler-mcp",
1276
2591
  version: PJANGLER_VERSION
1277
2592
  });
2593
+ var TICKET_PROVIDER_SCHEMA = z.enum(["plane", "linear", "trello"]);
1278
2594
  function resolveTargetDir(targetDir) {
1279
- const dir = resolve(targetDir ?? process.cwd());
1280
- if (!existsSync5(dir)) {
2595
+ const dir = resolve2(targetDir ?? process.cwd());
2596
+ if (!existsSync8(dir)) {
1281
2597
  throw new Error(`Target directory does not exist: ${dir}`);
1282
2598
  }
1283
2599
  if (!statSync(dir).isDirectory()) {
@@ -1285,6 +2601,49 @@ function resolveTargetDir(targetDir) {
1285
2601
  }
1286
2602
  return dir;
1287
2603
  }
2604
+ function resolvePjanglerRoot2() {
2605
+ let dir = dirname7(fileURLToPath5(import.meta.url));
2606
+ while (dir !== dirname7(dir)) {
2607
+ if (existsSync8(join10(dir, "package.json")) && existsSync8(join10(dir, "templates", "commonproject", "copier.yml"))) {
2608
+ return dir;
2609
+ }
2610
+ dir = dirname7(dir);
2611
+ }
2612
+ throw new Error("Unable to resolve pjangler root");
2613
+ }
2614
+ function slugify(value) {
2615
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
2616
+ }
2617
+ function asText(payload) {
2618
+ return { content: [{ type: "text", text: typeof payload === "string" ? payload : JSON.stringify(payload, null, 2) }] };
2619
+ }
2620
+ function auditSummary(report) {
2621
+ const counts = report.rules.reduce((acc, rule) => {
2622
+ acc[rule.status] = (acc[rule.status] ?? 0) + 1;
2623
+ return acc;
2624
+ }, {});
2625
+ const nextActions = report.rules.filter((rule) => (rule.status === "fail" || rule.status === "warn") && rule.fixable).map((rule) => `pjangler_migrate_project ${rule.id}`);
2626
+ return { counts, nextActions };
2627
+ }
2628
+ function migrationSummary(report) {
2629
+ const counts = report.results.reduce((acc, result) => {
2630
+ acc[result.status] = (acc[result.status] ?? 0) + 1;
2631
+ return acc;
2632
+ }, {});
2633
+ return { counts, changedFileCount: report.changedFiles.length };
2634
+ }
2635
+ function parityGuidance() {
2636
+ return {
2637
+ skill: "@33god-projects",
2638
+ guidance: "Use these tools before editing a project so the repo SOT, agent files, mise hooks, and Hermes role scaffold are current.",
2639
+ workflows: [
2640
+ "audit -> pjangler_audit_project",
2641
+ "migrate -> pjangler_migrate_project",
2642
+ "bootstrap -> pjangler_bootstrap_33god_project",
2643
+ "agent provisioning -> pjangler_deploy_hermes_agent"
2644
+ ]
2645
+ };
2646
+ }
1288
2647
  async function runRecipeWithCapture(recipeName, context) {
1289
2648
  const recipe = createRecipe(recipeName, context);
1290
2649
  if (!recipe) {
@@ -1317,11 +2676,31 @@ async function runRecipeWithCapture(recipeName, context) {
1317
2676
  console.error = origError;
1318
2677
  }
1319
2678
  }
2679
+ function buildCommonProjectCopierAction(input) {
2680
+ const data = {
2681
+ project_name: input.projectName,
2682
+ project_description: input.projectDescription ?? "",
2683
+ project_slug: input.projectSlug,
2684
+ ticket_provider: input.ticketProvider,
2685
+ plane_workspace: input.planeWorkspace,
2686
+ plane_project_id: input.planeProjectId ?? "",
2687
+ project_identifier: input.projectIdentifier,
2688
+ primary_language: input.primaryLanguage
2689
+ };
2690
+ const args = ["copy", "--trust", input.templateDir, input.targetDir, "--defaults"];
2691
+ for (const [key, value] of Object.entries(data)) args.push("--data", `${key}=${value}`);
2692
+ if (input.overwrite) args.push("--overwrite");
2693
+ return {
2694
+ kind: "copier.copy.commonproject",
2695
+ command: ["copier", ...args],
2696
+ data
2697
+ };
2698
+ }
1320
2699
  server.registerTool(
1321
2700
  "pjangler_list_capabilities",
1322
2701
  {
1323
2702
  title: "List pjangler capabilities",
1324
- description: "Returns available recipes and commands exposed by pjangler.",
2703
+ description: "Returns available recipes, commands, parity rules, workflows, and @33god-projects tool guidance.",
1325
2704
  inputSchema: {}
1326
2705
  },
1327
2706
  async () => {
@@ -1335,11 +2714,197 @@ server.registerTool(
1335
2714
  name: c.name,
1336
2715
  description: c.description,
1337
2716
  group: c.group
1338
- }))
1339
- };
1340
- return {
1341
- content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
2717
+ })),
2718
+ parityRules: getParityRuleIds(),
2719
+ recommendedWorkflows: {
2720
+ existingProject: ["pjangler_audit_project", "pjangler_migrate_project"],
2721
+ new33godProject: ["pjangler_bootstrap_33god_project", "pjangler_audit_project"],
2722
+ hermesAgentProvisioning: ["pjangler_deploy_hermes_agent", "pjangler_audit_project"]
2723
+ },
2724
+ skillSynergy: parityGuidance()
1342
2725
  };
2726
+ return asText(payload);
2727
+ }
2728
+ );
2729
+ server.registerTool(
2730
+ "pjangler_list_parity_rules",
2731
+ {
2732
+ title: "List pjangler parity rules",
2733
+ description: "Returns parity rule ids plus brief @33god-projects guidance.",
2734
+ inputSchema: {}
2735
+ },
2736
+ async () => asText({ parityRules: getParityRuleIds(), guidance: parityGuidance() })
2737
+ );
2738
+ server.registerTool(
2739
+ "pjangler_audit_project",
2740
+ {
2741
+ title: "Audit project parity",
2742
+ description: "Runs pjangler parity audit for a project and returns structured findings with summary counts and next actions.",
2743
+ inputSchema: {
2744
+ targetDir: z.string().optional(),
2745
+ json: z.boolean().optional()
2746
+ }
2747
+ },
2748
+ async ({ targetDir, json }) => {
2749
+ try {
2750
+ const resolvedTarget = resolveTargetDir(targetDir);
2751
+ const report = runAudit(resolvedTarget);
2752
+ const payload = { ...report, summary: auditSummary(report), guidance: parityGuidance() };
2753
+ return asText(json === false ? formatAuditReport(report) : payload);
2754
+ } catch (err) {
2755
+ return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
2756
+ }
2757
+ }
2758
+ );
2759
+ server.registerTool(
2760
+ "pjangler_migrate_project",
2761
+ {
2762
+ title: "Migrate project parity",
2763
+ description: "Runs one pjangler parity migration rule, or all rules, against a project.",
2764
+ inputSchema: {
2765
+ targetDir: z.string().optional(),
2766
+ ruleId: z.string().optional(),
2767
+ all: z.boolean().optional(),
2768
+ dryRun: z.boolean().optional()
2769
+ }
2770
+ },
2771
+ async ({ targetDir, ruleId, all, dryRun }) => {
2772
+ try {
2773
+ const runAll = all ?? false;
2774
+ if (!runAll && !ruleId) throw new Error("Either ruleId or all=true is required");
2775
+ if (runAll && ruleId) throw new Error("Pass either ruleId or all=true, not both");
2776
+ const resolvedTarget = resolveTargetDir(targetDir);
2777
+ const report = runMigration(ruleId, resolvedTarget, dryRun ?? true, runAll);
2778
+ return asText({
2779
+ ok: report.ok,
2780
+ repo: report.repo,
2781
+ dryRun: report.dryRun,
2782
+ selectedRules: report.selectedRules,
2783
+ changedFiles: report.changedFiles,
2784
+ results: report.results,
2785
+ summary: migrationSummary(report)
2786
+ });
2787
+ } catch (err) {
2788
+ return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
2789
+ }
2790
+ }
2791
+ );
2792
+ server.registerTool(
2793
+ "pjangler_bootstrap_33god_project",
2794
+ {
2795
+ title: "Bootstrap a new @33god project",
2796
+ description: "Create a new CommonProject-based 33god repo with optional local Hermes agent provisioning. Dry-run is safe and does not require copier.",
2797
+ inputSchema: {
2798
+ parentDir: z.string().optional(),
2799
+ targetDir: z.string().optional(),
2800
+ projectName: z.string(),
2801
+ projectDescription: z.string().optional(),
2802
+ projectSlug: z.string().optional(),
2803
+ ticketProvider: TICKET_PROVIDER_SCHEMA.optional(),
2804
+ planeWorkspace: z.string().optional(),
2805
+ planeProjectId: z.string().optional(),
2806
+ projectIdentifier: z.string().optional(),
2807
+ primaryLanguage: z.string().optional(),
2808
+ skipPlane: z.boolean().optional(),
2809
+ provisionAgent: z.boolean().optional(),
2810
+ agentRole: z.string().optional(),
2811
+ agentPurpose: z.string().optional(),
2812
+ local: z.boolean().optional(),
2813
+ force: z.boolean().optional(),
2814
+ overwrite: z.boolean().optional(),
2815
+ dryRun: z.boolean().optional()
2816
+ }
2817
+ },
2818
+ async (input) => {
2819
+ try {
2820
+ const pjanglerRoot = resolvePjanglerRoot2();
2821
+ const projectSlug = input.projectSlug ?? slugify(input.projectName);
2822
+ const parentDir = resolve2(input.parentDir ?? process.cwd());
2823
+ if (!existsSync8(parentDir) || !statSync(parentDir).isDirectory()) throw new Error(`Parent directory does not exist: ${parentDir}`);
2824
+ const targetDir = resolve2(input.targetDir ?? join10(parentDir, projectSlug));
2825
+ const overwrite = input.overwrite ?? input.force ?? false;
2826
+ const dryRun = input.dryRun ?? true;
2827
+ const local = input.local ?? true;
2828
+ const skipPlane = input.skipPlane ?? true;
2829
+ const planeProjectId = input.planeProjectId ?? "";
2830
+ if (!skipPlane && !planeProjectId) {
2831
+ throw new Error("planeProjectId is required when skipPlane=false; keep skipPlane=true for safe local bootstrap");
2832
+ }
2833
+ if (existsSync8(targetDir) && !overwrite) throw new Error(`Target already exists: ${targetDir} (set force/overwrite=true to re-render)`);
2834
+ const templateDir = join10(pjanglerRoot, "templates", "commonproject");
2835
+ const copierAction = buildCommonProjectCopierAction({
2836
+ templateDir,
2837
+ targetDir,
2838
+ projectName: input.projectName,
2839
+ projectDescription: input.projectDescription,
2840
+ projectSlug,
2841
+ ticketProvider: input.ticketProvider ?? "plane",
2842
+ planeWorkspace: input.planeWorkspace ?? "33god",
2843
+ planeProjectId,
2844
+ projectIdentifier: input.projectIdentifier ?? projectSlug.slice(0, 4).toUpperCase(),
2845
+ primaryLanguage: input.primaryLanguage ?? "python",
2846
+ overwrite
2847
+ });
2848
+ const actions = [
2849
+ { kind: "ensure.parent", path: dirname7(targetDir) },
2850
+ copierAction
2851
+ ];
2852
+ if (input.provisionAgent) {
2853
+ actions.push({
2854
+ kind: "pjangler.recipe.hermes-agent",
2855
+ targetDir,
2856
+ context: {
2857
+ targetRepo: projectSlug,
2858
+ role: input.agentRole ?? "pm",
2859
+ agentPurpose: input.agentPurpose ?? `Project manager for ${input.projectName}`,
2860
+ local,
2861
+ dryRun,
2862
+ force: overwrite,
2863
+ skipTelegram: true,
2864
+ skipEmail: true,
2865
+ skipRuntimeRepo: local,
2866
+ skipPlane: skipPlane || local,
2867
+ skipBloodbank: local,
2868
+ skipSystemd: local || process.platform === "darwin"
2869
+ }
2870
+ });
2871
+ }
2872
+ if (dryRun) {
2873
+ return asText({ ok: true, dryRun, targetDir, projectSlug, actions, guidance: parityGuidance() });
2874
+ }
2875
+ const which = spawnSync5("which", ["copier"], { encoding: "utf8" });
2876
+ if (which.status !== 0) throw new Error("copier not found on PATH. Install with: uv tool install copier or pip install copier");
2877
+ mkdirSync6(dirname7(targetDir), { recursive: true });
2878
+ const result = spawnSync5(copierAction.command[0], copierAction.command.slice(1), { encoding: "utf8", cwd: pjanglerRoot });
2879
+ const logs = [result.stdout.trim()].filter(Boolean);
2880
+ const errors = [result.stderr.trim()].filter(Boolean);
2881
+ if (result.status !== 0) {
2882
+ return asText({ ok: false, dryRun, targetDir, actions, logs, errors, exitCode: result.status });
2883
+ }
2884
+ let agentResult;
2885
+ if (input.provisionAgent) {
2886
+ const context = {
2887
+ targetDir,
2888
+ yes: true,
2889
+ targetRepo: projectSlug,
2890
+ role: input.agentRole ?? "pm",
2891
+ agentPurpose: input.agentPurpose ?? `Project manager for ${input.projectName}`,
2892
+ local,
2893
+ force: overwrite,
2894
+ dryRun: false,
2895
+ skipTelegram: true,
2896
+ skipEmail: true,
2897
+ skipRuntimeRepo: local,
2898
+ skipPlane: skipPlane || local,
2899
+ skipBloodbank: local,
2900
+ skipSystemd: local || process.platform === "darwin"
2901
+ };
2902
+ agentResult = await runRecipeWithCapture("hermes-agent", context);
2903
+ }
2904
+ return asText({ ok: !agentResult || agentResult.success, dryRun, targetDir, projectSlug, actions, logs, errors, agentResult });
2905
+ } catch (err) {
2906
+ return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
2907
+ }
1343
2908
  }
1344
2909
  );
1345
2910
  server.registerTool(
@@ -1359,22 +2924,11 @@ server.registerTool(
1359
2924
  content: [{ type: "text", text: `Recipe not found: ${recipe}` }]
1360
2925
  };
1361
2926
  }
1362
- return {
1363
- content: [
1364
- {
1365
- type: "text",
1366
- text: JSON.stringify(
1367
- {
1368
- name: info.name,
1369
- description: info.description,
1370
- commands: info.commands
1371
- },
1372
- null,
1373
- 2
1374
- )
1375
- }
1376
- ]
1377
- };
2927
+ return asText({
2928
+ name: info.name,
2929
+ description: info.description,
2930
+ commands: info.commands
2931
+ });
1378
2932
  }
1379
2933
  );
1380
2934
  server.registerTool(
@@ -1400,22 +2954,13 @@ server.registerTool(
1400
2954
  const result = await runRecipeWithCapture(recipe, context);
1401
2955
  return {
1402
2956
  isError: !result.success,
1403
- content: [
1404
- {
1405
- type: "text",
1406
- text: JSON.stringify(
1407
- {
1408
- success: result.success,
1409
- recipe,
1410
- targetDir: resolvedTarget,
1411
- logs: result.logs,
1412
- errors: result.errors
1413
- },
1414
- null,
1415
- 2
1416
- )
1417
- }
1418
- ]
2957
+ ...asText({
2958
+ success: result.success,
2959
+ recipe,
2960
+ targetDir: resolvedTarget,
2961
+ logs: result.logs,
2962
+ errors: result.errors
2963
+ })
1419
2964
  };
1420
2965
  } catch (err) {
1421
2966
  return {
@@ -1429,15 +2974,16 @@ server.registerTool(
1429
2974
  "pjangler_deploy_hermes_agent",
1430
2975
  {
1431
2976
  title: "Deploy Hermes agent",
1432
- description: "Provision a Hermes agent role with an inherited named profile and optional integrations (Telegram, email, runtime repo, Plane, Bloodbank, systemd).",
2977
+ description: "Provision a Hermes agent role for @33god-projects. For safe MCP use local=true defaults skip runtime repo, Plane, Bloodbank, and systemd; opt out with local=false plus explicit skip flags.",
1433
2978
  inputSchema: {
1434
2979
  targetDir: z.string(),
1435
- targetRepo: z.string(),
1436
- role: z.string(),
2980
+ targetRepo: z.string().optional(),
2981
+ role: z.enum(["pm", "dev", "review", "ops", "qa"]),
1437
2982
  agentPurpose: z.string().optional(),
1438
2983
  soulTone: z.enum(["direct", "playful", "formal", "terse"]).optional(),
1439
2984
  modelProvider: z.string().optional(),
1440
2985
  modelName: z.string().optional(),
2986
+ local: z.boolean().optional(),
1441
2987
  force: z.boolean().optional(),
1442
2988
  dryRun: z.boolean().optional(),
1443
2989
  skipTelegram: z.boolean().optional(),
@@ -1445,61 +2991,58 @@ server.registerTool(
1445
2991
  skipRuntimeRepo: z.boolean().optional(),
1446
2992
  skipPlane: z.boolean().optional(),
1447
2993
  skipBloodbank: z.boolean().optional(),
1448
- skipSystemd: z.boolean().optional()
2994
+ skipSystemd: z.boolean().optional(),
2995
+ ticketProvider: TICKET_PROVIDER_SCHEMA.optional()
1449
2996
  }
1450
2997
  },
1451
2998
  async (input) => {
1452
2999
  try {
1453
3000
  const resolvedTarget = resolveTargetDir(input.targetDir);
3001
+ const local = input.local ?? true;
1454
3002
  const context = {
1455
3003
  targetDir: resolvedTarget,
1456
3004
  yes: true,
1457
- targetRepo: input.targetRepo,
3005
+ local,
3006
+ targetRepo: input.targetRepo ?? basename2(resolvedTarget),
1458
3007
  role: input.role,
1459
3008
  agentPurpose: input.agentPurpose,
1460
3009
  soulTone: input.soulTone,
1461
3010
  modelProvider: input.modelProvider,
1462
3011
  modelName: input.modelName,
3012
+ ticketProvider: input.ticketProvider,
1463
3013
  force: input.force ?? false,
1464
3014
  dryRun: input.dryRun ?? false,
1465
3015
  skipTelegram: input.skipTelegram ?? true,
1466
3016
  skipEmail: input.skipEmail ?? true,
1467
- skipRuntimeRepo: input.skipRuntimeRepo ?? false,
1468
- skipPlane: input.skipPlane ?? false,
1469
- skipBloodbank: input.skipBloodbank ?? false,
1470
- skipSystemd: input.skipSystemd ?? false
3017
+ skipRuntimeRepo: input.skipRuntimeRepo ?? local,
3018
+ skipPlane: input.skipPlane ?? local,
3019
+ skipBloodbank: input.skipBloodbank ?? local,
3020
+ skipSystemd: input.skipSystemd ?? (local || process.platform === "darwin")
1471
3021
  };
1472
3022
  const result = await runRecipeWithCapture("hermes-agent", context);
1473
3023
  return {
1474
3024
  isError: !result.success,
1475
- content: [
1476
- {
1477
- type: "text",
1478
- text: JSON.stringify(
1479
- {
1480
- success: result.success,
1481
- recipe: "hermes-agent",
1482
- targetDir: resolvedTarget,
1483
- context: {
1484
- targetRepo: context.targetRepo,
1485
- role: context.role,
1486
- dryRun: context.dryRun,
1487
- force: context.force,
1488
- skipTelegram: context.skipTelegram,
1489
- skipEmail: context.skipEmail,
1490
- skipRuntimeRepo: context.skipRuntimeRepo,
1491
- skipPlane: context.skipPlane,
1492
- skipBloodbank: context.skipBloodbank,
1493
- skipSystemd: context.skipSystemd
1494
- },
1495
- logs: result.logs,
1496
- errors: result.errors
1497
- },
1498
- null,
1499
- 2
1500
- )
1501
- }
1502
- ]
3025
+ ...asText({
3026
+ success: result.success,
3027
+ recipe: "hermes-agent",
3028
+ targetDir: resolvedTarget,
3029
+ guidance: parityGuidance(),
3030
+ context: {
3031
+ targetRepo: context.targetRepo,
3032
+ role: context.role,
3033
+ local: context.local,
3034
+ dryRun: context.dryRun,
3035
+ force: context.force,
3036
+ skipTelegram: context.skipTelegram,
3037
+ skipEmail: context.skipEmail,
3038
+ skipRuntimeRepo: context.skipRuntimeRepo,
3039
+ skipPlane: context.skipPlane,
3040
+ skipBloodbank: context.skipBloodbank,
3041
+ skipSystemd: context.skipSystemd
3042
+ },
3043
+ logs: result.logs,
3044
+ errors: result.errors
3045
+ })
1503
3046
  };
1504
3047
  } catch (err) {
1505
3048
  return {