@delorenj/pjangler 1.2.9 → 1.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/index.js +498 -204
  2. package/dist/mcp-server.js +488 -194
  3. package/package.json +1 -1
  4. package/templates/commonproject/copier.yml +4 -1
  5. package/templates/commonproject/template/.project.json.jinja +9 -12
  6. package/templates/commonproject/template/mise.toml.jinja +1 -1
  7. package/templates/hermes-agent/README.md +1 -1
  8. package/templates/hermes-agent/config.example.toml +66 -0
  9. package/templates/hermes-agent/docs/architecture.md +5 -0
  10. package/templates/hermes-agent/docs/bloodbank-gateway.md +57 -0
  11. package/templates/hermes-agent/docs/fleet-control-plane/README.md +1 -1
  12. package/templates/hermes-agent/docs/fleet-control-plane/n8n-service-hub.md +60 -0
  13. package/templates/hermes-agent/docs/operations.md +1 -1
  14. package/templates/hermes-agent/docs/sentinel/README.md +2 -2
  15. package/templates/hermes-agent/docs/sentinel/development.md +3 -3
  16. package/templates/hermes-agent/docs/sentinel/providers.md +6 -5
  17. package/templates/hermes-agent/install-local.sh +10 -4
  18. package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +5 -3
  19. package/templates/hermes-agent/scripts/fleet-sync.sh +64 -1
  20. package/templates/hermes-agent/template/.gitignore.jinja +2 -0
  21. package/templates/hermes-agent/template/.scripts/01-config.sh +1 -0
  22. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +9 -0
  23. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +22 -3
  24. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +22 -0
  25. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -33
  26. package/templates/hermes-agent/template/.scripts/70-systemd.sh +8 -1
  27. package/templates/hermes-agent/template/.scripts/_lib.sh +61 -3
  28. package/templates/hermes-agent/template/.scripts/config.example.toml +5 -0
  29. package/templates/hermes-agent/template/.scripts/heartbeat.sh +66 -22
  30. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +3 -3
  31. package/templates/hermes-agent/template/.scripts/providers/plane.sh +29 -9
  32. package/templates/hermes-agent/template/role.yaml.jinja +2 -27
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/mcp-server.ts
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";
4
+ import { existsSync as existsSync10, statSync as statSync2 } from "node:fs";
5
+ import { basename as basename4, dirname as dirname8, join as join13, resolve as resolve3 } from "node:path";
6
6
  import { fileURLToPath as fileURLToPath5 } from "node:url";
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -630,6 +630,8 @@ function renderHostConfig() {
630
630
  const hermesRepo = join3(home, "code", "hermes-agent");
631
631
  const scaffoldDir = join3(home, "code", "hermes-agent-template", "runtime-scaffold");
632
632
  const skillsDir = join3(home, ".agents", "skills");
633
+ const pmExternalSkillGlobalDir = join3(home, "code", "skillex", "skill-sets", "global", ".system");
634
+ const pmExternalSkillBmadDir = join3(home, "code", "skillex", "packs", "bmad", "6.10.2");
633
635
  return `# hermes-agent-template \u2014 host configuration
634
636
  # Bootstrapped by \`pjangler config bootstrap\` for $HOME=${home} (platform=${platform()}).
635
637
  #
@@ -645,9 +647,14 @@ home = "~/.hermes"
645
647
  hermes_bin = "${hermesBin}"
646
648
  hermes_repo = "${hermesRepo}"
647
649
  runtime_scaffold_dir = "${scaffoldDir}"
650
+ # Shared fleet source-of-truth env file + fleet registry. ~ is expanded.
648
651
  fleet_env = "~/.hermes/fleet.env"
649
652
  registry_file = "~/.hermes/agents-registry.yaml"
650
653
  canonical_skills_dir = "${skillsDir}"
654
+ pm_external_skill_dirs = [
655
+ "${pmExternalSkillGlobalDir}",
656
+ "${pmExternalSkillBmadDir}",
657
+ ]
651
658
  symlinked_runtime_skills = []
652
659
 
653
660
  [github]
@@ -895,10 +902,100 @@ var RunCopierTemplate = class extends Command {
895
902
  }
896
903
  };
897
904
 
898
- // src/commands/hermes/WireTelegram.ts
905
+ // src/commands/hermes/UntrackHermesRuntimes.ts
906
+ import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync3, readdirSync } from "fs";
907
+ import { join as join6 } from "path";
899
908
  import { spawnSync as spawnSync2 } from "node:child_process";
900
- import { join as join6 } from "node:path";
901
- import { existsSync as existsSync4, unlinkSync } from "node:fs";
909
+ var UntrackHermesRuntimes = class extends Command {
910
+ async invoke() {
911
+ const targetDir = this.context.targetDir;
912
+ const rolesDir = join6(targetDir, "agents", "hermes");
913
+ if (!existsSync4(rolesDir)) {
914
+ return {
915
+ success: true,
916
+ message: "No Hermes agents found (no agents/hermes directory)."
917
+ };
918
+ }
919
+ const roles = readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
920
+ if (roles.length === 0) {
921
+ return {
922
+ success: true,
923
+ message: "No Hermes agents found."
924
+ };
925
+ }
926
+ let modifiedAny = false;
927
+ const details = [];
928
+ for (const role of roles) {
929
+ const roleDir = join6("agents", "hermes", role);
930
+ const runtimePath = join6(roleDir, "runtime");
931
+ const gitignorePath = join6(roleDir, ".gitignore");
932
+ let isTracked = false;
933
+ const lsResult = spawnSync2("git", ["ls-files", "--stage", runtimePath], {
934
+ cwd: targetDir,
935
+ encoding: "utf8"
936
+ });
937
+ if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
938
+ isTracked = true;
939
+ }
940
+ let isIgnored = false;
941
+ const fullGitignorePath = join6(targetDir, gitignorePath);
942
+ if (existsSync4(fullGitignorePath)) {
943
+ const content = readFileSync2(fullGitignorePath, "utf8");
944
+ const lines = content.split(/\r?\n/).map((line) => line.trim());
945
+ isIgnored = lines.includes("runtime/") || lines.includes("runtime");
946
+ }
947
+ if (isTracked || !isIgnored) {
948
+ modifiedAny = true;
949
+ if (isTracked) {
950
+ details.push(`untrack agents/hermes/${role}/runtime`);
951
+ if (!this.context.dryRun) {
952
+ const rmResult = spawnSync2("git", ["rm", "--cached", "-r", runtimePath], {
953
+ cwd: targetDir,
954
+ encoding: "utf8"
955
+ });
956
+ if (rmResult.status !== 0) {
957
+ return {
958
+ success: false,
959
+ message: `Failed to untrack agents/hermes/${role}/runtime: ${rmResult.stderr}`
960
+ };
961
+ }
962
+ }
963
+ }
964
+ if (!isIgnored) {
965
+ details.push(`ignore runtime/ in agents/hermes/${role}/.gitignore`);
966
+ if (!this.context.dryRun) {
967
+ let content = "";
968
+ if (existsSync4(fullGitignorePath)) {
969
+ content = readFileSync2(fullGitignorePath, "utf8");
970
+ }
971
+ if (content && !content.endsWith("\n")) {
972
+ content += "\n";
973
+ }
974
+ content += "runtime/\n";
975
+ writeFileSync3(fullGitignorePath, content, "utf8");
976
+ }
977
+ }
978
+ }
979
+ }
980
+ if (!modifiedAny) {
981
+ return {
982
+ success: true,
983
+ message: "\u2705 All Hermes agent runtimes are already untracked and gitignored."
984
+ };
985
+ }
986
+ const actionText = this.context.dryRun ? "Would make" : "Made";
987
+ return {
988
+ success: true,
989
+ message: `${actionText} Hermes agent runtimes untracked and gitignored:
990
+ ${details.map((d) => ` - ${d}`).join("\n")}`
991
+ };
992
+ }
993
+ };
994
+
995
+ // src/commands/hermes/WireTelegram.ts
996
+ import { spawnSync as spawnSync3 } from "node:child_process";
997
+ import { join as join7 } from "node:path";
998
+ import { existsSync as existsSync5, unlinkSync } from "node:fs";
902
999
  import * as p3 from "@clack/prompts";
903
1000
  var WireTelegram = class extends Command {
904
1001
  async invoke() {
@@ -920,7 +1017,7 @@ var WireTelegram = class extends Command {
920
1017
  let token = process.env.TELEGRAM_BOT_TOKEN;
921
1018
  let source = token ? "env" : null;
922
1019
  if (!token) {
923
- const tryOp = spawnSync2("op", ["read", vaultRef], { encoding: "utf8" });
1020
+ const tryOp = spawnSync3("op", ["read", vaultRef], { encoding: "utf8" });
924
1021
  if (tryOp.status === 0) {
925
1022
  token = tryOp.stdout.trim();
926
1023
  source = "op";
@@ -959,7 +1056,7 @@ var WireTelegram = class extends Command {
959
1056
  initialValue: true
960
1057
  });
961
1058
  if (!p3.isCancel(persist) && persist) {
962
- const create = spawnSync2(
1059
+ const create = spawnSync3(
963
1060
  "op",
964
1061
  [
965
1062
  "item",
@@ -986,18 +1083,18 @@ var WireTelegram = class extends Command {
986
1083
  if (p3.isCancel(allowedAnswer)) {
987
1084
  return { success: false, message: "\u2717 Aborted; Telegram step deferred." };
988
1085
  }
989
- const script = join6(roleDir, ".scripts", "30-telegram.sh");
990
- if (!existsSync4(script)) {
1086
+ const script = join7(roleDir, ".scripts", "30-telegram.sh");
1087
+ if (!existsSync5(script)) {
991
1088
  return {
992
1089
  success: false,
993
1090
  message: `\u2717 ${script} not found. Did copier finish? Re-run with --skip-runtime-repo=0 if you skipped it.`
994
1091
  };
995
1092
  }
996
- const marker = join6(roleDir, ".scripts", ".done-30-telegram");
997
- if (existsSync4(marker)) unlinkSync(marker);
1093
+ const marker = join7(roleDir, ".scripts", ".done-30-telegram");
1094
+ if (existsSync5(marker)) unlinkSync(marker);
998
1095
  const spinner4 = p3.spinner();
999
1096
  spinner4.start("Verifying token + wiring profile");
1000
- const result = spawnSync2("bash", [script], {
1097
+ const result = spawnSync3("bash", [script], {
1001
1098
  stdio: "inherit",
1002
1099
  env: {
1003
1100
  ...process.env,
@@ -1020,9 +1117,9 @@ function cap(s) {
1020
1117
  }
1021
1118
 
1022
1119
  // src/commands/hermes/WireEmail.ts
1023
- import { spawnSync as spawnSync3 } from "node:child_process";
1024
- import { join as join7 } from "node:path";
1025
- import { existsSync as existsSync5, unlinkSync as unlinkSync2 } from "node:fs";
1120
+ import { spawnSync as spawnSync4 } from "node:child_process";
1121
+ import { join as join8 } from "node:path";
1122
+ import { existsSync as existsSync6, unlinkSync as unlinkSync2 } from "node:fs";
1026
1123
  import * as p4 from "@clack/prompts";
1027
1124
  var WireEmail = class extends Command {
1028
1125
  async invoke() {
@@ -1037,13 +1134,13 @@ var WireEmail = class extends Command {
1037
1134
  if (!targetRepo || !role || !roleDir) {
1038
1135
  return { success: false, message: "Cannot wire email: missing target_repo/role/roleDir" };
1039
1136
  }
1040
- const script = join7(roleDir, ".scripts", "50-email.sh");
1041
- if (!existsSync5(script)) {
1137
+ const script = join8(roleDir, ".scripts", "50-email.sh");
1138
+ if (!existsSync6(script)) {
1042
1139
  return { success: false, message: `\u2717 ${script} not found` };
1043
1140
  }
1044
1141
  let token = process.env.CF_EMAIL_ROUTING_TOKEN;
1045
1142
  if (!token) {
1046
- const tryOp = spawnSync3(
1143
+ const tryOp = spawnSync4(
1047
1144
  "op",
1048
1145
  ["read", "op://DeLoSecrets/Cloudflare-EmailRouting/token"],
1049
1146
  { encoding: "utf8" }
@@ -1083,7 +1180,7 @@ var WireEmail = class extends Command {
1083
1180
  initialValue: true
1084
1181
  });
1085
1182
  if (!p4.isCancel(persist) && persist) {
1086
- const create = spawnSync3(
1183
+ const create = spawnSync4(
1087
1184
  "op",
1088
1185
  [
1089
1186
  "item",
@@ -1100,11 +1197,11 @@ var WireEmail = class extends Command {
1100
1197
  }
1101
1198
  }
1102
1199
  }
1103
- const marker = join7(roleDir, ".scripts", ".done-50-email");
1104
- if (existsSync5(marker)) unlinkSync2(marker);
1200
+ const marker = join8(roleDir, ".scripts", ".done-50-email");
1201
+ if (existsSync6(marker)) unlinkSync2(marker);
1105
1202
  const spinner4 = p4.spinner();
1106
1203
  spinner4.start("Creating Cloudflare Email Routing rule");
1107
- const result = spawnSync3("bash", [script], {
1204
+ const result = spawnSync4("bash", [script], {
1108
1205
  stdio: "inherit",
1109
1206
  env: { ...process.env, SKIP_EMAIL: "0", CF_EMAIL_ROUTING_TOKEN: token },
1110
1207
  cwd: roleDir
@@ -1164,7 +1261,7 @@ var PrintHermesSummary = class extends Command {
1164
1261
  var HermesAgentRecipe = class extends Recipe {
1165
1262
  constructor(context) {
1166
1263
  super(context);
1167
- this.addIngredient(EnsureTemplateConfig).addIngredient(PromptForAgentConfig).addIngredient(RunCopierTemplate).addIngredient(WireTelegram).addIngredient(WireEmail).addIngredient(PrintHermesSummary);
1264
+ this.addIngredient(EnsureTemplateConfig).addIngredient(PromptForAgentConfig).addIngredient(RunCopierTemplate).addIngredient(UntrackHermesRuntimes).addIngredient(WireTelegram).addIngredient(WireEmail).addIngredient(PrintHermesSummary);
1168
1265
  }
1169
1266
  // Override execute() to suppress the base class's per-command logging since
1170
1267
  // our commands already render their own UI via @clack/prompts.
@@ -1188,33 +1285,33 @@ var HermesAgentRecipe = class extends Recipe {
1188
1285
 
1189
1286
  // src/commands/AgentHooksCommands.ts
1190
1287
  import { homedir as homedir4 } from "node:os";
1191
- import { join as join9, dirname as dirname5 } from "node:path";
1192
- import { existsSync as existsSync7, cpSync, mkdirSync as mkdirSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
1288
+ import { join as join10, dirname as dirname5 } from "node:path";
1289
+ import { existsSync as existsSync8, cpSync, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "node:fs";
1193
1290
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1194
1291
 
1195
1292
  // src/project/index.ts
1196
- import { spawnSync as spawnSync4 } from "node:child_process";
1197
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync2, renameSync, statSync, writeFileSync as writeFileSync3 } from "node:fs";
1293
+ import { spawnSync as spawnSync5 } from "node:child_process";
1294
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync3, renameSync, statSync, writeFileSync as writeFileSync4 } from "node:fs";
1198
1295
  import { homedir as homedir3 } from "node:os";
1199
- import { basename as basename2, dirname as dirname4, join as join8, resolve } from "node:path";
1296
+ import { basename as basename2, delimiter, dirname as dirname4, join as join9, resolve } from "node:path";
1200
1297
  import YAML from "yaml";
1201
1298
  var PROJECT_REGISTRY_ENV = "PJ_PROJECT_REGISTRY";
1299
+ var PROJECT_SOURCE_SKILL_ROOTS_ENV = "PJ_SOURCE_SKILL_ROOTS";
1202
1300
  var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
1203
- var KNOWN_SKILL_ROOTS = [
1301
+ var DEFAULT_SOURCE_SKILL_ROOTS = [
1204
1302
  "/home/delorenj/code/skillex/all-skills",
1205
- "/home/delorenj/code/CoachingAgentFramework/.agents/skills",
1206
- "/home/delorenj/code/pjangler/.agents/skills",
1207
- join8(homedir3(), ".codex", "skills")
1303
+ join9(homedir3(), ".agents", "skills"),
1304
+ join9(homedir3(), ".codex", "skills")
1208
1305
  ];
1209
1306
  function projectRegistryPath(env2 = process.env) {
1210
- return expandHome(env2[PROJECT_REGISTRY_ENV] || join8(homedir3(), ".config", "pjangler", "projects.yaml"));
1307
+ return expandHome(env2[PROJECT_REGISTRY_ENV] || join9(homedir3(), ".config", "pjangler", "projects.yaml"));
1211
1308
  }
1212
1309
  function emptyProjectRegistry() {
1213
1310
  return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
1214
1311
  }
1215
1312
  function loadProjectRegistry(path = projectRegistryPath()) {
1216
- if (!existsSync6(path)) return emptyProjectRegistry();
1217
- const raw = YAML.parse(readFileSync2(path, "utf8"));
1313
+ if (!existsSync7(path)) return emptyProjectRegistry();
1314
+ const raw = YAML.parse(readFileSync3(path, "utf8"));
1218
1315
  if (raw == null) return emptyProjectRegistry();
1219
1316
  if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
1220
1317
  const registry = raw;
@@ -1229,7 +1326,7 @@ function saveProjectRegistry(registry, path = projectRegistryPath()) {
1229
1326
  validateProjectRegistry(registry);
1230
1327
  mkdirSync4(dirname4(path), { recursive: true });
1231
1328
  const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
1232
- writeFileSync3(temp, YAML.stringify(registry, { lineWidth: 0 }), "utf8");
1329
+ writeFileSync4(temp, YAML.stringify(registry, { lineWidth: 0 }), "utf8");
1233
1330
  renameSync(temp, path);
1234
1331
  }
1235
1332
  function validateProjectRegistry(registry) {
@@ -1274,8 +1371,7 @@ function buildTicketProviderBlock(input) {
1274
1371
  workspace: input.workspace ?? "",
1275
1372
  identifier: input.identifier,
1276
1373
  board_id: boardId,
1277
- board_url: input.boardUrl ?? (boardId ? `https://trello.com/b/${boardId}` : ""),
1278
- state: "planned"
1374
+ state: boardId ? "linked" : "planned"
1279
1375
  };
