@delorenj/pjangler 1.2.1 → 1.2.3

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.
@@ -1,9 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/mcp-server.ts
4
- import { spawnSync as spawnSync5 } from "node:child_process";
5
- import { existsSync as existsSync8, mkdirSync as mkdirSync6, statSync } from "node:fs";
6
- import { basename as basename2, dirname as dirname7, join as join10, resolve as resolve2 } from "node:path";
4
+ import { existsSync as existsSync9, statSync as statSync2 } from "node:fs";
5
+ import { basename as basename4, dirname as dirname8, join as join12, resolve as resolve3 } from "node:path";
7
6
  import { fileURLToPath as fileURLToPath5 } from "node:url";
8
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -45,6 +44,63 @@ var Command = class {
45
44
  }
46
45
  };
47
46
 
47
+ // src/utils/style.ts
48
+ var env = process.env;
49
+ function detectColor() {
50
+ if ("NO_COLOR" in env && env.NO_COLOR !== "") return false;
51
+ const force = env.FORCE_COLOR;
52
+ if (force === "0" || force === "false") return false;
53
+ if (force !== void 0 && force !== "") return true;
54
+ if (env.TERM === "dumb") return false;
55
+ return Boolean(process.stdout.isTTY);
56
+ }
57
+ var colorEnabled = detectColor();
58
+ function sgr(open, close) {
59
+ const prefix = `\x1B[${open}m`;
60
+ const suffix = `\x1B[${close}m`;
61
+ return (value) => colorEnabled ? `${prefix}${value}${suffix}` : String(value);
62
+ }
63
+ var bold = sgr(1, 22);
64
+ var dim = sgr(2, 22);
65
+ var italic = sgr(3, 23);
66
+ var underline = sgr(4, 24);
67
+ var red = sgr(31, 39);
68
+ var green = sgr(32, 39);
69
+ var yellow = sgr(33, 39);
70
+ var blue = sgr(34, 39);
71
+ var magenta = sgr(35, 39);
72
+ var cyan = sgr(36, 39);
73
+ var gray = sgr(90, 39);
74
+ var glyph = {
75
+ pass: "\u2714",
76
+ fail: "\u2716",
77
+ warn: "\u26A0",
78
+ skip: "\u25CB",
79
+ info: "\u2139",
80
+ arrow: "\u21B3",
81
+ bullet: "\u2022",
82
+ dot: "\xB7",
83
+ add: "+",
84
+ chevron: "\u25B8",
85
+ pointer: "\u276F"
86
+ };
87
+ var STATUS_STYLES = {
88
+ pass: { glyph: glyph.pass, color: green, label: "pass" },
89
+ fail: { glyph: glyph.fail, color: red, label: "fail" },
90
+ warn: { glyph: glyph.warn, color: yellow, label: "warn" },
91
+ skip: { glyph: glyph.skip, color: gray, label: "skip" },
92
+ applied: { glyph: glyph.pass, color: green, label: "applied" },
93
+ noop: { glyph: glyph.skip, color: gray, label: "noop" },
94
+ blocked: { glyph: glyph.fail, color: red, label: "blocked" },
95
+ skipped: { glyph: glyph.skip, color: gray, label: "skipped" }
96
+ };
97
+ function statusStyle(status) {
98
+ return STATUS_STYLES[status] ?? { glyph: glyph.dot, color: dim, label: status };
99
+ }
100
+ function joinDot(fragments) {
101
+ return fragments.join(dim(` ${glyph.dot} `));
102
+ }
103
+
48
104
  // src/recipes/Recipe.ts
49
105
  var Recipe = class {
50
106
  context;
@@ -57,26 +113,22 @@ var Recipe = class {
57
113
  return this;
58
114
  }
59
115
  async execute() {
60
- const dryRunPrefix = this.context.dryRun ? "[DRY RUN] " : "";
61
- console.log(`${dryRunPrefix}\u{1F680} Initializing ${this.constructor.name.replace("Recipe", "").toLowerCase()} subsystem...`);
62
- if (this.context.dryRun) {
63
- console.log("\u26A0\uFE0F Dry-run mode: No files will be modified");
64
- console.log("");
65
- }
116
+ const subsystem = this.constructor.name.replace("Recipe", "").toLowerCase();
117
+ const dryRun = this.context.dryRun;
118
+ console.log("");
119
+ console.log(` ${cyan(bold(glyph.chevron))} ${bold(`Initializing ${subsystem} subsystem`)}${dryRun ? ` ${dim(glyph.dot)} ${yellow("dry run")}` : ""}`);
120
+ console.log("");
66
121
  for (const command of this.ingredients) {
67
122
  const result = await command.invoke();
68
- if (result.success) {
69
- console.log(result.message);
70
- } else {
71
- console.log(result.message);
72
- }
123
+ console.log(result.message.split("\n").map((line) => line ? ` ${line}` : line).join("\n"));
73
124
  }
74
- if (!this.context.dryRun) {
125
+ if (!dryRun) {
75
126
  this.printNextSteps();
76
127
  } else {
77
128
  console.log("");
78
- console.log("\u2713 Dry-run complete - no files were modified");
79
- console.log(" Remove --dry-run flag to apply changes");
129
+ console.log(` ${green(glyph.pass)} ${dim("Dry-run complete \u2014 no files were modified.")}`);
130
+ console.log(` ${dim("Remove --dry-run to apply changes.")}`);
131
+ console.log("");
80
132
  }
81
133
  }
82
134
  };
@@ -219,11 +271,111 @@ if __name__ == "__main__":
219
271
  }
220
272
  };
221
273
 
274
+ // src/commands/AddMiseCodegraphScript.ts
275
+ import { chmodSync } from "fs";
276
+ import { join as join2 } from "path";
277
+ var AddMiseCodegraphScript = class extends Command {
278
+ async invoke() {
279
+ const filePath = ".mise/scripts/codegraph.sh";
280
+ if (this.fileExists(filePath) && !this.context.force) {
281
+ return {
282
+ success: false,
283
+ message: this.formatMessage("\u26A0\uFE0F .mise/scripts/codegraph.sh already exists"),
284
+ filePath
285
+ };
286
+ }
287
+ const content = `#!/usr/bin/env bash
288
+ # Mise enter hook: ensure the CodeGraph CLI is available and initialize the
289
+ # project index. If \`codegraph\` is not installed, this script installs it
290
+ # non-interactively into the project-local .mise/bin directory and retries.
291
+ #
292
+ # This is intended to run from a mise enter hook so onboarding a new host is
293
+ # fully automatic.
294
+
295
+ set -euo pipefail
296
+
297
+ REPO_ROOT="\${MISE_PROJECT_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}"
298
+ PROJECT_BIN_DIR="$REPO_ROOT/.mise/bin"
299
+ mkdir -p "$PROJECT_BIN_DIR"
300
+
301
+ # Install the CodeGraph CLI into the project-local bin directory.
302
+ install_codegraph() {
303
+ echo "[mise] codegraph not found. Installing non-interactively..."
304
+ export CODEGRAPH_BIN_DIR="$PROJECT_BIN_DIR"
305
+ curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh
306
+
307
+ if [ -x "$PROJECT_BIN_DIR/codegraph" ]; then
308
+ export PATH="$PROJECT_BIN_DIR:$PATH"
309
+ else
310
+ echo "[mise] codegraph install did not place a binary at $PROJECT_BIN_DIR/codegraph" >&2
311
+ return 1
312
+ fi
313
+ }
314
+
315
+ # Ensure a codegraph binary is available on PATH.
316
+ ensure_codegraph() {
317
+ if command -v codegraph >/dev/null 2>&1; then
318
+ return 0
319
+ fi
320
+
321
+ # Check the project-local bin dir first (previous install from this hook).
322
+ if [ -x "$PROJECT_BIN_DIR/codegraph" ]; then
323
+ export PATH="$PROJECT_BIN_DIR:$PATH"
324
+ return 0
325
+ fi
326
+
327
+ # Check typical user-level install locations before fetching anything.
328
+ for d in "$HOME/.local/bin" "$HOME/.codegraph/current/bin"; do
329
+ if [ -x "$d/codegraph" ]; then
330
+ export PATH="$d:$PATH"
331
+ return 0
332
+ fi
333
+ done
334
+
335
+ install_codegraph
336
+ }
337
+
338
+ # Attempt to initialize the project graph. If the command is missing, install
339
+ # it and retry once.
340
+ init_project() {
341
+ local err_file
342
+ err_file="$(mktemp)"
343
+ trap 'rm -f "$err_file"' RETURN
344
+
345
+ if codegraph init -i "$REPO_ROOT" 2>"$err_file"; then
346
+ return 0
347
+ fi
348
+
349
+ # If the failure looks like a missing binary, install and retry.
350
+ if grep -qiE 'command not found|not installed|No such file|executable file not found' "$err_file" 2>/dev/null; then
351
+ ensure_codegraph
352
+ codegraph init -i "$REPO_ROOT"
353
+ return 0
354
+ fi
355
+
356
+ cat "$err_file" >&2
357
+ return 1
358
+ }
359
+
360
+ init_project
361
+ `;
362
+ this.writeFile(filePath, content);
363
+ if (!this.context.dryRun) {
364
+ chmodSync(join2(this.context.targetDir, filePath), 493);
365
+ }
366
+ return {
367
+ success: true,
368
+ message: this.formatMessage(this.context.dryRun ? "Would create .mise/scripts/codegraph.sh" : "\u2705 Created .mise/scripts/codegraph.sh"),
369
+ filePath
370
+ };
371
+ }
372
+ };
373
+
222
374
  // src/recipes/MiseRecipe.ts
