@delorenj/pjangler 1.1.5 → 1.2.2

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 (139) hide show
  1. package/.mise/scripts/link-agentfiles.sh +9 -0
  2. package/dist/index.js +1492 -358
  3. package/dist/mcp-server.js +2391 -290
  4. package/package.json +5 -2
  5. package/templates/commonproject/.mise/scripts/create-plane-project.sh +26 -0
  6. package/templates/commonproject/copier.yml +2 -0
  7. package/templates/commonproject/template/.agents/hooks/README.md +151 -0
  8. package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +63 -0
  9. package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-journal-write.sh +327 -0
  10. package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-recall.sh +180 -0
  11. package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-retain.sh +74 -0
  12. package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-session-end.sh +57 -0
  13. package/templates/commonproject/template/.agents/hooks/hindsight/lib/hindsight-bank.sh +57 -0
  14. package/templates/commonproject/template/.agents/hooks/hindsight/lib/hindsight-journal.sh +48 -0
  15. package/templates/commonproject/template/.agents/hooks/hooks.master.json +97 -0
  16. package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +30 -0
  17. package/templates/commonproject/template/.agents/hooks/lib/local-config.sh +51 -0
  18. package/templates/commonproject/template/.agents/hooks/reminder-for-skill-check.sh +20 -0
  19. package/templates/commonproject/template/.agents/hooks/sync.py +618 -0
  20. package/templates/commonproject/template/.agents/local.example.json +15 -0
  21. package/templates/commonproject/template/.mise/scripts/hindsight-setup.sh +62 -0
  22. package/templates/commonproject/template/.mise/scripts/link-project-skills-to-clis.sh +110 -0
  23. package/templates/commonproject/template/.mise/scripts/unlink-project-skills-from-clis.sh +45 -0
  24. package/templates/commonproject/template/mise.toml.jinja +42 -0
  25. package/templates/hermes-agent/.codegraph/daemon.pid +6 -0
  26. package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +10 -0
  27. package/templates/hermes-agent/README.md +16 -14
  28. package/templates/hermes-agent/copier.yml +7 -25
  29. package/templates/hermes-agent/docs/architecture.md +16 -18
  30. package/templates/hermes-agent/docs/fleet-control-plane/README.md +35 -0
  31. package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +253 -0
  32. package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +280 -0
  33. package/templates/hermes-agent/docs/fleet-control-plane/implementation-readiness-report-2026-06-27.md +118 -0
  34. package/templates/hermes-agent/docs/fleet-control-plane/prd.md +141 -0
  35. package/templates/hermes-agent/docs/operations.md +17 -19
  36. package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +17 -13
  37. package/templates/hermes-agent/docs/{scrum-master → sentinel}/README.md +47 -46
  38. package/templates/hermes-agent/docs/{scrum-master → sentinel}/architecture.md +39 -38
  39. package/templates/hermes-agent/docs/{scrum-master → sentinel}/development.md +63 -99
  40. package/templates/hermes-agent/docs/{scrum-master → sentinel}/providers.md +2 -2
  41. package/templates/hermes-agent/docs/sentinel.md +18 -0
  42. package/templates/hermes-agent/install-local.sh +22 -20
  43. package/templates/hermes-agent/runtime-scaffold/README.md +10 -9
  44. package/templates/hermes-agent/scripts/backfill-fleet-sot.sh +12 -21
  45. package/templates/hermes-agent/scripts/fleet-sync.sh +369 -0
  46. package/templates/hermes-agent/scripts/migrate-unify.sh +252 -0
  47. package/templates/hermes-agent/scripts/unify-pm.sh +148 -0
  48. package/templates/hermes-agent/template/.runtime-scaffold/README.md +10 -9
  49. package/templates/hermes-agent/template/.scripts/01-config.sh +2 -3
  50. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -2
  51. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +47 -19
  52. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +71 -27
  53. package/templates/hermes-agent/template/.scripts/30-telegram.sh +3 -3
  54. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +3 -3
  55. package/templates/hermes-agent/template/.scripts/70-systemd.sh +48 -43
  56. package/templates/hermes-agent/template/.scripts/80-registry.sh +2 -2
  57. package/templates/hermes-agent/template/.scripts/99-summary.sh +3 -4
  58. package/templates/hermes-agent/template/.scripts/_lib.sh +4 -19
  59. package/templates/hermes-agent/template/.scripts/checkpoint.sh +36 -0
  60. package/templates/hermes-agent/template/.scripts/config.example.toml +11 -5
  61. package/templates/hermes-agent/template/.scripts/{scrum-master/continuous-ticket-sentinel.sh → heartbeat.sh} +84 -31
  62. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +1 -1
  63. package/templates/hermes-agent/template/.scripts/providers/plane.sh +9 -3
  64. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/bin/emit-event.py +5 -5
  65. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/bin/issue-autonomous-review.sh +6 -6
  66. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/bin/issue-close-gate.sh +2 -2
  67. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/docs/autonomous-delegated-review.md +7 -7
  68. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/docs/bloodbank-events.md +3 -3
  69. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/docs/continuous-ticket-orchestration.md +6 -6
  70. package/templates/hermes-agent/template/.scripts/{scrum-master/continuous-ticket-sentinel.prompt.md.jinja → sentinel.prompt.md.jinja} +5 -4
  71. package/templates/hermes-agent/template/SOUL.md.jinja +3 -6
  72. package/templates/hermes-agent/template/hermes.jinja +6 -15
  73. package/templates/hermes-agent/template/role.yaml.jinja +9 -8
  74. package/templates/commonproject/.claude/commands/bmad/bmb/agents/agent-builder.md +0 -14
  75. package/templates/commonproject/.claude/commands/bmad/bmb/agents/module-builder.md +0 -14
  76. package/templates/commonproject/.claude/commands/bmad/bmb/agents/workflow-builder.md +0 -14
  77. package/templates/commonproject/.claude/commands/bmad/bmb/workflows/agent.md +0 -5
  78. package/templates/commonproject/.claude/commands/bmad/bmb/workflows/module.md +0 -5
  79. package/templates/commonproject/.claude/commands/bmad/bmb/workflows/workflow.md +0 -5
  80. package/templates/commonproject/.claude/commands/bmad/bmm/agents/analyst.md +0 -14
  81. package/templates/commonproject/.claude/commands/bmad/bmm/agents/architect.md +0 -14
  82. package/templates/commonproject/.claude/commands/bmad/bmm/agents/dev.md +0 -14
  83. package/templates/commonproject/.claude/commands/bmad/bmm/agents/pm.md +0 -14
  84. package/templates/commonproject/.claude/commands/bmad/bmm/agents/quick-flow-solo-dev.md +0 -14
  85. package/templates/commonproject/.claude/commands/bmad/bmm/agents/sm.md +0 -14
  86. package/templates/commonproject/.claude/commands/bmad/bmm/agents/tea.md +0 -14
  87. package/templates/commonproject/.claude/commands/bmad/bmm/agents/tech-writer.md +0 -14
  88. package/templates/commonproject/.claude/commands/bmad/bmm/agents/ux-designer.md +0 -14
  89. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/check-implementation-readiness.md +0 -5
  90. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/code-review.md +0 -13
  91. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/correct-course.md +0 -13
  92. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-architecture.md +0 -5
  93. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-epics-and-stories.md +0 -5
  94. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-dataflow.md +0 -13
  95. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-diagram.md +0 -13
  96. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-flowchart.md +0 -13
  97. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-wireframe.md +0 -13
  98. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-product-brief.md +0 -5
  99. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-story.md +0 -13
  100. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-ux-design.md +0 -5
  101. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/dev-story.md +0 -13
  102. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/document-project.md +0 -13
  103. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/generate-project-context.md +0 -5
  104. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/prd.md +0 -5
  105. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/quick-dev.md +0 -5
  106. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/quick-spec.md +0 -5
  107. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/research.md +0 -5
  108. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/retrospective.md +0 -13
  109. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/sprint-planning.md +0 -13
  110. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/sprint-status.md +0 -13
  111. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-atdd.md +0 -13
  112. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-automate.md +0 -13
  113. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-ci.md +0 -13
  114. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-framework.md +0 -13
  115. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-nfr.md +0 -13
  116. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-test-design.md +0 -13
  117. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-test-review.md +0 -13
  118. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-trace.md +0 -13
  119. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/workflow-init.md +0 -13
  120. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/workflow-status.md +0 -13
  121. package/templates/commonproject/.claude/commands/bmad/cis/agents/brainstorming-coach.md +0 -14
  122. package/templates/commonproject/.claude/commands/bmad/cis/agents/creative-problem-solver.md +0 -14
  123. package/templates/commonproject/.claude/commands/bmad/cis/agents/design-thinking-coach.md +0 -14
  124. package/templates/commonproject/.claude/commands/bmad/cis/agents/innovation-strategist.md +0 -14
  125. package/templates/commonproject/.claude/commands/bmad/cis/agents/presentation-master.md +0 -14
  126. package/templates/commonproject/.claude/commands/bmad/cis/agents/storyteller.md +0 -14
  127. package/templates/commonproject/.claude/commands/bmad/cis/workflows/design-thinking.md +0 -13
  128. package/templates/commonproject/.claude/commands/bmad/cis/workflows/innovation-strategy.md +0 -13
  129. package/templates/commonproject/.claude/commands/bmad/cis/workflows/problem-solving.md +0 -13
  130. package/templates/commonproject/.claude/commands/bmad/cis/workflows/storytelling.md +0 -13
  131. package/templates/commonproject/.claude/commands/bmad/core/agents/bmad-master.md +0 -14
  132. package/templates/commonproject/.claude/commands/bmad/core/tasks/index-docs.md +0 -9
  133. package/templates/commonproject/.claude/commands/bmad/core/tasks/shard-doc.md +0 -9
  134. package/templates/commonproject/.claude/commands/bmad/core/workflows/brainstorming.md +0 -5
  135. package/templates/commonproject/.claude/commands/bmad/core/workflows/party-mode.md +0 -5
  136. package/templates/commonproject/.claude/commands/bmad/custom/workflows/ticket-lifecycle.md +0 -5
  137. package/templates/hermes-agent/docs/scrum-master-sentinel.md +0 -17
  138. package/templates/hermes-agent/template/.scripts/75-scrum-master.sh +0 -117
  139. package/templates/hermes-agent/template/.scripts/90-chain-scrum-master.sh +0 -45
@@ -1,19 +1,16 @@
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 { existsSync as existsSync9, statSync as statSync2 } from "node:fs";
5
+ import { basename as basename4, dirname as dirname8, join as join11, resolve as resolve3 } from "node:path";
6
+ import { fileURLToPath as fileURLToPath5 } from "node:url";
12
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
9
  import { z } from "zod";
15
10
 
16
11
  // src/commands/Command.ts
12
+ import { existsSync, writeFileSync, mkdirSync } from "fs";
13
+ import { join, dirname } from "path";
17
14
  var Command = class {
18
15
  context;
19
16
  constructor(context) {
@@ -26,30 +23,24 @@ var Command = class {
26
23
  return this.context.dryRun ? `[DRY RUN] ${message}` : message;
27
24
  }
28
25
  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);
26
+ const fullPath = join(this.context.targetDir, filePath);
27
+ return existsSync(fullPath);
33
28
  }
34
29
  writeFile(filePath, content) {
35
30
  if (this.context.dryRun) {
36
31
  return;
37
32
  }
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);
33
+ const fullPath = join(this.context.targetDir, filePath);
34
+ const dir = dirname(fullPath);
35
+ mkdirSync(dir, { recursive: true });
36
+ writeFileSync(fullPath, content);
44
37
  }
45
38
  createDirectory(dirPath) {
46
39
  if (this.context.dryRun) {
47
40
  return;
48
41
  }
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 });
42
+ const fullPath = join(this.context.targetDir, dirPath);
43
+ mkdirSync(fullPath, { recursive: true });
53
44
  }
54
45
  };
55
46
 
@@ -460,32 +451,32 @@ var NodeRecipe = class extends Recipe {
460
451
 
461
452
  // src/commands/hermes/EnsureTemplateConfig.ts
462
453
  import { homedir, platform } from "node:os";
463
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
464
- import { join, dirname } from "node:path";
454
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
455
+ import { join as join2, dirname as dirname2 } from "node:path";
465
456
  function resolveTemplateConfigPath() {
466
457
  const fromEnv = process.env.HERMES_TEMPLATE_CONFIG;
467
458
  if (fromEnv && fromEnv.trim()) return fromEnv.trim();
468
459
  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");
460
+ const base = xdg && xdg.length ? xdg : join2(homedir(), ".config");
461
+ return join2(base, "hermes-agent-template", "config.toml");
471
462
  }
472
463
  function detectHermesBin(home) {
473
464
  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")
465
+ join2(home, "code", "hermes-agent", "venv", "bin", "hermes"),
466
+ join2(home, "code", "hermes-agent", ".venv", "bin", "hermes"),
467
+ join2(home, ".local", "bin", "hermes")
477
468
  ];
478
469
  for (const c of candidates) {
479
- if (existsSync(c)) return c;
470
+ if (existsSync2(c)) return c;
480
471
  }
481
472
  return candidates[0];
482
473
  }
