@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
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { spawnSync as spawnSync6 } from "node:child_process";
5
- import { existsSync as existsSync9, readFileSync as readFileSync6, statSync as statSync2 } from "node:fs";
6
- import { basename as basename4, join as join12, resolve as resolve3 } from "node:path";
4
+ import { spawnSync as spawnSync7 } from "node:child_process";
5
+ import { existsSync as existsSync10, readFileSync as readFileSync7, statSync as statSync2 } from "node:fs";
6
+ import { basename as basename4, join as join13, resolve as resolve3 } from "node:path";
7
7
  import { Command as Command3 } from "commander";
8
8
 
9
9
  // src/commands/hermes/types.ts
@@ -82,6 +82,8 @@ function renderHostConfig() {
82
82
  const hermesRepo = join2(home, "code", "hermes-agent");
83
83
  const scaffoldDir = join2(home, "code", "hermes-agent-template", "runtime-scaffold");
84
84
  const skillsDir = join2(home, ".agents", "skills");
85
+ const pmExternalSkillGlobalDir = join2(home, "code", "skillex", "skill-sets", "global", ".system");
86
+ const pmExternalSkillBmadDir = join2(home, "code", "skillex", "packs", "bmad", "6.10.2");
85
87
  return `# hermes-agent-template \u2014 host configuration
86
88
  # Bootstrapped by \`pjangler config bootstrap\` for $HOME=${home} (platform=${platform()}).
87
89
  #
@@ -97,9 +99,14 @@ home = "~/.hermes"
97
99
  hermes_bin = "${hermesBin}"
98
100
  hermes_repo = "${hermesRepo}"
99
101
  runtime_scaffold_dir = "${scaffoldDir}"
102
+ # Shared fleet source-of-truth env file + fleet registry. ~ is expanded.
100
103
  fleet_env = "~/.hermes/fleet.env"
101
104
  registry_file = "~/.hermes/agents-registry.yaml"
102
105
  canonical_skills_dir = "${skillsDir}"
106
+ pm_external_skill_dirs = [
107
+ "${pmExternalSkillGlobalDir}",
108
+ "${pmExternalSkillBmadDir}",
109
+ ]
103
110
  symlinked_runtime_skills = []
104
111
 
105
112
  [github]
@@ -909,10 +916,100 @@ var RunCopierTemplate = class extends Command {
909
916
  }
910
917
  };
911
918
 
912
- // src/commands/hermes/WireTelegram.ts
919
+ // src/commands/hermes/UntrackHermesRuntimes.ts
920
+ import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync3, readdirSync } from "fs";
921
+ import { join as join6 } from "path";
913
922
  import { spawnSync as spawnSync2 } from "node:child_process";
914
- import { join as join6 } from "node:path";
915
- import { existsSync as existsSync4, unlinkSync } from "node:fs";
923
+ var UntrackHermesRuntimes = class extends Command {
924
+ async invoke() {
925
+ const targetDir = this.context.targetDir;
926
+ const rolesDir = join6(targetDir, "agents", "hermes");
927
+ if (!existsSync4(rolesDir)) {
928
+ return {
929
+ success: true,
930
+ message: "No Hermes agents found (no agents/hermes directory)."
931
+ };
932
+ }
933
+ const roles = readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
934
+ if (roles.length === 0) {
935
+ return {
936
+ success: true,
937
+ message: "No Hermes agents found."
938
+ };
939
+ }
940
+ let modifiedAny = false;
941
+ const details = [];
942
+ for (const role of roles) {
943
+ const roleDir = join6("agents", "hermes", role);
944
+ const runtimePath = join6(roleDir, "runtime");
945
+ const gitignorePath = join6(roleDir, ".gitignore");
946
+ let isTracked = false;
947
+ const lsResult = spawnSync2("git", ["ls-files", "--stage", runtimePath], {
948
+ cwd: targetDir,
949
+ encoding: "utf8"
950
+ });
951
+ if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
952
+ isTracked = true;
953
+ }
954
+ let isIgnored = false;
955
+ const fullGitignorePath = join6(targetDir, gitignorePath);
956
+ if (existsSync4(fullGitignorePath)) {
957
+ const content = readFileSync2(fullGitignorePath, "utf8");
958
+ const lines = content.split(/\r?\n/).map((line) => line.trim());
959
+ isIgnored = lines.includes("runtime/") || lines.includes("runtime");
960
+ }
961
+ if (isTracked || !isIgnored) {
962
+ modifiedAny = true;
963
+ if (isTracked) {
964
+ details.push(`untrack agents/hermes/${role}/runtime`);
965
+ if (!this.context.dryRun) {
966
+ const rmResult = spawnSync2("git", ["rm", "--cached", "-r", runtimePath], {
967
+ cwd: targetDir,
968
+ encoding: "utf8"
969
+ });
970
+ if (rmResult.status !== 0) {
971
+ return {
972
+ success: false,
973
+ message: `Failed to untrack agents/hermes/${role}/runtime: ${rmResult.stderr}`
974
+ };
975
+ }
976
+ }
977
+ }
978
+ if (!isIgnored) {
979
+ details.push(`ignore runtime/ in agents/hermes/${role}/.gitignore`);
980
+ if (!this.context.dryRun) {
981
+ let content = "";
982
+ if (existsSync4(fullGitignorePath)) {
983
+ content = readFileSync2(fullGitignorePath, "utf8");
984
+ }
985
+ if (content && !content.endsWith("\n")) {
986
+ content += "\n";
987
+ }
988
+ content += "runtime/\n";
989
+ writeFileSync3(fullGitignorePath, content, "utf8");
990
+ }
991
+ }
992
+ }
993
+ }
994
+ if (!modifiedAny) {
995
+ return {
996
+ success: true,
997
+ message: "\u2705 All Hermes agent runtimes are already untracked and gitignored."
998
+ };
999
+ }
1000
+ const actionText = this.context.dryRun ? "Would make" : "Made";
1001
+ return {
1002
+ success: true,
1003
+ message: `${actionText} Hermes agent runtimes untracked and gitignored:
1004
+ ${details.map((d) => ` - ${d}`).join("\n")}`
1005
+ };
1006
+ }
1007
+ };
1008
+
1009
+ // src/commands/hermes/WireTelegram.ts
1010
+ import { spawnSync as spawnSync3 } from "node:child_process";
1011
+ import { join as join7 } from "node:path";
1012
+ import { existsSync as existsSync5, unlinkSync } from "node:fs";
916
1013
  import * as p3 from "@clack/prompts";
917
1014
  var WireTelegram = class extends Command {
918
1015
  async invoke() {
@@ -934,7 +1031,7 @@ var WireTelegram = class extends Command {
934
1031
  let token = process.env.TELEGRAM_BOT_TOKEN;
935
1032
  let source = token ? "env" : null;
936
1033
  if (!token) {
937
- const tryOp = spawnSync2("op", ["read", vaultRef], { encoding: "utf8" });
1034
+ const tryOp = spawnSync3("op", ["read", vaultRef], { encoding: "utf8" });
938
1035
  if (tryOp.status === 0) {
939
1036
  token = tryOp.stdout.trim();
940
1037
  source = "op";
@@ -973,7 +1070,7 @@ var WireTelegram = class extends Command {
973
1070
  initialValue: true
974
1071
  });
975
1072
  if (!p3.isCancel(persist) && persist) {
976
- const create = spawnSync2(
1073
+ const create = spawnSync3(
977
1074
  "op",
978
1075
  [
979
1076
  "item",
@@ -1000,18 +1097,18 @@ var WireTelegram = class extends Command {
1000
1097
  if (p3.isCancel(allowedAnswer)) {
1001
1098
  return { success: false, message: "\u2717 Aborted; Telegram step deferred." };
1002
1099
  }
1003
- const script = join6(roleDir, ".scripts", "30-telegram.sh");
1004
- if (!existsSync4(script)) {
1100
+ const script = join7(roleDir, ".scripts", "30-telegram.sh");
1101
+ if (!existsSync5(script)) {
1005
1102
  return {
1006
1103
  success: false,
1007
1104
  message: `\u2717 ${script} not found. Did copier finish? Re-run with --skip-runtime-repo=0 if you skipped it.`
1008
1105
  };
1009
1106
  }
1010
- const marker = join6(roleDir, ".scripts", ".done-30-telegram");
1011
- if (existsSync4(marker)) unlinkSync(marker);
1107
+ const marker = join7(roleDir, ".scripts", ".done-30-telegram");
1108
+ if (existsSync5(marker)) unlinkSync(marker);
1012
1109
  const spinner4 = p3.spinner();
1013
1110
  spinner4.start("Verifying token + wiring profile");
1014
- const result = spawnSync2("bash", [script], {
1111
+ const result = spawnSync3("bash", [script], {
1015
1112
  stdio: "inherit",
1016
1113
  env: {
1017
1114
  ...process.env,
@@ -1034,9 +1131,9 @@ function cap(s) {
1034
1131
  }
1035
1132
 
1036
1133
  // src/commands/hermes/WireEmail.ts
1037
- import { spawnSync as spawnSync3 } from "node:child_process";
1038
- import { join as join7 } from "node:path";
1039
- import { existsSync as existsSync5, unlinkSync as unlinkSync2 } from "node:fs";
1134
+ import { spawnSync as spawnSync4 } from "node:child_process";
1135
+ import { join as join8 } from "node:path";
1136
+ import { existsSync as existsSync6, unlinkSync as unlinkSync2 } from "node:fs";
1040
1137
  import * as p4 from "@clack/prompts";
1041
1138
  var WireEmail = class extends Command {
1042
1139
  async invoke() {
@@ -1051,13 +1148,13 @@ var WireEmail = class extends Command {
1051
1148
  if (!targetRepo || !role || !roleDir) {
1052
1149
  return { success: false, message: "Cannot wire email: missing target_repo/role/roleDir" };
1053
1150
  }
1054
- const script = join7(roleDir, ".scripts", "50-email.sh");
1055
- if (!existsSync5(script)) {
1151
+ const script = join8(roleDir, ".scripts", "50-email.sh");
1152
+ if (!existsSync6(script)) {
1056
1153
  return { success: false, message: `\u2717 ${script} not found` };
1057
1154
  }
1058
1155
  let token = process.env.CF_EMAIL_ROUTING_TOKEN;
1059
1156
  if (!token) {
1060
- const tryOp = spawnSync3(
1157
+ const tryOp = spawnSync4(
1061
1158
  "op",
1062
1159
  ["read", "op://DeLoSecrets/Cloudflare-EmailRouting/token"],
1063
1160
  { encoding: "utf8" }
@@ -1097,7 +1194,7 @@ var WireEmail = class extends Command {
1097
1194
  initialValue: true
1098
1195
  });
1099
1196
  if (!p4.isCancel(persist) && persist) {
1100
- const create = spawnSync3(
1197
+ const create = spawnSync4(
1101
1198
  "op",
1102
1199
  [
1103
1200
  "item",
@@ -1114,11 +1211,11 @@ var WireEmail = class extends Command {
1114
1211
  }
1115
1212
  }
1116
1213
  }
1117
- const marker = join7(roleDir, ".scripts", ".done-50-email");
1118
- if (existsSync5(marker)) unlinkSync2(marker);
1214
+ const marker = join8(roleDir, ".scripts", ".done-50-email");
1215
+ if (existsSync6(marker)) unlinkSync2(marker);
1119
1216
  const spinner4 = p4.spinner();
1120
1217
  spinner4.start("Creating Cloudflare Email Routing rule");
1121
- const result = spawnSync3("bash", [script], {
1218
+ const result = spawnSync4("bash", [script], {
1122
1219
  stdio: "inherit",
1123
1220
  env: { ...process.env, SKIP_EMAIL: "0", CF_EMAIL_ROUTING_TOKEN: token },
1124
1221
  cwd: roleDir
@@ -1178,7 +1275,7 @@ var PrintHermesSummary = class extends Command {
1178
1275
  var HermesAgentRecipe = class extends Recipe {
1179
1276
  constructor(context) {
1180
1277
  super(context);
1181
- this.addIngredient(EnsureTemplateConfig).addIngredient(PromptForAgentConfig).addIngredient(RunCopierTemplate).addIngredient(WireTelegram).addIngredient(WireEmail).addIngredient(PrintHermesSummary);
1278
+ this.addIngredient(EnsureTemplateConfig).addIngredient(PromptForAgentConfig).addIngredient(RunCopierTemplate).addIngredient(UntrackHermesRuntimes).addIngredient(WireTelegram).addIngredient(WireEmail).addIngredient(PrintHermesSummary);
1182
1279
  }
1183
1280
  // Override execute() to suppress the base class's per-command logging since
1184
1281
  // our commands already render their own UI via @clack/prompts.
@@ -1202,33 +1299,33 @@ var HermesAgentRecipe = class extends Recipe {
1202
1299
 
1203
1300
  // src/commands/AgentHooksCommands.ts
1204
1301
  import { homedir as homedir4 } from "node:os";
1205
- import { join as join9, dirname as dirname5 } from "node:path";
1206
- import { existsSync as existsSync7, cpSync, mkdirSync as mkdirSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
1302
+ import { join as join10, dirname as dirname5 } from "node:path";
1303
+ import { existsSync as existsSync8, cpSync, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "node:fs";
1207
1304
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1208
1305
 
1209
1306
  // src/project/index.ts
1210
- import { spawnSync as spawnSync4 } from "node:child_process";
1211
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync2, renameSync, statSync, writeFileSync as writeFileSync3 } from "node:fs";
1307
+ import { spawnSync as spawnSync5 } from "node:child_process";
1308
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync3, renameSync, statSync, writeFileSync as writeFileSync4 } from "node:fs";
1212
1309
  import { homedir as homedir3 } from "node:os";
1213
- import { basename as basename2, dirname as dirname4, join as join8, resolve } from "node:path";
1310
+ import { basename as basename2, delimiter, dirname as dirname4, join as join9, resolve } from "node:path";
1214
1311
  import YAML from "yaml";
1215
1312
  var PROJECT_REGISTRY_ENV = "PJ_PROJECT_REGISTRY";
1313
+ var PROJECT_SOURCE_SKILL_ROOTS_ENV = "PJ_SOURCE_SKILL_ROOTS";
1216
1314
  var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
1217
- var KNOWN_SKILL_ROOTS = [
1315
+ var DEFAULT_SOURCE_SKILL_ROOTS = [
1218
1316
  "/home/delorenj/code/skillex/all-skills",
1219
- "/home/delorenj/code/CoachingAgentFramework/.agents/skills",
1220
- "/home/delorenj/code/pjangler/.agents/skills",
1221
- join8(homedir3(), ".codex", "skills")
1317
+ join9(homedir3(), ".agents", "skills"),
1318
+ join9(homedir3(), ".codex", "skills")
1222
1319
  ];
1223
1320
  function projectRegistryPath(env2 = process.env) {
1224
- return expandHome(env2[PROJECT_REGISTRY_ENV] || join8(homedir3(), ".config", "pjangler", "projects.yaml"));
1321
+ return expandHome(env2[PROJECT_REGISTRY_ENV] || join9(homedir3(), ".config", "pjangler", "projects.yaml"));
1225
1322
  }
1226
1323
  function emptyProjectRegistry() {
1227
1324
  return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
1228
1325
  }
1229
1326
  function loadProjectRegistry(path = projectRegistryPath()) {
1230
- if (!existsSync6(path)) return emptyProjectRegistry();
1231
- const raw = YAML.parse(readFileSync2(path, "utf8"));
1327
+ if (!existsSync7(path)) return emptyProjectRegistry();
1328
+ const raw = YAML.parse(readFileSync3(path, "utf8"));
1232
1329
  if (raw == null) return emptyProjectRegistry();
1233
1330
  if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
1234
1331
  const registry = raw;
@@ -1243,7 +1340,7 @@ function saveProjectRegistry(registry, path = projectRegistryPath()) {
1243
1340
  validateProjectRegistry(registry);
1244
1341
  mkdirSync4(dirname4(path), { recursive: true });
1245
1342
  const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
1246
- writeFileSync3(temp, YAML.stringify(registry, { lineWidth: 0 }), "utf8");
1343
+ writeFileSync4(temp, YAML.stringify(registry, { lineWidth: 0 }), "utf8");
1247
1344
  renameSync(temp, path);
1248
1345
  }
1249
1346
  function validateProjectRegistry(registry) {
@@ -1288,8 +1385,7 @@ function buildTicketProviderBlock(input) {
1288
1385
  workspace: input.workspace ?? "",
1289
1386
  identifier: input.identifier,
1290
1387
  board_id: boardId,
1291
- board_url: input.boardUrl ?? (boardId ? `https://trello.com/b/${boardId}` : ""),
1292
- state: "planned"
1388
+ state: boardId ? "linked" : "planned"
1293
1389
  };
