@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
package/dist/index.js CHANGED
@@ -1,24 +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/index.ts
4
+ import { spawnSync as spawnSync6 } from "node:child_process";
5
+ import { existsSync as existsSync9, readFileSync as readFileSync6, statSync as statSync2 } from "node:fs";
6
+ import { basename as basename4, join as join11, resolve as resolve3 } from "node:path";
10
7
  import { Command as Command3 } from "commander";
11
8
 
12
9
  // src/commands/hermes/types.ts
13
10
  var HERMES_AGENT_TEMPLATE = "gh:delorenj/hermes-agent-template";
14
11
  var SOUL_TONES = ["direct", "playful", "formal", "terse"];
15
12
  var ROLE_CHOICES = [
16
- { value: "pm", label: "Project Manager (pm)", hint: "triage, planning, ticket authorship" },
17
- {
18
- value: "scrum-master",
19
- label: "Scrum Master (Ticket Sentinel)",
20
- hint: "continuous ticket sentinel + autonomous delegated review"
21
- },
13
+ { value: "pm", label: "Project Manager (pm)", hint: "triage, planning, ticket authorship, board reconciliation" },
22
14
  { value: "dev", label: "Developer (dev)", hint: "implements tickets" },
23
15
  { value: "review", label: "Reviewer (review)", hint: "adversarial code review" },
24
16
  { value: "ops", label: "Ops (ops)", hint: "deploy / infra" },
@@ -38,10 +30,12 @@ function deriveProfileName(repo, role) {
38
30
 
39
31
  // src/commands/hermes/EnsureTemplateConfig.ts
40
32
  import { homedir, platform } from "node:os";
41
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
42
- import { join, dirname } from "node:path";
33
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
34
+ import { join as join2, dirname as dirname2 } from "node:path";
43
35
 
44
36
  // src/commands/Command.ts
37
+ import { existsSync, writeFileSync, mkdirSync } from "fs";
38
+ import { join, dirname } from "path";
45
39
  var Command = class {
46
40
  context;
47
41
  constructor(context) {
@@ -54,30 +48,24 @@ var Command = class {
54
48
  return this.context.dryRun ? `[DRY RUN] ${message}` : message;
55
49
  }
56
50
  fileExists(filePath) {
57
- const { existsSync: existsSync6 } = __require("fs");
58
- const { join: join8 } = __require("path");
59
- const fullPath = join8(this.context.targetDir, filePath);
60
- return existsSync6(fullPath);
51
+ const fullPath = join(this.context.targetDir, filePath);
52
+ return existsSync(fullPath);
61
53
  }
62
54
  writeFile(filePath, content) {
63
55
  if (this.context.dryRun) {
64
56
  return;
65
57
  }
66
- const { writeFileSync: writeFileSync3, mkdirSync: mkdirSync4 } = __require("fs");
67
- const { join: join8, dirname: dirname5 } = __require("path");
68
- const fullPath = join8(this.context.targetDir, filePath);
69
- const dir = dirname5(fullPath);
70
- mkdirSync4(dir, { recursive: true });
71
- writeFileSync3(fullPath, content);
58
+ const fullPath = join(this.context.targetDir, filePath);
59
+ const dir = dirname(fullPath);
60
+ mkdirSync(dir, { recursive: true });
61
+ writeFileSync(fullPath, content);
72
62
  }
73
63
  createDirectory(dirPath) {
74
64
  if (this.context.dryRun) {
75
65
  return;
76
66
  }
77
- const { mkdirSync: mkdirSync4 } = __require("fs");
78
- const { join: join8 } = __require("path");
79
- const fullPath = join8(this.context.targetDir, dirPath);
80
- mkdirSync4(fullPath, { recursive: true });
67
+ const fullPath = join(this.context.targetDir, dirPath);
68
+ mkdirSync(fullPath, { recursive: true });
81
69
  }
82
70
  };
83
71
 
@@ -86,26 +74,26 @@ function resolveTemplateConfigPath() {
86
74
  const fromEnv = process.env.HERMES_TEMPLATE_CONFIG;
87
75
  if (fromEnv && fromEnv.trim()) return fromEnv.trim();
88
76
  const xdg = process.env.XDG_CONFIG_HOME?.trim();
89
- const base = xdg && xdg.length ? xdg : join(homedir(), ".config");
90
- return join(base, "hermes-agent-template", "config.toml");
77
+ const base = xdg && xdg.length ? xdg : join2(homedir(), ".config");
78
+ return join2(base, "hermes-agent-template", "config.toml");
91
79
  }
92
80
  function detectHermesBin(home) {
93
81
  const candidates = [
94
- join(home, "code", "hermes-agent", "venv", "bin", "hermes"),
95
- join(home, "code", "hermes-agent", ".venv", "bin", "hermes"),
96
- join(home, ".local", "bin", "hermes")
82
+ join2(home, "code", "hermes-agent", "venv", "bin", "hermes"),
83
+ join2(home, "code", "hermes-agent", ".venv", "bin", "hermes"),
84
+ join2(home, ".local", "bin", "hermes")
97
85
  ];
98
86
  for (const c of candidates) {
99
- if (existsSync(c)) return c;
87
+ if (existsSync2(c)) return c;
100
88
  }
101
89
  return candidates[0];
102
90
  }
103
91
  function renderHostConfig() {
104
92
  const home = homedir();
105
93
  const hermesBin = detectHermesBin(home);
106
- const hermesRepo = join(home, "code", "hermes-agent");
107
- const scaffoldDir = join(home, "code", "hermes-agent-template", "runtime-scaffold");
108
- const skillsDir = join(home, ".agents", "skills");
94
+ const hermesRepo = join2(home, "code", "hermes-agent");
95
+ const scaffoldDir = join2(home, "code", "hermes-agent-template", "runtime-scaffold");
96
+ const skillsDir = join2(home, ".agents", "skills");
109
97
  return `# hermes-agent-template \u2014 host configuration
110
98
  # Bootstrapped by \`pjangler config bootstrap\` for $HOME=${home} (platform=${platform()}).
111
99
  #
@@ -149,7 +137,7 @@ var EnsureTemplateConfig = class extends Command {
149
137
  const ctx = this.context;
150
138
  const force = ctx.forceConfig === true || process.env.PJANGLER_FORCE_CONFIG === "1";
151
139
  const path = resolveTemplateConfigPath();
152
- const exists = existsSync(path);
140
+ const exists = existsSync2(path);
153
141
  if (exists && !force) {
154
142
  console.log(`\u2713 Config present: ${path}`);
155
143
  return { success: true, message: "" };
@@ -159,8 +147,8 @@ var EnsureTemplateConfig = class extends Command {
159
147
  return { success: true, message: "" };
160
148
  }
161
149
  try {
162
- mkdirSync(dirname(path), { recursive: true });
163
- writeFileSync(path, renderHostConfig());
150
+ mkdirSync2(dirname2(path), { recursive: true });
151
+ writeFileSync2(path, renderHostConfig());
164
152
  } catch (err) {
165
153
  const msg = err instanceof Error ? err.message : String(err);
166
154
  return { success: false, message: `\u2717 Failed to write ${path}: ${msg}` };
@@ -577,12 +565,12 @@ var NodeRecipe = class extends Recipe {
577
565
  };
578
566
 
579
567
  // src/commands/hermes/PromptForAgentConfig.ts
580
- import { basename, join as join2 } from "node:path";
568
+ import { basename, join as join3 } from "node:path";
581
569
  import { readFileSync } from "node:fs";
582
570
  import * as p from "@clack/prompts";
583
571
  function detectTicketProvider(targetDir) {
584
572
  try {
585
- const t = JSON.parse(readFileSync(join2(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
573
+ const t = JSON.parse(readFileSync(join3(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
586
574
  return t === "plane" || t === "linear" || t === "trello" ? t : void 0;
587
575
  } catch {
588
576
  return void 0;
@@ -601,7 +589,6 @@ var PromptForAgentConfig = class extends Command {
601
589
  ctx.modelProvider ??= "";
602
590
  ctx.modelName ??= "";
603
591
  ctx.ticketProvider ??= detectTicketProvider(ctx.targetDir) ?? "plane";
604
- ctx.withScrumMaster ??= false;
605
592
  ctx.skipTelegram ??= true;
606
593
  ctx.skipEmail ??= true;
607
594
  ctx.agentId = deriveAgentId(ctx.targetRepo, ctx.role);
@@ -647,14 +634,6 @@ var PromptForAgentConfig = class extends Command {
647
634
  if (p.isCancel(answer)) return this.cancelled();
648
635
  ctx.ticketProvider = answer;
649
636
  }
650
- if (ctx.role === "pm" && ctx.withScrumMaster === void 0) {
651
- const answer = await p.confirm({
652
- message: "Also provision the paired Scrum Master (Ticket Sentinel) for this repo?",
653
- initialValue: true
654
- });
655
- if (p.isCancel(answer)) return this.cancelled();
656
- ctx.withScrumMaster = answer === true;
657
- }
658
637
  if (!ctx.agentPurpose) {
659
638
  const answer = await p.text({
660
639
  message: "One-line purpose",
@@ -727,21 +706,21 @@ var PromptForAgentConfig = class extends Command {
727
706
  // src/commands/hermes/RunCopierTemplate.ts
728
707
  import { spawnSync } from "node:child_process";
729
708
  import { homedir as homedir2 } from "node:os";
730
- import { join as join3, dirname as dirname2 } from "node:path";
731
- import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "node:fs";
709
+ import { join as join4, dirname as dirname3 } from "node:path";
710
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3 } from "node:fs";
732
711
  import { fileURLToPath } from "node:url";
733
712
  import * as p2 from "@clack/prompts";
734
713
  function resolveVendoredTemplate(name) {
735
714
  let dir;
736
715
  try {
737
- dir = dirname2(fileURLToPath(import.meta.url));
716
+ dir = dirname3(fileURLToPath(import.meta.url));
738
717
  } catch {
739
718
  return void 0;
740
719
  }
741
720
  for (let i = 0; i < 8; i++) {
742
- const candidate = join3(dir, "templates", name);
743
- if (existsSync2(join3(candidate, "copier.yml"))) return candidate;
744
- const parent = dirname2(dir);
721
+ const candidate = join4(dir, "templates", name);
722
+ if (existsSync3(join4(candidate, "copier.yml"))) return candidate;
723
+ const parent = dirname3(dir);
745
724
  if (parent === dir) break;
746
725
  dir = parent;
747
726
  }
@@ -753,14 +732,13 @@ var RunCopierTemplate = class extends Command {
753
732
  const { targetRepo, role, agentPurpose, soulTone, modelProvider, modelName } = ctx;
754
733
  const ticketProvider = ctx.ticketProvider ?? "plane";
755
734
  const profileName = ctx.profileName ?? (targetRepo && role ? deriveProfileName(targetRepo, role) : void 0);
756
- const withScrumMaster = role === "pm" && ctx.withScrumMaster === true;
757
735
  if (!targetRepo || !role) {
758
736
  return {
759
737
  success: false,
760
738
  message: "PromptForAgentConfig must run before RunCopierTemplate (targetRepo/role unset)"
761
739
  };
762
740
  }
763
- const roleDir = join3(ctx.targetDir, "agents", "hermes", role);
741
+ const roleDir = join4(ctx.targetDir, "agents", "hermes", role);
764
742
  ctx.roleDir = roleDir;
765
743
  ctx.runtimeRepo = `delorenj/agent-hm-${targetRepo}-${role}`;
766
744
  const which = spawnSync("which", ["copier"], { encoding: "utf8" });
@@ -770,7 +748,7 @@ var RunCopierTemplate = class extends Command {
770
748
  message: "\u2717 copier not found on PATH. Install with: `uv tool install copier` or `pip install copier`"
771
749
  };
772
750
  }
773
- if (existsSync2(join3(roleDir, "role.yaml")) && !ctx.force) {
751
+ if (existsSync3(join4(roleDir, "role.yaml")) && !ctx.force) {
774
752
  if (ctx.yes) {
775
753
  ctx.force = true;
776
754
  } else {
@@ -797,9 +775,9 @@ var RunCopierTemplate = class extends Command {
797
775
  SKIP_BLOODBANK: ctx.skipBloodbank ? "1" : "0",
798
776
  SKIP_SYSTEMD: ctx.skipSystemd ? "1" : "0"
799
777
  };
800
- const LOCAL_TEMPLATE = join3(homedir2(), "code", "hermes-agent-template");
778
+ const LOCAL_TEMPLATE = join4(homedir2(), "code", "hermes-agent-template");
801
779
  const vendored = resolveVendoredTemplate("hermes-agent");
802
- const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync2(join3(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
780
+ const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync3(join4(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
803
781
  const args = [
804
782
  "copy",
805
783
  templateSrc,
@@ -820,8 +798,6 @@ var RunCopierTemplate = class extends Command {
820
798
  `soul_tone=${soulTone ?? "direct"}`,
821
799
  "--data",
822
800
  `ticket_provider=${ticketProvider}`,
823
- "--data",
824
- `with_scrum_master=${withScrumMaster}`,
825
801
  "--trust",
826
802
  "--vcs-ref=HEAD"
827
803
  ];
@@ -832,7 +808,7 @@ var RunCopierTemplate = class extends Command {
832
808
  message: this.formatMessage(`Would run: copier ${args.join(" ")}`)
833
809
  };
834
810
  }
835
- mkdirSync2(join3(ctx.targetDir, "agents", "hermes"), { recursive: true });
811
+ mkdirSync3(join4(ctx.targetDir, "agents", "hermes"), { recursive: true });
836
812
  const spinner4 = p2.spinner();
837
813
  spinner4.start(`Running copier copy (target: agents/hermes/${role})`);
838
814
  const result = spawnSync("copier", args, {
@@ -857,8 +833,8 @@ var RunCopierTemplate = class extends Command {
857
833
 
858
834
  // src/commands/hermes/WireTelegram.ts
859
835
  import { spawnSync as spawnSync2 } from "node:child_process";
860
- import { join as join4 } from "node:path";
861
- import { existsSync as existsSync3, unlinkSync } from "node:fs";
836
+ import { join as join5 } from "node:path";
837
+ import { existsSync as existsSync4, unlinkSync } from "node:fs";
862
838
  import * as p3 from "@clack/prompts";
863
839
  var WireTelegram = class extends Command {
864
840
  async invoke() {
@@ -946,15 +922,15 @@ var WireTelegram = class extends Command {
946
922
  if (p3.isCancel(allowedAnswer)) {
947
923
  return { success: false, message: "\u2717 Aborted; Telegram step deferred." };
948
924
  }
949
- const script = join4(roleDir, ".scripts", "30-telegram.sh");
950
- if (!existsSync3(script)) {
925
+ const script = join5(roleDir, ".scripts", "30-telegram.sh");
926
+ if (!existsSync4(script)) {
951
927
  return {
952
928
  success: false,
953
929
  message: `\u2717 ${script} not found. Did copier finish? Re-run with --skip-runtime-repo=0 if you skipped it.`
954
930
  };
955
931
  }
956
- const marker = join4(roleDir, ".scripts", ".done-30-telegram");
957
- if (existsSync3(marker)) unlinkSync(marker);
932
+ const marker = join5(roleDir, ".scripts", ".done-30-telegram");
933
+ if (existsSync4(marker)) unlinkSync(marker);
958
934
  const spinner4 = p3.spinner();
959
935
  spinner4.start("Verifying token + wiring profile");
960
936
  const result = spawnSync2("bash", [script], {
@@ -981,8 +957,8 @@ function cap(s) {
981
957
 
982
958
  // src/commands/hermes/WireEmail.ts
983
959
  import { spawnSync as spawnSync3 } from "node:child_process";
984
- import { join as join5 } from "node:path";
985
- import { existsSync as existsSync4, unlinkSync as unlinkSync2 } from "node:fs";
960
+ import { join as join6 } from "node:path";
961
+ import { existsSync as existsSync5, unlinkSync as unlinkSync2 } from "node:fs";
986
962
  import * as p4 from "@clack/prompts";
987
963
  var WireEmail = class extends Command {
988
964
  async invoke() {
@@ -997,8 +973,8 @@ var WireEmail = class extends Command {
997
973
  if (!targetRepo || !role || !roleDir) {
998
974
  return { success: false, message: "Cannot wire email: missing target_repo/role/roleDir" };
999
975
  }
1000
- const script = join5(roleDir, ".scripts", "50-email.sh");
1001
- if (!existsSync4(script)) {
976
+ const script = join6(roleDir, ".scripts", "50-email.sh");
977
+ if (!existsSync5(script)) {
1002
978
  return { success: false, message: `\u2717 ${script} not found` };
1003
979
  }
1004
980
  let token = process.env.CF_EMAIL_ROUTING_TOKEN;
@@ -1060,8 +1036,8 @@ var WireEmail = class extends Command {
1060
1036
  }
1061
1037
  }
1062
1038
  }
1063
- const marker = join5(roleDir, ".scripts", ".done-50-email");
1064
- if (existsSync4(marker)) unlinkSync2(marker);
1039
+ const marker = join6(roleDir, ".scripts", ".done-50-email");
1040
+ if (existsSync5(marker)) unlinkSync2(marker);
1065
1041
  const spinner4 = p4.spinner();
1066
1042
  spinner4.start("Creating Cloudflare Email Routing rule");
1067
1043
  const result = spawnSync3("bash", [script], {
@@ -1090,7 +1066,7 @@ var PrintHermesSummary = class extends Command {
1090
1066
  const email = `${targetRepo}-${role}@delo.sh`;
1091
1067
  const gw = `hermes-${agentId}-gateway.service`;
1092
1068
  const csm = `hermes-${agentId}-consumer.service`;
1093
- const ckpt = `hermes-${agentId}-checkpoint.timer`;
1069
+ const hb = `hermes-${agentId}-heartbeat.timer`;
1094
1070
  const lines = [];
1095
1071
  lines.push(`agent_id ${agentId}`);
1096
1072
  lines.push(`role dir ${ctx.roleDir}`);
@@ -1100,7 +1076,7 @@ var PrintHermesSummary = class extends Command {
1100
1076
  lines.push("");
1101
1077
  lines.push("Start daemons:");
1102
1078
  lines.push(` systemctl --user start ${csm}`);
1103
- lines.push(` systemctl --user start ${ckpt}`);
1079
+ lines.push(` systemctl --user start ${hb}`);
1104
1080
  if (!skipTelegram) {
1105
1081
  lines.push(` systemctl --user start ${gw}`);
1106
1082
  } else {
@@ -1147,6 +1123,193 @@ var HermesAgentRecipe = class extends Recipe {
1147
1123
  }
1148
1124
  };
1149
1125
 
1126
+ // src/commands/AgentHooksCommands.ts
1127
+ import { homedir as homedir3 } from "node:os";
1128
+ import { join as join7, dirname as dirname4 } from "node:path";
1129
+ import { existsSync as existsSync6, cpSync, mkdirSync as mkdirSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "node:fs";
1130
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1131
+ function resolveTemplateRoot() {
1132
+ const candidates = [];
1133
+ if (process.env.PJANGLER_COMMONPROJECT_TEMPLATE) {
1134
+ candidates.push(process.env.PJANGLER_COMMONPROJECT_TEMPLATE);
1135
+ }
1136
+ try {
1137
+ let dir = dirname4(fileURLToPath2(import.meta.url));
1138
+ for (let i = 0; i < 8; i++) {
1139
+ candidates.push(join7(dir, "templates", "commonproject", "template"));
1140
+ const parent = dirname4(dir);
1141
+ if (parent === dir) break;
1142
+ dir = parent;
1143
+ }
1144
+ } catch {
1145
+ }
1146
+ candidates.push(join7(homedir3(), "code", "pjangler", "templates", "commonproject", "template"));
1147
+ for (const c of candidates) {
1148
+ if (existsSync6(join7(c, ".agents", "hooks", "hooks.master.json"))) return c;
1149
+ }
1150
+ throw new Error(
1151
+ "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
1152
+ );
1153
+ }
1154
+ var CopyAgentHooksTree = class extends Command {
1155
+ async invoke() {
1156
+ let templateRoot;
1157
+ try {
1158
+ templateRoot = resolveTemplateRoot();
1159
+ } catch (e) {
1160
+ return { success: false, message: `\u26A0\uFE0F ${e.message}` };
1161
+ }
1162
+ const items = [
1163
+ { rel: ".agents/hooks", dir: true },
1164
+ { rel: ".agents/local.example.json", dir: false },
1165
+ { rel: ".mise/scripts/link-project-skills-to-clis.sh", dir: false },
1166
+ { rel: ".mise/scripts/unlink-project-skills-from-clis.sh", dir: false },
1167
+ { rel: ".mise/scripts/hindsight-setup.sh", dir: false }
1168
+ ];
1169
+ const created = [];
1170
+ const skipped = [];
1171
+ for (const { rel, dir } of items) {
1172
+ const src = join7(templateRoot, rel);
1173
+ const dest = join7(this.context.targetDir, rel);
1174
+ if (!existsSync6(src)) continue;
1175
+ if (existsSync6(dest) && !this.context.force) {
1176
+ skipped.push(rel);
1177
+ continue;
1178
+ }
1179
+ if (!this.context.dryRun) {
1180
+ mkdirSync4(dirname4(dest), { recursive: true });
1181
+ cpSync(src, dest, { recursive: dir, force: true });
1182
+ }
1183
+ created.push(rel);
1184
+ }
1185
+ const verb = this.context.dryRun ? "Would copy" : "Copied";
1186
+ const tail = skipped.length ? ` (${skipped.length} already present \u2014 use --force to overwrite)` : "";
1187
+ return {
1188
+ success: created.length > 0,
1189
+ message: this.formatMessage(`\u2705 ${verb} ${created.length} agent-hooks path(s)${tail}`)
1190
+ };
1191
+ }
1192
+ };
1193
+ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
1194
+ static MARKER = "# pjangler:agent-hooks";
1195
+ static CR = "{{config_root}}";
1196
+ // mise's own runtime var — emitted literally
1197
+ async invoke() {
1198
+ const misePath = join7(this.context.targetDir, "mise.toml");
1199
+ if (!existsSync6(misePath)) {
1200
+ return {
1201
+ success: false,
1202
+ message: "\u26A0\uFE0F No mise.toml found \u2014 run `pjangler init mise` first, then re-run."
1203
+ };
1204
+ }
1205
+ let content = readFileSync2(misePath, "utf8");
1206
+ if (content.includes(_WireMiseAgentHooks.MARKER)) {
1207
+ return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for agent-hooks") };
1208
+ }
1209
+ const cr = _WireMiseAgentHooks.CR;
1210
+ const enterAdds = [
1211
+ ` "${cr}/.mise/scripts/link-project-skills-to-clis.sh",`,
1212
+ ` "${cr}/.agents/hooks/sync.py --install --quiet",`
1213
+ ].join("\n");
1214
+ const leaveBlock = [
1215
+ "leave = [",
1216
+ ` "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",`,
1217
+ ` "${cr}/.agents/hooks/sync.py --uninstall --quiet",`,
1218
+ "]"
1219
+ ].join("\n");
1220
+ let wiredHooks = false;
1221
+ const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
1222
+ if (enterRe.test(content)) {
1223
+ content = content.replace(enterRe, (_m, head, close) => {
1224
+ const sep = /[,[]\s*$/.test(head) ? "" : ",";
1225
+ return `${head}${sep}
1226
+ ${enterAdds}${close}`;
1227
+ });
1228
+ const leaveRe = /(leave\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
1229
+ if (leaveRe.test(content)) {
1230
+ content = content.replace(leaveRe, (_m, head, close) => {
1231
+ const sep = /[,[]\s*$/.test(head) ? "" : ",";
1232
+ return `${head}${sep}
1233
+ "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",
1234
+ "${cr}/.agents/hooks/sync.py --uninstall --quiet",${close}`;
1235
+ });
1236
+ } else {
1237
+ content = content.replace(enterRe, (m) => `${m}
1238
+ ${leaveBlock}`);
1239
+ }
1240
+ wiredHooks = true;
1241
+ }
1242
+ const appended = [
1243
+ "",
1244
+ _WireMiseAgentHooks.MARKER + " (generated \u2014 see .agents/hooks/README.md)",
1245
+ "[[watch_files]]",
1246
+ 'patterns = [".agents/hooks/hooks.master.json"]',
1247
+ 'task = "hooks-sync"',
1248
+ "",
1249
+ "[tasks.hooks-sync]",
1250
+ 'description = "Fan out hooks.master.json to each agent CLI (claude/codex/kimi/hermes)"',
1251
+ `run = "${cr}/.agents/hooks/sync.py --install"`,
1252
+ "",
1253
+ "[tasks.hooks-check]",
1254
+ 'description = "Drift gate: verify generated hook configs match hooks.master.json"',
1255
+ `run = "${cr}/.agents/hooks/sync.py --check"`,
1256
+ "",
1257
+ "[tasks.hooks-uninstall]",
1258
+ 'description = "Remove per-user agent-hook injections (codex/kimi/hermes)"',
1259
+ `run = "${cr}/.agents/hooks/sync.py --uninstall"`,
1260
+ "",
1261
+ "[tasks.link-project-skills-to-clis]",
1262
+ 'description = "Fan .agents/skills out to each agent CLI (honors local.json)"',
1263
+ `run = "${cr}/.mise/scripts/link-project-skills-to-clis.sh"`,
1264
+ "",
1265
+ "[tasks.unlink-project-skills-from-clis]",
1266
+ 'description = "Remove project skill symlinks from shared per-CLI dirs"',
1267
+ `run = "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh"`,
1268
+ "",
1269
+ "[tasks.skills-relink]",
1270
+ 'description = "Re-fan the project skill set to all CLIs"',
1271
+ `run = "${cr}/.mise/scripts/link-project-skills-to-clis.sh"`,
1272
+ "",
1273
+ "[tasks.hindsight-setup]",
1274
+ `description = "Provision this dev's shared project Hindsight key from 1Password into .env"`,
1275
+ `run = "${cr}/.mise/scripts/hindsight-setup.sh"`,
1276
+ "",
1277
+ _WireMiseAgentHooks.MARKER + ":end",
1278
+ ""
1279
+ ].join("\n");
1280
+ content = content.replace(/\n*$/, "\n") + appended;
1281
+ if (!this.context.dryRun) writeFileSync3(misePath, content);
1282
+ if (wiredHooks) {
1283
+ return { success: true, message: this.formatMessage("\u2705 Wired mise.toml ([hooks] enter/leave + tasks)") };
1284
+ }
1285
+ return {
1286
+ success: true,
1287
+ message: this.formatMessage(
1288
+ `\u2705 Added agent-hooks tasks to mise.toml.
1289
+ \u26A0\uFE0F Could not find a [hooks].enter array to extend \u2014 add these to your [hooks] block manually:
1290
+ enter += "${cr}/.mise/scripts/link-project-skills-to-clis.sh", "${cr}/.agents/hooks/sync.py --install --quiet"
1291
+ leave += "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh", "${cr}/.agents/hooks/sync.py --uninstall --quiet"`
1292
+ )
1293
+ };
1294
+ }
1295
+ };
1296
+
1297
+ // src/recipes/AgentHooksRecipe.ts
1298
+ var AgentHooksRecipe = class extends Recipe {
1299
+ constructor(context) {
1300
+ super(context);
1301
+ this.addIngredient(CopyAgentHooksTree).addIngredient(WireMiseAgentHooks);
1302
+ }
1303
+ printNextSteps() {
1304
+ console.log("\u{1FA9D} Agent-hooks layer installed!");
1305
+ console.log(" Next steps:");
1306
+ console.log(" 1. mise run hooks-sync # generate .claude/settings.json + inject codex/kimi/hermes");
1307
+ console.log(" 2. git add .claude/settings.json .agents/hooks && commit (codex/kimi/hermes are per-dev)");
1308
+ console.log(" 3. mise run hindsight-setup # set HINDSIGHT_OP_KEY_REF to your 1Password item first");
1309
+ console.log(` 4. If you run a global agent system: echo '{"skills":{"defer_to_global":true}}' > .agents/local.json`);
1310
+ }
1311
+ };
1312
+
1150
1313
  // src/utils/registry.ts
1151
1314
  var RECIPE_REGISTRY = {
1152
1315
  mise: {
@@ -1180,9 +1343,27 @@ var RECIPE_REGISTRY = {
1180
1343
  "WireEmail",
1181
1344
  "PrintHermesSummary"
1182
1345
  ]
1346
+ },
1347
+ "agent-hooks": {
1348
+ name: "agent-hooks",
1349
+ description: "Retrofit the project-scoped agent-hooks + skill fan-out layer (Claude/Codex/Kimi/Hermes hooks via mise enter/leave)",
1350
+ class: AgentHooksRecipe,
1351
+ commands: ["CopyAgentHooksTree", "WireMiseAgentHooks"]
1183
1352
  }
1184
1353
  };
1185
1354
  var COMMAND_REGISTRY = {
1355
+ CopyAgentHooksTree: {
1356
+ name: "CopyAgentHooksTree",
1357
+ description: "Copy the generic agent-hooks tree (hooks SSOT + sync engine + scripts) from the CommonProject template",
1358
+ group: "agent-hooks",
1359
+ class: CopyAgentHooksTree
1360
+ },
1361
+ WireMiseAgentHooks: {
1362
+ name: "WireMiseAgentHooks",
1363
+ description: "Merge agent-hooks enter/leave + tasks into an existing mise.toml (idempotent)",
1364
+ group: "agent-hooks",
1365
+ class: WireMiseAgentHooks
1366
+ },
1186
1367
  AddDockerfile: {
1187
1368
  name: "AddDockerfile",
1188
1369
  description: "Create Dockerfile for containerization",
@@ -1260,11 +1441,14 @@ function createRecipe(name, context) {
1260
1441
  return new info.class(context);
1261
1442
  }
1262
1443
 
1444
+ // src/index.ts
1445
+ import { cancel as cancel2, multiselect, text as text3, isCancel as isCancel5 } from "@clack/prompts";
1446
+
1263
1447
  // src/parity/index.ts
1264
- import { existsSync as existsSync5, lstatSync, mkdirSync as mkdirSync3, readFileSync as readFileSync2, readlinkSync, readdirSync, renameSync, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync2, chmodSync, copyFileSync } from "node:fs";
1265
- import { dirname as dirname3, join as join6, relative, resolve } from "node:path";
1266
- import { fileURLToPath as fileURLToPath2 } from "node:url";
1267
- import { homedir as homedir3 } from "node:os";
1448
+ import { existsSync as existsSync7, lstatSync, mkdirSync as mkdirSync5, readFileSync as readFileSync3, readlinkSync, readdirSync, renameSync, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4, chmodSync, copyFileSync } from "node:fs";
1449
+ import { basename as basename2, dirname as dirname5, join as join8, relative, resolve } from "node:path";
1450
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
1451
+ import { homedir as homedir4 } from "node:os";
1268
1452
  import { spawnSync as spawnSync4 } from "node:child_process";
1269
1453
  var LINK_AGENTFILES_BLOCK = `# This block will handle the linking of
1270
1454
  # agent files to the main AGENTS.md file.
@@ -1282,6 +1466,28 @@ enter = [
1282
1466
  patterns = ["AGENTS.md"]
1283
1467
  task = "link-agentfiles"
1284
1468
 
1469
+ [tasks.link-agentfiles]
1470
+ description = "Symlink all agent files to AGENTS.md"
1471
+ run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
1472
+ var LINK_AGENTFILES_HOOK_ENTRIES = [
1473
+ "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1474
+ "op inject -i .env.op > .env"
1475
+ ];
1476
+ var LINK_AGENTFILES_HOOKS_BLOCK = `# This block will handle the linking of
1477
+ # agent files to the main AGENTS.md file.
1478
+ #
1479
+ # TODO: Ensure this works for all levels of nesting.
1480
+ # i.e. All linked agent files MUST be siblings at
1481
+ # any given level of nesting.
1482
+ [hooks]
1483
+ enter = [
1484
+ "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1485
+ "op inject -i .env.op > .env",
1486
+ ]`;
1487
+ var LINK_AGENTFILES_WATCH_TASK_BLOCK = `[[watch_files]]
1488
+ patterns = ["AGENTS.md"]
1489
+ task = "link-agentfiles"
1490
+
1285
1491
  [tasks.link-agentfiles]
1286
1492
  description = "Symlink all agent files to AGENTS.md"
1287
1493
  run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
@@ -1312,12 +1518,12 @@ description = "Force every versioned file up to the highest version"
1312
1518
  run = "{{config_root}}/.mise/scripts/versioning.sh sync"
1313
1519
  # <<< mise-versioning <<<`;
1314
1520
  function resolvePjanglerRoot() {
1315
- let dir = dirname3(fileURLToPath2(import.meta.url));
1316
- while (dir !== dirname3(dir)) {
1317
- if (existsSync5(join6(dir, "package.json")) && existsSync5(join6(dir, "templates", "commonproject", "copier.yml"))) {
1521
+ let dir = dirname5(fileURLToPath3(import.meta.url));
1522
+ while (dir !== dirname5(dir)) {
1523
+ if (existsSync7(join8(dir, "package.json")) && existsSync7(join8(dir, "templates", "commonproject", "copier.yml"))) {
1318
1524
  return dir;
1319
1525
  }
1320
- dir = dirname3(dir);
1526
+ dir = dirname5(dir);
1321
1527
  }
1322
1528
  throw new Error("Unable to resolve pjangler root");
1323
1529
  }
@@ -1325,22 +1531,22 @@ function normalizeNewlines(value) {
1325
1531
  return value.replace(/\r\n/g, "\n");
1326
1532
  }
1327
1533
  function readText(path) {
1328
- return normalizeNewlines(readFileSync2(path, "utf8"));
1534
+ return normalizeNewlines(readFileSync3(path, "utf8"));
1329
1535
  }
1330
1536
  function safeReadText(path) {
1331
- return existsSync5(path) ? readText(path) : null;
1537
+ return existsSync7(path) ? readText(path) : null;
1332
1538
  }
1333
1539
  function ensureParent(path) {
1334
- mkdirSync3(dirname3(path), { recursive: true });
1540
+ mkdirSync5(dirname5(path), { recursive: true });
1335
1541
  }
1336
1542
  function writeText(path, content) {
1337
1543
  ensureParent(path);
1338
- writeFileSync2(path, content);
1544
+ writeFileSync4(path, content);
1339
1545
  }
1340
- function tryParseJson(text3) {
1341
- if (!text3) return null;
1546
+ function tryParseJson(text4) {
1547
+ if (!text4) return null;
1342
1548
  try {
1343
- return JSON.parse(text3);
1549
+ return JSON.parse(text4);
1344
1550
  } catch {
1345
1551
  return null;
1346
1552
  }
@@ -1352,7 +1558,7 @@ function titleCaseSlug(slug) {
1352
1558
  return slug.split(/[-_]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
1353
1559
  }
1354
1560
  function readSymlinkTarget(path) {
1355
- if (!existsSync5(path)) return null;
1561
+ if (!existsSync7(path)) return null;
1356
1562
  try {
1357
1563
  return readlinkSync(path);
1358
1564
  } catch {
@@ -1360,7 +1566,7 @@ function readSymlinkTarget(path) {
1360
1566
  }
1361
1567
  }
1362
1568
  function ensureSymlink(path, target, dryRun) {
1363
- if (existsSync5(path)) {
1569
+ if (existsSync7(path)) {
1364
1570
  const stat = lstatSync(path);
1365
1571
  if (stat.isSymbolicLink()) {
1366
1572
  const current = readSymlinkTarget(path);
@@ -1376,9 +1582,32 @@ function ensureSymlink(path, target, dryRun) {
1376
1582
  if (!dryRun) symlinkSync(target, path);
1377
1583
  return { changed: true };
1378
1584
  }
1379
- function yamlGet(text3, keyPath) {
1585
+ function bootstrapAgentsFile(repoRoot, dryRun) {
1586
+ const agentsPath = join8(repoRoot, "AGENTS.md");
1587
+ if (existsSync7(agentsPath)) return { changedFiles: [], details: [] };
1588
+ for (const file of ["CLAUDE.md", "GEMINI.md"]) {
1589
+ const source = join8(repoRoot, file);
1590
+ if (!existsSync7(source)) continue;
1591
+ const stat = lstatSync(source);
1592
+ if (stat.isSymbolicLink()) continue;
1593
+ if (stat.isFile()) {
1594
+ if (!dryRun) renameSync(source, agentsPath);
1595
+ return { changedFiles: [agentsPath], details: [`Moved ${file} to AGENTS.md before wiring agent-file symlinks`] };
1596
+ }
1597
+ return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
1598
+ }
1599
+ const readmePath = join8(repoRoot, "README.md");
1600
+ if (existsSync7(readmePath)) {
1601
+ const stat = lstatSync(readmePath);
1602
+ if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
1603
+ if (!dryRun) copyFileSync(readmePath, agentsPath);
1604
+ return { changedFiles: [agentsPath], details: ["Copied README.md to AGENTS.md before wiring agent-file symlinks"] };
1605
+ }
1606
+ return { changedFiles: [], details: [], blocked: "AGENTS.md missing and no CLAUDE.md, GEMINI.md, or README.md source exists" };
1607
+ }
1608
+ function yamlGet(text4, keyPath) {
1380
1609
  const parts = keyPath.split(".");
1381
- const lines = text3.split("\n");
1610
+ const lines = text4.split("\n");
1382
1611
  let start = 0;
1383
1612
  let indent = 0;
1384
1613
  for (let idx = 0; idx < parts.length; idx += 1) {
@@ -1407,36 +1636,36 @@ function yamlGet(text3, keyPath) {
1407
1636
  return "";
1408
1637
  }
1409
1638
  function discoverRoles(repoRoot) {
1410
- const rolesDir = join6(repoRoot, "agents", "hermes");
1411
- if (!existsSync5(rolesDir)) return [];
1639
+ const rolesDir = join8(repoRoot, "agents", "hermes");
1640
+ if (!existsSync7(rolesDir)) return [];
1412
1641
  return readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
1413
- const roleDir = join6(rolesDir, entry.name);
1414
- const roleYamlPath = join6(roleDir, "role.yaml");
1415
- if (!existsSync5(roleYamlPath)) return null;
1416
- const text3 = readText(roleYamlPath);
1417
- const runtimeRepoRaw = yamlGet(text3, "runtime.github_repo");
1642
+ const roleDir = join8(rolesDir, entry.name);
1643
+ const roleYamlPath = join8(roleDir, "role.yaml");
1644
+ if (!existsSync7(roleYamlPath)) return null;
1645
+ const text4 = readText(roleYamlPath);
1646
+ const runtimeRepoRaw = yamlGet(text4, "runtime.github_repo");
1418
1647
  return {
1419
- role: yamlGet(text3, "role") || entry.name,
1648
+ role: yamlGet(text4, "role") || entry.name,
1420
1649
  roleDir,
1421
1650
  roleYamlPath,
1422
- repo: yamlGet(text3, "repo"),
1423
- agentId: yamlGet(text3, "agent_id"),
1424
- profileName: yamlGet(text3, "profile") || yamlGet(text3, "agent_id"),
1425
- displayName: yamlGet(text3, "display_name"),
1426
- purpose: yamlGet(text3, "purpose"),
1427
- botHandle: yamlGet(text3, "telegram.bot_username"),
1651
+ repo: yamlGet(text4, "repo"),
1652
+ agentId: yamlGet(text4, "agent_id"),
1653
+ profileName: yamlGet(text4, "profile") || yamlGet(text4, "agent_id"),
1654
+ displayName: yamlGet(text4, "display_name"),
1655
+ purpose: yamlGet(text4, "purpose"),
1656
+ botHandle: yamlGet(text4, "telegram.bot_username"),
1428
1657
  runtimeRepo: runtimeRepoRaw.includes("/") ? runtimeRepoRaw.split("/").slice(-1)[0] ?? runtimeRepoRaw : runtimeRepoRaw,
1429
- runtimeOwner: yamlGet(text3, "runtime.github_owner"),
1430
- planeWorkspace: yamlGet(text3, "ticket_provider.workspace") || yamlGet(text3, "plane.workspace"),
1431
- ticketProviderName: yamlGet(text3, "ticket_provider.name"),
1432
- ticketProviderBoardId: yamlGet(text3, "ticket_provider.board_id"),
1433
- ticketProviderBoardUrl: yamlGet(text3, "ticket_provider.board_url"),
1434
- ticketProviderIdentifier: yamlGet(text3, "plane.identifier")
1658
+ runtimeOwner: yamlGet(text4, "runtime.github_owner"),
1659
+ planeWorkspace: yamlGet(text4, "ticket_provider.workspace") || yamlGet(text4, "plane.workspace"),
1660
+ ticketProviderName: yamlGet(text4, "ticket_provider.name"),
1661
+ ticketProviderBoardId: yamlGet(text4, "ticket_provider.board_id"),
1662
+ ticketProviderBoardUrl: yamlGet(text4, "ticket_provider.board_url"),
1663
+ ticketProviderIdentifier: yamlGet(text4, "plane.identifier")
1435
1664
  };
1436
1665
  }).filter((value) => Boolean(value));
1437
1666
  }
1438
1667
  function registryPath(homeDir) {
1439
- return join6(homeDir, ".hermes", "agents-registry.yaml");
1668
+ return join8(homeDir, ".hermes", "agents-registry.yaml");
1440
1669
  }
1441
1670
  function systemctlUser(args) {
1442
1671
  const result = spawnSync4("systemctl", ["--user", ...args], { encoding: "utf8" });
@@ -1446,86 +1675,257 @@ function systemctlUser(args) {
1446
1675
  stderr: result.stderr.trim()
1447
1676
  };
1448
1677
  }
1678
+ function templateScript(ctx, name) {
1679
+ const source = join8(ctx.pjanglerRoot, ".mise", "scripts", name);
1680
+ return existsSync7(source) ? readText(source) : void 0;
1681
+ }
1449
1682
  function templateVersioningScript(ctx) {
1450
- const source = join6(ctx.pjanglerRoot, ".mise", "scripts", "versioning.sh");
1451
- return existsSync5(source) ? readText(source) : void 0;
1683
+ return templateScript(ctx, "versioning.sh");
1684
+ }
1685
+ function templateLinkAgentfilesScript(ctx) {
1686
+ return templateScript(ctx, "link-agentfiles.sh");
1687
+ }
1688
+ function renderGeneratedProjectMiseToml(ctx, template) {
1689
+ const project = readProjectJson(ctx);
1690
+ const projectName = String(project?.project_name ?? basename2(ctx.repoRoot) ?? "project");
1691
+ return template.replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
1692
+ }
1693
+ function ensureMiseTomlFromTemplate(ctx, changedFiles) {
1694
+ const targetPath = join8(ctx.repoRoot, "mise.toml");
1695
+ if (existsSync7(targetPath)) return false;
1696
+ const sourcePath = join8(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
1697
+ if (!existsSync7(sourcePath)) return false;
1698
+ changedFiles.push(targetPath);
1699
+ if (!ctx.dryRun) {
1700
+ writeText(targetPath, renderGeneratedProjectMiseToml(ctx, readText(sourcePath)));
1701
+ }
1702
+ return true;
1452
1703
  }
1453
1704
  function templateVersionFilesConf(ctx, repoRoot) {
1454
- const packageJson = join6(repoRoot, "package.json");
1455
- return existsSync5(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";
1705
+ const packageJson = join8(repoRoot, "package.json");
1706
+ 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";
1456
1707
  }
1457
- function replaceOrAppendManagedBlock(text3, startMarker, block, beforePattern) {
1458
- if (startMarker.test(text3)) {
1459
- return text3.replace(/# >>> mise-versioning >>>[\s\S]*?# <<< mise-versioning <<</, block);
1708
+ function replaceOrAppendManagedBlock(text4, startMarker, block, beforePattern) {
1709
+ if (startMarker.test(text4)) {
1710
+ return text4.replace(/# >>> mise-versioning >>>[\s\S]*?# <<< mise-versioning <<</, block);
1460
1711
  }
1461
1712
  if (beforePattern) {
1462
- const match = text3.match(beforePattern);
1713
+ const match = text4.match(beforePattern);
1463
1714
  if (match && typeof match.index === "number") {
1464
- return `${text3.slice(0, match.index).replace(/\s*$/, "\n\n")}${block}
1715
+ return `${text4.slice(0, match.index).replace(/\s*$/, "\n\n")}${block}
1465
1716
 
1466
- ${text3.slice(match.index)}`;
1717
+ ${text4.slice(match.index)}`;
1467
1718
  }
1468
1719
  }
1469
- return `${text3.replace(/\s*$/, "")}
1720
+ return `${text4.replace(/\s*$/, "")}
1470
1721
 
1471
1722
  ${block}
1472
1723
  `;
1473
1724
  }
1474
- function upsertLinkAgentfilesBlock(text3) {
1475
- const existing = /# This block will handle the linking of[\s\S]*?\[tasks\.link-agentfiles\][\s\S]*?run = "\{\{config_root\}\}\/\.mise\/scripts\/link-agentfiles\.sh"/;
1476
- if (existing.test(text3)) {
1477
- return text3.replace(existing, LINK_AGENTFILES_BLOCK);
1725
+ var BASE_MISE_PATH_ENTRIES = [".mise/scripts", "agents/hermes/pm"];
1726
+ var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/hermes"];
1727
+ function requiredMisePathEntries(ctx) {
1728
+ const required = [...BASE_MISE_PATH_ENTRIES];
1729
+ for (const candidate of CONDITIONAL_HERMES_PATHS) {
1730
+ if (existsSync7(join8(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
1731
+ }
1732
+ return required;
1733
+ }
1734
+ function upsertMisePath(text4, required = BASE_MISE_PATH_ENTRIES) {
1735
+ const render = (values) => `_.path = [${values.map((value) => JSON.stringify(value)).join(", ")}]`;
1736
+ const envMatch = text4.match(/(^|\n)(\[env\][\s\S]*?)(?=\n\[[^\]]+\]|$)/);
1737
+ if (!envMatch || typeof envMatch.index !== "number") {
1738
+ return `[env]
1739
+ ${render(required)}
1740
+
1741
+ ${text4.replace(/^\s+/, "")}`;
1742
+ }
1743
+ const prefix = text4.slice(0, envMatch.index + envMatch[1].length);
1744
+ const section = envMatch[2];
1745
+ const suffix = text4.slice(envMatch.index + envMatch[1].length + section.length);
1746
+ const pathLine = section.match(/^_\.path\s*=\s*\[([^\]]*)\]\s*$/m);
1747
+ if (!pathLine) {
1748
+ return `${prefix}${section.replace(/\n?$/, "\n")}${render(required)}${suffix}`;
1749
+ }
1750
+ const current = [...pathLine[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
1751
+ const merged = [...current];
1752
+ for (const value of required) {
1753
+ if (!merged.includes(value)) merged.push(value);
1754
+ }
1755
+ const nextLine = render(merged);
1756
+ if (pathLine[0] === nextLine) return text4;
1757
+ return `${prefix}${section.replace(pathLine[0], nextLine)}${suffix}`;
1758
+ }
1759
+ function removeTomlSection(text4, headerPattern, marker, options) {
1760
+ const lines = text4.split("\n");
1761
+ let start = -1;
1762
+ let end = -1;
1763
+ for (let i = 0; i < lines.length; i++) {
1764
+ if (!headerPattern.test(lines[i])) continue;
1765
+ if (marker) {
1766
+ let hasMarker = false;
1767
+ for (let j = i + 1; j < lines.length && !/^\[[^\]]+\]/.test(lines[j]); j++) {
1768
+ if (marker.test(lines[j])) {
1769
+ hasMarker = true;
1770
+ break;
1771
+ }
1772
+ }
1773
+ if (!hasMarker) continue;
1774
+ }
1775
+ start = i;
1776
+ for (let j = i + 1; j < lines.length; j++) {
1777
+ if (/^\[[^\]]+\]/.test(lines[j])) {
1778
+ end = j;
1779
+ break;
1780
+ }
1781
+ }
1782
+ if (end === -1) end = lines.length;
1783
+ break;
1784
+ }
1785
+ if (start === -1) return text4;
1786
+ if (options?.includePrecedingComments) {
1787
+ while (start > 0 && lines[start - 1].trim().startsWith("#")) {
1788
+ start--;
1789
+ }
1478
1790
  }
1479
- const versioningIndex = text3.indexOf("# >>> mise-versioning >>>");
1791
+ const result = lines.slice(0, start).concat(lines.slice(end)).join("\n");
1792
+ return result.replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
1793
+ }
1794
+ function insertTomlBlockBeforeVersioning(text4, block) {
1795
+ const versioningIndex = text4.indexOf("# >>> mise-versioning >>>");
1480
1796
  if (versioningIndex >= 0) {
1481
- return `${text3.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${LINK_AGENTFILES_BLOCK}
1797
+ return `${text4.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${block}
1482
1798
 
1483
- ${text3.slice(versioningIndex)}`;
1799
+ ${text4.slice(versioningIndex)}`;
1484
1800
  }
1485
- return `${text3.replace(/\s*$/, "")}
1801
+ return `${text4.replace(/\s*$/, "")}
1486
1802
 
1487
- ${LINK_AGENTFILES_BLOCK}
1803
+ ${block}
1488
1804
  `;
1489
1805
  }
1806
+ function extractTomlStrings(text4) {
1807
+ const values = [];
1808
+ const stringPattern = /"((?:\\.|[^"\\])*)"|'([^']*)'/g;
1809
+ for (const match of text4.matchAll(stringPattern)) {
1810
+ if (match[1] !== void 0) {
1811
+ try {
1812
+ values.push(JSON.parse(`"${match[1]}"`));
1813
+ } catch {
1814
+ values.push(match[1]);
1815
+ }
1816
+ } else if (match[2] !== void 0) {
1817
+ values.push(match[2]);
1818
+ }
1819
+ }
1820
+ return values;
1821
+ }
1822
+ function isManagedHookEntry(value) {
1823
+ const trimmed = value.trim();
1824
+ return trimmed === "op inject -i .env.op > .env" || /(^|\/)link-agentfiles\.sh$/.test(trimmed);
1825
+ }
1826
+ function renderHookEntries(entries, indent = "") {
1827
+ return [
1828
+ `${indent}enter = [`,
1829
+ ...entries.map((entry) => `${indent} ${JSON.stringify(entry)},`),
1830
+ `${indent}]`
1831
+ ];
1832
+ }
1833
+ function upsertLinkAgentfilesHooks(text4) {
1834
+ const lines = text4.split("\n");
1835
+ const hooksStart = lines.findIndex((line) => /^\[hooks\]$/.test(line.trim()));
1836
+ if (hooksStart === -1) return insertTomlBlockBeforeVersioning(text4, LINK_AGENTFILES_HOOKS_BLOCK);
1837
+ let hooksEnd = lines.length;
1838
+ for (let i = hooksStart + 1; i < lines.length; i++) {
1839
+ if (/^\[[^\]]+\]/.test(lines[i].trim())) {
1840
+ hooksEnd = i;
1841
+ break;
1842
+ }
1843
+ }
1844
+ let enterStart = -1;
1845
+ let enterEnd = -1;
1846
+ for (let i = hooksStart + 1; i < hooksEnd; i++) {
1847
+ if (!/^\s*enter\s*=/.test(lines[i])) continue;
1848
+ enterStart = i;
1849
+ enterEnd = i + 1;
1850
+ const afterEquals = lines[i].slice(lines[i].indexOf("=") + 1);
1851
+ if (afterEquals.includes("[") && !afterEquals.includes("]")) {
1852
+ while (enterEnd < hooksEnd && !lines[enterEnd].includes("]")) enterEnd++;
1853
+ if (enterEnd < hooksEnd) enterEnd++;
1854
+ }
1855
+ break;
1856
+ }
1857
+ const existingBlock = enterStart >= 0 ? lines.slice(enterStart, enterEnd).join("\n") : "";
1858
+ const preserved = extractTomlStrings(existingBlock).filter((entry) => !isManagedHookEntry(entry));
1859
+ const merged = [...LINK_AGENTFILES_HOOK_ENTRIES];
1860
+ for (const entry of preserved) {
1861
+ if (!merged.includes(entry)) merged.push(entry);
1862
+ }
1863
+ const indent = enterStart >= 0 ? lines[enterStart].match(/^\s*/)?.[0] ?? "" : "";
1864
+ const rendered = renderHookEntries(merged, indent);
1865
+ if (enterStart >= 0) {
1866
+ return lines.slice(0, enterStart).concat(rendered, lines.slice(enterEnd)).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
1867
+ }
1868
+ return lines.slice(0, hooksStart + 1).concat(rendered, lines.slice(hooksStart + 1)).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
1869
+ }
1870
+ function upsertLinkAgentfilesBlock(text4, ctx) {
1871
+ const withPath = upsertMisePath(text4, requiredMisePathEntries(ctx));
1872
+ if (withPath.includes(LINK_AGENTFILES_BLOCK)) return withPath;
1873
+ let cleaned = removeTomlSection(withPath, /^\[tasks\.link-agentfiles\]$/, /link-agentfiles/, { includePrecedingComments: false });
1874
+ cleaned = removeTomlSection(cleaned, /^\[\[watch_files\]\]$/, /AGENTS\.md/, { includePrecedingComments: false });
1875
+ cleaned = upsertLinkAgentfilesHooks(cleaned);
1876
+ return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
1877
+ }
1490
1878
  function readProjectJson(ctx) {
1491
- return tryParseJson(safeReadText(join6(ctx.repoRoot, ".project.json")));
1879
+ return tryParseJson(safeReadText(join8(ctx.repoRoot, ".project.json")));
1492
1880
  }
1493
1881
  function canonicalProjectJson(ctx) {
1494
1882
  const roles = discoverRoles(ctx.repoRoot);
1495
1883
  const existing = readProjectJson(ctx) ?? {};
1496
- const slug = String(existing.project_slug ?? slugifyRepoName(dirname3(ctx.repoRoot) === ctx.repoRoot ? ctx.repoRoot.split("/").pop() ?? "project" : ctx.repoRoot.split("/").pop() ?? "project"));
1884
+ const slug = String(existing.project_slug ?? slugifyRepoName(dirname5(ctx.repoRoot) === ctx.repoRoot ? ctx.repoRoot.split("/").pop() ?? "project" : ctx.repoRoot.split("/").pop() ?? "project"));
1497
1885
  const firstRole = roles[0];
1498
1886
  const ticketProvider = {
1499
1887
  type: String((existing.ticket_provider?.type ?? firstRole?.ticketProviderName ?? "plane") || "plane"),
1500
1888
  workspace: String((existing.ticket_provider?.workspace ?? firstRole?.planeWorkspace ?? "") || ""),
1501
1889
  identifier: String((existing.ticket_provider?.identifier ?? firstRole?.ticketProviderIdentifier ?? "") || ""),
1502
1890
  board_id: String((existing.ticket_provider?.board_id ?? firstRole?.ticketProviderBoardId ?? "") || ""),
1503
- board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || "")
1891
+ board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || ""),
1892
+ state: String((existing.ticket_provider?.state ?? "planned") || "planned")
1504
1893
  };
1894
+ const existingAgents = existing.agents ?? {};
1895
+ const discoveredAgents = Object.fromEntries(
1896
+ roles.map((role) => [
1897
+ role.agentId || `${slug}-${role.role}`,
1898
+ {
1899
+ role: role.role,
1900
+ role_dir: relative(ctx.repoRoot, role.roleDir)
1901
+ }
1902
+ ])
1903
+ );
1904
+ const agents = { ...existingAgents };
1905
+ for (const [agentId, discovered] of Object.entries(discoveredAgents)) {
1906
+ const existingAgent = existingAgents[agentId] ?? {};
1907
+ agents[agentId] = {
1908
+ role: discovered.role,
1909
+ role_dir: discovered.role_dir,
1910
+ provisioning_state: existingAgent.provisioning_state
1911
+ };
1912
+ }
1505
1913
  return {
1506
1914
  project_name: String(existing.project_name ?? titleCaseSlug(slug)),
1507
1915
  project_description: String(existing.project_description ?? ""),
1508
1916
  project_slug: slug,
1509
1917
  repo_path: ctx.repoRoot,
1510
1918
  ticket_provider: ticketProvider,
1511
- agents: Object.fromEntries(
1512
- roles.map((role) => [
1513
- role.agentId || `${slug}-${role.role}`,
1514
- {
1515
- role: role.role,
1516
- role_dir: relative(ctx.repoRoot, role.roleDir)
1517
- }
1518
- ])
1519
- )
1919
+ agents
1520
1920
  };
1521
1921
  }
1522
1922
  function projectJsonFinding(ctx) {
1523
- const projectPath = join6(ctx.repoRoot, ".project.json");
1524
- const planeJsonPath = join6(ctx.repoRoot, ".plane.json");
1923
+ const projectPath = join8(ctx.repoRoot, ".project.json");
1924
+ const planeJsonPath = join8(ctx.repoRoot, ".plane.json");
1525
1925
  const details = [];
1526
1926
  const data = readProjectJson(ctx);
1527
1927
  const roles = discoverRoles(ctx.repoRoot);
1528
- if (!existsSync5(projectPath)) {
1928
+ if (!existsSync7(projectPath)) {
1529
1929
  return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json missing", details: [], fixable: true };
1530
1930
  }
1531
1931
  if (!data) {
@@ -1548,10 +1948,10 @@ function projectJsonFinding(ctx) {
1548
1948
  }
1549
1949
  }
1550
1950
  const ticketProvider = data.ticket_provider ?? {};
1551
- for (const key of ["type", "workspace", "identifier", "board_id", "board_url"]) {
1951
+ for (const key of ["type", "workspace", "identifier", "board_id", "board_url", "state"]) {
1552
1952
  if (!(key in ticketProvider)) details.push(`ticket_provider.${key} missing`);
1553
1953
  }
1554
- if (existsSync5(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
1954
+ if (existsSync7(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
1555
1955
  return {
1556
1956
  id: "sot.project-json",
1557
1957
  title: "Canonical .project.json",
@@ -1563,8 +1963,8 @@ function projectJsonFinding(ctx) {
1563
1963
  }
1564
1964
  function renderSoul(role) {
1565
1965
  const telegram = role.botHandle ? `@${role.botHandle}` : "(unwired)";
1566
- const tone = role.role === "pm" ? `Direct and brief. Decision-forward. No throat-clearing, no apologies, no "I'll help you with that" preambles.` : role.role === "scrum-master" ? "Operational, skeptical, and schedule-aware. Prefer explicit next actions, evidence, and status transitions." : "Direct and brief.";
1567
- 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.` : role.role === "scrum-master" ? `You own the continuous-ticket sentinel. You watch the ticket board, enforce workflow policy, and keep work moving without inventing requirements.` : `You operate as the ${role.role} agent for this repo.`;
1966
+ 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.";
1967
+ 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.`;
1568
1968
  const runtimeOwner = role.runtimeOwner || "delorenj";
1569
1969
  return `# ${role.displayName || role.agentId}
1570
1970
 
@@ -1584,7 +1984,7 @@ You are **${role.displayName || role.agentId}** \u2014 a Hermes agent provisione
1584
1984
 
1585
1985
  ## Scope
1586
1986
 
1587
- You operate only within the working directory of \`${role.repo}\`. Your HERMES_HOME resolves through the named profile \`${role.profileName || role.agentId}\`, which is symlinked to the runtime submodule at \`./runtime/\` (repo \`${runtimeOwner}/${role.runtimeRepo}\`). Your \`config.yaml\` inherits shared non-secret defaults from the fleet default profile; secrets, SOUL, memories, skills, sessions, gateway state, and runtime files remain local to this profile.
1987
+ 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.
1588
1988
 
1589
1989
  ## Tone
1590
1990
 
@@ -1601,8 +2001,7 @@ Your memory is the submodule at \`./runtime/memories/\`. Use durable memory deli
1601
2001
  }
1602
2002
  function renderHermesWrapper(role) {
1603
2003
  return `#!/usr/bin/env bash
1604
- # Launcher for ${role.agentId}. Resolves HERMES_HOME through the fleet profile
1605
- # when available, then falls back to the local runtime submodule.
2004
+ # Launcher for ${role.agentId}. Resolves HERMES_HOME to the runtime submodule.
1606
2005
 
1607
2006
  set -euo pipefail
1608
2007
 
@@ -1621,12 +2020,7 @@ CODEX_HOME="{CODEX_HOME:-{HERMES_FLEET_CODEX_HOME:-$HOME/.codex}}"
1621
2020
 
1622
2021
  FLEET_HOME="{HERMES_FLEET_HOME:-$HOME/.hermes}"
1623
2022
  PROFILE_NAME="{HERMES_PROFILE_NAME:-${role.profileName || role.agentId}}"
1624
- PROFILE_HOME="$FLEET_HOME/profiles/$PROFILE_NAME"
1625
- if [[ -d "$PROFILE_HOME" ]]; then
1626
- HERMES_HOME="$PROFILE_HOME"
1627
- else
1628
- HERMES_HOME="$RUNTIME_HOME"
1629
- fi
2023
+ HERMES_HOME="$RUNTIME_HOME"
1630
2024
 
1631
2025
  if [[ ! -d "$RUNTIME_HOME" ]]; then
1632
2026
  echo "hermes: runtime submodule not initialized at $RUNTIME_HOME" >&2
@@ -1638,17 +2032,17 @@ exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" HERMES_OAUTH
1638
2032
  `.replace(/\u0010/g, "$");
1639
2033
  }
1640
2034
  function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip) {
1641
- if (!existsSync5(sourceDir)) return;
1642
- mkdirSync3(targetDir, { recursive: true });
2035
+ if (!existsSync7(sourceDir)) return;
2036
+ mkdirSync5(targetDir, { recursive: true });
1643
2037
  for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
1644
- const sourcePath = join6(sourceDir, entry.name);
2038
+ const sourcePath = join8(sourceDir, entry.name);
1645
2039
  if (skip?.(sourcePath)) continue;
1646
- const targetPath = join6(targetDir, entry.name);
2040
+ const targetPath = join8(targetDir, entry.name);
1647
2041
  if (entry.isDirectory()) {
1648
2042
  copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
1649
2043
  continue;
1650
2044
  }
1651
- if (existsSync5(targetPath)) continue;
2045
+ if (existsSync7(targetPath)) continue;
1652
2046
  changedFiles.push(targetPath);
1653
2047
  if (!dryRun) {
1654
2048
  ensureParent(targetPath);
@@ -1657,7 +2051,7 @@ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip)
1657
2051
  }
1658
2052
  }
1659
2053
  function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
1660
- const gitmodulesPath = join6(repoRoot, ".gitmodules");
2054
+ const gitmodulesPath = join8(repoRoot, ".gitmodules");
1661
2055
  const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
1662
2056
  const owner = role.runtimeOwner || "delorenj";
1663
2057
  const block = `[submodule "agents/hermes/${role.role}/runtime"]
@@ -1679,7 +2073,7 @@ function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
1679
2073
  repo: ${role.repo}
1680
2074
  role: ${role.role}
1681
2075
  display_name: ${JSON.stringify(role.displayName || role.agentId)}
1682
- project_path: ${ctxEscape(role.roleDir ? dirname3(dirname3(dirname3(role.roleDir))) : "")}
2076
+ project_path: ${ctxEscape(role.roleDir ? dirname5(dirname5(dirname5(role.roleDir))) : "")}
1683
2077
  role_dir: ${ctxEscape(role.roleDir)}
1684
2078
  profile_name: ${role.profileName || role.agentId}
1685
2079
  telegram:
@@ -1692,7 +2086,7 @@ function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
1692
2086
  systemd:
1693
2087
  gateway_unit: hermes-${role.agentId}-gateway.service
1694
2088
  consumer_unit: hermes-${role.agentId}-consumer.service
1695
- checkpoint_timer: hermes-${role.agentId}-checkpoint.timer
2089
+ heartbeat_timer: hermes-${role.agentId}-heartbeat.timer
1696
2090
  `;
1697
2091
  const next = current.includes("agents: {}") ? current.replace("agents: {}", `agents:
1698
2092
  ${block}`) : `${current.replace(/\s*$/, "\n")}${block}`;
@@ -1701,9 +2095,9 @@ ${block}`) : `${current.replace(/\s*$/, "\n")}${block}`;
1701
2095
  return path;
1702
2096
  }
1703
2097
  function profileMetaInheritsDefault(path) {
1704
- const text3 = safeReadText(path);
2098
+ const text4 = safeReadText(path);
1705
2099
  return Boolean(
1706
- text3 && /^config:\s*$/m.test(text3) && /^\s+inherit_from:\s*default\s*$/m.test(text3) && /^\s+save_mode:\s*delta\s*$/m.test(text3)
2100
+ text4 && /^config:\s*$/m.test(text4) && /^\s+inherit_from:\s*default\s*$/m.test(text4) && /^\s+save_mode:\s*delta\s*$/m.test(text4)
1707
2101
  );
1708
2102
  }
1709
2103
  function upsertInheritedProfileMeta(path, changedFiles, dryRun) {
@@ -1757,17 +2151,21 @@ var RULES = [
1757
2151
  id: "mise.config-root",
1758
2152
  title: "mise config_root + AGENTS link hooks",
1759
2153
  audit: (ctx) => {
1760
- const misePath = join6(ctx.repoRoot, "mise.toml");
1761
- if (!existsSync5(misePath)) {
2154
+ const misePath = join8(ctx.repoRoot, "mise.toml");
2155
+ if (!existsSync7(misePath)) {
1762
2156
  return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
1763
2157
  }
1764
- const text3 = readText(misePath);
2158
+ const text4 = readText(misePath);
1765
2159
  const details = [];
1766
- if (!text3.includes('_.path = [".mise/scripts", "agents/hermes/pm"]')) details.push("[env]._.path should include .mise/scripts and agents/hermes/pm");
1767
- if (!text3.includes('"{{config_root}}/.mise/scripts/link-agentfiles.sh"')) details.push("link-agentfiles must use raw {{config_root}} guard");
1768
- if (!text3.includes("op inject -i .env.op > .env")) details.push("[hooks].enter must materialize .env from .env.op");
1769
- if (!text3.includes('patterns = ["AGENTS.md"]')) details.push("watch_files must monitor AGENTS.md");
1770
- if (!text3.includes('task = "link-agentfiles"')) details.push("watch_files must dispatch link-agentfiles task");
2160
+ const linkAgentfilesPath = join8(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2161
+ if (!existsSync7(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2162
+ const pathValues = [...(text4.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2163
+ const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
2164
+ if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
2165
+ if (!text4.includes('"{{config_root}}/.mise/scripts/link-agentfiles.sh"')) details.push("link-agentfiles must use raw {{config_root}} guard");
2166
+ if (!text4.includes("op inject -i .env.op > .env")) details.push("[hooks].enter must materialize .env from .env.op");
2167
+ if (!text4.includes('patterns = ["AGENTS.md"]')) details.push("watch_files must monitor AGENTS.md");
2168
+ if (!text4.includes('task = "link-agentfiles"')) details.push("watch_files must dispatch link-agentfiles task");
1771
2169
  return {
1772
2170
  id: "mise.config-root",
1773
2171
  title: "mise config_root + AGENTS link hooks",
@@ -1778,17 +2176,36 @@ var RULES = [
1778
2176
  };
1779
2177
  },
1780
2178
  migrate: (ctx, finding) => {
1781
- const path = join6(ctx.repoRoot, "mise.toml");
2179
+ const path = join8(ctx.repoRoot, "mise.toml");
1782
2180
  const changedFiles = [];
1783
- if (!existsSync5(path)) {
1784
- return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing; initialize mise first", changedFiles, details: [] };
2181
+ const details = [];
2182
+ if (!existsSync7(path)) {
2183
+ if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2184
+ 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: [] };
2185
+ }
2186
+ details.push("Initialized mise.toml from generated-project template");
2187
+ if (ctx.dryRun) {
2188
+ return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
2189
+ }
1785
2190
  }
1786
- let text3 = readText(path);
1787
- const next = upsertLinkAgentfilesBlock(text3);
1788
- if (next !== text3) {
1789
- changedFiles.push(path);
2191
+ let text4 = readText(path);
2192
+ const next = upsertLinkAgentfilesBlock(text4, ctx);
2193
+ if (next !== text4) {
2194
+ if (!changedFiles.includes(path)) changedFiles.push(path);
1790
2195
  if (!ctx.dryRun) writeText(path, next);
1791
- text3 = next;
2196
+ text4 = next;
2197
+ }
2198
+ const linkAgentfilesPath = join8(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2199
+ const expectedScript = templateLinkAgentfilesScript(ctx);
2200
+ if (expectedScript === void 0) {
2201
+ 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: [] };
2202
+ }
2203
+ if (safeReadText(linkAgentfilesPath) !== expectedScript) {
2204
+ changedFiles.push(linkAgentfilesPath);
2205
+ if (!ctx.dryRun) {
2206
+ writeText(linkAgentfilesPath, expectedScript);
2207
+ chmodSync(linkAgentfilesPath, 493);
2208
+ }
1792
2209
  }
1793
2210
  return {
1794
2211
  id: finding.id,
@@ -1796,7 +2213,7 @@ var RULES = [
1796
2213
  status: changedFiles.length ? "applied" : "noop",
1797
2214
  summary: changedFiles.length ? "Updated mise AGENTS-linking contract" : "No changes required",
1798
2215
  changedFiles,
1799
- details: changedFiles.length ? ["Normalized hooks/watch_files/tasks.link-agentfiles block"] : []
2216
+ details: changedFiles.length ? ["Normalized hooks/watch_files/tasks.link-agentfiles block and script"] : []
1800
2217
  };
1801
2218
  }
1802
2219
  },
@@ -1805,13 +2222,13 @@ var RULES = [
1805
2222
  title: "managed mise versioning block",
1806
2223
  audit: (ctx) => {
1807
2224
  const details = [];
1808
- const misePath = join6(ctx.repoRoot, "mise.toml");
1809
- const versioningPath = join6(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
1810
- const manifestPath = join6(ctx.repoRoot, ".mise", "version-files.conf");
1811
- const text3 = safeReadText(misePath);
1812
- if (!text3?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
1813
- if (!existsSync5(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
1814
- if (!existsSync5(manifestPath)) details.push(".mise/version-files.conf missing");
2225
+ const misePath = join8(ctx.repoRoot, "mise.toml");
2226
+ const versioningPath = join8(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2227
+ const manifestPath = join8(ctx.repoRoot, ".mise", "version-files.conf");
2228
+ const text4 = safeReadText(misePath);
2229
+ if (!text4?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
2230
+ if (!existsSync7(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
2231
+ if (!existsSync7(manifestPath)) details.push(".mise/version-files.conf missing");
1815
2232
  return {
1816
2233
  id: "mise.versioning",
1817
2234
  title: "managed mise versioning block",
@@ -1823,17 +2240,24 @@ var RULES = [
1823
2240
  },
1824
2241
  migrate: (ctx, finding) => {
1825
2242
  const changedFiles = [];
1826
- const misePath = join6(ctx.repoRoot, "mise.toml");
1827
- if (!existsSync5(misePath)) {
1828
- return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing; cannot inject versioning block", changedFiles, details: [] };
2243
+ const details = [];
2244
+ const misePath = join8(ctx.repoRoot, "mise.toml");
2245
+ if (!existsSync7(misePath)) {
2246
+ if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2247
+ 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: [] };
2248
+ }
2249
+ details.push("Initialized mise.toml from generated-project template");
2250
+ if (ctx.dryRun) {
2251
+ return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
2252
+ }
1829
2253
  }
1830
2254
  const currentMise = readText(misePath);
1831
2255
  const nextMise = replaceOrAppendManagedBlock(currentMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
1832
2256
  if (nextMise !== currentMise) {
1833
- changedFiles.push(misePath);
2257
+ if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
1834
2258
  if (!ctx.dryRun) writeText(misePath, nextMise);
1835
2259
  }
1836
- const versioningPath = join6(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2260
+ const versioningPath = join8(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
1837
2261
  const expectedScript = templateVersioningScript(ctx);
1838
2262
  if (expectedScript === void 0) {
1839
2263
  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: [] };
@@ -1845,7 +2269,7 @@ var RULES = [
1845
2269
  chmodSync(versioningPath, 493);
1846
2270
  }
1847
2271
  }
1848
- const manifestPath = join6(ctx.repoRoot, ".mise", "version-files.conf");
2272
+ const manifestPath = join8(ctx.repoRoot, ".mise", "version-files.conf");
1849
2273
  const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
1850
2274
  if (safeReadText(manifestPath) !== expectedManifest) {
1851
2275
  changedFiles.push(manifestPath);
@@ -1865,13 +2289,24 @@ var RULES = [
1865
2289
  id: "sot.agent-symlinks",
1866
2290
  title: "AGENTS/CLAUDE/GEMINI symlink contract",
1867
2291
  audit: (ctx) => {
1868
- const agentsPath = join6(ctx.repoRoot, "AGENTS.md");
1869
- if (!existsSync5(agentsPath)) {
1870
- return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
2292
+ const agentsPath = join8(ctx.repoRoot, "AGENTS.md");
2293
+ if (!existsSync7(agentsPath)) {
2294
+ const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync7(join8(ctx.repoRoot, file)));
2295
+ if (fallbackSources.length === 0) {
2296
+ return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
2297
+ }
2298
+ return {
2299
+ id: "sot.agent-symlinks",
2300
+ title: "AGENTS/CLAUDE/GEMINI symlink contract",
2301
+ status: "fail",
2302
+ summary: "AGENTS.md missing but can be derived from existing project documentation",
2303
+ details: [`AGENTS.md can be created from ${fallbackSources[0]}`],
2304
+ fixable: true
2305
+ };
1871
2306
  }
1872
2307
  const details = [];
1873
2308
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
1874
- const full = join6(ctx.repoRoot, file);
2309
+ const full = join8(ctx.repoRoot, file);
1875
2310
  const target = readSymlinkTarget(full);
1876
2311
  if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
1877
2312
  }
@@ -1887,22 +2322,26 @@ var RULES = [
1887
2322
  migrate: (ctx, finding) => {
1888
2323
  const changedFiles = [];
1889
2324
  const details = [];
1890
- if (!existsSync5(join6(ctx.repoRoot, "AGENTS.md"))) {
1891
- return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot wire symlinks", changedFiles, details: [] };
2325
+ const blockedDetails = [];
2326
+ const bootstrap = bootstrapAgentsFile(ctx.repoRoot, ctx.dryRun);
2327
+ changedFiles.push(...bootstrap.changedFiles);
2328
+ details.push(...bootstrap.details);
2329
+ if (bootstrap.blocked) {
2330
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
1892
2331
  }
1893
2332
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
1894
- const full = join6(ctx.repoRoot, file);
2333
+ const full = join8(ctx.repoRoot, file);
1895
2334
  const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
1896
- if (result.blocked) details.push(result.blocked);
2335
+ if (result.blocked) blockedDetails.push(result.blocked);
1897
2336
  if (result.changed) changedFiles.push(full);
1898
2337
  }
1899
2338
  return {
1900
2339
  id: finding.id,
1901
2340
  title: finding.title,
1902
- status: details.length ? "blocked" : changedFiles.length ? "applied" : "noop",
1903
- summary: details.length ? "One or more files could not be replaced safely" : changedFiles.length ? "Symlink contract repaired" : "No changes required",
2341
+ status: blockedDetails.length ? "blocked" : changedFiles.length ? "applied" : "noop",
2342
+ summary: blockedDetails.length ? "One or more files could not be replaced safely" : changedFiles.length ? "Symlink contract repaired" : "No changes required",
1904
2343
  changedFiles,
1905
- details
2344
+ details: [...details, ...blockedDetails]
1906
2345
  };
1907
2346
  }
1908
2347
  },
@@ -1913,7 +2352,7 @@ var RULES = [
1913
2352
  migrate: (ctx, finding) => {
1914
2353
  const changedFiles = [];
1915
2354
  const details = [];
1916
- const path = join6(ctx.repoRoot, ".project.json");
2355
+ const path = join8(ctx.repoRoot, ".project.json");
1917
2356
  const existing = readProjectJson(ctx) ?? {};
1918
2357
  const canonical = canonicalProjectJson(ctx);
1919
2358
  const merged = { ...existing, ...canonical };
@@ -1923,10 +2362,10 @@ var RULES = [
1923
2362
  changedFiles.push(path);
1924
2363
  if (!ctx.dryRun) writeText(path, expected);
1925
2364
  }
1926
- const planeJson = join6(ctx.repoRoot, ".plane.json");
1927
- if (existsSync5(planeJson)) {
2365
+ const planeJson = join8(ctx.repoRoot, ".plane.json");
2366
+ if (existsSync7(planeJson)) {
1928
2367
  const backup = `${planeJson}.migrated-backup`;
1929
- if (existsSync5(backup)) {
2368
+ if (existsSync7(backup)) {
1930
2369
  details.push(`cannot back up .plane.json because ${relative(ctx.repoRoot, backup)} already exists`);
1931
2370
  } else {
1932
2371
  changedFiles.push(backup);
@@ -1948,14 +2387,15 @@ var RULES = [
1948
2387
  title: ".env.op + gitignore secrets contract",
1949
2388
  audit: (ctx) => {
1950
2389
  const details = [];
1951
- const envOp = safeReadText(join6(ctx.repoRoot, ".env.op"));
1952
- const gitignore = safeReadText(join6(ctx.repoRoot, ".gitignore"));
2390
+ const envOp = safeReadText(join8(ctx.repoRoot, ".env.op"));
2391
+ const gitignore = safeReadText(join8(ctx.repoRoot, ".gitignore"));
1953
2392
  if (!envOp) {
1954
2393
  details.push(".env.op missing");
1955
2394
  } else {
1956
2395
  const invalidLines = envOp.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).filter((line) => {
1957
2396
  const value = line.slice(line.indexOf("=") + 1).trim();
1958
- return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value);
2397
+ const quotedLiteral = /^"[^"\r\n]*"$/.test(value) || /^'[^'\r\n]*'$/.test(value);
2398
+ return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value) && !quotedLiteral;
1959
2399
  });
1960
2400
  if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals: ${invalidLines.join(", ")}`);
1961
2401
  }
@@ -1974,12 +2414,12 @@ var RULES = [
1974
2414
  migrate: (ctx, finding) => {
1975
2415
  const changedFiles = [];
1976
2416
  const details = [];
1977
- const envOpPath = join6(ctx.repoRoot, ".env.op");
1978
- if (!existsSync5(envOpPath)) {
2417
+ const envOpPath = join8(ctx.repoRoot, ".env.op");
2418
+ if (!existsSync7(envOpPath)) {
1979
2419
  changedFiles.push(envOpPath);
1980
- if (!ctx.dryRun) writeText(envOpPath, readText(join6(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2420
+ if (!ctx.dryRun) writeText(envOpPath, readText(join8(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
1981
2421
  }
1982
- const gitignorePath = join6(ctx.repoRoot, ".gitignore");
2422
+ const gitignorePath = join8(ctx.repoRoot, ".gitignore");
1983
2423
  const gitignore = safeReadText(gitignorePath) ?? "";
1984
2424
  const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
1985
2425
  # NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
@@ -2006,20 +2446,20 @@ var RULES = [
2006
2446
  title: ".copier-answers.yml provenance + drift report",
2007
2447
  audit: (ctx) => {
2008
2448
  const details = [];
2009
- const path = join6(ctx.repoRoot, ".copier-answers.yml");
2010
- const text3 = safeReadText(path);
2449
+ const path = join8(ctx.repoRoot, ".copier-answers.yml");
2450
+ const text4 = safeReadText(path);
2011
2451
  const project = readProjectJson(ctx);
2012
- if (!text3) {
2452
+ if (!text4) {
2013
2453
  details.push(".copier-answers.yml missing");
2014
2454
  } else {
2015
- if (!text3.startsWith("# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY")) details.push("missing Copier overwrite warning header");
2016
- if (!text3.includes("_src_path:")) details.push("_src_path missing");
2455
+ if (!text4.startsWith("# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY")) details.push("missing Copier overwrite warning header");
2456
+ if (!text4.includes("_src_path:")) details.push("_src_path missing");
2017
2457
  if (project?.project_name) {
2018
- const nameMatch = text3.match(/project_name:\s*(.+)/);
2458
+ const nameMatch = text4.match(/project_name:\s*(.+)/);
2019
2459
  if (!nameMatch || nameMatch[1]?.trim() !== String(project.project_name)) details.push("project_name drift between .copier-answers.yml and .project.json");
2020
2460
  }
2021
2461
  if (project?.project_description) {
2022
- const descMatch = text3.match(/project_description:\s*([\s\S]*?)(?=\n\w|$)/);
2462
+ const descMatch = text4.match(/project_description:\s*([\s\S]*?)(?=\n\w|$)/);
2023
2463
  const yamlDesc = descMatch?.[1]?.replace(/\n\s+/g, " ").trim() ?? "";
2024
2464
  if (yamlDesc !== String(project.project_description)) details.push("project_description drift between .copier-answers.yml and .project.json");
2025
2465
  }
@@ -2036,16 +2476,16 @@ var RULES = [
2036
2476
  migrate: (ctx, finding) => {
2037
2477
  const changedFiles = [];
2038
2478
  const project = canonicalProjectJson(ctx);
2039
- const text3 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
2040
- _src_path: ${join6(ctx.pjanglerRoot, "templates", "commonproject")}
2479
+ const text4 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
2480
+ _src_path: ${join8(ctx.pjanglerRoot, "templates", "commonproject")}
2041
2481
  project_description: ${String(project.project_description)}
2042
2482
  project_name: ${String(project.project_name)}
2043
2483
  ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2044
2484
  `;
2045
- const path = join6(ctx.repoRoot, ".copier-answers.yml");
2046
- if (safeReadText(path) !== text3) {
2485
+ const path = join8(ctx.repoRoot, ".copier-answers.yml");
2486
+ if (safeReadText(path) !== text4) {
2047
2487
  changedFiles.push(path);
2048
- if (!ctx.dryRun) writeText(path, text3);
2488
+ if (!ctx.dryRun) writeText(path, text4);
2049
2489
  }
2050
2490
  return {
2051
2491
  id: finding.id,
@@ -2061,15 +2501,15 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2061
2501
  id: "bmad.scaffold",
2062
2502
  title: "BMAD modules/docs scaffold",
2063
2503
  audit: (ctx) => {
2064
- const sourceRoot = join6(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
2065
- const targetRoot = join6(ctx.repoRoot, "_bmad");
2504
+ const sourceRoot = join8(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
2505
+ const targetRoot = join8(ctx.repoRoot, "_bmad");
2066
2506
  const sentinels = [
2067
- join6("core", "config.yaml"),
2068
- join6("custom", "config.yaml"),
2069
- join6("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
2070
- join6("bmm", "workflows", "workflow-status", "workflow.yaml")
2507
+ join8("core", "config.yaml"),
2508
+ join8("custom", "config.yaml"),
2509
+ join8("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
2510
+ join8("bmm", "workflows", "workflow-status", "workflow.yaml")
2071
2511
  ];
2072
- const missing = sentinels.filter((file) => existsSync5(join6(sourceRoot, file)) && !existsSync5(join6(targetRoot, file)));
2512
+ const missing = sentinels.filter((file) => existsSync7(join8(sourceRoot, file)) && !existsSync7(join8(targetRoot, file)));
2073
2513
  return {
2074
2514
  id: "bmad.scaffold",
2075
2515
  title: "BMAD modules/docs scaffold",
@@ -2081,7 +2521,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2081
2521
  },
2082
2522
  migrate: (ctx, finding) => {
2083
2523
  const changedFiles = [];
2084
- copyMissingRecursive(join6(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join6(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
2524
+ copyMissingRecursive(join8(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join8(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
2085
2525
  return {
2086
2526
  id: finding.id,
2087
2527
  title: finding.title,
@@ -2102,12 +2542,12 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2102
2542
  return { id: "hermes.pm-scaffold", title: "Hermes PM scaffold parity", status: "skip", summary: "No pm role present", details: [], fixable: false };
2103
2543
  }
2104
2544
  const details = [];
2105
- for (const rel of ["role.yaml", "SOUL.md", "hermes", ".gitignore", ".scripts/70-systemd.sh", ".runtime-scaffold/README.md", "runtime/memories/MEMORY.md", "runtime/bloodbank-consumer.py"]) {
2106
- if (!existsSync5(join6(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join6(role.roleDir, rel))}`);
2545
+ 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"]) {
2546
+ if (!existsSync7(join8(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join8(role.roleDir, rel))}`);
2107
2547
  }
2108
- const gitmodules = safeReadText(join6(ctx.repoRoot, ".gitmodules")) ?? "";
2548
+ const gitmodules = safeReadText(join8(ctx.repoRoot, ".gitmodules")) ?? "";
2109
2549
  if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing pm runtime submodule entry");
2110
- if (!profileMetaInheritsDefault(join6(role.roleDir, "runtime", "profile.yaml"))) {
2550
+ if (!profileMetaInheritsDefault(join8(role.roleDir, "runtime", "profile.yaml"))) {
2111
2551
  details.push("runtime/profile.yaml missing inherited default config metadata");
2112
2552
  }
2113
2553
  const registry = safeReadText(registryPath(ctx.homeDir));
@@ -2128,79 +2568,21 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2128
2568
  if (!role) {
2129
2569
  return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
2130
2570
  }
2131
- const templateRoleDir = join6(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
2132
- writeIfDifferent(join6(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
2133
- writeIfDifferent(join6(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
2134
- writeIfDifferent(join6(role.roleDir, ".gitignore"), readText(join6(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
2135
- copyMissingRecursive(join6(templateRoleDir, ".runtime-scaffold"), join6(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
2136
- copyMissingRecursive(join6(templateRoleDir, ".runtime-scaffold"), join6(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
2137
- copyMissingRecursive(join6(templateRoleDir, ".scripts"), join6(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("continuous-ticket-sentinel.prompt.md.jinja"));
2138
- upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
2139
- const profileMetaUpdated = upsertInheritedProfileMeta(join6(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
2140
- if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
2141
- const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
2142
- if (registryUpdated) details.push(`updated ${registryUpdated}`);
2143
- return {
2144
- id: finding.id,
2145
- title: finding.title,
2146
- status: changedFiles.length ? "applied" : "noop",
2147
- summary: changedFiles.length ? "PM scaffold normalized" : "No changes required",
2148
- changedFiles,
2149
- details
2150
- };
2151
- }
2152
- },
2153
- {
2154
- id: "hermes.scrum-master-scaffold",
2155
- title: "Hermes scrum-master scaffold parity",
2156
- audit: (ctx) => {
2157
- const roles = discoverRoles(ctx.repoRoot);
2158
- const role = roles.find((item) => item.role === "scrum-master");
2159
- if (!role) {
2160
- return { id: "hermes.scrum-master-scaffold", title: "Hermes scrum-master scaffold parity", status: "skip", summary: "No scrum-master role present", details: [], fixable: false };
2161
- }
2162
- const details = [];
2163
- for (const rel of ["role.yaml", "SOUL.md", "hermes", ".gitignore", ".scripts/75-scrum-master.sh", ".scripts/scrum-master/continuous-ticket-sentinel.sh", "runtime/memories/MEMORY.md", "runtime/bloodbank-consumer.py"]) {
2164
- if (!existsSync5(join6(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join6(role.roleDir, rel))}`);
2165
- }
2166
- const gitmodules = safeReadText(join6(ctx.repoRoot, ".gitmodules")) ?? "";
2167
- if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing scrum-master runtime submodule entry");
2168
- if (!profileMetaInheritsDefault(join6(role.roleDir, "runtime", "profile.yaml"))) {
2169
- details.push("runtime/profile.yaml missing inherited default config metadata");
2170
- }
2171
- const registry = safeReadText(registryPath(ctx.homeDir));
2172
- if (!registry?.includes(`${role.agentId}:`)) details.push(`fleet registry missing ${role.agentId}`);
2173
- return {
2174
- id: "hermes.scrum-master-scaffold",
2175
- title: "Hermes scrum-master scaffold parity",
2176
- status: details.length === 0 ? "pass" : "fail",
2177
- summary: details.length === 0 ? "scrum-master scaffold parity verified" : `${details.length} scrum-master scaffold issue(s) detected`,
2178
- details,
2179
- fixable: true
2180
- };
2181
- },
2182
- migrate: (ctx, finding) => {
2183
- const role = discoverRoles(ctx.repoRoot).find((item) => item.role === "scrum-master");
2184
- const changedFiles = [];
2185
- const details = [];
2186
- if (!role) {
2187
- return { id: finding.id, title: finding.title, status: "blocked", summary: "No scrum-master role present", changedFiles, details: [] };
2188
- }
2189
- const templateRoleDir = join6(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
2190
- writeIfDifferent(join6(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
2191
- writeIfDifferent(join6(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
2192
- writeIfDifferent(join6(role.roleDir, ".gitignore"), readText(join6(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
2193
- copyMissingRecursive(join6(templateRoleDir, ".runtime-scaffold"), join6(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
2194
- copyMissingRecursive(join6(templateRoleDir, ".runtime-scaffold"), join6(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
2195
- copyMissingRecursive(join6(templateRoleDir, ".scripts"), join6(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("continuous-ticket-sentinel.prompt.md.jinja"));
2196
- const promptSource = join6(templateRoleDir, ".scripts", "scrum-master", "continuous-ticket-sentinel.prompt.md.jinja");
2197
- const promptTarget = join6(role.roleDir, ".scripts", "scrum-master", "continuous-ticket-sentinel.prompt.md");
2198
- if (!existsSync5(promptTarget)) {
2199
- const prompt = readText(promptSource).replace(/\{\{ agent_id \}\}/g, role.agentId).replace(/\{\{ role \}\}/g, role.role).replace(/\{\{ target_repo \}\}/g, role.repo);
2571
+ const templateRoleDir = join8(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
2572
+ writeIfDifferent(join8(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
2573
+ writeIfDifferent(join8(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
2574
+ writeIfDifferent(join8(role.roleDir, ".gitignore"), readText(join8(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
2575
+ copyMissingRecursive(join8(templateRoleDir, ".runtime-scaffold"), join8(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
2576
+ copyMissingRecursive(join8(templateRoleDir, ".runtime-scaffold"), join8(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
2577
+ copyMissingRecursive(join8(templateRoleDir, ".scripts"), join8(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
2578
+ const promptSource = join8(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
2579
+ const promptTarget = join8(role.roleDir, ".scripts", "sentinel.prompt.md");
2580
+ if (existsSync7(promptSource) && !existsSync7(promptTarget)) {
2581
+ 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);
2200
2582
  writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
2201
2583
  }
2202
2584
  upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
2203
- const profileMetaUpdated = upsertInheritedProfileMeta(join6(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
2585
+ const profileMetaUpdated = upsertInheritedProfileMeta(join8(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
2204
2586
  if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
2205
2587
  const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
2206
2588
  if (registryUpdated) details.push(`updated ${registryUpdated}`);
@@ -2208,7 +2590,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2208
2590
  id: finding.id,
2209
2591
  title: finding.title,
2210
2592
  status: changedFiles.length ? "applied" : "noop",
2211
- summary: changedFiles.length ? "scrum-master scaffold normalized" : "No changes required",
2593
+ summary: changedFiles.length ? "PM scaffold normalized" : "No changes required",
2212
2594
  changedFiles,
2213
2595
  details
2214
2596
  };
@@ -2228,14 +2610,10 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2228
2610
  }
2229
2611
  const details = [];
2230
2612
  for (const role of roles) {
2231
- for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-checkpoint.timer`]) {
2613
+ for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
2232
2614
  const state = checkUnit(unit);
2233
2615
  if (!state.enabled || !state.active) details.push(`${unit} should be enabled+active`);
2234
2616
  }
2235
- if (role.role === "scrum-master") {
2236
- const state = checkUnit(`hermes-${role.agentId}-continuous-ticket-sentinel.timer`);
2237
- if (!state.enabled || !state.active) details.push(`hermes-${role.agentId}-continuous-ticket-sentinel.timer should be enabled+active`);
2238
- }
2239
2617
  }
2240
2618
  return {
2241
2619
  id: "systemd.sentinel",
@@ -2258,10 +2636,9 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2258
2636
  return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
2259
2637
  }
2260
2638
  for (const role of roles) {
2261
- const sysDir = join6(ctx.homeDir, ".config", "systemd", "user");
2262
- const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-checkpoint.timer`];
2263
- if (role.role === "scrum-master") units.push(`hermes-${role.agentId}-continuous-ticket-sentinel.timer`);
2264
- const allUnitsPresent = units.every((unit) => existsSync5(join6(sysDir, unit)));
2639
+ const sysDir = join8(ctx.homeDir, ".config", "systemd", "user");
2640
+ const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
2641
+ const allUnitsPresent = units.every((unit) => existsSync7(join8(sysDir, unit)));
2265
2642
  if (allUnitsPresent) {
2266
2643
  if (ctx.dryRun) {
2267
2644
  details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
@@ -2273,8 +2650,8 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2273
2650
  }
2274
2651
  continue;
2275
2652
  }
2276
- for (const script of [join6(role.roleDir, ".scripts", "70-systemd.sh"), role.role === "scrum-master" ? join6(role.roleDir, ".scripts", "75-scrum-master.sh") : ""]) {
2277
- if (!script || !existsSync5(script)) continue;
2653
+ for (const script of [join8(role.roleDir, ".scripts", "70-systemd.sh")]) {
2654
+ if (!script || !existsSync7(script)) continue;
2278
2655
  if (ctx.dryRun) {
2279
2656
  details.push(`would run: bash ${script}`);
2280
2657
  } else {
@@ -2304,13 +2681,16 @@ function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
2304
2681
  if (mode) chmodSync(path, mode);
2305
2682
  }
2306
2683
  }
2684
+ function getParityRuleIds() {
2685
+ return RULES.map((rule) => rule.id);
2686
+ }
2307
2687
  function runAudit(repoArg) {
2308
2688
  const pjanglerRoot = resolvePjanglerRoot();
2309
2689
  const ctx = {
2310
2690
  repoRoot: resolve(repoArg ?? process.cwd()),
2311
2691
  dryRun: true,
2312
2692
  pjanglerRoot,
2313
- homeDir: homedir3()
2693
+ homeDir: homedir4()
2314
2694
  };
2315
2695
  const rules = RULES.map((rule) => rule.audit(ctx));
2316
2696
  return {
@@ -2320,17 +2700,17 @@ function runAudit(repoArg) {
2320
2700
  rules
2321
2701
  };
2322
2702
  }
2323
- function runMigration(selector, repoArg, dryRun, all) {
2703
+ function runMigrationForRules(ruleIds, repoArg, dryRun) {
2324
2704
  const pjanglerRoot = resolvePjanglerRoot();
2325
2705
  const ctx = {
2326
2706
  repoRoot: resolve(repoArg ?? process.cwd()),
2327
2707
  dryRun,
2328
2708
  pjanglerRoot,
2329
- homeDir: homedir3()
2709
+ homeDir: homedir4()
2330
2710
  };
2331
- const selected = all ? RULES : RULES.filter((rule) => rule.id === selector);
2711
+ const selected = RULES.filter((rule) => ruleIds.includes(rule.id));
2332
2712
  if (!selected.length) {
2333
- throw new Error(`Unknown parity rule: ${selector}`);
2713
+ throw new Error(`Unknown parity rules: ${ruleIds.join(", ")}`);
2334
2714
  }
2335
2715
  const results = selected.map((rule) => {
2336
2716
  try {
@@ -2356,6 +2736,10 @@ function runMigration(selector, repoArg, dryRun, all) {
2356
2736
  changedFiles
2357
2737
  };
2358
2738
  }
2739
+ function runMigration(selector, repoArg, dryRun, all) {
2740
+ const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
2741
+ return runMigrationForRules(ruleIds, repoArg, dryRun);
2742
+ }
2359
2743
  function formatAuditReport(report) {
2360
2744
  const lines = [`repo: ${report.repo}`, `ok: ${report.ok}`, `audited_at: ${report.auditedAt}`, "rules:"];
2361
2745
  for (const rule of report.rules) {
@@ -2380,19 +2764,421 @@ function formatMigrationReport(report) {
2380
2764
  `;
2381
2765
  }
2382
2766
 
2767
+ // src/project/index.ts
2768
+ import { spawnSync as spawnSync5 } from "node:child_process";
2769
+ import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync4, renameSync as renameSync2, statSync, writeFileSync as writeFileSync5 } from "node:fs";
2770
+ import { homedir as homedir5 } from "node:os";
2771
+ import { basename as basename3, dirname as dirname6, join as join9, resolve as resolve2 } from "node:path";
2772
+ import YAML from "yaml";
2773
+ var PROJECT_REGISTRY_ENV = "PJ_PROJECT_REGISTRY";
2774
+ var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
2775
+ var KNOWN_SKILL_ROOTS = [
2776
+ "/home/delorenj/code/skillex/all-skills",
2777
+ "/home/delorenj/code/CoachingAgentFramework/.agents/skills",
2778
+ "/home/delorenj/code/pjangler/.agents/skills",
2779
+ join9(homedir5(), ".codex", "skills")
2780
+ ];
2781
+ function projectRegistryPath(env = process.env) {
2782
+ return expandHome(env[PROJECT_REGISTRY_ENV] || join9(homedir5(), ".config", "pjangler", "projects.yaml"));
2783
+ }
2784
+ function emptyProjectRegistry() {
2785
+ return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
2786
+ }
2787
+ function loadProjectRegistry(path = projectRegistryPath()) {
2788
+ if (!existsSync8(path)) return emptyProjectRegistry();
2789
+ const raw = YAML.parse(readFileSync4(path, "utf8"));
2790
+ if (raw == null) return emptyProjectRegistry();
2791
+ if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
2792
+ const registry = raw;
2793
+ const normalized = {
2794
+ schema_version: Number(registry.schema_version ?? PROJECT_REGISTRY_SCHEMA_VERSION),
2795
+ projects: isRecord(registry.projects) ? registry.projects : {}
2796
+ };
2797
+ validateProjectRegistry(normalized);
2798
+ return normalized;
2799
+ }
2800
+ function saveProjectRegistry(registry, path = projectRegistryPath()) {
2801
+ validateProjectRegistry(registry);
2802
+ mkdirSync6(dirname6(path), { recursive: true });
2803
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
2804
+ writeFileSync5(temp, YAML.stringify(registry, { lineWidth: 0 }), "utf8");
2805
+ renameSync2(temp, path);
2806
+ }
2807
+ function validateProjectRegistry(registry) {
2808
+ if (registry.schema_version !== PROJECT_REGISTRY_SCHEMA_VERSION) {
2809
+ throw new Error(`Unsupported project registry schema_version: ${registry.schema_version}`);
2810
+ }
2811
+ if (!isRecord(registry.projects)) throw new Error("Project registry projects must be a mapping");
2812
+ const slugs = /* @__PURE__ */ new Set();
2813
+ const repoPaths = /* @__PURE__ */ new Map();
2814
+ const identifiers = /* @__PURE__ */ new Map();
2815
+ for (const [slug, project] of Object.entries(registry.projects)) {
2816
+ validateProjectRecord(project, slug);
2817
+ if (slugs.has(project.slug)) throw new Error(`Duplicate project slug: ${project.slug}`);
2818
+ slugs.add(project.slug);
2819
+ const repoKey = resolve2(project.repo_path);
2820
+ const existingRepoSlug = repoPaths.get(repoKey);
2821
+ if (existingRepoSlug && existingRepoSlug !== slug) {
2822
+ throw new Error(`Duplicate project repo_path: ${project.repo_path} used by ${existingRepoSlug} and ${slug}`);
2823
+ }
2824
+ repoPaths.set(repoKey, slug);
2825
+ const identifier = project.ticket_provider.identifier?.toUpperCase();
2826
+ if (identifier) {
2827
+ const existingIdentifierSlug = identifiers.get(identifier);
2828
+ if (existingIdentifierSlug && existingIdentifierSlug !== slug) {
2829
+ throw new Error(`Duplicate project identifier: ${identifier} used by ${existingIdentifierSlug} and ${slug}`);
2830
+ }
2831
+ identifiers.set(identifier, slug);
2832
+ }
2833
+ }
2834
+ }
2835
+ function slugifyProjectName(value) {
2836
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
2837
+ }
2838
+ function deriveProjectIdentifier(value) {
2839
+ const compact = value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
2840
+ const identifier = compact.slice(0, 4) || "PROJ";
2841
+ return identifier.length >= 2 ? identifier : `${identifier}XX`.slice(0, 4);
2842
+ }
2843
+ function normalizeAgentRole(value) {
2844
+ return value?.trim() || "pm";
2845
+ }
2846
+ function jsonStable(value) {
2847
+ return JSON.stringify(value);
2848
+ }
2849
+ function projectRecordEquivalent(a, b) {
2850
+ if (!a) return false;
2851
+ const { created_at: _aCreated, updated_at: _aUpdated, ...aComparable } = a;
2852
+ const { created_at: _bCreated, updated_at: _bUpdated, ...bComparable } = b;
2853
+ return jsonStable(aComparable) === jsonStable(bComparable);
2854
+ }
2855
+ function defaultProjectTargetDir(name, cwd = process.cwd()) {
2856
+ const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
2857
+ return resolve2(dirname6(resolve2(cwd)), compactName);
2858
+ }
2859
+ function resolveSourceSkillPath(sourceSkill) {
2860
+ if (!sourceSkill) return void 0;
2861
+ const expanded = expandHome(sourceSkill);
2862
+ const direct = resolve2(expanded);
2863
+ if (existsSync8(direct)) return direct;
2864
+ const name = basename3(sourceSkill);
2865
+ for (const root of KNOWN_SKILL_ROOTS) {
2866
+ const candidate = join9(root, name);
2867
+ if (existsSync8(candidate)) return candidate;
2868
+ }
2869
+ const civilWarLetterifier = "/home/delorenj/code/skillex/all-skills/civilwar-letterifier";
2870
+ const hint = existsSync8(civilWarLetterifier) ? ` Did you mean ${civilWarLetterifier}?` : "";
2871
+ throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
2872
+ }
2873
+ function planProjectInit(input) {
2874
+ if (!input.name.trim()) throw new Error("Project name is required");
2875
+ const registryPath2 = resolve2(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
2876
+ const registry = loadProjectRegistry(registryPath2);
2877
+ const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
2878
+ const slug = input.projectSlug ?? slugifyProjectName(input.name);
2879
+ const targetDir = resolve2(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
2880
+ const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
2881
+ const existing = registry.projects[slug];
2882
+ const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
2883
+ const overwrite = input.overwrite ?? input.force ?? false;
2884
+ const agentRole = normalizeAgentRole(input.agentRole);
2885
+ const agents = input.provisionAgent ? {
2886
+ ...existing?.agents ?? {},
2887
+ [agentRole]: {
2888
+ role: agentRole,
2889
+ provisioning_state: "planned"
2890
+ }
2891
+ } : existing?.agents ?? {};
2892
+ const scaffold = input.scaffold ?? true;
2893
+ const candidateProject = {
2894
+ name: input.name,
2895
+ slug,
2896
+ repo_path: targetDir,
2897
+ description: input.description ?? "",
2898
+ status: "planned",
2899
+ source_artifacts: sourceSkillPath ? [{ kind: "skill", path: sourceSkillPath, package_name: input.packageName ?? slug }] : [],
2900
+ template: {
2901
+ commonproject: {
2902
+ enabled: true,
2903
+ primary_language: input.primaryLanguage ?? "python"
2904
+ }
2905
+ },
2906
+ ticket_provider: {
2907
+ type: input.ticketProvider ?? "plane",
2908
+ workspace: input.planeWorkspace ?? "33god",
2909
+ identifier,
2910
+ board_id: input.planeProjectId ?? "",
2911
+ board_url: input.planeProjectId ? `https://plane.delo.sh/${input.planeWorkspace ?? "33god"}/projects/${input.planeProjectId}/issues/` : "",
2912
+ state: input.live ? "planned" : "planned"
2913
+ },
2914
+ agents,
2915
+ created_at: existing?.created_at ?? now,
2916
+ updated_at: now
2917
+ };
2918
+ const project = {
2919
+ ...candidateProject,
2920
+ updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
2921
+ };
2922
+ validateNoDuplicateProject(registry, project, overwrite);
2923
+ const pjanglerRoot = resolve2(input.pjanglerRoot ?? resolvePjanglerRoot2());
2924
+ const manifest = projectManifestFromRegistryProject(project);
2925
+ const apply = input.apply ?? false;
2926
+ const live = input.live ?? false;
2927
+ const actions = [
2928
+ { kind: "registry.upsert", registryPath: registryPath2, slug, project }
2929
+ ];
2930
+ if (scaffold) {
2931
+ actions.push(buildCommonProjectCopierAction({
2932
+ pjanglerRoot,
2933
+ targetDir,
2934
+ projectName: project.name,
2935
+ projectDescription: project.description,
2936
+ projectSlug: project.slug,
2937
+ ticketProvider: project.ticket_provider.type,
2938
+ planeWorkspace: project.ticket_provider.workspace ?? "33god",
2939
+ planeProjectId: project.ticket_provider.board_id ?? "",
2940
+ projectIdentifier: identifier,
2941
+ primaryLanguage: project.template.commonproject.primary_language,
2942
+ overwrite
2943
+ }));
2944
+ }
2945
+ actions.push(
2946
+ { kind: "project.write-manifest", path: join9(targetDir, ".project.json"), manifest },
2947
+ {
2948
+ kind: "plane.create-or-link",
2949
+ enabled: live,
2950
+ live,
2951
+ workspace: project.ticket_provider.workspace ?? "33god",
2952
+ identifier,
2953
+ state: live ? "planned" : "planned",
2954
+ reason: live ? void 0 : "network/cloud actions require --live"
2955
+ },
2956
+ {
2957
+ kind: "hermes.provision-agent",
2958
+ enabled: input.provisionAgent ?? false,
2959
+ local: !live,
2960
+ targetDir,
2961
+ targetRepo: slug,
2962
+ role: agentRole,
2963
+ context: {
2964
+ skipRuntimeRepo: !live,
2965
+ skipPlane: !live,
2966
+ skipBloodbank: !live,
2967
+ skipSystemd: !live || process.platform === "darwin"
2968
+ }
2969
+ }
2970
+ );
2971
+ return { ok: true, apply, dryRun: !apply, live, registryPath: registryPath2, project, manifest, actions };
2972
+ }
2973
+ function executeProjectInitPlan(plan) {
2974
+ const logs = [];
2975
+ const errors = [];
2976
+ const changedFiles = [];
2977
+ if (!plan.apply) return { ok: true, plan, logs, errors, changedFiles };
2978
+ const registry = loadProjectRegistry(plan.registryPath);
2979
+ let pendingRegistryAction;
2980
+ for (const action of plan.actions) {
2981
+ if (action.kind === "copier.copy.commonproject") {
2982
+ mkdirSync6(dirname6(action.targetDir), { recursive: true });
2983
+ const result = spawnSync5(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
2984
+ if (result.stdout?.trim()) logs.push(result.stdout.trim());
2985
+ if (result.stderr?.trim()) logs.push(result.stderr.trim());
2986
+ if (result.error) {
2987
+ const code = result.error.code;
2988
+ errors.push(
2989
+ code === "ENOENT" ? "copier not found on PATH. Install with: uv tool install copier or pip install copier" : `copier failed: ${result.error.message}`
2990
+ );
2991
+ break;
2992
+ }
2993
+ if (result.status !== 0) {
2994
+ errors.push(`copier exited with status ${result.status ?? "unknown"}`);
2995
+ if (existsSync8(action.targetDir)) changedFiles.push(action.targetDir);
2996
+ break;
2997
+ }
2998
+ changedFiles.push(action.targetDir);
2999
+ } else if (action.kind === "project.write-manifest") {
3000
+ mkdirSync6(dirname6(action.path), { recursive: true });
3001
+ const next = `${JSON.stringify(action.manifest, null, 2)}
3002
+ `;
3003
+ const current = existsSync8(action.path) ? readFileSync4(action.path, "utf8") : void 0;
3004
+ if (current !== next) {
3005
+ writeFileSync5(action.path, next, "utf8");
3006
+ changedFiles.push(action.path);
3007
+ }
3008
+ } else if (action.kind === "registry.upsert") {
3009
+ pendingRegistryAction = action;
3010
+ } else if (action.kind === "plane.create-or-link") {
3011
+ logs.push(action.enabled ? "plane.create-or-link requires a live provider integration" : "plane.create-or-link skipped (requires --live)");
3012
+ } else if (action.kind === "hermes.provision-agent") {
3013
+ logs.push(action.enabled ? "hermes.provision-agent planned for the caller to execute" : "hermes.provision-agent skipped");
3014
+ }
3015
+ }
3016
+ if (pendingRegistryAction && errors.length === 0) {
3017
+ if (!projectRecordEquivalent(registry.projects[pendingRegistryAction.slug], pendingRegistryAction.project)) {
3018
+ registry.projects[pendingRegistryAction.slug] = pendingRegistryAction.project;
3019
+ saveProjectRegistry(registry, pendingRegistryAction.registryPath);
3020
+ changedFiles.push(pendingRegistryAction.registryPath);
3021
+ }
3022
+ }
3023
+ return { ok: errors.length === 0, plan, logs, errors, changedFiles };
3024
+ }
3025
+ function projectManifestFromRegistryProject(project) {
3026
+ const agents = Object.fromEntries(
3027
+ Object.entries(project.agents).map(([name, agent]) => [
3028
+ `${project.slug}-${name}`,
3029
+ {
3030
+ role: agent.role,
3031
+ role_dir: agent.role_dir,
3032
+ provisioning_state: agent.provisioning_state
3033
+ }
3034
+ ])
3035
+ );
3036
+ return {
3037
+ project_name: project.name,
3038
+ project_description: project.description,
3039
+ project_slug: project.slug,
3040
+ repo_path: project.repo_path,
3041
+ ticket_provider: {
3042
+ type: project.ticket_provider.type,
3043
+ workspace: project.ticket_provider.workspace ?? "",
3044
+ identifier: project.ticket_provider.identifier ?? "",
3045
+ board_id: project.ticket_provider.board_id ?? "",
3046
+ board_url: project.ticket_provider.board_url ?? "",
3047
+ state: project.ticket_provider.state
3048
+ },
3049
+ agents
3050
+ };
3051
+ }
3052
+ function formatProjectInitPlan(plan) {
3053
+ const lines = [
3054
+ `${plan.dryRun ? "[DRY RUN] " : ""}Project init plan: ${plan.project.name} (${plan.project.slug})`,
3055
+ `Registry: ${plan.registryPath}`,
3056
+ `Target: ${plan.project.repo_path}`,
3057
+ "Actions:"
3058
+ ];
3059
+ for (const action of plan.actions) {
3060
+ lines.push(` - ${action.kind}`);
3061
+ if (action.kind === "copier.copy.commonproject") lines.push(` target: ${action.targetDir}`);
3062
+ if (action.kind === "project.write-manifest") lines.push(` path: ${action.path}`);
3063
+ if (action.kind === "plane.create-or-link" && action.reason) lines.push(` note: ${action.reason}`);
3064
+ }
3065
+ return lines.join("\n");
3066
+ }
3067
+ function formatProjectList(registry) {
3068
+ const projects = Object.values(registry.projects).sort((a, b) => a.slug.localeCompare(b.slug));
3069
+ if (!projects.length) return "No projects registered.";
3070
+ return projects.map((project) => `${project.slug.padEnd(18)} ${String(project.ticket_provider.identifier ?? "").padEnd(6)} ${project.status.padEnd(8)} ${project.repo_path}`).join("\n");
3071
+ }
3072
+ function getProject(registry, slug) {
3073
+ const project = registry.projects[slug];
3074
+ if (!project) throw new Error(`Project not found in registry: ${slug}`);
3075
+ return project;
3076
+ }
3077
+ function doctorProjectRegistry(registryPath2 = projectRegistryPath(), slug) {
3078
+ const issues = [];
3079
+ const registry = loadProjectRegistry(registryPath2);
3080
+ const projects = slug ? [[slug, getProject(registry, slug)]] : Object.entries(registry.projects);
3081
+ for (const [projectSlug, project] of projects) {
3082
+ if (!existsSync8(project.repo_path)) {
3083
+ issues.push({ level: "warn", slug: projectSlug, message: `repo_path does not exist: ${project.repo_path}` });
3084
+ } else if (!statSync(project.repo_path).isDirectory()) {
3085
+ issues.push({ level: "error", slug: projectSlug, message: `repo_path is not a directory: ${project.repo_path}` });
3086
+ } else {
3087
+ const manifestPath = join9(project.repo_path, ".project.json");
3088
+ if (!existsSync8(manifestPath)) issues.push({ level: "warn", slug: projectSlug, message: ".project.json is missing" });
3089
+ }
3090
+ for (const artifact of project.source_artifacts) {
3091
+ if (artifact.path && !existsSync8(artifact.path)) {
3092
+ issues.push({ level: "warn", slug: projectSlug, message: `source artifact missing: ${artifact.path}` });
3093
+ }
3094
+ }
3095
+ }
3096
+ return {
3097
+ ok: !issues.some((issue) => issue.level === "error"),
3098
+ registryPath: registryPath2,
3099
+ checkedProjects: projects.map(([projectSlug]) => projectSlug),
3100
+ issues
3101
+ };
3102
+ }
3103
+ function buildCommonProjectCopierAction(input) {
3104
+ const templateDir = join9(input.pjanglerRoot, "templates", "commonproject");
3105
+ const data = {
3106
+ project_name: input.projectName,
3107
+ project_description: input.projectDescription ?? "",
3108
+ project_slug: input.projectSlug,
3109
+ ticket_provider: input.ticketProvider,
3110
+ plane_workspace: input.planeWorkspace,
3111
+ plane_project_id: input.planeProjectId ?? "",
3112
+ project_identifier: input.projectIdentifier,
3113
+ primary_language: input.primaryLanguage
3114
+ };
3115
+ const command = ["copier", "copy", "--trust", templateDir, input.targetDir, "--defaults"];
3116
+ for (const [key, value] of Object.entries(data)) command.push("--data", `${key}=${value}`);
3117
+ if (input.overwrite) command.push("--overwrite");
3118
+ return {
3119
+ kind: "copier.copy.commonproject",
3120
+ cwd: input.pjanglerRoot,
3121
+ command,
3122
+ targetDir: input.targetDir,
3123
+ data,
3124
+ overwrite: input.overwrite
3125
+ };
3126
+ }
3127
+ function resolvePjanglerRoot2() {
3128
+ let dir = dirname6(new URL(import.meta.url).pathname);
3129
+ while (dir !== dirname6(dir)) {
3130
+ if (existsSync8(join9(dir, "package.json")) && existsSync8(join9(dir, "templates", "commonproject", "copier.yml"))) return dir;
3131
+ dir = dirname6(dir);
3132
+ }
3133
+ return resolve2(process.cwd());
3134
+ }
3135
+ function validateNoDuplicateProject(registry, project, overwrite) {
3136
+ const existingSameSlug = registry.projects[project.slug];
3137
+ if (existingSameSlug && !overwrite && resolve2(existingSameSlug.repo_path) !== resolve2(project.repo_path)) {
3138
+ throw new Error(`Project slug already exists in registry: ${project.slug}`);
3139
+ }
3140
+ for (const [slug, existing] of Object.entries(registry.projects)) {
3141
+ if (slug === project.slug) continue;
3142
+ if (resolve2(existing.repo_path) === resolve2(project.repo_path)) {
3143
+ throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
3144
+ }
3145
+ if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
3146
+ throw new Error(`Project identifier already registered by ${slug}: ${project.ticket_provider.identifier}`);
3147
+ }
3148
+ }
3149
+ }
3150
+ function validateProjectRecord(project, key) {
3151
+ if (!isRecord(project)) throw new Error(`Project ${key} must be a mapping`);
3152
+ if (!project.name) throw new Error(`Project ${key} missing name`);
3153
+ if (!project.slug) throw new Error(`Project ${key} missing slug`);
3154
+ if (project.slug !== key) throw new Error(`Project key ${key} does not match slug ${project.slug}`);
3155
+ if (!project.repo_path) throw new Error(`Project ${key} missing repo_path`);
3156
+ if (!Array.isArray(project.source_artifacts)) throw new Error(`Project ${key} source_artifacts must be a list`);
3157
+ if (!isRecord(project.ticket_provider)) throw new Error(`Project ${key} ticket_provider must be a mapping`);
3158
+ if (!isRecord(project.agents)) throw new Error(`Project ${key} agents must be a mapping`);
3159
+ }
3160
+ function expandHome(path) {
3161
+ if (path === "~") return homedir5();
3162
+ if (path.startsWith("~/")) return join9(homedir5(), path.slice(2));
3163
+ return path;
3164
+ }
3165
+ function isRecord(value) {
3166
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3167
+ }
3168
+
2383
3169
  // src/utils/version.ts
2384
- import { readFileSync as readFileSync3 } from "node:fs";
2385
- import { dirname as dirname4, join as join7 } from "node:path";
2386
- import { fileURLToPath as fileURLToPath3 } from "node:url";
3170
+ import { readFileSync as readFileSync5 } from "node:fs";
3171
+ import { dirname as dirname7, join as join10 } from "node:path";
3172
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
2387
3173
  var PJANGLER_VERSION = (() => {
2388
3174
  try {
2389
- let dir = dirname4(fileURLToPath3(import.meta.url));
3175
+ let dir = dirname7(fileURLToPath4(import.meta.url));
2390
3176
  for (let i = 0; i < 4; i++) {
2391
3177
  try {
2392
- const raw = readFileSync3(join7(dir, "package.json"), "utf8");
3178
+ const raw = readFileSync5(join10(dir, "package.json"), "utf8");
2393
3179
  return JSON.parse(raw).version ?? "0.0.0";
2394
3180
  } catch {
2395
- const parent = dirname4(dir);
3181
+ const parent = dirname7(dir);
2396
3182
  if (parent === dir) break;
2397
3183
  dir = parent;
2398
3184
  }
@@ -2403,6 +3189,190 @@ var PJANGLER_VERSION = (() => {
2403
3189
  })();
2404
3190
 
2405
3191
  // src/index.ts
3192
+ function printMigrationReport(report, asJson) {
3193
+ if (asJson) {
3194
+ console.log(JSON.stringify(report, null, 2));
3195
+ } else {
3196
+ console.log(formatMigrationReport(report));
3197
+ }
3198
+ }
3199
+ async function promptForRuleIds(rules) {
3200
+ const options = rules.filter((rule) => rule.fixable).map((rule) => ({
3201
+ value: rule.id,
3202
+ label: `${rule.id} [${rule.status}] ${rule.title}`,
3203
+ hint: rule.summary
3204
+ }));
3205
+ if (!options.length) {
3206
+ return [];
3207
+ }
3208
+ const initialValues = rules.filter((rule) => rule.fixable && rule.status !== "pass" && rule.status !== "skip").map((rule) => rule.id);
3209
+ const selected = await multiselect({
3210
+ message: "Select parity rules to apply (space to toggle, enter to confirm):",
3211
+ options,
3212
+ initialValues
3213
+ });
3214
+ if (isCancel5(selected)) {
3215
+ return [];
3216
+ }
3217
+ return selected;
3218
+ }
3219
+ function readJson(path) {
3220
+ if (!existsSync9(path)) return void 0;
3221
+ try {
3222
+ const parsed = JSON.parse(readFileSync6(path, "utf8"));
3223
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
3224
+ } catch {
3225
+ return void 0;
3226
+ }
3227
+ }
3228
+ function findGitRoot(cwd) {
3229
+ const result = spawnSync6("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
3230
+ if (result.status !== 0) return void 0;
3231
+ return resolve3(result.stdout.trim());
3232
+ }
3233
+ function packageNameToProjectName(value) {
3234
+ if (!value) return void 0;
3235
+ const name = value.split("/").pop() ?? value;
3236
+ return name.replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()).trim();
3237
+ }
3238
+ function deriveProjectDefaults(targetDir) {
3239
+ const manifest = readJson(join11(targetDir, ".project.json"));
3240
+ const pkg = readJson(join11(targetDir, "package.json"));
3241
+ const name = String(manifest?.project_name ?? "").trim() || packageNameToProjectName(typeof pkg?.name === "string" ? pkg.name : void 0) || packageNameToProjectName(basename4(targetDir)) || "Project";
3242
+ const ticketProvider = manifest?.ticket_provider && typeof manifest.ticket_provider === "object" ? manifest.ticket_provider : {};
3243
+ return {
3244
+ name,
3245
+ description: String(manifest?.project_description ?? pkg?.description ?? ""),
3246
+ slug: typeof manifest?.project_slug === "string" ? manifest.project_slug : void 0,
3247
+ identifier: typeof ticketProvider.identifier === "string" ? ticketProvider.identifier : void 0
3248
+ };
3249
+ }
3250
+ function isInteractiveProjectInit(options) {
3251
+ return !options.json && !options.yes && options.tui !== false && Boolean(process.stdin.isTTY && process.stdout.isTTY);
3252
+ }
3253
+ async function promptTextValue(message, initialValue) {
3254
+ const value = await text3({
3255
+ message,
3256
+ initialValue,
3257
+ validate: (input) => input?.trim() ? void 0 : "Required"
3258
+ });
3259
+ if (isCancel5(value)) {
3260
+ cancel2("project init cancelled");
3261
+ process.exit(1);
3262
+ }
3263
+ return value.trim();
3264
+ }
3265
+ function projectInitActionLabel(kind) {
3266
+ switch (kind) {
3267
+ case "registry.upsert":
3268
+ return "Register/update project registry entry";
3269
+ case "copier.copy.commonproject":
3270
+ return "Render CommonProject scaffold";
3271
+ case "project.write-manifest":
3272
+ return "Write repo-local .project.json projection";
3273
+ case "plane.create-or-link":
3274
+ return "Create/link ticket provider project";
3275
+ case "hermes.provision-agent":
3276
+ return "Provision Hermes agent";
3277
+ default:
3278
+ return kind;
3279
+ }
3280
+ }
3281
+ function registryNeedsUpsert(plan) {
3282
+ const registry = loadProjectRegistry(plan.registryPath);
3283
+ const existing = registry.projects[plan.project.slug];
3284
+ if (!existing) return true;
3285
+ const { created_at: _existingCreated, updated_at: _existingUpdated, ...existingComparable } = existing;
3286
+ const { created_at: _projectCreated, updated_at: _projectUpdated, ...projectComparable } = plan.project;
3287
+ return JSON.stringify(existingComparable) !== JSON.stringify(projectComparable);
3288
+ }
3289
+ function actionNeedsRun(plan, kind, syncMode) {
3290
+ if (kind === "registry.upsert") return registryNeedsUpsert(plan);
3291
+ if (kind === "project.write-manifest") {
3292
+ const action = plan.actions.find((item) => item.kind === "project.write-manifest");
3293
+ if (!action || action.kind !== "project.write-manifest") return false;
3294
+ const next = `${JSON.stringify(action.manifest, null, 2)}
3295
+ `;
3296
+ return !existsSync9(action.path) || readFileSync6(action.path, "utf8") !== next;
3297
+ }
3298
+ if (kind === "copier.copy.commonproject") return true;
3299
+ if (kind === "plane.create-or-link") return plan.actions.some((action) => action.kind === kind && action.enabled);
3300
+ if (kind === "hermes.provision-agent") return plan.actions.some((action) => action.kind === kind && action.enabled);
3301
+ return true;
3302
+ }
3303
+ async function selectProjectInitOperations(input) {
3304
+ const planOperations = input.plan.actions.filter((action) => actionNeedsRun(input.plan, action.kind, input.syncMode)).map((action) => ({
3305
+ value: action.kind,
3306
+ label: projectInitActionLabel(action.kind),
3307
+ hint: action.kind === "registry.upsert" ? input.plan.registryPath : action.kind
3308
+ }));
3309
+ const parityOperations = input.auditRules.filter((rule) => rule.fixable && rule.status !== "pass" && rule.status !== "skip").map((rule) => ({
3310
+ value: `parity:${rule.id}`,
3311
+ label: `${rule.title}`,
3312
+ hint: `${rule.id}: ${rule.summary}`
3313
+ }));
3314
+ const operations = [...planOperations, ...parityOperations];
3315
+ const all = operations.map((operation) => operation.value);
3316
+ if (input.options.yes || input.options.apply && !isInteractiveProjectInit(input.options)) {
3317
+ return {
3318
+ selectedOperations: all,
3319
+ selectedParityRules: parityOperations.map((operation) => operation.value.replace(/^parity:/, ""))
3320
+ };
3321
+ }
3322
+ if (input.options.dryRun || !isInteractiveProjectInit(input.options)) {
3323
+ return { selectedOperations: [], selectedParityRules: [] };
3324
+ }
3325
+ if (!operations.length) return { selectedOperations: [], selectedParityRules: [] };
3326
+ const selected = await multiselect({
3327
+ message: "Select project init operations to run:",
3328
+ options: operations,
3329
+ initialValues: all
3330
+ });
3331
+ if (isCancel5(selected)) {
3332
+ cancel2("project init cancelled");
3333
+ process.exit(1);
3334
+ }
3335
+ return {
3336
+ selectedOperations: selected,
3337
+ selectedParityRules: selected.filter((value) => value.startsWith("parity:")).map((value) => value.replace(/^parity:/, ""))
3338
+ };
3339
+ }
3340
+ async function resolveProjectInitTarget(name, options) {
3341
+ const interactive = isInteractiveProjectInit(options);
3342
+ const cwd = process.cwd();
3343
+ const cwdGitRoot = findGitRoot(cwd);
3344
+ let targetDir = options.targetDir ? resolve3(options.targetDir) : void 0;
3345
+ if (!targetDir && cwdGitRoot) {
3346
+ targetDir = cwdGitRoot;
3347
+ }
3348
+ if (!targetDir && interactive) {
3349
+ const defaultName = name ?? basename4(cwd);
3350
+ const promptedName = name ?? await promptTextValue("Project name", packageNameToProjectName(defaultName));
3351
+ const defaultDir = join11(cwd, promptedName.replace(/[^A-Za-z0-9._-]/g, "") || promptedName.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
3352
+ targetDir = await promptTextValue("Project directory", defaultDir);
3353
+ name = promptedName;
3354
+ }
3355
+ if (!targetDir) {
3356
+ if (!name) throw new Error("Project name or --target-dir is required when project init is not run inside a git repo");
3357
+ targetDir = resolve3(process.cwd(), name.replace(/[^A-Za-z0-9._-]/g, "") || name.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
3358
+ }
3359
+ const targetExists = existsSync9(targetDir);
3360
+ if (targetExists && !statSync2(targetDir).isDirectory()) throw new Error(`Target path is not a directory: ${targetDir}`);
3361
+ const targetGitRoot = targetExists ? findGitRoot(targetDir) : void 0;
3362
+ const syncMode = Boolean(targetGitRoot && resolve3(targetGitRoot) === resolve3(targetDir));
3363
+ const defaults = targetExists ? deriveProjectDefaults(targetDir) : { name: packageNameToProjectName(basename4(targetDir)) ?? "Project", description: "" };
3364
+ if (!name && interactive && !syncMode) {
3365
+ name = await promptTextValue("Project name", defaults.name);
3366
+ }
3367
+ return {
3368
+ name: name ?? defaults.name,
3369
+ targetDir,
3370
+ description: options.description ?? defaults.description,
3371
+ syncMode,
3372
+ slug: options.slug ?? defaults.slug,
3373
+ identifier: options.identifier ?? defaults.identifier
3374
+ };
3375
+ }
2406
3376
  var program = new Command3();
2407
3377
  program.name("pjangler").description("Project subsystem bootstrapper CLI").version(PJANGLER_VERSION);
2408
3378
  program.command("init").argument("<subsystem>", "Subsystem to initialize").description("Initialize a project subsystem").option("--dry-run", "Preview changes without writing files").option("-f, --force", "Overwrite existing files").action(async (subsystem, options) => {
@@ -2436,6 +3406,144 @@ program.command("list").description("List available subsystems").action(() => {
2436
3406
  console.log(" pjangler init docker");
2437
3407
  console.log(" pjangler init node");
2438
3408
  });
3409
+ var projectCmd = program.command("project").description("Manage the pjangler project registry");
3410
+ projectCmd.command("init").argument("[name]", "Project display name").description("Plan or apply a registry-backed CommonProject initialization or legacy repo sync").option("--description <text>", "Project description").option("--target-dir <path>", "Target repo path").option("--source-skill <path>", "Source skill/template provenance path").option("--primary-language <language>", "Primary language for CommonProject rendering", "python").option("--provision-agent", "Plan local Hermes PM agent provisioning").option("--agent-role <role>", "Hermes agent role to plan when --provision-agent is set", "pm").option("--apply", "Write the registry and render the repo scaffold").option("--dry-run", "Preview changes without writing files (default)").option("--live", "Allow live/network/cloud provisioning actions").option("--slug <slug>", "Project registry slug override").option("--identifier <identifier>", "Ticket identifier override").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("-f, --force", "Allow replacing an existing registry entry and re-rendering files").option("-y, --yes", "Apply every proposed operation without prompting").option("--no-tui", "Disable interactive prompts").option("--json", "Output machine-parseable JSON").action(async (name, options) => {
3411
+ try {
3412
+ const target = await resolveProjectInitTarget(name, options);
3413
+ const interactive = isInteractiveProjectInit(options);
3414
+ const apply = Boolean(!options.dryRun && (options.yes || options.apply || interactive));
3415
+ const plan = planProjectInit({
3416
+ name: target.name,
3417
+ description: target.description,
3418
+ targetDir: target.targetDir,
3419
+ sourceSkill: options.sourceSkill,
3420
+ primaryLanguage: options.primaryLanguage,
3421
+ provisionAgent: options.provisionAgent ?? false,
3422
+ agentRole: options.agentRole,
3423
+ apply,
3424
+ live: options.live ?? false,
3425
+ projectSlug: target.slug,
3426
+ projectIdentifier: target.identifier,
3427
+ registryPath: options.registry,
3428
+ force: options.force ?? false,
3429
+ overwrite: options.force ?? false,
3430
+ cwd: process.cwd(),
3431
+ scaffold: !target.syncMode
3432
+ });
3433
+ const audit = target.syncMode ? runAudit(target.targetDir) : void 0;
3434
+ const selection = await selectProjectInitOperations({
3435
+ plan,
3436
+ auditRules: audit?.rules ?? [],
3437
+ syncMode: target.syncMode,
3438
+ options
3439
+ });
3440
+ const selectedPlanActionKinds = new Set(selection.selectedOperations.filter((value) => !value.startsWith("parity:")));
3441
+ const selectedPlan = {
3442
+ ...plan,
3443
+ apply,
3444
+ dryRun: !apply,
3445
+ actions: apply ? plan.actions.filter((action) => selectedPlanActionKinds.has(action.kind)) : plan.actions
3446
+ };
3447
+ if (!apply) {
3448
+ const payload = {
3449
+ ...plan,
3450
+ mode: target.syncMode ? "sync" : "create",
3451
+ audit,
3452
+ proposedOperations: [
3453
+ ...plan.actions.filter((action) => actionNeedsRun(plan, action.kind, target.syncMode)).map((action) => action.kind),
3454
+ ...(audit?.rules ?? []).filter((rule) => rule.fixable && rule.status !== "pass" && rule.status !== "skip").map((rule) => `parity:${rule.id}`)
3455
+ ]
3456
+ };
3457
+ if (options.json) console.log(JSON.stringify(payload, null, 2));
3458
+ else {
3459
+ console.log(formatProjectInitPlan(plan));
3460
+ if (payload.proposedOperations.length) {
3461
+ console.log("Proposed sync operations:");
3462
+ for (const operation of payload.proposedOperations) console.log(` - ${operation}`);
3463
+ } else {
3464
+ console.log("Project is already in parity.");
3465
+ }
3466
+ }
3467
+ return;
3468
+ }
3469
+ const initResult = selectedPlan.actions.length ? executeProjectInitPlan(selectedPlan) : { ok: true, plan: selectedPlan, logs: [], errors: [], changedFiles: [] };
3470
+ const migrationReport = selection.selectedParityRules.length ? runMigrationForRules(selection.selectedParityRules, target.targetDir, false) : void 0;
3471
+ const migrationErrors = migrationReport?.results.filter((result2) => result2.status === "blocked").map((result2) => `${result2.id}: ${result2.summary}`) ?? [];
3472
+ const changedFiles = Array.from(/* @__PURE__ */ new Set([
3473
+ ...initResult.changedFiles,
3474
+ ...migrationReport?.changedFiles ?? []
3475
+ ])).sort();
3476
+ const result = {
3477
+ ok: initResult.ok && (migrationReport?.ok ?? true),
3478
+ mode: target.syncMode ? "sync" : "create",
3479
+ plan: selectedPlan,
3480
+ audit,
3481
+ selectedOperations: selection.selectedOperations,
3482
+ selectedParityRules: selection.selectedParityRules,
3483
+ logs: initResult.logs,
3484
+ errors: [...initResult.errors, ...migrationErrors],
3485
+ changedFiles,
3486
+ migrationReport
3487
+ };
3488
+ if (options.json) {
3489
+ console.log(JSON.stringify(result, null, 2));
3490
+ } else {
3491
+ console.log(formatProjectInitPlan(selectedPlan));
3492
+ for (const line of result.logs) console.log(line);
3493
+ for (const line of result.errors) console.error(line);
3494
+ if (migrationReport) console.log(formatMigrationReport(migrationReport));
3495
+ if (result.ok && changedFiles.length) console.log(`Project synchronized: ${plan.project.slug}`);
3496
+ if (result.ok && changedFiles.length === 0) console.log(`Project already in parity: ${plan.project.slug}`);
3497
+ }
3498
+ process.exitCode = result.ok ? 0 : 1;
3499
+ } catch (err) {
3500
+ if (options.json) {
3501
+ console.log(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }, null, 2));
3502
+ } else {
3503
+ console.error("\u274C project init failed:", err instanceof Error ? err.message : err);
3504
+ }
3505
+ process.exit(1);
3506
+ }
3507
+ });
3508
+ projectCmd.command("list").description("List projects in the pjangler registry").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("--json", "Output machine-parseable JSON").action((options) => {
3509
+ try {
3510
+ const registry = loadProjectRegistry(options.registry ?? projectRegistryPath());
3511
+ if (options.json) console.log(JSON.stringify(registry, null, 2));
3512
+ else console.log(formatProjectList(registry));
3513
+ } catch (err) {
3514
+ console.error("\u274C project list failed:", err instanceof Error ? err.message : err);
3515
+ process.exit(1);
3516
+ }
3517
+ });
3518
+ projectCmd.command("show").argument("<slug>", "Project slug").description("Show one project from the pjangler registry").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("--json", "Output machine-parseable JSON").action((slug, options) => {
3519
+ try {
3520
+ const project = getProject(loadProjectRegistry(options.registry ?? projectRegistryPath()), slug);
3521
+ if (options.json) console.log(JSON.stringify(project, null, 2));
3522
+ else console.log(`${project.name} (${project.slug})
3523
+ ${project.repo_path}
3524
+ ${project.description}`);
3525
+ } catch (err) {
3526
+ console.error("\u274C project show failed:", err instanceof Error ? err.message : err);
3527
+ process.exit(1);
3528
+ }
3529
+ });
3530
+ projectCmd.command("doctor").argument("[slug]", "Optional project slug").description("Validate the project registry and local projections").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("--json", "Output machine-parseable JSON").action((slug, options) => {
3531
+ try {
3532
+ const report = doctorProjectRegistry(options.registry ?? projectRegistryPath(), slug);
3533
+ if (options.json) {
3534
+ console.log(JSON.stringify(report, null, 2));
3535
+ } else if (!report.issues.length) {
3536
+ console.log(`Project registry OK: ${report.registryPath}`);
3537
+ } else {
3538
+ console.log(`Project registry issues: ${report.registryPath}`);
3539
+ for (const issue of report.issues) console.log(` [${issue.level}] ${issue.slug ?? "registry"}: ${issue.message}`);
3540
+ }
3541
+ process.exit(report.ok ? 0 : 1);
3542
+ } catch (err) {
3543
+ console.error("\u274C project doctor failed:", err instanceof Error ? err.message : err);
3544
+ process.exit(1);
3545
+ }
3546
+ });
2439
3547
  var recipeCmd = program.command("recipe").description("Manage pjangler recipes");
2440
3548
  recipeCmd.command("list").description("List all available recipes").action(() => {
2441
3549
  console.log("\u{1F4E6} Available Recipes:");
@@ -2568,24 +3676,50 @@ program.command("audit").argument("[repo]", "Path to repo to audit (default: cwd
2568
3676
  process.exit(1);
2569
3677
  }
2570
3678
  });
2571
- program.command("migrate").argument("[rule-id]", "Rule ID to migrate (omit with --all to apply all)").argument("[repo]", "Path to repo (default: cwd)").description("Idempotent migration recipe for a parity rule (or --all)").option("--all", "Apply every migration recipe in order").option("--dry-run", "Preview changes without writing files").option("--json", "Output machine-parseable JSON").action((ruleId, repo, options) => {
3679
+ program.command("migrate").argument("[rule-id]", "Rule ID to migrate (omit to open interactive rule selector)").argument("[repo]", "Path to repo (default: cwd)").description("Idempotent migration recipe for a parity rule (or open the rule selector)").option("--all", "Apply every migration recipe in order").option("--dry-run", "Preview changes without writing files").option("--json", "Output machine-parseable JSON").action(async (ruleId, repo, options) => {
2572
3680
  try {
2573
3681
  const all = options.all ?? false;
2574
- if (!all && !ruleId) {
2575
- console.error("\u274C Provide a rule-id or use --all");
2576
- process.exit(1);
3682
+ const dryRun = options.dryRun ?? false;
3683
+ if (all) {
3684
+ let actualRepo = repo;
3685
+ if (ruleId && !actualRepo) {
3686
+ actualRepo = ruleId;
3687
+ }
3688
+ const report2 = runMigration(void 0, actualRepo, dryRun, true);
3689
+ printMigrationReport(report2, options.json);
3690
+ process.exit(report2.ok ? 0 : 1);
3691
+ }
3692
+ if (ruleId && repo) {
3693
+ if (!getParityRuleIds().includes(ruleId)) {
3694
+ console.error(`\u274C Unknown parity rule: ${ruleId}`);
3695
+ process.exit(1);
3696
+ }
3697
+ const report2 = runMigration(ruleId, repo, dryRun, false);
3698
+ printMigrationReport(report2, options.json);
3699
+ process.exit(report2.ok ? 0 : 1);
2577
3700
  }
2578
- let actualRuleId = all ? void 0 : ruleId;
2579
- let actualRepo = repo;
2580
- if (all && ruleId && !actualRepo) {
2581
- actualRepo = ruleId;
3701
+ if (ruleId && getParityRuleIds().includes(ruleId)) {
3702
+ const report2 = runMigration(ruleId, void 0, dryRun, false);
3703
+ printMigrationReport(report2, options.json);
3704
+ process.exit(report2.ok ? 0 : 1);
2582
3705
  }
2583
- const report = runMigration(actualRuleId, actualRepo, options.dryRun ?? false, all);
2584
3706
  if (options.json) {
2585
- console.log(JSON.stringify(report, null, 2));
2586
- } else {
2587
- console.log(formatMigrationReport(report));
3707
+ console.error("\u274C JSON output requires a rule-id or --all");
3708
+ process.exit(1);
3709
+ }
3710
+ if (!process.stdin.isTTY) {
3711
+ console.error("\u274C Provide a rule-id, use --all, or run in an interactive terminal");
3712
+ process.exit(1);
3713
+ }
3714
+ const targetRepo = ruleId ?? repo;
3715
+ const audit = runAudit(targetRepo);
3716
+ const ruleIds = await promptForRuleIds(audit.rules);
3717
+ if (!ruleIds.length) {
3718
+ console.log("No rules selected; nothing to migrate.");
3719
+ process.exit(0);
2588
3720
  }
3721
+ const report = runMigrationForRules(ruleIds, targetRepo, dryRun);
3722
+ printMigrationReport(report, false);
2589
3723
  process.exit(report.ok ? 0 : 1);
2590
3724
  } catch (err) {
2591
3725
  console.error("\u274C migrate failed:", err);