483
474
  function renderHostConfig() {
484
475
  const home = homedir();
485
476
  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");
477
+ const hermesRepo = join2(home, "code", "hermes-agent");
478
+ const scaffoldDir = join2(home, "code", "hermes-agent-template", "runtime-scaffold");
479
+ const skillsDir = join2(home, ".agents", "skills");
489
480
  return `# hermes-agent-template \u2014 host configuration
490
481
  # Bootstrapped by \`pjangler config bootstrap\` for $HOME=${home} (platform=${platform()}).
491
482
  #
@@ -529,7 +520,7 @@ var EnsureTemplateConfig = class extends Command {
529
520
  const ctx = this.context;
530
521
  const force = ctx.forceConfig === true || process.env.PJANGLER_FORCE_CONFIG === "1";
531
522
  const path = resolveTemplateConfigPath();
532
- const exists = existsSync(path);
523
+ const exists = existsSync2(path);
533
524
  if (exists && !force) {
534
525
  console.log(`\u2713 Config present: ${path}`);
535
526
  return { success: true, message: "" };
@@ -539,8 +530,8 @@ var EnsureTemplateConfig = class extends Command {
539
530
  return { success: true, message: "" };
540
531
  }
541
532
  try {
542
- mkdirSync(dirname(path), { recursive: true });
543
- writeFileSync(path, renderHostConfig());
533
+ mkdirSync2(dirname2(path), { recursive: true });
534
+ writeFileSync2(path, renderHostConfig());
544
535
  } catch (err) {
545
536
  const msg = err instanceof Error ? err.message : String(err);
546
537
  return { success: false, message: `\u2717 Failed to write ${path}: ${msg}` };
@@ -552,7 +543,7 @@ var EnsureTemplateConfig = class extends Command {
552
543
  };
553
544
 
554
545
  // src/commands/hermes/PromptForAgentConfig.ts
555
- import { basename, join as join2 } from "node:path";
546
+ import { basename, join as join3 } from "node:path";
556
547
  import { readFileSync } from "node:fs";
557
548
  import * as p from "@clack/prompts";
558
549
 
@@ -560,12 +551,7 @@ import * as p from "@clack/prompts";
560
551
  var HERMES_AGENT_TEMPLATE = "gh:delorenj/hermes-agent-template";
561
552
  var SOUL_TONES = ["direct", "playful", "formal", "terse"];
562
553
  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
- },
554
+ { value: "pm", label: "Project Manager (pm)", hint: "triage, planning, ticket authorship, board reconciliation" },
569
555
  { value: "dev", label: "Developer (dev)", hint: "implements tickets" },
570
556
  { value: "review", label: "Reviewer (review)", hint: "adversarial code review" },
571
557
  { value: "ops", label: "Ops (ops)", hint: "deploy / infra" },
@@ -586,7 +572,7 @@ function deriveProfileName(repo, role) {
586
572
  // src/commands/hermes/PromptForAgentConfig.ts
587
573
  function detectTicketProvider(targetDir) {
588
574
  try {
589
- const t = JSON.parse(readFileSync(join2(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
575
+ const t = JSON.parse(readFileSync(join3(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
590
576
  return t === "plane" || t === "linear" || t === "trello" ? t : void 0;
591
577
  } catch {
592
578
  return void 0;
@@ -605,7 +591,6 @@ var PromptForAgentConfig = class extends Command {
605
591
  ctx.modelProvider ??= "";
606
592
  ctx.modelName ??= "";
607
593
  ctx.ticketProvider ??= detectTicketProvider(ctx.targetDir) ?? "plane";
608
- ctx.withScrumMaster ??= false;
609
594
  ctx.skipTelegram ??= true;
610
595
  ctx.skipEmail ??= true;
611
596
  ctx.agentId = deriveAgentId(ctx.targetRepo, ctx.role);
@@ -651,14 +636,6 @@ var PromptForAgentConfig = class extends Command {
651
636
  if (p.isCancel(answer)) return this.cancelled();
652
637
  ctx.ticketProvider = answer;
653
638
  }
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
639
  if (!ctx.agentPurpose) {
663
640
  const answer = await p.text({
664
641
  message: "One-line purpose",
@@ -731,21 +708,21 @@ var PromptForAgentConfig = class extends Command {
731
708
  // src/commands/hermes/RunCopierTemplate.ts
732
709
  import { spawnSync } from "node:child_process";
733
710
  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";
711
+ import { join as join4, dirname as dirname3 } from "node:path";
712
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3 } from "node:fs";
736
713
  import { fileURLToPath } from "node:url";
737
714
  import * as p2 from "@clack/prompts";
738
715
  function resolveVendoredTemplate(name) {
739
716
  let dir;
740
717
  try {
741
- dir = dirname2(fileURLToPath(import.meta.url));
718
+ dir = dirname3(fileURLToPath(import.meta.url));
742
719
  } catch {
743
720
  return void 0;
744
721
  }
745
722
  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);
723
+ const candidate = join4(dir, "templates", name);
724
+ if (existsSync3(join4(candidate, "copier.yml"))) return candidate;
725
+ const parent = dirname3(dir);
749
726
  if (parent === dir) break;
750
727
  dir = parent;
751
728
  }
@@ -757,14 +734,13 @@ var RunCopierTemplate = class extends Command {
757
734
  const { targetRepo, role, agentPurpose, soulTone, modelProvider, modelName } = ctx;
758
735
  const ticketProvider = ctx.ticketProvider ?? "plane";
759
736
  const profileName = ctx.profileName ?? (targetRepo && role ? deriveProfileName(targetRepo, role) : void 0);
760
- const withScrumMaster = role === "pm" && ctx.withScrumMaster === true;
761
737
  if (!targetRepo || !role) {
762
738
  return {
763
739
  success: false,
764
740
  message: "PromptForAgentConfig must run before RunCopierTemplate (targetRepo/role unset)"
765
741
  };
766
742
  }
767
- const roleDir = join3(ctx.targetDir, "agents", "hermes", role);
743
+ const roleDir = join4(ctx.targetDir, "agents", "hermes", role);
768
744
  ctx.roleDir = roleDir;
769
745
  ctx.runtimeRepo = `delorenj/agent-hm-${targetRepo}-${role}`;
770
746
  const which = spawnSync("which", ["copier"], { encoding: "utf8" });
@@ -774,7 +750,7 @@ var RunCopierTemplate = class extends Command {
774
750
  message: "\u2717 copier not found on PATH. Install with: `uv tool install copier` or `pip install copier`"
775
751
  };
776
752
  }
777
- if (existsSync2(join3(roleDir, "role.yaml")) && !ctx.force) {
753
+ if (existsSync3(join4(roleDir, "role.yaml")) && !ctx.force) {
778
754
  if (ctx.yes) {
779
755
  ctx.force = true;
780
756
  } else {
@@ -801,9 +777,9 @@ var RunCopierTemplate = class extends Command {
801
777
  SKIP_BLOODBANK: ctx.skipBloodbank ? "1" : "0",
802
778
  SKIP_SYSTEMD: ctx.skipSystemd ? "1" : "0"
803
779
  };
804
- const LOCAL_TEMPLATE = join3(homedir2(), "code", "hermes-agent-template");
780
+ const LOCAL_TEMPLATE = join4(homedir2(), "code", "hermes-agent-template");
805
781
  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);
782
+ const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync3(join4(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
807
783
  const args = [
808
784
  "copy",
809
785
  templateSrc,
@@ -824,8 +800,6 @@ var RunCopierTemplate = class extends Command {
824
800
  `soul_tone=${soulTone ?? "direct"}`,
825
801
  "--data",
826
802
  `ticket_provider=${ticketProvider}`,
827
- "--data",
828
- `with_scrum_master=${withScrumMaster}`,
829
803
  "--trust",
830
804
  "--vcs-ref=HEAD"
831
805
  ];
@@ -836,7 +810,7 @@ var RunCopierTemplate = class extends Command {
836
810
  message: this.formatMessage(`Would run: copier ${args.join(" ")}`)
837
811
  };
838
812
  }
839
- mkdirSync2(join3(ctx.targetDir, "agents", "hermes"), { recursive: true });
813
+ mkdirSync3(join4(ctx.targetDir, "agents", "hermes"), { recursive: true });
840
814
  const spinner4 = p2.spinner();
841
815
  spinner4.start(`Running copier copy (target: agents/hermes/${role})`);
842
816
  const result = spawnSync("copier", args, {
@@ -861,8 +835,8 @@ var RunCopierTemplate = class extends Command {
861
835
 
862
836
  // src/commands/hermes/WireTelegram.ts
863
837
  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";
838
+ import { join as join5 } from "node:path";
839
+ import { existsSync as existsSync4, unlinkSync } from "node:fs";
866
840
  import * as p3 from "@clack/prompts";
867
841
  var WireTelegram = class extends Command {
868
842
  async invoke() {
@@ -950,15 +924,15 @@ var WireTelegram = class extends Command {
950
924
  if (p3.isCancel(allowedAnswer)) {
951
925
  return { success: false, message: "\u2717 Aborted; Telegram step deferred." };
952
926
  }
953
- const script = join4(roleDir, ".scripts", "30-telegram.sh");
954
- if (!existsSync3(script)) {
927
+ const script = join5(roleDir, ".scripts", "30-telegram.sh");
928
+ if (!existsSync4(script)) {
955
929
  return {
956
930
  success: false,
957
931
  message: `\u2717 ${script} not found. Did copier finish? Re-run with --skip-runtime-repo=0 if you skipped it.`
958
932
  };
959
933
  }
960
- const marker = join4(roleDir, ".scripts", ".done-30-telegram");
961
- if (existsSync3(marker)) unlinkSync(marker);
934
+ const marker = join5(roleDir, ".scripts", ".done-30-telegram");
935
+ if (existsSync4(marker)) unlinkSync(marker);
962
936
  const spinner4 = p3.spinner();
963
937
  spinner4.start("Verifying token + wiring profile");
964
938
  const result = spawnSync2("bash", [script], {
@@ -985,8 +959,8 @@ function cap(s) {
985
959
 
986
960
  // src/commands/hermes/WireEmail.ts
987
961
  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";
962
+ import { join as join6 } from "node:path";
963
+ import { existsSync as existsSync5, unlinkSync as unlinkSync2 } from "node:fs";
990
964
  import * as p4 from "@clack/prompts";
991
965
  var WireEmail = class extends Command {
992
966
  async invoke() {
@@ -1001,8 +975,8 @@ var WireEmail = class extends Command {
1001
975
  if (!targetRepo || !role || !roleDir) {
1002
976
  return { success: false, message: "Cannot wire email: missing target_repo/role/roleDir" };
1003
977
  }
1004
- const script = join5(roleDir, ".scripts", "50-email.sh");
1005
- if (!existsSync4(script)) {
978
+ const script = join6(roleDir, ".scripts", "50-email.sh");
979
+ if (!existsSync5(script)) {
1006
980
  return { success: false, message: `\u2717 ${script} not found` };
1007
981
  }
1008
982
  let token = process.env.CF_EMAIL_ROUTING_TOKEN;
@@ -1064,8 +1038,8 @@ var WireEmail = class extends Command {
1064
1038
  }
1065
1039
  }
1066
1040
  }
1067
- const marker = join5(roleDir, ".scripts", ".done-50-email");
1068
- if (existsSync4(marker)) unlinkSync2(marker);
1041
+ const marker = join6(roleDir, ".scripts", ".done-50-email");
1042
+ if (existsSync5(marker)) unlinkSync2(marker);
1069
1043
  const spinner4 = p4.spinner();
1070
1044
  spinner4.start("Creating Cloudflare Email Routing rule");
1071
1045
  const result = spawnSync3("bash", [script], {
@@ -1094,7 +1068,7 @@ var PrintHermesSummary = class extends Command {
1094
1068
  const email = `${targetRepo}-${role}@delo.sh`;
1095
1069
  const gw = `hermes-${agentId}-gateway.service`;
1096
1070
  const csm = `hermes-${agentId}-consumer.service`;
1097
- const ckpt = `hermes-${agentId}-checkpoint.timer`;
1071
+ const hb = `hermes-${agentId}-heartbeat.timer`;
1098
1072
  const lines = [];
1099
1073
  lines.push(`agent_id ${agentId}`);
1100
1074
  lines.push(`role dir ${ctx.roleDir}`);
@@ -1104,7 +1078,7 @@ var PrintHermesSummary = class extends Command {
1104
1078
  lines.push("");
1105
1079
  lines.push("Start daemons:");
1106
1080
  lines.push(` systemctl --user start ${csm}`);
1107
- lines.push(` systemctl --user start ${ckpt}`);
1081
+ lines.push(` systemctl --user start ${hb}`);
1108
1082
  if (!skipTelegram) {
1109
1083
  lines.push(` systemctl --user start ${gw}`);
1110
1084
  } else {
@@ -1151,6 +1125,193 @@ var HermesAgentRecipe = class extends Recipe {
1151
1125
  }
1152
1126
  };
1153
1127
 
1128
+ // src/commands/AgentHooksCommands.ts
1129
+ import { homedir as homedir3 } from "node:os";
1130
+ import { join as join7, dirname as dirname4 } from "node:path";
1131
+ import { existsSync as existsSync6, cpSync, mkdirSync as mkdirSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "node:fs";
1132
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1133
+ function resolveTemplateRoot() {
1134
+ const candidates = [];
1135
+ if (process.env.PJANGLER_COMMONPROJECT_TEMPLATE) {
1136
+ candidates.push(process.env.PJANGLER_COMMONPROJECT_TEMPLATE);
1137
+ }
1138
+ try {
1139
+ let dir = dirname4(fileURLToPath2(import.meta.url));
1140
+ for (let i = 0; i < 8; i++) {
1141
+ candidates.push(join7(dir, "templates", "commonproject", "template"));
1142
+ const parent = dirname4(dir);
1143
+ if (parent === dir) break;
1144
+ dir = parent;
1145
+ }
1146
+ } catch {
1147
+ }
1148
+ candidates.push(join7(homedir3(), "code", "pjangler", "templates", "commonproject", "template"));
1149
+ for (const c of candidates) {
1150
+ if (existsSync6(join7(c, ".agents", "hooks", "hooks.master.json"))) return c;
1151
+ }
1152
+ throw new Error(
1153
+ "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
1154
+ );
1155
+ }
1156
+ var CopyAgentHooksTree = class extends Command {
1157
+ async invoke() {
1158
+ let templateRoot;
1159
+ try {
1160
+ templateRoot = resolveTemplateRoot();
1161
+ } catch (e) {
1162
+ return { success: false, message: `\u26A0\uFE0F ${e.message}` };
1163
+ }
1164
+ const items = [
1165
+ { rel: ".agents/hooks", dir: true },
1166
+ { rel: ".agents/local.example.json", dir: false },
1167
+ { rel: ".mise/scripts/link-project-skills-to-clis.sh", dir: false },
1168
+ { rel: ".mise/scripts/unlink-project-skills-from-clis.sh", dir: false },
1169
+ { rel: ".mise/scripts/hindsight-setup.sh", dir: false }
1170
+ ];
1171
+ const created = [];
1172
+ const skipped = [];
1173
+ for (const { rel, dir } of items) {
1174
+ const src = join7(templateRoot, rel);
1175
+ const dest = join7(this.context.targetDir, rel);
1176
+ if (!existsSync6(src)) continue;
1177
+ if (existsSync6(dest) && !this.context.force) {
1178
+ skipped.push(rel);
1179
+ continue;
1180
+ }
1181
+ if (!this.context.dryRun) {
1182
+ mkdirSync4(dirname4(dest), { recursive: true });
1183
+ cpSync(src, dest, { recursive: dir, force: true });
1184
+ }
1185
+ created.push(rel);
1186
+ }
1187
+ const verb = this.context.dryRun ? "Would copy" : "Copied";
1188
+ const tail = skipped.length ? ` (${skipped.length} already present \u2014 use --force to overwrite)` : "";
1189
+ return {
1190
+ success: created.length > 0,
1191
+ message: this.formatMessage(`\u2705 ${verb} ${created.length} agent-hooks path(s)${tail}`)
1192
+ };
1193
+ }
1194
+ };
1195
+ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
1196
+ static MARKER = "# pjangler:agent-hooks";
1197
+ static CR = "{{config_root}}";
1198
+ // mise's own runtime var — emitted literally
1199
+ async invoke() {
1200
+ const misePath = join7(this.context.targetDir, "mise.toml");
1201
+ if (!existsSync6(misePath)) {
1202
+ return {
1203
+ success: false,
1204
+ message: "\u26A0\uFE0F No mise.toml found \u2014 run `pjangler init mise` first, then re-run."
1205
+ };
1206
+ }
1207
+ let content = readFileSync2(misePath, "utf8");
1208
+ if (content.includes(_WireMiseAgentHooks.MARKER)) {
1209
+ return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for agent-hooks") };
1210
+ }
1211
+ const cr = _WireMiseAgentHooks.CR;
1212
+ const enterAdds = [
1213
+ ` "${cr}/.mise/scripts/link-project-skills-to-clis.sh",`,
1214
+ ` "${cr}/.agents/hooks/sync.py --install --quiet",`
1215
+ ].join("\n");
1216
+ const leaveBlock = [
1217
+ "leave = [",
1218
+ ` "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",`,
1219
+ ` "${cr}/.agents/hooks/sync.py --uninstall --quiet",`,
1220
+ "]"
1221
+ ].join("\n");
1222
+ let wiredHooks = false;
1223
+ const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
1224
+ if (enterRe.test(content)) {
1225
+ content = content.replace(enterRe, (_m, head, close) => {
1226
+ const sep = /[,[]\s*$/.test(head) ? "" : ",";
1227
+ return `${head}${sep}
1228
+ ${enterAdds}${close}`;
1229
+ });
1230
+ const leaveRe = /(leave\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
1231
+ if (leaveRe.test(content)) {
1232
+ content = content.replace(leaveRe, (_m, head, close) => {
1233
+ const sep = /[,[]\s*$/.test(head) ? "" : ",";
1234
+ return `${head}${sep}
1235
+ "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",
1236
+ "${cr}/.agents/hooks/sync.py --uninstall --quiet",${close}`;
1237
+ });
1238
+ } else {
1239
+ content = content.replace(enterRe, (m) => `${m}
1240
+ ${leaveBlock}`);
1241
+ }
1242
+ wiredHooks = true;
1243
+ }
1244
+ const appended = [
1245
+ "",
1246
+ _WireMiseAgentHooks.MARKER + " (generated \u2014 see .agents/hooks/README.md)",
1247
+ "[[watch_files]]",
1248
+ 'patterns = [".agents/hooks/hooks.master.json"]',
1249
+ 'task = "hooks-sync"',
1250
+ "",
1251
+ "[tasks.hooks-sync]",
1252
+ 'description = "Fan out hooks.master.json to each agent CLI (claude/codex/kimi/hermes)"',
1253
+ `run = "${cr}/.agents/hooks/sync.py --install"`,
1254
+ "",
1255
+ "[tasks.hooks-check]",
1256
+ 'description = "Drift gate: verify generated hook configs match hooks.master.json"',
1257
+ `run = "${cr}/.agents/hooks/sync.py --check"`,
1258
+ "",
1259
+ "[tasks.hooks-uninstall]",
1260
+ 'description = "Remove per-user agent-hook injections (codex/kimi/hermes)"',
1261
+ `run = "${cr}/.agents/hooks/sync.py --uninstall"`,
1262
+ "",
1263
+ "[tasks.link-project-skills-to-clis]",
1264
+ 'description = "Fan .agents/skills out to each agent CLI (honors local.json)"',
1265
+ `run = "${cr}/.mise/scripts/link-project-skills-to-clis.sh"`,
1266
+ "",
1267
+ "[tasks.unlink-project-skills-from-clis]",
1268
+ 'description = "Remove project skill symlinks from shared per-CLI dirs"',
1269
+ `run = "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh"`,
1270
+ "",
1271
+ "[tasks.skills-relink]",
1272
+ 'description = "Re-fan the project skill set to all CLIs"',
1273
+ `run = "${cr}/.mise/scripts/link-project-skills-to-clis.sh"`,
1274
+ "",
1275
+ "[tasks.hindsight-setup]",
1276
+ `description = "Provision this dev's shared project Hindsight key from 1Password into .env"`,
1277
+ `run = "${cr}/.mise/scripts/hindsight-setup.sh"`,
1278
+ "",
1279
+ _WireMiseAgentHooks.MARKER + ":end",
1280
+ ""
1281
+ ].join("\n");
1282
+ content = content.replace(/\n*$/, "\n") + appended;
1283
+ if (!this.context.dryRun) writeFileSync3(misePath, content);
1284
+ if (wiredHooks) {
1285
+ return { success: true, message: this.formatMessage("\u2705 Wired mise.toml ([hooks] enter/leave + tasks)") };
1286
+ }
1287
+ return {
1288
+ success: true,
1289
+ message: this.formatMessage(
1290
+ `\u2705 Added agent-hooks tasks to mise.toml.
1291
+ \u26A0\uFE0F Could not find a [hooks].enter array to extend \u2014 add these to your [hooks] block manually:
1292
+ enter += "${cr}/.mise/scripts/link-project-skills-to-clis.sh", "${cr}/.agents/hooks/sync.py --install --quiet"
1293
+ leave += "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh", "${cr}/.agents/hooks/sync.py --uninstall --quiet"`
1294
+ )
1295
+ };
1296
+ }
1297
+ };
1298
+
1299
+ // src/recipes/AgentHooksRecipe.ts
1300
+ var AgentHooksRecipe = class extends Recipe {
1301
+ constructor(context) {
1302
+ super(context);
1303
+ this.addIngredient(CopyAgentHooksTree).addIngredient(WireMiseAgentHooks);
1304
+ }
1305
+ printNextSteps() {
1306
+ console.log("\u{1FA9D} Agent-hooks layer installed!");
1307
+ console.log(" Next steps:");
1308
+ console.log(" 1. mise run hooks-sync # generate .claude/settings.json + inject codex/kimi/hermes");
1309
+ console.log(" 2. git add .claude/settings.json .agents/hooks && commit (codex/kimi/hermes are per-dev)");
1310
+ console.log(" 3. mise run hindsight-setup # set HINDSIGHT_OP_KEY_REF to your 1Password item first");
1311
+ console.log(` 4. If you run a global agent system: echo '{"skills":{"defer_to_global":true}}' > .agents/local.json`);
1312
+ }
1313
+ };
1314
+
1154
1315
  // src/utils/registry.ts
1155
1316
  var RECIPE_REGISTRY = {
1156
1317
  mise: {
@@ -1184,9 +1345,27 @@ var RECIPE_REGISTRY = {
1184
1345
  "WireEmail",
1185
1346
  "PrintHermesSummary"
1186
1347
  ]
1348
+ },
1349
+ "agent-hooks": {
1350
+ name: "agent-hooks",
1351
+ description: "Retrofit the project-scoped agent-hooks + skill fan-out layer (Claude/Codex/Kimi/Hermes hooks via mise enter/leave)",
1352
+ class: AgentHooksRecipe,
1353
+ commands: ["CopyAgentHooksTree", "WireMiseAgentHooks"]
1187
1354
  }
1188
1355
  };
1189
1356
  var COMMAND_REGISTRY = {
1357
+ CopyAgentHooksTree: {
1358
+ name: "CopyAgentHooksTree",
1359
+ description: "Copy the generic agent-hooks tree (hooks SSOT + sync engine + scripts) from the CommonProject template",
1360
+ group: "agent-hooks",
1361
+ class: CopyAgentHooksTree
1362
+ },
1363
+ WireMiseAgentHooks: {
1364
+ name: "WireMiseAgentHooks",
1365
+ description: "Merge agent-hooks enter/leave + tasks into an existing mise.toml (idempotent)",
1366
+ group: "agent-hooks",
1367
+ class: WireMiseAgentHooks
1368
+ },
1190
1369
  AddDockerfile: {
1191
1370
  name: "AddDockerfile",
1192
1371
  description: "Create Dockerfile for containerization",
@@ -1249,18 +1428,18 @@ function createRecipe(name, context) {
1249
1428
  }
1250
1429
 
1251
1430
  // 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";
1431
+ import { readFileSync as readFileSync3 } from "node:fs";
1432
+ import { dirname as dirname5, join as join8 } from "node:path";
1433
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
1255
1434
  var PJANGLER_VERSION = (() => {
1256
1435
  try {
1257
- let dir = dirname3(fileURLToPath2(import.meta.url));
1436
+ let dir = dirname5(fileURLToPath3(import.meta.url));
1258
1437
  for (let i = 0; i < 4; i++) {
1259
1438
  try {
1260
- const raw = readFileSync2(join6(dir, "package.json"), "utf8");
1439
+ const raw = readFileSync3(join8(dir, "package.json"), "utf8");
1261
1440
  return JSON.parse(raw).version ?? "0.0.0";
1262
1441
  } catch {
1263
- const parent = dirname3(dir);
1442
+ const parent = dirname5(dir);
1264
1443
  if (parent === dir) break;
1265
1444
  dir = parent;
1266
1445
  }
@@ -1270,236 +1449,2158 @@ var PJANGLER_VERSION = (() => {
1270
1449
  return "0.0.0";
1271
1450
  })();
1272
1451
 
1273
- // src/mcp-server.ts
1274
- var server = new McpServer({
1275
- name: "pjangler-mcp",
1276
- version: PJANGLER_VERSION
1277
- });
1278
- function resolveTargetDir(targetDir) {
1279
- const dir = resolve(targetDir ?? process.cwd());
1280
- if (!existsSync5(dir)) {
1281
- throw new Error(`Target directory does not exist: ${dir}`);
1282
- }
1283
- if (!statSync(dir).isDirectory()) {
1284
- throw new Error(`Target path is not a directory: ${dir}`);
1452
+ // src/parity/index.ts
1453
+ 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";
1454
+ import { basename as basename2, dirname as dirname6, join as join9, relative, resolve } from "node:path";
1455
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
1456
+ import { homedir as homedir4 } from "node:os";
1457
+ import { spawnSync as spawnSync4 } from "node:child_process";
1458
+ var LINK_AGENTFILES_BLOCK = `# This block will handle the linking of
1459
+ # agent files to the main AGENTS.md file.
1460
+ #
1461
+ # TODO: Ensure this works for all levels of nesting.
1462
+ # i.e. All linked agent files MUST be siblings at
1463
+ # any given level of nesting.
1464
+ [hooks]
1465
+ enter = [
1466
+ "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1467
+ "op inject -i .env.op > .env",
1468
+ ]
1469
+
1470
+ [[watch_files]]
1471
+ patterns = ["AGENTS.md"]
1472
+ task = "link-agentfiles"
1473
+
1474
+ [tasks.link-agentfiles]
1475
+ description = "Symlink all agent files to AGENTS.md"
1476
+ run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
1477
+ var LINK_AGENTFILES_HOOK_ENTRIES = [
1478
+ "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1479
+ "op inject -i .env.op > .env"
1480
+ ];
1481
+ var LINK_AGENTFILES_HOOKS_BLOCK = `# This block will handle the linking of
1482
+ # agent files to the main AGENTS.md file.
1483
+ #
1484
+ # TODO: Ensure this works for all levels of nesting.
1485
+ # i.e. All linked agent files MUST be siblings at
1486
+ # any given level of nesting.
1487
+ [hooks]
1488
+ enter = [
1489
+ "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1490
+ "op inject -i .env.op > .env",
1491
+ ]`;
1492
+ var LINK_AGENTFILES_WATCH_TASK_BLOCK = `[[watch_files]]
1493
+ patterns = ["AGENTS.md"]
1494
+ task = "link-agentfiles"
1495
+
1496
+ [tasks.link-agentfiles]
1497
+ description = "Symlink all agent files to AGENTS.md"
1498
+ run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
1499
+ var VERSIONING_BLOCK = `# >>> mise-versioning >>> (managed block \u2014 do not edit by hand; re-run init to update)
1500
+ [tasks."version"]
1501
+ description = "Print the current version (vX.Y.Z)"
1502
+ run = "{{config_root}}/.mise/scripts/versioning.sh current"
1503
+
1504
+ [tasks."version:bump"]
1505
+ description = "Bump patch version: vX.Y.Z -> vX.Y.(Z+1)"
1506
+ alias = "version:bump-patch"
1507
+ run = "{{config_root}}/.mise/scripts/versioning.sh bump patch"
1508
+
1509
+ [tasks."version:bump-minor"]
1510
+ description = "Bump minor version: vX.Y.Z -> vX.(Y+1).0"
1511
+ run = "{{config_root}}/.mise/scripts/versioning.sh bump minor"
1512
+
1513
+ [tasks."version:bump-major"]
1514
+ description = "Bump major version: vX.Y.Z -> v(X+1).0.0"
1515
+ run = "{{config_root}}/.mise/scripts/versioning.sh bump major"
1516
+
1517
+ [tasks."version:check"]
1518
+ description = "Verify every versioned file is in parity"
1519
+ run = "{{config_root}}/.mise/scripts/versioning.sh check"
1520
+
1521
+ [tasks."version:sync"]
1522
+ description = "Force every versioned file up to the highest version"
1523
+ run = "{{config_root}}/.mise/scripts/versioning.sh sync"
1524
+ # <<< mise-versioning <<<`;
1525
+ function resolvePjanglerRoot() {
1526
+ let dir = dirname6(fileURLToPath4(import.meta.url));
1527
+ while (dir !== dirname6(dir)) {
1528
+ if (existsSync7(join9(dir, "package.json")) && existsSync7(join9(dir, "templates", "commonproject", "copier.yml"))) {
1529
+ return dir;
1530
+ }
1531
+ dir = dirname6(dir);
1285
1532
  }
1286
- return dir;
1533
+ throw new Error("Unable to resolve pjangler root");
1287
1534
  }
1288
- async function runRecipeWithCapture(recipeName, context) {
1289
- const recipe = createRecipe(recipeName, context);
1290
- if (!recipe) {
1291
- return {
1292
- success: false,
1293
- logs: [],
1294
- errors: [`Unknown recipe: ${recipeName}. Available: ${getRecipeNames().join(", ")}`]
1295
- };
1535
+ function normalizeNewlines(value) {
1536
+ return value.replace(/\r\n/g, "\n");
1537
+ }
1538
+ function readText(path) {
1539
+ return normalizeNewlines(readFileSync4(path, "utf8"));
1540
+ }
1541
+ function safeReadText(path) {
1542
+ return existsSync7(path) ? readText(path) : null;
1543
+ }
1544
+ function ensureParent(path) {
1545
+ mkdirSync5(dirname6(path), { recursive: true });
1546
+ }
1547
+ function writeText(path, content) {
1548
+ ensureParent(path);
1549
+ writeFileSync4(path, content);
1550
+ }
1551
+ function tryParseJson(text3) {
1552
+ if (!text3) return null;
1553
+ try {
1554
+ return JSON.parse(text3);
1555
+ } catch {
1556
+ return null;
1296
1557
  }
1297
- const logs = [];
1298
- const errors = [];
1299
- const origLog = console.log;
1300
- const origError = console.error;
1301
- console.log = (...args) => {
1302
- logs.push(args.map((a) => String(a)).join(" "));
1303
- };
1304
- console.error = (...args) => {
1305
- errors.push(args.map((a) => String(a)).join(" "));
1306
- };
1558
+ }
1559
+ function slugifyRepoName(name) {
1560
+ return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
1561
+ }
1562
+ function titleCaseSlug(slug) {
1563
+ return slug.split(/[-_]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
1564
+ }
1565
+ function readSymlinkTarget(path) {
1566
+ if (!existsSync7(path)) return null;
1307
1567
  try {
1308
- await recipe.execute();
1309
- const combined = [...logs, ...errors].join("\n");
1310
- const success = !combined.match(/(^|\n)✗/);
1311
- return { success, logs, errors };
1312
- } catch (err) {
1313
- errors.push(err instanceof Error ? err.message : String(err));
1314
- return { success: false, logs, errors };
1315
- } finally {
1316
- console.log = origLog;
1317
- console.error = origError;
1568
+ return readlinkSync(path);
1569
+ } catch {
1570
+ return null;
1318
1571
  }
1319
1572
  }
1320
- server.registerTool(
1321
- "pjangler_list_capabilities",
1322
- {
1323
- title: "List pjangler capabilities",
1324
- description: "Returns available recipes and commands exposed by pjangler.",
1325
- inputSchema: {}
1326
- },
1327
- async () => {
1328
- const payload = {
1329
- recipes: Object.values(RECIPE_REGISTRY).map((r) => ({
1330
- name: r.name,
1331
- description: r.description,
1332
- commands: r.commands
1333
- })),
1334
- commands: Object.values(COMMAND_REGISTRY).map((c) => ({
1335
- name: c.name,
1336
- description: c.description,
1337
- group: c.group
1338
- }))
1339
- };
1340
- return {
1341
- content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
1342
- };
1573
+ function ensureSymlink(path, target, dryRun) {
1574
+ if (existsSync7(path)) {
1575
+ const stat = lstatSync(path);
1576
+ if (stat.isSymbolicLink()) {
1577
+ const current = readSymlinkTarget(path);
1578
+ if (current === target) return { changed: false };
1579
+ if (!dryRun) {
1580
+ unlinkSync3(path);
1581
+ symlinkSync(target, path);
1582
+ }
1583
+ return { changed: true };
1584
+ }
1585
+ return { changed: false, blocked: `${relative(process.cwd(), path) || path} exists and is not a symlink` };
1343
1586
  }
1344
- );
1345
- server.registerTool(
1346
- "pjangler_describe_recipe",
1347
- {
1348
- title: "Describe recipe",
1349
- description: "Returns metadata for a specific pjangler recipe.",
1350
- inputSchema: {
1351
- recipe: z.string()
1587
+ if (!dryRun) symlinkSync(target, path);
1588
+ return { changed: true };
1589
+ }
1590
+ function bootstrapAgentsFile(repoRoot, dryRun) {
1591
+ const agentsPath = join9(repoRoot, "AGENTS.md");
1592
+ if (existsSync7(agentsPath)) return { changedFiles: [], details: [] };
1593
+ for (const file of ["CLAUDE.md", "GEMINI.md"]) {
1594
+ const source = join9(repoRoot, file);
1595
+ if (!existsSync7(source)) continue;
1596
+ const stat = lstatSync(source);
1597
+ if (stat.isSymbolicLink()) continue;
1598
+ if (stat.isFile()) {
1599
+ if (!dryRun) renameSync(source, agentsPath);
1600
+ return { changedFiles: [agentsPath], details: [`Moved ${file} to AGENTS.md before wiring agent-file symlinks`] };
1352
1601
  }
1353
- },
1354
- async ({ recipe }) => {
1355
- const info = getRecipeInfo(recipe);
1356
- if (!info) {
1357
- return {
1358
- isError: true,
1359
- content: [{ type: "text", text: `Recipe not found: ${recipe}` }]
1360
- };
1602
+ return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
1603
+ }
1604
+ const readmePath = join9(repoRoot, "README.md");
1605
+ if (existsSync7(readmePath)) {
1606
+ const stat = lstatSync(readmePath);
1607
+ if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
1608
+ if (!dryRun) copyFileSync(readmePath, agentsPath);
1609
+ return { changedFiles: [agentsPath], details: ["Copied README.md to AGENTS.md before wiring agent-file symlinks"] };
1610
+ }
1611
+ return { changedFiles: [], details: [], blocked: "AGENTS.md missing and no CLAUDE.md, GEMINI.md, or README.md source exists" };
1612
+ }
1613
+ function yamlGet(text3, keyPath) {
1614
+ const parts = keyPath.split(".");
1615
+ const lines = text3.split("\n");
1616
+ let start = 0;
1617
+ let indent = 0;
1618
+ for (let idx = 0; idx < parts.length; idx += 1) {
1619
+ const key = parts[idx];
1620
+ let found = false;
1621
+ for (let i = start; i < lines.length; i += 1) {
1622
+ const line = lines[i];
1623
+ if (!line.trim() || line.trim().startsWith("#")) continue;
1624
+ const match = line.match(/^(\s*)([^:#]+):\s*(.*)$/);
1625
+ if (!match) continue;
1626
+ const currentIndent = match[1].length;
1627
+ const currentKey = match[2].trim();
1628
+ const rest = match[3].trim();
1629
+ if (idx > 0 && currentIndent < indent) break;
1630
+ if (currentIndent !== indent || currentKey !== key) continue;
1631
+ found = true;
1632
+ if (idx === parts.length - 1) {
1633
+ return rest.replace(/^['"]|['"]$/g, "").trim();
1634
+ }
1635
+ start = i + 1;
1636
+ indent = currentIndent + 2;
1637
+ break;
1361
1638
  }
1639
+ if (!found) return "";
1640
+ }
1641
+ return "";
1642
+ }
1643
+ function discoverRoles(repoRoot) {
1644
+ const rolesDir = join9(repoRoot, "agents", "hermes");
1645
+ if (!existsSync7(rolesDir)) return [];
1646
+ return readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
1647
+ const roleDir = join9(rolesDir, entry.name);
1648
+ const roleYamlPath = join9(roleDir, "role.yaml");
1649
+ if (!existsSync7(roleYamlPath)) return null;
1650
+ const text3 = readText(roleYamlPath);
1651
+ const runtimeRepoRaw = yamlGet(text3, "runtime.github_repo");
1362
1652
  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
- ]
1653
+ role: yamlGet(text3, "role") || entry.name,
1654
+ roleDir,
1655
+ roleYamlPath,
1656
+ repo: yamlGet(text3, "repo"),
1657
+ agentId: yamlGet(text3, "agent_id"),
1658
+ profileName: yamlGet(text3, "profile") || yamlGet(text3, "agent_id"),
1659
+ displayName: yamlGet(text3, "display_name"),
1660
+ purpose: yamlGet(text3, "purpose"),
1661
+ botHandle: yamlGet(text3, "telegram.bot_username"),
1662
+ runtimeRepo: runtimeRepoRaw.includes("/") ? runtimeRepoRaw.split("/").slice(-1)[0] ?? runtimeRepoRaw : runtimeRepoRaw,
1663
+ runtimeOwner: yamlGet(text3, "runtime.github_owner"),
1664
+ planeWorkspace: yamlGet(text3, "ticket_provider.workspace") || yamlGet(text3, "plane.workspace"),
1665
+ ticketProviderName: yamlGet(text3, "ticket_provider.name"),
1666
+ ticketProviderBoardId: yamlGet(text3, "ticket_provider.board_id"),
1667
+ ticketProviderBoardUrl: yamlGet(text3, "ticket_provider.board_url"),
1668
+ ticketProviderIdentifier: yamlGet(text3, "plane.identifier")
1377
1669
  };
1670
+ }).filter((value) => Boolean(value));
1671
+ }
1672
+ function registryPath(homeDir) {
1673
+ return join9(homeDir, ".hermes", "agents-registry.yaml");
1674
+ }
1675
+ function systemctlUser(args) {
1676
+ const result = spawnSync4("systemctl", ["--user", ...args], { encoding: "utf8" });
1677
+ return {
1678
+ ok: result.status === 0,
1679
+ stdout: result.stdout.trim(),
1680
+ stderr: result.stderr.trim()
1681
+ };
1682
+ }
1683
+ function templateScript(ctx, name) {
1684
+ const source = join9(ctx.pjanglerRoot, ".mise", "scripts", name);
1685
+ return existsSync7(source) ? readText(source) : void 0;
1686
+ }
1687
+ function templateVersioningScript(ctx) {
1688
+ return templateScript(ctx, "versioning.sh");
1689
+ }
1690
+ function templateLinkAgentfilesScript(ctx) {
1691
+ return templateScript(ctx, "link-agentfiles.sh");
1692
+ }
1693
+ function renderGeneratedProjectMiseToml(ctx, template) {
1694
+ const project = readProjectJson(ctx);
1695
+ const projectName = String(project?.project_name ?? basename2(ctx.repoRoot) ?? "project");
1696
+ return template.replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
1697
+ }
1698
+ function ensureMiseTomlFromTemplate(ctx, changedFiles) {
1699
+ const targetPath = join9(ctx.repoRoot, "mise.toml");
1700
+ if (existsSync7(targetPath)) return false;
1701
+ const sourcePath = join9(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
1702
+ if (!existsSync7(sourcePath)) return false;
1703
+ changedFiles.push(targetPath);
1704
+ if (!ctx.dryRun) {
1705
+ writeText(targetPath, renderGeneratedProjectMiseToml(ctx, readText(sourcePath)));
1378
1706
  }
1379
- );
1380
- server.registerTool(
1381
- "pjangler_run_recipe",
1382
- {
1383
- title: "Run recipe",
1384
- description: "Executes any pjangler recipe against a target directory.",
1385
- inputSchema: {
1386
- recipe: z.enum(getRecipeNames()),
1387
- targetDir: z.string().optional(),
1388
- force: z.boolean().optional(),
1389
- dryRun: z.boolean().optional()
1707
+ return true;
1708
+ }
1709
+ function templateVersionFilesConf(ctx, repoRoot) {
1710
+ const packageJson = join9(repoRoot, "package.json");
1711
+ 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";
1712
+ }
1713
+ function replaceOrAppendManagedBlock(text3, startMarker, block, beforePattern) {
1714
+ if (startMarker.test(text3)) {
1715
+ return text3.replace(/# >>> mise-versioning >>>[\s\S]*?# <<< mise-versioning <<</, block);
1716
+ }
1717
+ if (beforePattern) {
1718
+ const match = text3.match(beforePattern);
1719
+ if (match && typeof match.index === "number") {
1720
+ return `${text3.slice(0, match.index).replace(/\s*$/, "\n\n")}${block}
1721
+
1722
+ ${text3.slice(match.index)}`;
1390
1723
  }
1391
- },
1392
- async ({ recipe, targetDir, force, dryRun }) => {
1393
- try {
1394
- const resolvedTarget = resolveTargetDir(targetDir);
1395
- const context = {
1396
- targetDir: resolvedTarget,
1397
- force: force ?? false,
1398
- dryRun: dryRun ?? false
1399
- };
1400
- const result = await runRecipeWithCapture(recipe, context);
1401
- return {
1402
- 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
- ]
1419
- };
1420
- } catch (err) {
1421
- return {
1422
- isError: true,
1423
- content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }]
1424
- };
1724
+ }
1725
+ return `${text3.replace(/\s*$/, "")}
1726
+
1727
+ ${block}
1728
+ `;
1729
+ }
1730
+ var BASE_MISE_PATH_ENTRIES = [".mise/scripts", "agents/hermes/pm"];
1731
+ var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/hermes"];
1732
+ function requiredMisePathEntries(ctx) {
1733
+ const required = [...BASE_MISE_PATH_ENTRIES];
1734
+ for (const candidate of CONDITIONAL_HERMES_PATHS) {
1735
+ if (existsSync7(join9(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
1736
+ }
1737
+ return required;
1738
+ }
1739
+ function upsertMisePath(text3, required = BASE_MISE_PATH_ENTRIES) {
1740
+ const render = (values) => `_.path = [${values.map((value) => JSON.stringify(value)).join(", ")}]`;
1741
+ const envMatch = text3.match(/(^|\n)(\[env\][\s\S]*?)(?=\n\[[^\]]+\]|$)/);
1742
+ if (!envMatch || typeof envMatch.index !== "number") {
1743
+ return `[env]
1744
+ ${render(required)}
1745
+
1746
+ ${text3.replace(/^\s+/, "")}`;
1747
+ }
1748
+ const prefix = text3.slice(0, envMatch.index + envMatch[1].length);
1749
+ const section = envMatch[2];
1750
+ const suffix = text3.slice(envMatch.index + envMatch[1].length + section.length);
1751
+ const pathLine = section.match(/^_\.path\s*=\s*\[([^\]]*)\]\s*$/m);
1752
+ if (!pathLine) {
1753
+ return `${prefix}${section.replace(/\n?$/, "\n")}${render(required)}${suffix}`;
1754
+ }
1755
+ const current = [...pathLine[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
1756
+ const merged = [...current];
1757
+ for (const value of required) {
1758
+ if (!merged.includes(value)) merged.push(value);
1759
+ }
1760
+ const nextLine = render(merged);
1761
+ if (pathLine[0] === nextLine) return text3;
1762
+ return `${prefix}${section.replace(pathLine[0], nextLine)}${suffix}`;
1763
+ }
1764
+ function removeTomlSection(text3, headerPattern, marker, options) {
1765
+ const lines = text3.split("\n");
1766
+ let start = -1;
1767
+ let end = -1;
1768
+ for (let i = 0; i < lines.length; i++) {
1769
+ if (!headerPattern.test(lines[i])) continue;
1770
+ if (marker) {
1771
+ let hasMarker = false;
1772
+ for (let j = i + 1; j < lines.length && !/^\[[^\]]+\]/.test(lines[j]); j++) {
1773
+ if (marker.test(lines[j])) {
1774
+ hasMarker = true;
1775
+ break;
1776
+ }
1777
+ }
1778
+ if (!hasMarker) continue;
1779
+ }
1780
+ start = i;
1781
+ for (let j = i + 1; j < lines.length; j++) {
1782
+ if (/^\[[^\]]+\]/.test(lines[j])) {
1783
+ end = j;
1784
+ break;
1785
+ }
1425
1786
  }
1787
+ if (end === -1) end = lines.length;
1788
+ break;
1426
1789
  }
1427
- );
1428
- server.registerTool(
1429
- "pjangler_deploy_hermes_agent",
1430
- {
1431
- 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).",
1433
- inputSchema: {
1434
- targetDir: z.string(),
1435
- targetRepo: z.string(),
1436
- role: z.string(),
1437
- agentPurpose: z.string().optional(),
1438
- soulTone: z.enum(["direct", "playful", "formal", "terse"]).optional(),
1439
- modelProvider: z.string().optional(),
1440
- modelName: z.string().optional(),
1441
- force: z.boolean().optional(),
1442
- dryRun: z.boolean().optional(),
1443
- skipTelegram: z.boolean().optional(),
1444
- skipEmail: z.boolean().optional(),
1445
- skipRuntimeRepo: z.boolean().optional(),
1446
- skipPlane: z.boolean().optional(),
1447
- skipBloodbank: z.boolean().optional(),
1448
- skipSystemd: z.boolean().optional()
1790
+ if (start === -1) return text3;
1791
+ if (options?.includePrecedingComments) {
1792
+ while (start > 0 && lines[start - 1].trim().startsWith("#")) {
1793
+ start--;
1449
1794
  }
1450
- },
1451
- async (input) => {
1452
- try {
1453
- const resolvedTarget = resolveTargetDir(input.targetDir);
1454
- const context = {
1455
- targetDir: resolvedTarget,
1456
- yes: true,
1457
- targetRepo: input.targetRepo,
1458
- role: input.role,
1459
- agentPurpose: input.agentPurpose,
1795
+ }
1796
+ const result = lines.slice(0, start).concat(lines.slice(end)).join("\n");
1797
+ return result.replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
1798
+ }
1799
+ function insertTomlBlockBeforeVersioning(text3, block) {
1800
+ const versioningIndex = text3.indexOf("# >>> mise-versioning >>>");
1801
+ if (versioningIndex >= 0) {
1802
+ return `${text3.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${block}
1803
+
1804
+ ${text3.slice(versioningIndex)}`;
1805
+ }
1806
+ return `${text3.replace(/\s*$/, "")}
1807
+
1808
+ ${block}
1809
+ `;
1810
+ }
1811
+ function extractTomlStrings(text3) {
1812
+ const values = [];
1813
+ const stringPattern = /"((?:\\.|[^"\\])*)"|'([^']*)'/g;
1814
+ for (const match of text3.matchAll(stringPattern)) {
1815
+ if (match[1] !== void 0) {
1816
+ try {
1817
+ values.push(JSON.parse(`"${match[1]}"`));
1818
+ } catch {
1819
+ values.push(match[1]);
1820
+ }
1821
+ } else if (match[2] !== void 0) {
1822
+ values.push(match[2]);
1823
+ }
1824
+ }
1825
+ return values;
1826
+ }
1827
+ function isManagedHookEntry(value) {
1828
+ const trimmed = value.trim();
1829
+ return trimmed === "op inject -i .env.op > .env" || /(^|\/)link-agentfiles\.sh$/.test(trimmed);
1830
+ }
1831
+ function renderHookEntries(entries, indent = "") {
1832
+ return [
1833
+ `${indent}enter = [`,
1834
+ ...entries.map((entry) => `${indent} ${JSON.stringify(entry)},`),
1835
+ `${indent}]`
1836
+ ];
1837
+ }
1838
+ function upsertLinkAgentfilesHooks(text3) {
1839
+ const lines = text3.split("\n");
1840
+ const hooksStart = lines.findIndex((line) => /^\[hooks\]$/.test(line.trim()));
1841
+ if (hooksStart === -1) return insertTomlBlockBeforeVersioning(text3, LINK_AGENTFILES_HOOKS_BLOCK);
1842
+ let hooksEnd = lines.length;
1843
+ for (let i = hooksStart + 1; i < lines.length; i++) {
1844
+ if (/^\[[^\]]+\]/.test(lines[i].trim())) {
1845
+ hooksEnd = i;
1846
+ break;
1847
+ }
1848
+ }
1849
+ let enterStart = -1;
1850
+ let enterEnd = -1;
1851
+ for (let i = hooksStart + 1; i < hooksEnd; i++) {
1852
+ if (!/^\s*enter\s*=/.test(lines[i])) continue;
1853
+ enterStart = i;
1854
+ enterEnd = i + 1;
1855
+ const afterEquals = lines[i].slice(lines[i].indexOf("=") + 1);
1856
+ if (afterEquals.includes("[") && !afterEquals.includes("]")) {
1857
+ while (enterEnd < hooksEnd && !lines[enterEnd].includes("]")) enterEnd++;
1858
+ if (enterEnd < hooksEnd) enterEnd++;
1859
+ }
1860
+ break;
1861
+ }
1862
+ const existingBlock = enterStart >= 0 ? lines.slice(enterStart, enterEnd).join("\n") : "";
1863
+ const preserved = extractTomlStrings(existingBlock).filter((entry) => !isManagedHookEntry(entry));
1864
+ const merged = [...LINK_AGENTFILES_HOOK_ENTRIES];
1865
+ for (const entry of preserved) {
1866
+ if (!merged.includes(entry)) merged.push(entry);
1867
+ }
1868
+ const indent = enterStart >= 0 ? lines[enterStart].match(/^\s*/)?.[0] ?? "" : "";
1869
+ const rendered = renderHookEntries(merged, indent);
1870
+ if (enterStart >= 0) {
1871
+ return lines.slice(0, enterStart).concat(rendered, lines.slice(enterEnd)).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
1872
+ }
1873
+ return lines.slice(0, hooksStart + 1).concat(rendered, lines.slice(hooksStart + 1)).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
1874
+ }
1875
+ function upsertLinkAgentfilesBlock(text3, ctx) {
1876
+ const withPath = upsertMisePath(text3, requiredMisePathEntries(ctx));
1877
+ if (withPath.includes(LINK_AGENTFILES_BLOCK)) return withPath;
1878
+ let cleaned = removeTomlSection(withPath, /^\[tasks\.link-agentfiles\]$/, /link-agentfiles/, { includePrecedingComments: false });
1879
+ cleaned = removeTomlSection(cleaned, /^\[\[watch_files\]\]$/, /AGENTS\.md/, { includePrecedingComments: false });
1880
+ cleaned = upsertLinkAgentfilesHooks(cleaned);
1881
+ return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
1882
+ }
1883
+ function readProjectJson(ctx) {
1884
+ return tryParseJson(safeReadText(join9(ctx.repoRoot, ".project.json")));
1885
+ }
1886
+ function canonicalProjectJson(ctx) {
1887
+ const roles = discoverRoles(ctx.repoRoot);
1888
+ const existing = readProjectJson(ctx) ?? {};
1889
+ const slug = String(existing.project_slug ?? slugifyRepoName(dirname6(ctx.repoRoot) === ctx.repoRoot ? ctx.repoRoot.split("/").pop() ?? "project" : ctx.repoRoot.split("/").pop() ?? "project"));
1890
+ const firstRole = roles[0];
1891
+ const ticketProvider = {
1892
+ type: String((existing.ticket_provider?.type ?? firstRole?.ticketProviderName ?? "plane") || "plane"),
1893
+ workspace: String((existing.ticket_provider?.workspace ?? firstRole?.planeWorkspace ?? "") || ""),
1894
+ identifier: String((existing.ticket_provider?.identifier ?? firstRole?.ticketProviderIdentifier ?? "") || ""),
1895
+ board_id: String((existing.ticket_provider?.board_id ?? firstRole?.ticketProviderBoardId ?? "") || ""),
1896
+ board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || ""),
1897
+ state: String((existing.ticket_provider?.state ?? "planned") || "planned")
1898
+ };
1899
+ const existingAgents = existing.agents ?? {};
1900
+ const discoveredAgents = Object.fromEntries(
1901
+ roles.map((role) => [
1902
+ role.agentId || `${slug}-${role.role}`,
1903
+ {
1904
+ role: role.role,
1905
+ role_dir: relative(ctx.repoRoot, role.roleDir)
1906
+ }
1907
+ ])
1908
+ );
1909
+ const agents = { ...existingAgents };
1910
+ for (const [agentId, discovered] of Object.entries(discoveredAgents)) {
1911
+ const existingAgent = existingAgents[agentId] ?? {};
1912
+ agents[agentId] = {
1913
+ role: discovered.role,
1914
+ role_dir: discovered.role_dir,
1915
+ provisioning_state: existingAgent.provisioning_state
1916
+ };
1917
+ }
1918
+ return {
1919
+ project_name: String(existing.project_name ?? titleCaseSlug(slug)),
1920
+ project_description: String(existing.project_description ?? ""),
1921
+ project_slug: slug,
1922
+ repo_path: ctx.repoRoot,
1923
+ ticket_provider: ticketProvider,
1924
+ agents
1925
+ };
1926
+ }
1927
+ function projectJsonFinding(ctx) {
1928
+ const projectPath = join9(ctx.repoRoot, ".project.json");
1929
+ const planeJsonPath = join9(ctx.repoRoot, ".plane.json");
1930
+ const details = [];
1931
+ const data = readProjectJson(ctx);
1932
+ const roles = discoverRoles(ctx.repoRoot);
1933
+ if (!existsSync7(projectPath)) {
1934
+ return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json missing", details: [], fixable: true };
1935
+ }
1936
+ if (!data) {
1937
+ return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json is not valid JSON", details: [], fixable: true };
1938
+ }
1939
+ for (const key of ["project_name", "project_description", "project_slug", "repo_path", "ticket_provider", "agents"]) {
1940
+ if (!(key in data)) details.push(`missing key: ${key}`);
1941
+ }
1942
+ if (data.repo_path !== ctx.repoRoot) details.push(`repo_path should be ${ctx.repoRoot}`);
1943
+ const agents = data.agents ?? {};
1944
+ for (const role of roles) {
1945
+ const agent = agents[role.agentId];
1946
+ if (!agent) {
1947
+ details.push(`agents.${role.agentId} missing`);
1948
+ continue;
1949
+ }
1950
+ if (agent.role !== role.role) details.push(`agents.${role.agentId}.role should be ${role.role}`);
1951
+ if (agent.role_dir !== relative(ctx.repoRoot, role.roleDir)) {
1952
+ details.push(`agents.${role.agentId}.role_dir should be ${relative(ctx.repoRoot, role.roleDir)}`);
1953
+ }
1954
+ }
1955
+ const ticketProvider = data.ticket_provider ?? {};
1956
+ for (const key of ["type", "workspace", "identifier", "board_id", "board_url", "state"]) {
1957
+ if (!(key in ticketProvider)) details.push(`ticket_provider.${key} missing`);
1958
+ }
1959
+ if (existsSync7(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
1960
+ return {
1961
+ id: "sot.project-json",
1962
+ title: "Canonical .project.json",
1963
+ status: details.length === 0 ? "pass" : "fail",
1964
+ summary: details.length === 0 ? ".project.json matches canonical parity contract" : `${details.length} parity issue(s) detected`,
1965
+ details,
1966
+ fixable: true
1967
+ };
1968
+ }
1969
+ function renderSoul(role) {
1970
+ const telegram = role.botHandle ? `@${role.botHandle}` : "(unwired)";
1971
+ 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.";
1972
+ 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.`;
1973
+ const runtimeOwner = role.runtimeOwner || "delorenj";
1974
+ return `# ${role.displayName || role.agentId}
1975
+
1976
+ You are **${role.displayName || role.agentId}** \u2014 a Hermes agent provisioned to work inside the
1977
+ \`${role.repo}\` repository.
1978
+
1979
+ ## Identity
1980
+
1981
+ | | |
1982
+ | --- | --- |
1983
+ | Agent ID | \`${role.agentId}\` |
1984
+ | Profile | \`${role.profileName || role.agentId}\` |
1985
+ | Repo | \`${role.repo}\` |
1986
+ | Role | \`${role.role}\` |
1987
+ | Telegram | \`${telegram}\` |
1988
+ | Purpose | ${role.purpose || `${role.role} agent for ${role.repo}`} |
1989
+
1990
+ ## Scope
1991
+
1992
+ 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.
1993
+
1994
+ ## Tone
1995
+
1996
+ ${tone}
1997
+
1998
+ ## Role-specific behavior
1999
+
2000
+ ${roleSpecific}
2001
+
2002
+ ## Memory hygiene
2003
+
2004
+ Your memory is the submodule at \`./runtime/memories/\`. Use durable memory deliberately and keep \`memories/MEMORY.md\` current.
2005
+ `;
2006
+ }
2007
+ function renderHermesWrapper(role) {
2008
+ return `#!/usr/bin/env bash
2009
+ # Launcher for ${role.agentId}. Resolves HERMES_HOME to the runtime submodule.
2010
+
2011
+ set -euo pipefail
2012
+
2013
+ ROLE_DIR="$(cd "$(dirname "$0")" && pwd)"
2014
+ RUNTIME_HOME="$ROLE_DIR/runtime"
2015
+
2016
+ FLEET_ENV="{HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
2017
+ if [[ -f "$FLEET_ENV" ]]; then
2018
+ # shellcheck disable=SC1090
2019
+ source "$FLEET_ENV"
2020
+ fi
2021
+
2022
+ HERMES_BIN="{HERMES_BIN:-{HERMES_FLEET_BIN:-/home/delorenj/code/hermes-agent/.venv/bin/hermes}}"
2023
+ HERMES_OAUTH_FILE="{HERMES_OAUTH_FILE:-{HERMES_FLEET_OAUTH_FILE:-$HOME/.hermes/auth.json}}"
2024
+ CODEX_HOME="{CODEX_HOME:-{HERMES_FLEET_CODEX_HOME:-$HOME/.codex}}"
2025
+
2026
+ FLEET_HOME="{HERMES_FLEET_HOME:-$HOME/.hermes}"
2027
+ PROFILE_NAME="{HERMES_PROFILE_NAME:-${role.profileName || role.agentId}}"
2028
+ HERMES_HOME="$RUNTIME_HOME"
2029
+
2030
+ if [[ ! -d "$RUNTIME_HOME" ]]; then
2031
+ echo "hermes: runtime submodule not initialized at $RUNTIME_HOME" >&2
2032
+ echo " fix: git submodule update --init --recursive" >&2
2033
+ exit 1
2034
+ fi
2035
+
2036
+ exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" HERMES_OAUTH_FILE="$HERMES_OAUTH_FILE" CODEX_HOME="$CODEX_HOME" "$HERMES_BIN" "$@"
2037
+ `.replace(/\u0010/g, "$");
2038
+ }
2039
+ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip) {
2040
+ if (!existsSync7(sourceDir)) return;
2041
+ mkdirSync5(targetDir, { recursive: true });
2042
+ for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
2043
+ const sourcePath = join9(sourceDir, entry.name);
2044
+ if (skip?.(sourcePath)) continue;
2045
+ const targetPath = join9(targetDir, entry.name);
2046
+ if (entry.isDirectory()) {
2047
+ copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
2048
+ continue;
2049
+ }
2050
+ if (existsSync7(targetPath)) continue;
2051
+ changedFiles.push(targetPath);
2052
+ if (!dryRun) {
2053
+ ensureParent(targetPath);
2054
+ copyFileSync(sourcePath, targetPath);
2055
+ }
2056
+ }
2057
+ }
2058
+ function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
2059
+ const gitmodulesPath = join9(repoRoot, ".gitmodules");
2060
+ const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
2061
+ const owner = role.runtimeOwner || "delorenj";
2062
+ const block = `[submodule "agents/hermes/${role.role}/runtime"]
2063
+ path = agents/hermes/${role.role}/runtime
2064
+ url = git@github.com:${owner}/${repoName}.git
2065
+ `;
2066
+ const current = safeReadText(gitmodulesPath) ?? "";
2067
+ const header = `[submodule "agents/hermes/${role.role}/runtime"]`;
2068
+ if (current.includes(header)) return [];
2069
+ changedFiles.push(gitmodulesPath);
2070
+ if (!dryRun) writeText(gitmodulesPath, `${current.replace(/\s*$/, "")}${current.trim() ? "\n" : ""}${block}`);
2071
+ return [gitmodulesPath];
2072
+ }
2073
+ function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
2074
+ const path = registryPath(homeDir);
2075
+ 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";
2076
+ if (current.includes(`${role.agentId}:`)) return null;
2077
+ const block = ` ${role.agentId}:
2078
+ repo: ${role.repo}
2079
+ role: ${role.role}
2080
+ display_name: ${JSON.stringify(role.displayName || role.agentId)}
2081
+ project_path: ${ctxEscape(role.roleDir ? dirname6(dirname6(dirname6(role.roleDir))) : "")}
2082
+ role_dir: ${ctxEscape(role.roleDir)}
2083
+ profile_name: ${role.profileName || role.agentId}
2084
+ telegram:
2085
+ bot_username: ${ctxEscape(role.botHandle)}
2086
+ plane:
2087
+ workspace: ${ctxEscape(role.planeWorkspace)}
2088
+ project_id: ${ctxEscape(role.ticketProviderBoardId)}
2089
+ identifier: ${ctxEscape(role.ticketProviderIdentifier)}
2090
+ runtime_repo: ${ctxEscape(role.runtimeRepo)}
2091
+ systemd:
2092
+ gateway_unit: hermes-${role.agentId}-gateway.service
2093
+ consumer_unit: hermes-${role.agentId}-consumer.service
2094
+ heartbeat_timer: hermes-${role.agentId}-heartbeat.timer
2095
+ `;
2096
+ const next = current.includes("agents: {}") ? current.replace("agents: {}", `agents:
2097
+ ${block}`) : `${current.replace(/\s*$/, "\n")}${block}`;
2098
+ changedFiles.push(path);
2099
+ if (!dryRun) writeText(path, next);
2100
+ return path;
2101
+ }
2102
+ function profileMetaInheritsDefault(path) {
2103
+ const text3 = safeReadText(path);
2104
+ return Boolean(
2105
+ text3 && /^config:\s*$/m.test(text3) && /^\s+inherit_from:\s*default\s*$/m.test(text3) && /^\s+save_mode:\s*delta\s*$/m.test(text3)
2106
+ );
2107
+ }
2108
+ function upsertInheritedProfileMeta(path, changedFiles, dryRun) {
2109
+ const current = safeReadText(path) ?? "";
2110
+ const lines = current.split("\n");
2111
+ let next;
2112
+ const start = lines.findIndex((line) => /^config:\s*$/.test(line));
2113
+ if (!current.trim()) {
2114
+ next = "config:\n inherit_from: default\n save_mode: delta\n";
2115
+ } else if (start === -1) {
2116
+ next = `${current.replace(/\s*$/, "\n")}config:
2117
+ inherit_from: default
2118
+ save_mode: delta
2119
+ `;
2120
+ } else {
2121
+ let end = start + 1;
2122
+ while (end < lines.length && !/^[^#\s][^:]*:\s*/.test(lines[end] ?? "")) end++;
2123
+ let hasInherit = false;
2124
+ let hasSave = false;
2125
+ for (let idx = start + 1; idx < end; idx++) {
2126
+ if (/^\s+inherit_from:\s*/.test(lines[idx] ?? "")) {
2127
+ lines[idx] = " inherit_from: default";
2128
+ hasInherit = true;
2129
+ } else if (/^\s+save_mode:\s*/.test(lines[idx] ?? "")) {
2130
+ lines[idx] = " save_mode: delta";
2131
+ hasSave = true;
2132
+ }
2133
+ }
2134
+ const inserts = [];
2135
+ if (!hasInherit) inserts.push(" inherit_from: default");
2136
+ if (!hasSave) inserts.push(" save_mode: delta");
2137
+ if (inserts.length) lines.splice(end, 0, ...inserts);
2138
+ next = lines.join("\n");
2139
+ if (!next.endsWith("\n")) next += "\n";
2140
+ }
2141
+ if (next === current) return null;
2142
+ changedFiles.push(path);
2143
+ if (!dryRun) writeText(path, next);
2144
+ return path;
2145
+ }
2146
+ function ctxEscape(value) {
2147
+ return JSON.stringify(value || "");
2148
+ }
2149
+ function checkUnit(unit) {
2150
+ const enabled = systemctlUser(["is-enabled", unit]).ok;
2151
+ const active = systemctlUser(["is-active", unit]).ok;
2152
+ return { enabled, active };
2153
+ }
2154
+ var RULES = [
2155
+ {
2156
+ id: "mise.config-root",
2157
+ title: "mise config_root + AGENTS link hooks",
2158
+ audit: (ctx) => {
2159
+ const misePath = join9(ctx.repoRoot, "mise.toml");
2160
+ if (!existsSync7(misePath)) {
2161
+ return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
2162
+ }
2163
+ const text3 = readText(misePath);
2164
+ const details = [];
2165
+ const linkAgentfilesPath = join9(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2166
+ if (!existsSync7(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2167
+ const pathValues = [...(text3.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2168
+ const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
2169
+ if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
2170
+ if (!text3.includes('"{{config_root}}/.mise/scripts/link-agentfiles.sh"')) details.push("link-agentfiles must use raw {{config_root}} guard");
2171
+ if (!text3.includes("op inject -i .env.op > .env")) details.push("[hooks].enter must materialize .env from .env.op");
2172
+ if (!text3.includes('patterns = ["AGENTS.md"]')) details.push("watch_files must monitor AGENTS.md");
2173
+ if (!text3.includes('task = "link-agentfiles"')) details.push("watch_files must dispatch link-agentfiles task");
2174
+ return {
2175
+ id: "mise.config-root",
2176
+ title: "mise config_root + AGENTS link hooks",
2177
+ status: details.length === 0 ? "pass" : "fail",
2178
+ summary: details.length === 0 ? "mise AGENTS-linking parity verified" : `${details.length} issue(s) detected in mise AGENTS-linking contract`,
2179
+ details,
2180
+ fixable: true
2181
+ };
2182
+ },
2183
+ migrate: (ctx, finding) => {
2184
+ const path = join9(ctx.repoRoot, "mise.toml");
2185
+ const changedFiles = [];
2186
+ const details = [];
2187
+ if (!existsSync7(path)) {
2188
+ if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2189
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing and no generated-project mise template available to initialize from", changedFiles, details: [] };
2190
+ }
2191
+ details.push("Initialized mise.toml from generated-project template");
2192
+ if (ctx.dryRun) {
2193
+ return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
2194
+ }
2195
+ }
2196
+ let text3 = readText(path);
2197
+ const next = upsertLinkAgentfilesBlock(text3, ctx);
2198
+ if (next !== text3) {
2199
+ if (!changedFiles.includes(path)) changedFiles.push(path);
2200
+ if (!ctx.dryRun) writeText(path, next);
2201
+ text3 = next;
2202
+ }
2203
+ const linkAgentfilesPath = join9(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2204
+ const expectedScript = templateLinkAgentfilesScript(ctx);
2205
+ if (expectedScript === void 0) {
2206
+ 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: [] };
2207
+ }
2208
+ if (safeReadText(linkAgentfilesPath) !== expectedScript) {
2209
+ changedFiles.push(linkAgentfilesPath);
2210
+ if (!ctx.dryRun) {
2211
+ writeText(linkAgentfilesPath, expectedScript);
2212
+ chmodSync(linkAgentfilesPath, 493);
2213
+ }
2214
+ }
2215
+ return {
2216
+ id: finding.id,
2217
+ title: finding.title,
2218
+ status: changedFiles.length ? "applied" : "noop",
2219
+ summary: changedFiles.length ? "Updated mise AGENTS-linking contract" : "No changes required",
2220
+ changedFiles,
2221
+ details: changedFiles.length ? ["Normalized hooks/watch_files/tasks.link-agentfiles block and script"] : []
2222
+ };
2223
+ }
2224
+ },
2225
+ {
2226
+ id: "mise.versioning",
2227
+ title: "managed mise versioning block",
2228
+ audit: (ctx) => {
2229
+ const details = [];
2230
+ const misePath = join9(ctx.repoRoot, "mise.toml");
2231
+ const versioningPath = join9(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2232
+ const manifestPath = join9(ctx.repoRoot, ".mise", "version-files.conf");
2233
+ const text3 = safeReadText(misePath);
2234
+ if (!text3?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
2235
+ if (!existsSync7(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
2236
+ if (!existsSync7(manifestPath)) details.push(".mise/version-files.conf missing");
2237
+ return {
2238
+ id: "mise.versioning",
2239
+ title: "managed mise versioning block",
2240
+ status: details.length === 0 ? "pass" : "fail",
2241
+ summary: details.length === 0 ? "mise versioning parity verified" : `${details.length} versioning issue(s) detected`,
2242
+ details,
2243
+ fixable: true
2244
+ };
2245
+ },
2246
+ migrate: (ctx, finding) => {
2247
+ const changedFiles = [];
2248
+ const details = [];
2249
+ const misePath = join9(ctx.repoRoot, "mise.toml");
2250
+ if (!existsSync7(misePath)) {
2251
+ if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2252
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing and no generated-project mise template available to initialize from", changedFiles, details: [] };
2253
+ }
2254
+ details.push("Initialized mise.toml from generated-project template");
2255
+ if (ctx.dryRun) {
2256
+ return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
2257
+ }
2258
+ }
2259
+ const currentMise = readText(misePath);
2260
+ const nextMise = replaceOrAppendManagedBlock(currentMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
2261
+ if (nextMise !== currentMise) {
2262
+ if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
2263
+ if (!ctx.dryRun) writeText(misePath, nextMise);
2264
+ }
2265
+ const versioningPath = join9(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2266
+ const expectedScript = templateVersioningScript(ctx);
2267
+ if (expectedScript === void 0) {
2268
+ 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: [] };
2269
+ }
2270
+ if (safeReadText(versioningPath) !== expectedScript) {
2271
+ changedFiles.push(versioningPath);
2272
+ if (!ctx.dryRun) {
2273
+ writeText(versioningPath, expectedScript);
2274
+ chmodSync(versioningPath, 493);
2275
+ }
2276
+ }
2277
+ const manifestPath = join9(ctx.repoRoot, ".mise", "version-files.conf");
2278
+ const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
2279
+ if (safeReadText(manifestPath) !== expectedManifest) {
2280
+ changedFiles.push(manifestPath);
2281
+ if (!ctx.dryRun) writeText(manifestPath, expectedManifest);
2282
+ }
2283
+ return {
2284
+ id: finding.id,
2285
+ title: finding.title,
2286
+ status: changedFiles.length ? "applied" : "noop",
2287
+ summary: changedFiles.length ? "Versioning block/script/manifest normalized" : "No changes required",
2288
+ changedFiles,
2289
+ details: []
2290
+ };
2291
+ }
2292
+ },
2293
+ {
2294
+ id: "sot.agent-symlinks",
2295
+ title: "AGENTS/CLAUDE/GEMINI symlink contract",
2296
+ audit: (ctx) => {
2297
+ const agentsPath = join9(ctx.repoRoot, "AGENTS.md");
2298
+ if (!existsSync7(agentsPath)) {
2299
+ const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync7(join9(ctx.repoRoot, file)));
2300
+ if (fallbackSources.length === 0) {
2301
+ return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
2302
+ }
2303
+ return {
2304
+ id: "sot.agent-symlinks",
2305
+ title: "AGENTS/CLAUDE/GEMINI symlink contract",
2306
+ status: "fail",
2307
+ summary: "AGENTS.md missing but can be derived from existing project documentation",
2308
+ details: [`AGENTS.md can be created from ${fallbackSources[0]}`],
2309
+ fixable: true
2310
+ };
2311
+ }
2312
+ const details = [];
2313
+ for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2314
+ const full = join9(ctx.repoRoot, file);
2315
+ const target = readSymlinkTarget(full);
2316
+ if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
2317
+ }
2318
+ return {
2319
+ id: "sot.agent-symlinks",
2320
+ title: "AGENTS/CLAUDE/GEMINI symlink contract",
2321
+ status: details.length === 0 ? "pass" : "fail",
2322
+ summary: details.length === 0 ? "Agent documentation symlinks are in parity" : `${details.length} symlink issue(s) detected`,
2323
+ details,
2324
+ fixable: true
2325
+ };
2326
+ },
2327
+ migrate: (ctx, finding) => {
2328
+ const changedFiles = [];
2329
+ const details = [];
2330
+ const blockedDetails = [];
2331
+ const bootstrap = bootstrapAgentsFile(ctx.repoRoot, ctx.dryRun);
2332
+ changedFiles.push(...bootstrap.changedFiles);
2333
+ details.push(...bootstrap.details);
2334
+ if (bootstrap.blocked) {
2335
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
2336
+ }
2337
+ for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2338
+ const full = join9(ctx.repoRoot, file);
2339
+ const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
2340
+ if (result.blocked) blockedDetails.push(result.blocked);
2341
+ if (result.changed) changedFiles.push(full);
2342
+ }
2343
+ return {
2344
+ id: finding.id,
2345
+ title: finding.title,
2346
+ status: blockedDetails.length ? "blocked" : changedFiles.length ? "applied" : "noop",
2347
+ summary: blockedDetails.length ? "One or more files could not be replaced safely" : changedFiles.length ? "Symlink contract repaired" : "No changes required",
2348
+ changedFiles,
2349
+ details: [...details, ...blockedDetails]
2350
+ };
2351
+ }
2352
+ },
2353
+ {
2354
+ id: "sot.project-json",
2355
+ title: "Canonical .project.json",
2356
+ audit: projectJsonFinding,
2357
+ migrate: (ctx, finding) => {
2358
+ const changedFiles = [];
2359
+ const details = [];
2360
+ const path = join9(ctx.repoRoot, ".project.json");
2361
+ const existing = readProjectJson(ctx) ?? {};
2362
+ const canonical = canonicalProjectJson(ctx);
2363
+ const merged = { ...existing, ...canonical };
2364
+ const expected = `${JSON.stringify(merged, null, 2)}
2365
+ `;
2366
+ if (safeReadText(path) !== expected) {
2367
+ changedFiles.push(path);
2368
+ if (!ctx.dryRun) writeText(path, expected);
2369
+ }
2370
+ const planeJson = join9(ctx.repoRoot, ".plane.json");
2371
+ if (existsSync7(planeJson)) {
2372
+ const backup = `${planeJson}.migrated-backup`;
2373
+ if (existsSync7(backup)) {
2374
+ details.push(`cannot back up .plane.json because ${relative(ctx.repoRoot, backup)} already exists`);
2375
+ } else {
2376
+ changedFiles.push(backup);
2377
+ if (!ctx.dryRun) renameSync(planeJson, backup);
2378
+ }
2379
+ }
2380
+ return {
2381
+ id: finding.id,
2382
+ title: finding.title,
2383
+ status: details.length ? "blocked" : changedFiles.length ? "applied" : "noop",
2384
+ summary: details.length ? "Project SOT partially blocked" : changedFiles.length ? "Canonical .project.json written" : "No changes required",
2385
+ changedFiles,
2386
+ details
2387
+ };
2388
+ }
2389
+ },
2390
+ {
2391
+ id: "secrets.env-op",
2392
+ title: ".env.op + gitignore secrets contract",
2393
+ audit: (ctx) => {
2394
+ const details = [];
2395
+ const envOp = safeReadText(join9(ctx.repoRoot, ".env.op"));
2396
+ const gitignore = safeReadText(join9(ctx.repoRoot, ".gitignore"));
2397
+ if (!envOp) {
2398
+ details.push(".env.op missing");
2399
+ } else {
2400
+ const invalidLines = envOp.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).filter((line) => {
2401
+ const value = line.slice(line.indexOf("=") + 1).trim();
2402
+ const quotedLiteral = /^"[^"\r\n]*"$/.test(value) || /^'[^'\r\n]*'$/.test(value);
2403
+ return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value) && !quotedLiteral;
2404
+ });
2405
+ if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals: ${invalidLines.join(", ")}`);
2406
+ }
2407
+ if (!gitignore?.includes(".env\n") && !gitignore?.includes(".env\r\n")) details.push(".gitignore should ignore .env");
2408
+ if (!gitignore?.includes(".env.*")) details.push(".gitignore should ignore .env.*");
2409
+ if (!gitignore?.includes("!.env.op")) details.push(".gitignore should unignore .env.op");
2410
+ return {
2411
+ id: "secrets.env-op",
2412
+ title: ".env.op + gitignore secrets contract",
2413
+ status: details.length === 0 ? "pass" : "fail",
2414
+ summary: details.length === 0 ? "Secret reference file and ignore rules are in parity" : `${details.length} env parity issue(s) detected`,
2415
+ details,
2416
+ fixable: true
2417
+ };
2418
+ },
2419
+ migrate: (ctx, finding) => {
2420
+ const changedFiles = [];
2421
+ const details = [];
2422
+ const envOpPath = join9(ctx.repoRoot, ".env.op");
2423
+ if (!existsSync7(envOpPath)) {
2424
+ changedFiles.push(envOpPath);
2425
+ if (!ctx.dryRun) writeText(envOpPath, readText(join9(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2426
+ }
2427
+ const gitignorePath = join9(ctx.repoRoot, ".gitignore");
2428
+ const gitignore = safeReadText(gitignorePath) ?? "";
2429
+ const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
2430
+ # NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
2431
+ .env
2432
+ .env.*
2433
+ !.env.op
2434
+ `;
2435
+ if (!gitignore.includes("!.env.op") || !gitignore.includes(".env.*")) {
2436
+ changedFiles.push(gitignorePath);
2437
+ if (!ctx.dryRun) writeText(gitignorePath, `${gitignore.replace(/\s*$/, "")}${gitignore.trim() ? "\n\n" : ""}${requiredBlock}`);
2438
+ }
2439
+ return {
2440
+ id: finding.id,
2441
+ title: finding.title,
2442
+ status: details.length ? "blocked" : changedFiles.length ? "applied" : "noop",
2443
+ summary: details.length ? "Manual cleanup still required" : changedFiles.length ? "Wrote .env.op/gitignore parity files" : "No changes required",
2444
+ changedFiles,
2445
+ details
2446
+ };
2447
+ }
2448
+ },
2449
+ {
2450
+ id: "provenance.copier",
2451
+ title: ".copier-answers.yml provenance + drift report",
2452
+ audit: (ctx) => {
2453
+ const details = [];
2454
+ const path = join9(ctx.repoRoot, ".copier-answers.yml");
2455
+ const text3 = safeReadText(path);
2456
+ const project = readProjectJson(ctx);
2457
+ if (!text3) {
2458
+ details.push(".copier-answers.yml missing");
2459
+ } else {
2460
+ if (!text3.startsWith("# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY")) details.push("missing Copier overwrite warning header");
2461
+ if (!text3.includes("_src_path:")) details.push("_src_path missing");
2462
+ if (project?.project_name) {
2463
+ const nameMatch = text3.match(/project_name:\s*(.+)/);
2464
+ if (!nameMatch || nameMatch[1]?.trim() !== String(project.project_name)) details.push("project_name drift between .copier-answers.yml and .project.json");
2465
+ }
2466
+ if (project?.project_description) {
2467
+ const descMatch = text3.match(/project_description:\s*([\s\S]*?)(?=\n\w|$)/);
2468
+ const yamlDesc = descMatch?.[1]?.replace(/\n\s+/g, " ").trim() ?? "";
2469
+ if (yamlDesc !== String(project.project_description)) details.push("project_description drift between .copier-answers.yml and .project.json");
2470
+ }
2471
+ }
2472
+ return {
2473
+ id: "provenance.copier",
2474
+ title: ".copier-answers.yml provenance + drift report",
2475
+ status: details.length === 0 ? "pass" : "fail",
2476
+ summary: details.length === 0 ? "Copier provenance is in parity" : `${details.length} provenance issue(s) detected`,
2477
+ details,
2478
+ fixable: true
2479
+ };
2480
+ },
2481
+ migrate: (ctx, finding) => {
2482
+ const changedFiles = [];
2483
+ const project = canonicalProjectJson(ctx);
2484
+ const text3 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
2485
+ _src_path: ${join9(ctx.pjanglerRoot, "templates", "commonproject")}
2486
+ project_description: ${String(project.project_description)}
2487
+ project_name: ${String(project.project_name)}
2488
+ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2489
+ `;
2490
+ const path = join9(ctx.repoRoot, ".copier-answers.yml");
2491
+ if (safeReadText(path) !== text3) {
2492
+ changedFiles.push(path);
2493
+ if (!ctx.dryRun) writeText(path, text3);
2494
+ }
2495
+ return {
2496
+ id: finding.id,
2497
+ title: finding.title,
2498
+ status: changedFiles.length ? "applied" : "noop",
2499
+ summary: changedFiles.length ? "Copier provenance file refreshed" : "No changes required",
2500
+ changedFiles,
2501
+ details: []
2502
+ };
2503
+ }
2504
+ },
2505
+ {
2506
+ id: "bmad.scaffold",
2507
+ title: "BMAD modules/docs scaffold",
2508
+ audit: (ctx) => {
2509
+ const sourceRoot = join9(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
2510
+ const targetRoot = join9(ctx.repoRoot, "_bmad");
2511
+ const sentinels = [
2512
+ join9("core", "config.yaml"),
2513
+ join9("custom", "config.yaml"),
2514
+ join9("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
2515
+ join9("bmm", "workflows", "workflow-status", "workflow.yaml")
2516
+ ];
2517
+ const missing = sentinels.filter((file) => existsSync7(join9(sourceRoot, file)) && !existsSync7(join9(targetRoot, file)));
2518
+ return {
2519
+ id: "bmad.scaffold",
2520
+ title: "BMAD modules/docs scaffold",
2521
+ status: missing.length === 0 ? "pass" : "fail",
2522
+ summary: missing.length === 0 ? "BMAD scaffold parity verified" : `${missing.length} BMAD sentinel file(s) missing`,
2523
+ details: missing.map((file) => `_bmad/${file}`),
2524
+ fixable: true
2525
+ };
2526
+ },
2527
+ migrate: (ctx, finding) => {
2528
+ const changedFiles = [];
2529
+ copyMissingRecursive(join9(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join9(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
2530
+ return {
2531
+ id: finding.id,
2532
+ title: finding.title,
2533
+ status: changedFiles.length ? "applied" : "noop",
2534
+ summary: changedFiles.length ? "Copied missing BMAD scaffold files" : "No changes required",
2535
+ changedFiles,
2536
+ details: []
2537
+ };
2538
+ }
2539
+ },
2540
+ {
2541
+ id: "hermes.pm-scaffold",
2542
+ title: "Hermes PM scaffold parity",
2543
+ audit: (ctx) => {
2544
+ const roles = discoverRoles(ctx.repoRoot);
2545
+ const role = roles.find((item) => item.role === "pm");
2546
+ if (!role) {
2547
+ return { id: "hermes.pm-scaffold", title: "Hermes PM scaffold parity", status: "skip", summary: "No pm role present", details: [], fixable: false };
2548
+ }
2549
+ const details = [];
2550
+ 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"]) {
2551
+ if (!existsSync7(join9(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join9(role.roleDir, rel))}`);
2552
+ }
2553
+ const gitmodules = safeReadText(join9(ctx.repoRoot, ".gitmodules")) ?? "";
2554
+ if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing pm runtime submodule entry");
2555
+ if (!profileMetaInheritsDefault(join9(role.roleDir, "runtime", "profile.yaml"))) {
2556
+ details.push("runtime/profile.yaml missing inherited default config metadata");
2557
+ }
2558
+ const registry = safeReadText(registryPath(ctx.homeDir));
2559
+ if (!registry?.includes(`${role.agentId}:`)) details.push(`fleet registry missing ${role.agentId}`);
2560
+ return {
2561
+ id: "hermes.pm-scaffold",
2562
+ title: "Hermes PM scaffold parity",
2563
+ status: details.length === 0 ? "pass" : "fail",
2564
+ summary: details.length === 0 ? "PM scaffold parity verified" : `${details.length} PM scaffold issue(s) detected`,
2565
+ details,
2566
+ fixable: true
2567
+ };
2568
+ },
2569
+ migrate: (ctx, finding) => {
2570
+ const role = discoverRoles(ctx.repoRoot).find((item) => item.role === "pm");
2571
+ const changedFiles = [];
2572
+ const details = [];
2573
+ if (!role) {
2574
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
2575
+ }
2576
+ const templateRoleDir = join9(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
2577
+ writeIfDifferent(join9(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
2578
+ writeIfDifferent(join9(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
2579
+ writeIfDifferent(join9(role.roleDir, ".gitignore"), readText(join9(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
2580
+ copyMissingRecursive(join9(templateRoleDir, ".runtime-scaffold"), join9(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
2581
+ copyMissingRecursive(join9(templateRoleDir, ".runtime-scaffold"), join9(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
2582
+ copyMissingRecursive(join9(templateRoleDir, ".scripts"), join9(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
2583
+ const promptSource = join9(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
2584
+ const promptTarget = join9(role.roleDir, ".scripts", "sentinel.prompt.md");
2585
+ if (existsSync7(promptSource) && !existsSync7(promptTarget)) {
2586
+ 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);
2587
+ writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
2588
+ }
2589
+ upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
2590
+ const profileMetaUpdated = upsertInheritedProfileMeta(join9(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
2591
+ if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
2592
+ const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
2593
+ if (registryUpdated) details.push(`updated ${registryUpdated}`);
2594
+ return {
2595
+ id: finding.id,
2596
+ title: finding.title,
2597
+ status: changedFiles.length ? "applied" : "noop",
2598
+ summary: changedFiles.length ? "PM scaffold normalized" : "No changes required",
2599
+ changedFiles,
2600
+ details
2601
+ };
2602
+ }
2603
+ },
2604
+ {
2605
+ id: "systemd.sentinel",
2606
+ title: "Hermes systemd/sentinel units enabled + active",
2607
+ audit: (ctx) => {
2608
+ const roles = discoverRoles(ctx.repoRoot);
2609
+ if (!roles.length) {
2610
+ return { id: "systemd.sentinel", title: "Hermes systemd/sentinel units enabled + active", status: "skip", summary: "No Hermes roles present", details: [], fixable: false };
2611
+ }
2612
+ const probe = systemctlUser(["is-system-running"]);
2613
+ if (!probe.ok && !/running|degraded|starting|maintenance/.test(`${probe.stdout} ${probe.stderr}`)) {
2614
+ 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 };
2615
+ }
2616
+ const details = [];
2617
+ for (const role of roles) {
2618
+ for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
2619
+ const state = checkUnit(unit);
2620
+ if (!state.enabled || !state.active) details.push(`${unit} should be enabled+active`);
2621
+ }
2622
+ }
2623
+ return {
2624
+ id: "systemd.sentinel",
2625
+ title: "Hermes systemd/sentinel units enabled + active",
2626
+ status: details.length === 0 ? "pass" : "fail",
2627
+ summary: details.length === 0 ? "Hermes user units are enabled and active" : `${details.length} systemd parity issue(s) detected`,
2628
+ details,
2629
+ fixable: true
2630
+ };
2631
+ },
2632
+ migrate: (ctx, finding) => {
2633
+ const roles = discoverRoles(ctx.repoRoot);
2634
+ const changedFiles = [];
2635
+ const details = [];
2636
+ if (!roles.length) {
2637
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "No Hermes roles present", changedFiles, details };
2638
+ }
2639
+ const probe = systemctlUser(["is-system-running"]);
2640
+ if (!probe.ok && !/running|degraded|starting|maintenance/.test(`${probe.stdout} ${probe.stderr}`)) {
2641
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
2642
+ }
2643
+ for (const role of roles) {
2644
+ const sysDir = join9(ctx.homeDir, ".config", "systemd", "user");
2645
+ const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
2646
+ const allUnitsPresent = units.every((unit) => existsSync7(join9(sysDir, unit)));
2647
+ if (allUnitsPresent) {
2648
+ if (ctx.dryRun) {
2649
+ details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
2650
+ } else {
2651
+ systemctlUser(["daemon-reload"]);
2652
+ for (const unit of units) {
2653
+ systemctlUser(["enable", "--now", unit]);
2654
+ }
2655
+ }
2656
+ continue;
2657
+ }
2658
+ for (const script of [join9(role.roleDir, ".scripts", "70-systemd.sh")]) {
2659
+ if (!script || !existsSync7(script)) continue;
2660
+ if (ctx.dryRun) {
2661
+ details.push(`would run: bash ${script}`);
2662
+ } else {
2663
+ const result = spawnSync4("bash", [script], { cwd: role.roleDir, encoding: "utf8" });
2664
+ if (result.status !== 0) details.push(`script failed: ${script}: ${result.stderr.trim() || result.stdout.trim()}`);
2665
+ }
2666
+ }
2667
+ }
2668
+ return {
2669
+ id: finding.id,
2670
+ title: finding.title,
2671
+ status: details.some((detail) => detail.includes("failed:")) ? "blocked" : details.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
2672
+ summary: details.length ? ctx.dryRun ? "Planned systemd remediation commands" : "Attempted systemd remediation" : "No changes required",
2673
+ changedFiles,
2674
+ details
2675
+ };
2676
+ }
2677
+ }
2678
+ ];
2679
+ function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
2680
+ const normalized = content.endsWith("\n") ? content : `${content}
2681
+ `;
2682
+ if (safeReadText(path) === normalized) return;
2683
+ changedFiles.push(path);
2684
+ if (!dryRun) {
2685
+ writeText(path, normalized);
2686
+ if (mode) chmodSync(path, mode);
2687
+ }
2688
+ }
2689
+ function getParityRuleIds() {
2690
+ return RULES.map((rule) => rule.id);
2691
+ }
2692
+ function runAudit(repoArg) {
2693
+ const pjanglerRoot = resolvePjanglerRoot();
2694
+ const ctx = {
2695
+ repoRoot: resolve(repoArg ?? process.cwd()),
2696
+ dryRun: true,
2697
+ pjanglerRoot,
2698
+ homeDir: homedir4()
2699
+ };
2700
+ const rules = RULES.map((rule) => rule.audit(ctx));
2701
+ return {
2702
+ repo: ctx.repoRoot,
2703
+ ok: rules.every((rule) => rule.status === "pass" || rule.status === "skip"),
2704
+ auditedAt: (/* @__PURE__ */ new Date()).toISOString(),
2705
+ rules
2706
+ };
2707
+ }
2708
+ function runMigrationForRules(ruleIds, repoArg, dryRun) {
2709
+ const pjanglerRoot = resolvePjanglerRoot();
2710
+ const ctx = {
2711
+ repoRoot: resolve(repoArg ?? process.cwd()),
2712
+ dryRun,
2713
+ pjanglerRoot,
2714
+ homeDir: homedir4()
2715
+ };
2716
+ const selected = RULES.filter((rule) => ruleIds.includes(rule.id));
2717
+ if (!selected.length) {
2718
+ throw new Error(`Unknown parity rules: ${ruleIds.join(", ")}`);
2719
+ }
2720
+ const results = selected.map((rule) => {
2721
+ try {
2722
+ return rule.migrate(ctx, rule.audit(ctx));
2723
+ } catch (err) {
2724
+ return {
2725
+ id: rule.id,
2726
+ title: rule.title,
2727
+ status: "blocked",
2728
+ summary: `migrate threw: ${err instanceof Error ? err.message : String(err)}`,
2729
+ changedFiles: [],
2730
+ details: []
2731
+ };
2732
+ }
2733
+ });
2734
+ const changedFiles = Array.from(new Set(results.flatMap((result) => result.changedFiles))).sort();
2735
+ return {
2736
+ repo: ctx.repoRoot,
2737
+ dryRun,
2738
+ ok: results.every((result) => result.status !== "blocked"),
2739
+ selectedRules: selected.map((rule) => rule.id),
2740
+ results,
2741
+ changedFiles
2742
+ };
2743
+ }
2744
+ function runMigration(selector, repoArg, dryRun, all) {
2745
+ const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
2746
+ return runMigrationForRules(ruleIds, repoArg, dryRun);
2747
+ }
2748
+ function formatAuditReport(report) {
2749
+ const lines = [`repo: ${report.repo}`, `ok: ${report.ok}`, `audited_at: ${report.auditedAt}`, "rules:"];
2750
+ for (const rule of report.rules) {
2751
+ lines.push(`- ${rule.id} [${rule.status}] ${rule.summary}`);
2752
+ for (const detail of rule.details) lines.push(` - ${detail}`);
2753
+ }
2754
+ return `${lines.join("\n")}
2755
+ `;
2756
+ }
2757
+
2758
+ // src/project/index.ts
2759
+ import { spawnSync as spawnSync5 } from "node:child_process";
2760
+ import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync5, renameSync as renameSync2, statSync, writeFileSync as writeFileSync5 } from "node:fs";
2761
+ import { homedir as homedir5 } from "node:os";
2762
+ import { basename as basename3, dirname as dirname7, join as join10, resolve as resolve2 } from "node:path";
2763
+ import YAML from "yaml";
2764
+ var PROJECT_REGISTRY_ENV = "PJ_PROJECT_REGISTRY";
2765
+ var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
2766
+ var KNOWN_SKILL_ROOTS = [
2767
+ "/home/delorenj/code/skillex/all-skills",
2768
+ "/home/delorenj/code/CoachingAgentFramework/.agents/skills",
2769
+ "/home/delorenj/code/pjangler/.agents/skills",
2770
+ join10(homedir5(), ".codex", "skills")
2771
+ ];
2772
+ function projectRegistryPath(env = process.env) {
2773
+ return expandHome(env[PROJECT_REGISTRY_ENV] || join10(homedir5(), ".config", "pjangler", "projects.yaml"));
2774
+ }
2775
+ function emptyProjectRegistry() {
2776
+ return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
2777
+ }
2778
+ function loadProjectRegistry(path = projectRegistryPath()) {
2779
+ if (!existsSync8(path)) return emptyProjectRegistry();
2780
+ const raw = YAML.parse(readFileSync5(path, "utf8"));
2781
+ if (raw == null) return emptyProjectRegistry();
2782
+ if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
2783
+ const registry = raw;
2784
+ const normalized = {
2785
+ schema_version: Number(registry.schema_version ?? PROJECT_REGISTRY_SCHEMA_VERSION),
2786
+ projects: isRecord(registry.projects) ? registry.projects : {}
2787
+ };
2788
+ validateProjectRegistry(normalized);
2789
+ return normalized;
2790
+ }
2791
+ function saveProjectRegistry(registry, path = projectRegistryPath()) {
2792
+ validateProjectRegistry(registry);
2793
+ mkdirSync6(dirname7(path), { recursive: true });
2794
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
2795
+ writeFileSync5(temp, YAML.stringify(registry, { lineWidth: 0 }), "utf8");
2796
+ renameSync2(temp, path);
2797
+ }
2798
+ function validateProjectRegistry(registry) {
2799
+ if (registry.schema_version !== PROJECT_REGISTRY_SCHEMA_VERSION) {
2800
+ throw new Error(`Unsupported project registry schema_version: ${registry.schema_version}`);
2801
+ }
2802
+ if (!isRecord(registry.projects)) throw new Error("Project registry projects must be a mapping");
2803
+ const slugs = /* @__PURE__ */ new Set();
2804
+ const repoPaths = /* @__PURE__ */ new Map();
2805
+ const identifiers = /* @__PURE__ */ new Map();
2806
+ for (const [slug, project] of Object.entries(registry.projects)) {
2807
+ validateProjectRecord(project, slug);
2808
+ if (slugs.has(project.slug)) throw new Error(`Duplicate project slug: ${project.slug}`);
2809
+ slugs.add(project.slug);
2810
+ const repoKey = resolve2(project.repo_path);
2811
+ const existingRepoSlug = repoPaths.get(repoKey);
2812
+ if (existingRepoSlug && existingRepoSlug !== slug) {
2813
+ throw new Error(`Duplicate project repo_path: ${project.repo_path} used by ${existingRepoSlug} and ${slug}`);
2814
+ }
2815
+ repoPaths.set(repoKey, slug);
2816
+ const identifier = project.ticket_provider.identifier?.toUpperCase();
2817
+ if (identifier) {
2818
+ const existingIdentifierSlug = identifiers.get(identifier);
2819
+ if (existingIdentifierSlug && existingIdentifierSlug !== slug) {
2820
+ throw new Error(`Duplicate project identifier: ${identifier} used by ${existingIdentifierSlug} and ${slug}`);
2821
+ }
2822
+ identifiers.set(identifier, slug);
2823
+ }
2824
+ }
2825
+ }
2826
+ function slugifyProjectName(value) {
2827
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
2828
+ }
2829
+ function deriveProjectIdentifier(value) {
2830
+ const compact = value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
2831
+ const identifier = compact.slice(0, 4) || "PROJ";
2832
+ return identifier.length >= 2 ? identifier : `${identifier}XX`.slice(0, 4);
2833
+ }
2834
+ function normalizeAgentRole(value) {
2835
+ return value?.trim() || "pm";
2836
+ }
2837
+ function jsonStable(value) {
2838
+ return JSON.stringify(value);
2839
+ }
2840
+ function projectRecordEquivalent(a, b) {
2841
+ if (!a) return false;
2842
+ const { created_at: _aCreated, updated_at: _aUpdated, ...aComparable } = a;
2843
+ const { created_at: _bCreated, updated_at: _bUpdated, ...bComparable } = b;
2844
+ return jsonStable(aComparable) === jsonStable(bComparable);
2845
+ }
2846
+ function defaultProjectTargetDir(name, cwd = process.cwd()) {
2847
+ const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
2848
+ return resolve2(dirname7(resolve2(cwd)), compactName);
2849
+ }
2850
+ function resolveSourceSkillPath(sourceSkill) {
2851
+ if (!sourceSkill) return void 0;
2852
+ const expanded = expandHome(sourceSkill);
2853
+ const direct = resolve2(expanded);
2854
+ if (existsSync8(direct)) return direct;
2855
+ const name = basename3(sourceSkill);
2856
+ for (const root of KNOWN_SKILL_ROOTS) {
2857
+ const candidate = join10(root, name);
2858
+ if (existsSync8(candidate)) return candidate;
2859
+ }
2860
+ const civilWarLetterifier = "/home/delorenj/code/skillex/all-skills/civilwar-letterifier";
2861
+ const hint = existsSync8(civilWarLetterifier) ? ` Did you mean ${civilWarLetterifier}?` : "";
2862
+ throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
2863
+ }
2864
+ function planProjectInit(input) {
2865
+ if (!input.name.trim()) throw new Error("Project name is required");
2866
+ const registryPath2 = resolve2(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
2867
+ const registry = loadProjectRegistry(registryPath2);
2868
+ const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
2869
+ const slug = input.projectSlug ?? slugifyProjectName(input.name);
2870
+ const targetDir = resolve2(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
2871
+ const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
2872
+ const existing = registry.projects[slug];
2873
+ const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
2874
+ const overwrite = input.overwrite ?? input.force ?? false;
2875
+ const agentRole = normalizeAgentRole(input.agentRole);
2876
+ const agents = input.provisionAgent ? {
2877
+ ...existing?.agents ?? {},
2878
+ [agentRole]: {
2879
+ role: agentRole,
2880
+ provisioning_state: "planned"
2881
+ }
2882
+ } : existing?.agents ?? {};
2883
+ const scaffold = input.scaffold ?? true;
2884
+ const candidateProject = {
2885
+ name: input.name,
2886
+ slug,
2887
+ repo_path: targetDir,
2888
+ description: input.description ?? "",
2889
+ status: "planned",
2890
+ source_artifacts: sourceSkillPath ? [{ kind: "skill", path: sourceSkillPath, package_name: input.packageName ?? slug }] : [],
2891
+ template: {
2892
+ commonproject: {
2893
+ enabled: true,
2894
+ primary_language: input.primaryLanguage ?? "python"
2895
+ }
2896
+ },
2897
+ ticket_provider: {
2898
+ type: input.ticketProvider ?? "plane",
2899
+ workspace: input.planeWorkspace ?? "33god",
2900
+ identifier,
2901
+ board_id: input.planeProjectId ?? "",
2902
+ board_url: input.planeProjectId ? `https://plane.delo.sh/${input.planeWorkspace ?? "33god"}/projects/${input.planeProjectId}/issues/` : "",
2903
+ state: input.live ? "planned" : "planned"
2904
+ },
2905
+ agents,
2906
+ created_at: existing?.created_at ?? now,
2907
+ updated_at: now
2908
+ };
2909
+ const project = {
2910
+ ...candidateProject,
2911
+ updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
2912
+ };
2913
+ validateNoDuplicateProject(registry, project, overwrite);
2914
+ const pjanglerRoot = resolve2(input.pjanglerRoot ?? resolvePjanglerRoot2());
2915
+ const manifest = projectManifestFromRegistryProject(project);
2916
+ const apply = input.apply ?? false;
2917
+ const live = input.live ?? false;
2918
+ const actions = [
2919
+ { kind: "registry.upsert", registryPath: registryPath2, slug, project }
2920
+ ];
2921
+ if (scaffold) {
2922
+ actions.push(buildCommonProjectCopierAction({
2923
+ pjanglerRoot,
2924
+ targetDir,
2925
+ projectName: project.name,
2926
+ projectDescription: project.description,
2927
+ projectSlug: project.slug,
2928
+ ticketProvider: project.ticket_provider.type,
2929
+ planeWorkspace: project.ticket_provider.workspace ?? "33god",
2930
+ planeProjectId: project.ticket_provider.board_id ?? "",
2931
+ projectIdentifier: identifier,
2932
+ primaryLanguage: project.template.commonproject.primary_language,
2933
+ overwrite
2934
+ }));
2935
+ }
2936
+ actions.push(
2937
+ { kind: "project.write-manifest", path: join10(targetDir, ".project.json"), manifest },
2938
+ {
2939
+ kind: "plane.create-or-link",
2940
+ enabled: live,
2941
+ live,
2942
+ workspace: project.ticket_provider.workspace ?? "33god",
2943
+ identifier,
2944
+ state: live ? "planned" : "planned",
2945
+ reason: live ? void 0 : "network/cloud actions require --live"
2946
+ },
2947
+ {
2948
+ kind: "hermes.provision-agent",
2949
+ enabled: input.provisionAgent ?? false,
2950
+ local: !live,
2951
+ targetDir,
2952
+ targetRepo: slug,
2953
+ role: agentRole,
2954
+ context: {
2955
+ skipRuntimeRepo: !live,
2956
+ skipPlane: !live,
2957
+ skipBloodbank: !live,
2958
+ skipSystemd: !live || process.platform === "darwin"
2959
+ }
2960
+ }
2961
+ );
2962
+ return { ok: true, apply, dryRun: !apply, live, registryPath: registryPath2, project, manifest, actions };
2963
+ }
2964
+ function executeProjectInitPlan(plan) {
2965
+ const logs = [];
2966
+ const errors = [];
2967
+ const changedFiles = [];
2968
+ if (!plan.apply) return { ok: true, plan, logs, errors, changedFiles };
2969
+ const registry = loadProjectRegistry(plan.registryPath);
2970
+ let pendingRegistryAction;
2971
+ for (const action of plan.actions) {
2972
+ if (action.kind === "copier.copy.commonproject") {
2973
+ mkdirSync6(dirname7(action.targetDir), { recursive: true });
2974
+ const result = spawnSync5(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
2975
+ if (result.stdout?.trim()) logs.push(result.stdout.trim());
2976
+ if (result.stderr?.trim()) logs.push(result.stderr.trim());
2977
+ if (result.error) {
2978
+ const code = result.error.code;
2979
+ errors.push(
2980
+ code === "ENOENT" ? "copier not found on PATH. Install with: uv tool install copier or pip install copier" : `copier failed: ${result.error.message}`
2981
+ );
2982
+ break;
2983
+ }
2984
+ if (result.status !== 0) {
2985
+ errors.push(`copier exited with status ${result.status ?? "unknown"}`);
2986
+ if (existsSync8(action.targetDir)) changedFiles.push(action.targetDir);
2987
+ break;
2988
+ }
2989
+ changedFiles.push(action.targetDir);
2990
+ } else if (action.kind === "project.write-manifest") {
2991
+ mkdirSync6(dirname7(action.path), { recursive: true });
2992
+ const next = `${JSON.stringify(action.manifest, null, 2)}
2993
+ `;
2994
+ const current = existsSync8(action.path) ? readFileSync5(action.path, "utf8") : void 0;
2995
+ if (current !== next) {
2996
+ writeFileSync5(action.path, next, "utf8");
2997
+ changedFiles.push(action.path);
2998
+ }
2999
+ } else if (action.kind === "registry.upsert") {
3000
+ pendingRegistryAction = action;
3001
+ } else if (action.kind === "plane.create-or-link") {
3002
+ logs.push(action.enabled ? "plane.create-or-link requires a live provider integration" : "plane.create-or-link skipped (requires --live)");
3003
+ } else if (action.kind === "hermes.provision-agent") {
3004
+ logs.push(action.enabled ? "hermes.provision-agent planned for the caller to execute" : "hermes.provision-agent skipped");
3005
+ }
3006
+ }
3007
+ if (pendingRegistryAction && errors.length === 0) {
3008
+ if (!projectRecordEquivalent(registry.projects[pendingRegistryAction.slug], pendingRegistryAction.project)) {
3009
+ registry.projects[pendingRegistryAction.slug] = pendingRegistryAction.project;
3010
+ saveProjectRegistry(registry, pendingRegistryAction.registryPath);
3011
+ changedFiles.push(pendingRegistryAction.registryPath);
3012
+ }
3013
+ }
3014
+ return { ok: errors.length === 0, plan, logs, errors, changedFiles };
3015
+ }
3016
+ function projectManifestFromRegistryProject(project) {
3017
+ const agents = Object.fromEntries(
3018
+ Object.entries(project.agents).map(([name, agent]) => [
3019
+ `${project.slug}-${name}`,
3020
+ {
3021
+ role: agent.role,
3022
+ role_dir: agent.role_dir,
3023
+ provisioning_state: agent.provisioning_state
3024
+ }
3025
+ ])
3026
+ );
3027
+ return {
3028
+ project_name: project.name,
3029
+ project_description: project.description,
3030
+ project_slug: project.slug,
3031
+ repo_path: project.repo_path,
3032
+ ticket_provider: {
3033
+ type: project.ticket_provider.type,
3034
+ workspace: project.ticket_provider.workspace ?? "",
3035
+ identifier: project.ticket_provider.identifier ?? "",
3036
+ board_id: project.ticket_provider.board_id ?? "",
3037
+ board_url: project.ticket_provider.board_url ?? "",
3038
+ state: project.ticket_provider.state
3039
+ },
3040
+ agents
3041
+ };
3042
+ }
3043
+ function getProject(registry, slug) {
3044
+ const project = registry.projects[slug];
3045
+ if (!project) throw new Error(`Project not found in registry: ${slug}`);
3046
+ return project;
3047
+ }
3048
+ function buildCommonProjectCopierAction(input) {
3049
+ const templateDir = join10(input.pjanglerRoot, "templates", "commonproject");
3050
+ const data = {
3051
+ project_name: input.projectName,
3052
+ project_description: input.projectDescription ?? "",
3053
+ project_slug: input.projectSlug,
3054
+ ticket_provider: input.ticketProvider,
3055
+ plane_workspace: input.planeWorkspace,
3056
+ plane_project_id: input.planeProjectId ?? "",
3057
+ project_identifier: input.projectIdentifier,
3058
+ primary_language: input.primaryLanguage
3059
+ };
3060
+ const command = ["copier", "copy", "--trust", templateDir, input.targetDir, "--defaults"];
3061
+ for (const [key, value] of Object.entries(data)) command.push("--data", `${key}=${value}`);
3062
+ if (input.overwrite) command.push("--overwrite");
3063
+ return {
3064
+ kind: "copier.copy.commonproject",
3065
+ cwd: input.pjanglerRoot,
3066
+ command,
3067
+ targetDir: input.targetDir,
3068
+ data,
3069
+ overwrite: input.overwrite
3070
+ };
3071
+ }
3072
+ function resolvePjanglerRoot2() {
3073
+ let dir = dirname7(new URL(import.meta.url).pathname);
3074
+ while (dir !== dirname7(dir)) {
3075
+ if (existsSync8(join10(dir, "package.json")) && existsSync8(join10(dir, "templates", "commonproject", "copier.yml"))) return dir;
3076
+ dir = dirname7(dir);
3077
+ }
3078
+ return resolve2(process.cwd());
3079
+ }
3080
+ function validateNoDuplicateProject(registry, project, overwrite) {
3081
+ const existingSameSlug = registry.projects[project.slug];
3082
+ if (existingSameSlug && !overwrite && resolve2(existingSameSlug.repo_path) !== resolve2(project.repo_path)) {
3083
+ throw new Error(`Project slug already exists in registry: ${project.slug}`);
3084
+ }
3085
+ for (const [slug, existing] of Object.entries(registry.projects)) {
3086
+ if (slug === project.slug) continue;
3087
+ if (resolve2(existing.repo_path) === resolve2(project.repo_path)) {
3088
+ throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
3089
+ }
3090
+ if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
3091
+ throw new Error(`Project identifier already registered by ${slug}: ${project.ticket_provider.identifier}`);
3092
+ }
3093
+ }
3094
+ }
3095
+ function validateProjectRecord(project, key) {
3096
+ if (!isRecord(project)) throw new Error(`Project ${key} must be a mapping`);
3097
+ if (!project.name) throw new Error(`Project ${key} missing name`);
3098
+ if (!project.slug) throw new Error(`Project ${key} missing slug`);
3099
+ if (project.slug !== key) throw new Error(`Project key ${key} does not match slug ${project.slug}`);
3100
+ if (!project.repo_path) throw new Error(`Project ${key} missing repo_path`);
3101
+ if (!Array.isArray(project.source_artifacts)) throw new Error(`Project ${key} source_artifacts must be a list`);
3102
+ if (!isRecord(project.ticket_provider)) throw new Error(`Project ${key} ticket_provider must be a mapping`);
3103
+ if (!isRecord(project.agents)) throw new Error(`Project ${key} agents must be a mapping`);
3104
+ }
3105
+ function expandHome(path) {
3106
+ if (path === "~") return homedir5();
3107
+ if (path.startsWith("~/")) return join10(homedir5(), path.slice(2));
3108
+ return path;
3109
+ }
3110
+ function isRecord(value) {
3111
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3112
+ }
3113
+
3114
+ // src/mcp-server.ts
3115
+ var server = new McpServer({
3116
+ name: "pjangler-mcp",
3117
+ version: PJANGLER_VERSION
3118
+ });
3119
+ var TICKET_PROVIDER_SCHEMA = z.enum(["plane", "linear", "trello"]);
3120
+ function resolveTargetDir(targetDir) {
3121
+ const dir = resolve3(targetDir ?? process.cwd());
3122
+ if (!existsSync9(dir)) {
3123
+ throw new Error(`Target directory does not exist: ${dir}`);
3124
+ }
3125
+ if (!statSync2(dir).isDirectory()) {
3126
+ throw new Error(`Target path is not a directory: ${dir}`);
3127
+ }
3128
+ return dir;
3129
+ }
3130
+ function resolvePjanglerRoot3() {
3131
+ let dir = dirname8(fileURLToPath5(import.meta.url));
3132
+ while (dir !== dirname8(dir)) {
3133
+ if (existsSync9(join11(dir, "package.json")) && existsSync9(join11(dir, "templates", "commonproject", "copier.yml"))) {
3134
+ return dir;
3135
+ }
3136
+ dir = dirname8(dir);
3137
+ }
3138
+ throw new Error("Unable to resolve pjangler root");
3139
+ }
3140
+ function slugify(value) {
3141
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
3142
+ }
3143
+ function asText(payload) {
3144
+ return { content: [{ type: "text", text: typeof payload === "string" ? payload : JSON.stringify(payload, null, 2) }] };
3145
+ }
3146
+ function auditSummary(report) {
3147
+ const counts = report.rules.reduce((acc, rule) => {
3148
+ acc[rule.status] = (acc[rule.status] ?? 0) + 1;
3149
+ return acc;
3150
+ }, {});
3151
+ const nextActions = report.rules.filter((rule) => (rule.status === "fail" || rule.status === "warn") && rule.fixable).map((rule) => `pjangler_migrate_project ${rule.id}`);
3152
+ return { counts, nextActions };
3153
+ }
3154
+ function migrationSummary(report) {
3155
+ const counts = report.results.reduce((acc, result) => {
3156
+ acc[result.status] = (acc[result.status] ?? 0) + 1;
3157
+ return acc;
3158
+ }, {});
3159
+ return { counts, changedFileCount: report.changedFiles.length };
3160
+ }
3161
+ function parityGuidance() {
3162
+ return {
3163
+ skill: "@33god-projects",
3164
+ guidance: "Use these tools before editing a project so the repo SOT, agent files, mise hooks, and Hermes role scaffold are current.",
3165
+ workflows: [
3166
+ "audit -> pjangler_audit_project",
3167
+ "migrate -> pjangler_migrate_project",
3168
+ "bootstrap -> pjangler_bootstrap_33god_project",
3169
+ "agent provisioning -> pjangler_deploy_hermes_agent"
3170
+ ]
3171
+ };
3172
+ }
3173
+ async function runRecipeWithCapture(recipeName, context) {
3174
+ const recipe = createRecipe(recipeName, context);
3175
+ if (!recipe) {
3176
+ return {
3177
+ success: false,
3178
+ logs: [],
3179
+ errors: [`Unknown recipe: ${recipeName}. Available: ${getRecipeNames().join(", ")}`]
3180
+ };
3181
+ }
3182
+ const logs = [];
3183
+ const errors = [];
3184
+ const origLog = console.log;
3185
+ const origError = console.error;
3186
+ console.log = (...args) => {
3187
+ logs.push(args.map((a) => String(a)).join(" "));
3188
+ };
3189
+ console.error = (...args) => {
3190
+ errors.push(args.map((a) => String(a)).join(" "));
3191
+ };
3192
+ try {
3193
+ await recipe.execute();
3194
+ const combined = [...logs, ...errors].join("\n");
3195
+ const success = !combined.match(/(^|\n)✗/);
3196
+ return { success, logs, errors };
3197
+ } catch (err) {
3198
+ errors.push(err instanceof Error ? err.message : String(err));
3199
+ return { success: false, logs, errors };
3200
+ } finally {
3201
+ console.log = origLog;
3202
+ console.error = origError;
3203
+ }
3204
+ }
3205
+ server.registerTool(
3206
+ "pjangler_list_capabilities",
3207
+ {
3208
+ title: "List pjangler capabilities",
3209
+ description: "Returns available recipes, commands, parity rules, workflows, and @33god-projects tool guidance.",
3210
+ inputSchema: {}
3211
+ },
3212
+ async () => {
3213
+ const payload = {
3214
+ recipes: Object.values(RECIPE_REGISTRY).map((r) => ({
3215
+ name: r.name,
3216
+ description: r.description,
3217
+ commands: r.commands
3218
+ })),
3219
+ commands: Object.values(COMMAND_REGISTRY).map((c) => ({
3220
+ name: c.name,
3221
+ description: c.description,
3222
+ group: c.group
3223
+ })),
3224
+ parityRules: getParityRuleIds(),
3225
+ recommendedWorkflows: {
3226
+ existingProject: ["pjangler_audit_project", "pjangler_migrate_project"],
3227
+ new33godProject: ["pjangler_project_init", "pjangler_bootstrap_33god_project", "pjangler_audit_project"],
3228
+ hermesAgentProvisioning: ["pjangler_deploy_hermes_agent", "pjangler_audit_project"]
3229
+ },
3230
+ skillSynergy: parityGuidance()
3231
+ };
3232
+ return asText(payload);
3233
+ }
3234
+ );
3235
+ server.registerTool(
3236
+ "pjangler_list_parity_rules",
3237
+ {
3238
+ title: "List pjangler parity rules",
3239
+ description: "Returns parity rule ids plus brief @33god-projects guidance.",
3240
+ inputSchema: {}
3241
+ },
3242
+ async () => asText({ parityRules: getParityRuleIds(), guidance: parityGuidance() })
3243
+ );
3244
+ server.registerTool(
3245
+ "pjangler_audit_project",
3246
+ {
3247
+ title: "Audit project parity",
3248
+ description: "Runs pjangler parity audit for a project and returns structured findings with summary counts and next actions.",
3249
+ inputSchema: {
3250
+ targetDir: z.string().optional(),
3251
+ json: z.boolean().optional()
3252
+ }
3253
+ },
3254
+ async ({ targetDir, json }) => {
3255
+ try {
3256
+ const resolvedTarget = resolveTargetDir(targetDir);
3257
+ const report = runAudit(resolvedTarget);
3258
+ const payload = { ...report, summary: auditSummary(report), guidance: parityGuidance() };
3259
+ return asText(json === false ? formatAuditReport(report) : payload);
3260
+ } catch (err) {
3261
+ return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
3262
+ }
3263
+ }
3264
+ );
3265
+ server.registerTool(
3266
+ "pjangler_migrate_project",
3267
+ {
3268
+ title: "Migrate project parity",
3269
+ description: "Runs one pjangler parity migration rule, or all rules, against a project.",
3270
+ inputSchema: {
3271
+ targetDir: z.string().optional(),
3272
+ ruleId: z.string().optional(),
3273
+ all: z.boolean().optional(),
3274
+ dryRun: z.boolean().optional()
3275
+ }
3276
+ },
3277
+ async ({ targetDir, ruleId, all, dryRun }) => {
3278
+ try {
3279
+ const runAll = all ?? false;
3280
+ if (!runAll && !ruleId) throw new Error("Either ruleId or all=true is required");
3281
+ if (runAll && ruleId) throw new Error("Pass either ruleId or all=true, not both");
3282
+ const resolvedTarget = resolveTargetDir(targetDir);
3283
+ const report = runMigration(ruleId, resolvedTarget, dryRun ?? true, runAll);
3284
+ return asText({
3285
+ ok: report.ok,
3286
+ repo: report.repo,
3287
+ dryRun: report.dryRun,
3288
+ selectedRules: report.selectedRules,
3289
+ changedFiles: report.changedFiles,
3290
+ results: report.results,
3291
+ summary: migrationSummary(report)
3292
+ });
3293
+ } catch (err) {
3294
+ return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
3295
+ }
3296
+ }
3297
+ );
3298
+ server.registerTool(
3299
+ "pjangler_bootstrap_33god_project",
3300
+ {
3301
+ title: "Bootstrap a new @33god project",
3302
+ description: "Create a new CommonProject-based 33god repo with optional local Hermes agent provisioning. Dry-run is safe and does not require copier.",
3303
+ inputSchema: {
3304
+ parentDir: z.string().optional(),
3305
+ targetDir: z.string().optional(),
3306
+ projectName: z.string(),
3307
+ projectDescription: z.string().optional(),
3308
+ projectSlug: z.string().optional(),
3309
+ ticketProvider: TICKET_PROVIDER_SCHEMA.optional(),
3310
+ planeWorkspace: z.string().optional(),
3311
+ planeProjectId: z.string().optional(),
3312
+ projectIdentifier: z.string().optional(),
3313
+ primaryLanguage: z.string().optional(),
3314
+ skipPlane: z.boolean().optional(),
3315
+ provisionAgent: z.boolean().optional(),
3316
+ agentRole: z.string().optional(),
3317
+ agentPurpose: z.string().optional(),
3318
+ local: z.boolean().optional(),
3319
+ force: z.boolean().optional(),
3320
+ overwrite: z.boolean().optional(),
3321
+ dryRun: z.boolean().optional(),
3322
+ registryPath: z.string().optional(),
3323
+ sourceSkill: z.string().optional(),
3324
+ live: z.boolean().optional()
3325
+ }
3326
+ },
3327
+ async (input) => {
3328
+ try {
3329
+ const pjanglerRoot = resolvePjanglerRoot3();
3330
+ const projectSlug = input.projectSlug ?? slugify(input.projectName);
3331
+ const parentDir = resolve3(input.parentDir ?? process.cwd());
3332
+ if (!existsSync9(parentDir) || !statSync2(parentDir).isDirectory()) throw new Error(`Parent directory does not exist: ${parentDir}`);
3333
+ const targetDir = resolve3(input.targetDir ?? join11(parentDir, projectSlug));
3334
+ const overwrite = input.overwrite ?? input.force ?? false;
3335
+ const dryRun = input.dryRun ?? true;
3336
+ const local = input.local ?? true;
3337
+ const skipPlane = input.skipPlane ?? true;
3338
+ const planeProjectId = input.planeProjectId ?? "";
3339
+ if (!skipPlane && !planeProjectId) {
3340
+ throw new Error("planeProjectId is required when skipPlane=false; keep skipPlane=true for safe local bootstrap");
3341
+ }
3342
+ if (!dryRun && existsSync9(targetDir) && !overwrite) throw new Error(`Target already exists: ${targetDir} (set force/overwrite=true to re-render)`);
3343
+ const plan = planProjectInit({
3344
+ name: input.projectName,
3345
+ description: input.projectDescription,
3346
+ targetDir,
3347
+ projectSlug,
3348
+ sourceSkill: input.sourceSkill,
3349
+ primaryLanguage: input.primaryLanguage ?? "python",
3350
+ provisionAgent: input.provisionAgent ?? false,
3351
+ agentRole: input.agentRole ?? "pm",
3352
+ apply: !dryRun,
3353
+ live: input.live ?? false,
3354
+ registryPath: input.registryPath,
3355
+ projectIdentifier: input.projectIdentifier ?? projectSlug.slice(0, 4).toUpperCase(),
3356
+ ticketProvider: input.ticketProvider ?? "plane",
3357
+ planeWorkspace: input.planeWorkspace ?? "33god",
3358
+ planeProjectId,
3359
+ pjanglerRoot,
3360
+ overwrite
3361
+ });
3362
+ if (dryRun) {
3363
+ return asText({ ...plan, guidance: parityGuidance() });
3364
+ }
3365
+ const result = executeProjectInitPlan(plan);
3366
+ if (!result.ok) return asText({ ...result, guidance: parityGuidance() });
3367
+ let agentResult;
3368
+ if (input.provisionAgent) {
3369
+ const context = {
3370
+ targetDir,
3371
+ yes: true,
3372
+ targetRepo: projectSlug,
3373
+ role: input.agentRole ?? "pm",
3374
+ agentPurpose: input.agentPurpose ?? `Project manager for ${input.projectName}`,
3375
+ local,
3376
+ force: overwrite,
3377
+ dryRun: false,
3378
+ skipTelegram: true,
3379
+ skipEmail: true,
3380
+ skipRuntimeRepo: local,
3381
+ skipPlane: skipPlane || local,
3382
+ skipBloodbank: local,
3383
+ skipSystemd: local || process.platform === "darwin"
3384
+ };
3385
+ agentResult = await runRecipeWithCapture("hermes-agent", context);
3386
+ }
3387
+ return asText({ ...result, ok: result.ok && (!agentResult || agentResult.success), agentResult, guidance: parityGuidance() });
3388
+ } catch (err) {
3389
+ return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
3390
+ }
3391
+ }
3392
+ );
3393
+ server.registerTool(
3394
+ "pjangler_project_init",
3395
+ {
3396
+ title: "Initialize a pjangler project",
3397
+ description: "Plan or apply a registry-backed CommonProject project init. Dry-run is the default; writes require apply=true and live actions require live=true.",
3398
+ inputSchema: {
3399
+ name: z.string(),
3400
+ description: z.string().optional(),
3401
+ targetDir: z.string().optional(),
3402
+ sourceSkill: z.string().optional(),
3403
+ primaryLanguage: z.string().optional(),
3404
+ provisionAgent: z.boolean().optional(),
3405
+ agentRole: z.string().optional(),
3406
+ apply: z.boolean().optional(),
3407
+ live: z.boolean().optional(),
3408
+ slug: z.string().optional(),
3409
+ identifier: z.string().optional(),
3410
+ registryPath: z.string().optional(),
3411
+ force: z.boolean().optional()
3412
+ }
3413
+ },
3414
+ async (input) => {
3415
+ try {
3416
+ const plan = planProjectInit({
3417
+ name: input.name,
3418
+ description: input.description,
3419
+ targetDir: input.targetDir,
3420
+ sourceSkill: input.sourceSkill,
3421
+ primaryLanguage: input.primaryLanguage,
3422
+ provisionAgent: input.provisionAgent ?? false,
3423
+ agentRole: input.agentRole,
3424
+ apply: input.apply ?? false,
3425
+ live: input.live ?? false,
3426
+ projectSlug: input.slug,
3427
+ projectIdentifier: input.identifier,
3428
+ registryPath: input.registryPath,
3429
+ force: input.force ?? false,
3430
+ overwrite: input.force ?? false
3431
+ });
3432
+ if (!input.apply) return asText(plan);
3433
+ return asText(executeProjectInitPlan(plan));
3434
+ } catch (err) {
3435
+ return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
3436
+ }
3437
+ }
3438
+ );
3439
+ server.registerTool(
3440
+ "pjangler_project_list",
3441
+ {
3442
+ title: "List pjangler registry projects",
3443
+ description: "Return projects from the pjangler central registry.",
3444
+ inputSchema: {
3445
+ registryPath: z.string().optional()
3446
+ }
3447
+ },
3448
+ async ({ registryPath: registryPath2 }) => asText(loadProjectRegistry(registryPath2 ?? projectRegistryPath()))
3449
+ );
3450
+ server.registerTool(
3451
+ "pjangler_project_show",
3452
+ {
3453
+ title: "Show a pjangler registry project",
3454
+ description: "Return one project by slug from the pjangler central registry.",
3455
+ inputSchema: {
3456
+ slug: z.string(),
3457
+ registryPath: z.string().optional()
3458
+ }
3459
+ },
3460
+ async ({ slug, registryPath: registryPath2 }) => {
3461
+ try {
3462
+ return asText(getProject(loadProjectRegistry(registryPath2 ?? projectRegistryPath()), slug));
3463
+ } catch (err) {
3464
+ return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
3465
+ }
3466
+ }
3467
+ );
3468
+ server.registerTool(
3469
+ "pjangler_describe_recipe",
3470
+ {
3471
+ title: "Describe recipe",
3472
+ description: "Returns metadata for a specific pjangler recipe.",
3473
+ inputSchema: {
3474
+ recipe: z.string()
3475
+ }
3476
+ },
3477
+ async ({ recipe }) => {
3478
+ const info = getRecipeInfo(recipe);
3479
+ if (!info) {
3480
+ return {
3481
+ isError: true,
3482
+ content: [{ type: "text", text: `Recipe not found: ${recipe}` }]
3483
+ };
3484
+ }
3485
+ return asText({
3486
+ name: info.name,
3487
+ description: info.description,
3488
+ commands: info.commands
3489
+ });
3490
+ }
3491
+ );
3492
+ server.registerTool(
3493
+ "pjangler_run_recipe",
3494
+ {
3495
+ title: "Run recipe",
3496
+ description: "Executes any pjangler recipe against a target directory.",
3497
+ inputSchema: {
3498
+ recipe: z.enum(getRecipeNames()),
3499
+ targetDir: z.string().optional(),
3500
+ force: z.boolean().optional(),
3501
+ dryRun: z.boolean().optional()
3502
+ }
3503
+ },
3504
+ async ({ recipe, targetDir, force, dryRun }) => {
3505
+ try {
3506
+ const resolvedTarget = resolveTargetDir(targetDir);
3507
+ const context = {
3508
+ targetDir: resolvedTarget,
3509
+ force: force ?? false,
3510
+ dryRun: dryRun ?? false
3511
+ };
3512
+ const result = await runRecipeWithCapture(recipe, context);
3513
+ return {
3514
+ isError: !result.success,
3515
+ ...asText({
3516
+ success: result.success,
3517
+ recipe,
3518
+ targetDir: resolvedTarget,
3519
+ logs: result.logs,
3520
+ errors: result.errors
3521
+ })
3522
+ };
3523
+ } catch (err) {
3524
+ return {
3525
+ isError: true,
3526
+ content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }]
3527
+ };
3528
+ }
3529
+ }
3530
+ );
3531
+ server.registerTool(
3532
+ "pjangler_deploy_hermes_agent",
3533
+ {
3534
+ title: "Deploy Hermes agent",
3535
+ 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.",
3536
+ inputSchema: {
3537
+ targetDir: z.string(),
3538
+ targetRepo: z.string().optional(),
3539
+ role: z.enum(["pm", "dev", "review", "ops", "qa"]),
3540
+ agentPurpose: z.string().optional(),
3541
+ soulTone: z.enum(["direct", "playful", "formal", "terse"]).optional(),
3542
+ modelProvider: z.string().optional(),
3543
+ modelName: z.string().optional(),
3544
+ local: z.boolean().optional(),
3545
+ force: z.boolean().optional(),
3546
+ dryRun: z.boolean().optional(),
3547
+ skipTelegram: z.boolean().optional(),
3548
+ skipEmail: z.boolean().optional(),
3549
+ skipRuntimeRepo: z.boolean().optional(),
3550
+ skipPlane: z.boolean().optional(),
3551
+ skipBloodbank: z.boolean().optional(),
3552
+ skipSystemd: z.boolean().optional(),
3553
+ ticketProvider: TICKET_PROVIDER_SCHEMA.optional()
3554
+ }
3555
+ },
3556
+ async (input) => {
3557
+ try {
3558
+ const resolvedTarget = resolveTargetDir(input.targetDir);
3559
+ const local = input.local ?? true;
3560
+ const context = {
3561
+ targetDir: resolvedTarget,
3562
+ yes: true,
3563
+ local,
3564
+ targetRepo: input.targetRepo ?? basename4(resolvedTarget),
3565
+ role: input.role,
3566
+ agentPurpose: input.agentPurpose,
1460
3567
  soulTone: input.soulTone,
1461
3568
  modelProvider: input.modelProvider,
1462
3569
  modelName: input.modelName,
3570
+ ticketProvider: input.ticketProvider,
1463
3571
  force: input.force ?? false,
1464
3572
  dryRun: input.dryRun ?? false,
1465
3573
  skipTelegram: input.skipTelegram ?? true,
1466
3574
  skipEmail: input.skipEmail ?? true,
1467
- skipRuntimeRepo: input.skipRuntimeRepo ?? false,
1468
- skipPlane: input.skipPlane ?? false,
1469
- skipBloodbank: input.skipBloodbank ?? false,
1470
- skipSystemd: input.skipSystemd ?? false
3575
+ skipRuntimeRepo: input.skipRuntimeRepo ?? local,
3576
+ skipPlane: input.skipPlane ?? local,
3577
+ skipBloodbank: input.skipBloodbank ?? local,
3578
+ skipSystemd: input.skipSystemd ?? (local || process.platform === "darwin")
1471
3579
  };
1472
3580
  const result = await runRecipeWithCapture("hermes-agent", context);
1473
3581
  return {
1474
3582
  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
- ]
3583
+ ...asText({
3584
+ success: result.success,
3585
+ recipe: "hermes-agent",
3586
+ targetDir: resolvedTarget,
3587
+ guidance: parityGuidance(),
3588
+ context: {
3589
+ targetRepo: context.targetRepo,
3590
+ role: context.role,
3591
+ local: context.local,
3592
+ dryRun: context.dryRun,
3593
+ force: context.force,
3594
+ skipTelegram: context.skipTelegram,
3595
+ skipEmail: context.skipEmail,
3596
+ skipRuntimeRepo: context.skipRuntimeRepo,
3597
+ skipPlane: context.skipPlane,
3598
+ skipBloodbank: context.skipBloodbank,
3599
+ skipSystemd: context.skipSystemd
3600
+ },
3601
+ logs: result.logs,
3602
+ errors: result.errors
3603
+ })
1503
3604
  };
1504
3605
  } catch (err) {
1505
3606
  return {