1280
1376
  }
1281
1377
  const workspace = input.workspace ?? "33god";
@@ -1284,8 +1380,16 @@ function buildTicketProviderBlock(input) {
1284
1380
  workspace,
1285
1381
  identifier: input.identifier,
1286
1382
  board_id: boardId,
1287
- board_url: input.boardUrl ?? (boardId ? `https://plane.delo.sh/${workspace}/projects/${boardId}/issues/` : ""),
1288
- state: "planned"
1383
+ state: boardId ? "linked" : "planned"
1384
+ };
1385
+ }
1386
+ function defaultProjectAutomation() {
1387
+ return {
1388
+ reconcile: {
1389
+ enabled: false,
1390
+ grace_hours: 0,
1391
+ auto_review: true
1392
+ }
1289
1393
  };
1290
1394
  }
1291
1395
  function slugifyProjectName(value) {
@@ -1304,7 +1408,7 @@ function resolveAgentHooksLayer(input, env2 = process.env) {
1304
1408
  const override = env2.PJ_AGENT_HOOKS_LAYER;
1305
1409
  if (override === "0" || override === "false") return false;
1306
1410
  if (override === "1" || override === "true") return true;
1307
- return !existsSync6(join8(homedir3(), ".agents", "hooks"));
1411
+ return !existsSync7(join9(homedir3(), ".agents", "hooks"));
1308
1412
  }
1309
1413
  function jsonStable(value) {
1310
1414
  return JSON.stringify(value);
@@ -1319,18 +1423,31 @@ function defaultProjectTargetDir(name, cwd = process.cwd()) {
1319
1423
  const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
1320
1424
  return resolve(dirname4(resolve(cwd)), compactName);
1321
1425
  }
1322
- function resolveSourceSkillPath(sourceSkill) {
1426
+ function sourceSkillRoots(env2 = process.env) {
1427
+ const configuredRoots = (env2[PROJECT_SOURCE_SKILL_ROOTS_ENV] || "").split(delimiter).map((root) => root.trim()).filter(Boolean);
1428
+ const seen = /* @__PURE__ */ new Set();
1429
+ const roots = [];
1430
+ for (const root of [...DEFAULT_SOURCE_SKILL_ROOTS, ...configuredRoots]) {
1431
+ const normalized = resolve(expandHome(root));
1432
+ if (seen.has(normalized)) continue;
1433
+ seen.add(normalized);
1434
+ roots.push(normalized);
1435
+ }
1436
+ return roots;
1437
+ }
1438
+ function resolveSourceSkillPath(sourceSkill, env2 = process.env) {
1323
1439
  if (!sourceSkill) return void 0;
1324
1440
  const expanded = expandHome(sourceSkill);
1325
1441
  const direct = resolve(expanded);
1326
- if (existsSync6(direct)) return direct;
1442
+ if (existsSync7(direct)) return direct;
1327
1443
  const name = basename2(sourceSkill);
1328
- for (const root of KNOWN_SKILL_ROOTS) {
1329
- const candidate = join8(root, name);
1330
- if (existsSync6(candidate)) return candidate;
1444
+ const roots = sourceSkillRoots(env2);
1445
+ for (const root of roots) {
1446
+ const candidate = join9(root, name);
1447
+ if (existsSync7(candidate)) return candidate;
1331
1448
  }
1332
- const civilWarLetterifier = "/home/delorenj/code/skillex/all-skills/civilwar-letterifier";
1333
- const hint = existsSync6(civilWarLetterifier) ? ` Did you mean ${civilWarLetterifier}?` : "";
1449
+ const searched = roots.length ? ` Searched roots: ${roots.join(", ")}.` : "";
1450
+ const hint = `${searched} Add project-specific roots with ${PROJECT_SOURCE_SKILL_ROOTS_ENV}.`;
1334
1451
  throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
1335
1452
  }
1336
1453
  function planProjectInit(input) {
@@ -1370,10 +1487,10 @@ function planProjectInit(input) {
1370
1487
  type: input.ticketProvider ?? "plane",
1371
1488
  identifier,
1372
1489
  boardId: input.boardId ?? input.planeProjectId,
1373
- boardUrl: input.boardUrl,
1374
1490
  workspace: input.boardWorkspace ?? input.planeWorkspace
1375
1491
  }),
1376
1492
  agents,
1493
+ automation: existing?.automation ?? defaultProjectAutomation(),
1377
1494
  created_at: existing?.created_at ?? now,
1378
1495
  updated_at: now
1379
1496
  };
@@ -1401,7 +1518,6 @@ function planProjectInit(input) {
1401
1518
  planeProjectId: project.ticket_provider.board_id ?? "",
1402
1519
  ticketWorkspace: project.ticket_provider.workspace ?? "",
1403
1520
  boardId: project.ticket_provider.board_id ?? "",
1404
- boardUrl: project.ticket_provider.board_url ?? "",
1405
1521
  projectIdentifier: identifier,
1406
1522
  primaryLanguage: project.template.commonproject.primary_language,
1407
1523
  agentHooksLayer: resolveAgentHooksLayer(input.agentHooksLayer),
@@ -1409,7 +1525,7 @@ function planProjectInit(input) {
1409
1525
  }));
1410
1526
  }
1411
1527
  actions.push(
1412
- { kind: "project.write-manifest", path: join8(targetDir, ".project.json"), manifest },
1528
+ { kind: "project.write-manifest", path: join9(targetDir, ".project.json"), manifest },
1413
1529
  {
1414
1530
  kind: "ticket-provider.create-or-link",
1415
1531
  enabled: live,
@@ -1450,7 +1566,7 @@ function executeProjectInitPlan(plan) {
1450
1566
  action.data.agent_hooks_layer === "false" ? "commonproject: agent-hooks layer skipped (global ~/.agents/hooks detected \u2014 no per-user CLI injection)" : "commonproject: agent-hooks layer included"
1451
1567
  );
1452
1568
  mkdirSync4(dirname4(action.targetDir), { recursive: true });
1453
- const result = spawnSync4(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
1569
+ const result = spawnSync5(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
1454
1570
  if (result.stdout?.trim()) logs.push(result.stdout.trim());
1455
1571
  if (result.stderr?.trim()) logs.push(result.stderr.trim());
1456
1572
  if (result.error) {
@@ -1462,7 +1578,7 @@ function executeProjectInitPlan(plan) {
1462
1578
  }
1463
1579
  if (result.status !== 0) {
1464
1580
  errors.push(`copier exited with status ${result.status ?? "unknown"}`);
1465
- if (existsSync6(action.targetDir)) changedFiles.push(action.targetDir);
1581
+ if (existsSync7(action.targetDir)) changedFiles.push(action.targetDir);
1466
1582
  break;
1467
1583
  }
1468
1584
  changedFiles.push(action.targetDir);
@@ -1470,9 +1586,9 @@ function executeProjectInitPlan(plan) {
1470
1586
  mkdirSync4(dirname4(action.path), { recursive: true });
1471
1587
  const next = `${JSON.stringify(action.manifest, null, 2)}
1472
1588
  `;
1473
- const current = existsSync6(action.path) ? readFileSync2(action.path, "utf8") : void 0;
1589
+ const current = existsSync7(action.path) ? readFileSync3(action.path, "utf8") : void 0;
1474
1590
  if (current !== next) {
1475
- writeFileSync3(action.path, next, "utf8");
1591
+ writeFileSync4(action.path, next, "utf8");
1476
1592
  changedFiles.push(action.path);
1477
1593
  }
1478
1594
  } else if (action.kind === "registry.upsert") {
@@ -1513,10 +1629,10 @@ function projectManifestFromRegistryProject(project) {
1513
1629
  workspace: project.ticket_provider.workspace ?? "",
1514
1630
  identifier: project.ticket_provider.identifier ?? "",
1515
1631
  board_id: project.ticket_provider.board_id ?? "",
1516
- board_url: project.ticket_provider.board_url ?? "",
1517
1632
  state: project.ticket_provider.state
1518
1633
  },
1519
- agents
1634
+ agents,
1635
+ automation: project.automation ?? defaultProjectAutomation()
1520
1636
  };
1521
1637
  }
1522
1638
  function getProject(registry, slug) {
@@ -1525,7 +1641,7 @@ function getProject(registry, slug) {
1525
1641
  return project;
1526
1642
  }
1527
1643
  function buildCommonProjectCopierAction(input) {
1528
- const templateDir = join8(input.pjanglerRoot, "templates", "commonproject");
1644
+ const templateDir = join9(input.pjanglerRoot, "templates", "commonproject");
1529
1645
  const data = {
1530
1646
  project_name: input.projectName,
1531
1647
  project_description: input.projectDescription ?? "",
@@ -1535,7 +1651,6 @@ function buildCommonProjectCopierAction(input) {
1535
1651
  plane_project_id: input.planeProjectId ?? "",
1536
1652
  ticket_workspace: input.ticketWorkspace ?? input.planeWorkspace,
1537
1653
  board_id: input.boardId ?? input.planeProjectId ?? "",
1538
- board_url: input.boardUrl ?? "",
1539
1654
  project_identifier: input.projectIdentifier,
1540
1655
  primary_language: input.primaryLanguage,
1541
1656
  agent_hooks_layer: input.agentHooksLayer ?? true ? "true" : "false"
@@ -1555,7 +1670,7 @@ function buildCommonProjectCopierAction(input) {
1555
1670
  function resolvePjanglerRoot() {
1556
1671
  let dir = dirname4(new URL(import.meta.url).pathname);
1557
1672
  while (dir !== dirname4(dir)) {
1558
- if (existsSync6(join8(dir, "package.json")) && existsSync6(join8(dir, "templates", "commonproject", "copier.yml"))) return dir;
1673
+ if (existsSync7(join9(dir, "package.json")) && existsSync7(join9(dir, "templates", "commonproject", "copier.yml"))) return dir;
1559
1674
  dir = dirname4(dir);
1560
1675
  }
1561
1676
  return resolve(process.cwd());
@@ -1587,7 +1702,7 @@ function validateProjectRecord(project, key) {
1587
1702
  }
1588
1703
  function expandHome(path) {
1589
1704
  if (path === "~") return homedir3();
1590
- if (path.startsWith("~/")) return join8(homedir3(), path.slice(2));
1705
+ if (path.startsWith("~/")) return join9(homedir3(), path.slice(2));
1591
1706
  return path;
1592
1707
  }
1593
1708
  function isRecord(value) {
@@ -1604,16 +1719,16 @@ function resolveTemplateRoot() {
1604
1719
  try {
1605
1720
  let dir = dirname5(fileURLToPath2(import.meta.url));
1606
1721
  for (let i = 0; i < 8; i++) {
1607
- candidates.push(join9(dir, "templates", "commonproject", "template"));
1722
+ candidates.push(join10(dir, "templates", "commonproject", "template"));
1608
1723
  const parent = dirname5(dir);
1609
1724
  if (parent === dir) break;
1610
1725
  dir = parent;
1611
1726
  }
1612
1727
  } catch {
1613
1728
  }
1614
- candidates.push(join9(homedir4(), "code", "pjangler", "templates", "commonproject", "template"));
1729
+ candidates.push(join10(homedir4(), "code", "pjangler", "templates", "commonproject", "template"));
1615
1730
  for (const c of candidates) {
1616
- if (existsSync7(join9(c, ".agents", "hooks", "hooks.master.json"))) return c;
1731
+ if (existsSync8(join10(c, ".agents", "hooks", "hooks.master.json"))) return c;
1617
1732
  }
1618
1733
  throw new Error(
1619
1734
  "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
@@ -1640,10 +1755,10 @@ var CopyAgentHooksTree = class extends Command {
1640
1755
  const created = [];
1641
1756
  const skipped = [];
1642
1757
  for (const { rel, dir } of items) {
1643
- const src = join9(templateRoot, rel);
1644
- const dest = join9(this.context.targetDir, rel);
1645
- if (!existsSync7(src)) continue;
1646
- if (existsSync7(dest) && !this.context.force) {
1758
+ const src = join10(templateRoot, rel);
1759
+ const dest = join10(this.context.targetDir, rel);
1760
+ if (!existsSync8(src)) continue;
1761
+ if (existsSync8(dest) && !this.context.force) {
1647
1762
  skipped.push(rel);
1648
1763
  continue;
1649
1764
  }
@@ -1669,14 +1784,14 @@ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
1669
1784
  if (!resolveAgentHooksLayer()) {
1670
1785
  return { success: true, message: this.formatMessage(AGENT_HOOKS_SKIP_MESSAGE) };
1671
1786
  }
1672
- const misePath = join9(this.context.targetDir, "mise.toml");
1673
- if (!existsSync7(misePath)) {
1787
+ const misePath = join10(this.context.targetDir, "mise.toml");
1788
+ if (!existsSync8(misePath)) {
1674
1789
  return {
1675
1790
  success: false,
1676
1791
  message: "\u26A0\uFE0F No mise.toml found \u2014 run `pjangler init mise` first, then re-run."
1677
1792
  };
1678
1793
  }
1679
- let content = readFileSync3(misePath, "utf8");
1794
+ let content = readFileSync4(misePath, "utf8");
1680
1795
  if (content.includes(_WireMiseAgentHooks.MARKER)) {
1681
1796
  return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for agent-hooks") };
1682
1797
  }
@@ -1752,7 +1867,7 @@ ${leaveBlock}`);
1752
1867
  ""
1753
1868
  ].join("\n");
1754
1869
  content = content.replace(/\n*$/, "\n") + appended;
1755
- if (!this.context.dryRun) writeFileSync4(misePath, content);
1870
+ if (!this.context.dryRun) writeFileSync5(misePath, content);
1756
1871
  if (wiredHooks) {
1757
1872
  return { success: true, message: this.formatMessage("\u2705 Wired mise.toml ([hooks] enter/leave + tasks)") };
1758
1873
  }
@@ -1906,15 +2021,15 @@ function createRecipe(name, context) {
1906
2021
  }
1907
2022
 
1908
2023
  // src/utils/version.ts
1909
- import { readFileSync as readFileSync4 } from "node:fs";
1910
- import { dirname as dirname6, join as join10 } from "node:path";
2024
+ import { readFileSync as readFileSync5 } from "node:fs";
2025
+ import { dirname as dirname6, join as join11 } from "node:path";
1911
2026
  import { fileURLToPath as fileURLToPath3 } from "node:url";
1912
2027
  var PJANGLER_VERSION = (() => {
1913
2028
  try {
1914
2029
  let dir = dirname6(fileURLToPath3(import.meta.url));
1915
2030
  for (let i = 0; i < 4; i++) {
1916
2031
  try {
1917
- const raw = readFileSync4(join10(dir, "package.json"), "utf8");
2032
+ const raw = readFileSync5(join11(dir, "package.json"), "utf8");
1918
2033
  return JSON.parse(raw).version ?? "0.0.0";
1919
2034
  } catch {
1920
2035
  const parent = dirname6(dir);
@@ -1928,11 +2043,11 @@ var PJANGLER_VERSION = (() => {
1928
2043
  })();
1929
2044
 
1930
2045
  // src/parity/index.ts
1931
- import { existsSync as existsSync8, lstatSync, mkdirSync as mkdirSync6, readFileSync as readFileSync5, readlinkSync, readdirSync, renameSync as renameSync2, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync5, chmodSync as chmodSync2, copyFileSync } from "node:fs";
1932
- import { basename as basename3, dirname as dirname7, join as join11, relative, resolve as resolve2 } from "node:path";
2046
+ import { existsSync as existsSync9, lstatSync, mkdirSync as mkdirSync6, readFileSync as readFileSync6, readlinkSync, readdirSync as readdirSync2, renameSync as renameSync2, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync6, chmodSync as chmodSync2, copyFileSync } from "node:fs";
2047
+ import { basename as basename3, dirname as dirname7, join as join12, relative, resolve as resolve2 } from "node:path";
1933
2048
  import { fileURLToPath as fileURLToPath4 } from "node:url";
1934
2049
  import { homedir as homedir5 } from "node:os";
1935
- import { spawnSync as spawnSync5 } from "node:child_process";
2050
+ import { spawnSync as spawnSync6 } from "node:child_process";
1936
2051
  var LINK_AGENTFILES_BLOCK = `# This block will handle the linking of
1937
2052
  # agent files to the main AGENTS.md file.
1938
2053
  #
@@ -2003,7 +2118,7 @@ run = "{{config_root}}/.mise/scripts/versioning.sh sync"
2003
2118
  function resolvePjanglerRoot2() {
2004
2119
  let dir = dirname7(fileURLToPath4(import.meta.url));
2005
2120
  while (dir !== dirname7(dir)) {
2006
- if (existsSync8(join11(dir, "package.json")) && existsSync8(join11(dir, "templates", "commonproject", "copier.yml"))) {
2121
+ if (existsSync9(join12(dir, "package.json")) && existsSync9(join12(dir, "templates", "commonproject", "copier.yml"))) {
2007
2122
  return dir;
2008
2123
  }
2009
2124
  dir = dirname7(dir);
@@ -2014,17 +2129,17 @@ function normalizeNewlines(value) {
2014
2129
  return value.replace(/\r\n/g, "\n");
2015
2130
  }
2016
2131
  function readText(path) {
2017
- return normalizeNewlines(readFileSync5(path, "utf8"));
2132
+ return normalizeNewlines(readFileSync6(path, "utf8"));
2018
2133
  }
2019
2134
  function safeReadText(path) {
2020
- return existsSync8(path) ? readText(path) : null;
2135
+ return existsSync9(path) ? readText(path) : null;
2021
2136
  }
2022
2137
  function ensureParent(path) {
2023
2138
  mkdirSync6(dirname7(path), { recursive: true });
2024
2139
  }
2025
2140
  function writeText(path, content) {
2026
2141
  ensureParent(path);
2027
- writeFileSync5(path, content);
2142
+ writeFileSync6(path, content);
2028
2143
  }
2029
2144
  function tryParseJson(text2) {
2030
2145
  if (!text2) return null;
@@ -2041,7 +2156,7 @@ function titleCaseSlug(slug) {
2041
2156
  return slug.split(/[-_]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
2042
2157
  }
2043
2158
  function readSymlinkTarget(path) {
2044
- if (!existsSync8(path)) return null;
2159
+ if (!existsSync9(path)) return null;
2045
2160
  try {
2046
2161
  return readlinkSync(path);
2047
2162
  } catch {
@@ -2049,7 +2164,7 @@ function readSymlinkTarget(path) {
2049
2164
  }
2050
2165
  }
2051
2166
  function ensureSymlink(path, target, dryRun) {
2052
- if (existsSync8(path)) {
2167
+ if (existsSync9(path)) {
2053
2168
  const stat = lstatSync(path);
2054
2169
  if (stat.isSymbolicLink()) {
2055
2170
  const current = readSymlinkTarget(path);
@@ -2066,11 +2181,11 @@ function ensureSymlink(path, target, dryRun) {
2066
2181
  return { changed: true };
2067
2182
  }
2068
2183
  function bootstrapAgentsFile(repoRoot, dryRun) {
2069
- const agentsPath = join11(repoRoot, "AGENTS.md");
2070
- if (existsSync8(agentsPath)) return { changedFiles: [], details: [] };
2184
+ const agentsPath = join12(repoRoot, "AGENTS.md");
2185
+ if (existsSync9(agentsPath)) return { changedFiles: [], details: [] };
2071
2186
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2072
- const source = join11(repoRoot, file);
2073
- if (!existsSync8(source)) continue;
2187
+ const source = join12(repoRoot, file);
2188
+ if (!existsSync9(source)) continue;
2074
2189
  const stat = lstatSync(source);
2075
2190
  if (stat.isSymbolicLink()) continue;
2076
2191
  if (stat.isFile()) {
@@ -2079,8 +2194,8 @@ function bootstrapAgentsFile(repoRoot, dryRun) {
2079
2194
  }
2080
2195
  return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
2081
2196
  }
2082
- const readmePath = join11(repoRoot, "README.md");
2083
- if (existsSync8(readmePath)) {
2197
+ const readmePath = join12(repoRoot, "README.md");
2198
+ if (existsSync9(readmePath)) {
2084
2199
  const stat = lstatSync(readmePath);
2085
2200
  if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
2086
2201
  if (!dryRun) copyFileSync(readmePath, agentsPath);
@@ -2119,12 +2234,12 @@ function yamlGet(text2, keyPath) {
2119
2234
  return "";
2120
2235
  }
2121
2236
  function discoverRoles(repoRoot) {
2122
- const rolesDir = join11(repoRoot, "agents", "hermes");
2123
- if (!existsSync8(rolesDir)) return [];
2124
- return readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
2125
- const roleDir = join11(rolesDir, entry.name);
2126
- const roleYamlPath = join11(roleDir, "role.yaml");
2127
- if (!existsSync8(roleYamlPath)) return null;
2237
+ const rolesDir = join12(repoRoot, "agents", "hermes");
2238
+ if (!existsSync9(rolesDir)) return [];
2239
+ return readdirSync2(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
2240
+ const roleDir = join12(rolesDir, entry.name);
2241
+ const roleYamlPath = join12(roleDir, "role.yaml");
2242
+ if (!existsSync9(roleYamlPath)) return null;
2128
2243
  const text2 = readText(roleYamlPath);
2129
2244
  const runtimeRepoRaw = yamlGet(text2, "runtime.github_repo");
2130
2245
  return {
@@ -2142,16 +2257,20 @@ function discoverRoles(repoRoot) {
2142
2257
  planeWorkspace: yamlGet(text2, "ticket_provider.workspace") || yamlGet(text2, "plane.workspace"),
2143
2258
  ticketProviderName: yamlGet(text2, "ticket_provider.name"),
2144
2259
  ticketProviderBoardId: yamlGet(text2, "ticket_provider.board_id"),
2145
- ticketProviderBoardUrl: yamlGet(text2, "ticket_provider.board_url"),
2146
- ticketProviderIdentifier: yamlGet(text2, "plane.identifier")
2260
+ ticketProviderIdentifier: yamlGet(text2, "plane.identifier"),
2261
+ legacyReconcileEnabled: yamlGet(text2, "reconcile.enabled"),
2262
+ legacyReconcileGraceHours: yamlGet(text2, "reconcile.grace_hours"),
2263
+ legacyReconcileAutoReview: yamlGet(text2, "reconcile.auto_review"),
2264
+ legacyScrumGraceHours: yamlGet(text2, "scrum_master.grace_hours"),
2265
+ legacyScrumAutoReview: yamlGet(text2, "scrum_master.auto_review")
2147
2266
  };
2148
2267
  }).filter((value) => Boolean(value));
2149
2268
  }
2150
2269
  function registryPath(homeDir) {
2151
- return join11(homeDir, ".hermes", "agents-registry.yaml");
2270
+ return join12(homeDir, ".hermes", "agents-registry.yaml");
2152
2271
  }
2153
2272
  function systemctlUser(args) {
2154
- const result = spawnSync5("systemctl", ["--user", ...args], { encoding: "utf8" });
2273
+ const result = spawnSync6("systemctl", ["--user", ...args], { encoding: "utf8" });
2155
2274
  return {
2156
2275
  ok: result.status === 0,
2157
2276
  stdout: result.stdout.trim(),
@@ -2159,8 +2278,8 @@ function systemctlUser(args) {
2159
2278
  };
2160
2279
  }
2161
2280
  function templateScript(ctx, name) {
2162
- const source = join11(ctx.pjanglerRoot, ".mise", "scripts", name);
2163
- return existsSync8(source) ? readText(source) : void 0;
2281
+ const source = join12(ctx.pjanglerRoot, ".mise", "scripts", name);
2282
+ return existsSync9(source) ? readText(source) : void 0;
2164
2283
  }
2165
2284
  function templateVersioningScript(ctx) {
2166
2285
  return templateScript(ctx, "versioning.sh");
@@ -2168,16 +2287,45 @@ function templateVersioningScript(ctx) {
2168
2287
  function templateLinkAgentfilesScript(ctx) {
2169
2288
  return templateScript(ctx, "link-agentfiles.sh");
2170
2289
  }
2290
+ function resolveAgentHooksLayer2(ctx) {
2291
+ const override = process.env.PJ_AGENT_HOOKS_LAYER;
2292
+ if (override === "0" || override === "false") return false;
2293
+ if (override === "1" || override === "true") return true;
2294
+ if (existsSync9(join12(ctx.repoRoot, ".agents", "hooks", "sync.py"))) return true;
2295
+ return !existsSync9(join12(ctx.homeDir, ".agents", "hooks"));
2296
+ }
2297
+ function evaluateMiseConditionals(template, agentHooksLayer) {
2298
+ const out = [];
2299
+ let depth = 0;
2300
+ let skipDepth = 0;
2301
+ for (const line of template.split("\n")) {
2302
+ const stmt = line.trim();
2303
+ const ifMatch = /^\{%-?\s*if\s+(\w+)\s*-?%\}$/.exec(stmt);
2304
+ if (ifMatch) {
2305
+ depth += 1;
2306
+ const truthy = ifMatch[1] === "agent_hooks_layer" ? agentHooksLayer : false;
2307
+ if (skipDepth === 0 && !truthy) skipDepth = depth;
2308
+ continue;
2309
+ }
2310
+ if (/^\{%-?\s*endif\s*-?%\}$/.test(stmt)) {
2311
+ if (skipDepth === depth) skipDepth = 0;
2312
+ depth = Math.max(0, depth - 1);
2313
+ continue;
2314
+ }
2315
+ if (skipDepth === 0) out.push(line);
2316
+ }
2317
+ return out.join("\n");
2318
+ }
2171
2319
  function renderGeneratedProjectMiseToml(ctx, template) {
2172
2320
  const project = readProjectJson(ctx);
2173
2321
  const projectName = String(project?.project_name ?? basename3(ctx.repoRoot) ?? "project");
2174
- return template.replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
2322
+ return evaluateMiseConditionals(template, resolveAgentHooksLayer2(ctx)).replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
2175
2323
  }
2176
2324
  function ensureMiseTomlFromTemplate(ctx, changedFiles) {
2177
- const targetPath = join11(ctx.repoRoot, "mise.toml");
2178
- if (existsSync8(targetPath)) return false;
2179
- const sourcePath = join11(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
2180
- if (!existsSync8(sourcePath)) return false;
2325
+ const targetPath = join12(ctx.repoRoot, "mise.toml");
2326
+ if (existsSync9(targetPath)) return false;
2327
+ const sourcePath = join12(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
2328
+ if (!existsSync9(sourcePath)) return false;
2181
2329
  changedFiles.push(targetPath);
2182
2330
  if (!ctx.dryRun) {
2183
2331
  writeText(targetPath, renderGeneratedProjectMiseToml(ctx, readText(sourcePath)));
@@ -2185,8 +2333,8 @@ function ensureMiseTomlFromTemplate(ctx, changedFiles) {
2185
2333
  return true;
2186
2334
  }
2187
2335
  function templateVersionFilesConf(ctx, repoRoot) {
2188
- const packageJson = join11(repoRoot, "package.json");
2189
- return existsSync8(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";
2336
+ const packageJson = join12(repoRoot, "package.json");
2337
+ return existsSync9(packageJson) ? "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\njson package.json\ngittag .\n" : "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\ngittag .\n";
2190
2338
  }
2191
2339
  function replaceOrAppendManagedBlock(text2, startMarker, block, beforePattern) {
2192
2340
  if (startMarker.test(text2)) {
@@ -2210,7 +2358,7 @@ var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/herm
2210
2358
  function requiredMisePathEntries(ctx) {
2211
2359
  const required = [...BASE_MISE_PATH_ENTRIES];
2212
2360
  for (const candidate of CONDITIONAL_HERMES_PATHS) {
2213
- if (existsSync8(join11(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
2361
+ if (existsSync9(join12(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
2214
2362
  }
2215
2363
  return required;
2216
2364
  }
@@ -2359,7 +2507,24 @@ function upsertLinkAgentfilesBlock(text2, ctx) {
2359
2507
  return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
2360
2508
  }
2361
2509
  function readProjectJson(ctx) {
2362
- return tryParseJson(safeReadText(join11(ctx.repoRoot, ".project.json")));
2510
+ return tryParseJson(safeReadText(join12(ctx.repoRoot, ".project.json")));
2511
+ }
2512
+ function boolSetting(value, fallback) {
2513
+ if (typeof value === "boolean") return value;
2514
+ if (typeof value === "string") {
2515
+ const normalized = value.trim().toLowerCase();
2516
+ if (["true", "1", "yes", "on"].includes(normalized)) return true;
2517
+ if (["false", "0", "no", "off"].includes(normalized)) return false;
2518
+ }
2519
+ return fallback;
2520
+ }
2521
+ function numberSetting(value, fallback) {
2522
+ if (typeof value === "number" && Number.isFinite(value)) return value;
2523
+ if (typeof value === "string" && value.trim()) {
2524
+ const parsed = Number(value);
2525
+ if (Number.isFinite(parsed)) return parsed;
2526
+ }
2527
+ return fallback;
2363
2528
  }
2364
2529
  function canonicalProjectJson(ctx) {
2365
2530
  const roles = discoverRoles(ctx.repoRoot);
@@ -2371,9 +2536,9 @@ function canonicalProjectJson(ctx) {
2371
2536
  workspace: String((existing.ticket_provider?.workspace ?? firstRole?.planeWorkspace ?? "") || ""),
2372
2537
  identifier: String((existing.ticket_provider?.identifier ?? firstRole?.ticketProviderIdentifier ?? "") || ""),
2373
2538
  board_id: String((existing.ticket_provider?.board_id ?? firstRole?.ticketProviderBoardId ?? "") || ""),
2374
- board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || ""),
2375
- state: String((existing.ticket_provider?.state ?? "planned") || "planned")
2539
+ state: String((existing.ticket_provider?.state ?? (firstRole?.ticketProviderBoardId ? "linked" : "planned")) || "planned")
2376
2540
  };
2541
+ if (ticketProvider.board_id && ticketProvider.state === "planned") ticketProvider.state = "linked";
2377
2542
  const existingAgents = existing.agents ?? {};
2378
2543
  const discoveredAgents = Object.fromEntries(
2379
2544
  roles.map((role) => [
@@ -2393,28 +2558,42 @@ function canonicalProjectJson(ctx) {
2393
2558
  provisioning_state: existingAgent.provisioning_state
2394
2559
  };
2395
2560
  }
2561
+ const existingAutomation = existing.automation ?? {};
2562
+ const existingReconcile = existingAutomation.reconcile ?? {};
2563
+ const legacyEnabled = roles.find((role) => role.legacyReconcileEnabled)?.legacyReconcileEnabled;
2564
+ const legacyGrace = roles.find((role) => role.legacyReconcileGraceHours || role.legacyScrumGraceHours);
2565
+ const legacyAutoReview = roles.find((role) => role.legacyReconcileAutoReview || role.legacyScrumAutoReview);
2566
+ const automation = {
2567
+ ...existingAutomation,
2568
+ reconcile: {
2569
+ enabled: boolSetting(existingReconcile.enabled, boolSetting(legacyEnabled, false)),
2570
+ grace_hours: numberSetting(existingReconcile.grace_hours, numberSetting(legacyGrace?.legacyReconcileGraceHours || legacyGrace?.legacyScrumGraceHours, 0)),
2571
+ auto_review: boolSetting(existingReconcile.auto_review, boolSetting(legacyAutoReview?.legacyReconcileAutoReview || legacyAutoReview?.legacyScrumAutoReview, true))
2572
+ }
2573
+ };
2396
2574
  return {
2397
2575
  project_name: String(existing.project_name ?? titleCaseSlug(slug)),
2398
2576
  project_description: String(existing.project_description ?? ""),
2399
2577
  project_slug: slug,
2400
2578
  repo_path: ctx.repoRoot,
2401
2579
  ticket_provider: ticketProvider,
2402
- agents
2580
+ agents,
2581
+ automation
2403
2582
  };
2404
2583
  }
2405
2584
  function projectJsonFinding(ctx) {
2406
- const projectPath = join11(ctx.repoRoot, ".project.json");
2407
- const planeJsonPath = join11(ctx.repoRoot, ".plane.json");
2585
+ const projectPath = join12(ctx.repoRoot, ".project.json");
2586
+ const planeJsonPath = join12(ctx.repoRoot, ".plane.json");
2408
2587
  const details = [];
2409
2588
  const data = readProjectJson(ctx);
2410
2589
  const roles = discoverRoles(ctx.repoRoot);
2411
- if (!existsSync8(projectPath)) {
2590
+ if (!existsSync9(projectPath)) {
2412
2591
  return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json missing", details: [], fixable: true };
2413
2592
  }
2414
2593
  if (!data) {
2415
2594
  return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json is not valid JSON", details: [], fixable: true };
2416
2595
  }
2417
- for (const key of ["project_name", "project_description", "project_slug", "repo_path", "ticket_provider", "agents"]) {
2596
+ for (const key of ["project_name", "project_description", "project_slug", "repo_path", "ticket_provider", "agents", "automation"]) {
2418
2597
  if (!(key in data)) details.push(`missing key: ${key}`);
2419
2598
  }
2420
2599
  if (data.repo_path !== ctx.repoRoot) details.push(`repo_path should be ${ctx.repoRoot}`);
@@ -2431,10 +2610,19 @@ function projectJsonFinding(ctx) {
2431
2610
  }
2432
2611
  }
2433
2612
  const ticketProvider = data.ticket_provider ?? {};
2434
- for (const key of ["type", "workspace", "identifier", "board_id", "board_url", "state"]) {
2613
+ for (const key of ["type", "workspace", "identifier", "board_id", "state"]) {
2435
2614
  if (!(key in ticketProvider)) details.push(`ticket_provider.${key} missing`);
2436
2615
  }
2437
- if (existsSync8(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
2616
+ if ("board_url" in ticketProvider) details.push("ticket_provider.board_url should be removed; derive it from provider/workspace/board_id");
2617
+ if (!ticketProvider.board_id && roles.some((role) => role.ticketProviderBoardId)) {
2618
+ details.push("ticket_provider.board_id missing even though legacy role.yaml contains a board binding");
2619
+ }
2620
+ const automation = data.automation ?? {};
2621
+ const reconcile = automation.reconcile ?? {};
2622
+ for (const key of ["enabled", "grace_hours", "auto_review"]) {
2623
+ if (!(key in reconcile)) details.push(`automation.reconcile.${key} missing`);
2624
+ }
2625
+ if (existsSync9(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
2438
2626
  return {
2439
2627
  id: "sot.project-json",
2440
2628
  title: "Canonical .project.json",
@@ -2447,7 +2635,7 @@ function projectJsonFinding(ctx) {
2447
2635
  function renderSoul(role) {
2448
2636
  const telegram = role.botHandle ? `@${role.botHandle}` : "(unwired)";
2449
2637
  const tone = role.role === "pm" ? `Direct and brief. Decision-forward. No throat-clearing, no apologies, no "I'll help you with that" preambles.` : "Direct and brief.";
2450
- const roleSpecific = role.role === "pm" ? `You are the project manager. You triage incoming work, create or refine tickets, and delegate implementation. You do not ship product code. A systemd heartbeat checkpoints your runtime; when this repo opts into reconciliation (\`reconcile.enabled\` in role.yaml), the same heartbeat also runs your continuous board-reconciliation pass out-of-band (\`.scripts/sentinel.prompt.md\`, \`--source cron\`), kept separate from your interactive session memory.` : `You operate as the ${role.role} agent for this repo.`;
2638
+ const roleSpecific = role.role === "pm" ? `You are the project manager. You triage incoming work, create or refine tickets, and delegate implementation. You do not ship product code. A systemd heartbeat checkpoints your runtime; when this repo opts into reconciliation (\`automation.reconcile.enabled\` in repo-root \`.project.json\`), the same heartbeat also runs your continuous board-reconciliation pass out-of-band (\`.scripts/sentinel.prompt.md\`, \`--source cron\`), kept separate from your interactive session memory.` : `You operate as the ${role.role} agent for this repo.`;
2451
2639
  const runtimeOwner = role.runtimeOwner || "delorenj";
2452
2640
  return `# ${role.displayName || role.agentId}
2453
2641
 
@@ -2515,17 +2703,17 @@ exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" HERMES_OAUTH
2515
2703
  `.replace(/\u0010/g, "$");
2516
2704
  }
2517
2705
  function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip) {
2518
- if (!existsSync8(sourceDir)) return;
2706
+ if (!existsSync9(sourceDir)) return;
2519
2707
  mkdirSync6(targetDir, { recursive: true });
2520
- for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
2521
- const sourcePath = join11(sourceDir, entry.name);
2708
+ for (const entry of readdirSync2(sourceDir, { withFileTypes: true })) {
2709
+ const sourcePath = join12(sourceDir, entry.name);
2522
2710
  if (skip?.(sourcePath)) continue;
2523
- const targetPath = join11(targetDir, entry.name);
2711
+ const targetPath = join12(targetDir, entry.name);
2524
2712
  if (entry.isDirectory()) {
2525
2713
  copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
2526
2714
  continue;
2527
2715
  }
2528
- if (existsSync8(targetPath)) continue;
2716
+ if (existsSync9(targetPath)) continue;
2529
2717
  changedFiles.push(targetPath);
2530
2718
  if (!dryRun) {
2531
2719
  ensureParent(targetPath);
@@ -2534,7 +2722,7 @@ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip)
2534
2722
  }
2535
2723
  }
2536
2724
  function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
2537
- const gitmodulesPath = join11(repoRoot, ".gitmodules");
2725
+ const gitmodulesPath = join12(repoRoot, ".gitmodules");
2538
2726
  const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
2539
2727
  const owner = role.runtimeOwner || "delorenj";
2540
2728
  const block = `[submodule "agents/hermes/${role.role}/runtime"]
@@ -2634,14 +2822,14 @@ var RULES = [
2634
2822
  id: "mise.config-root",
2635
2823
  title: "mise config_root + AGENTS link hooks",
2636
2824
  audit: (ctx) => {
2637
- const misePath = join11(ctx.repoRoot, "mise.toml");
2638
- if (!existsSync8(misePath)) {
2825
+ const misePath = join12(ctx.repoRoot, "mise.toml");
2826
+ if (!existsSync9(misePath)) {
2639
2827
  return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
2640
2828
  }
2641
2829
  const text2 = readText(misePath);
2642
2830
  const details = [];
2643
- const linkAgentfilesPath = join11(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2644
- if (!existsSync8(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2831
+ const linkAgentfilesPath = join12(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2832
+ if (!existsSync9(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2645
2833
  const pathValues = [...(text2.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2646
2834
  const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
2647
2835
  if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
@@ -2659,10 +2847,10 @@ var RULES = [
2659
2847
  };
2660
2848
  },
2661
2849
  migrate: (ctx, finding) => {
2662
- const path = join11(ctx.repoRoot, "mise.toml");
2850
+ const path = join12(ctx.repoRoot, "mise.toml");
2663
2851
  const changedFiles = [];
2664
2852
  const details = [];
2665
- if (!existsSync8(path)) {
2853
+ if (!existsSync9(path)) {
2666
2854
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2667
2855
  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: [] };
2668
2856
  }
@@ -2678,7 +2866,7 @@ var RULES = [
2678
2866
  if (!ctx.dryRun) writeText(path, next);
2679
2867
  text2 = next;
2680
2868
  }
2681
- const linkAgentfilesPath = join11(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2869
+ const linkAgentfilesPath = join12(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2682
2870
  const expectedScript = templateLinkAgentfilesScript(ctx);
2683
2871
  if (expectedScript === void 0) {
2684
2872
  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: [] };
@@ -2705,13 +2893,13 @@ var RULES = [
2705
2893
  title: "managed mise versioning block",
2706
2894
  audit: (ctx) => {
2707
2895
  const details = [];
2708
- const misePath = join11(ctx.repoRoot, "mise.toml");
2709
- const versioningPath = join11(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2710
- const manifestPath = join11(ctx.repoRoot, ".mise", "version-files.conf");
2896
+ const misePath = join12(ctx.repoRoot, "mise.toml");
2897
+ const versioningPath = join12(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2898
+ const manifestPath = join12(ctx.repoRoot, ".mise", "version-files.conf");
2711
2899
  const text2 = safeReadText(misePath);
2712
2900
  if (!text2?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
2713
- if (!existsSync8(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
2714
- if (!existsSync8(manifestPath)) details.push(".mise/version-files.conf missing");
2901
+ if (!existsSync9(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
2902
+ if (!existsSync9(manifestPath)) details.push(".mise/version-files.conf missing");
2715
2903
  return {
2716
2904
  id: "mise.versioning",
2717
2905
  title: "managed mise versioning block",
@@ -2724,8 +2912,8 @@ var RULES = [
2724
2912
  migrate: (ctx, finding) => {
2725
2913
  const changedFiles = [];
2726
2914
  const details = [];
2727
- const misePath = join11(ctx.repoRoot, "mise.toml");
2728
- if (!existsSync8(misePath)) {
2915
+ const misePath = join12(ctx.repoRoot, "mise.toml");
2916
+ if (!existsSync9(misePath)) {
2729
2917
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2730
2918
  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: [] };
2731
2919
  }
@@ -2735,12 +2923,21 @@ var RULES = [
2735
2923
  }
2736
2924
  }
2737
2925
  const currentMise = readText(misePath);
2738
- const nextMise = replaceOrAppendManagedBlock(currentMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
2926
+ let cleanedMise = currentMise;
2927
+ if (!currentMise.includes("# >>> mise-versioning >>>")) {
2928
+ const taskNames = ["version", "version:bump", "version:bump-patch", "version:bump-minor", "version:bump-major", "version:check", "version:sync"];
2929
+ for (const taskName of taskNames) {
2930
+ const escaped = taskName.replace(/:/g, "\\:");
2931
+ const headerPattern = new RegExp(`^\\[tasks\\.(?:"${escaped}"|'${escaped}'|${escaped})\\]$`);
2932
+ cleanedMise = removeTomlSection(cleanedMise, headerPattern);
2933
+ }
2934
+ }
2935
+ const nextMise = replaceOrAppendManagedBlock(cleanedMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
2739
2936
  if (nextMise !== currentMise) {
2740
2937
  if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
2741
2938
  if (!ctx.dryRun) writeText(misePath, nextMise);
2742
2939
  }
2743
- const versioningPath = join11(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2940
+ const versioningPath = join12(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2744
2941
  const expectedScript = templateVersioningScript(ctx);
2745
2942
  if (expectedScript === void 0) {
2746
2943
  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: [] };
@@ -2752,7 +2949,7 @@ var RULES = [
2752
2949
  chmodSync2(versioningPath, 493);
2753
2950
  }
2754
2951
  }
2755
- const manifestPath = join11(ctx.repoRoot, ".mise", "version-files.conf");
2952
+ const manifestPath = join12(ctx.repoRoot, ".mise", "version-files.conf");
2756
2953
  const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
2757
2954
  if (safeReadText(manifestPath) !== expectedManifest) {
2758
2955
  changedFiles.push(manifestPath);
@@ -2772,9 +2969,9 @@ var RULES = [
2772
2969
  id: "sot.agent-symlinks",
2773
2970
  title: "AGENTS/CLAUDE/GEMINI symlink contract",
2774
2971
  audit: (ctx) => {
2775
- const agentsPath = join11(ctx.repoRoot, "AGENTS.md");
2776
- if (!existsSync8(agentsPath)) {
2777
- const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync8(join11(ctx.repoRoot, file)));
2972
+ const agentsPath = join12(ctx.repoRoot, "AGENTS.md");
2973
+ if (!existsSync9(agentsPath)) {
2974
+ const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync9(join12(ctx.repoRoot, file)));
2778
2975
  if (fallbackSources.length === 0) {
2779
2976
  return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
2780
2977
  }
@@ -2789,7 +2986,7 @@ var RULES = [
2789
2986
  }
2790
2987
  const details = [];
2791
2988
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2792
- const full = join11(ctx.repoRoot, file);
2989
+ const full = join12(ctx.repoRoot, file);
2793
2990
  const target = readSymlinkTarget(full);
2794
2991
  if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
2795
2992
  }
@@ -2813,7 +3010,7 @@ var RULES = [
2813
3010
  return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
2814
3011
  }
2815
3012
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2816
- const full = join11(ctx.repoRoot, file);
3013
+ const full = join12(ctx.repoRoot, file);
2817
3014
  const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
2818
3015
  if (result.blocked) blockedDetails.push(result.blocked);
2819
3016
  if (result.changed) changedFiles.push(full);
@@ -2835,7 +3032,7 @@ var RULES = [
2835
3032
  migrate: (ctx, finding) => {
2836
3033
  const changedFiles = [];
2837
3034
  const details = [];
2838
- const path = join11(ctx.repoRoot, ".project.json");
3035
+ const path = join12(ctx.repoRoot, ".project.json");
2839
3036
  const existing = readProjectJson(ctx) ?? {};
2840
3037
  const canonical = canonicalProjectJson(ctx);
2841
3038
  const merged = { ...existing, ...canonical };
@@ -2845,10 +3042,10 @@ var RULES = [
2845
3042
  changedFiles.push(path);
2846
3043
  if (!ctx.dryRun) writeText(path, expected);
2847
3044
  }
2848
- const planeJson = join11(ctx.repoRoot, ".plane.json");
2849
- if (existsSync8(planeJson)) {
3045
+ const planeJson = join12(ctx.repoRoot, ".plane.json");
3046
+ if (existsSync9(planeJson)) {
2850
3047
  const backup = `${planeJson}.migrated-backup`;
2851
- if (existsSync8(backup)) {
3048
+ if (existsSync9(backup)) {
2852
3049
  details.push(`cannot back up .plane.json because ${relative(ctx.repoRoot, backup)} already exists`);
2853
3050
  } else {
2854
3051
  changedFiles.push(backup);
@@ -2870,8 +3067,8 @@ var RULES = [
2870
3067
  title: ".env.op + gitignore secrets contract",
2871
3068
  audit: (ctx) => {
2872
3069
  const details = [];
2873
- const envOp = safeReadText(join11(ctx.repoRoot, ".env.op"));
2874
- const gitignore = safeReadText(join11(ctx.repoRoot, ".gitignore"));
3070
+ const envOp = safeReadText(join12(ctx.repoRoot, ".env.op"));
3071
+ const gitignore = safeReadText(join12(ctx.repoRoot, ".gitignore"));
2875
3072
  if (!envOp) {
2876
3073
  details.push(".env.op missing");
2877
3074
  } else {
@@ -2897,12 +3094,12 @@ var RULES = [
2897
3094
  migrate: (ctx, finding) => {
2898
3095
  const changedFiles = [];
2899
3096
  const details = [];
2900
- const envOpPath = join11(ctx.repoRoot, ".env.op");
2901
- if (!existsSync8(envOpPath)) {
3097
+ const envOpPath = join12(ctx.repoRoot, ".env.op");
3098
+ if (!existsSync9(envOpPath)) {
2902
3099
  changedFiles.push(envOpPath);
2903
- if (!ctx.dryRun) writeText(envOpPath, readText(join11(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
3100
+ if (!ctx.dryRun) writeText(envOpPath, readText(join12(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2904
3101
  }
2905
- const gitignorePath = join11(ctx.repoRoot, ".gitignore");
3102
+ const gitignorePath = join12(ctx.repoRoot, ".gitignore");
2906
3103
  const gitignore = safeReadText(gitignorePath) ?? "";
2907
3104
  const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
2908
3105
  # NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
@@ -2929,7 +3126,7 @@ var RULES = [
2929
3126
  title: ".copier-answers.yml provenance + drift report",
2930
3127
  audit: (ctx) => {
2931
3128
  const details = [];
2932
- const path = join11(ctx.repoRoot, ".copier-answers.yml");
3129
+ const path = join12(ctx.repoRoot, ".copier-answers.yml");
2933
3130
  const text2 = safeReadText(path);
2934
3131
  const project = readProjectJson(ctx);
2935
3132
  if (!text2) {
@@ -2960,12 +3157,12 @@ var RULES = [
2960
3157
  const changedFiles = [];
2961
3158
  const project = canonicalProjectJson(ctx);
2962
3159
  const text2 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
2963
- _src_path: ${join11(ctx.pjanglerRoot, "templates", "commonproject")}
3160
+ _src_path: ${join12(ctx.pjanglerRoot, "templates", "commonproject")}
2964
3161
  project_description: ${String(project.project_description)}
2965
3162
  project_name: ${String(project.project_name)}
2966
3163
  ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2967
3164
  `;
2968
- const path = join11(ctx.repoRoot, ".copier-answers.yml");
3165
+ const path = join12(ctx.repoRoot, ".copier-answers.yml");
2969
3166
  if (safeReadText(path) !== text2) {
2970
3167
  changedFiles.push(path);
2971
3168
  if (!ctx.dryRun) writeText(path, text2);
@@ -2984,15 +3181,15 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2984
3181
  id: "bmad.scaffold",
2985
3182
  title: "BMAD modules/docs scaffold",
2986
3183
  audit: (ctx) => {
2987
- const sourceRoot = join11(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
2988
- const targetRoot = join11(ctx.repoRoot, "_bmad");
3184
+ const sourceRoot = join12(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
3185
+ const targetRoot = join12(ctx.repoRoot, "_bmad");
2989
3186
  const sentinels = [
2990
- join11("core", "config.yaml"),
2991
- join11("custom", "config.yaml"),
2992
- join11("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
2993
- join11("bmm", "workflows", "workflow-status", "workflow.yaml")
3187
+ join12("core", "config.yaml"),
3188
+ join12("custom", "config.yaml"),
3189
+ join12("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
3190
+ join12("bmm", "workflows", "workflow-status", "workflow.yaml")
2994
3191
  ];
2995
- const missing = sentinels.filter((file) => existsSync8(join11(sourceRoot, file)) && !existsSync8(join11(targetRoot, file)));
3192
+ const missing = sentinels.filter((file) => existsSync9(join12(sourceRoot, file)) && !existsSync9(join12(targetRoot, file)));
2996
3193
  return {
2997
3194
  id: "bmad.scaffold",
2998
3195
  title: "BMAD modules/docs scaffold",
@@ -3004,7 +3201,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3004
3201
  },
3005
3202
  migrate: (ctx, finding) => {
3006
3203
  const changedFiles = [];
3007
- copyMissingRecursive(join11(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join11(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
3204
+ copyMissingRecursive(join12(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join12(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
3008
3205
  return {
3009
3206
  id: finding.id,
3010
3207
  title: finding.title,
@@ -3026,11 +3223,11 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3026
3223
  }
3027
3224
  const details = [];
3028
3225
  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"]) {
3029
- if (!existsSync8(join11(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join11(role.roleDir, rel))}`);
3226
+ if (!existsSync9(join12(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join12(role.roleDir, rel))}`);
3030
3227
  }
3031
- const gitmodules = safeReadText(join11(ctx.repoRoot, ".gitmodules")) ?? "";
3228
+ const gitmodules = safeReadText(join12(ctx.repoRoot, ".gitmodules")) ?? "";
3032
3229
  if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing pm runtime submodule entry");
3033
- if (!profileMetaInheritsDefault(join11(role.roleDir, "runtime", "profile.yaml"))) {
3230
+ if (!profileMetaInheritsDefault(join12(role.roleDir, "runtime", "profile.yaml"))) {
3034
3231
  details.push("runtime/profile.yaml missing inherited default config metadata");
3035
3232
  }
3036
3233
  const registry = safeReadText(registryPath(ctx.homeDir));
@@ -3051,21 +3248,21 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3051
3248
  if (!role) {
3052
3249
  return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
3053
3250
  }
3054
- const templateRoleDir = join11(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
3055
- writeIfDifferent(join11(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
3056
- writeIfDifferent(join11(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
3057
- writeIfDifferent(join11(role.roleDir, ".gitignore"), readText(join11(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
3058
- copyMissingRecursive(join11(templateRoleDir, ".runtime-scaffold"), join11(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
3059
- copyMissingRecursive(join11(templateRoleDir, ".runtime-scaffold"), join11(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
3060
- copyMissingRecursive(join11(templateRoleDir, ".scripts"), join11(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
3061
- const promptSource = join11(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
3062
- const promptTarget = join11(role.roleDir, ".scripts", "sentinel.prompt.md");
3063
- if (existsSync8(promptSource) && !existsSync8(promptTarget)) {
3251
+ const templateRoleDir = join12(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
3252
+ writeIfDifferent(join12(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
3253
+ writeIfDifferent(join12(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
3254
+ writeIfDifferent(join12(role.roleDir, ".gitignore"), readText(join12(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
3255
+ copyMissingRecursive(join12(templateRoleDir, ".runtime-scaffold"), join12(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
3256
+ copyMissingRecursive(join12(templateRoleDir, ".runtime-scaffold"), join12(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
3257
+ copyMissingRecursive(join12(templateRoleDir, ".scripts"), join12(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
3258
+ const promptSource = join12(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
3259
+ const promptTarget = join12(role.roleDir, ".scripts", "sentinel.prompt.md");
3260
+ if (existsSync9(promptSource) && !existsSync9(promptTarget)) {
3064
3261
  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);
3065
3262
  writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
3066
3263
  }
3067
3264
  upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
3068
- const profileMetaUpdated = upsertInheritedProfileMeta(join11(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
3265
+ const profileMetaUpdated = upsertInheritedProfileMeta(join12(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
3069
3266
  if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
3070
3267
  const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
3071
3268
  if (registryUpdated) details.push(`updated ${registryUpdated}`);
@@ -3079,6 +3276,103 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3079
3276
  };
3080
3277
  }
3081
3278
  },
3279
+ {
3280
+ id: "hermes.untracked-runtimes",
3281
+ title: "Hermes agent runtimes untracked + gitignored",
3282
+ audit: (ctx) => {
3283
+ const roles = discoverRoles(ctx.repoRoot);
3284
+ if (roles.length === 0) {
3285
+ return {
3286
+ id: "hermes.untracked-runtimes",
3287
+ title: "Hermes agent runtimes untracked + gitignored",
3288
+ status: "skip",
3289
+ summary: "No Hermes roles present",
3290
+ details: [],
3291
+ fixable: false
3292
+ };
3293
+ }
3294
+ const details = [];
3295
+ for (const role of roles) {
3296
+ const roleRelDir = relative(ctx.repoRoot, role.roleDir);
3297
+ const runtimeRelPath = join12(roleRelDir, "runtime");
3298
+ const lsResult = spawnSync6("git", ["ls-files", "--stage", runtimeRelPath], {
3299
+ cwd: ctx.repoRoot,
3300
+ encoding: "utf8"
3301
+ });
3302
+ if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
3303
+ details.push(`submodule runtime is tracked in Git index at ${runtimeRelPath}`);
3304
+ }
3305
+ const gitignorePath = join12(role.roleDir, ".gitignore");
3306
+ if (existsSync9(gitignorePath)) {
3307
+ const content = safeReadText(gitignorePath) ?? "";
3308
+ const lines = content.split(/\r?\n/).map((line) => line.trim());
3309
+ if (!lines.includes("runtime/") && !lines.includes("runtime")) {
3310
+ details.push(`.gitignore missing runtime/ ignore entry in ${relative(ctx.repoRoot, gitignorePath)}`);
3311
+ }
3312
+ } else {
3313
+ details.push(`.gitignore is missing in ${relative(ctx.repoRoot, gitignorePath)}`);
3314
+ }
3315
+ }
3316
+ return {
3317
+ id: "hermes.untracked-runtimes",
3318
+ title: "Hermes agent runtimes untracked + gitignored",
3319
+ status: details.length === 0 ? "pass" : "fail",
3320
+ summary: details.length === 0 ? "All Hermes agent runtimes are untracked and gitignored" : `${details.length} issue(s) with untracked/ignored runtimes detected`,
3321
+ details,
3322
+ fixable: true
3323
+ };
3324
+ },
3325
+ migrate: (ctx, finding) => {
3326
+ const roles = discoverRoles(ctx.repoRoot);
3327
+ const changedFiles = [];
3328
+ const details = [];
3329
+ for (const role of roles) {
3330
+ const roleRelDir = relative(ctx.repoRoot, role.roleDir);
3331
+ const runtimeRelPath = join12(roleRelDir, "runtime");
3332
+ const lsResult = spawnSync6("git", ["ls-files", "--stage", runtimeRelPath], {
3333
+ cwd: ctx.repoRoot,
3334
+ encoding: "utf8"
3335
+ });
3336
+ if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
3337
+ details.push(`untrack ${runtimeRelPath}`);
3338
+ changedFiles.push(runtimeRelPath);
3339
+ if (!ctx.dryRun) {
3340
+ spawnSync6("git", ["rm", "--cached", "-r", runtimeRelPath], {
3341
+ cwd: ctx.repoRoot,
3342
+ encoding: "utf8"
3343
+ });
3344
+ }
3345
+ }
3346
+ const gitignorePath = join12(role.roleDir, ".gitignore");
3347
+ let content = "";
3348
+ let isIgnored = false;
3349
+ if (existsSync9(gitignorePath)) {
3350
+ content = safeReadText(gitignorePath) ?? "";
3351
+ const lines = content.split(/\r?\n/).map((line) => line.trim());
3352
+ isIgnored = lines.includes("runtime/") || lines.includes("runtime");
3353
+ }
3354
+ if (!isIgnored) {
3355
+ details.push(`ignore runtime/ in ${relative(ctx.repoRoot, gitignorePath)}`);
3356
+ changedFiles.push(gitignorePath);
3357
+ if (!ctx.dryRun) {
3358
+ if (content && !content.endsWith("\n")) {
3359
+ content += "\n";
3360
+ }
3361
+ content += "runtime/\n";
3362
+ writeText(gitignorePath, content);
3363
+ }
3364
+ }
3365
+ }
3366
+ return {
3367
+ id: finding.id,
3368
+ title: finding.title,
3369
+ status: changedFiles.length ? "applied" : "noop",
3370
+ summary: changedFiles.length ? "Hermes agent runtimes made untracked and ignored" : "No changes required",
3371
+ changedFiles,
3372
+ details
3373
+ };
3374
+ }
3375
+ },
3082
3376
  {
3083
3377
  id: "systemd.sentinel",
3084
3378
  title: "Hermes systemd/sentinel units enabled + active",
@@ -3119,9 +3413,9 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3119
3413
  return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
3120
3414
  }
3121
3415
  for (const role of roles) {
3122
- const sysDir = join11(ctx.homeDir, ".config", "systemd", "user");
3416
+ const sysDir = join12(ctx.homeDir, ".config", "systemd", "user");
3123
3417
  const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
3124
- const allUnitsPresent = units.every((unit) => existsSync8(join11(sysDir, unit)));
3418
+ const allUnitsPresent = units.every((unit) => existsSync9(join12(sysDir, unit)));
3125
3419
  if (allUnitsPresent) {
3126
3420
  if (ctx.dryRun) {
3127
3421
  details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
@@ -3133,12 +3427,12 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3133
3427
  }
3134
3428
  continue;
3135
3429
  }
3136
- for (const script of [join11(role.roleDir, ".scripts", "70-systemd.sh")]) {
3137
- if (!script || !existsSync8(script)) continue;
3430
+ for (const script of [join12(role.roleDir, ".scripts", "70-systemd.sh")]) {
3431
+ if (!script || !existsSync9(script)) continue;
3138
3432
  if (ctx.dryRun) {
3139
3433
  details.push(`would run: bash ${script}`);
3140
3434
  } else {
3141
- const result = spawnSync5("bash", [script], { cwd: role.roleDir, encoding: "utf8" });
3435
+ const result = spawnSync6("bash", [script], { cwd: role.roleDir, encoding: "utf8" });
3142
3436
  if (result.status !== 0) details.push(`script failed: ${script}: ${result.stderr.trim() || result.stdout.trim()}`);
3143
3437
  }
3144
3438
  }
@@ -3258,7 +3552,7 @@ var server = new McpServer({
3258
3552
  var TICKET_PROVIDER_SCHEMA = z.enum(["plane", "trello"]);
3259
3553
  function resolveTargetDir(targetDir) {
3260
3554
  const dir = resolve3(targetDir ?? process.cwd());
3261
- if (!existsSync9(dir)) {
3555
+ if (!existsSync10(dir)) {
3262
3556
  throw new Error(`Target directory does not exist: ${dir}`);
3263
3557
  }
3264
3558
  if (!statSync2(dir).isDirectory()) {
@@ -3269,7 +3563,7 @@ function resolveTargetDir(targetDir) {
3269
3563
  function resolvePjanglerRoot3() {
3270
3564
  let dir = dirname8(fileURLToPath5(import.meta.url));
3271
3565
  while (dir !== dirname8(dir)) {
3272
- if (existsSync9(join12(dir, "package.json")) && existsSync9(join12(dir, "templates", "commonproject", "copier.yml"))) {
3566
+ if (existsSync10(join13(dir, "package.json")) && existsSync10(join13(dir, "templates", "commonproject", "copier.yml"))) {
3273
3567
  return dir;
3274
3568
  }
3275
3569
  dir = dirname8(dir);
@@ -3471,8 +3765,8 @@ server.registerTool(
3471
3765
  const pjanglerRoot = resolvePjanglerRoot3();
3472
3766
  const projectSlug = input.projectSlug ?? slugify(input.projectName);
3473
3767
  const parentDir = resolve3(input.parentDir ?? process.cwd());
3474
- if (!existsSync9(parentDir) || !statSync2(parentDir).isDirectory()) throw new Error(`Parent directory does not exist: ${parentDir}`);
3475
- const targetDir = resolve3(input.targetDir ?? join12(parentDir, projectSlug));
3768
+ if (!existsSync10(parentDir) || !statSync2(parentDir).isDirectory()) throw new Error(`Parent directory does not exist: ${parentDir}`);
3769
+ const targetDir = resolve3(input.targetDir ?? join13(parentDir, projectSlug));
3476
3770
  const overwrite = input.overwrite ?? input.force ?? false;
3477
3771
  const dryRun = input.dryRun ?? true;
3478
3772
  const local = input.local ?? true;
@@ -3482,7 +3776,7 @@ server.registerTool(
3482
3776
  if (!skipPlane && ticketProvider === "plane" && !boardId) {
3483
3777
  throw new Error("boardId or planeProjectId is required when skipPlane=false for Plane; keep skipPlane=true for safe local bootstrap");
3484
3778
  }
3485
- if (!dryRun && existsSync9(targetDir) && !overwrite) throw new Error(`Target already exists: ${targetDir} (set force/overwrite=true to re-render)`);
3779
+ if (!dryRun && existsSync10(targetDir) && !overwrite) throw new Error(`Target already exists: ${targetDir} (set force/overwrite=true to re-render)`);
3486
3780
  const plan = planProjectInit({
3487
3781
  name: input.projectName,
3488
3782
  description: input.projectDescription,