223
375
  var MiseRecipe = class extends Recipe {
224
376
  constructor(context) {
225
377
  super(context);
226
- this.addIngredient(AddMiseToml).addIngredient(AddDotenv).addIngredient(AddMiseTasksStructure).addIngredient(AddMiseBaseToml).addIngredient(AddMiseBaseScript);
378
+ this.addIngredient(AddMiseToml).addIngredient(AddDotenv).addIngredient(AddMiseTasksStructure).addIngredient(AddMiseBaseToml).addIngredient(AddMiseBaseScript).addIngredient(AddMiseCodegraphScript);
227
379
  }
228
380
  printNextSteps() {
229
381
  console.log("\u{1F389} Mise subsystem initialized successfully!");
@@ -453,19 +605,19 @@ var NodeRecipe = class extends Recipe {
453
605
  // src/commands/hermes/EnsureTemplateConfig.ts
454
606
  import { homedir, platform } from "node:os";
455
607
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
456
- import { join as join2, dirname as dirname2 } from "node:path";
608
+ import { join as join3, dirname as dirname2 } from "node:path";
457
609
  function resolveTemplateConfigPath() {
458
610
  const fromEnv = process.env.HERMES_TEMPLATE_CONFIG;
459
611
  if (fromEnv && fromEnv.trim()) return fromEnv.trim();
460
612
  const xdg = process.env.XDG_CONFIG_HOME?.trim();
461
- const base = xdg && xdg.length ? xdg : join2(homedir(), ".config");
462
- return join2(base, "hermes-agent-template", "config.toml");
613
+ const base = xdg && xdg.length ? xdg : join3(homedir(), ".config");
614
+ return join3(base, "hermes-agent-template", "config.toml");
463
615
  }
464
616
  function detectHermesBin(home) {
465
617
  const candidates = [
466
- join2(home, "code", "hermes-agent", "venv", "bin", "hermes"),
467
- join2(home, "code", "hermes-agent", ".venv", "bin", "hermes"),
468
- join2(home, ".local", "bin", "hermes")
618
+ join3(home, "code", "hermes-agent", "venv", "bin", "hermes"),
619
+ join3(home, "code", "hermes-agent", ".venv", "bin", "hermes"),
620
+ join3(home, ".local", "bin", "hermes")
469
621
  ];
470
622
  for (const c of candidates) {
471
623
  if (existsSync2(c)) return c;
@@ -475,9 +627,9 @@ function detectHermesBin(home) {
475
627
  function renderHostConfig() {
476
628
  const home = homedir();
477
629
  const hermesBin = detectHermesBin(home);
478
- const hermesRepo = join2(home, "code", "hermes-agent");
479
- const scaffoldDir = join2(home, "code", "hermes-agent-template", "runtime-scaffold");
480
- const skillsDir = join2(home, ".agents", "skills");
630
+ const hermesRepo = join3(home, "code", "hermes-agent");
631
+ const scaffoldDir = join3(home, "code", "hermes-agent-template", "runtime-scaffold");
632
+ const skillsDir = join3(home, ".agents", "skills");
481
633
  return `# hermes-agent-template \u2014 host configuration
482
634
  # Bootstrapped by \`pjangler config bootstrap\` for $HOME=${home} (platform=${platform()}).
483
635
  #
@@ -544,7 +696,7 @@ var EnsureTemplateConfig = class extends Command {
544
696
  };
545
697
 
546
698
  // src/commands/hermes/PromptForAgentConfig.ts
547
- import { basename, join as join3 } from "node:path";
699
+ import { basename, join as join4 } from "node:path";
548
700
  import { readFileSync } from "node:fs";
549
701
  import * as p from "@clack/prompts";
550
702
 
@@ -573,7 +725,7 @@ function deriveProfileName(repo, role) {
573
725
  // src/commands/hermes/PromptForAgentConfig.ts
574
726
  function detectTicketProvider(targetDir) {
575
727
  try {
576
- const t = JSON.parse(readFileSync(join3(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
728
+ const t = JSON.parse(readFileSync(join4(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
577
729
  return t === "plane" || t === "linear" || t === "trello" ? t : void 0;
578
730
  } catch {
579
731
  return void 0;
@@ -709,7 +861,7 @@ var PromptForAgentConfig = class extends Command {
709
861
  // src/commands/hermes/RunCopierTemplate.ts
710
862
  import { spawnSync } from "node:child_process";
711
863
  import { homedir as homedir2 } from "node:os";
712
- import { join as join4, dirname as dirname3 } from "node:path";
864
+ import { join as join5, dirname as dirname3 } from "node:path";
713
865
  import { existsSync as existsSync3, mkdirSync as mkdirSync3 } from "node:fs";
714
866
  import { fileURLToPath } from "node:url";
715
867
  import * as p2 from "@clack/prompts";
@@ -721,8 +873,8 @@ function resolveVendoredTemplate(name) {
721
873
  return void 0;
722
874
  }
723
875
  for (let i = 0; i < 8; i++) {
724
- const candidate = join4(dir, "templates", name);
725
- if (existsSync3(join4(candidate, "copier.yml"))) return candidate;
876
+ const candidate = join5(dir, "templates", name);
877
+ if (existsSync3(join5(candidate, "copier.yml"))) return candidate;
726
878
  const parent = dirname3(dir);
727
879
  if (parent === dir) break;
728
880
  dir = parent;
@@ -741,7 +893,7 @@ var RunCopierTemplate = class extends Command {
741
893
  message: "PromptForAgentConfig must run before RunCopierTemplate (targetRepo/role unset)"
742
894
  };
743
895
  }
744
- const roleDir = join4(ctx.targetDir, "agents", "hermes", role);
896
+ const roleDir = join5(ctx.targetDir, "agents", "hermes", role);
745
897
  ctx.roleDir = roleDir;
746
898
  ctx.runtimeRepo = `delorenj/agent-hm-${targetRepo}-${role}`;
747
899
  const which = spawnSync("which", ["copier"], { encoding: "utf8" });
@@ -751,7 +903,7 @@ var RunCopierTemplate = class extends Command {
751
903
  message: "\u2717 copier not found on PATH. Install with: `uv tool install copier` or `pip install copier`"
752
904
  };
753
905
  }
754
- if (existsSync3(join4(roleDir, "role.yaml")) && !ctx.force) {
906
+ if (existsSync3(join5(roleDir, "role.yaml")) && !ctx.force) {
755
907
  if (ctx.yes) {
756
908
  ctx.force = true;
757
909
  } else {
@@ -768,7 +920,7 @@ var RunCopierTemplate = class extends Command {
768
920
  ctx.force = true;
769
921
  }
770
922
  }
771
- const env = {
923
+ const env2 = {
772
924
  ...process.env,
773
925
  SKIP_TELEGRAM: "1",
774
926
  SKIP_EMAIL: "1",
@@ -778,9 +930,9 @@ var RunCopierTemplate = class extends Command {
778
930
  SKIP_BLOODBANK: ctx.skipBloodbank ? "1" : "0",
779
931
  SKIP_SYSTEMD: ctx.skipSystemd ? "1" : "0"
780
932
  };
781
- const LOCAL_TEMPLATE = join4(homedir2(), "code", "hermes-agent-template");
933
+ const LOCAL_TEMPLATE = join5(homedir2(), "code", "hermes-agent-template");
782
934
  const vendored = resolveVendoredTemplate("hermes-agent");
783
- const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync3(join4(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
935
+ const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync3(join5(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
784
936
  const args = [
785
937
  "copy",
786
938
  templateSrc,
@@ -811,13 +963,13 @@ var RunCopierTemplate = class extends Command {
811
963
  message: this.formatMessage(`Would run: copier ${args.join(" ")}`)
812
964
  };
813
965
  }
814
- mkdirSync3(join4(ctx.targetDir, "agents", "hermes"), { recursive: true });
966
+ mkdirSync3(join5(ctx.targetDir, "agents", "hermes"), { recursive: true });
815
967
  const spinner4 = p2.spinner();
816
968
  spinner4.start(`Running copier copy (target: agents/hermes/${role})`);
817
969
  const result = spawnSync("copier", args, {
818
970
  stdio: "inherit",
819
971
  // pass the interactive output through; copier prints its own progress
820
- env,
972
+ env: env2,
821
973
  cwd: ctx.targetDir
822
974
  });
823
975
  spinner4.stop(result.status === 0 ? "\u2713 copier run complete" : "\u2717 copier failed");
@@ -836,7 +988,7 @@ var RunCopierTemplate = class extends Command {
836
988
 
837
989
  // src/commands/hermes/WireTelegram.ts
838
990
  import { spawnSync as spawnSync2 } from "node:child_process";
839
- import { join as join5 } from "node:path";
991
+ import { join as join6 } from "node:path";
840
992
  import { existsSync as existsSync4, unlinkSync } from "node:fs";
841
993
  import * as p3 from "@clack/prompts";
842
994
  var WireTelegram = class extends Command {
@@ -925,14 +1077,14 @@ var WireTelegram = class extends Command {
925
1077
  if (p3.isCancel(allowedAnswer)) {
926
1078
  return { success: false, message: "\u2717 Aborted; Telegram step deferred." };
927
1079
  }
928
- const script = join5(roleDir, ".scripts", "30-telegram.sh");
1080
+ const script = join6(roleDir, ".scripts", "30-telegram.sh");
929
1081
  if (!existsSync4(script)) {
930
1082
  return {
931
1083
  success: false,
932
1084
  message: `\u2717 ${script} not found. Did copier finish? Re-run with --skip-runtime-repo=0 if you skipped it.`
933
1085
  };
934
1086
  }
935
- const marker = join5(roleDir, ".scripts", ".done-30-telegram");
1087
+ const marker = join6(roleDir, ".scripts", ".done-30-telegram");
936
1088
  if (existsSync4(marker)) unlinkSync(marker);
937
1089
  const spinner4 = p3.spinner();
938
1090
  spinner4.start("Verifying token + wiring profile");
@@ -960,7 +1112,7 @@ function cap(s) {
960
1112
 
961
1113
  // src/commands/hermes/WireEmail.ts
962
1114
  import { spawnSync as spawnSync3 } from "node:child_process";
963
- import { join as join6 } from "node:path";
1115
+ import { join as join7 } from "node:path";
964
1116
  import { existsSync as existsSync5, unlinkSync as unlinkSync2 } from "node:fs";
965
1117
  import * as p4 from "@clack/prompts";
966
1118
  var WireEmail = class extends Command {
@@ -976,7 +1128,7 @@ var WireEmail = class extends Command {
976
1128
  if (!targetRepo || !role || !roleDir) {
977
1129
  return { success: false, message: "Cannot wire email: missing target_repo/role/roleDir" };
978
1130
  }
979
- const script = join6(roleDir, ".scripts", "50-email.sh");
1131
+ const script = join7(roleDir, ".scripts", "50-email.sh");
980
1132
  if (!existsSync5(script)) {
981
1133
  return { success: false, message: `\u2717 ${script} not found` };
982
1134
  }
@@ -1039,7 +1191,7 @@ var WireEmail = class extends Command {
1039
1191
  }
1040
1192
  }
1041
1193
  }
1042
- const marker = join6(roleDir, ".scripts", ".done-50-email");
1194
+ const marker = join7(roleDir, ".scripts", ".done-50-email");
1043
1195
  if (existsSync5(marker)) unlinkSync2(marker);
1044
1196
  const spinner4 = p4.spinner();
1045
1197
  spinner4.start("Creating Cloudflare Email Routing rule");
@@ -1128,7 +1280,7 @@ var HermesAgentRecipe = class extends Recipe {
1128
1280
 
1129
1281
  // src/commands/AgentHooksCommands.ts
1130
1282
  import { homedir as homedir3 } from "node:os";
1131
- import { join as join7, dirname as dirname4 } from "node:path";
1283
+ import { join as join8, dirname as dirname4 } from "node:path";
1132
1284
  import { existsSync as existsSync6, cpSync, mkdirSync as mkdirSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "node:fs";
1133
1285
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1134
1286
  function resolveTemplateRoot() {
@@ -1139,16 +1291,16 @@ function resolveTemplateRoot() {
1139
1291
  try {
1140
1292
  let dir = dirname4(fileURLToPath2(import.meta.url));
1141
1293
  for (let i = 0; i < 8; i++) {
1142
- candidates.push(join7(dir, "templates", "commonproject", "template"));
1294
+ candidates.push(join8(dir, "templates", "commonproject", "template"));
1143
1295
  const parent = dirname4(dir);
1144
1296
  if (parent === dir) break;
1145
1297
  dir = parent;
1146
1298
  }
1147
1299
  } catch {
1148
1300
  }
1149
- candidates.push(join7(homedir3(), "code", "pjangler", "templates", "commonproject", "template"));
1301
+ candidates.push(join8(homedir3(), "code", "pjangler", "templates", "commonproject", "template"));
1150
1302
  for (const c of candidates) {
1151
- if (existsSync6(join7(c, ".agents", "hooks", "hooks.master.json"))) return c;
1303
+ if (existsSync6(join8(c, ".agents", "hooks", "hooks.master.json"))) return c;
1152
1304
  }
1153
1305
  throw new Error(
1154
1306
  "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
@@ -1172,8 +1324,8 @@ var CopyAgentHooksTree = class extends Command {
1172
1324
  const created = [];
1173
1325
  const skipped = [];
1174
1326
  for (const { rel, dir } of items) {
1175
- const src = join7(templateRoot, rel);
1176
- const dest = join7(this.context.targetDir, rel);
1327
+ const src = join8(templateRoot, rel);
1328
+ const dest = join8(this.context.targetDir, rel);
1177
1329
  if (!existsSync6(src)) continue;
1178
1330
  if (existsSync6(dest) && !this.context.force) {
1179
1331
  skipped.push(rel);
@@ -1198,7 +1350,7 @@ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
1198
1350
  static CR = "{{config_root}}";
1199
1351
  // mise's own runtime var — emitted literally
1200
1352
  async invoke() {
1201
- const misePath = join7(this.context.targetDir, "mise.toml");
1353
+ const misePath = join8(this.context.targetDir, "mise.toml");
1202
1354
  if (!existsSync6(misePath)) {
1203
1355
  return {
1204
1356
  success: false,
@@ -1319,7 +1471,7 @@ var RECIPE_REGISTRY = {
1319
1471
  name: "mise",
1320
1472
  description: "Mise task runner and environment setup",
1321
1473
  class: MiseRecipe,
1322
- commands: ["AddMiseToml", "AddDotenv", "AddMiseTasksStructure", "AddMiseBaseToml", "AddMiseBaseScript"]
1474
+ commands: ["AddMiseToml", "AddDotenv", "AddMiseTasksStructure", "AddMiseBaseToml", "AddMiseBaseScript", "AddMiseCodegraphScript"]
1323
1475
  },
1324
1476
  docker: {
1325
1477
  name: "docker",
@@ -1409,6 +1561,12 @@ var COMMAND_REGISTRY = {
1409
1561
  group: "mise",
1410
1562
  class: AddMiseBaseScript
1411
1563
  },
1564
+ AddMiseCodegraphScript: {
1565
+ name: "AddMiseCodegraphScript",
1566
+ description: "Create .mise/scripts/codegraph.sh enter hook",
1567
+ group: "mise",
1568
+ class: AddMiseCodegraphScript
1569
+ },
1412
1570
  AddDotenv: {
1413
1571
  name: "AddDotenv",
1414
1572
  description: "Create .env.example file",
@@ -1430,14 +1588,14 @@ function createRecipe(name, context) {
1430
1588
 
1431
1589
  // src/utils/version.ts
1432
1590
  import { readFileSync as readFileSync3 } from "node:fs";
1433
- import { dirname as dirname5, join as join8 } from "node:path";
1591
+ import { dirname as dirname5, join as join9 } from "node:path";
1434
1592
  import { fileURLToPath as fileURLToPath3 } from "node:url";
1435
1593
  var PJANGLER_VERSION = (() => {
1436
1594
  try {
1437
1595
  let dir = dirname5(fileURLToPath3(import.meta.url));
1438
1596
  for (let i = 0; i < 4; i++) {
1439
1597
  try {
1440
- const raw = readFileSync3(join8(dir, "package.json"), "utf8");
1598
+ const raw = readFileSync3(join9(dir, "package.json"), "utf8");
1441
1599
  return JSON.parse(raw).version ?? "0.0.0";
1442
1600
  } catch {
1443
1601
  const parent = dirname5(dir);
@@ -1451,8 +1609,8 @@ var PJANGLER_VERSION = (() => {
1451
1609
  })();
1452
1610
 
1453
1611
  // src/parity/index.ts
1454
- import { existsSync as existsSync7, lstatSync, mkdirSync as mkdirSync5, readFileSync as readFileSync4, readlinkSync, readdirSync, renameSync, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4, chmodSync, copyFileSync } from "node:fs";
1455
- import { dirname as dirname6, join as join9, relative, resolve } from "node:path";
1612
+ import { existsSync as existsSync7, lstatSync, mkdirSync as mkdirSync5, readFileSync as readFileSync4, readlinkSync, readdirSync, renameSync, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4, chmodSync as chmodSync2, copyFileSync } from "node:fs";
1613
+ import { basename as basename2, dirname as dirname6, join as join10, relative, resolve } from "node:path";
1456
1614
  import { fileURLToPath as fileURLToPath4 } from "node:url";
1457
1615
  import { homedir as homedir4 } from "node:os";
1458
1616
  import { spawnSync as spawnSync4 } from "node:child_process";
@@ -1472,6 +1630,28 @@ enter = [
1472
1630
  patterns = ["AGENTS.md"]
1473
1631
  task = "link-agentfiles"
1474
1632
 
1633
+ [tasks.link-agentfiles]
1634
+ description = "Symlink all agent files to AGENTS.md"
1635
+ run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
1636
+ var LINK_AGENTFILES_HOOK_ENTRIES = [
1637
+ "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1638
+ "op inject -i .env.op > .env"
1639
+ ];
1640
+ var LINK_AGENTFILES_HOOKS_BLOCK = `# This block will handle the linking of
1641
+ # agent files to the main AGENTS.md file.
1642
+ #
1643
+ # TODO: Ensure this works for all levels of nesting.
1644
+ # i.e. All linked agent files MUST be siblings at
1645
+ # any given level of nesting.
1646
+ [hooks]
1647
+ enter = [
1648
+ "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1649
+ "op inject -i .env.op > .env",
1650
+ ]`;
1651
+ var LINK_AGENTFILES_WATCH_TASK_BLOCK = `[[watch_files]]
1652
+ patterns = ["AGENTS.md"]
1653
+ task = "link-agentfiles"
1654
+
1475
1655
  [tasks.link-agentfiles]
1476
1656
  description = "Symlink all agent files to AGENTS.md"
1477
1657
  run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
@@ -1504,7 +1684,7 @@ run = "{{config_root}}/.mise/scripts/versioning.sh sync"
1504
1684
  function resolvePjanglerRoot() {
1505
1685
  let dir = dirname6(fileURLToPath4(import.meta.url));
1506
1686
  while (dir !== dirname6(dir)) {
1507
- if (existsSync7(join9(dir, "package.json")) && existsSync7(join9(dir, "templates", "commonproject", "copier.yml"))) {
1687
+ if (existsSync7(join10(dir, "package.json")) && existsSync7(join10(dir, "templates", "commonproject", "copier.yml"))) {
1508
1688
  return dir;
1509
1689
  }
1510
1690
  dir = dirname6(dir);
@@ -1567,10 +1747,10 @@ function ensureSymlink(path, target, dryRun) {
1567
1747
  return { changed: true };
1568
1748
  }
1569
1749
  function bootstrapAgentsFile(repoRoot, dryRun) {
1570
- const agentsPath = join9(repoRoot, "AGENTS.md");
1750
+ const agentsPath = join10(repoRoot, "AGENTS.md");
1571
1751
  if (existsSync7(agentsPath)) return { changedFiles: [], details: [] };
1572
1752
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
1573
- const source = join9(repoRoot, file);
1753
+ const source = join10(repoRoot, file);
1574
1754
  if (!existsSync7(source)) continue;
1575
1755
  const stat = lstatSync(source);
1576
1756
  if (stat.isSymbolicLink()) continue;
@@ -1580,7 +1760,7 @@ function bootstrapAgentsFile(repoRoot, dryRun) {
1580
1760
  }
1581
1761
  return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
1582
1762
  }
1583
- const readmePath = join9(repoRoot, "README.md");
1763
+ const readmePath = join10(repoRoot, "README.md");
1584
1764
  if (existsSync7(readmePath)) {
1585
1765
  const stat = lstatSync(readmePath);
1586
1766
  if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
@@ -1620,11 +1800,11 @@ function yamlGet(text3, keyPath) {
1620
1800
  return "";
1621
1801
  }
1622
1802
  function discoverRoles(repoRoot) {
1623
- const rolesDir = join9(repoRoot, "agents", "hermes");
1803
+ const rolesDir = join10(repoRoot, "agents", "hermes");
1624
1804
  if (!existsSync7(rolesDir)) return [];
1625
1805
  return readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
1626
- const roleDir = join9(rolesDir, entry.name);
1627
- const roleYamlPath = join9(roleDir, "role.yaml");
1806
+ const roleDir = join10(rolesDir, entry.name);
1807
+ const roleYamlPath = join10(roleDir, "role.yaml");
1628
1808
  if (!existsSync7(roleYamlPath)) return null;
1629
1809
  const text3 = readText(roleYamlPath);
1630
1810
  const runtimeRepoRaw = yamlGet(text3, "runtime.github_repo");
@@ -1649,7 +1829,7 @@ function discoverRoles(repoRoot) {
1649
1829
  }).filter((value) => Boolean(value));
1650
1830
  }
1651
1831
  function registryPath(homeDir) {
1652
- return join9(homeDir, ".hermes", "agents-registry.yaml");
1832
+ return join10(homeDir, ".hermes", "agents-registry.yaml");
1653
1833
  }
1654
1834
  function systemctlUser(args) {
1655
1835
  const result = spawnSync4("systemctl", ["--user", ...args], { encoding: "utf8" });
@@ -1660,7 +1840,7 @@ function systemctlUser(args) {
1660
1840
  };
1661
1841
  }
1662
1842
  function templateScript(ctx, name) {
1663
- const source = join9(ctx.pjanglerRoot, ".mise", "scripts", name);
1843
+ const source = join10(ctx.pjanglerRoot, ".mise", "scripts", name);
1664
1844
  return existsSync7(source) ? readText(source) : void 0;
1665
1845
  }
1666
1846
  function templateVersioningScript(ctx) {
@@ -1669,8 +1849,24 @@ function templateVersioningScript(ctx) {
1669
1849
  function templateLinkAgentfilesScript(ctx) {
1670
1850
  return templateScript(ctx, "link-agentfiles.sh");
1671
1851
  }
1852
+ function renderGeneratedProjectMiseToml(ctx, template) {
1853
+ const project = readProjectJson(ctx);
1854
+ const projectName = String(project?.project_name ?? basename2(ctx.repoRoot) ?? "project");
1855
+ return template.replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
1856
+ }
1857
+ function ensureMiseTomlFromTemplate(ctx, changedFiles) {
1858
+ const targetPath = join10(ctx.repoRoot, "mise.toml");
1859
+ if (existsSync7(targetPath)) return false;
1860
+ const sourcePath = join10(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
1861
+ if (!existsSync7(sourcePath)) return false;
1862
+ changedFiles.push(targetPath);
1863
+ if (!ctx.dryRun) {
1864
+ writeText(targetPath, renderGeneratedProjectMiseToml(ctx, readText(sourcePath)));
1865
+ }
1866
+ return true;
1867
+ }
1672
1868
  function templateVersionFilesConf(ctx, repoRoot) {
1673
- const packageJson = join9(repoRoot, "package.json");
1869
+ const packageJson = join10(repoRoot, "package.json");
1674
1870
  return existsSync7(packageJson) ? "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\njson package.json\ngittag .\n" : "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\ngittag .\n";
1675
1871
  }
1676
1872
  function replaceOrAppendManagedBlock(text3, startMarker, block, beforePattern) {
@@ -1695,7 +1891,7 @@ var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/herm
1695
1891
  function requiredMisePathEntries(ctx) {
1696
1892
  const required = [...BASE_MISE_PATH_ENTRIES];
1697
1893
  for (const candidate of CONDITIONAL_HERMES_PATHS) {
1698
- if (existsSync7(join9(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
1894
+ if (existsSync7(join10(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
1699
1895
  }
1700
1896
  return required;
1701
1897
  }
@@ -1724,25 +1920,127 @@ ${text3.replace(/^\s+/, "")}`;
1724
1920
  if (pathLine[0] === nextLine) return text3;
1725
1921
  return `${prefix}${section.replace(pathLine[0], nextLine)}${suffix}`;
1726
1922
  }
1727
- function upsertLinkAgentfilesBlock(text3, ctx) {
1728
- const withPath = upsertMisePath(text3, requiredMisePathEntries(ctx));
1729
- const existing = /# This block will handle the linking of[\s\S]*?\[tasks\.link-agentfiles\][\s\S]*?run = "\{\{config_root\}\}\/\.mise\/scripts\/link-agentfiles\.sh"/;
1730
- if (existing.test(withPath)) {
1731
- return withPath.replace(existing, LINK_AGENTFILES_BLOCK);
1923
+ function removeTomlSection(text3, headerPattern, marker, options) {
1924
+ const lines = text3.split("\n");
1925
+ let start = -1;
1926
+ let end = -1;
1927
+ for (let i = 0; i < lines.length; i++) {
1928
+ if (!headerPattern.test(lines[i])) continue;
1929
+ if (marker) {
1930
+ let hasMarker = false;
1931
+ for (let j = i + 1; j < lines.length && !/^\[[^\]]+\]/.test(lines[j]); j++) {
1932
+ if (marker.test(lines[j])) {
1933
+ hasMarker = true;
1934
+ break;
1935
+ }
1936
+ }
1937
+ if (!hasMarker) continue;
1938
+ }
1939
+ start = i;
1940
+ for (let j = i + 1; j < lines.length; j++) {
1941
+ if (/^\[[^\]]+\]/.test(lines[j])) {
1942
+ end = j;
1943
+ break;
1944
+ }
1945
+ }
1946
+ if (end === -1) end = lines.length;
1947
+ break;
1732
1948
  }
1733
- const versioningIndex = withPath.indexOf("# >>> mise-versioning >>>");
1949
+ if (start === -1) return text3;
1950
+ if (options?.includePrecedingComments) {
1951
+ while (start > 0 && lines[start - 1].trim().startsWith("#")) {
1952
+ start--;
1953
+ }
1954
+ }
1955
+ const result = lines.slice(0, start).concat(lines.slice(end)).join("\n");
1956
+ return result.replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
1957
+ }
1958
+ function insertTomlBlockBeforeVersioning(text3, block) {
1959
+ const versioningIndex = text3.indexOf("# >>> mise-versioning >>>");
1734
1960
  if (versioningIndex >= 0) {
1735
- return `${withPath.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${LINK_AGENTFILES_BLOCK}
1961
+ return `${text3.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${block}
1736
1962
 
1737
- ${withPath.slice(versioningIndex)}`;
1963
+ ${text3.slice(versioningIndex)}`;
1738
1964
  }
1739
- return `${withPath.replace(/\s*$/, "")}
1965
+ return `${text3.replace(/\s*$/, "")}
1740
1966
 
1741
- ${LINK_AGENTFILES_BLOCK}
1967
+ ${block}
1742
1968
  `;
1743
1969
  }
1970
+ function extractTomlStrings(text3) {
1971
+ const values = [];
1972
+ const stringPattern = /"((?:\\.|[^"\\])*)"|'([^']*)'/g;
1973
+ for (const match of text3.matchAll(stringPattern)) {
1974
+ if (match[1] !== void 0) {
1975
+ try {
1976
+ values.push(JSON.parse(`"${match[1]}"`));
1977
+ } catch {
1978
+ values.push(match[1]);
1979
+ }
1980
+ } else if (match[2] !== void 0) {
1981
+ values.push(match[2]);
1982
+ }
1983
+ }
1984
+ return values;
1985
+ }
1986
+ function isManagedHookEntry(value) {
1987
+ const trimmed = value.trim();
1988
+ return trimmed === "op inject -i .env.op > .env" || /(^|\/)link-agentfiles\.sh$/.test(trimmed);
1989
+ }
1990
+ function renderHookEntries(entries, indent = "") {
1991
+ return [
1992
+ `${indent}enter = [`,
1993
+ ...entries.map((entry) => `${indent} ${JSON.stringify(entry)},`),
1994
+ `${indent}]`
1995
+ ];
1996
+ }
1997
+ function upsertLinkAgentfilesHooks(text3) {
1998
+ const lines = text3.split("\n");
1999
+ const hooksStart = lines.findIndex((line) => /^\[hooks\]$/.test(line.trim()));
2000
+ if (hooksStart === -1) return insertTomlBlockBeforeVersioning(text3, LINK_AGENTFILES_HOOKS_BLOCK);
2001
+ let hooksEnd = lines.length;
2002
+ for (let i = hooksStart + 1; i < lines.length; i++) {
2003
+ if (/^\[[^\]]+\]/.test(lines[i].trim())) {
2004
+ hooksEnd = i;
2005
+ break;
2006
+ }
2007
+ }
2008
+ let enterStart = -1;
2009
+ let enterEnd = -1;
2010
+ for (let i = hooksStart + 1; i < hooksEnd; i++) {
2011
+ if (!/^\s*enter\s*=/.test(lines[i])) continue;
2012
+ enterStart = i;
2013
+ enterEnd = i + 1;
2014
+ const afterEquals = lines[i].slice(lines[i].indexOf("=") + 1);
2015
+ if (afterEquals.includes("[") && !afterEquals.includes("]")) {
2016
+ while (enterEnd < hooksEnd && !lines[enterEnd].includes("]")) enterEnd++;
2017
+ if (enterEnd < hooksEnd) enterEnd++;
2018
+ }
2019
+ break;
2020
+ }
2021
+ const existingBlock = enterStart >= 0 ? lines.slice(enterStart, enterEnd).join("\n") : "";
2022
+ const preserved = extractTomlStrings(existingBlock).filter((entry) => !isManagedHookEntry(entry));
2023
+ const merged = [...LINK_AGENTFILES_HOOK_ENTRIES];
2024
+ for (const entry of preserved) {
2025
+ if (!merged.includes(entry)) merged.push(entry);
2026
+ }
2027
+ const indent = enterStart >= 0 ? lines[enterStart].match(/^\s*/)?.[0] ?? "" : "";
2028
+ const rendered = renderHookEntries(merged, indent);
2029
+ if (enterStart >= 0) {
2030
+ return lines.slice(0, enterStart).concat(rendered, lines.slice(enterEnd)).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
2031
+ }
2032
+ return lines.slice(0, hooksStart + 1).concat(rendered, lines.slice(hooksStart + 1)).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
2033
+ }
2034
+ function upsertLinkAgentfilesBlock(text3, ctx) {
2035
+ const withPath = upsertMisePath(text3, requiredMisePathEntries(ctx));
2036
+ if (withPath.includes(LINK_AGENTFILES_BLOCK)) return withPath;
2037
+ let cleaned = removeTomlSection(withPath, /^\[tasks\.link-agentfiles\]$/, /link-agentfiles/, { includePrecedingComments: false });
2038
+ cleaned = removeTomlSection(cleaned, /^\[\[watch_files\]\]$/, /AGENTS\.md/, { includePrecedingComments: false });
2039
+ cleaned = upsertLinkAgentfilesHooks(cleaned);
2040
+ return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
2041
+ }
1744
2042
  function readProjectJson(ctx) {
1745
- return tryParseJson(safeReadText(join9(ctx.repoRoot, ".project.json")));
2043
+ return tryParseJson(safeReadText(join10(ctx.repoRoot, ".project.json")));
1746
2044
  }
1747
2045
  function canonicalProjectJson(ctx) {
1748
2046
  const roles = discoverRoles(ctx.repoRoot);
@@ -1754,28 +2052,40 @@ function canonicalProjectJson(ctx) {
1754
2052
  workspace: String((existing.ticket_provider?.workspace ?? firstRole?.planeWorkspace ?? "") || ""),
1755
2053
  identifier: String((existing.ticket_provider?.identifier ?? firstRole?.ticketProviderIdentifier ?? "") || ""),
1756
2054
  board_id: String((existing.ticket_provider?.board_id ?? firstRole?.ticketProviderBoardId ?? "") || ""),
1757
- board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || "")
2055
+ board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || ""),
2056
+ state: String((existing.ticket_provider?.state ?? "planned") || "planned")
1758
2057
  };
2058
+ const existingAgents = existing.agents ?? {};
2059
+ const discoveredAgents = Object.fromEntries(
2060
+ roles.map((role) => [
2061
+ role.agentId || `${slug}-${role.role}`,
2062
+ {
2063
+ role: role.role,
2064
+ role_dir: relative(ctx.repoRoot, role.roleDir)
2065
+ }
2066
+ ])
2067
+ );
2068
+ const agents = { ...existingAgents };
2069
+ for (const [agentId, discovered] of Object.entries(discoveredAgents)) {
2070
+ const existingAgent = existingAgents[agentId] ?? {};
2071
+ agents[agentId] = {
2072
+ role: discovered.role,
2073
+ role_dir: discovered.role_dir,
2074
+ provisioning_state: existingAgent.provisioning_state
2075
+ };
2076
+ }
1759
2077
  return {
1760
2078
  project_name: String(existing.project_name ?? titleCaseSlug(slug)),
1761
2079
  project_description: String(existing.project_description ?? ""),
1762
2080
  project_slug: slug,
1763
2081
  repo_path: ctx.repoRoot,
1764
2082
  ticket_provider: ticketProvider,
1765
- agents: Object.fromEntries(
1766
- roles.map((role) => [
1767
- role.agentId || `${slug}-${role.role}`,
1768
- {
1769
- role: role.role,
1770
- role_dir: relative(ctx.repoRoot, role.roleDir)
1771
- }
1772
- ])
1773
- )
2083
+ agents
1774
2084
  };
1775
2085
  }
1776
2086
  function projectJsonFinding(ctx) {
1777
- const projectPath = join9(ctx.repoRoot, ".project.json");
1778
- const planeJsonPath = join9(ctx.repoRoot, ".plane.json");
2087
+ const projectPath = join10(ctx.repoRoot, ".project.json");
2088
+ const planeJsonPath = join10(ctx.repoRoot, ".plane.json");
1779
2089
  const details = [];
1780
2090
  const data = readProjectJson(ctx);
1781
2091
  const roles = discoverRoles(ctx.repoRoot);
@@ -1802,7 +2112,7 @@ function projectJsonFinding(ctx) {
1802
2112
  }
1803
2113
  }
1804
2114
  const ticketProvider = data.ticket_provider ?? {};
1805
- for (const key of ["type", "workspace", "identifier", "board_id", "board_url"]) {
2115
+ for (const key of ["type", "workspace", "identifier", "board_id", "board_url", "state"]) {
1806
2116
  if (!(key in ticketProvider)) details.push(`ticket_provider.${key} missing`);
1807
2117
  }
1808
2118
  if (existsSync7(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
@@ -1889,9 +2199,9 @@ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip)
1889
2199
  if (!existsSync7(sourceDir)) return;
1890
2200
  mkdirSync5(targetDir, { recursive: true });
1891
2201
  for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
1892
- const sourcePath = join9(sourceDir, entry.name);
2202
+ const sourcePath = join10(sourceDir, entry.name);
1893
2203
  if (skip?.(sourcePath)) continue;
1894
- const targetPath = join9(targetDir, entry.name);
2204
+ const targetPath = join10(targetDir, entry.name);
1895
2205
  if (entry.isDirectory()) {
1896
2206
  copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
1897
2207
  continue;
@@ -1905,7 +2215,7 @@ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip)
1905
2215
  }
1906
2216
  }
1907
2217
  function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
1908
- const gitmodulesPath = join9(repoRoot, ".gitmodules");
2218
+ const gitmodulesPath = join10(repoRoot, ".gitmodules");
1909
2219
  const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
1910
2220
  const owner = role.runtimeOwner || "delorenj";
1911
2221
  const block = `[submodule "agents/hermes/${role.role}/runtime"]
@@ -2005,13 +2315,13 @@ var RULES = [
2005
2315
  id: "mise.config-root",
2006
2316
  title: "mise config_root + AGENTS link hooks",
2007
2317
  audit: (ctx) => {
2008
- const misePath = join9(ctx.repoRoot, "mise.toml");
2318
+ const misePath = join10(ctx.repoRoot, "mise.toml");
2009
2319
  if (!existsSync7(misePath)) {
2010
2320
  return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
2011
2321
  }
2012
2322
  const text3 = readText(misePath);
2013
2323
  const details = [];
2014
- const linkAgentfilesPath = join9(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2324
+ const linkAgentfilesPath = join10(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2015
2325
  if (!existsSync7(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2016
2326
  const pathValues = [...(text3.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2017
2327
  const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
@@ -2030,19 +2340,26 @@ var RULES = [
2030
2340
  };
2031
2341
  },
2032
2342
  migrate: (ctx, finding) => {
2033
- const path = join9(ctx.repoRoot, "mise.toml");
2343
+ const path = join10(ctx.repoRoot, "mise.toml");
2034
2344
  const changedFiles = [];
2345
+ const details = [];
2035
2346
  if (!existsSync7(path)) {
2036
- return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing; initialize mise first", changedFiles, details: [] };
2347
+ if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2348
+ 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: [] };
2349
+ }
2350
+ details.push("Initialized mise.toml from generated-project template");
2351
+ if (ctx.dryRun) {
2352
+ return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
2353
+ }
2037
2354
  }
2038
2355
  let text3 = readText(path);
2039
2356
  const next = upsertLinkAgentfilesBlock(text3, ctx);
2040
2357
  if (next !== text3) {
2041
- changedFiles.push(path);
2358
+ if (!changedFiles.includes(path)) changedFiles.push(path);
2042
2359
  if (!ctx.dryRun) writeText(path, next);
2043
2360
  text3 = next;
2044
2361
  }
2045
- const linkAgentfilesPath = join9(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2362
+ const linkAgentfilesPath = join10(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2046
2363
  const expectedScript = templateLinkAgentfilesScript(ctx);
2047
2364
  if (expectedScript === void 0) {
2048
2365
  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: [] };
@@ -2051,7 +2368,7 @@ var RULES = [
2051
2368
  changedFiles.push(linkAgentfilesPath);
2052
2369
  if (!ctx.dryRun) {
2053
2370
  writeText(linkAgentfilesPath, expectedScript);
2054
- chmodSync(linkAgentfilesPath, 493);
2371
+ chmodSync2(linkAgentfilesPath, 493);
2055
2372
  }
2056
2373
  }
2057
2374
  return {
@@ -2069,9 +2386,9 @@ var RULES = [
2069
2386
  title: "managed mise versioning block",
2070
2387
  audit: (ctx) => {
2071
2388
  const details = [];
2072
- const misePath = join9(ctx.repoRoot, "mise.toml");
2073
- const versioningPath = join9(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2074
- const manifestPath = join9(ctx.repoRoot, ".mise", "version-files.conf");
2389
+ const misePath = join10(ctx.repoRoot, "mise.toml");
2390
+ const versioningPath = join10(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2391
+ const manifestPath = join10(ctx.repoRoot, ".mise", "version-files.conf");
2075
2392
  const text3 = safeReadText(misePath);
2076
2393
  if (!text3?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
2077
2394
  if (!existsSync7(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
@@ -2087,17 +2404,24 @@ var RULES = [
2087
2404
  },
2088
2405
  migrate: (ctx, finding) => {
2089
2406
  const changedFiles = [];
2090
- const misePath = join9(ctx.repoRoot, "mise.toml");
2407
+ const details = [];
2408
+ const misePath = join10(ctx.repoRoot, "mise.toml");
2091
2409
  if (!existsSync7(misePath)) {
2092
- return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing; cannot inject versioning block", changedFiles, details: [] };
2410
+ if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2411
+ 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: [] };
2412
+ }
2413
+ details.push("Initialized mise.toml from generated-project template");
2414
+ if (ctx.dryRun) {
2415
+ return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
2416
+ }
2093
2417
  }
2094
2418
  const currentMise = readText(misePath);
2095
2419
  const nextMise = replaceOrAppendManagedBlock(currentMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
2096
2420
  if (nextMise !== currentMise) {
2097
- changedFiles.push(misePath);
2421
+ if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
2098
2422
  if (!ctx.dryRun) writeText(misePath, nextMise);
2099
2423
  }
2100
- const versioningPath = join9(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2424
+ const versioningPath = join10(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2101
2425
  const expectedScript = templateVersioningScript(ctx);
2102
2426
  if (expectedScript === void 0) {
2103
2427
  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: [] };
@@ -2106,10 +2430,10 @@ var RULES = [
2106
2430
  changedFiles.push(versioningPath);
2107
2431
  if (!ctx.dryRun) {
2108
2432
  writeText(versioningPath, expectedScript);
2109
- chmodSync(versioningPath, 493);
2433
+ chmodSync2(versioningPath, 493);
2110
2434
  }
2111
2435
  }
2112
- const manifestPath = join9(ctx.repoRoot, ".mise", "version-files.conf");
2436
+ const manifestPath = join10(ctx.repoRoot, ".mise", "version-files.conf");
2113
2437
  const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
2114
2438
  if (safeReadText(manifestPath) !== expectedManifest) {
2115
2439
  changedFiles.push(manifestPath);
@@ -2129,9 +2453,9 @@ var RULES = [
2129
2453
  id: "sot.agent-symlinks",
2130
2454
  title: "AGENTS/CLAUDE/GEMINI symlink contract",
2131
2455
  audit: (ctx) => {
2132
- const agentsPath = join9(ctx.repoRoot, "AGENTS.md");
2456
+ const agentsPath = join10(ctx.repoRoot, "AGENTS.md");
2133
2457
  if (!existsSync7(agentsPath)) {
2134
- const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync7(join9(ctx.repoRoot, file)));
2458
+ const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync7(join10(ctx.repoRoot, file)));
2135
2459
  if (fallbackSources.length === 0) {
2136
2460
  return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
2137
2461
  }
@@ -2146,7 +2470,7 @@ var RULES = [
2146
2470
  }
2147
2471
  const details = [];
2148
2472
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2149
- const full = join9(ctx.repoRoot, file);
2473
+ const full = join10(ctx.repoRoot, file);
2150
2474
  const target = readSymlinkTarget(full);
2151
2475
  if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
2152
2476
  }
@@ -2170,7 +2494,7 @@ var RULES = [
2170
2494
  return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
2171
2495
  }
2172
2496
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2173
- const full = join9(ctx.repoRoot, file);
2497
+ const full = join10(ctx.repoRoot, file);
2174
2498
  const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
2175
2499
  if (result.blocked) blockedDetails.push(result.blocked);
2176
2500
  if (result.changed) changedFiles.push(full);
@@ -2192,7 +2516,7 @@ var RULES = [
2192
2516
  migrate: (ctx, finding) => {
2193
2517
  const changedFiles = [];
2194
2518
  const details = [];
2195
- const path = join9(ctx.repoRoot, ".project.json");
2519
+ const path = join10(ctx.repoRoot, ".project.json");
2196
2520
  const existing = readProjectJson(ctx) ?? {};
2197
2521
  const canonical = canonicalProjectJson(ctx);
2198
2522
  const merged = { ...existing, ...canonical };
@@ -2202,7 +2526,7 @@ var RULES = [
2202
2526
  changedFiles.push(path);
2203
2527
  if (!ctx.dryRun) writeText(path, expected);
2204
2528
  }
2205
- const planeJson = join9(ctx.repoRoot, ".plane.json");
2529
+ const planeJson = join10(ctx.repoRoot, ".plane.json");
2206
2530
  if (existsSync7(planeJson)) {
2207
2531
  const backup = `${planeJson}.migrated-backup`;
2208
2532
  if (existsSync7(backup)) {
@@ -2227,14 +2551,15 @@ var RULES = [
2227
2551
  title: ".env.op + gitignore secrets contract",
2228
2552
  audit: (ctx) => {
2229
2553
  const details = [];
2230
- const envOp = safeReadText(join9(ctx.repoRoot, ".env.op"));
2231
- const gitignore = safeReadText(join9(ctx.repoRoot, ".gitignore"));
2554
+ const envOp = safeReadText(join10(ctx.repoRoot, ".env.op"));
2555
+ const gitignore = safeReadText(join10(ctx.repoRoot, ".gitignore"));
2232
2556
  if (!envOp) {
2233
2557
  details.push(".env.op missing");
2234
2558
  } else {
2235
2559
  const invalidLines = envOp.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).filter((line) => {
2236
2560
  const value = line.slice(line.indexOf("=") + 1).trim();
2237
- return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value);
2561
+ const quotedLiteral = /^"[^"\r\n]*"$/.test(value) || /^'[^'\r\n]*'$/.test(value);
2562
+ return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value) && !quotedLiteral;
2238
2563
  });
2239
2564
  if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals: ${invalidLines.join(", ")}`);
2240
2565
  }
@@ -2253,12 +2578,12 @@ var RULES = [
2253
2578
  migrate: (ctx, finding) => {
2254
2579
  const changedFiles = [];
2255
2580
  const details = [];
2256
- const envOpPath = join9(ctx.repoRoot, ".env.op");
2581
+ const envOpPath = join10(ctx.repoRoot, ".env.op");
2257
2582
  if (!existsSync7(envOpPath)) {
2258
2583
  changedFiles.push(envOpPath);
2259
- if (!ctx.dryRun) writeText(envOpPath, readText(join9(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2584
+ if (!ctx.dryRun) writeText(envOpPath, readText(join10(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2260
2585
  }
2261
- const gitignorePath = join9(ctx.repoRoot, ".gitignore");
2586
+ const gitignorePath = join10(ctx.repoRoot, ".gitignore");
2262
2587
  const gitignore = safeReadText(gitignorePath) ?? "";
2263
2588
  const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
2264
2589
  # NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
@@ -2285,7 +2610,7 @@ var RULES = [
2285
2610
  title: ".copier-answers.yml provenance + drift report",
2286
2611
  audit: (ctx) => {
2287
2612
  const details = [];
2288
- const path = join9(ctx.repoRoot, ".copier-answers.yml");
2613
+ const path = join10(ctx.repoRoot, ".copier-answers.yml");
2289
2614
  const text3 = safeReadText(path);
2290
2615
  const project = readProjectJson(ctx);
2291
2616
  if (!text3) {
@@ -2316,12 +2641,12 @@ var RULES = [
2316
2641
  const changedFiles = [];
2317
2642
  const project = canonicalProjectJson(ctx);
2318
2643
  const text3 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
2319
- _src_path: ${join9(ctx.pjanglerRoot, "templates", "commonproject")}
2644
+ _src_path: ${join10(ctx.pjanglerRoot, "templates", "commonproject")}
2320
2645
  project_description: ${String(project.project_description)}
2321
2646
  project_name: ${String(project.project_name)}
2322
2647
  ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2323
2648
  `;
2324
- const path = join9(ctx.repoRoot, ".copier-answers.yml");
2649
+ const path = join10(ctx.repoRoot, ".copier-answers.yml");
2325
2650
  if (safeReadText(path) !== text3) {
2326
2651
  changedFiles.push(path);
2327
2652
  if (!ctx.dryRun) writeText(path, text3);
@@ -2340,15 +2665,15 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2340
2665
  id: "bmad.scaffold",
2341
2666
  title: "BMAD modules/docs scaffold",
2342
2667
  audit: (ctx) => {
2343
- const sourceRoot = join9(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
2344
- const targetRoot = join9(ctx.repoRoot, "_bmad");
2668
+ const sourceRoot = join10(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
2669
+ const targetRoot = join10(ctx.repoRoot, "_bmad");
2345
2670
  const sentinels = [
2346
- join9("core", "config.yaml"),
2347
- join9("custom", "config.yaml"),
2348
- join9("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
2349
- join9("bmm", "workflows", "workflow-status", "workflow.yaml")
2671
+ join10("core", "config.yaml"),
2672
+ join10("custom", "config.yaml"),
2673
+ join10("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
2674
+ join10("bmm", "workflows", "workflow-status", "workflow.yaml")
2350
2675
  ];
2351
- const missing = sentinels.filter((file) => existsSync7(join9(sourceRoot, file)) && !existsSync7(join9(targetRoot, file)));
2676
+ const missing = sentinels.filter((file) => existsSync7(join10(sourceRoot, file)) && !existsSync7(join10(targetRoot, file)));
2352
2677
  return {
2353
2678
  id: "bmad.scaffold",
2354
2679
  title: "BMAD modules/docs scaffold",
@@ -2360,7 +2685,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2360
2685
  },
2361
2686
  migrate: (ctx, finding) => {
2362
2687
  const changedFiles = [];
2363
- copyMissingRecursive(join9(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join9(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
2688
+ copyMissingRecursive(join10(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join10(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
2364
2689
  return {
2365
2690
  id: finding.id,
2366
2691
  title: finding.title,
@@ -2382,11 +2707,11 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2382
2707
  }
2383
2708
  const details = [];
2384
2709
  for (const rel of ["role.yaml", "SOUL.md", "hermes", ".gitignore", ".scripts/70-systemd.sh", ".scripts/heartbeat.sh", ".scripts/checkpoint.sh", ".runtime-scaffold/README.md", "runtime/memories/MEMORY.md", "runtime/bloodbank-consumer.py"]) {
2385
- if (!existsSync7(join9(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join9(role.roleDir, rel))}`);
2710
+ if (!existsSync7(join10(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join10(role.roleDir, rel))}`);
2386
2711
  }
2387
- const gitmodules = safeReadText(join9(ctx.repoRoot, ".gitmodules")) ?? "";
2712
+ const gitmodules = safeReadText(join10(ctx.repoRoot, ".gitmodules")) ?? "";
2388
2713
  if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing pm runtime submodule entry");
2389
- if (!profileMetaInheritsDefault(join9(role.roleDir, "runtime", "profile.yaml"))) {
2714
+ if (!profileMetaInheritsDefault(join10(role.roleDir, "runtime", "profile.yaml"))) {
2390
2715
  details.push("runtime/profile.yaml missing inherited default config metadata");
2391
2716
  }
2392
2717
  const registry = safeReadText(registryPath(ctx.homeDir));
@@ -2407,21 +2732,21 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2407
2732
  if (!role) {
2408
2733
  return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
2409
2734
  }
2410
- const templateRoleDir = join9(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
2411
- writeIfDifferent(join9(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
2412
- writeIfDifferent(join9(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
2413
- writeIfDifferent(join9(role.roleDir, ".gitignore"), readText(join9(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
2414
- copyMissingRecursive(join9(templateRoleDir, ".runtime-scaffold"), join9(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
2415
- copyMissingRecursive(join9(templateRoleDir, ".runtime-scaffold"), join9(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
2416
- copyMissingRecursive(join9(templateRoleDir, ".scripts"), join9(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
2417
- const promptSource = join9(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
2418
- const promptTarget = join9(role.roleDir, ".scripts", "sentinel.prompt.md");
2735
+ const templateRoleDir = join10(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
2736
+ writeIfDifferent(join10(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
2737
+ writeIfDifferent(join10(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
2738
+ writeIfDifferent(join10(role.roleDir, ".gitignore"), readText(join10(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
2739
+ copyMissingRecursive(join10(templateRoleDir, ".runtime-scaffold"), join10(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
2740
+ copyMissingRecursive(join10(templateRoleDir, ".runtime-scaffold"), join10(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
2741
+ copyMissingRecursive(join10(templateRoleDir, ".scripts"), join10(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
2742
+ const promptSource = join10(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
2743
+ const promptTarget = join10(role.roleDir, ".scripts", "sentinel.prompt.md");
2419
2744
  if (existsSync7(promptSource) && !existsSync7(promptTarget)) {
2420
2745
  const prompt = readText(promptSource).replace(/\{\{ agent_id \}\}/g, role.agentId).replace(/\{\{ role \}\}/g, role.role).replace(/\{\{ target_repo \}\}/g, role.repo).replace(/\{\{ display_name \}\}/g, role.displayName || role.agentId);
2421
2746
  writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
2422
2747
  }
2423
2748
  upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
2424
- const profileMetaUpdated = upsertInheritedProfileMeta(join9(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
2749
+ const profileMetaUpdated = upsertInheritedProfileMeta(join10(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
2425
2750
  if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
2426
2751
  const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
2427
2752
  if (registryUpdated) details.push(`updated ${registryUpdated}`);
@@ -2475,9 +2800,9 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2475
2800
  return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
2476
2801
  }
2477
2802
  for (const role of roles) {
2478
- const sysDir = join9(ctx.homeDir, ".config", "systemd", "user");
2803
+ const sysDir = join10(ctx.homeDir, ".config", "systemd", "user");
2479
2804
  const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
2480
- const allUnitsPresent = units.every((unit) => existsSync7(join9(sysDir, unit)));
2805
+ const allUnitsPresent = units.every((unit) => existsSync7(join10(sysDir, unit)));
2481
2806
  if (allUnitsPresent) {
2482
2807
  if (ctx.dryRun) {
2483
2808
  details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
@@ -2489,7 +2814,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2489
2814
  }
2490
2815
  continue;
2491
2816
  }
2492
- for (const script of [join9(role.roleDir, ".scripts", "70-systemd.sh")]) {
2817
+ for (const script of [join10(role.roleDir, ".scripts", "70-systemd.sh")]) {
2493
2818
  if (!script || !existsSync7(script)) continue;
2494
2819
  if (ctx.dryRun) {
2495
2820
  details.push(`would run: bash ${script}`);
@@ -2517,7 +2842,7 @@ function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
2517
2842
  changedFiles.push(path);
2518
2843
  if (!dryRun) {
2519
2844
  writeText(path, normalized);
2520
- if (mode) chmodSync(path, mode);
2845
+ if (mode) chmodSync2(path, mode);
2521
2846
  }
2522
2847
  }
2523
2848
  function getParityRuleIds() {
@@ -2539,7 +2864,7 @@ function runAudit(repoArg) {
2539
2864
  rules
2540
2865
  };
2541
2866
  }
2542
- function runMigration(selector, repoArg, dryRun, all) {
2867
+ function runMigrationForRules(ruleIds, repoArg, dryRun) {
2543
2868
  const pjanglerRoot = resolvePjanglerRoot();
2544
2869
  const ctx = {
2545
2870
  repoRoot: resolve(repoArg ?? process.cwd()),
@@ -2547,9 +2872,9 @@ function runMigration(selector, repoArg, dryRun, all) {
2547
2872
  pjanglerRoot,
2548
2873
  homeDir: homedir4()
2549
2874
  };
2550
- const selected = all ? RULES : RULES.filter((rule) => rule.id === selector);
2875
+ const selected = RULES.filter((rule) => ruleIds.includes(rule.id));
2551
2876
  if (!selected.length) {
2552
- throw new Error(`Unknown parity rule: ${selector}`);
2877
+ throw new Error(`Unknown parity rules: ${ruleIds.join(", ")}`);
2553
2878
  }
2554
2879
  const results = selected.map((rule) => {
2555
2880
  try {
@@ -2575,14 +2900,391 @@ function runMigration(selector, repoArg, dryRun, all) {
2575
2900
  changedFiles
2576
2901
  };
2577
2902
  }
2903
+ function runMigration(selector, repoArg, dryRun, all) {
2904
+ const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
2905
+ return runMigrationForRules(ruleIds, repoArg, dryRun);
2906
+ }
2907
+ function prettyTimestamp(iso) {
2908
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/.exec(iso);
2909
+ return match ? `${match[1]} ${match[2]} UTC` : iso;
2910
+ }
2578
2911
  function formatAuditReport(report) {
2579
- const lines = [`repo: ${report.repo}`, `ok: ${report.ok}`, `audited_at: ${report.auditedAt}`, "rules:"];
2912
+ const counts = {};
2913
+ for (const rule of report.rules) counts[rule.status] = (counts[rule.status] ?? 0) + 1;
2914
+ const idWidth = report.rules.reduce((width, rule) => Math.max(width, rule.id.length), 0);
2915
+ const tally = [];
2916
+ if (counts.pass) tally.push(green(`${counts.pass} passed`));
2917
+ if (counts.fail) tally.push(red(`${counts.fail} failed`));
2918
+ if (counts.warn) tally.push(yellow(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`));
2919
+ if (counts.skip) tally.push(gray(`${counts.skip} skipped`));
2920
+ const overall = report.ok ? `${green(glyph.pass)} ${bold("Parity audit passed")}` : `${red(glyph.fail)} ${bold("Parity audit failed")}`;
2921
+ const lines = [""];
2922
+ lines.push(` ${overall}${tally.length ? ` ${dim(glyph.dot)} ${joinDot(tally)}` : ""}`);
2923
+ lines.push(` ${dim(report.repo)} ${dim(glyph.dot)} ${dim(prettyTimestamp(report.auditedAt))}`);
2924
+ lines.push("");
2580
2925
  for (const rule of report.rules) {
2581
- lines.push(`- ${rule.id} [${rule.status}] ${rule.summary}`);
2582
- for (const detail of rule.details) lines.push(` - ${detail}`);
2926
+ const style = statusStyle(rule.status);
2927
+ lines.push(` ${style.color(style.glyph)} ${style.color(rule.id.padEnd(idWidth))} ${rule.summary}`);
2928
+ for (const detail of rule.details) lines.push(` ${dim(glyph.arrow)} ${dim(detail)}`);
2929
+ }
2930
+ lines.push("");
2931
+ return lines.join("\n");
2932
+ }
2933
+
2934
+ // src/project/index.ts
2935
+ import { spawnSync as spawnSync5 } from "node:child_process";
2936
+ import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync5, renameSync as renameSync2, statSync, writeFileSync as writeFileSync5 } from "node:fs";
2937
+ import { homedir as homedir5 } from "node:os";
2938
+ import { basename as basename3, dirname as dirname7, join as join11, resolve as resolve2 } from "node:path";
2939
+ import YAML from "yaml";
2940
+ var PROJECT_REGISTRY_ENV = "PJ_PROJECT_REGISTRY";
2941
+ var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
2942
+ var KNOWN_SKILL_ROOTS = [
2943
+ "/home/delorenj/code/skillex/all-skills",
2944
+ "/home/delorenj/code/CoachingAgentFramework/.agents/skills",
2945
+ "/home/delorenj/code/pjangler/.agents/skills",
2946
+ join11(homedir5(), ".codex", "skills")
2947
+ ];
2948
+ function projectRegistryPath(env2 = process.env) {
2949
+ return expandHome(env2[PROJECT_REGISTRY_ENV] || join11(homedir5(), ".config", "pjangler", "projects.yaml"));
2950
+ }
2951
+ function emptyProjectRegistry() {
2952
+ return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
2953
+ }
2954
+ function loadProjectRegistry(path = projectRegistryPath()) {
2955
+ if (!existsSync8(path)) return emptyProjectRegistry();
2956
+ const raw = YAML.parse(readFileSync5(path, "utf8"));
2957
+ if (raw == null) return emptyProjectRegistry();
2958
+ if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
2959
+ const registry = raw;
2960
+ const normalized = {
2961
+ schema_version: Number(registry.schema_version ?? PROJECT_REGISTRY_SCHEMA_VERSION),
2962
+ projects: isRecord(registry.projects) ? registry.projects : {}
2963
+ };
2964
+ validateProjectRegistry(normalized);
2965
+ return normalized;
2966
+ }
2967
+ function saveProjectRegistry(registry, path = projectRegistryPath()) {
2968
+ validateProjectRegistry(registry);
2969
+ mkdirSync6(dirname7(path), { recursive: true });
2970
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
2971
+ writeFileSync5(temp, YAML.stringify(registry, { lineWidth: 0 }), "utf8");
2972
+ renameSync2(temp, path);
2973
+ }
2974
+ function validateProjectRegistry(registry) {
2975
+ if (registry.schema_version !== PROJECT_REGISTRY_SCHEMA_VERSION) {
2976
+ throw new Error(`Unsupported project registry schema_version: ${registry.schema_version}`);
2977
+ }
2978
+ if (!isRecord(registry.projects)) throw new Error("Project registry projects must be a mapping");
2979
+ const slugs = /* @__PURE__ */ new Set();
2980
+ const repoPaths = /* @__PURE__ */ new Map();
2981
+ const identifiers = /* @__PURE__ */ new Map();
2982
+ for (const [slug, project] of Object.entries(registry.projects)) {
2983
+ validateProjectRecord(project, slug);
2984
+ if (slugs.has(project.slug)) throw new Error(`Duplicate project slug: ${project.slug}`);
2985
+ slugs.add(project.slug);
2986
+ const repoKey = resolve2(project.repo_path);
2987
+ const existingRepoSlug = repoPaths.get(repoKey);
2988
+ if (existingRepoSlug && existingRepoSlug !== slug) {
2989
+ throw new Error(`Duplicate project repo_path: ${project.repo_path} used by ${existingRepoSlug} and ${slug}`);
2990
+ }
2991
+ repoPaths.set(repoKey, slug);
2992
+ const identifier = project.ticket_provider.identifier?.toUpperCase();
2993
+ if (identifier) {
2994
+ const existingIdentifierSlug = identifiers.get(identifier);
2995
+ if (existingIdentifierSlug && existingIdentifierSlug !== slug) {
2996
+ throw new Error(`Duplicate project identifier: ${identifier} used by ${existingIdentifierSlug} and ${slug}`);
2997
+ }
2998
+ identifiers.set(identifier, slug);
2999
+ }
2583
3000
  }
2584
- return `${lines.join("\n")}
3001
+ }
3002
+ function slugifyProjectName(value) {
3003
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
3004
+ }
3005
+ function deriveProjectIdentifier(value) {
3006
+ const compact = value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
3007
+ const identifier = compact.slice(0, 4) || "PROJ";
3008
+ return identifier.length >= 2 ? identifier : `${identifier}XX`.slice(0, 4);
3009
+ }
3010
+ function normalizeAgentRole(value) {
3011
+ return value?.trim() || "pm";
3012
+ }
3013
+ function jsonStable(value) {
3014
+ return JSON.stringify(value);
3015
+ }
3016
+ function projectRecordEquivalent(a, b) {
3017
+ if (!a) return false;
3018
+ const { created_at: _aCreated, updated_at: _aUpdated, ...aComparable } = a;
3019
+ const { created_at: _bCreated, updated_at: _bUpdated, ...bComparable } = b;
3020
+ return jsonStable(aComparable) === jsonStable(bComparable);
3021
+ }
3022
+ function defaultProjectTargetDir(name, cwd = process.cwd()) {
3023
+ const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
3024
+ return resolve2(dirname7(resolve2(cwd)), compactName);
3025
+ }
3026
+ function resolveSourceSkillPath(sourceSkill) {
3027
+ if (!sourceSkill) return void 0;
3028
+ const expanded = expandHome(sourceSkill);
3029
+ const direct = resolve2(expanded);
3030
+ if (existsSync8(direct)) return direct;
3031
+ const name = basename3(sourceSkill);
3032
+ for (const root of KNOWN_SKILL_ROOTS) {
3033
+ const candidate = join11(root, name);
3034
+ if (existsSync8(candidate)) return candidate;
3035
+ }
3036
+ const civilWarLetterifier = "/home/delorenj/code/skillex/all-skills/civilwar-letterifier";
3037
+ const hint = existsSync8(civilWarLetterifier) ? ` Did you mean ${civilWarLetterifier}?` : "";
3038
+ throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
3039
+ }
3040
+ function planProjectInit(input) {
3041
+ if (!input.name.trim()) throw new Error("Project name is required");
3042
+ const registryPath2 = resolve2(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
3043
+ const registry = loadProjectRegistry(registryPath2);
3044
+ const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
3045
+ const slug = input.projectSlug ?? slugifyProjectName(input.name);
3046
+ const targetDir = resolve2(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
3047
+ const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
3048
+ const existing = registry.projects[slug];
3049
+ const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
3050
+ const overwrite = input.overwrite ?? input.force ?? false;
3051
+ const agentRole = normalizeAgentRole(input.agentRole);
3052
+ const agents = input.provisionAgent ? {
3053
+ ...existing?.agents ?? {},
3054
+ [agentRole]: {
3055
+ role: agentRole,
3056
+ provisioning_state: "planned"
3057
+ }
3058
+ } : existing?.agents ?? {};
3059
+ const scaffold = input.scaffold ?? true;
3060
+ const candidateProject = {
3061
+ name: input.name,
3062
+ slug,
3063
+ repo_path: targetDir,
3064
+ description: input.description ?? "",
3065
+ status: "planned",
3066
+ source_artifacts: sourceSkillPath ? [{ kind: "skill", path: sourceSkillPath, package_name: input.packageName ?? slug }] : [],
3067
+ template: {
3068
+ commonproject: {
3069
+ enabled: true,
3070
+ primary_language: input.primaryLanguage ?? "python"
3071
+ }
3072
+ },
3073
+ ticket_provider: {
3074
+ type: input.ticketProvider ?? "plane",
3075
+ workspace: input.planeWorkspace ?? "33god",
3076
+ identifier,
3077
+ board_id: input.planeProjectId ?? "",
3078
+ board_url: input.planeProjectId ? `https://plane.delo.sh/${input.planeWorkspace ?? "33god"}/projects/${input.planeProjectId}/issues/` : "",
3079
+ state: input.live ? "planned" : "planned"
3080
+ },
3081
+ agents,
3082
+ created_at: existing?.created_at ?? now,
3083
+ updated_at: now
3084
+ };
3085
+ const project = {
3086
+ ...candidateProject,
3087
+ updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
3088
+ };
3089
+ validateNoDuplicateProject(registry, project, overwrite);
3090
+ const pjanglerRoot = resolve2(input.pjanglerRoot ?? resolvePjanglerRoot2());
3091
+ const manifest = projectManifestFromRegistryProject(project);
3092
+ const apply = input.apply ?? false;
3093
+ const live = input.live ?? false;
3094
+ const actions = [
3095
+ { kind: "registry.upsert", registryPath: registryPath2, slug, project }
3096
+ ];
3097
+ if (scaffold) {
3098
+ actions.push(buildCommonProjectCopierAction({
3099
+ pjanglerRoot,
3100
+ targetDir,
3101
+ projectName: project.name,
3102
+ projectDescription: project.description,
3103
+ projectSlug: project.slug,
3104
+ ticketProvider: project.ticket_provider.type,
3105
+ planeWorkspace: project.ticket_provider.workspace ?? "33god",
3106
+ planeProjectId: project.ticket_provider.board_id ?? "",
3107
+ projectIdentifier: identifier,
3108
+ primaryLanguage: project.template.commonproject.primary_language,
3109
+ overwrite
3110
+ }));
3111
+ }
3112
+ actions.push(
3113
+ { kind: "project.write-manifest", path: join11(targetDir, ".project.json"), manifest },
3114
+ {
3115
+ kind: "plane.create-or-link",
3116
+ enabled: live,
3117
+ live,
3118
+ workspace: project.ticket_provider.workspace ?? "33god",
3119
+ identifier,
3120
+ state: live ? "planned" : "planned",
3121
+ reason: live ? void 0 : "network/cloud actions require --live"
3122
+ },
3123
+ {
3124
+ kind: "hermes.provision-agent",
3125
+ enabled: input.provisionAgent ?? false,
3126
+ local: !live,
3127
+ targetDir,
3128
+ targetRepo: slug,
3129
+ role: agentRole,
3130
+ context: {
3131
+ skipRuntimeRepo: !live,
3132
+ skipPlane: !live,
3133
+ skipBloodbank: !live,
3134
+ skipSystemd: !live || process.platform === "darwin"
3135
+ }
3136
+ }
3137
+ );
3138
+ return { ok: true, apply, dryRun: !apply, live, registryPath: registryPath2, project, manifest, actions };
3139
+ }
3140
+ function executeProjectInitPlan(plan) {
3141
+ const logs = [];
3142
+ const errors = [];
3143
+ const changedFiles = [];
3144
+ if (!plan.apply) return { ok: true, plan, logs, errors, changedFiles };
3145
+ const registry = loadProjectRegistry(plan.registryPath);
3146
+ let pendingRegistryAction;
3147
+ for (const action of plan.actions) {
3148
+ if (action.kind === "copier.copy.commonproject") {
3149
+ mkdirSync6(dirname7(action.targetDir), { recursive: true });
3150
+ const result = spawnSync5(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
3151
+ if (result.stdout?.trim()) logs.push(result.stdout.trim());
3152
+ if (result.stderr?.trim()) logs.push(result.stderr.trim());
3153
+ if (result.error) {
3154
+ const code = result.error.code;
3155
+ errors.push(
3156
+ code === "ENOENT" ? "copier not found on PATH. Install with: uv tool install copier or pip install copier" : `copier failed: ${result.error.message}`
3157
+ );
3158
+ break;
3159
+ }
3160
+ if (result.status !== 0) {
3161
+ errors.push(`copier exited with status ${result.status ?? "unknown"}`);
3162
+ if (existsSync8(action.targetDir)) changedFiles.push(action.targetDir);
3163
+ break;
3164
+ }
3165
+ changedFiles.push(action.targetDir);
3166
+ } else if (action.kind === "project.write-manifest") {
3167
+ mkdirSync6(dirname7(action.path), { recursive: true });
3168
+ const next = `${JSON.stringify(action.manifest, null, 2)}
2585
3169
  `;
3170
+ const current = existsSync8(action.path) ? readFileSync5(action.path, "utf8") : void 0;
3171
+ if (current !== next) {
3172
+ writeFileSync5(action.path, next, "utf8");
3173
+ changedFiles.push(action.path);
3174
+ }
3175
+ } else if (action.kind === "registry.upsert") {
3176
+ pendingRegistryAction = action;
3177
+ } else if (action.kind === "plane.create-or-link") {
3178
+ logs.push(action.enabled ? "plane.create-or-link requires a live provider integration" : "plane.create-or-link skipped (requires --live)");
3179
+ } else if (action.kind === "hermes.provision-agent") {
3180
+ logs.push(action.enabled ? "hermes.provision-agent planned for the caller to execute" : "hermes.provision-agent skipped");
3181
+ }
3182
+ }
3183
+ if (pendingRegistryAction && errors.length === 0) {
3184
+ if (!projectRecordEquivalent(registry.projects[pendingRegistryAction.slug], pendingRegistryAction.project)) {
3185
+ registry.projects[pendingRegistryAction.slug] = pendingRegistryAction.project;
3186
+ saveProjectRegistry(registry, pendingRegistryAction.registryPath);
3187
+ changedFiles.push(pendingRegistryAction.registryPath);
3188
+ }
3189
+ }
3190
+ return { ok: errors.length === 0, plan, logs, errors, changedFiles };
3191
+ }
3192
+ function projectManifestFromRegistryProject(project) {
3193
+ const agents = Object.fromEntries(
3194
+ Object.entries(project.agents).map(([name, agent]) => [
3195
+ `${project.slug}-${name}`,
3196
+ {
3197
+ role: agent.role,
3198
+ role_dir: agent.role_dir,
3199
+ provisioning_state: agent.provisioning_state
3200
+ }
3201
+ ])
3202
+ );
3203
+ return {
3204
+ project_name: project.name,
3205
+ project_description: project.description,
3206
+ project_slug: project.slug,
3207
+ repo_path: project.repo_path,
3208
+ ticket_provider: {
3209
+ type: project.ticket_provider.type,
3210
+ workspace: project.ticket_provider.workspace ?? "",
3211
+ identifier: project.ticket_provider.identifier ?? "",
3212
+ board_id: project.ticket_provider.board_id ?? "",
3213
+ board_url: project.ticket_provider.board_url ?? "",
3214
+ state: project.ticket_provider.state
3215
+ },
3216
+ agents
3217
+ };
3218
+ }
3219
+ function getProject(registry, slug) {
3220
+ const project = registry.projects[slug];
3221
+ if (!project) throw new Error(`Project not found in registry: ${slug}`);
3222
+ return project;
3223
+ }
3224
+ function buildCommonProjectCopierAction(input) {
3225
+ const templateDir = join11(input.pjanglerRoot, "templates", "commonproject");
3226
+ const data = {
3227
+ project_name: input.projectName,
3228
+ project_description: input.projectDescription ?? "",
3229
+ project_slug: input.projectSlug,
3230
+ ticket_provider: input.ticketProvider,
3231
+ plane_workspace: input.planeWorkspace,
3232
+ plane_project_id: input.planeProjectId ?? "",
3233
+ project_identifier: input.projectIdentifier,
3234
+ primary_language: input.primaryLanguage
3235
+ };
3236
+ const command = ["copier", "copy", "--trust", templateDir, input.targetDir, "--defaults"];
3237
+ for (const [key, value] of Object.entries(data)) command.push("--data", `${key}=${value}`);
3238
+ if (input.overwrite) command.push("--overwrite");
3239
+ return {
3240
+ kind: "copier.copy.commonproject",
3241
+ cwd: input.pjanglerRoot,
3242
+ command,
3243
+ targetDir: input.targetDir,
3244
+ data,
3245
+ overwrite: input.overwrite
3246
+ };
3247
+ }
3248
+ function resolvePjanglerRoot2() {
3249
+ let dir = dirname7(new URL(import.meta.url).pathname);
3250
+ while (dir !== dirname7(dir)) {
3251
+ if (existsSync8(join11(dir, "package.json")) && existsSync8(join11(dir, "templates", "commonproject", "copier.yml"))) return dir;
3252
+ dir = dirname7(dir);
3253
+ }
3254
+ return resolve2(process.cwd());
3255
+ }
3256
+ function validateNoDuplicateProject(registry, project, overwrite) {
3257
+ const existingSameSlug = registry.projects[project.slug];
3258
+ if (existingSameSlug && !overwrite && resolve2(existingSameSlug.repo_path) !== resolve2(project.repo_path)) {
3259
+ throw new Error(`Project slug already exists in registry: ${project.slug}`);
3260
+ }
3261
+ for (const [slug, existing] of Object.entries(registry.projects)) {
3262
+ if (slug === project.slug) continue;
3263
+ if (resolve2(existing.repo_path) === resolve2(project.repo_path)) {
3264
+ throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
3265
+ }
3266
+ if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
3267
+ throw new Error(`Project identifier already registered by ${slug}: ${project.ticket_provider.identifier}`);
3268
+ }
3269
+ }
3270
+ }
3271
+ function validateProjectRecord(project, key) {
3272
+ if (!isRecord(project)) throw new Error(`Project ${key} must be a mapping`);
3273
+ if (!project.name) throw new Error(`Project ${key} missing name`);
3274
+ if (!project.slug) throw new Error(`Project ${key} missing slug`);
3275
+ if (project.slug !== key) throw new Error(`Project key ${key} does not match slug ${project.slug}`);
3276
+ if (!project.repo_path) throw new Error(`Project ${key} missing repo_path`);
3277
+ if (!Array.isArray(project.source_artifacts)) throw new Error(`Project ${key} source_artifacts must be a list`);
3278
+ if (!isRecord(project.ticket_provider)) throw new Error(`Project ${key} ticket_provider must be a mapping`);
3279
+ if (!isRecord(project.agents)) throw new Error(`Project ${key} agents must be a mapping`);
3280
+ }
3281
+ function expandHome(path) {
3282
+ if (path === "~") return homedir5();
3283
+ if (path.startsWith("~/")) return join11(homedir5(), path.slice(2));
3284
+ return path;
3285
+ }
3286
+ function isRecord(value) {
3287
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2586
3288
  }
2587
3289
 
2588
3290
  // src/mcp-server.ts
@@ -2592,22 +3294,22 @@ var server = new McpServer({
2592
3294
  });
2593
3295
  var TICKET_PROVIDER_SCHEMA = z.enum(["plane", "linear", "trello"]);
2594
3296
  function resolveTargetDir(targetDir) {
2595
- const dir = resolve2(targetDir ?? process.cwd());
2596
- if (!existsSync8(dir)) {
3297
+ const dir = resolve3(targetDir ?? process.cwd());
3298
+ if (!existsSync9(dir)) {
2597
3299
  throw new Error(`Target directory does not exist: ${dir}`);
2598
3300
  }
2599
- if (!statSync(dir).isDirectory()) {
3301
+ if (!statSync2(dir).isDirectory()) {
2600
3302
  throw new Error(`Target path is not a directory: ${dir}`);
2601
3303
  }
2602
3304
  return dir;
2603
3305
  }
2604
- function resolvePjanglerRoot2() {
2605
- let dir = dirname7(fileURLToPath5(import.meta.url));
2606
- while (dir !== dirname7(dir)) {
2607
- if (existsSync8(join10(dir, "package.json")) && existsSync8(join10(dir, "templates", "commonproject", "copier.yml"))) {
3306
+ function resolvePjanglerRoot3() {
3307
+ let dir = dirname8(fileURLToPath5(import.meta.url));
3308
+ while (dir !== dirname8(dir)) {
3309
+ if (existsSync9(join12(dir, "package.json")) && existsSync9(join12(dir, "templates", "commonproject", "copier.yml"))) {
2608
3310
  return dir;
2609
3311
  }
2610
- dir = dirname7(dir);
3312
+ dir = dirname8(dir);
2611
3313
  }
2612
3314
  throw new Error("Unable to resolve pjangler root");
2613
3315
  }
@@ -2676,26 +3378,6 @@ async function runRecipeWithCapture(recipeName, context) {
2676
3378
  console.error = origError;
2677
3379
  }
2678
3380
  }
2679
- function buildCommonProjectCopierAction(input) {
2680
- const data = {
2681
- project_name: input.projectName,
2682
- project_description: input.projectDescription ?? "",
2683
- project_slug: input.projectSlug,
2684
- ticket_provider: input.ticketProvider,
2685
- plane_workspace: input.planeWorkspace,
2686
- plane_project_id: input.planeProjectId ?? "",
2687
- project_identifier: input.projectIdentifier,
2688
- primary_language: input.primaryLanguage
2689
- };
2690
- const args = ["copy", "--trust", input.templateDir, input.targetDir, "--defaults"];
2691
- for (const [key, value] of Object.entries(data)) args.push("--data", `${key}=${value}`);
2692
- if (input.overwrite) args.push("--overwrite");
2693
- return {
2694
- kind: "copier.copy.commonproject",
2695
- command: ["copier", ...args],
2696
- data
2697
- };
2698
- }
2699
3381
  server.registerTool(
2700
3382
  "pjangler_list_capabilities",
2701
3383
  {
@@ -2718,7 +3400,7 @@ server.registerTool(
2718
3400
  parityRules: getParityRuleIds(),
2719
3401
  recommendedWorkflows: {
2720
3402
  existingProject: ["pjangler_audit_project", "pjangler_migrate_project"],
2721
- new33godProject: ["pjangler_bootstrap_33god_project", "pjangler_audit_project"],
3403
+ new33godProject: ["pjangler_project_init", "pjangler_bootstrap_33god_project", "pjangler_audit_project"],
2722
3404
  hermesAgentProvisioning: ["pjangler_deploy_hermes_agent", "pjangler_audit_project"]
2723
3405
  },
2724
3406
  skillSynergy: parityGuidance()
@@ -2812,16 +3494,19 @@ server.registerTool(
2812
3494
  local: z.boolean().optional(),
2813
3495
  force: z.boolean().optional(),
2814
3496
  overwrite: z.boolean().optional(),
2815
- dryRun: z.boolean().optional()
3497
+ dryRun: z.boolean().optional(),
3498
+ registryPath: z.string().optional(),
3499
+ sourceSkill: z.string().optional(),
3500
+ live: z.boolean().optional()
2816
3501
  }
2817
3502
  },
2818
3503
  async (input) => {
2819
3504
  try {
2820
- const pjanglerRoot = resolvePjanglerRoot2();
3505
+ const pjanglerRoot = resolvePjanglerRoot3();
2821
3506
  const projectSlug = input.projectSlug ?? slugify(input.projectName);
2822
- const parentDir = resolve2(input.parentDir ?? process.cwd());
2823
- if (!existsSync8(parentDir) || !statSync(parentDir).isDirectory()) throw new Error(`Parent directory does not exist: ${parentDir}`);
2824
- const targetDir = resolve2(input.targetDir ?? join10(parentDir, projectSlug));
3507
+ const parentDir = resolve3(input.parentDir ?? process.cwd());
3508
+ if (!existsSync9(parentDir) || !statSync2(parentDir).isDirectory()) throw new Error(`Parent directory does not exist: ${parentDir}`);
3509
+ const targetDir = resolve3(input.targetDir ?? join12(parentDir, projectSlug));
2825
3510
  const overwrite = input.overwrite ?? input.force ?? false;
2826
3511
  const dryRun = input.dryRun ?? true;
2827
3512
  const local = input.local ?? true;
@@ -2830,57 +3515,31 @@ server.registerTool(
2830
3515
  if (!skipPlane && !planeProjectId) {
2831
3516
  throw new Error("planeProjectId is required when skipPlane=false; keep skipPlane=true for safe local bootstrap");
2832
3517
  }
2833
- if (existsSync8(targetDir) && !overwrite) throw new Error(`Target already exists: ${targetDir} (set force/overwrite=true to re-render)`);
2834
- const templateDir = join10(pjanglerRoot, "templates", "commonproject");
2835
- const copierAction = buildCommonProjectCopierAction({
2836
- templateDir,
3518
+ if (!dryRun && existsSync9(targetDir) && !overwrite) throw new Error(`Target already exists: ${targetDir} (set force/overwrite=true to re-render)`);
3519
+ const plan = planProjectInit({
3520
+ name: input.projectName,
3521
+ description: input.projectDescription,
2837
3522
  targetDir,
2838
- projectName: input.projectName,
2839
- projectDescription: input.projectDescription,
2840
3523
  projectSlug,
3524
+ sourceSkill: input.sourceSkill,
3525
+ primaryLanguage: input.primaryLanguage ?? "python",
3526
+ provisionAgent: input.provisionAgent ?? false,
3527
+ agentRole: input.agentRole ?? "pm",
3528
+ apply: !dryRun,
3529
+ live: input.live ?? false,
3530
+ registryPath: input.registryPath,
3531
+ projectIdentifier: input.projectIdentifier ?? projectSlug.slice(0, 4).toUpperCase(),
2841
3532
  ticketProvider: input.ticketProvider ?? "plane",
2842
3533
  planeWorkspace: input.planeWorkspace ?? "33god",
2843
3534
  planeProjectId,
2844
- projectIdentifier: input.projectIdentifier ?? projectSlug.slice(0, 4).toUpperCase(),
2845
- primaryLanguage: input.primaryLanguage ?? "python",
3535
+ pjanglerRoot,
2846
3536
  overwrite
2847
3537
  });
2848
- const actions = [
2849
- { kind: "ensure.parent", path: dirname7(targetDir) },
2850
- copierAction
2851
- ];
2852
- if (input.provisionAgent) {
2853
- actions.push({
2854
- kind: "pjangler.recipe.hermes-agent",
2855
- targetDir,
2856
- context: {
2857
- targetRepo: projectSlug,
2858
- role: input.agentRole ?? "pm",
2859
- agentPurpose: input.agentPurpose ?? `Project manager for ${input.projectName}`,
2860
- local,
2861
- dryRun,
2862
- force: overwrite,
2863
- skipTelegram: true,
2864
- skipEmail: true,
2865
- skipRuntimeRepo: local,
2866
- skipPlane: skipPlane || local,
2867
- skipBloodbank: local,
2868
- skipSystemd: local || process.platform === "darwin"
2869
- }
2870
- });
2871
- }
2872
3538
  if (dryRun) {
2873
- return asText({ ok: true, dryRun, targetDir, projectSlug, actions, guidance: parityGuidance() });
2874
- }
2875
- const which = spawnSync5("which", ["copier"], { encoding: "utf8" });
2876
- if (which.status !== 0) throw new Error("copier not found on PATH. Install with: uv tool install copier or pip install copier");
2877
- mkdirSync6(dirname7(targetDir), { recursive: true });
2878
- const result = spawnSync5(copierAction.command[0], copierAction.command.slice(1), { encoding: "utf8", cwd: pjanglerRoot });
2879
- const logs = [result.stdout.trim()].filter(Boolean);
2880
- const errors = [result.stderr.trim()].filter(Boolean);
2881
- if (result.status !== 0) {
2882
- return asText({ ok: false, dryRun, targetDir, actions, logs, errors, exitCode: result.status });
3539
+ return asText({ ...plan, guidance: parityGuidance() });
2883
3540
  }
3541
+ const result = executeProjectInitPlan(plan);
3542
+ if (!result.ok) return asText({ ...result, guidance: parityGuidance() });
2884
3543
  let agentResult;
2885
3544
  if (input.provisionAgent) {
2886
3545
  const context = {
@@ -2901,7 +3560,82 @@ server.registerTool(
2901
3560
  };
2902
3561
  agentResult = await runRecipeWithCapture("hermes-agent", context);
2903
3562
  }
2904
- return asText({ ok: !agentResult || agentResult.success, dryRun, targetDir, projectSlug, actions, logs, errors, agentResult });
3563
+ return asText({ ...result, ok: result.ok && (!agentResult || agentResult.success), agentResult, guidance: parityGuidance() });
3564
+ } catch (err) {
3565
+ return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
3566
+ }
3567
+ }
3568
+ );
3569
+ server.registerTool(
3570
+ "pjangler_project_init",
3571
+ {
3572
+ title: "Initialize a pjangler project",
3573
+ description: "Plan or apply a registry-backed CommonProject project init. Dry-run is the default; writes require apply=true and live actions require live=true.",
3574
+ inputSchema: {
3575
+ name: z.string(),
3576
+ description: z.string().optional(),
3577
+ targetDir: z.string().optional(),
3578
+ sourceSkill: z.string().optional(),
3579
+ primaryLanguage: z.string().optional(),
3580
+ provisionAgent: z.boolean().optional(),
3581
+ agentRole: z.string().optional(),
3582
+ apply: z.boolean().optional(),
3583
+ live: z.boolean().optional(),
3584
+ slug: z.string().optional(),
3585
+ identifier: z.string().optional(),
3586
+ registryPath: z.string().optional(),
3587
+ force: z.boolean().optional()
3588
+ }
3589
+ },
3590
+ async (input) => {
3591
+ try {
3592
+ const plan = planProjectInit({
3593
+ name: input.name,
3594
+ description: input.description,
3595
+ targetDir: input.targetDir,
3596
+ sourceSkill: input.sourceSkill,
3597
+ primaryLanguage: input.primaryLanguage,
3598
+ provisionAgent: input.provisionAgent ?? false,
3599
+ agentRole: input.agentRole,
3600
+ apply: input.apply ?? false,
3601
+ live: input.live ?? false,
3602
+ projectSlug: input.slug,
3603
+ projectIdentifier: input.identifier,
3604
+ registryPath: input.registryPath,
3605
+ force: input.force ?? false,
3606
+ overwrite: input.force ?? false
3607
+ });
3608
+ if (!input.apply) return asText(plan);
3609
+ return asText(executeProjectInitPlan(plan));
3610
+ } catch (err) {
3611
+ return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
3612
+ }
3613
+ }
3614
+ );
3615
+ server.registerTool(
3616
+ "pjangler_project_list",
3617
+ {
3618
+ title: "List pjangler registry projects",
3619
+ description: "Return projects from the pjangler central registry.",
3620
+ inputSchema: {
3621
+ registryPath: z.string().optional()
3622
+ }
3623
+ },
3624
+ async ({ registryPath: registryPath2 }) => asText(loadProjectRegistry(registryPath2 ?? projectRegistryPath()))
3625
+ );
3626
+ server.registerTool(
3627
+ "pjangler_project_show",
3628
+ {
3629
+ title: "Show a pjangler registry project",
3630
+ description: "Return one project by slug from the pjangler central registry.",
3631
+ inputSchema: {
3632
+ slug: z.string(),
3633
+ registryPath: z.string().optional()
3634
+ }
3635
+ },
3636
+ async ({ slug, registryPath: registryPath2 }) => {
3637
+ try {
3638
+ return asText(getProject(loadProjectRegistry(registryPath2 ?? projectRegistryPath()), slug));
2905
3639
  } catch (err) {
2906
3640
  return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
2907
3641
  }
@@ -3003,7 +3737,7 @@ server.registerTool(
3003
3737
  targetDir: resolvedTarget,
3004
3738
  yes: true,
3005
3739
  local,
3006
- targetRepo: input.targetRepo ?? basename2(resolvedTarget),
3740
+ targetRepo: input.targetRepo ?? basename4(resolvedTarget),
3007
3741
  role: input.role,
3008
3742
  agentPurpose: input.agentPurpose,
3009
3743
  soulTone: input.soulTone,