1294
1390
  }
1295
1391
  const workspace = input.workspace ?? "33god";
@@ -1298,8 +1394,16 @@ function buildTicketProviderBlock(input) {
1298
1394
  workspace,
1299
1395
  identifier: input.identifier,
1300
1396
  board_id: boardId,
1301
- board_url: input.boardUrl ?? (boardId ? `https://plane.delo.sh/${workspace}/projects/${boardId}/issues/` : ""),
1302
- state: "planned"
1397
+ state: boardId ? "linked" : "planned"
1398
+ };
1399
+ }
1400
+ function defaultProjectAutomation() {
1401
+ return {
1402
+ reconcile: {
1403
+ enabled: false,
1404
+ grace_hours: 0,
1405
+ auto_review: true
1406
+ }
1303
1407
  };
1304
1408
  }
1305
1409
  function slugifyProjectName(value) {
@@ -1318,7 +1422,7 @@ function resolveAgentHooksLayer(input, env2 = process.env) {
1318
1422
  const override = env2.PJ_AGENT_HOOKS_LAYER;
1319
1423
  if (override === "0" || override === "false") return false;
1320
1424
  if (override === "1" || override === "true") return true;
1321
- return !existsSync6(join8(homedir3(), ".agents", "hooks"));
1425
+ return !existsSync7(join9(homedir3(), ".agents", "hooks"));
1322
1426
  }
1323
1427
  function jsonStable(value) {
1324
1428
  return JSON.stringify(value);
@@ -1333,18 +1437,31 @@ function defaultProjectTargetDir(name, cwd = process.cwd()) {
1333
1437
  const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
1334
1438
  return resolve(dirname4(resolve(cwd)), compactName);
1335
1439
  }
1336
- function resolveSourceSkillPath(sourceSkill) {
1440
+ function sourceSkillRoots(env2 = process.env) {
1441
+ const configuredRoots = (env2[PROJECT_SOURCE_SKILL_ROOTS_ENV] || "").split(delimiter).map((root) => root.trim()).filter(Boolean);
1442
+ const seen = /* @__PURE__ */ new Set();
1443
+ const roots = [];
1444
+ for (const root of [...DEFAULT_SOURCE_SKILL_ROOTS, ...configuredRoots]) {
1445
+ const normalized = resolve(expandHome(root));
1446
+ if (seen.has(normalized)) continue;
1447
+ seen.add(normalized);
1448
+ roots.push(normalized);
1449
+ }
1450
+ return roots;
1451
+ }
1452
+ function resolveSourceSkillPath(sourceSkill, env2 = process.env) {
1337
1453
  if (!sourceSkill) return void 0;
1338
1454
  const expanded = expandHome(sourceSkill);
1339
1455
  const direct = resolve(expanded);
1340
- if (existsSync6(direct)) return direct;
1456
+ if (existsSync7(direct)) return direct;
1341
1457
  const name = basename2(sourceSkill);
1342
- for (const root of KNOWN_SKILL_ROOTS) {
1343
- const candidate = join8(root, name);
1344
- if (existsSync6(candidate)) return candidate;
1458
+ const roots = sourceSkillRoots(env2);
1459
+ for (const root of roots) {
1460
+ const candidate = join9(root, name);
1461
+ if (existsSync7(candidate)) return candidate;
1345
1462
  }
1346
- const civilWarLetterifier = "/home/delorenj/code/skillex/all-skills/civilwar-letterifier";
1347
- const hint = existsSync6(civilWarLetterifier) ? ` Did you mean ${civilWarLetterifier}?` : "";
1463
+ const searched = roots.length ? ` Searched roots: ${roots.join(", ")}.` : "";
1464
+ const hint = `${searched} Add project-specific roots with ${PROJECT_SOURCE_SKILL_ROOTS_ENV}.`;
1348
1465
  throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
1349
1466
  }
1350
1467
  function planProjectInit(input) {
@@ -1384,10 +1501,10 @@ function planProjectInit(input) {
1384
1501
  type: input.ticketProvider ?? "plane",
1385
1502
  identifier,
1386
1503
  boardId: input.boardId ?? input.planeProjectId,
1387
- boardUrl: input.boardUrl,
1388
1504
  workspace: input.boardWorkspace ?? input.planeWorkspace
1389
1505
  }),
1390
1506
  agents,
1507
+ automation: existing?.automation ?? defaultProjectAutomation(),
1391
1508
  created_at: existing?.created_at ?? now,
1392
1509
  updated_at: now
1393
1510
  };
@@ -1415,7 +1532,6 @@ function planProjectInit(input) {
1415
1532
  planeProjectId: project.ticket_provider.board_id ?? "",
1416
1533
  ticketWorkspace: project.ticket_provider.workspace ?? "",
1417
1534
  boardId: project.ticket_provider.board_id ?? "",
1418
- boardUrl: project.ticket_provider.board_url ?? "",
1419
1535
  projectIdentifier: identifier,
1420
1536
  primaryLanguage: project.template.commonproject.primary_language,
1421
1537
  agentHooksLayer: resolveAgentHooksLayer(input.agentHooksLayer),
@@ -1423,7 +1539,7 @@ function planProjectInit(input) {
1423
1539
  }));
1424
1540
  }
1425
1541
  actions.push(
1426
- { kind: "project.write-manifest", path: join8(targetDir, ".project.json"), manifest },
1542
+ { kind: "project.write-manifest", path: join9(targetDir, ".project.json"), manifest },
1427
1543
  {
1428
1544
  kind: "ticket-provider.create-or-link",
1429
1545
  enabled: live,
@@ -1464,7 +1580,7 @@ function executeProjectInitPlan(plan) {
1464
1580
  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"
1465
1581
  );
1466
1582
  mkdirSync4(dirname4(action.targetDir), { recursive: true });
1467
- const result = spawnSync4(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
1583
+ const result = spawnSync5(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
1468
1584
  if (result.stdout?.trim()) logs.push(result.stdout.trim());
1469
1585
  if (result.stderr?.trim()) logs.push(result.stderr.trim());
1470
1586
  if (result.error) {
@@ -1476,7 +1592,7 @@ function executeProjectInitPlan(plan) {
1476
1592
  }
1477
1593
  if (result.status !== 0) {
1478
1594
  errors.push(`copier exited with status ${result.status ?? "unknown"}`);
1479
- if (existsSync6(action.targetDir)) changedFiles.push(action.targetDir);
1595
+ if (existsSync7(action.targetDir)) changedFiles.push(action.targetDir);
1480
1596
  break;
1481
1597
  }
1482
1598
  changedFiles.push(action.targetDir);
@@ -1484,9 +1600,9 @@ function executeProjectInitPlan(plan) {
1484
1600
  mkdirSync4(dirname4(action.path), { recursive: true });
1485
1601
  const next = `${JSON.stringify(action.manifest, null, 2)}
1486
1602
  `;
1487
- const current = existsSync6(action.path) ? readFileSync2(action.path, "utf8") : void 0;
1603
+ const current = existsSync7(action.path) ? readFileSync3(action.path, "utf8") : void 0;
1488
1604
  if (current !== next) {
1489
- writeFileSync3(action.path, next, "utf8");
1605
+ writeFileSync4(action.path, next, "utf8");
1490
1606
  changedFiles.push(action.path);
1491
1607
  }
1492
1608
  } else if (action.kind === "registry.upsert") {
@@ -1527,10 +1643,10 @@ function projectManifestFromRegistryProject(project) {
1527
1643
  workspace: project.ticket_provider.workspace ?? "",
1528
1644
  identifier: project.ticket_provider.identifier ?? "",
1529
1645
  board_id: project.ticket_provider.board_id ?? "",
1530
- board_url: project.ticket_provider.board_url ?? "",
1531
1646
  state: project.ticket_provider.state
1532
1647
  },
1533
- agents
1648
+ agents,
1649
+ automation: project.automation ?? defaultProjectAutomation()
1534
1650
  };
1535
1651
  }
1536
1652
  function formatProjectInitPlan(plan) {
@@ -1579,16 +1695,16 @@ function doctorProjectRegistry(registryPath2 = projectRegistryPath(), slug) {
1579
1695
  const registry = loadProjectRegistry(registryPath2);
1580
1696
  const projects = slug ? [[slug, getProject(registry, slug)]] : Object.entries(registry.projects);
1581
1697
  for (const [projectSlug, project] of projects) {
1582
- if (!existsSync6(project.repo_path)) {
1698
+ if (!existsSync7(project.repo_path)) {
1583
1699
  issues.push({ level: "warn", slug: projectSlug, message: `repo_path does not exist: ${project.repo_path}` });
1584
1700
  } else if (!statSync(project.repo_path).isDirectory()) {
1585
1701
  issues.push({ level: "error", slug: projectSlug, message: `repo_path is not a directory: ${project.repo_path}` });
1586
1702
  } else {
1587
- const manifestPath = join8(project.repo_path, ".project.json");
1588
- if (!existsSync6(manifestPath)) issues.push({ level: "warn", slug: projectSlug, message: ".project.json is missing" });
1703
+ const manifestPath = join9(project.repo_path, ".project.json");
1704
+ if (!existsSync7(manifestPath)) issues.push({ level: "warn", slug: projectSlug, message: ".project.json is missing" });
1589
1705
  }
1590
1706
  for (const artifact of project.source_artifacts) {
1591
- if (artifact.path && !existsSync6(artifact.path)) {
1707
+ if (artifact.path && !existsSync7(artifact.path)) {
1592
1708
  issues.push({ level: "warn", slug: projectSlug, message: `source artifact missing: ${artifact.path}` });
1593
1709
  }
1594
1710
  }
@@ -1601,7 +1717,7 @@ function doctorProjectRegistry(registryPath2 = projectRegistryPath(), slug) {
1601
1717
  };
1602
1718
  }
1603
1719
  function buildCommonProjectCopierAction(input) {
1604
- const templateDir = join8(input.pjanglerRoot, "templates", "commonproject");
1720
+ const templateDir = join9(input.pjanglerRoot, "templates", "commonproject");
1605
1721
  const data = {
1606
1722
  project_name: input.projectName,
1607
1723
  project_description: input.projectDescription ?? "",
@@ -1611,7 +1727,6 @@ function buildCommonProjectCopierAction(input) {
1611
1727
  plane_project_id: input.planeProjectId ?? "",
1612
1728
  ticket_workspace: input.ticketWorkspace ?? input.planeWorkspace,
1613
1729
  board_id: input.boardId ?? input.planeProjectId ?? "",
1614
- board_url: input.boardUrl ?? "",
1615
1730
  project_identifier: input.projectIdentifier,
1616
1731
  primary_language: input.primaryLanguage,
1617
1732
  agent_hooks_layer: input.agentHooksLayer ?? true ? "true" : "false"
@@ -1631,7 +1746,7 @@ function buildCommonProjectCopierAction(input) {
1631
1746
  function resolvePjanglerRoot() {
1632
1747
  let dir = dirname4(new URL(import.meta.url).pathname);
1633
1748
  while (dir !== dirname4(dir)) {
1634
- if (existsSync6(join8(dir, "package.json")) && existsSync6(join8(dir, "templates", "commonproject", "copier.yml"))) return dir;
1749
+ if (existsSync7(join9(dir, "package.json")) && existsSync7(join9(dir, "templates", "commonproject", "copier.yml"))) return dir;
1635
1750
  dir = dirname4(dir);
1636
1751
  }
1637
1752
  return resolve(process.cwd());
@@ -1663,7 +1778,7 @@ function validateProjectRecord(project, key) {
1663
1778
  }
1664
1779
  function expandHome(path) {
1665
1780
  if (path === "~") return homedir3();
1666
- if (path.startsWith("~/")) return join8(homedir3(), path.slice(2));
1781
+ if (path.startsWith("~/")) return join9(homedir3(), path.slice(2));
1667
1782
  return path;
1668
1783
  }
1669
1784
  function isRecord(value) {
@@ -1680,16 +1795,16 @@ function resolveTemplateRoot() {
1680
1795
  try {
1681
1796
  let dir = dirname5(fileURLToPath2(import.meta.url));
1682
1797
  for (let i = 0; i < 8; i++) {
1683
- candidates.push(join9(dir, "templates", "commonproject", "template"));
1798
+ candidates.push(join10(dir, "templates", "commonproject", "template"));
1684
1799
  const parent = dirname5(dir);
1685
1800
  if (parent === dir) break;
1686
1801
  dir = parent;
1687
1802
  }
1688
1803
  } catch {
1689
1804
  }
1690
- candidates.push(join9(homedir4(), "code", "pjangler", "templates", "commonproject", "template"));
1805
+ candidates.push(join10(homedir4(), "code", "pjangler", "templates", "commonproject", "template"));
1691
1806
  for (const c of candidates) {
1692
- if (existsSync7(join9(c, ".agents", "hooks", "hooks.master.json"))) return c;
1807
+ if (existsSync8(join10(c, ".agents", "hooks", "hooks.master.json"))) return c;
1693
1808
  }
1694
1809
  throw new Error(
1695
1810
  "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
@@ -1716,10 +1831,10 @@ var CopyAgentHooksTree = class extends Command {
1716
1831
  const created = [];
1717
1832
  const skipped = [];
1718
1833
  for (const { rel, dir } of items) {
1719
- const src = join9(templateRoot, rel);
1720
- const dest = join9(this.context.targetDir, rel);
1721
- if (!existsSync7(src)) continue;
1722
- if (existsSync7(dest) && !this.context.force) {
1834
+ const src = join10(templateRoot, rel);
1835
+ const dest = join10(this.context.targetDir, rel);
1836
+ if (!existsSync8(src)) continue;
1837
+ if (existsSync8(dest) && !this.context.force) {
1723
1838
  skipped.push(rel);
1724
1839
  continue;
1725
1840
  }
@@ -1745,14 +1860,14 @@ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
1745
1860
  if (!resolveAgentHooksLayer()) {
1746
1861
  return { success: true, message: this.formatMessage(AGENT_HOOKS_SKIP_MESSAGE) };
1747
1862
  }
1748
- const misePath = join9(this.context.targetDir, "mise.toml");
1749
- if (!existsSync7(misePath)) {
1863
+ const misePath = join10(this.context.targetDir, "mise.toml");
1864
+ if (!existsSync8(misePath)) {
1750
1865
  return {
1751
1866
  success: false,
1752
1867
  message: "\u26A0\uFE0F No mise.toml found \u2014 run `pjangler init mise` first, then re-run."
1753
1868
  };
1754
1869
  }
1755
- let content = readFileSync3(misePath, "utf8");
1870
+ let content = readFileSync4(misePath, "utf8");
1756
1871
  if (content.includes(_WireMiseAgentHooks.MARKER)) {
1757
1872
  return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for agent-hooks") };
1758
1873
  }
@@ -1828,7 +1943,7 @@ ${leaveBlock}`);
1828
1943
  ""
1829
1944
  ].join("\n");
1830
1945
  content = content.replace(/\n*$/, "\n") + appended;
1831
- if (!this.context.dryRun) writeFileSync4(misePath, content);
1946
+ if (!this.context.dryRun) writeFileSync5(misePath, content);
1832
1947
  if (wiredHooks) {
1833
1948
  return { success: true, message: this.formatMessage("\u2705 Wired mise.toml ([hooks] enter/leave + tasks)") };
1834
1949
  }
@@ -2001,11 +2116,11 @@ function createRecipe(name, context) {
2001
2116
  import { cancel as cancel2, multiselect, text as text2, isCancel as isCancel5 } from "@clack/prompts";
2002
2117
 
2003
2118
  // src/parity/index.ts
2004
- import { existsSync as existsSync8, lstatSync, mkdirSync as mkdirSync6, readFileSync as readFileSync4, readlinkSync, readdirSync, renameSync as renameSync2, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync5, chmodSync as chmodSync2, copyFileSync } from "node:fs";
2005
- import { basename as basename3, dirname as dirname6, join as join10, relative, resolve as resolve2 } from "node:path";
2119
+ import { existsSync as existsSync9, lstatSync, mkdirSync as mkdirSync6, readFileSync as readFileSync5, readlinkSync, readdirSync as readdirSync2, renameSync as renameSync2, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync6, chmodSync as chmodSync2, copyFileSync } from "node:fs";
2120
+ import { basename as basename3, dirname as dirname6, join as join11, relative, resolve as resolve2 } from "node:path";
2006
2121
  import { fileURLToPath as fileURLToPath3 } from "node:url";
2007
2122
  import { homedir as homedir5 } from "node:os";
2008
- import { spawnSync as spawnSync5 } from "node:child_process";
2123
+ import { spawnSync as spawnSync6 } from "node:child_process";
2009
2124
  var LINK_AGENTFILES_BLOCK = `# This block will handle the linking of
2010
2125
  # agent files to the main AGENTS.md file.
2011
2126
  #
@@ -2076,7 +2191,7 @@ run = "{{config_root}}/.mise/scripts/versioning.sh sync"
2076
2191
  function resolvePjanglerRoot2() {
2077
2192
  let dir = dirname6(fileURLToPath3(import.meta.url));
2078
2193
  while (dir !== dirname6(dir)) {
2079
- if (existsSync8(join10(dir, "package.json")) && existsSync8(join10(dir, "templates", "commonproject", "copier.yml"))) {
2194
+ if (existsSync9(join11(dir, "package.json")) && existsSync9(join11(dir, "templates", "commonproject", "copier.yml"))) {
2080
2195
  return dir;
2081
2196
  }
2082
2197
  dir = dirname6(dir);
@@ -2087,17 +2202,17 @@ function normalizeNewlines(value) {
2087
2202
  return value.replace(/\r\n/g, "\n");
2088
2203
  }
2089
2204
  function readText(path) {
2090
- return normalizeNewlines(readFileSync4(path, "utf8"));
2205
+ return normalizeNewlines(readFileSync5(path, "utf8"));
2091
2206
  }
2092
2207
  function safeReadText(path) {
2093
- return existsSync8(path) ? readText(path) : null;
2208
+ return existsSync9(path) ? readText(path) : null;
2094
2209
  }
2095
2210
  function ensureParent(path) {
2096
2211
  mkdirSync6(dirname6(path), { recursive: true });
2097
2212
  }
2098
2213
  function writeText(path, content) {
2099
2214
  ensureParent(path);
2100
- writeFileSync5(path, content);
2215
+ writeFileSync6(path, content);
2101
2216
  }
2102
2217
  function tryParseJson(text3) {
2103
2218
  if (!text3) return null;
@@ -2114,7 +2229,7 @@ function titleCaseSlug(slug) {
2114
2229
  return slug.split(/[-_]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
2115
2230
  }
2116
2231
  function readSymlinkTarget(path) {
2117
- if (!existsSync8(path)) return null;
2232
+ if (!existsSync9(path)) return null;
2118
2233
  try {
2119
2234
  return readlinkSync(path);
2120
2235
  } catch {
@@ -2122,7 +2237,7 @@ function readSymlinkTarget(path) {
2122
2237
  }
2123
2238
  }
2124
2239
  function ensureSymlink(path, target, dryRun) {
2125
- if (existsSync8(path)) {
2240
+ if (existsSync9(path)) {
2126
2241
  const stat = lstatSync(path);
2127
2242
  if (stat.isSymbolicLink()) {
2128
2243
  const current = readSymlinkTarget(path);
@@ -2139,11 +2254,11 @@ function ensureSymlink(path, target, dryRun) {
2139
2254
  return { changed: true };
2140
2255
  }
2141
2256
  function bootstrapAgentsFile(repoRoot, dryRun) {
2142
- const agentsPath = join10(repoRoot, "AGENTS.md");
2143
- if (existsSync8(agentsPath)) return { changedFiles: [], details: [] };
2257
+ const agentsPath = join11(repoRoot, "AGENTS.md");
2258
+ if (existsSync9(agentsPath)) return { changedFiles: [], details: [] };
2144
2259
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2145
- const source = join10(repoRoot, file);
2146
- if (!existsSync8(source)) continue;
2260
+ const source = join11(repoRoot, file);
2261
+ if (!existsSync9(source)) continue;
2147
2262
  const stat = lstatSync(source);
2148
2263
  if (stat.isSymbolicLink()) continue;
2149
2264
  if (stat.isFile()) {
@@ -2152,8 +2267,8 @@ function bootstrapAgentsFile(repoRoot, dryRun) {
2152
2267
  }
2153
2268
  return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
2154
2269
  }
2155
- const readmePath = join10(repoRoot, "README.md");
2156
- if (existsSync8(readmePath)) {
2270
+ const readmePath = join11(repoRoot, "README.md");
2271
+ if (existsSync9(readmePath)) {
2157
2272
  const stat = lstatSync(readmePath);
2158
2273
  if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
2159
2274
  if (!dryRun) copyFileSync(readmePath, agentsPath);
@@ -2192,12 +2307,12 @@ function yamlGet(text3, keyPath) {
2192
2307
  return "";
2193
2308
  }
2194
2309
  function discoverRoles(repoRoot) {
2195
- const rolesDir = join10(repoRoot, "agents", "hermes");
2196
- if (!existsSync8(rolesDir)) return [];
2197
- return readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
2198
- const roleDir = join10(rolesDir, entry.name);
2199
- const roleYamlPath = join10(roleDir, "role.yaml");
2200
- if (!existsSync8(roleYamlPath)) return null;
2310
+ const rolesDir = join11(repoRoot, "agents", "hermes");
2311
+ if (!existsSync9(rolesDir)) return [];
2312
+ return readdirSync2(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
2313
+ const roleDir = join11(rolesDir, entry.name);
2314
+ const roleYamlPath = join11(roleDir, "role.yaml");
2315
+ if (!existsSync9(roleYamlPath)) return null;
2201
2316
  const text3 = readText(roleYamlPath);
2202
2317
  const runtimeRepoRaw = yamlGet(text3, "runtime.github_repo");
2203
2318
  return {
@@ -2215,16 +2330,20 @@ function discoverRoles(repoRoot) {
2215
2330
  planeWorkspace: yamlGet(text3, "ticket_provider.workspace") || yamlGet(text3, "plane.workspace"),
2216
2331
  ticketProviderName: yamlGet(text3, "ticket_provider.name"),
2217
2332
  ticketProviderBoardId: yamlGet(text3, "ticket_provider.board_id"),
2218
- ticketProviderBoardUrl: yamlGet(text3, "ticket_provider.board_url"),
2219
- ticketProviderIdentifier: yamlGet(text3, "plane.identifier")
2333
+ ticketProviderIdentifier: yamlGet(text3, "plane.identifier"),
2334
+ legacyReconcileEnabled: yamlGet(text3, "reconcile.enabled"),
2335
+ legacyReconcileGraceHours: yamlGet(text3, "reconcile.grace_hours"),
2336
+ legacyReconcileAutoReview: yamlGet(text3, "reconcile.auto_review"),
2337
+ legacyScrumGraceHours: yamlGet(text3, "scrum_master.grace_hours"),
2338
+ legacyScrumAutoReview: yamlGet(text3, "scrum_master.auto_review")
2220
2339
  };
2221
2340
  }).filter((value) => Boolean(value));
2222
2341
  }
2223
2342
  function registryPath(homeDir) {
2224
- return join10(homeDir, ".hermes", "agents-registry.yaml");
2343
+ return join11(homeDir, ".hermes", "agents-registry.yaml");
2225
2344
  }
2226
2345
  function systemctlUser(args) {
2227
- const result = spawnSync5("systemctl", ["--user", ...args], { encoding: "utf8" });
2346
+ const result = spawnSync6("systemctl", ["--user", ...args], { encoding: "utf8" });
2228
2347
  return {
2229
2348
  ok: result.status === 0,
2230
2349
  stdout: result.stdout.trim(),
@@ -2232,8 +2351,8 @@ function systemctlUser(args) {
2232
2351
  };
2233
2352
  }
2234
2353
  function templateScript(ctx, name) {
2235
- const source = join10(ctx.pjanglerRoot, ".mise", "scripts", name);
2236
- return existsSync8(source) ? readText(source) : void 0;
2354
+ const source = join11(ctx.pjanglerRoot, ".mise", "scripts", name);
2355
+ return existsSync9(source) ? readText(source) : void 0;
2237
2356
  }
2238
2357
  function templateVersioningScript(ctx) {
2239
2358
  return templateScript(ctx, "versioning.sh");
@@ -2241,16 +2360,45 @@ function templateVersioningScript(ctx) {
2241
2360
  function templateLinkAgentfilesScript(ctx) {
2242
2361
  return templateScript(ctx, "link-agentfiles.sh");
2243
2362
  }
2363
+ function resolveAgentHooksLayer2(ctx) {
2364
+ const override = process.env.PJ_AGENT_HOOKS_LAYER;
2365
+ if (override === "0" || override === "false") return false;
2366
+ if (override === "1" || override === "true") return true;
2367
+ if (existsSync9(join11(ctx.repoRoot, ".agents", "hooks", "sync.py"))) return true;
2368
+ return !existsSync9(join11(ctx.homeDir, ".agents", "hooks"));
2369
+ }
2370
+ function evaluateMiseConditionals(template, agentHooksLayer) {
2371
+ const out = [];
2372
+ let depth = 0;
2373
+ let skipDepth = 0;
2374
+ for (const line of template.split("\n")) {
2375
+ const stmt = line.trim();
2376
+ const ifMatch = /^\{%-?\s*if\s+(\w+)\s*-?%\}$/.exec(stmt);
2377
+ if (ifMatch) {
2378
+ depth += 1;
2379
+ const truthy = ifMatch[1] === "agent_hooks_layer" ? agentHooksLayer : false;
2380
+ if (skipDepth === 0 && !truthy) skipDepth = depth;
2381
+ continue;
2382
+ }
2383
+ if (/^\{%-?\s*endif\s*-?%\}$/.test(stmt)) {
2384
+ if (skipDepth === depth) skipDepth = 0;
2385
+ depth = Math.max(0, depth - 1);
2386
+ continue;
2387
+ }
2388
+ if (skipDepth === 0) out.push(line);
2389
+ }
2390
+ return out.join("\n");
2391
+ }
2244
2392
  function renderGeneratedProjectMiseToml(ctx, template) {
2245
2393
  const project = readProjectJson(ctx);
2246
2394
  const projectName = String(project?.project_name ?? basename3(ctx.repoRoot) ?? "project");
2247
- return template.replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
2395
+ return evaluateMiseConditionals(template, resolveAgentHooksLayer2(ctx)).replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
2248
2396
  }
2249
2397
  function ensureMiseTomlFromTemplate(ctx, changedFiles) {
2250
- const targetPath = join10(ctx.repoRoot, "mise.toml");
2251
- if (existsSync8(targetPath)) return false;
2252
- const sourcePath = join10(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
2253
- if (!existsSync8(sourcePath)) return false;
2398
+ const targetPath = join11(ctx.repoRoot, "mise.toml");
2399
+ if (existsSync9(targetPath)) return false;
2400
+ const sourcePath = join11(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
2401
+ if (!existsSync9(sourcePath)) return false;
2254
2402
  changedFiles.push(targetPath);
2255
2403
  if (!ctx.dryRun) {
2256
2404
  writeText(targetPath, renderGeneratedProjectMiseToml(ctx, readText(sourcePath)));
@@ -2258,8 +2406,8 @@ function ensureMiseTomlFromTemplate(ctx, changedFiles) {
2258
2406
  return true;
2259
2407
  }
2260
2408
  function templateVersionFilesConf(ctx, repoRoot) {
2261
- const packageJson = join10(repoRoot, "package.json");
2262
- 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";
2409
+ const packageJson = join11(repoRoot, "package.json");
2410
+ 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";
2263
2411
  }
2264
2412
  function replaceOrAppendManagedBlock(text3, startMarker, block, beforePattern) {
2265
2413
  if (startMarker.test(text3)) {
@@ -2283,7 +2431,7 @@ var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/herm
2283
2431
  function requiredMisePathEntries(ctx) {
2284
2432
  const required = [...BASE_MISE_PATH_ENTRIES];
2285
2433
  for (const candidate of CONDITIONAL_HERMES_PATHS) {
2286
- if (existsSync8(join10(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
2434
+ if (existsSync9(join11(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
2287
2435
  }
2288
2436
  return required;
2289
2437
  }
@@ -2432,7 +2580,24 @@ function upsertLinkAgentfilesBlock(text3, ctx) {
2432
2580
  return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
2433
2581
  }
2434
2582
  function readProjectJson(ctx) {
2435
- return tryParseJson(safeReadText(join10(ctx.repoRoot, ".project.json")));
2583
+ return tryParseJson(safeReadText(join11(ctx.repoRoot, ".project.json")));
2584
+ }
2585
+ function boolSetting(value, fallback) {
2586
+ if (typeof value === "boolean") return value;
2587
+ if (typeof value === "string") {
2588
+ const normalized = value.trim().toLowerCase();
2589
+ if (["true", "1", "yes", "on"].includes(normalized)) return true;
2590
+ if (["false", "0", "no", "off"].includes(normalized)) return false;
2591
+ }
2592
+ return fallback;
2593
+ }
2594
+ function numberSetting(value, fallback) {
2595
+ if (typeof value === "number" && Number.isFinite(value)) return value;
2596
+ if (typeof value === "string" && value.trim()) {
2597
+ const parsed = Number(value);
2598
+ if (Number.isFinite(parsed)) return parsed;
2599
+ }
2600
+ return fallback;
2436
2601
  }
2437
2602
  function canonicalProjectJson(ctx) {
2438
2603
  const roles = discoverRoles(ctx.repoRoot);
@@ -2444,9 +2609,9 @@ function canonicalProjectJson(ctx) {
2444
2609
  workspace: String((existing.ticket_provider?.workspace ?? firstRole?.planeWorkspace ?? "") || ""),
2445
2610
  identifier: String((existing.ticket_provider?.identifier ?? firstRole?.ticketProviderIdentifier ?? "") || ""),
2446
2611
  board_id: String((existing.ticket_provider?.board_id ?? firstRole?.ticketProviderBoardId ?? "") || ""),
2447
- board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || ""),
2448
- state: String((existing.ticket_provider?.state ?? "planned") || "planned")
2612
+ state: String((existing.ticket_provider?.state ?? (firstRole?.ticketProviderBoardId ? "linked" : "planned")) || "planned")
2449
2613
  };
2614
+ if (ticketProvider.board_id && ticketProvider.state === "planned") ticketProvider.state = "linked";
2450
2615
  const existingAgents = existing.agents ?? {};
2451
2616
  const discoveredAgents = Object.fromEntries(
2452
2617
  roles.map((role) => [
@@ -2466,28 +2631,42 @@ function canonicalProjectJson(ctx) {
2466
2631
  provisioning_state: existingAgent.provisioning_state
2467
2632
  };
2468
2633
  }
2634
+ const existingAutomation = existing.automation ?? {};
2635
+ const existingReconcile = existingAutomation.reconcile ?? {};
2636
+ const legacyEnabled = roles.find((role) => role.legacyReconcileEnabled)?.legacyReconcileEnabled;
2637
+ const legacyGrace = roles.find((role) => role.legacyReconcileGraceHours || role.legacyScrumGraceHours);
2638
+ const legacyAutoReview = roles.find((role) => role.legacyReconcileAutoReview || role.legacyScrumAutoReview);
2639
+ const automation = {
2640
+ ...existingAutomation,
2641
+ reconcile: {
2642
+ enabled: boolSetting(existingReconcile.enabled, boolSetting(legacyEnabled, false)),
2643
+ grace_hours: numberSetting(existingReconcile.grace_hours, numberSetting(legacyGrace?.legacyReconcileGraceHours || legacyGrace?.legacyScrumGraceHours, 0)),
2644
+ auto_review: boolSetting(existingReconcile.auto_review, boolSetting(legacyAutoReview?.legacyReconcileAutoReview || legacyAutoReview?.legacyScrumAutoReview, true))
2645
+ }
2646
+ };
2469
2647
  return {
2470
2648
  project_name: String(existing.project_name ?? titleCaseSlug(slug)),
2471
2649
  project_description: String(existing.project_description ?? ""),
2472
2650
  project_slug: slug,
2473
2651
  repo_path: ctx.repoRoot,
2474
2652
  ticket_provider: ticketProvider,
2475
- agents
2653
+ agents,
2654
+ automation
2476
2655
  };
2477
2656
  }
2478
2657
  function projectJsonFinding(ctx) {
2479
- const projectPath = join10(ctx.repoRoot, ".project.json");
2480
- const planeJsonPath = join10(ctx.repoRoot, ".plane.json");
2658
+ const projectPath = join11(ctx.repoRoot, ".project.json");
2659
+ const planeJsonPath = join11(ctx.repoRoot, ".plane.json");
2481
2660
  const details = [];
2482
2661
  const data = readProjectJson(ctx);
2483
2662
  const roles = discoverRoles(ctx.repoRoot);
2484
- if (!existsSync8(projectPath)) {
2663
+ if (!existsSync9(projectPath)) {
2485
2664
  return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json missing", details: [], fixable: true };
2486
2665
  }
2487
2666
  if (!data) {
2488
2667
  return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json is not valid JSON", details: [], fixable: true };
2489
2668
  }
2490
- for (const key of ["project_name", "project_description", "project_slug", "repo_path", "ticket_provider", "agents"]) {
2669
+ for (const key of ["project_name", "project_description", "project_slug", "repo_path", "ticket_provider", "agents", "automation"]) {
2491
2670
  if (!(key in data)) details.push(`missing key: ${key}`);
2492
2671
  }
2493
2672
  if (data.repo_path !== ctx.repoRoot) details.push(`repo_path should be ${ctx.repoRoot}`);
@@ -2504,10 +2683,19 @@ function projectJsonFinding(ctx) {
2504
2683
  }
2505
2684
  }
2506
2685
  const ticketProvider = data.ticket_provider ?? {};
2507
- for (const key of ["type", "workspace", "identifier", "board_id", "board_url", "state"]) {
2686
+ for (const key of ["type", "workspace", "identifier", "board_id", "state"]) {
2508
2687
  if (!(key in ticketProvider)) details.push(`ticket_provider.${key} missing`);
2509
2688
  }
2510
- if (existsSync8(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
2689
+ if ("board_url" in ticketProvider) details.push("ticket_provider.board_url should be removed; derive it from provider/workspace/board_id");
2690
+ if (!ticketProvider.board_id && roles.some((role) => role.ticketProviderBoardId)) {
2691
+ details.push("ticket_provider.board_id missing even though legacy role.yaml contains a board binding");
2692
+ }
2693
+ const automation = data.automation ?? {};
2694
+ const reconcile = automation.reconcile ?? {};
2695
+ for (const key of ["enabled", "grace_hours", "auto_review"]) {
2696
+ if (!(key in reconcile)) details.push(`automation.reconcile.${key} missing`);
2697
+ }
2698
+ if (existsSync9(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
2511
2699
  return {
2512
2700
  id: "sot.project-json",
2513
2701
  title: "Canonical .project.json",
@@ -2520,7 +2708,7 @@ function projectJsonFinding(ctx) {
2520
2708
  function renderSoul(role) {
2521
2709
  const telegram = role.botHandle ? `@${role.botHandle}` : "(unwired)";
2522
2710
  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.";
2523
- 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.`;
2711
+ 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.`;
2524
2712
  const runtimeOwner = role.runtimeOwner || "delorenj";
2525
2713
  return `# ${role.displayName || role.agentId}
2526
2714
 
@@ -2588,17 +2776,17 @@ exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" HERMES_OAUTH
2588
2776
  `.replace(/\u0010/g, "$");
2589
2777
  }
2590
2778
  function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip) {
2591
- if (!existsSync8(sourceDir)) return;
2779
+ if (!existsSync9(sourceDir)) return;
2592
2780
  mkdirSync6(targetDir, { recursive: true });
2593
- for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
2594
- const sourcePath = join10(sourceDir, entry.name);
2781
+ for (const entry of readdirSync2(sourceDir, { withFileTypes: true })) {
2782
+ const sourcePath = join11(sourceDir, entry.name);
2595
2783
  if (skip?.(sourcePath)) continue;
2596
- const targetPath = join10(targetDir, entry.name);
2784
+ const targetPath = join11(targetDir, entry.name);
2597
2785
  if (entry.isDirectory()) {
2598
2786
  copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
2599
2787
  continue;
2600
2788
  }
2601
- if (existsSync8(targetPath)) continue;
2789
+ if (existsSync9(targetPath)) continue;
2602
2790
  changedFiles.push(targetPath);
2603
2791
  if (!dryRun) {
2604
2792
  ensureParent(targetPath);
@@ -2607,7 +2795,7 @@ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip)
2607
2795
  }
2608
2796
  }
2609
2797
  function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
2610
- const gitmodulesPath = join10(repoRoot, ".gitmodules");
2798
+ const gitmodulesPath = join11(repoRoot, ".gitmodules");
2611
2799
  const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
2612
2800
  const owner = role.runtimeOwner || "delorenj";
2613
2801
  const block = `[submodule "agents/hermes/${role.role}/runtime"]
@@ -2707,14 +2895,14 @@ var RULES = [
2707
2895
  id: "mise.config-root",
2708
2896
  title: "mise config_root + AGENTS link hooks",
2709
2897
  audit: (ctx) => {
2710
- const misePath = join10(ctx.repoRoot, "mise.toml");
2711
- if (!existsSync8(misePath)) {
2898
+ const misePath = join11(ctx.repoRoot, "mise.toml");
2899
+ if (!existsSync9(misePath)) {
2712
2900
  return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
2713
2901
  }
2714
2902
  const text3 = readText(misePath);
2715
2903
  const details = [];
2716
- const linkAgentfilesPath = join10(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2717
- if (!existsSync8(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2904
+ const linkAgentfilesPath = join11(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2905
+ if (!existsSync9(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2718
2906
  const pathValues = [...(text3.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2719
2907
  const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
2720
2908
  if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
@@ -2732,10 +2920,10 @@ var RULES = [
2732
2920
  };
2733
2921
  },
2734
2922
  migrate: (ctx, finding) => {
2735
- const path = join10(ctx.repoRoot, "mise.toml");
2923
+ const path = join11(ctx.repoRoot, "mise.toml");
2736
2924
  const changedFiles = [];
2737
2925
  const details = [];
2738
- if (!existsSync8(path)) {
2926
+ if (!existsSync9(path)) {
2739
2927
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2740
2928
  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: [] };
2741
2929
  }
@@ -2751,7 +2939,7 @@ var RULES = [
2751
2939
  if (!ctx.dryRun) writeText(path, next);
2752
2940
  text3 = next;
2753
2941
  }
2754
- const linkAgentfilesPath = join10(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2942
+ const linkAgentfilesPath = join11(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2755
2943
  const expectedScript = templateLinkAgentfilesScript(ctx);
2756
2944
  if (expectedScript === void 0) {
2757
2945
  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: [] };
@@ -2778,13 +2966,13 @@ var RULES = [
2778
2966
  title: "managed mise versioning block",
2779
2967
  audit: (ctx) => {
2780
2968
  const details = [];
2781
- const misePath = join10(ctx.repoRoot, "mise.toml");
2782
- const versioningPath = join10(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2783
- const manifestPath = join10(ctx.repoRoot, ".mise", "version-files.conf");
2969
+ const misePath = join11(ctx.repoRoot, "mise.toml");
2970
+ const versioningPath = join11(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2971
+ const manifestPath = join11(ctx.repoRoot, ".mise", "version-files.conf");
2784
2972
  const text3 = safeReadText(misePath);
2785
2973
  if (!text3?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
2786
- if (!existsSync8(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
2787
- if (!existsSync8(manifestPath)) details.push(".mise/version-files.conf missing");
2974
+ if (!existsSync9(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
2975
+ if (!existsSync9(manifestPath)) details.push(".mise/version-files.conf missing");
2788
2976
  return {
2789
2977
  id: "mise.versioning",
2790
2978
  title: "managed mise versioning block",
@@ -2797,8 +2985,8 @@ var RULES = [
2797
2985
  migrate: (ctx, finding) => {
2798
2986
  const changedFiles = [];
2799
2987
  const details = [];
2800
- const misePath = join10(ctx.repoRoot, "mise.toml");
2801
- if (!existsSync8(misePath)) {
2988
+ const misePath = join11(ctx.repoRoot, "mise.toml");
2989
+ if (!existsSync9(misePath)) {
2802
2990
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2803
2991
  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: [] };
2804
2992
  }
@@ -2808,12 +2996,21 @@ var RULES = [
2808
2996
  }
2809
2997
  }
2810
2998
  const currentMise = readText(misePath);
2811
- const nextMise = replaceOrAppendManagedBlock(currentMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
2999
+ let cleanedMise = currentMise;
3000
+ if (!currentMise.includes("# >>> mise-versioning >>>")) {
3001
+ const taskNames = ["version", "version:bump", "version:bump-patch", "version:bump-minor", "version:bump-major", "version:check", "version:sync"];
3002
+ for (const taskName of taskNames) {
3003
+ const escaped = taskName.replace(/:/g, "\\:");
3004
+ const headerPattern = new RegExp(`^\\[tasks\\.(?:"${escaped}"|'${escaped}'|${escaped})\\]$`);
3005
+ cleanedMise = removeTomlSection(cleanedMise, headerPattern);
3006
+ }
3007
+ }
3008
+ const nextMise = replaceOrAppendManagedBlock(cleanedMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
2812
3009
  if (nextMise !== currentMise) {
2813
3010
  if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
2814
3011
  if (!ctx.dryRun) writeText(misePath, nextMise);
2815
3012
  }
2816
- const versioningPath = join10(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
3013
+ const versioningPath = join11(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2817
3014
  const expectedScript = templateVersioningScript(ctx);
2818
3015
  if (expectedScript === void 0) {
2819
3016
  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: [] };
@@ -2825,7 +3022,7 @@ var RULES = [
2825
3022
  chmodSync2(versioningPath, 493);
2826
3023
  }
2827
3024
  }
2828
- const manifestPath = join10(ctx.repoRoot, ".mise", "version-files.conf");
3025
+ const manifestPath = join11(ctx.repoRoot, ".mise", "version-files.conf");
2829
3026
  const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
2830
3027
  if (safeReadText(manifestPath) !== expectedManifest) {
2831
3028
  changedFiles.push(manifestPath);
@@ -2845,9 +3042,9 @@ var RULES = [
2845
3042
  id: "sot.agent-symlinks",
2846
3043
  title: "AGENTS/CLAUDE/GEMINI symlink contract",
2847
3044
  audit: (ctx) => {
2848
- const agentsPath = join10(ctx.repoRoot, "AGENTS.md");
2849
- if (!existsSync8(agentsPath)) {
2850
- const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync8(join10(ctx.repoRoot, file)));
3045
+ const agentsPath = join11(ctx.repoRoot, "AGENTS.md");
3046
+ if (!existsSync9(agentsPath)) {
3047
+ const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync9(join11(ctx.repoRoot, file)));
2851
3048
  if (fallbackSources.length === 0) {
2852
3049
  return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
2853
3050
  }
@@ -2862,7 +3059,7 @@ var RULES = [
2862
3059
  }
2863
3060
  const details = [];
2864
3061
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2865
- const full = join10(ctx.repoRoot, file);
3062
+ const full = join11(ctx.repoRoot, file);
2866
3063
  const target = readSymlinkTarget(full);
2867
3064
  if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
2868
3065
  }
@@ -2886,7 +3083,7 @@ var RULES = [
2886
3083
  return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
2887
3084
  }
2888
3085
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2889
- const full = join10(ctx.repoRoot, file);
3086
+ const full = join11(ctx.repoRoot, file);
2890
3087
  const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
2891
3088
  if (result.blocked) blockedDetails.push(result.blocked);
2892
3089
  if (result.changed) changedFiles.push(full);
@@ -2908,7 +3105,7 @@ var RULES = [
2908
3105
  migrate: (ctx, finding) => {
2909
3106
  const changedFiles = [];
2910
3107
  const details = [];
2911
- const path = join10(ctx.repoRoot, ".project.json");
3108
+ const path = join11(ctx.repoRoot, ".project.json");
2912
3109
  const existing = readProjectJson(ctx) ?? {};
2913
3110
  const canonical = canonicalProjectJson(ctx);
2914
3111
  const merged = { ...existing, ...canonical };
@@ -2918,10 +3115,10 @@ var RULES = [
2918
3115
  changedFiles.push(path);
2919
3116
  if (!ctx.dryRun) writeText(path, expected);
2920
3117
  }
2921
- const planeJson = join10(ctx.repoRoot, ".plane.json");
2922
- if (existsSync8(planeJson)) {
3118
+ const planeJson = join11(ctx.repoRoot, ".plane.json");
3119
+ if (existsSync9(planeJson)) {
2923
3120
  const backup = `${planeJson}.migrated-backup`;
2924
- if (existsSync8(backup)) {
3121
+ if (existsSync9(backup)) {
2925
3122
  details.push(`cannot back up .plane.json because ${relative(ctx.repoRoot, backup)} already exists`);
2926
3123
  } else {
2927
3124
  changedFiles.push(backup);
@@ -2943,8 +3140,8 @@ var RULES = [
2943
3140
  title: ".env.op + gitignore secrets contract",
2944
3141
  audit: (ctx) => {
2945
3142
  const details = [];
2946
- const envOp = safeReadText(join10(ctx.repoRoot, ".env.op"));
2947
- const gitignore = safeReadText(join10(ctx.repoRoot, ".gitignore"));
3143
+ const envOp = safeReadText(join11(ctx.repoRoot, ".env.op"));
3144
+ const gitignore = safeReadText(join11(ctx.repoRoot, ".gitignore"));
2948
3145
  if (!envOp) {
2949
3146
  details.push(".env.op missing");
2950
3147
  } else {
@@ -2970,12 +3167,12 @@ var RULES = [
2970
3167
  migrate: (ctx, finding) => {
2971
3168
  const changedFiles = [];
2972
3169
  const details = [];
2973
- const envOpPath = join10(ctx.repoRoot, ".env.op");
2974
- if (!existsSync8(envOpPath)) {
3170
+ const envOpPath = join11(ctx.repoRoot, ".env.op");
3171
+ if (!existsSync9(envOpPath)) {
2975
3172
  changedFiles.push(envOpPath);
2976
- if (!ctx.dryRun) writeText(envOpPath, readText(join10(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
3173
+ if (!ctx.dryRun) writeText(envOpPath, readText(join11(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2977
3174
  }
2978
- const gitignorePath = join10(ctx.repoRoot, ".gitignore");
3175
+ const gitignorePath = join11(ctx.repoRoot, ".gitignore");
2979
3176
  const gitignore = safeReadText(gitignorePath) ?? "";
2980
3177
  const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
2981
3178
  # NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
@@ -3002,7 +3199,7 @@ var RULES = [
3002
3199
  title: ".copier-answers.yml provenance + drift report",
3003
3200
  audit: (ctx) => {
3004
3201
  const details = [];
3005
- const path = join10(ctx.repoRoot, ".copier-answers.yml");
3202
+ const path = join11(ctx.repoRoot, ".copier-answers.yml");
3006
3203
  const text3 = safeReadText(path);
3007
3204
  const project = readProjectJson(ctx);
3008
3205
  if (!text3) {
@@ -3033,12 +3230,12 @@ var RULES = [
3033
3230
  const changedFiles = [];
3034
3231
  const project = canonicalProjectJson(ctx);
3035
3232
  const text3 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
3036
- _src_path: ${join10(ctx.pjanglerRoot, "templates", "commonproject")}
3233
+ _src_path: ${join11(ctx.pjanglerRoot, "templates", "commonproject")}
3037
3234
  project_description: ${String(project.project_description)}
3038
3235
  project_name: ${String(project.project_name)}
3039
3236
  ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3040
3237
  `;
3041
- const path = join10(ctx.repoRoot, ".copier-answers.yml");
3238
+ const path = join11(ctx.repoRoot, ".copier-answers.yml");
3042
3239
  if (safeReadText(path) !== text3) {
3043
3240
  changedFiles.push(path);
3044
3241
  if (!ctx.dryRun) writeText(path, text3);
@@ -3057,15 +3254,15 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3057
3254
  id: "bmad.scaffold",
3058
3255
  title: "BMAD modules/docs scaffold",
3059
3256
  audit: (ctx) => {
3060
- const sourceRoot = join10(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
3061
- const targetRoot = join10(ctx.repoRoot, "_bmad");
3257
+ const sourceRoot = join11(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
3258
+ const targetRoot = join11(ctx.repoRoot, "_bmad");
3062
3259
  const sentinels = [
3063
- join10("core", "config.yaml"),
3064
- join10("custom", "config.yaml"),
3065
- join10("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
3066
- join10("bmm", "workflows", "workflow-status", "workflow.yaml")
3260
+ join11("core", "config.yaml"),
3261
+ join11("custom", "config.yaml"),
3262
+ join11("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
3263
+ join11("bmm", "workflows", "workflow-status", "workflow.yaml")
3067
3264
  ];
3068
- const missing = sentinels.filter((file) => existsSync8(join10(sourceRoot, file)) && !existsSync8(join10(targetRoot, file)));
3265
+ const missing = sentinels.filter((file) => existsSync9(join11(sourceRoot, file)) && !existsSync9(join11(targetRoot, file)));
3069
3266
  return {
3070
3267
  id: "bmad.scaffold",
3071
3268
  title: "BMAD modules/docs scaffold",
@@ -3077,7 +3274,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3077
3274
  },
3078
3275
  migrate: (ctx, finding) => {
3079
3276
  const changedFiles = [];
3080
- copyMissingRecursive(join10(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join10(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
3277
+ copyMissingRecursive(join11(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join11(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
3081
3278
  return {
3082
3279
  id: finding.id,
3083
3280
  title: finding.title,
@@ -3099,11 +3296,11 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3099
3296
  }
3100
3297
  const details = [];
3101
3298
  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"]) {
3102
- if (!existsSync8(join10(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join10(role.roleDir, rel))}`);
3299
+ if (!existsSync9(join11(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join11(role.roleDir, rel))}`);
3103
3300
  }
3104
- const gitmodules = safeReadText(join10(ctx.repoRoot, ".gitmodules")) ?? "";
3301
+ const gitmodules = safeReadText(join11(ctx.repoRoot, ".gitmodules")) ?? "";
3105
3302
  if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing pm runtime submodule entry");
3106
- if (!profileMetaInheritsDefault(join10(role.roleDir, "runtime", "profile.yaml"))) {
3303
+ if (!profileMetaInheritsDefault(join11(role.roleDir, "runtime", "profile.yaml"))) {
3107
3304
  details.push("runtime/profile.yaml missing inherited default config metadata");
3108
3305
  }
3109
3306
  const registry = safeReadText(registryPath(ctx.homeDir));
@@ -3124,21 +3321,21 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3124
3321
  if (!role) {
3125
3322
  return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
3126
3323
  }
3127
- const templateRoleDir = join10(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
3128
- writeIfDifferent(join10(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
3129
- writeIfDifferent(join10(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
3130
- writeIfDifferent(join10(role.roleDir, ".gitignore"), readText(join10(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
3131
- copyMissingRecursive(join10(templateRoleDir, ".runtime-scaffold"), join10(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
3132
- copyMissingRecursive(join10(templateRoleDir, ".runtime-scaffold"), join10(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
3133
- copyMissingRecursive(join10(templateRoleDir, ".scripts"), join10(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
3134
- const promptSource = join10(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
3135
- const promptTarget = join10(role.roleDir, ".scripts", "sentinel.prompt.md");
3136
- if (existsSync8(promptSource) && !existsSync8(promptTarget)) {
3324
+ const templateRoleDir = join11(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
3325
+ writeIfDifferent(join11(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
3326
+ writeIfDifferent(join11(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
3327
+ writeIfDifferent(join11(role.roleDir, ".gitignore"), readText(join11(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
3328
+ copyMissingRecursive(join11(templateRoleDir, ".runtime-scaffold"), join11(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
3329
+ copyMissingRecursive(join11(templateRoleDir, ".runtime-scaffold"), join11(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
3330
+ copyMissingRecursive(join11(templateRoleDir, ".scripts"), join11(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
3331
+ const promptSource = join11(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
3332
+ const promptTarget = join11(role.roleDir, ".scripts", "sentinel.prompt.md");
3333
+ if (existsSync9(promptSource) && !existsSync9(promptTarget)) {
3137
3334
  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);
3138
3335
  writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
3139
3336
  }
3140
3337
  upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
3141
- const profileMetaUpdated = upsertInheritedProfileMeta(join10(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
3338
+ const profileMetaUpdated = upsertInheritedProfileMeta(join11(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
3142
3339
  if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
3143
3340
  const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
3144
3341
  if (registryUpdated) details.push(`updated ${registryUpdated}`);
@@ -3152,6 +3349,103 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3152
3349
  };
3153
3350
  }
3154
3351
  },
3352
+ {
3353
+ id: "hermes.untracked-runtimes",
3354
+ title: "Hermes agent runtimes untracked + gitignored",
3355
+ audit: (ctx) => {
3356
+ const roles = discoverRoles(ctx.repoRoot);
3357
+ if (roles.length === 0) {
3358
+ return {
3359
+ id: "hermes.untracked-runtimes",
3360
+ title: "Hermes agent runtimes untracked + gitignored",
3361
+ status: "skip",
3362
+ summary: "No Hermes roles present",
3363
+ details: [],
3364
+ fixable: false
3365
+ };
3366
+ }
3367
+ const details = [];
3368
+ for (const role of roles) {
3369
+ const roleRelDir = relative(ctx.repoRoot, role.roleDir);
3370
+ const runtimeRelPath = join11(roleRelDir, "runtime");
3371
+ const lsResult = spawnSync6("git", ["ls-files", "--stage", runtimeRelPath], {
3372
+ cwd: ctx.repoRoot,
3373
+ encoding: "utf8"
3374
+ });
3375
+ if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
3376
+ details.push(`submodule runtime is tracked in Git index at ${runtimeRelPath}`);
3377
+ }
3378
+ const gitignorePath = join11(role.roleDir, ".gitignore");
3379
+ if (existsSync9(gitignorePath)) {
3380
+ const content = safeReadText(gitignorePath) ?? "";
3381
+ const lines = content.split(/\r?\n/).map((line) => line.trim());
3382
+ if (!lines.includes("runtime/") && !lines.includes("runtime")) {
3383
+ details.push(`.gitignore missing runtime/ ignore entry in ${relative(ctx.repoRoot, gitignorePath)}`);
3384
+ }
3385
+ } else {
3386
+ details.push(`.gitignore is missing in ${relative(ctx.repoRoot, gitignorePath)}`);
3387
+ }
3388
+ }
3389
+ return {
3390
+ id: "hermes.untracked-runtimes",
3391
+ title: "Hermes agent runtimes untracked + gitignored",
3392
+ status: details.length === 0 ? "pass" : "fail",
3393
+ summary: details.length === 0 ? "All Hermes agent runtimes are untracked and gitignored" : `${details.length} issue(s) with untracked/ignored runtimes detected`,
3394
+ details,
3395
+ fixable: true
3396
+ };
3397
+ },
3398
+ migrate: (ctx, finding) => {
3399
+ const roles = discoverRoles(ctx.repoRoot);
3400
+ const changedFiles = [];
3401
+ const details = [];
3402
+ for (const role of roles) {
3403
+ const roleRelDir = relative(ctx.repoRoot, role.roleDir);
3404
+ const runtimeRelPath = join11(roleRelDir, "runtime");
3405
+ const lsResult = spawnSync6("git", ["ls-files", "--stage", runtimeRelPath], {
3406
+ cwd: ctx.repoRoot,
3407
+ encoding: "utf8"
3408
+ });
3409
+ if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
3410
+ details.push(`untrack ${runtimeRelPath}`);
3411
+ changedFiles.push(runtimeRelPath);
3412
+ if (!ctx.dryRun) {
3413
+ spawnSync6("git", ["rm", "--cached", "-r", runtimeRelPath], {
3414
+ cwd: ctx.repoRoot,
3415
+ encoding: "utf8"
3416
+ });
3417
+ }
3418
+ }
3419
+ const gitignorePath = join11(role.roleDir, ".gitignore");
3420
+ let content = "";
3421
+ let isIgnored = false;
3422
+ if (existsSync9(gitignorePath)) {
3423
+ content = safeReadText(gitignorePath) ?? "";
3424
+ const lines = content.split(/\r?\n/).map((line) => line.trim());
3425
+ isIgnored = lines.includes("runtime/") || lines.includes("runtime");
3426
+ }
3427
+ if (!isIgnored) {
3428
+ details.push(`ignore runtime/ in ${relative(ctx.repoRoot, gitignorePath)}`);
3429
+ changedFiles.push(gitignorePath);
3430
+ if (!ctx.dryRun) {
3431
+ if (content && !content.endsWith("\n")) {
3432
+ content += "\n";
3433
+ }
3434
+ content += "runtime/\n";
3435
+ writeText(gitignorePath, content);
3436
+ }
3437
+ }
3438
+ }
3439
+ return {
3440
+ id: finding.id,
3441
+ title: finding.title,
3442
+ status: changedFiles.length ? "applied" : "noop",
3443
+ summary: changedFiles.length ? "Hermes agent runtimes made untracked and ignored" : "No changes required",
3444
+ changedFiles,
3445
+ details
3446
+ };
3447
+ }
3448
+ },
3155
3449
  {
3156
3450
  id: "systemd.sentinel",
3157
3451
  title: "Hermes systemd/sentinel units enabled + active",
@@ -3192,9 +3486,9 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3192
3486
  return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
3193
3487
  }
3194
3488
  for (const role of roles) {
3195
- const sysDir = join10(ctx.homeDir, ".config", "systemd", "user");
3489
+ const sysDir = join11(ctx.homeDir, ".config", "systemd", "user");
3196
3490
  const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
3197
- const allUnitsPresent = units.every((unit) => existsSync8(join10(sysDir, unit)));
3491
+ const allUnitsPresent = units.every((unit) => existsSync9(join11(sysDir, unit)));
3198
3492
  if (allUnitsPresent) {
3199
3493
  if (ctx.dryRun) {
3200
3494
  details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
@@ -3206,12 +3500,12 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3206
3500
  }
3207
3501
  continue;
3208
3502
  }
3209
- for (const script of [join10(role.roleDir, ".scripts", "70-systemd.sh")]) {
3210
- if (!script || !existsSync8(script)) continue;
3503
+ for (const script of [join11(role.roleDir, ".scripts", "70-systemd.sh")]) {
3504
+ if (!script || !existsSync9(script)) continue;
3211
3505
  if (ctx.dryRun) {
3212
3506
  details.push(`would run: bash ${script}`);
3213
3507
  } else {
3214
- const result = spawnSync5("bash", [script], { cwd: role.roleDir, encoding: "utf8" });
3508
+ const result = spawnSync6("bash", [script], { cwd: role.roleDir, encoding: "utf8" });
3215
3509
  if (result.status !== 0) details.push(`script failed: ${script}: ${result.stderr.trim() || result.stdout.trim()}`);
3216
3510
  }
3217
3511
  }
@@ -3346,15 +3640,15 @@ function formatMigrationReport(report) {
3346
3640
  }
3347
3641
 
3348
3642
  // src/utils/version.ts
3349
- import { readFileSync as readFileSync5 } from "node:fs";
3350
- import { dirname as dirname7, join as join11 } from "node:path";
3643
+ import { readFileSync as readFileSync6 } from "node:fs";
3644
+ import { dirname as dirname7, join as join12 } from "node:path";
3351
3645
  import { fileURLToPath as fileURLToPath4 } from "node:url";
3352
3646
  var PJANGLER_VERSION = (() => {
3353
3647
  try {
3354
3648
  let dir = dirname7(fileURLToPath4(import.meta.url));
3355
3649
  for (let i = 0; i < 4; i++) {
3356
3650
  try {
3357
- const raw = readFileSync5(join11(dir, "package.json"), "utf8");
3651
+ const raw = readFileSync6(join12(dir, "package.json"), "utf8");
3358
3652
  return JSON.parse(raw).version ?? "0.0.0";
3359
3653
  } catch {
3360
3654
  const parent = dirname7(dir);
@@ -3397,16 +3691,16 @@ async function promptForRuleIds(rules) {
3397
3691
  return selected;
3398
3692
  }
3399
3693
  function readJson(path) {
3400
- if (!existsSync9(path)) return void 0;
3694
+ if (!existsSync10(path)) return void 0;
3401
3695
  try {
3402
- const parsed = JSON.parse(readFileSync6(path, "utf8"));
3696
+ const parsed = JSON.parse(readFileSync7(path, "utf8"));
3403
3697
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
3404
3698
  } catch {
3405
3699
  return void 0;
3406
3700
  }
3407
3701
  }
3408
3702
  function findGitRoot(cwd) {
3409
- const result = spawnSync6("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
3703
+ const result = spawnSync7("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
3410
3704
  if (result.status !== 0) return void 0;
3411
3705
  return resolve3(result.stdout.trim());
3412
3706
  }
@@ -3416,8 +3710,8 @@ function packageNameToProjectName(value) {
3416
3710
  return name.replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()).trim();
3417
3711
  }
3418
3712
  function deriveProjectDefaults(targetDir) {
3419
- const manifest = readJson(join12(targetDir, ".project.json"));
3420
- const pkg = readJson(join12(targetDir, "package.json"));
3713
+ const manifest = readJson(join13(targetDir, ".project.json"));
3714
+ const pkg = readJson(join13(targetDir, "package.json"));
3421
3715
  const name = String(manifest?.project_name ?? "").trim() || packageNameToProjectName(typeof pkg?.name === "string" ? pkg.name : void 0) || packageNameToProjectName(basename4(targetDir)) || "Project";
3422
3716
  const ticketProvider = manifest?.ticket_provider && typeof manifest.ticket_provider === "object" ? manifest.ticket_provider : {};
3423
3717
  return {
@@ -3473,7 +3767,7 @@ function actionNeedsRun(plan, kind, syncMode) {
3473
3767
  if (!action || action.kind !== "project.write-manifest") return false;
3474
3768
  const next = `${JSON.stringify(action.manifest, null, 2)}
3475
3769
  `;
3476
- return !existsSync9(action.path) || readFileSync6(action.path, "utf8") !== next;
3770
+ return !existsSync10(action.path) || readFileSync7(action.path, "utf8") !== next;
3477
3771
  }
3478
3772
  if (kind === "copier.copy.commonproject") return true;
3479
3773
  if (kind === "ticket-provider.create-or-link") return plan.actions.some((action) => action.kind === kind && action.enabled);
@@ -3528,7 +3822,7 @@ async function resolveProjectInitTarget(name, options) {
3528
3822
  if (!targetDir && interactive) {
3529
3823
  const defaultName = name ?? basename4(cwd);
3530
3824
  const promptedName = name ?? await promptTextValue("Project name", packageNameToProjectName(defaultName));
3531
- const defaultDir = join12(cwd, promptedName.replace(/[^A-Za-z0-9._-]/g, "") || promptedName.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
3825
+ const defaultDir = join13(cwd, promptedName.replace(/[^A-Za-z0-9._-]/g, "") || promptedName.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
3532
3826
  targetDir = await promptTextValue("Project directory", defaultDir);
3533
3827
  name = promptedName;
3534
3828
  }
@@ -3536,7 +3830,7 @@ async function resolveProjectInitTarget(name, options) {
3536
3830
  if (!name) throw new Error("Project name or --target-dir is required when project init is not run inside a git repo");
3537
3831
  targetDir = resolve3(process.cwd(), name.replace(/[^A-Za-z0-9._-]/g, "") || name.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
3538
3832
  }
3539
- const targetExists = existsSync9(targetDir);
3833
+ const targetExists = existsSync10(targetDir);
3540
3834
  if (targetExists && !statSync2(targetDir).isDirectory()) throw new Error(`Target path is not a directory: ${targetDir}`);
3541
3835
  const targetGitRoot = targetExists ? findGitRoot(targetDir) : void 0;
3542
3836
  const syncMode = Boolean(targetGitRoot && resolve3(targetGitRoot) === resolve3(targetDir));
@@ -3574,7 +3868,7 @@ async function runRecipeSubsystem(name, options) {
3574
3868
  }
3575
3869
  var program = new Command3();
3576
3870
  program.name("pjangler").description("Project subsystem bootstrapper CLI").version(PJANGLER_VERSION);
3577
- program.command("init").argument("[name]", "Project name to bootstrap (omit inside an existing git repo)").description("Bootstrap a project: registry entry + CommonProject scaffold + .project.json").option("--description <text>", "Project description").option("--target-dir <path>", "Target repo path").option("--source-skill <path>", "Source skill/template provenance path").option("--primary-language <language>", "Primary language for CommonProject rendering", "python").option("--provision-agent", "Plan local Hermes PM agent provisioning").option("--agent-role <role>", "Hermes agent role to plan when --provision-agent is set", "pm").option("--apply", "Write the registry and render the repo scaffold").option("--dry-run", "Preview changes without writing files (default)").option("--live", "Allow live/network/cloud provisioning actions").option("--slug <slug>", "Project registry slug override").option("--identifier <identifier>", "Ticket identifier override").option("--ticket-provider <type>", "Ticket provider: plane | trello", "plane").option("--board-id <id>", "Board id (Plane project UUID or Trello board id)").option("--board-url <url>", "Board URL override (derived from provider + board-id if omitted)").option("--workspace <name>", "Ticket workspace/org (Plane workspace; blank for Trello)").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("-f, --force", "Allow replacing an existing registry entry and re-rendering files").option("-y, --yes", "Apply every proposed operation without prompting").option("--no-tui", "Disable interactive prompts").option("--json", "Output machine-parseable JSON").action(async (name, options) => {
3871
+ program.command("init").argument("[name]", "Project name to bootstrap (omit inside an existing git repo)").description("Bootstrap a project: registry entry + CommonProject scaffold + .project.json").option("--description <text>", "Project description").option("--target-dir <path>", "Target repo path").option("--source-skill <path>", "Source skill/template provenance path").option("--primary-language <language>", "Primary language for CommonProject rendering", "python").option("--provision-agent", "Plan local Hermes PM agent provisioning").option("--agent-role <role>", "Hermes agent role to plan when --provision-agent is set", "pm").option("--apply", "Write the registry and render the repo scaffold").option("--dry-run", "Preview changes without writing files (default)").option("--live", "Allow live/network/cloud provisioning actions").option("--slug <slug>", "Project registry slug override").option("--identifier <identifier>", "Ticket identifier override").option("--ticket-provider <type>", "Ticket provider: plane | trello", "plane").option("--board-id <id>", "Board id (Plane project UUID or Trello board id)").option("--board-url <url>", "Deprecated no-op; board URLs are derived from provider + workspace + board-id").option("--workspace <name>", "Ticket workspace/org (Plane workspace; blank for Trello)").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("-f, --force", "Allow replacing an existing registry entry and re-rendering files").option("-y, --yes", "Apply every proposed operation without prompting").option("--no-tui", "Disable interactive prompts").option("--json", "Output machine-parseable JSON").action(async (name, options) => {
3578
3872
  if (name && getRecipeNames().includes(name)) {
3579
3873
  if (!options.json) {
3580
3874
  console.error(`${yellow(glyph.warn)} ${dim(`"pjangler init ${name}" is deprecated \u2014 use "pjangler add ${name}". Forwarding\u2026`)}`);
@@ -3603,7 +3897,7 @@ program.command("list").description("List available subsystems").action(() => {
3603
3897
  console.log("");
3604
3898
  });
3605
3899
  var projectCmd = program.command("project").description("Manage the pjangler project registry");
3606
- projectCmd.command("init").argument("[name]", "Project display name").description("Plan or apply a registry-backed CommonProject initialization or legacy repo sync").option("--description <text>", "Project description").option("--target-dir <path>", "Target repo path").option("--source-skill <path>", "Source skill/template provenance path").option("--primary-language <language>", "Primary language for CommonProject rendering", "python").option("--provision-agent", "Plan local Hermes PM agent provisioning").option("--agent-role <role>", "Hermes agent role to plan when --provision-agent is set", "pm").option("--apply", "Write the registry and render the repo scaffold").option("--dry-run", "Preview changes without writing files (default)").option("--live", "Allow live/network/cloud provisioning actions").option("--slug <slug>", "Project registry slug override").option("--identifier <identifier>", "Ticket identifier override").option("--ticket-provider <type>", "Ticket provider: plane | trello", "plane").option("--board-id <id>", "Board id (Plane project UUID or Trello board id)").option("--board-url <url>", "Board URL override (derived from provider + board-id if omitted)").option("--workspace <name>", "Ticket workspace/org (Plane workspace; blank for Trello)").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("-f, --force", "Allow replacing an existing registry entry and re-rendering files").option("-y, --yes", "Apply every proposed operation without prompting").option("--no-tui", "Disable interactive prompts").option("--json", "Output machine-parseable JSON").action((name, options) => {
3900
+ projectCmd.command("init").argument("[name]", "Project display name").description("Plan or apply a registry-backed CommonProject initialization or legacy repo sync").option("--description <text>", "Project description").option("--target-dir <path>", "Target repo path").option("--source-skill <path>", "Source skill/template provenance path").option("--primary-language <language>", "Primary language for CommonProject rendering", "python").option("--provision-agent", "Plan local Hermes PM agent provisioning").option("--agent-role <role>", "Hermes agent role to plan when --provision-agent is set", "pm").option("--apply", "Write the registry and render the repo scaffold").option("--dry-run", "Preview changes without writing files (default)").option("--live", "Allow live/network/cloud provisioning actions").option("--slug <slug>", "Project registry slug override").option("--identifier <identifier>", "Ticket identifier override").option("--ticket-provider <type>", "Ticket provider: plane | trello", "plane").option("--board-id <id>", "Board id (Plane project UUID or Trello board id)").option("--board-url <url>", "Deprecated no-op; board URLs are derived from provider + workspace + board-id").option("--workspace <name>", "Ticket workspace/org (Plane workspace; blank for Trello)").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("-f, --force", "Allow replacing an existing registry entry and re-rendering files").option("-y, --yes", "Apply every proposed operation without prompting").option("--no-tui", "Disable interactive prompts").option("--json", "Output machine-parseable JSON").action((name, options) => {
3607
3901
  if (!options.json) console.error(`${yellow(glyph.warn)} ${dim('"pjangler project init" is deprecated \u2014 use "pjangler init".')}`);
3608
3902
  return runProjectInit(name, options);
3609
3903
  });