@delorenj/pjangler 1.2.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +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
7
  import { Command as Command3 } from "commander";
5
8
 
6
9
  // src/commands/hermes/types.ts
@@ -156,6 +159,78 @@ var EnsureTemplateConfig = class extends Command {
156
159
  }
157
160
  };
158
161
 
162
+ // src/utils/style.ts
163
+ var env = process.env;
164
+ function detectColor() {
165
+ if ("NO_COLOR" in env && env.NO_COLOR !== "") return false;
166
+ const force = env.FORCE_COLOR;
167
+ if (force === "0" || force === "false") return false;
168
+ if (force !== void 0 && force !== "") return true;
169
+ if (env.TERM === "dumb") return false;
170
+ return Boolean(process.stdout.isTTY);
171
+ }
172
+ var colorEnabled = detectColor();
173
+ function sgr(open, close) {
174
+ const prefix = `\x1B[${open}m`;
175
+ const suffix = `\x1B[${close}m`;
176
+ return (value) => colorEnabled ? `${prefix}${value}${suffix}` : String(value);
177
+ }
178
+ var bold = sgr(1, 22);
179
+ var dim = sgr(2, 22);
180
+ var italic = sgr(3, 23);
181
+ var underline = sgr(4, 24);
182
+ var red = sgr(31, 39);
183
+ var green = sgr(32, 39);
184
+ var yellow = sgr(33, 39);
185
+ var blue = sgr(34, 39);
186
+ var magenta = sgr(35, 39);
187
+ var cyan = sgr(36, 39);
188
+ var gray = sgr(90, 39);
189
+ var glyph = {
190
+ pass: "\u2714",
191
+ fail: "\u2716",
192
+ warn: "\u26A0",
193
+ skip: "\u25CB",
194
+ info: "\u2139",
195
+ arrow: "\u21B3",
196
+ bullet: "\u2022",
197
+ dot: "\xB7",
198
+ add: "+",
199
+ chevron: "\u25B8",
200
+ pointer: "\u276F"
201
+ };
202
+ var STATUS_STYLES = {
203
+ pass: { glyph: glyph.pass, color: green, label: "pass" },
204
+ fail: { glyph: glyph.fail, color: red, label: "fail" },
205
+ warn: { glyph: glyph.warn, color: yellow, label: "warn" },
206
+ skip: { glyph: glyph.skip, color: gray, label: "skip" },
207
+ applied: { glyph: glyph.pass, color: green, label: "applied" },
208
+ noop: { glyph: glyph.skip, color: gray, label: "noop" },
209
+ blocked: { glyph: glyph.fail, color: red, label: "blocked" },
210
+ skipped: { glyph: glyph.skip, color: gray, label: "skipped" }
211
+ };
212
+ function statusStyle(status) {
213
+ return STATUS_STYLES[status] ?? { glyph: glyph.dot, color: dim, label: status };
214
+ }
215
+ function projectStatusColor(status) {
216
+ switch (status) {
217
+ case "active":
218
+ return green;
219
+ case "planned":
220
+ return yellow;
221
+ case "archived":
222
+ return gray;
223
+ default:
224
+ return cyan;
225
+ }
226
+ }
227
+ function heading(title, marker = glyph.chevron) {
228
+ return `${cyan(bold(marker))} ${bold(title)}`;
229
+ }
230
+ function joinDot(fragments) {
231
+ return fragments.join(dim(` ${glyph.dot} `));
232
+ }
233
+
159
234
  // src/recipes/Recipe.ts
160
235
  var Recipe = class {
161
236
  context;
@@ -168,26 +243,22 @@ var Recipe = class {
168
243
  return this;
169
244
  }
170
245
  async execute() {
171
- const dryRunPrefix = this.context.dryRun ? "[DRY RUN] " : "";
172
- console.log(`${dryRunPrefix}\u{1F680} Initializing ${this.constructor.name.replace("Recipe", "").toLowerCase()} subsystem...`);
173
- if (this.context.dryRun) {
174
- console.log("\u26A0\uFE0F Dry-run mode: No files will be modified");
175
- console.log("");
176
- }
246
+ const subsystem = this.constructor.name.replace("Recipe", "").toLowerCase();
247
+ const dryRun = this.context.dryRun;
248
+ console.log("");
249
+ console.log(` ${cyan(bold(glyph.chevron))} ${bold(`Initializing ${subsystem} subsystem`)}${dryRun ? ` ${dim(glyph.dot)} ${yellow("dry run")}` : ""}`);
250
+ console.log("");
177
251
  for (const command of this.ingredients) {
178
252
  const result = await command.invoke();
179
- if (result.success) {
180
- console.log(result.message);
181
- } else {
182
- console.log(result.message);
183
- }
253
+ console.log(result.message.split("\n").map((line) => line ? ` ${line}` : line).join("\n"));
184
254
  }
185
- if (!this.context.dryRun) {
255
+ if (!dryRun) {
186
256
  this.printNextSteps();
187
257
  } else {
188
258
  console.log("");
189
- console.log("\u2713 Dry-run complete - no files were modified");
190
- console.log(" Remove --dry-run flag to apply changes");
259
+ console.log(` ${green(glyph.pass)} ${dim("Dry-run complete \u2014 no files were modified.")}`);
260
+ console.log(` ${dim("Remove --dry-run to apply changes.")}`);
261
+ console.log("");
191
262
  }
192
263
  }
193
264
  };
@@ -330,11 +401,111 @@ if __name__ == "__main__":
330
401
  }
331
402
  };
332
403
 
404
+ // src/commands/AddMiseCodegraphScript.ts
405
+ import { chmodSync } from "fs";
406
+ import { join as join3 } from "path";
407
+ var AddMiseCodegraphScript = class extends Command {
408
+ async invoke() {
409
+ const filePath = ".mise/scripts/codegraph.sh";
410
+ if (this.fileExists(filePath) && !this.context.force) {
411
+ return {
412
+ success: false,
413
+ message: this.formatMessage("\u26A0\uFE0F .mise/scripts/codegraph.sh already exists"),
414
+ filePath
415
+ };
416
+ }
417
+ const content = `#!/usr/bin/env bash
418
+ # Mise enter hook: ensure the CodeGraph CLI is available and initialize the
419
+ # project index. If \`codegraph\` is not installed, this script installs it
420
+ # non-interactively into the project-local .mise/bin directory and retries.
421
+ #
422
+ # This is intended to run from a mise enter hook so onboarding a new host is
423
+ # fully automatic.
424
+
425
+ set -euo pipefail
426
+
427
+ REPO_ROOT="\${MISE_PROJECT_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}"
428
+ PROJECT_BIN_DIR="$REPO_ROOT/.mise/bin"
429
+ mkdir -p "$PROJECT_BIN_DIR"
430
+
431
+ # Install the CodeGraph CLI into the project-local bin directory.
432
+ install_codegraph() {
433
+ echo "[mise] codegraph not found. Installing non-interactively..."
434
+ export CODEGRAPH_BIN_DIR="$PROJECT_BIN_DIR"
435
+ curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh
436
+
437
+ if [ -x "$PROJECT_BIN_DIR/codegraph" ]; then
438
+ export PATH="$PROJECT_BIN_DIR:$PATH"
439
+ else
440
+ echo "[mise] codegraph install did not place a binary at $PROJECT_BIN_DIR/codegraph" >&2
441
+ return 1
442
+ fi
443
+ }
444
+
445
+ # Ensure a codegraph binary is available on PATH.
446
+ ensure_codegraph() {
447
+ if command -v codegraph >/dev/null 2>&1; then
448
+ return 0
449
+ fi
450
+
451
+ # Check the project-local bin dir first (previous install from this hook).
452
+ if [ -x "$PROJECT_BIN_DIR/codegraph" ]; then
453
+ export PATH="$PROJECT_BIN_DIR:$PATH"
454
+ return 0
455
+ fi
456
+
457
+ # Check typical user-level install locations before fetching anything.
458
+ for d in "$HOME/.local/bin" "$HOME/.codegraph/current/bin"; do
459
+ if [ -x "$d/codegraph" ]; then
460
+ export PATH="$d:$PATH"
461
+ return 0
462
+ fi
463
+ done
464
+
465
+ install_codegraph
466
+ }
467
+
468
+ # Attempt to initialize the project graph. If the command is missing, install
469
+ # it and retry once.
470
+ init_project() {
471
+ local err_file
472
+ err_file="$(mktemp)"
473
+ trap 'rm -f "$err_file"' RETURN
474
+
475
+ if codegraph init -i "$REPO_ROOT" 2>"$err_file"; then
476
+ return 0
477
+ fi
478
+
479
+ # If the failure looks like a missing binary, install and retry.
480
+ if grep -qiE 'command not found|not installed|No such file|executable file not found' "$err_file" 2>/dev/null; then
481
+ ensure_codegraph
482
+ codegraph init -i "$REPO_ROOT"
483
+ return 0
484
+ fi
485
+
486
+ cat "$err_file" >&2
487
+ return 1
488
+ }
489
+
490
+ init_project
491
+ `;
492
+ this.writeFile(filePath, content);
493
+ if (!this.context.dryRun) {
494
+ chmodSync(join3(this.context.targetDir, filePath), 493);
495
+ }
496
+ return {
497
+ success: true,
498
+ message: this.formatMessage(this.context.dryRun ? "Would create .mise/scripts/codegraph.sh" : "\u2705 Created .mise/scripts/codegraph.sh"),
499
+ filePath
500
+ };
501
+ }
502
+ };
503
+
333
504
  // src/recipes/MiseRecipe.ts
334
505
  var MiseRecipe = class extends Recipe {
335
506
  constructor(context) {
336
507
  super(context);
337
- this.addIngredient(AddMiseToml).addIngredient(AddDotenv).addIngredient(AddMiseTasksStructure).addIngredient(AddMiseBaseToml).addIngredient(AddMiseBaseScript);
508
+ this.addIngredient(AddMiseToml).addIngredient(AddDotenv).addIngredient(AddMiseTasksStructure).addIngredient(AddMiseBaseToml).addIngredient(AddMiseBaseScript).addIngredient(AddMiseCodegraphScript);
338
509
  }
339
510
  printNextSteps() {
340
511
  console.log("\u{1F389} Mise subsystem initialized successfully!");
@@ -562,12 +733,12 @@ var NodeRecipe = class extends Recipe {
562
733
  };
563
734
 
564
735
  // src/commands/hermes/PromptForAgentConfig.ts
565
- import { basename, join as join3 } from "node:path";
736
+ import { basename, join as join4 } from "node:path";
566
737
  import { readFileSync } from "node:fs";
567
738
  import * as p from "@clack/prompts";
568
739
  function detectTicketProvider(targetDir) {
569
740
  try {
570
- const t = JSON.parse(readFileSync(join3(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
741
+ const t = JSON.parse(readFileSync(join4(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
571
742
  return t === "plane" || t === "linear" || t === "trello" ? t : void 0;
572
743
  } catch {
573
744
  return void 0;
@@ -703,7 +874,7 @@ var PromptForAgentConfig = class extends Command {
703
874
  // src/commands/hermes/RunCopierTemplate.ts
704
875
  import { spawnSync } from "node:child_process";
705
876
  import { homedir as homedir2 } from "node:os";
706
- import { join as join4, dirname as dirname3 } from "node:path";
877
+ import { join as join5, dirname as dirname3 } from "node:path";
707
878
  import { existsSync as existsSync3, mkdirSync as mkdirSync3 } from "node:fs";
708
879
  import { fileURLToPath } from "node:url";
709
880
  import * as p2 from "@clack/prompts";
@@ -715,8 +886,8 @@ function resolveVendoredTemplate(name) {
715
886
  return void 0;
716
887
  }
717
888
  for (let i = 0; i < 8; i++) {
718
- const candidate = join4(dir, "templates", name);
719
- if (existsSync3(join4(candidate, "copier.yml"))) return candidate;
889
+ const candidate = join5(dir, "templates", name);
890
+ if (existsSync3(join5(candidate, "copier.yml"))) return candidate;
720
891
  const parent = dirname3(dir);
721
892
  if (parent === dir) break;
722
893
  dir = parent;
@@ -735,7 +906,7 @@ var RunCopierTemplate = class extends Command {
735
906
  message: "PromptForAgentConfig must run before RunCopierTemplate (targetRepo/role unset)"
736
907
  };
737
908
  }
738
- const roleDir = join4(ctx.targetDir, "agents", "hermes", role);
909
+ const roleDir = join5(ctx.targetDir, "agents", "hermes", role);
739
910
  ctx.roleDir = roleDir;
740
911
  ctx.runtimeRepo = `delorenj/agent-hm-${targetRepo}-${role}`;
741
912
  const which = spawnSync("which", ["copier"], { encoding: "utf8" });
@@ -745,7 +916,7 @@ var RunCopierTemplate = class extends Command {
745
916
  message: "\u2717 copier not found on PATH. Install with: `uv tool install copier` or `pip install copier`"
746
917
  };
747
918
  }
748
- if (existsSync3(join4(roleDir, "role.yaml")) && !ctx.force) {
919
+ if (existsSync3(join5(roleDir, "role.yaml")) && !ctx.force) {
749
920
  if (ctx.yes) {
750
921
  ctx.force = true;
751
922
  } else {
@@ -762,7 +933,7 @@ var RunCopierTemplate = class extends Command {
762
933
  ctx.force = true;
763
934
  }
764
935
  }
765
- const env = {
936
+ const env2 = {
766
937
  ...process.env,
767
938
  SKIP_TELEGRAM: "1",
768
939
  SKIP_EMAIL: "1",
@@ -772,9 +943,9 @@ var RunCopierTemplate = class extends Command {
772
943
  SKIP_BLOODBANK: ctx.skipBloodbank ? "1" : "0",
773
944
  SKIP_SYSTEMD: ctx.skipSystemd ? "1" : "0"
774
945
  };
775
- const LOCAL_TEMPLATE = join4(homedir2(), "code", "hermes-agent-template");
946
+ const LOCAL_TEMPLATE = join5(homedir2(), "code", "hermes-agent-template");
776
947
  const vendored = resolveVendoredTemplate("hermes-agent");
777
- const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync3(join4(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
948
+ const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync3(join5(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
778
949
  const args = [
779
950
  "copy",
780
951
  templateSrc,
@@ -805,13 +976,13 @@ var RunCopierTemplate = class extends Command {
805
976
  message: this.formatMessage(`Would run: copier ${args.join(" ")}`)
806
977
  };
807
978
  }
808
- mkdirSync3(join4(ctx.targetDir, "agents", "hermes"), { recursive: true });
979
+ mkdirSync3(join5(ctx.targetDir, "agents", "hermes"), { recursive: true });
809
980
  const spinner4 = p2.spinner();
810
981
  spinner4.start(`Running copier copy (target: agents/hermes/${role})`);
811
982
  const result = spawnSync("copier", args, {
812
983
  stdio: "inherit",
813
984
  // pass the interactive output through; copier prints its own progress
814
- env,
985
+ env: env2,
815
986
  cwd: ctx.targetDir
816
987
  });
817
988
  spinner4.stop(result.status === 0 ? "\u2713 copier run complete" : "\u2717 copier failed");
@@ -830,7 +1001,7 @@ var RunCopierTemplate = class extends Command {
830
1001
 
831
1002
  // src/commands/hermes/WireTelegram.ts
832
1003
  import { spawnSync as spawnSync2 } from "node:child_process";
833
- import { join as join5 } from "node:path";
1004
+ import { join as join6 } from "node:path";
834
1005
  import { existsSync as existsSync4, unlinkSync } from "node:fs";
835
1006
  import * as p3 from "@clack/prompts";
836
1007
  var WireTelegram = class extends Command {
@@ -919,14 +1090,14 @@ var WireTelegram = class extends Command {
919
1090
  if (p3.isCancel(allowedAnswer)) {
920
1091
  return { success: false, message: "\u2717 Aborted; Telegram step deferred." };
921
1092
  }
922
- const script = join5(roleDir, ".scripts", "30-telegram.sh");
1093
+ const script = join6(roleDir, ".scripts", "30-telegram.sh");
923
1094
  if (!existsSync4(script)) {
924
1095
  return {
925
1096
  success: false,
926
1097
  message: `\u2717 ${script} not found. Did copier finish? Re-run with --skip-runtime-repo=0 if you skipped it.`
927
1098
  };
928
1099
  }
929
- const marker = join5(roleDir, ".scripts", ".done-30-telegram");
1100
+ const marker = join6(roleDir, ".scripts", ".done-30-telegram");
930
1101
  if (existsSync4(marker)) unlinkSync(marker);
931
1102
  const spinner4 = p3.spinner();
932
1103
  spinner4.start("Verifying token + wiring profile");
@@ -954,7 +1125,7 @@ function cap(s) {
954
1125
 
955
1126
  // src/commands/hermes/WireEmail.ts
956
1127
  import { spawnSync as spawnSync3 } from "node:child_process";
957
- import { join as join6 } from "node:path";
1128
+ import { join as join7 } from "node:path";
958
1129
  import { existsSync as existsSync5, unlinkSync as unlinkSync2 } from "node:fs";
959
1130
  import * as p4 from "@clack/prompts";
960
1131
  var WireEmail = class extends Command {
@@ -970,7 +1141,7 @@ var WireEmail = class extends Command {
970
1141
  if (!targetRepo || !role || !roleDir) {
971
1142
  return { success: false, message: "Cannot wire email: missing target_repo/role/roleDir" };
972
1143
  }
973
- const script = join6(roleDir, ".scripts", "50-email.sh");
1144
+ const script = join7(roleDir, ".scripts", "50-email.sh");
974
1145
  if (!existsSync5(script)) {
975
1146
  return { success: false, message: `\u2717 ${script} not found` };
976
1147
  }
@@ -1033,7 +1204,7 @@ var WireEmail = class extends Command {
1033
1204
  }
1034
1205
  }
1035
1206
  }
1036
- const marker = join6(roleDir, ".scripts", ".done-50-email");
1207
+ const marker = join7(roleDir, ".scripts", ".done-50-email");
1037
1208
  if (existsSync5(marker)) unlinkSync2(marker);
1038
1209
  const spinner4 = p4.spinner();
1039
1210
  spinner4.start("Creating Cloudflare Email Routing rule");
@@ -1122,7 +1293,7 @@ var HermesAgentRecipe = class extends Recipe {
1122
1293
 
1123
1294
  // src/commands/AgentHooksCommands.ts
1124
1295
  import { homedir as homedir3 } from "node:os";
1125
- import { join as join7, dirname as dirname4 } from "node:path";
1296
+ import { join as join8, dirname as dirname4 } from "node:path";
1126
1297
  import { existsSync as existsSync6, cpSync, mkdirSync as mkdirSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "node:fs";
1127
1298
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1128
1299
  function resolveTemplateRoot() {
@@ -1133,16 +1304,16 @@ function resolveTemplateRoot() {
1133
1304
  try {
1134
1305
  let dir = dirname4(fileURLToPath2(import.meta.url));
1135
1306
  for (let i = 0; i < 8; i++) {
1136
- candidates.push(join7(dir, "templates", "commonproject", "template"));
1307
+ candidates.push(join8(dir, "templates", "commonproject", "template"));
1137
1308
  const parent = dirname4(dir);
1138
1309
  if (parent === dir) break;
1139
1310
  dir = parent;
1140
1311
  }
1141
1312
  } catch {
1142
1313
  }
1143
- candidates.push(join7(homedir3(), "code", "pjangler", "templates", "commonproject", "template"));
1314
+ candidates.push(join8(homedir3(), "code", "pjangler", "templates", "commonproject", "template"));
1144
1315
  for (const c of candidates) {
1145
- if (existsSync6(join7(c, ".agents", "hooks", "hooks.master.json"))) return c;
1316
+ if (existsSync6(join8(c, ".agents", "hooks", "hooks.master.json"))) return c;
1146
1317
  }
1147
1318
  throw new Error(
1148
1319
  "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
@@ -1166,8 +1337,8 @@ var CopyAgentHooksTree = class extends Command {
1166
1337
  const created = [];
1167
1338
  const skipped = [];
1168
1339
  for (const { rel, dir } of items) {
1169
- const src = join7(templateRoot, rel);
1170
- const dest = join7(this.context.targetDir, rel);
1340
+ const src = join8(templateRoot, rel);
1341
+ const dest = join8(this.context.targetDir, rel);
1171
1342
  if (!existsSync6(src)) continue;
1172
1343
  if (existsSync6(dest) && !this.context.force) {
1173
1344
  skipped.push(rel);
@@ -1192,7 +1363,7 @@ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
1192
1363
  static CR = "{{config_root}}";
1193
1364
  // mise's own runtime var — emitted literally
1194
1365
  async invoke() {
1195
- const misePath = join7(this.context.targetDir, "mise.toml");
1366
+ const misePath = join8(this.context.targetDir, "mise.toml");
1196
1367
  if (!existsSync6(misePath)) {
1197
1368
  return {
1198
1369
  success: false,
@@ -1313,7 +1484,7 @@ var RECIPE_REGISTRY = {
1313
1484
  name: "mise",
1314
1485
  description: "Mise task runner and environment setup",
1315
1486
  class: MiseRecipe,
1316
- commands: ["AddMiseToml", "AddDotenv", "AddMiseTasksStructure", "AddMiseBaseToml", "AddMiseBaseScript"]
1487
+ commands: ["AddMiseToml", "AddDotenv", "AddMiseTasksStructure", "AddMiseBaseToml", "AddMiseBaseScript", "AddMiseCodegraphScript"]
1317
1488
  },
1318
1489
  docker: {
1319
1490
  name: "docker",
@@ -1403,6 +1574,12 @@ var COMMAND_REGISTRY = {
1403
1574
  group: "mise",
1404
1575
  class: AddMiseBaseScript
1405
1576
  },
1577
+ AddMiseCodegraphScript: {
1578
+ name: "AddMiseCodegraphScript",
1579
+ description: "Create .mise/scripts/codegraph.sh enter hook",
1580
+ group: "mise",
1581
+ class: AddMiseCodegraphScript
1582
+ },
1406
1583
  AddDotenv: {
1407
1584
  name: "AddDotenv",
1408
1585
  description: "Create .env.example file",
@@ -1438,9 +1615,12 @@ function createRecipe(name, context) {
1438
1615
  return new info.class(context);
1439
1616
  }
1440
1617
 
1618
+ // src/index.ts
1619
+ import { cancel as cancel2, multiselect, text as text3, isCancel as isCancel5 } from "@clack/prompts";
1620
+
1441
1621
  // src/parity/index.ts
1442
- import { existsSync as existsSync7, lstatSync, mkdirSync as mkdirSync5, readFileSync as readFileSync3, readlinkSync, readdirSync, renameSync, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4, chmodSync, copyFileSync } from "node:fs";
1443
- import { dirname as dirname5, join as join8, relative, resolve } from "node:path";
1622
+ import { existsSync as existsSync7, lstatSync, mkdirSync as mkdirSync5, readFileSync as readFileSync3, readlinkSync, readdirSync, renameSync, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4, chmodSync as chmodSync2, copyFileSync } from "node:fs";
1623
+ import { basename as basename2, dirname as dirname5, join as join9, relative, resolve } from "node:path";
1444
1624
  import { fileURLToPath as fileURLToPath3 } from "node:url";
1445
1625
  import { homedir as homedir4 } from "node:os";
1446
1626
  import { spawnSync as spawnSync4 } from "node:child_process";
@@ -1460,6 +1640,28 @@ enter = [
1460
1640
  patterns = ["AGENTS.md"]
1461
1641
  task = "link-agentfiles"
1462
1642
 
1643
+ [tasks.link-agentfiles]
1644
+ description = "Symlink all agent files to AGENTS.md"
1645
+ run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
1646
+ var LINK_AGENTFILES_HOOK_ENTRIES = [
1647
+ "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1648
+ "op inject -i .env.op > .env"
1649
+ ];
1650
+ var LINK_AGENTFILES_HOOKS_BLOCK = `# This block will handle the linking of
1651
+ # agent files to the main AGENTS.md file.
1652
+ #
1653
+ # TODO: Ensure this works for all levels of nesting.
1654
+ # i.e. All linked agent files MUST be siblings at
1655
+ # any given level of nesting.
1656
+ [hooks]
1657
+ enter = [
1658
+ "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1659
+ "op inject -i .env.op > .env",
1660
+ ]`;
1661
+ var LINK_AGENTFILES_WATCH_TASK_BLOCK = `[[watch_files]]
1662
+ patterns = ["AGENTS.md"]
1663
+ task = "link-agentfiles"
1664
+
1463
1665
  [tasks.link-agentfiles]
1464
1666
  description = "Symlink all agent files to AGENTS.md"
1465
1667
  run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
@@ -1492,7 +1694,7 @@ run = "{{config_root}}/.mise/scripts/versioning.sh sync"
1492
1694
  function resolvePjanglerRoot() {
1493
1695
  let dir = dirname5(fileURLToPath3(import.meta.url));
1494
1696
  while (dir !== dirname5(dir)) {
1495
- if (existsSync7(join8(dir, "package.json")) && existsSync7(join8(dir, "templates", "commonproject", "copier.yml"))) {
1697
+ if (existsSync7(join9(dir, "package.json")) && existsSync7(join9(dir, "templates", "commonproject", "copier.yml"))) {
1496
1698
  return dir;
1497
1699
  }
1498
1700
  dir = dirname5(dir);
@@ -1515,10 +1717,10 @@ function writeText(path, content) {
1515
1717
  ensureParent(path);
1516
1718
  writeFileSync4(path, content);
1517
1719
  }
1518
- function tryParseJson(text3) {
1519
- if (!text3) return null;
1720
+ function tryParseJson(text4) {
1721
+ if (!text4) return null;
1520
1722
  try {
1521
- return JSON.parse(text3);
1723
+ return JSON.parse(text4);
1522
1724
  } catch {
1523
1725
  return null;
1524
1726
  }
@@ -1555,10 +1757,10 @@ function ensureSymlink(path, target, dryRun) {
1555
1757
  return { changed: true };
1556
1758
  }
1557
1759
  function bootstrapAgentsFile(repoRoot, dryRun) {
1558
- const agentsPath = join8(repoRoot, "AGENTS.md");
1760
+ const agentsPath = join9(repoRoot, "AGENTS.md");
1559
1761
  if (existsSync7(agentsPath)) return { changedFiles: [], details: [] };
1560
1762
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
1561
- const source = join8(repoRoot, file);
1763
+ const source = join9(repoRoot, file);
1562
1764
  if (!existsSync7(source)) continue;
1563
1765
  const stat = lstatSync(source);
1564
1766
  if (stat.isSymbolicLink()) continue;
@@ -1568,7 +1770,7 @@ function bootstrapAgentsFile(repoRoot, dryRun) {
1568
1770
  }
1569
1771
  return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
1570
1772
  }
1571
- const readmePath = join8(repoRoot, "README.md");
1773
+ const readmePath = join9(repoRoot, "README.md");
1572
1774
  if (existsSync7(readmePath)) {
1573
1775
  const stat = lstatSync(readmePath);
1574
1776
  if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
@@ -1577,9 +1779,9 @@ function bootstrapAgentsFile(repoRoot, dryRun) {
1577
1779
  }
1578
1780
  return { changedFiles: [], details: [], blocked: "AGENTS.md missing and no CLAUDE.md, GEMINI.md, or README.md source exists" };
1579
1781
  }
1580
- function yamlGet(text3, keyPath) {
1782
+ function yamlGet(text4, keyPath) {
1581
1783
  const parts = keyPath.split(".");
1582
- const lines = text3.split("\n");
1784
+ const lines = text4.split("\n");
1583
1785
  let start = 0;
1584
1786
  let indent = 0;
1585
1787
  for (let idx = 0; idx < parts.length; idx += 1) {
@@ -1608,36 +1810,36 @@ function yamlGet(text3, keyPath) {
1608
1810
  return "";
1609
1811
  }
1610
1812
  function discoverRoles(repoRoot) {
1611
- const rolesDir = join8(repoRoot, "agents", "hermes");
1813
+ const rolesDir = join9(repoRoot, "agents", "hermes");
1612
1814
  if (!existsSync7(rolesDir)) return [];
1613
1815
  return readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
1614
- const roleDir = join8(rolesDir, entry.name);
1615
- const roleYamlPath = join8(roleDir, "role.yaml");
1816
+ const roleDir = join9(rolesDir, entry.name);
1817
+ const roleYamlPath = join9(roleDir, "role.yaml");
1616
1818
  if (!existsSync7(roleYamlPath)) return null;
1617
- const text3 = readText(roleYamlPath);
1618
- const runtimeRepoRaw = yamlGet(text3, "runtime.github_repo");
1819
+ const text4 = readText(roleYamlPath);
1820
+ const runtimeRepoRaw = yamlGet(text4, "runtime.github_repo");
1619
1821
  return {
1620
- role: yamlGet(text3, "role") || entry.name,
1822
+ role: yamlGet(text4, "role") || entry.name,
1621
1823
  roleDir,
1622
1824
  roleYamlPath,
1623
- repo: yamlGet(text3, "repo"),
1624
- agentId: yamlGet(text3, "agent_id"),
1625
- profileName: yamlGet(text3, "profile") || yamlGet(text3, "agent_id"),
1626
- displayName: yamlGet(text3, "display_name"),
1627
- purpose: yamlGet(text3, "purpose"),
1628
- botHandle: yamlGet(text3, "telegram.bot_username"),
1825
+ repo: yamlGet(text4, "repo"),
1826
+ agentId: yamlGet(text4, "agent_id"),
1827
+ profileName: yamlGet(text4, "profile") || yamlGet(text4, "agent_id"),
1828
+ displayName: yamlGet(text4, "display_name"),
1829
+ purpose: yamlGet(text4, "purpose"),
1830
+ botHandle: yamlGet(text4, "telegram.bot_username"),
1629
1831
  runtimeRepo: runtimeRepoRaw.includes("/") ? runtimeRepoRaw.split("/").slice(-1)[0] ?? runtimeRepoRaw : runtimeRepoRaw,
1630
- runtimeOwner: yamlGet(text3, "runtime.github_owner"),
1631
- planeWorkspace: yamlGet(text3, "ticket_provider.workspace") || yamlGet(text3, "plane.workspace"),
1632
- ticketProviderName: yamlGet(text3, "ticket_provider.name"),
1633
- ticketProviderBoardId: yamlGet(text3, "ticket_provider.board_id"),
1634
- ticketProviderBoardUrl: yamlGet(text3, "ticket_provider.board_url"),
1635
- ticketProviderIdentifier: yamlGet(text3, "plane.identifier")
1832
+ runtimeOwner: yamlGet(text4, "runtime.github_owner"),
1833
+ planeWorkspace: yamlGet(text4, "ticket_provider.workspace") || yamlGet(text4, "plane.workspace"),
1834
+ ticketProviderName: yamlGet(text4, "ticket_provider.name"),
1835
+ ticketProviderBoardId: yamlGet(text4, "ticket_provider.board_id"),
1836
+ ticketProviderBoardUrl: yamlGet(text4, "ticket_provider.board_url"),
1837
+ ticketProviderIdentifier: yamlGet(text4, "plane.identifier")
1636
1838
  };
1637
1839
  }).filter((value) => Boolean(value));
1638
1840
  }
1639
1841
  function registryPath(homeDir) {
1640
- return join8(homeDir, ".hermes", "agents-registry.yaml");
1842
+ return join9(homeDir, ".hermes", "agents-registry.yaml");
1641
1843
  }
1642
1844
  function systemctlUser(args) {
1643
1845
  const result = spawnSync4("systemctl", ["--user", ...args], { encoding: "utf8" });
@@ -1648,7 +1850,7 @@ function systemctlUser(args) {
1648
1850
  };
1649
1851
  }
1650
1852
  function templateScript(ctx, name) {
1651
- const source = join8(ctx.pjanglerRoot, ".mise", "scripts", name);
1853
+ const source = join9(ctx.pjanglerRoot, ".mise", "scripts", name);
1652
1854
  return existsSync7(source) ? readText(source) : void 0;
1653
1855
  }
1654
1856
  function templateVersioningScript(ctx) {
@@ -1657,23 +1859,39 @@ function templateVersioningScript(ctx) {
1657
1859
  function templateLinkAgentfilesScript(ctx) {
1658
1860
  return templateScript(ctx, "link-agentfiles.sh");
1659
1861
  }
1862
+ function renderGeneratedProjectMiseToml(ctx, template) {
1863
+ const project = readProjectJson(ctx);
1864
+ const projectName = String(project?.project_name ?? basename2(ctx.repoRoot) ?? "project");
1865
+ return template.replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
1866
+ }
1867
+ function ensureMiseTomlFromTemplate(ctx, changedFiles) {
1868
+ const targetPath = join9(ctx.repoRoot, "mise.toml");
1869
+ if (existsSync7(targetPath)) return false;
1870
+ const sourcePath = join9(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
1871
+ if (!existsSync7(sourcePath)) return false;
1872
+ changedFiles.push(targetPath);
1873
+ if (!ctx.dryRun) {
1874
+ writeText(targetPath, renderGeneratedProjectMiseToml(ctx, readText(sourcePath)));
1875
+ }
1876
+ return true;
1877
+ }
1660
1878
  function templateVersionFilesConf(ctx, repoRoot) {
1661
- const packageJson = join8(repoRoot, "package.json");
1879
+ const packageJson = join9(repoRoot, "package.json");
1662
1880
  return existsSync7(packageJson) ? "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\njson package.json\ngittag .\n" : "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\ngittag .\n";
1663
1881
  }
1664
- function replaceOrAppendManagedBlock(text3, startMarker, block, beforePattern) {
1665
- if (startMarker.test(text3)) {
1666
- return text3.replace(/# >>> mise-versioning >>>[\s\S]*?# <<< mise-versioning <<</, block);
1882
+ function replaceOrAppendManagedBlock(text4, startMarker, block, beforePattern) {
1883
+ if (startMarker.test(text4)) {
1884
+ return text4.replace(/# >>> mise-versioning >>>[\s\S]*?# <<< mise-versioning <<</, block);
1667
1885
  }
1668
1886
  if (beforePattern) {
1669
- const match = text3.match(beforePattern);
1887
+ const match = text4.match(beforePattern);
1670
1888
  if (match && typeof match.index === "number") {
1671
- return `${text3.slice(0, match.index).replace(/\s*$/, "\n\n")}${block}
1889
+ return `${text4.slice(0, match.index).replace(/\s*$/, "\n\n")}${block}
1672
1890
 
1673
- ${text3.slice(match.index)}`;
1891
+ ${text4.slice(match.index)}`;
1674
1892
  }
1675
1893
  }
1676
- return `${text3.replace(/\s*$/, "")}
1894
+ return `${text4.replace(/\s*$/, "")}
1677
1895
 
1678
1896
  ${block}
1679
1897
  `;
@@ -1683,22 +1901,22 @@ var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/herm
1683
1901
  function requiredMisePathEntries(ctx) {
1684
1902
  const required = [...BASE_MISE_PATH_ENTRIES];
1685
1903
  for (const candidate of CONDITIONAL_HERMES_PATHS) {
1686
- if (existsSync7(join8(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
1904
+ if (existsSync7(join9(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
1687
1905
  }
1688
1906
  return required;
1689
1907
  }
1690
- function upsertMisePath(text3, required = BASE_MISE_PATH_ENTRIES) {
1908
+ function upsertMisePath(text4, required = BASE_MISE_PATH_ENTRIES) {
1691
1909
  const render = (values) => `_.path = [${values.map((value) => JSON.stringify(value)).join(", ")}]`;
1692
- const envMatch = text3.match(/(^|\n)(\[env\][\s\S]*?)(?=\n\[[^\]]+\]|$)/);
1910
+ const envMatch = text4.match(/(^|\n)(\[env\][\s\S]*?)(?=\n\[[^\]]+\]|$)/);
1693
1911
  if (!envMatch || typeof envMatch.index !== "number") {
1694
1912
  return `[env]
1695
1913
  ${render(required)}
1696
1914
 
1697
- ${text3.replace(/^\s+/, "")}`;
1915
+ ${text4.replace(/^\s+/, "")}`;
1698
1916
  }
1699
- const prefix = text3.slice(0, envMatch.index + envMatch[1].length);
1917
+ const prefix = text4.slice(0, envMatch.index + envMatch[1].length);
1700
1918
  const section = envMatch[2];
1701
- const suffix = text3.slice(envMatch.index + envMatch[1].length + section.length);
1919
+ const suffix = text4.slice(envMatch.index + envMatch[1].length + section.length);
1702
1920
  const pathLine = section.match(/^_\.path\s*=\s*\[([^\]]*)\]\s*$/m);
1703
1921
  if (!pathLine) {
1704
1922
  return `${prefix}${section.replace(/\n?$/, "\n")}${render(required)}${suffix}`;
@@ -1709,28 +1927,130 @@ ${text3.replace(/^\s+/, "")}`;
1709
1927
  if (!merged.includes(value)) merged.push(value);
1710
1928
  }
1711
1929
  const nextLine = render(merged);
1712
- if (pathLine[0] === nextLine) return text3;
1930
+ if (pathLine[0] === nextLine) return text4;
1713
1931
  return `${prefix}${section.replace(pathLine[0], nextLine)}${suffix}`;
1714
1932
  }
1715
- function upsertLinkAgentfilesBlock(text3, ctx) {
1716
- const withPath = upsertMisePath(text3, requiredMisePathEntries(ctx));
1717
- const existing = /# This block will handle the linking of[\s\S]*?\[tasks\.link-agentfiles\][\s\S]*?run = "\{\{config_root\}\}\/\.mise\/scripts\/link-agentfiles\.sh"/;
1718
- if (existing.test(withPath)) {
1719
- return withPath.replace(existing, LINK_AGENTFILES_BLOCK);
1933
+ function removeTomlSection(text4, headerPattern, marker, options) {
1934
+ const lines = text4.split("\n");
1935
+ let start = -1;
1936
+ let end = -1;
1937
+ for (let i = 0; i < lines.length; i++) {
1938
+ if (!headerPattern.test(lines[i])) continue;
1939
+ if (marker) {
1940
+ let hasMarker = false;
1941
+ for (let j = i + 1; j < lines.length && !/^\[[^\]]+\]/.test(lines[j]); j++) {
1942
+ if (marker.test(lines[j])) {
1943
+ hasMarker = true;
1944
+ break;
1945
+ }
1946
+ }
1947
+ if (!hasMarker) continue;
1948
+ }
1949
+ start = i;
1950
+ for (let j = i + 1; j < lines.length; j++) {
1951
+ if (/^\[[^\]]+\]/.test(lines[j])) {
1952
+ end = j;
1953
+ break;
1954
+ }
1955
+ }
1956
+ if (end === -1) end = lines.length;
1957
+ break;
1958
+ }
1959
+ if (start === -1) return text4;
1960
+ if (options?.includePrecedingComments) {
1961
+ while (start > 0 && lines[start - 1].trim().startsWith("#")) {
1962
+ start--;
1963
+ }
1720
1964
  }
1721
- const versioningIndex = withPath.indexOf("# >>> mise-versioning >>>");
1965
+ const result = lines.slice(0, start).concat(lines.slice(end)).join("\n");
1966
+ return result.replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
1967
+ }
1968
+ function insertTomlBlockBeforeVersioning(text4, block) {
1969
+ const versioningIndex = text4.indexOf("# >>> mise-versioning >>>");
1722
1970
  if (versioningIndex >= 0) {
1723
- return `${withPath.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${LINK_AGENTFILES_BLOCK}
1971
+ return `${text4.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${block}
1724
1972
 
1725
- ${withPath.slice(versioningIndex)}`;
1973
+ ${text4.slice(versioningIndex)}`;
1726
1974
  }
1727
- return `${withPath.replace(/\s*$/, "")}
1975
+ return `${text4.replace(/\s*$/, "")}
1728
1976
 
1729
- ${LINK_AGENTFILES_BLOCK}
1977
+ ${block}
1730
1978
  `;
1731
1979
  }
1980
+ function extractTomlStrings(text4) {
1981
+ const values = [];
1982
+ const stringPattern = /"((?:\\.|[^"\\])*)"|'([^']*)'/g;
1983
+ for (const match of text4.matchAll(stringPattern)) {
1984
+ if (match[1] !== void 0) {
1985
+ try {
1986
+ values.push(JSON.parse(`"${match[1]}"`));
1987
+ } catch {
1988
+ values.push(match[1]);
1989
+ }
1990
+ } else if (match[2] !== void 0) {
1991
+ values.push(match[2]);
1992
+ }
1993
+ }
1994
+ return values;
1995
+ }
1996
+ function isManagedHookEntry(value) {
1997
+ const trimmed = value.trim();
1998
+ return trimmed === "op inject -i .env.op > .env" || /(^|\/)link-agentfiles\.sh$/.test(trimmed);
1999
+ }
2000
+ function renderHookEntries(entries, indent = "") {
2001
+ return [
2002
+ `${indent}enter = [`,
2003
+ ...entries.map((entry) => `${indent} ${JSON.stringify(entry)},`),
2004
+ `${indent}]`
2005
+ ];
2006
+ }
2007
+ function upsertLinkAgentfilesHooks(text4) {
2008
+ const lines = text4.split("\n");
2009
+ const hooksStart = lines.findIndex((line) => /^\[hooks\]$/.test(line.trim()));
2010
+ if (hooksStart === -1) return insertTomlBlockBeforeVersioning(text4, LINK_AGENTFILES_HOOKS_BLOCK);
2011
+ let hooksEnd = lines.length;
2012
+ for (let i = hooksStart + 1; i < lines.length; i++) {
2013
+ if (/^\[[^\]]+\]/.test(lines[i].trim())) {
2014
+ hooksEnd = i;
2015
+ break;
2016
+ }
2017
+ }
2018
+ let enterStart = -1;
2019
+ let enterEnd = -1;
2020
+ for (let i = hooksStart + 1; i < hooksEnd; i++) {
2021
+ if (!/^\s*enter\s*=/.test(lines[i])) continue;
2022
+ enterStart = i;
2023
+ enterEnd = i + 1;
2024
+ const afterEquals = lines[i].slice(lines[i].indexOf("=") + 1);
2025
+ if (afterEquals.includes("[") && !afterEquals.includes("]")) {
2026
+ while (enterEnd < hooksEnd && !lines[enterEnd].includes("]")) enterEnd++;
2027
+ if (enterEnd < hooksEnd) enterEnd++;
2028
+ }
2029
+ break;
2030
+ }
2031
+ const existingBlock = enterStart >= 0 ? lines.slice(enterStart, enterEnd).join("\n") : "";
2032
+ const preserved = extractTomlStrings(existingBlock).filter((entry) => !isManagedHookEntry(entry));
2033
+ const merged = [...LINK_AGENTFILES_HOOK_ENTRIES];
2034
+ for (const entry of preserved) {
2035
+ if (!merged.includes(entry)) merged.push(entry);
2036
+ }
2037
+ const indent = enterStart >= 0 ? lines[enterStart].match(/^\s*/)?.[0] ?? "" : "";
2038
+ const rendered = renderHookEntries(merged, indent);
2039
+ if (enterStart >= 0) {
2040
+ return lines.slice(0, enterStart).concat(rendered, lines.slice(enterEnd)).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
2041
+ }
2042
+ return lines.slice(0, hooksStart + 1).concat(rendered, lines.slice(hooksStart + 1)).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
2043
+ }
2044
+ function upsertLinkAgentfilesBlock(text4, ctx) {
2045
+ const withPath = upsertMisePath(text4, requiredMisePathEntries(ctx));
2046
+ if (withPath.includes(LINK_AGENTFILES_BLOCK)) return withPath;
2047
+ let cleaned = removeTomlSection(withPath, /^\[tasks\.link-agentfiles\]$/, /link-agentfiles/, { includePrecedingComments: false });
2048
+ cleaned = removeTomlSection(cleaned, /^\[\[watch_files\]\]$/, /AGENTS\.md/, { includePrecedingComments: false });
2049
+ cleaned = upsertLinkAgentfilesHooks(cleaned);
2050
+ return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
2051
+ }
1732
2052
  function readProjectJson(ctx) {
1733
- return tryParseJson(safeReadText(join8(ctx.repoRoot, ".project.json")));
2053
+ return tryParseJson(safeReadText(join9(ctx.repoRoot, ".project.json")));
1734
2054
  }
1735
2055
  function canonicalProjectJson(ctx) {
1736
2056
  const roles = discoverRoles(ctx.repoRoot);
@@ -1742,28 +2062,40 @@ function canonicalProjectJson(ctx) {
1742
2062
  workspace: String((existing.ticket_provider?.workspace ?? firstRole?.planeWorkspace ?? "") || ""),
1743
2063
  identifier: String((existing.ticket_provider?.identifier ?? firstRole?.ticketProviderIdentifier ?? "") || ""),
1744
2064
  board_id: String((existing.ticket_provider?.board_id ?? firstRole?.ticketProviderBoardId ?? "") || ""),
1745
- board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || "")
2065
+ board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || ""),
2066
+ state: String((existing.ticket_provider?.state ?? "planned") || "planned")
1746
2067
  };
2068
+ const existingAgents = existing.agents ?? {};
2069
+ const discoveredAgents = Object.fromEntries(
2070
+ roles.map((role) => [
2071
+ role.agentId || `${slug}-${role.role}`,
2072
+ {
2073
+ role: role.role,
2074
+ role_dir: relative(ctx.repoRoot, role.roleDir)
2075
+ }
2076
+ ])
2077
+ );
2078
+ const agents = { ...existingAgents };
2079
+ for (const [agentId, discovered] of Object.entries(discoveredAgents)) {
2080
+ const existingAgent = existingAgents[agentId] ?? {};
2081
+ agents[agentId] = {
2082
+ role: discovered.role,
2083
+ role_dir: discovered.role_dir,
2084
+ provisioning_state: existingAgent.provisioning_state
2085
+ };
2086
+ }
1747
2087
  return {
1748
2088
  project_name: String(existing.project_name ?? titleCaseSlug(slug)),
1749
2089
  project_description: String(existing.project_description ?? ""),
1750
2090
  project_slug: slug,
1751
2091
  repo_path: ctx.repoRoot,
1752
2092
  ticket_provider: ticketProvider,
1753
- agents: Object.fromEntries(
1754
- roles.map((role) => [
1755
- role.agentId || `${slug}-${role.role}`,
1756
- {
1757
- role: role.role,
1758
- role_dir: relative(ctx.repoRoot, role.roleDir)
1759
- }
1760
- ])
1761
- )
2093
+ agents
1762
2094
  };
1763
2095
  }
1764
2096
  function projectJsonFinding(ctx) {
1765
- const projectPath = join8(ctx.repoRoot, ".project.json");
1766
- const planeJsonPath = join8(ctx.repoRoot, ".plane.json");
2097
+ const projectPath = join9(ctx.repoRoot, ".project.json");
2098
+ const planeJsonPath = join9(ctx.repoRoot, ".plane.json");
1767
2099
  const details = [];
1768
2100
  const data = readProjectJson(ctx);
1769
2101
  const roles = discoverRoles(ctx.repoRoot);
@@ -1790,7 +2122,7 @@ function projectJsonFinding(ctx) {
1790
2122
  }
1791
2123
  }
1792
2124
  const ticketProvider = data.ticket_provider ?? {};
1793
- for (const key of ["type", "workspace", "identifier", "board_id", "board_url"]) {
2125
+ for (const key of ["type", "workspace", "identifier", "board_id", "board_url", "state"]) {
1794
2126
  if (!(key in ticketProvider)) details.push(`ticket_provider.${key} missing`);
1795
2127
  }
1796
2128
  if (existsSync7(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
@@ -1877,9 +2209,9 @@ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip)
1877
2209
  if (!existsSync7(sourceDir)) return;
1878
2210
  mkdirSync5(targetDir, { recursive: true });
1879
2211
  for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
1880
- const sourcePath = join8(sourceDir, entry.name);
2212
+ const sourcePath = join9(sourceDir, entry.name);
1881
2213
  if (skip?.(sourcePath)) continue;
1882
- const targetPath = join8(targetDir, entry.name);
2214
+ const targetPath = join9(targetDir, entry.name);
1883
2215
  if (entry.isDirectory()) {
1884
2216
  copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
1885
2217
  continue;
@@ -1893,7 +2225,7 @@ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip)
1893
2225
  }
1894
2226
  }
1895
2227
  function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
1896
- const gitmodulesPath = join8(repoRoot, ".gitmodules");
2228
+ const gitmodulesPath = join9(repoRoot, ".gitmodules");
1897
2229
  const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
1898
2230
  const owner = role.runtimeOwner || "delorenj";
1899
2231
  const block = `[submodule "agents/hermes/${role.role}/runtime"]
@@ -1937,9 +2269,9 @@ ${block}`) : `${current.replace(/\s*$/, "\n")}${block}`;
1937
2269
  return path;
1938
2270
  }
1939
2271
  function profileMetaInheritsDefault(path) {
1940
- const text3 = safeReadText(path);
2272
+ const text4 = safeReadText(path);
1941
2273
  return Boolean(
1942
- text3 && /^config:\s*$/m.test(text3) && /^\s+inherit_from:\s*default\s*$/m.test(text3) && /^\s+save_mode:\s*delta\s*$/m.test(text3)
2274
+ text4 && /^config:\s*$/m.test(text4) && /^\s+inherit_from:\s*default\s*$/m.test(text4) && /^\s+save_mode:\s*delta\s*$/m.test(text4)
1943
2275
  );
1944
2276
  }
1945
2277
  function upsertInheritedProfileMeta(path, changedFiles, dryRun) {
@@ -1993,21 +2325,21 @@ var RULES = [
1993
2325
  id: "mise.config-root",
1994
2326
  title: "mise config_root + AGENTS link hooks",
1995
2327
  audit: (ctx) => {
1996
- const misePath = join8(ctx.repoRoot, "mise.toml");
2328
+ const misePath = join9(ctx.repoRoot, "mise.toml");
1997
2329
  if (!existsSync7(misePath)) {
1998
2330
  return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
1999
2331
  }
2000
- const text3 = readText(misePath);
2332
+ const text4 = readText(misePath);
2001
2333
  const details = [];
2002
- const linkAgentfilesPath = join8(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2334
+ const linkAgentfilesPath = join9(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2003
2335
  if (!existsSync7(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2004
- const pathValues = [...(text3.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2336
+ const pathValues = [...(text4.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2005
2337
  const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
2006
2338
  if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
2007
- if (!text3.includes('"{{config_root}}/.mise/scripts/link-agentfiles.sh"')) details.push("link-agentfiles must use raw {{config_root}} guard");
2008
- if (!text3.includes("op inject -i .env.op > .env")) details.push("[hooks].enter must materialize .env from .env.op");
2009
- if (!text3.includes('patterns = ["AGENTS.md"]')) details.push("watch_files must monitor AGENTS.md");
2010
- if (!text3.includes('task = "link-agentfiles"')) details.push("watch_files must dispatch link-agentfiles task");
2339
+ if (!text4.includes('"{{config_root}}/.mise/scripts/link-agentfiles.sh"')) details.push("link-agentfiles must use raw {{config_root}} guard");
2340
+ if (!text4.includes("op inject -i .env.op > .env")) details.push("[hooks].enter must materialize .env from .env.op");
2341
+ if (!text4.includes('patterns = ["AGENTS.md"]')) details.push("watch_files must monitor AGENTS.md");
2342
+ if (!text4.includes('task = "link-agentfiles"')) details.push("watch_files must dispatch link-agentfiles task");
2011
2343
  return {
2012
2344
  id: "mise.config-root",
2013
2345
  title: "mise config_root + AGENTS link hooks",
@@ -2018,19 +2350,26 @@ var RULES = [
2018
2350
  };
2019
2351
  },
2020
2352
  migrate: (ctx, finding) => {
2021
- const path = join8(ctx.repoRoot, "mise.toml");
2353
+ const path = join9(ctx.repoRoot, "mise.toml");
2022
2354
  const changedFiles = [];
2355
+ const details = [];
2023
2356
  if (!existsSync7(path)) {
2024
- return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing; initialize mise first", changedFiles, details: [] };
2357
+ if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2358
+ 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: [] };
2359
+ }
2360
+ details.push("Initialized mise.toml from generated-project template");
2361
+ if (ctx.dryRun) {
2362
+ return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
2363
+ }
2025
2364
  }
2026
- let text3 = readText(path);
2027
- const next = upsertLinkAgentfilesBlock(text3, ctx);
2028
- if (next !== text3) {
2029
- changedFiles.push(path);
2365
+ let text4 = readText(path);
2366
+ const next = upsertLinkAgentfilesBlock(text4, ctx);
2367
+ if (next !== text4) {
2368
+ if (!changedFiles.includes(path)) changedFiles.push(path);
2030
2369
  if (!ctx.dryRun) writeText(path, next);
2031
- text3 = next;
2370
+ text4 = next;
2032
2371
  }
2033
- const linkAgentfilesPath = join8(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2372
+ const linkAgentfilesPath = join9(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2034
2373
  const expectedScript = templateLinkAgentfilesScript(ctx);
2035
2374
  if (expectedScript === void 0) {
2036
2375
  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: [] };
@@ -2039,7 +2378,7 @@ var RULES = [
2039
2378
  changedFiles.push(linkAgentfilesPath);
2040
2379
  if (!ctx.dryRun) {
2041
2380
  writeText(linkAgentfilesPath, expectedScript);
2042
- chmodSync(linkAgentfilesPath, 493);
2381
+ chmodSync2(linkAgentfilesPath, 493);
2043
2382
  }
2044
2383
  }
2045
2384
  return {
@@ -2057,11 +2396,11 @@ var RULES = [
2057
2396
  title: "managed mise versioning block",
2058
2397
  audit: (ctx) => {
2059
2398
  const details = [];
2060
- const misePath = join8(ctx.repoRoot, "mise.toml");
2061
- const versioningPath = join8(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2062
- const manifestPath = join8(ctx.repoRoot, ".mise", "version-files.conf");
2063
- const text3 = safeReadText(misePath);
2064
- if (!text3?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
2399
+ const misePath = join9(ctx.repoRoot, "mise.toml");
2400
+ const versioningPath = join9(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2401
+ const manifestPath = join9(ctx.repoRoot, ".mise", "version-files.conf");
2402
+ const text4 = safeReadText(misePath);
2403
+ if (!text4?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
2065
2404
  if (!existsSync7(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
2066
2405
  if (!existsSync7(manifestPath)) details.push(".mise/version-files.conf missing");
2067
2406
  return {
@@ -2075,17 +2414,24 @@ var RULES = [
2075
2414
  },
2076
2415
  migrate: (ctx, finding) => {
2077
2416
  const changedFiles = [];
2078
- const misePath = join8(ctx.repoRoot, "mise.toml");
2417
+ const details = [];
2418
+ const misePath = join9(ctx.repoRoot, "mise.toml");
2079
2419
  if (!existsSync7(misePath)) {
2080
- return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing; cannot inject versioning block", changedFiles, details: [] };
2420
+ if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2421
+ 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: [] };
2422
+ }
2423
+ details.push("Initialized mise.toml from generated-project template");
2424
+ if (ctx.dryRun) {
2425
+ return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
2426
+ }
2081
2427
  }
2082
2428
  const currentMise = readText(misePath);
2083
2429
  const nextMise = replaceOrAppendManagedBlock(currentMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
2084
2430
  if (nextMise !== currentMise) {
2085
- changedFiles.push(misePath);
2431
+ if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
2086
2432
  if (!ctx.dryRun) writeText(misePath, nextMise);
2087
2433
  }
2088
- const versioningPath = join8(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2434
+ const versioningPath = join9(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2089
2435
  const expectedScript = templateVersioningScript(ctx);
2090
2436
  if (expectedScript === void 0) {
2091
2437
  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: [] };
@@ -2094,10 +2440,10 @@ var RULES = [
2094
2440
  changedFiles.push(versioningPath);
2095
2441
  if (!ctx.dryRun) {
2096
2442
  writeText(versioningPath, expectedScript);
2097
- chmodSync(versioningPath, 493);
2443
+ chmodSync2(versioningPath, 493);
2098
2444
  }
2099
2445
  }
2100
- const manifestPath = join8(ctx.repoRoot, ".mise", "version-files.conf");
2446
+ const manifestPath = join9(ctx.repoRoot, ".mise", "version-files.conf");
2101
2447
  const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
2102
2448
  if (safeReadText(manifestPath) !== expectedManifest) {
2103
2449
  changedFiles.push(manifestPath);
@@ -2117,9 +2463,9 @@ var RULES = [
2117
2463
  id: "sot.agent-symlinks",
2118
2464
  title: "AGENTS/CLAUDE/GEMINI symlink contract",
2119
2465
  audit: (ctx) => {
2120
- const agentsPath = join8(ctx.repoRoot, "AGENTS.md");
2466
+ const agentsPath = join9(ctx.repoRoot, "AGENTS.md");
2121
2467
  if (!existsSync7(agentsPath)) {
2122
- const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync7(join8(ctx.repoRoot, file)));
2468
+ const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync7(join9(ctx.repoRoot, file)));
2123
2469
  if (fallbackSources.length === 0) {
2124
2470
  return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
2125
2471
  }
@@ -2134,7 +2480,7 @@ var RULES = [
2134
2480
  }
2135
2481
  const details = [];
2136
2482
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2137
- const full = join8(ctx.repoRoot, file);
2483
+ const full = join9(ctx.repoRoot, file);
2138
2484
  const target = readSymlinkTarget(full);
2139
2485
  if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
2140
2486
  }
@@ -2158,7 +2504,7 @@ var RULES = [
2158
2504
  return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
2159
2505
  }
2160
2506
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2161
- const full = join8(ctx.repoRoot, file);
2507
+ const full = join9(ctx.repoRoot, file);
2162
2508
  const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
2163
2509
  if (result.blocked) blockedDetails.push(result.blocked);
2164
2510
  if (result.changed) changedFiles.push(full);
@@ -2180,7 +2526,7 @@ var RULES = [
2180
2526
  migrate: (ctx, finding) => {
2181
2527
  const changedFiles = [];
2182
2528
  const details = [];
2183
- const path = join8(ctx.repoRoot, ".project.json");
2529
+ const path = join9(ctx.repoRoot, ".project.json");
2184
2530
  const existing = readProjectJson(ctx) ?? {};
2185
2531
  const canonical = canonicalProjectJson(ctx);
2186
2532
  const merged = { ...existing, ...canonical };
@@ -2190,7 +2536,7 @@ var RULES = [
2190
2536
  changedFiles.push(path);
2191
2537
  if (!ctx.dryRun) writeText(path, expected);
2192
2538
  }
2193
- const planeJson = join8(ctx.repoRoot, ".plane.json");
2539
+ const planeJson = join9(ctx.repoRoot, ".plane.json");
2194
2540
  if (existsSync7(planeJson)) {
2195
2541
  const backup = `${planeJson}.migrated-backup`;
2196
2542
  if (existsSync7(backup)) {
@@ -2215,14 +2561,15 @@ var RULES = [
2215
2561
  title: ".env.op + gitignore secrets contract",
2216
2562
  audit: (ctx) => {
2217
2563
  const details = [];
2218
- const envOp = safeReadText(join8(ctx.repoRoot, ".env.op"));
2219
- const gitignore = safeReadText(join8(ctx.repoRoot, ".gitignore"));
2564
+ const envOp = safeReadText(join9(ctx.repoRoot, ".env.op"));
2565
+ const gitignore = safeReadText(join9(ctx.repoRoot, ".gitignore"));
2220
2566
  if (!envOp) {
2221
2567
  details.push(".env.op missing");
2222
2568
  } else {
2223
2569
  const invalidLines = envOp.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).filter((line) => {
2224
2570
  const value = line.slice(line.indexOf("=") + 1).trim();
2225
- return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value);
2571
+ const quotedLiteral = /^"[^"\r\n]*"$/.test(value) || /^'[^'\r\n]*'$/.test(value);
2572
+ return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value) && !quotedLiteral;
2226
2573
  });
2227
2574
  if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals: ${invalidLines.join(", ")}`);
2228
2575
  }
@@ -2241,12 +2588,12 @@ var RULES = [
2241
2588
  migrate: (ctx, finding) => {
2242
2589
  const changedFiles = [];
2243
2590
  const details = [];
2244
- const envOpPath = join8(ctx.repoRoot, ".env.op");
2591
+ const envOpPath = join9(ctx.repoRoot, ".env.op");
2245
2592
  if (!existsSync7(envOpPath)) {
2246
2593
  changedFiles.push(envOpPath);
2247
- if (!ctx.dryRun) writeText(envOpPath, readText(join8(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2594
+ if (!ctx.dryRun) writeText(envOpPath, readText(join9(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2248
2595
  }
2249
- const gitignorePath = join8(ctx.repoRoot, ".gitignore");
2596
+ const gitignorePath = join9(ctx.repoRoot, ".gitignore");
2250
2597
  const gitignore = safeReadText(gitignorePath) ?? "";
2251
2598
  const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
2252
2599
  # NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
@@ -2273,20 +2620,20 @@ var RULES = [
2273
2620
  title: ".copier-answers.yml provenance + drift report",
2274
2621
  audit: (ctx) => {
2275
2622
  const details = [];
2276
- const path = join8(ctx.repoRoot, ".copier-answers.yml");
2277
- const text3 = safeReadText(path);
2623
+ const path = join9(ctx.repoRoot, ".copier-answers.yml");
2624
+ const text4 = safeReadText(path);
2278
2625
  const project = readProjectJson(ctx);
2279
- if (!text3) {
2626
+ if (!text4) {
2280
2627
  details.push(".copier-answers.yml missing");
2281
2628
  } else {
2282
- if (!text3.startsWith("# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY")) details.push("missing Copier overwrite warning header");
2283
- if (!text3.includes("_src_path:")) details.push("_src_path missing");
2629
+ if (!text4.startsWith("# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY")) details.push("missing Copier overwrite warning header");
2630
+ if (!text4.includes("_src_path:")) details.push("_src_path missing");
2284
2631
  if (project?.project_name) {
2285
- const nameMatch = text3.match(/project_name:\s*(.+)/);
2632
+ const nameMatch = text4.match(/project_name:\s*(.+)/);
2286
2633
  if (!nameMatch || nameMatch[1]?.trim() !== String(project.project_name)) details.push("project_name drift between .copier-answers.yml and .project.json");
2287
2634
  }
2288
2635
  if (project?.project_description) {
2289
- const descMatch = text3.match(/project_description:\s*([\s\S]*?)(?=\n\w|$)/);
2636
+ const descMatch = text4.match(/project_description:\s*([\s\S]*?)(?=\n\w|$)/);
2290
2637
  const yamlDesc = descMatch?.[1]?.replace(/\n\s+/g, " ").trim() ?? "";
2291
2638
  if (yamlDesc !== String(project.project_description)) details.push("project_description drift between .copier-answers.yml and .project.json");
2292
2639
  }
@@ -2303,16 +2650,16 @@ var RULES = [
2303
2650
  migrate: (ctx, finding) => {
2304
2651
  const changedFiles = [];
2305
2652
  const project = canonicalProjectJson(ctx);
2306
- const text3 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
2307
- _src_path: ${join8(ctx.pjanglerRoot, "templates", "commonproject")}
2653
+ const text4 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
2654
+ _src_path: ${join9(ctx.pjanglerRoot, "templates", "commonproject")}
2308
2655
  project_description: ${String(project.project_description)}
2309
2656
  project_name: ${String(project.project_name)}
2310
2657
  ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2311
2658
  `;
2312
- const path = join8(ctx.repoRoot, ".copier-answers.yml");
2313
- if (safeReadText(path) !== text3) {
2659
+ const path = join9(ctx.repoRoot, ".copier-answers.yml");
2660
+ if (safeReadText(path) !== text4) {
2314
2661
  changedFiles.push(path);
2315
- if (!ctx.dryRun) writeText(path, text3);
2662
+ if (!ctx.dryRun) writeText(path, text4);
2316
2663
  }
2317
2664
  return {
2318
2665
  id: finding.id,
@@ -2328,15 +2675,15 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2328
2675
  id: "bmad.scaffold",
2329
2676
  title: "BMAD modules/docs scaffold",
2330
2677
  audit: (ctx) => {
2331
- const sourceRoot = join8(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
2332
- const targetRoot = join8(ctx.repoRoot, "_bmad");
2678
+ const sourceRoot = join9(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
2679
+ const targetRoot = join9(ctx.repoRoot, "_bmad");
2333
2680
  const sentinels = [
2334
- join8("core", "config.yaml"),
2335
- join8("custom", "config.yaml"),
2336
- join8("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
2337
- join8("bmm", "workflows", "workflow-status", "workflow.yaml")
2681
+ join9("core", "config.yaml"),
2682
+ join9("custom", "config.yaml"),
2683
+ join9("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
2684
+ join9("bmm", "workflows", "workflow-status", "workflow.yaml")
2338
2685
  ];
2339
- const missing = sentinels.filter((file) => existsSync7(join8(sourceRoot, file)) && !existsSync7(join8(targetRoot, file)));
2686
+ const missing = sentinels.filter((file) => existsSync7(join9(sourceRoot, file)) && !existsSync7(join9(targetRoot, file)));
2340
2687
  return {
2341
2688
  id: "bmad.scaffold",
2342
2689
  title: "BMAD modules/docs scaffold",
@@ -2348,7 +2695,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2348
2695
  },
2349
2696
  migrate: (ctx, finding) => {
2350
2697
  const changedFiles = [];
2351
- copyMissingRecursive(join8(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join8(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
2698
+ copyMissingRecursive(join9(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join9(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
2352
2699
  return {
2353
2700
  id: finding.id,
2354
2701
  title: finding.title,
@@ -2370,11 +2717,11 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2370
2717
  }
2371
2718
  const details = [];
2372
2719
  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"]) {
2373
- if (!existsSync7(join8(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join8(role.roleDir, rel))}`);
2720
+ if (!existsSync7(join9(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join9(role.roleDir, rel))}`);
2374
2721
  }
2375
- const gitmodules = safeReadText(join8(ctx.repoRoot, ".gitmodules")) ?? "";
2722
+ const gitmodules = safeReadText(join9(ctx.repoRoot, ".gitmodules")) ?? "";
2376
2723
  if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing pm runtime submodule entry");
2377
- if (!profileMetaInheritsDefault(join8(role.roleDir, "runtime", "profile.yaml"))) {
2724
+ if (!profileMetaInheritsDefault(join9(role.roleDir, "runtime", "profile.yaml"))) {
2378
2725
  details.push("runtime/profile.yaml missing inherited default config metadata");
2379
2726
  }
2380
2727
  const registry = safeReadText(registryPath(ctx.homeDir));
@@ -2395,21 +2742,21 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2395
2742
  if (!role) {
2396
2743
  return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
2397
2744
  }
2398
- const templateRoleDir = join8(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
2399
- writeIfDifferent(join8(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
2400
- writeIfDifferent(join8(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
2401
- writeIfDifferent(join8(role.roleDir, ".gitignore"), readText(join8(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
2402
- copyMissingRecursive(join8(templateRoleDir, ".runtime-scaffold"), join8(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
2403
- copyMissingRecursive(join8(templateRoleDir, ".runtime-scaffold"), join8(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
2404
- copyMissingRecursive(join8(templateRoleDir, ".scripts"), join8(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
2405
- const promptSource = join8(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
2406
- const promptTarget = join8(role.roleDir, ".scripts", "sentinel.prompt.md");
2745
+ const templateRoleDir = join9(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
2746
+ writeIfDifferent(join9(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
2747
+ writeIfDifferent(join9(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
2748
+ writeIfDifferent(join9(role.roleDir, ".gitignore"), readText(join9(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
2749
+ copyMissingRecursive(join9(templateRoleDir, ".runtime-scaffold"), join9(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
2750
+ copyMissingRecursive(join9(templateRoleDir, ".runtime-scaffold"), join9(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
2751
+ copyMissingRecursive(join9(templateRoleDir, ".scripts"), join9(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
2752
+ const promptSource = join9(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
2753
+ const promptTarget = join9(role.roleDir, ".scripts", "sentinel.prompt.md");
2407
2754
  if (existsSync7(promptSource) && !existsSync7(promptTarget)) {
2408
2755
  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);
2409
2756
  writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
2410
2757
  }
2411
2758
  upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
2412
- const profileMetaUpdated = upsertInheritedProfileMeta(join8(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
2759
+ const profileMetaUpdated = upsertInheritedProfileMeta(join9(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
2413
2760
  if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
2414
2761
  const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
2415
2762
  if (registryUpdated) details.push(`updated ${registryUpdated}`);
@@ -2463,9 +2810,9 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2463
2810
  return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
2464
2811
  }
2465
2812
  for (const role of roles) {
2466
- const sysDir = join8(ctx.homeDir, ".config", "systemd", "user");
2813
+ const sysDir = join9(ctx.homeDir, ".config", "systemd", "user");
2467
2814
  const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
2468
- const allUnitsPresent = units.every((unit) => existsSync7(join8(sysDir, unit)));
2815
+ const allUnitsPresent = units.every((unit) => existsSync7(join9(sysDir, unit)));
2469
2816
  if (allUnitsPresent) {
2470
2817
  if (ctx.dryRun) {
2471
2818
  details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
@@ -2477,7 +2824,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2477
2824
  }
2478
2825
  continue;
2479
2826
  }
2480
- for (const script of [join8(role.roleDir, ".scripts", "70-systemd.sh")]) {
2827
+ for (const script of [join9(role.roleDir, ".scripts", "70-systemd.sh")]) {
2481
2828
  if (!script || !existsSync7(script)) continue;
2482
2829
  if (ctx.dryRun) {
2483
2830
  details.push(`would run: bash ${script}`);
@@ -2505,9 +2852,12 @@ function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
2505
2852
  changedFiles.push(path);
2506
2853
  if (!dryRun) {
2507
2854
  writeText(path, normalized);
2508
- if (mode) chmodSync(path, mode);
2855
+ if (mode) chmodSync2(path, mode);
2509
2856
  }
2510
2857
  }
2858
+ function getParityRuleIds() {
2859
+ return RULES.map((rule) => rule.id);
2860
+ }
2511
2861
  function runAudit(repoArg) {
2512
2862
  const pjanglerRoot = resolvePjanglerRoot();
2513
2863
  const ctx = {
@@ -2524,7 +2874,7 @@ function runAudit(repoArg) {
2524
2874
  rules
2525
2875
  };
2526
2876
  }
2527
- function runMigration(selector, repoArg, dryRun, all) {
2877
+ function runMigrationForRules(ruleIds, repoArg, dryRun) {
2528
2878
  const pjanglerRoot = resolvePjanglerRoot();
2529
2879
  const ctx = {
2530
2880
  repoRoot: resolve(repoArg ?? process.cwd()),
@@ -2532,9 +2882,9 @@ function runMigration(selector, repoArg, dryRun, all) {
2532
2882
  pjanglerRoot,
2533
2883
  homeDir: homedir4()
2534
2884
  };
2535
- const selected = all ? RULES : RULES.filter((rule) => rule.id === selector);
2885
+ const selected = RULES.filter((rule) => ruleIds.includes(rule.id));
2536
2886
  if (!selected.length) {
2537
- throw new Error(`Unknown parity rule: ${selector}`);
2887
+ throw new Error(`Unknown parity rules: ${ruleIds.join(", ")}`);
2538
2888
  }
2539
2889
  const results = selected.map((rule) => {
2540
2890
  try {
@@ -2560,43 +2910,490 @@ function runMigration(selector, repoArg, dryRun, all) {
2560
2910
  changedFiles
2561
2911
  };
2562
2912
  }
2913
+ function runMigration(selector, repoArg, dryRun, all) {
2914
+ const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
2915
+ return runMigrationForRules(ruleIds, repoArg, dryRun);
2916
+ }
2917
+ function prettyTimestamp(iso) {
2918
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/.exec(iso);
2919
+ return match ? `${match[1]} ${match[2]} UTC` : iso;
2920
+ }
2563
2921
  function formatAuditReport(report) {
2564
- const lines = [`repo: ${report.repo}`, `ok: ${report.ok}`, `audited_at: ${report.auditedAt}`, "rules:"];
2922
+ const counts = {};
2923
+ for (const rule of report.rules) counts[rule.status] = (counts[rule.status] ?? 0) + 1;
2924
+ const idWidth = report.rules.reduce((width, rule) => Math.max(width, rule.id.length), 0);
2925
+ const tally = [];
2926
+ if (counts.pass) tally.push(green(`${counts.pass} passed`));
2927
+ if (counts.fail) tally.push(red(`${counts.fail} failed`));
2928
+ if (counts.warn) tally.push(yellow(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`));
2929
+ if (counts.skip) tally.push(gray(`${counts.skip} skipped`));
2930
+ const overall = report.ok ? `${green(glyph.pass)} ${bold("Parity audit passed")}` : `${red(glyph.fail)} ${bold("Parity audit failed")}`;
2931
+ const lines = [""];
2932
+ lines.push(` ${overall}${tally.length ? ` ${dim(glyph.dot)} ${joinDot(tally)}` : ""}`);
2933
+ lines.push(` ${dim(report.repo)} ${dim(glyph.dot)} ${dim(prettyTimestamp(report.auditedAt))}`);
2934
+ lines.push("");
2565
2935
  for (const rule of report.rules) {
2566
- lines.push(`- ${rule.id} [${rule.status}] ${rule.summary}`);
2567
- for (const detail of rule.details) lines.push(` - ${detail}`);
2936
+ const style = statusStyle(rule.status);
2937
+ lines.push(` ${style.color(style.glyph)} ${style.color(rule.id.padEnd(idWidth))} ${rule.summary}`);
2938
+ for (const detail of rule.details) lines.push(` ${dim(glyph.arrow)} ${dim(detail)}`);
2568
2939
  }
2569
- return `${lines.join("\n")}
2570
- `;
2940
+ lines.push("");
2941
+ return lines.join("\n");
2571
2942
  }
2572
2943
  function formatMigrationReport(report) {
2573
- const lines = [`repo: ${report.repo}`, `dry_run: ${report.dryRun}`, `ok: ${report.ok}`, `selected_rules: ${report.selectedRules.join(", ")}`, "results:"];
2944
+ const idWidth = report.results.reduce((width, result) => Math.max(width, result.id.length), 0);
2945
+ const overall = report.ok ? `${green(glyph.pass)} ${bold(report.dryRun ? "Migration preview complete" : "Migration complete")}` : `${red(glyph.fail)} ${bold("Migration finished with blockers")}`;
2946
+ const lines = [""];
2947
+ lines.push(` ${overall}${report.dryRun ? ` ${dim(glyph.dot)} ${yellow("dry run")}` : ""}`);
2948
+ lines.push(` ${dim(report.repo)}`);
2949
+ if (report.selectedRules.length) lines.push(` ${dim(`rules: ${report.selectedRules.join(", ")}`)}`);
2950
+ lines.push("");
2574
2951
  for (const result of report.results) {
2575
- lines.push(`- ${result.id} [${result.status}] ${result.summary}`);
2576
- for (const detail of result.details) lines.push(` - ${detail}`);
2577
- for (const file of result.changedFiles) lines.push(` - changed: ${file}`);
2952
+ const style = statusStyle(result.status);
2953
+ lines.push(` ${style.color(style.glyph)} ${style.color(result.id.padEnd(idWidth))} ${result.summary} ${dim(`[${style.label}]`)}`);
2954
+ for (const detail of result.details) lines.push(` ${dim(glyph.arrow)} ${dim(detail)}`);
2955
+ for (const file of result.changedFiles) lines.push(` ${green(glyph.add)} ${file}`);
2578
2956
  }
2579
2957
  if (report.changedFiles.length) {
2580
- lines.push("changed_files:");
2581
- for (const file of report.changedFiles) lines.push(`- ${file}`);
2958
+ lines.push("");
2959
+ lines.push(` ${bold(`Changed files (${report.changedFiles.length})`)}`);
2960
+ for (const file of report.changedFiles) lines.push(` ${green(glyph.add)} ${file}`);
2582
2961
  }
2583
- return `${lines.join("\n")}
2962
+ lines.push("");
2963
+ return lines.join("\n");
2964
+ }
2965
+
2966
+ // src/project/index.ts
2967
+ import { spawnSync as spawnSync5 } from "node:child_process";
2968
+ import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync4, renameSync as renameSync2, statSync, writeFileSync as writeFileSync5 } from "node:fs";
2969
+ import { homedir as homedir5 } from "node:os";
2970
+ import { basename as basename3, dirname as dirname6, join as join10, resolve as resolve2 } from "node:path";
2971
+ import YAML from "yaml";
2972
+ var PROJECT_REGISTRY_ENV = "PJ_PROJECT_REGISTRY";
2973
+ var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
2974
+ var KNOWN_SKILL_ROOTS = [
2975
+ "/home/delorenj/code/skillex/all-skills",
2976
+ "/home/delorenj/code/CoachingAgentFramework/.agents/skills",
2977
+ "/home/delorenj/code/pjangler/.agents/skills",
2978
+ join10(homedir5(), ".codex", "skills")
2979
+ ];
2980
+ function projectRegistryPath(env2 = process.env) {
2981
+ return expandHome(env2[PROJECT_REGISTRY_ENV] || join10(homedir5(), ".config", "pjangler", "projects.yaml"));
2982
+ }
2983
+ function emptyProjectRegistry() {
2984
+ return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
2985
+ }
2986
+ function loadProjectRegistry(path = projectRegistryPath()) {
2987
+ if (!existsSync8(path)) return emptyProjectRegistry();
2988
+ const raw = YAML.parse(readFileSync4(path, "utf8"));
2989
+ if (raw == null) return emptyProjectRegistry();
2990
+ if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
2991
+ const registry = raw;
2992
+ const normalized = {
2993
+ schema_version: Number(registry.schema_version ?? PROJECT_REGISTRY_SCHEMA_VERSION),
2994
+ projects: isRecord(registry.projects) ? registry.projects : {}
2995
+ };
2996
+ validateProjectRegistry(normalized);
2997
+ return normalized;
2998
+ }
2999
+ function saveProjectRegistry(registry, path = projectRegistryPath()) {
3000
+ validateProjectRegistry(registry);
3001
+ mkdirSync6(dirname6(path), { recursive: true });
3002
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
3003
+ writeFileSync5(temp, YAML.stringify(registry, { lineWidth: 0 }), "utf8");
3004
+ renameSync2(temp, path);
3005
+ }
3006
+ function validateProjectRegistry(registry) {
3007
+ if (registry.schema_version !== PROJECT_REGISTRY_SCHEMA_VERSION) {
3008
+ throw new Error(`Unsupported project registry schema_version: ${registry.schema_version}`);
3009
+ }
3010
+ if (!isRecord(registry.projects)) throw new Error("Project registry projects must be a mapping");
3011
+ const slugs = /* @__PURE__ */ new Set();
3012
+ const repoPaths = /* @__PURE__ */ new Map();
3013
+ const identifiers = /* @__PURE__ */ new Map();
3014
+ for (const [slug, project] of Object.entries(registry.projects)) {
3015
+ validateProjectRecord(project, slug);
3016
+ if (slugs.has(project.slug)) throw new Error(`Duplicate project slug: ${project.slug}`);
3017
+ slugs.add(project.slug);
3018
+ const repoKey = resolve2(project.repo_path);
3019
+ const existingRepoSlug = repoPaths.get(repoKey);
3020
+ if (existingRepoSlug && existingRepoSlug !== slug) {
3021
+ throw new Error(`Duplicate project repo_path: ${project.repo_path} used by ${existingRepoSlug} and ${slug}`);
3022
+ }
3023
+ repoPaths.set(repoKey, slug);
3024
+ const identifier = project.ticket_provider.identifier?.toUpperCase();
3025
+ if (identifier) {
3026
+ const existingIdentifierSlug = identifiers.get(identifier);
3027
+ if (existingIdentifierSlug && existingIdentifierSlug !== slug) {
3028
+ throw new Error(`Duplicate project identifier: ${identifier} used by ${existingIdentifierSlug} and ${slug}`);
3029
+ }
3030
+ identifiers.set(identifier, slug);
3031
+ }
3032
+ }
3033
+ }
3034
+ function slugifyProjectName(value) {
3035
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
3036
+ }
3037
+ function deriveProjectIdentifier(value) {
3038
+ const compact = value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
3039
+ const identifier = compact.slice(0, 4) || "PROJ";
3040
+ return identifier.length >= 2 ? identifier : `${identifier}XX`.slice(0, 4);
3041
+ }
3042
+ function normalizeAgentRole(value) {
3043
+ return value?.trim() || "pm";
3044
+ }
3045
+ function jsonStable(value) {
3046
+ return JSON.stringify(value);
3047
+ }
3048
+ function projectRecordEquivalent(a, b) {
3049
+ if (!a) return false;
3050
+ const { created_at: _aCreated, updated_at: _aUpdated, ...aComparable } = a;
3051
+ const { created_at: _bCreated, updated_at: _bUpdated, ...bComparable } = b;
3052
+ return jsonStable(aComparable) === jsonStable(bComparable);
3053
+ }
3054
+ function defaultProjectTargetDir(name, cwd = process.cwd()) {
3055
+ const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
3056
+ return resolve2(dirname6(resolve2(cwd)), compactName);
3057
+ }
3058
+ function resolveSourceSkillPath(sourceSkill) {
3059
+ if (!sourceSkill) return void 0;
3060
+ const expanded = expandHome(sourceSkill);
3061
+ const direct = resolve2(expanded);
3062
+ if (existsSync8(direct)) return direct;
3063
+ const name = basename3(sourceSkill);
3064
+ for (const root of KNOWN_SKILL_ROOTS) {
3065
+ const candidate = join10(root, name);
3066
+ if (existsSync8(candidate)) return candidate;
3067
+ }
3068
+ const civilWarLetterifier = "/home/delorenj/code/skillex/all-skills/civilwar-letterifier";
3069
+ const hint = existsSync8(civilWarLetterifier) ? ` Did you mean ${civilWarLetterifier}?` : "";
3070
+ throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
3071
+ }
3072
+ function planProjectInit(input) {
3073
+ if (!input.name.trim()) throw new Error("Project name is required");
3074
+ const registryPath2 = resolve2(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
3075
+ const registry = loadProjectRegistry(registryPath2);
3076
+ const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
3077
+ const slug = input.projectSlug ?? slugifyProjectName(input.name);
3078
+ const targetDir = resolve2(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
3079
+ const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
3080
+ const existing = registry.projects[slug];
3081
+ const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
3082
+ const overwrite = input.overwrite ?? input.force ?? false;
3083
+ const agentRole = normalizeAgentRole(input.agentRole);
3084
+ const agents = input.provisionAgent ? {
3085
+ ...existing?.agents ?? {},
3086
+ [agentRole]: {
3087
+ role: agentRole,
3088
+ provisioning_state: "planned"
3089
+ }
3090
+ } : existing?.agents ?? {};
3091
+ const scaffold = input.scaffold ?? true;
3092
+ const candidateProject = {
3093
+ name: input.name,
3094
+ slug,
3095
+ repo_path: targetDir,
3096
+ description: input.description ?? "",
3097
+ status: "planned",
3098
+ source_artifacts: sourceSkillPath ? [{ kind: "skill", path: sourceSkillPath, package_name: input.packageName ?? slug }] : [],
3099
+ template: {
3100
+ commonproject: {
3101
+ enabled: true,
3102
+ primary_language: input.primaryLanguage ?? "python"
3103
+ }
3104
+ },
3105
+ ticket_provider: {
3106
+ type: input.ticketProvider ?? "plane",
3107
+ workspace: input.planeWorkspace ?? "33god",
3108
+ identifier,
3109
+ board_id: input.planeProjectId ?? "",
3110
+ board_url: input.planeProjectId ? `https://plane.delo.sh/${input.planeWorkspace ?? "33god"}/projects/${input.planeProjectId}/issues/` : "",
3111
+ state: input.live ? "planned" : "planned"
3112
+ },
3113
+ agents,
3114
+ created_at: existing?.created_at ?? now,
3115
+ updated_at: now
3116
+ };
3117
+ const project = {
3118
+ ...candidateProject,
3119
+ updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
3120
+ };
3121
+ validateNoDuplicateProject(registry, project, overwrite);
3122
+ const pjanglerRoot = resolve2(input.pjanglerRoot ?? resolvePjanglerRoot2());
3123
+ const manifest = projectManifestFromRegistryProject(project);
3124
+ const apply = input.apply ?? false;
3125
+ const live = input.live ?? false;
3126
+ const actions = [
3127
+ { kind: "registry.upsert", registryPath: registryPath2, slug, project }
3128
+ ];
3129
+ if (scaffold) {
3130
+ actions.push(buildCommonProjectCopierAction({
3131
+ pjanglerRoot,
3132
+ targetDir,
3133
+ projectName: project.name,
3134
+ projectDescription: project.description,
3135
+ projectSlug: project.slug,
3136
+ ticketProvider: project.ticket_provider.type,
3137
+ planeWorkspace: project.ticket_provider.workspace ?? "33god",
3138
+ planeProjectId: project.ticket_provider.board_id ?? "",
3139
+ projectIdentifier: identifier,
3140
+ primaryLanguage: project.template.commonproject.primary_language,
3141
+ overwrite
3142
+ }));
3143
+ }
3144
+ actions.push(
3145
+ { kind: "project.write-manifest", path: join10(targetDir, ".project.json"), manifest },
3146
+ {
3147
+ kind: "plane.create-or-link",
3148
+ enabled: live,
3149
+ live,
3150
+ workspace: project.ticket_provider.workspace ?? "33god",
3151
+ identifier,
3152
+ state: live ? "planned" : "planned",
3153
+ reason: live ? void 0 : "network/cloud actions require --live"
3154
+ },
3155
+ {
3156
+ kind: "hermes.provision-agent",
3157
+ enabled: input.provisionAgent ?? false,
3158
+ local: !live,
3159
+ targetDir,
3160
+ targetRepo: slug,
3161
+ role: agentRole,
3162
+ context: {
3163
+ skipRuntimeRepo: !live,
3164
+ skipPlane: !live,
3165
+ skipBloodbank: !live,
3166
+ skipSystemd: !live || process.platform === "darwin"
3167
+ }
3168
+ }
3169
+ );
3170
+ return { ok: true, apply, dryRun: !apply, live, registryPath: registryPath2, project, manifest, actions };
3171
+ }
3172
+ function executeProjectInitPlan(plan) {
3173
+ const logs = [];
3174
+ const errors = [];
3175
+ const changedFiles = [];
3176
+ if (!plan.apply) return { ok: true, plan, logs, errors, changedFiles };
3177
+ const registry = loadProjectRegistry(plan.registryPath);
3178
+ let pendingRegistryAction;
3179
+ for (const action of plan.actions) {
3180
+ if (action.kind === "copier.copy.commonproject") {
3181
+ mkdirSync6(dirname6(action.targetDir), { recursive: true });
3182
+ const result = spawnSync5(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
3183
+ if (result.stdout?.trim()) logs.push(result.stdout.trim());
3184
+ if (result.stderr?.trim()) logs.push(result.stderr.trim());
3185
+ if (result.error) {
3186
+ const code = result.error.code;
3187
+ errors.push(
3188
+ code === "ENOENT" ? "copier not found on PATH. Install with: uv tool install copier or pip install copier" : `copier failed: ${result.error.message}`
3189
+ );
3190
+ break;
3191
+ }
3192
+ if (result.status !== 0) {
3193
+ errors.push(`copier exited with status ${result.status ?? "unknown"}`);
3194
+ if (existsSync8(action.targetDir)) changedFiles.push(action.targetDir);
3195
+ break;
3196
+ }
3197
+ changedFiles.push(action.targetDir);
3198
+ } else if (action.kind === "project.write-manifest") {
3199
+ mkdirSync6(dirname6(action.path), { recursive: true });
3200
+ const next = `${JSON.stringify(action.manifest, null, 2)}
2584
3201
  `;
3202
+ const current = existsSync8(action.path) ? readFileSync4(action.path, "utf8") : void 0;
3203
+ if (current !== next) {
3204
+ writeFileSync5(action.path, next, "utf8");
3205
+ changedFiles.push(action.path);
3206
+ }
3207
+ } else if (action.kind === "registry.upsert") {
3208
+ pendingRegistryAction = action;
3209
+ } else if (action.kind === "plane.create-or-link") {
3210
+ logs.push(action.enabled ? "plane.create-or-link requires a live provider integration" : "plane.create-or-link skipped (requires --live)");
3211
+ } else if (action.kind === "hermes.provision-agent") {
3212
+ logs.push(action.enabled ? "hermes.provision-agent planned for the caller to execute" : "hermes.provision-agent skipped");
3213
+ }
3214
+ }
3215
+ if (pendingRegistryAction && errors.length === 0) {
3216
+ if (!projectRecordEquivalent(registry.projects[pendingRegistryAction.slug], pendingRegistryAction.project)) {
3217
+ registry.projects[pendingRegistryAction.slug] = pendingRegistryAction.project;
3218
+ saveProjectRegistry(registry, pendingRegistryAction.registryPath);
3219
+ changedFiles.push(pendingRegistryAction.registryPath);
3220
+ }
3221
+ }
3222
+ return { ok: errors.length === 0, plan, logs, errors, changedFiles };
3223
+ }
3224
+ function projectManifestFromRegistryProject(project) {
3225
+ const agents = Object.fromEntries(
3226
+ Object.entries(project.agents).map(([name, agent]) => [
3227
+ `${project.slug}-${name}`,
3228
+ {
3229
+ role: agent.role,
3230
+ role_dir: agent.role_dir,
3231
+ provisioning_state: agent.provisioning_state
3232
+ }
3233
+ ])
3234
+ );
3235
+ return {
3236
+ project_name: project.name,
3237
+ project_description: project.description,
3238
+ project_slug: project.slug,
3239
+ repo_path: project.repo_path,
3240
+ ticket_provider: {
3241
+ type: project.ticket_provider.type,
3242
+ workspace: project.ticket_provider.workspace ?? "",
3243
+ identifier: project.ticket_provider.identifier ?? "",
3244
+ board_id: project.ticket_provider.board_id ?? "",
3245
+ board_url: project.ticket_provider.board_url ?? "",
3246
+ state: project.ticket_provider.state
3247
+ },
3248
+ agents
3249
+ };
3250
+ }
3251
+ function formatProjectInitPlan(plan) {
3252
+ const lines = [""];
3253
+ const title = `${bold(plan.project.name)} ${dim(`(${plan.project.slug})`)}`;
3254
+ lines.push(` ${cyan(bold(glyph.chevron))} ${title}${plan.dryRun ? ` ${dim(glyph.dot)} ${yellow("dry run")}` : ""}`);
3255
+ lines.push(` ${dim("registry".padEnd(8))} ${dim(plan.registryPath)}`);
3256
+ lines.push(` ${dim("target".padEnd(8))} ${dim(plan.project.repo_path)}`);
3257
+ lines.push("");
3258
+ lines.push(` ${bold("Actions")} ${dim(`(${plan.actions.length})`)}`);
3259
+ if (!plan.actions.length) lines.push(` ${dim("(nothing to do)")}`);
3260
+ for (const action of plan.actions) {
3261
+ lines.push(` ${cyan(glyph.bullet)} ${action.kind}`);
3262
+ if (action.kind === "copier.copy.commonproject") lines.push(` ${dim(`target: ${action.targetDir}`)}`);
3263
+ if (action.kind === "project.write-manifest") lines.push(` ${dim(`path: ${action.path}`)}`);
3264
+ if (action.kind === "plane.create-or-link" && action.reason) lines.push(` ${dim(`note: ${action.reason}`)}`);
3265
+ }
3266
+ lines.push("");
3267
+ return lines.join("\n");
3268
+ }
3269
+ function formatProjectList(registry) {
3270
+ const projects = Object.values(registry.projects).sort((a, b) => a.slug.localeCompare(b.slug));
3271
+ if (!projects.length) return `
3272
+ ${dim("No projects registered.")}
3273
+ `;
3274
+ const slugWidth = projects.reduce((width, project) => Math.max(width, project.slug.length), 0);
3275
+ const idWidth = projects.reduce((width, project) => Math.max(width, String(project.ticket_provider.identifier ?? "").length), 0);
3276
+ const statusWidth = projects.reduce((width, project) => Math.max(width, project.status.length), 0);
3277
+ const lines = ["", ` ${bold("Projects")} ${dim(`(${projects.length})`)}`, ""];
3278
+ for (const project of projects) {
3279
+ const slug = bold(project.slug.padEnd(slugWidth));
3280
+ const identifier = cyan(String(project.ticket_provider.identifier ?? "").padEnd(idWidth));
3281
+ const status = projectStatusColor(project.status)(project.status.padEnd(statusWidth));
3282
+ lines.push(` ${slug} ${identifier} ${status} ${dim(project.repo_path)}`);
3283
+ }
3284
+ lines.push("");
3285
+ return lines.join("\n");
3286
+ }
3287
+ function getProject(registry, slug) {
3288
+ const project = registry.projects[slug];
3289
+ if (!project) throw new Error(`Project not found in registry: ${slug}`);
3290
+ return project;
3291
+ }
3292
+ function doctorProjectRegistry(registryPath2 = projectRegistryPath(), slug) {
3293
+ const issues = [];
3294
+ const registry = loadProjectRegistry(registryPath2);
3295
+ const projects = slug ? [[slug, getProject(registry, slug)]] : Object.entries(registry.projects);
3296
+ for (const [projectSlug, project] of projects) {
3297
+ if (!existsSync8(project.repo_path)) {
3298
+ issues.push({ level: "warn", slug: projectSlug, message: `repo_path does not exist: ${project.repo_path}` });
3299
+ } else if (!statSync(project.repo_path).isDirectory()) {
3300
+ issues.push({ level: "error", slug: projectSlug, message: `repo_path is not a directory: ${project.repo_path}` });
3301
+ } else {
3302
+ const manifestPath = join10(project.repo_path, ".project.json");
3303
+ if (!existsSync8(manifestPath)) issues.push({ level: "warn", slug: projectSlug, message: ".project.json is missing" });
3304
+ }
3305
+ for (const artifact of project.source_artifacts) {
3306
+ if (artifact.path && !existsSync8(artifact.path)) {
3307
+ issues.push({ level: "warn", slug: projectSlug, message: `source artifact missing: ${artifact.path}` });
3308
+ }
3309
+ }
3310
+ }
3311
+ return {
3312
+ ok: !issues.some((issue) => issue.level === "error"),
3313
+ registryPath: registryPath2,
3314
+ checkedProjects: projects.map(([projectSlug]) => projectSlug),
3315
+ issues
3316
+ };
3317
+ }
3318
+ function buildCommonProjectCopierAction(input) {
3319
+ const templateDir = join10(input.pjanglerRoot, "templates", "commonproject");
3320
+ const data = {
3321
+ project_name: input.projectName,
3322
+ project_description: input.projectDescription ?? "",
3323
+ project_slug: input.projectSlug,
3324
+ ticket_provider: input.ticketProvider,
3325
+ plane_workspace: input.planeWorkspace,
3326
+ plane_project_id: input.planeProjectId ?? "",
3327
+ project_identifier: input.projectIdentifier,
3328
+ primary_language: input.primaryLanguage
3329
+ };
3330
+ const command = ["copier", "copy", "--trust", templateDir, input.targetDir, "--defaults"];
3331
+ for (const [key, value] of Object.entries(data)) command.push("--data", `${key}=${value}`);
3332
+ if (input.overwrite) command.push("--overwrite");
3333
+ return {
3334
+ kind: "copier.copy.commonproject",
3335
+ cwd: input.pjanglerRoot,
3336
+ command,
3337
+ targetDir: input.targetDir,
3338
+ data,
3339
+ overwrite: input.overwrite
3340
+ };
3341
+ }
3342
+ function resolvePjanglerRoot2() {
3343
+ let dir = dirname6(new URL(import.meta.url).pathname);
3344
+ while (dir !== dirname6(dir)) {
3345
+ if (existsSync8(join10(dir, "package.json")) && existsSync8(join10(dir, "templates", "commonproject", "copier.yml"))) return dir;
3346
+ dir = dirname6(dir);
3347
+ }
3348
+ return resolve2(process.cwd());
3349
+ }
3350
+ function validateNoDuplicateProject(registry, project, overwrite) {
3351
+ const existingSameSlug = registry.projects[project.slug];
3352
+ if (existingSameSlug && !overwrite && resolve2(existingSameSlug.repo_path) !== resolve2(project.repo_path)) {
3353
+ throw new Error(`Project slug already exists in registry: ${project.slug}`);
3354
+ }
3355
+ for (const [slug, existing] of Object.entries(registry.projects)) {
3356
+ if (slug === project.slug) continue;
3357
+ if (resolve2(existing.repo_path) === resolve2(project.repo_path)) {
3358
+ throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
3359
+ }
3360
+ if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
3361
+ throw new Error(`Project identifier already registered by ${slug}: ${project.ticket_provider.identifier}`);
3362
+ }
3363
+ }
3364
+ }
3365
+ function validateProjectRecord(project, key) {
3366
+ if (!isRecord(project)) throw new Error(`Project ${key} must be a mapping`);
3367
+ if (!project.name) throw new Error(`Project ${key} missing name`);
3368
+ if (!project.slug) throw new Error(`Project ${key} missing slug`);
3369
+ if (project.slug !== key) throw new Error(`Project key ${key} does not match slug ${project.slug}`);
3370
+ if (!project.repo_path) throw new Error(`Project ${key} missing repo_path`);
3371
+ if (!Array.isArray(project.source_artifacts)) throw new Error(`Project ${key} source_artifacts must be a list`);
3372
+ if (!isRecord(project.ticket_provider)) throw new Error(`Project ${key} ticket_provider must be a mapping`);
3373
+ if (!isRecord(project.agents)) throw new Error(`Project ${key} agents must be a mapping`);
3374
+ }
3375
+ function expandHome(path) {
3376
+ if (path === "~") return homedir5();
3377
+ if (path.startsWith("~/")) return join10(homedir5(), path.slice(2));
3378
+ return path;
3379
+ }
3380
+ function isRecord(value) {
3381
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2585
3382
  }
2586
3383
 
2587
3384
  // src/utils/version.ts
2588
- import { readFileSync as readFileSync4 } from "node:fs";
2589
- import { dirname as dirname6, join as join9 } from "node:path";
3385
+ import { readFileSync as readFileSync5 } from "node:fs";
3386
+ import { dirname as dirname7, join as join11 } from "node:path";
2590
3387
  import { fileURLToPath as fileURLToPath4 } from "node:url";
2591
3388
  var PJANGLER_VERSION = (() => {
2592
3389
  try {
2593
- let dir = dirname6(fileURLToPath4(import.meta.url));
3390
+ let dir = dirname7(fileURLToPath4(import.meta.url));
2594
3391
  for (let i = 0; i < 4; i++) {
2595
3392
  try {
2596
- const raw = readFileSync4(join9(dir, "package.json"), "utf8");
3393
+ const raw = readFileSync5(join11(dir, "package.json"), "utf8");
2597
3394
  return JSON.parse(raw).version ?? "0.0.0";
2598
3395
  } catch {
2599
- const parent = dirname6(dir);
3396
+ const parent = dirname7(dir);
2600
3397
  if (parent === dir) break;
2601
3398
  dir = parent;
2602
3399
  }
@@ -2607,6 +3404,191 @@ var PJANGLER_VERSION = (() => {
2607
3404
  })();
2608
3405
 
2609
3406
  // src/index.ts
3407
+ var xmark = `${red(glyph.fail)}`;
3408
+ function printMigrationReport(report, asJson) {
3409
+ if (asJson) {
3410
+ console.log(JSON.stringify(report, null, 2));
3411
+ } else {
3412
+ console.log(formatMigrationReport(report));
3413
+ }
3414
+ }
3415
+ async function promptForRuleIds(rules) {
3416
+ const options = rules.filter((rule) => rule.fixable).map((rule) => ({
3417
+ value: rule.id,
3418
+ label: `${rule.id} [${rule.status}] ${rule.title}`,
3419
+ hint: rule.summary
3420
+ }));
3421
+ if (!options.length) {
3422
+ return [];
3423
+ }
3424
+ const initialValues = rules.filter((rule) => rule.fixable && rule.status !== "pass" && rule.status !== "skip").map((rule) => rule.id);
3425
+ const selected = await multiselect({
3426
+ message: "Select parity rules to apply (space to toggle, enter to confirm):",
3427
+ options,
3428
+ initialValues
3429
+ });
3430
+ if (isCancel5(selected)) {
3431
+ return [];
3432
+ }
3433
+ return selected;
3434
+ }
3435
+ function readJson(path) {
3436
+ if (!existsSync9(path)) return void 0;
3437
+ try {
3438
+ const parsed = JSON.parse(readFileSync6(path, "utf8"));
3439
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
3440
+ } catch {
3441
+ return void 0;
3442
+ }
3443
+ }
3444
+ function findGitRoot(cwd) {
3445
+ const result = spawnSync6("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
3446
+ if (result.status !== 0) return void 0;
3447
+ return resolve3(result.stdout.trim());
3448
+ }
3449
+ function packageNameToProjectName(value) {
3450
+ if (!value) return void 0;
3451
+ const name = value.split("/").pop() ?? value;
3452
+ return name.replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()).trim();
3453
+ }
3454
+ function deriveProjectDefaults(targetDir) {
3455
+ const manifest = readJson(join12(targetDir, ".project.json"));
3456
+ const pkg = readJson(join12(targetDir, "package.json"));
3457
+ const name = String(manifest?.project_name ?? "").trim() || packageNameToProjectName(typeof pkg?.name === "string" ? pkg.name : void 0) || packageNameToProjectName(basename4(targetDir)) || "Project";
3458
+ const ticketProvider = manifest?.ticket_provider && typeof manifest.ticket_provider === "object" ? manifest.ticket_provider : {};
3459
+ return {
3460
+ name,
3461
+ description: String(manifest?.project_description ?? pkg?.description ?? ""),
3462
+ slug: typeof manifest?.project_slug === "string" ? manifest.project_slug : void 0,
3463
+ identifier: typeof ticketProvider.identifier === "string" ? ticketProvider.identifier : void 0
3464
+ };
3465
+ }
3466
+ function isInteractiveProjectInit(options) {
3467
+ return !options.json && !options.yes && options.tui !== false && Boolean(process.stdin.isTTY && process.stdout.isTTY);
3468
+ }
3469
+ async function promptTextValue(message, initialValue) {
3470
+ const value = await text3({
3471
+ message,
3472
+ initialValue,
3473
+ validate: (input) => input?.trim() ? void 0 : "Required"
3474
+ });
3475
+ if (isCancel5(value)) {
3476
+ cancel2("project init cancelled");
3477
+ process.exit(1);
3478
+ }
3479
+ return value.trim();
3480
+ }
3481
+ function projectInitActionLabel(kind) {
3482
+ switch (kind) {
3483
+ case "registry.upsert":
3484
+ return "Register/update project registry entry";
3485
+ case "copier.copy.commonproject":
3486
+ return "Render CommonProject scaffold";
3487
+ case "project.write-manifest":
3488
+ return "Write repo-local .project.json projection";
3489
+ case "plane.create-or-link":
3490
+ return "Create/link ticket provider project";
3491
+ case "hermes.provision-agent":
3492
+ return "Provision Hermes agent";
3493
+ default:
3494
+ return kind;
3495
+ }
3496
+ }
3497
+ function registryNeedsUpsert(plan) {
3498
+ const registry = loadProjectRegistry(plan.registryPath);
3499
+ const existing = registry.projects[plan.project.slug];
3500
+ if (!existing) return true;
3501
+ const { created_at: _existingCreated, updated_at: _existingUpdated, ...existingComparable } = existing;
3502
+ const { created_at: _projectCreated, updated_at: _projectUpdated, ...projectComparable } = plan.project;
3503
+ return JSON.stringify(existingComparable) !== JSON.stringify(projectComparable);
3504
+ }
3505
+ function actionNeedsRun(plan, kind, syncMode) {
3506
+ if (kind === "registry.upsert") return registryNeedsUpsert(plan);
3507
+ if (kind === "project.write-manifest") {
3508
+ const action = plan.actions.find((item) => item.kind === "project.write-manifest");
3509
+ if (!action || action.kind !== "project.write-manifest") return false;
3510
+ const next = `${JSON.stringify(action.manifest, null, 2)}
3511
+ `;
3512
+ return !existsSync9(action.path) || readFileSync6(action.path, "utf8") !== next;
3513
+ }
3514
+ if (kind === "copier.copy.commonproject") return true;
3515
+ if (kind === "plane.create-or-link") return plan.actions.some((action) => action.kind === kind && action.enabled);
3516
+ if (kind === "hermes.provision-agent") return plan.actions.some((action) => action.kind === kind && action.enabled);
3517
+ return true;
3518
+ }
3519
+ async function selectProjectInitOperations(input) {
3520
+ const planOperations = input.plan.actions.filter((action) => actionNeedsRun(input.plan, action.kind, input.syncMode)).map((action) => ({
3521
+ value: action.kind,
3522
+ label: projectInitActionLabel(action.kind),
3523
+ hint: action.kind === "registry.upsert" ? input.plan.registryPath : action.kind
3524
+ }));
3525
+ const parityOperations = input.auditRules.filter((rule) => rule.fixable && rule.status !== "pass" && rule.status !== "skip").map((rule) => ({
3526
+ value: `parity:${rule.id}`,
3527
+ label: `${rule.title}`,
3528
+ hint: `${rule.id}: ${rule.summary}`
3529
+ }));
3530
+ const operations = [...planOperations, ...parityOperations];
3531
+ const all = operations.map((operation) => operation.value);
3532
+ if (input.options.yes || input.options.apply && !isInteractiveProjectInit(input.options)) {
3533
+ return {
3534
+ selectedOperations: all,
3535
+ selectedParityRules: parityOperations.map((operation) => operation.value.replace(/^parity:/, ""))
3536
+ };
3537
+ }
3538
+ if (input.options.dryRun || !isInteractiveProjectInit(input.options)) {
3539
+ return { selectedOperations: [], selectedParityRules: [] };
3540
+ }
3541
+ if (!operations.length) return { selectedOperations: [], selectedParityRules: [] };
3542
+ const selected = await multiselect({
3543
+ message: "Select project init operations to run:",
3544
+ options: operations,
3545
+ initialValues: all
3546
+ });
3547
+ if (isCancel5(selected)) {
3548
+ cancel2("project init cancelled");
3549
+ process.exit(1);
3550
+ }
3551
+ return {
3552
+ selectedOperations: selected,
3553
+ selectedParityRules: selected.filter((value) => value.startsWith("parity:")).map((value) => value.replace(/^parity:/, ""))
3554
+ };
3555
+ }
3556
+ async function resolveProjectInitTarget(name, options) {
3557
+ const interactive = isInteractiveProjectInit(options);
3558
+ const cwd = process.cwd();
3559
+ const cwdGitRoot = findGitRoot(cwd);
3560
+ let targetDir = options.targetDir ? resolve3(options.targetDir) : void 0;
3561
+ if (!targetDir && cwdGitRoot) {
3562
+ targetDir = cwdGitRoot;
3563
+ }
3564
+ if (!targetDir && interactive) {
3565
+ const defaultName = name ?? basename4(cwd);
3566
+ const promptedName = name ?? await promptTextValue("Project name", packageNameToProjectName(defaultName));
3567
+ const defaultDir = join12(cwd, promptedName.replace(/[^A-Za-z0-9._-]/g, "") || promptedName.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
3568
+ targetDir = await promptTextValue("Project directory", defaultDir);
3569
+ name = promptedName;
3570
+ }
3571
+ if (!targetDir) {
3572
+ if (!name) throw new Error("Project name or --target-dir is required when project init is not run inside a git repo");
3573
+ targetDir = resolve3(process.cwd(), name.replace(/[^A-Za-z0-9._-]/g, "") || name.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
3574
+ }
3575
+ const targetExists = existsSync9(targetDir);
3576
+ if (targetExists && !statSync2(targetDir).isDirectory()) throw new Error(`Target path is not a directory: ${targetDir}`);
3577
+ const targetGitRoot = targetExists ? findGitRoot(targetDir) : void 0;
3578
+ const syncMode = Boolean(targetGitRoot && resolve3(targetGitRoot) === resolve3(targetDir));
3579
+ const defaults = targetExists ? deriveProjectDefaults(targetDir) : { name: packageNameToProjectName(basename4(targetDir)) ?? "Project", description: "" };
3580
+ if (!name && interactive && !syncMode) {
3581
+ name = await promptTextValue("Project name", defaults.name);
3582
+ }
3583
+ return {
3584
+ name: name ?? defaults.name,
3585
+ targetDir,
3586
+ description: options.description ?? defaults.description,
3587
+ syncMode,
3588
+ slug: options.slug ?? defaults.slug,
3589
+ identifier: options.identifier ?? defaults.identifier
3590
+ };
3591
+ }
2610
3592
  var program = new Command3();
2611
3593
  program.name("pjangler").description("Project subsystem bootstrapper CLI").version(PJANGLER_VERSION);
2612
3594
  program.command("init").argument("<subsystem>", "Subsystem to initialize").description("Initialize a project subsystem").option("--dry-run", "Preview changes without writing files").option("-f, --force", "Overwrite existing files").action(async (subsystem, options) => {
@@ -2618,61 +3600,219 @@ program.command("init").argument("<subsystem>", "Subsystem to initialize").descr
2618
3600
  try {
2619
3601
  const recipe = createRecipe(subsystem, context);
2620
3602
  if (!recipe) {
2621
- console.error(`\u274C Unknown subsystem: ${subsystem}`);
2622
- console.log(`Available subsystems: ${getRecipeNames().join(", ")}`);
3603
+ console.error(`${xmark} Unknown subsystem: ${bold(subsystem)}`);
3604
+ console.error(` ${dim("Available:")} ${getRecipeNames().map((available) => cyan(available)).join(dim(", "))}`);
2623
3605
  process.exit(1);
2624
3606
  }
2625
3607
  await recipe.execute();
2626
3608
  } catch (error) {
2627
- console.error(`\u274C Error initializing ${subsystem}:`, error);
3609
+ console.error(`${xmark} Error initializing ${bold(subsystem)}:`, error);
2628
3610
  process.exit(1);
2629
3611
  }
2630
3612
  });
2631
3613
  program.command("list").description("List available subsystems").action(() => {
2632
- console.log("Available subsystems:");
3614
+ const width = Object.keys(RECIPE_REGISTRY).reduce((max, name) => Math.max(max, name.length), 0);
3615
+ console.log("");
3616
+ console.log(` ${heading("Available subsystems")}`);
2633
3617
  console.log("");
2634
3618
  for (const [name, info] of Object.entries(RECIPE_REGISTRY)) {
2635
- console.log(` ${name.padEnd(10)} - ${info.description}`);
3619
+ console.log(` ${cyan(name.padEnd(width))} ${dim(info.description)}`);
3620
+ }
3621
+ console.log("");
3622
+ console.log(` ${dim("Examples")}`);
3623
+ for (const example of ["pj init mise", "pj init docker", "pj init node"]) {
3624
+ console.log(` ${dim(glyph.pointer)} ${dim(example)}`);
2636
3625
  }
2637
3626
  console.log("");
2638
- console.log("Usage examples:");
2639
- console.log(" pjangler init mise");
2640
- console.log(" pjangler init docker");
2641
- console.log(" pjangler init node");
3627
+ });
3628
+ var projectCmd = program.command("project").description("Manage the pjangler project registry");
3629
+ projectCmd.command("init").argument("[name]", "Project display name").description("Plan or apply a registry-backed CommonProject initialization or legacy repo sync").option("--description <text>", "Project description").option("--target-dir <path>", "Target repo path").option("--source-skill <path>", "Source skill/template provenance path").option("--primary-language <language>", "Primary language for CommonProject rendering", "python").option("--provision-agent", "Plan local Hermes PM agent provisioning").option("--agent-role <role>", "Hermes agent role to plan when --provision-agent is set", "pm").option("--apply", "Write the registry and render the repo scaffold").option("--dry-run", "Preview changes without writing files (default)").option("--live", "Allow live/network/cloud provisioning actions").option("--slug <slug>", "Project registry slug override").option("--identifier <identifier>", "Ticket identifier override").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("-f, --force", "Allow replacing an existing registry entry and re-rendering files").option("-y, --yes", "Apply every proposed operation without prompting").option("--no-tui", "Disable interactive prompts").option("--json", "Output machine-parseable JSON").action(async (name, options) => {
3630
+ try {
3631
+ const target = await resolveProjectInitTarget(name, options);
3632
+ const interactive = isInteractiveProjectInit(options);
3633
+ const apply = Boolean(!options.dryRun && (options.yes || options.apply || interactive));
3634
+ const plan = planProjectInit({
3635
+ name: target.name,
3636
+ description: target.description,
3637
+ targetDir: target.targetDir,
3638
+ sourceSkill: options.sourceSkill,
3639
+ primaryLanguage: options.primaryLanguage,
3640
+ provisionAgent: options.provisionAgent ?? false,
3641
+ agentRole: options.agentRole,
3642
+ apply,
3643
+ live: options.live ?? false,
3644
+ projectSlug: target.slug,
3645
+ projectIdentifier: target.identifier,
3646
+ registryPath: options.registry,
3647
+ force: options.force ?? false,
3648
+ overwrite: options.force ?? false,
3649
+ cwd: process.cwd(),
3650
+ scaffold: !target.syncMode
3651
+ });
3652
+ const audit = target.syncMode ? runAudit(target.targetDir) : void 0;
3653
+ const selection = await selectProjectInitOperations({
3654
+ plan,
3655
+ auditRules: audit?.rules ?? [],
3656
+ syncMode: target.syncMode,
3657
+ options
3658
+ });
3659
+ const selectedPlanActionKinds = new Set(selection.selectedOperations.filter((value) => !value.startsWith("parity:")));
3660
+ const selectedPlan = {
3661
+ ...plan,
3662
+ apply,
3663
+ dryRun: !apply,
3664
+ actions: apply ? plan.actions.filter((action) => selectedPlanActionKinds.has(action.kind)) : plan.actions
3665
+ };
3666
+ if (!apply) {
3667
+ const payload = {
3668
+ ...plan,
3669
+ mode: target.syncMode ? "sync" : "create",
3670
+ audit,
3671
+ proposedOperations: [
3672
+ ...plan.actions.filter((action) => actionNeedsRun(plan, action.kind, target.syncMode)).map((action) => action.kind),
3673
+ ...(audit?.rules ?? []).filter((rule) => rule.fixable && rule.status !== "pass" && rule.status !== "skip").map((rule) => `parity:${rule.id}`)
3674
+ ]
3675
+ };
3676
+ if (options.json) console.log(JSON.stringify(payload, null, 2));
3677
+ else {
3678
+ console.log(formatProjectInitPlan(plan));
3679
+ if (payload.proposedOperations.length) {
3680
+ console.log(` ${bold("Proposed operations")} ${dim(`(${payload.proposedOperations.length})`)}`);
3681
+ for (const operation of payload.proposedOperations) console.log(` ${cyan(glyph.bullet)} ${operation}`);
3682
+ } else {
3683
+ console.log(` ${green(glyph.pass)} ${dim("Project is already in parity.")}`);
3684
+ }
3685
+ console.log("");
3686
+ }
3687
+ return;
3688
+ }
3689
+ const initResult = selectedPlan.actions.length ? executeProjectInitPlan(selectedPlan) : { ok: true, plan: selectedPlan, logs: [], errors: [], changedFiles: [] };
3690
+ const migrationReport = selection.selectedParityRules.length ? runMigrationForRules(selection.selectedParityRules, target.targetDir, false) : void 0;
3691
+ const migrationErrors = migrationReport?.results.filter((result2) => result2.status === "blocked").map((result2) => `${result2.id}: ${result2.summary}`) ?? [];
3692
+ const changedFiles = Array.from(/* @__PURE__ */ new Set([
3693
+ ...initResult.changedFiles,
3694
+ ...migrationReport?.changedFiles ?? []
3695
+ ])).sort();
3696
+ const result = {
3697
+ ok: initResult.ok && (migrationReport?.ok ?? true),
3698
+ mode: target.syncMode ? "sync" : "create",
3699
+ plan: selectedPlan,
3700
+ audit,
3701
+ selectedOperations: selection.selectedOperations,
3702
+ selectedParityRules: selection.selectedParityRules,
3703
+ logs: initResult.logs,
3704
+ errors: [...initResult.errors, ...migrationErrors],
3705
+ changedFiles,
3706
+ migrationReport
3707
+ };
3708
+ if (options.json) {
3709
+ console.log(JSON.stringify(result, null, 2));
3710
+ } else {
3711
+ console.log(formatProjectInitPlan(selectedPlan));
3712
+ for (const line of result.logs) console.log(line);
3713
+ for (const line of result.errors) console.error(` ${xmark} ${line}`);
3714
+ if (migrationReport) console.log(formatMigrationReport(migrationReport));
3715
+ if (result.ok && changedFiles.length) console.log(` ${green(glyph.pass)} ${bold("Project synchronized")} ${dim(glyph.dot)} ${cyan(plan.project.slug)}
3716
+ `);
3717
+ if (result.ok && changedFiles.length === 0) console.log(` ${green(glyph.pass)} ${dim("Already in parity")} ${dim(glyph.dot)} ${cyan(plan.project.slug)}
3718
+ `);
3719
+ }
3720
+ process.exitCode = result.ok ? 0 : 1;
3721
+ } catch (err) {
3722
+ if (options.json) {
3723
+ console.log(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }, null, 2));
3724
+ } else {
3725
+ console.error(`${xmark} project init failed:`, err instanceof Error ? err.message : err);
3726
+ }
3727
+ process.exit(1);
3728
+ }
3729
+ });
3730
+ projectCmd.command("list").description("List projects in the pjangler registry").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("--json", "Output machine-parseable JSON").action((options) => {
3731
+ try {
3732
+ const registry = loadProjectRegistry(options.registry ?? projectRegistryPath());
3733
+ if (options.json) console.log(JSON.stringify(registry, null, 2));
3734
+ else console.log(formatProjectList(registry));
3735
+ } catch (err) {
3736
+ console.error(`${xmark} project list failed:`, err instanceof Error ? err.message : err);
3737
+ process.exit(1);
3738
+ }
3739
+ });
3740
+ projectCmd.command("show").argument("<slug>", "Project slug").description("Show one project from the pjangler registry").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("--json", "Output machine-parseable JSON").action((slug, options) => {
3741
+ try {
3742
+ const project = getProject(loadProjectRegistry(options.registry ?? projectRegistryPath()), slug);
3743
+ if (options.json) {
3744
+ console.log(JSON.stringify(project, null, 2));
3745
+ } else {
3746
+ console.log("");
3747
+ console.log(` ${heading(project.name)} ${dim(`(${project.slug})`)}`);
3748
+ console.log(` ${dim(project.repo_path)}`);
3749
+ if (project.description) console.log(` ${project.description}`);
3750
+ console.log("");
3751
+ }
3752
+ } catch (err) {
3753
+ console.error(`${xmark} project show failed:`, err instanceof Error ? err.message : err);
3754
+ process.exit(1);
3755
+ }
3756
+ });
3757
+ projectCmd.command("doctor").argument("[slug]", "Optional project slug").description("Validate the project registry and local projections").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("--json", "Output machine-parseable JSON").action((slug, options) => {
3758
+ try {
3759
+ const report = doctorProjectRegistry(options.registry ?? projectRegistryPath(), slug);
3760
+ if (options.json) {
3761
+ console.log(JSON.stringify(report, null, 2));
3762
+ } else if (!report.issues.length) {
3763
+ console.log("");
3764
+ console.log(` ${green(glyph.pass)} ${bold("Project registry OK")} ${dim(glyph.dot)} ${dim(report.registryPath)}`);
3765
+ console.log("");
3766
+ } else {
3767
+ console.log("");
3768
+ console.log(` ${red(glyph.fail)} ${bold("Project registry issues")} ${dim(glyph.dot)} ${dim(report.registryPath)}`);
3769
+ console.log("");
3770
+ for (const issue of report.issues) {
3771
+ const mark = issue.level === "error" ? red(glyph.fail) : yellow(glyph.warn);
3772
+ console.log(` ${mark} ${bold(issue.slug ?? "registry")} ${issue.message}`);
3773
+ }
3774
+ console.log("");
3775
+ }
3776
+ process.exit(report.ok ? 0 : 1);
3777
+ } catch (err) {
3778
+ console.error(`${xmark} project doctor failed:`, err instanceof Error ? err.message : err);
3779
+ process.exit(1);
3780
+ }
2642
3781
  });
2643
3782
  var recipeCmd = program.command("recipe").description("Manage pjangler recipes");
2644
3783
  recipeCmd.command("list").description("List all available recipes").action(() => {
2645
- console.log("\u{1F4E6} Available Recipes:");
3784
+ console.log("");
3785
+ console.log(` ${heading("Recipes")}`);
2646
3786
  console.log("");
2647
3787
  for (const [name, info] of Object.entries(RECIPE_REGISTRY)) {
2648
- console.log(` ${name}`);
2649
- console.log(` ${info.description}`);
2650
- console.log(` Commands: ${info.commands.join(", ")}`);
3788
+ console.log(` ${cyan(bold(name))}`);
3789
+ console.log(` ${dim(info.description)}`);
3790
+ console.log(` ${dim("commands")} ${info.commands.map((command) => cyan(command)).join(dim(", "))}`);
2651
3791
  console.log("");
2652
3792
  }
2653
- console.log("Usage:");
2654
- console.log(" pjangler recipe run <name>");
2655
- console.log(" pjangler recipe describe <name>");
3793
+ console.log(` ${dim("Usage")}`);
3794
+ console.log(` ${dim(glyph.pointer)} ${dim("pj recipe run <name>")}`);
3795
+ console.log(` ${dim(glyph.pointer)} ${dim("pj recipe describe <name>")}`);
3796
+ console.log("");
2656
3797
  });
2657
3798
  recipeCmd.command("describe").argument("<name>", "Recipe name").description("Show detailed information about a recipe").action((name) => {
2658
3799
  const info = getRecipeInfo(name);
2659
3800
  if (!info) {
2660
- console.error(`\u274C Recipe not found: ${name}`);
2661
- console.log(`Available recipes: ${getRecipeNames().join(", ")}`);
3801
+ console.error(`${xmark} Recipe not found: ${bold(name)}`);
3802
+ console.error(` ${dim("Available:")} ${getRecipeNames().map((available) => cyan(available)).join(dim(", "))}`);
2662
3803
  process.exit(1);
2663
3804
  }
2664
- console.log(`\u{1F4E6} Recipe: ${info.name}`);
2665
3805
  console.log("");
2666
- console.log(`Description: ${info.description}`);
3806
+ console.log(` ${heading(info.name)}`);
3807
+ console.log(` ${dim(info.description)}`);
2667
3808
  console.log("");
2668
- console.log("Commands:");
2669
- for (const cmd of info.commands) {
2670
- console.log(` - ${cmd}`);
2671
- }
3809
+ console.log(` ${bold("Commands")}`);
3810
+ for (const command of info.commands) console.log(` ${cyan(glyph.bullet)} ${command}`);
3811
+ console.log("");
3812
+ console.log(` ${dim("Usage")}`);
3813
+ console.log(` ${dim(glyph.pointer)} ${dim(`pj recipe run ${name}`)}`);
3814
+ console.log(` ${dim(glyph.pointer)} ${dim(`pj init ${name}`)}`);
2672
3815
  console.log("");
2673
- console.log("Usage:");
2674
- console.log(` pjangler recipe run ${name}`);
2675
- console.log(` pjangler init ${name}`);
2676
3816
  });
2677
3817
  recipeCmd.command("run").argument("<name>", "Recipe name").description("Execute a specific recipe").option("--dry-run", "Preview changes without writing files").option("-f, --force", "Overwrite existing files").action(async (name, options) => {
2678
3818
  const context = {
@@ -2683,80 +3823,73 @@ recipeCmd.command("run").argument("<name>", "Recipe name").description("Execute
2683
3823
  try {
2684
3824
  const recipe = createRecipe(name, context);
2685
3825
  if (!recipe) {
2686
- console.error(`\u274C Recipe not found: ${name}`);
2687
- console.log(`Available recipes: ${getRecipeNames().join(", ")}`);
3826
+ console.error(`${xmark} Recipe not found: ${bold(name)}`);
3827
+ console.error(` ${dim("Available:")} ${getRecipeNames().map((available) => cyan(available)).join(dim(", "))}`);
2688
3828
  process.exit(1);
2689
3829
  }
2690
- const dryRunPrefix = context.dryRun ? "[DRY RUN] " : "";
2691
- console.log(`${dryRunPrefix}\u{1F680} Running recipe: ${name}`);
2692
- console.log("");
2693
3830
  await recipe.execute();
2694
3831
  } catch (error) {
2695
- console.error(`\u274C Error running recipe ${name}:`, error);
3832
+ console.error(`${xmark} Error running recipe ${bold(name)}:`, error);
2696
3833
  process.exit(1);
2697
3834
  }
2698
3835
  });
2699
3836
  var commandCmd = program.command("command").alias("cmd").description("Manage pjangler commands");
2700
3837
  commandCmd.command("list").description("List all available commands").option("-g, --group", "Group commands by category").action((options) => {
3838
+ console.log("");
2701
3839
  if (options.group) {
2702
- console.log("\u2699\uFE0F Available Commands (Grouped):");
2703
- console.log("");
2704
- const grouped = getCommandsByGroup();
2705
- for (const [group, commands] of Object.entries(grouped)) {
2706
- console.log(` ${group.toUpperCase()}:`);
2707
- for (const cmd of commands) {
2708
- console.log(` ${cmd.name.padEnd(30)} - ${cmd.description}`);
2709
- }
3840
+ console.log(` ${heading("Commands by category")}`);
3841
+ for (const [group, commands] of Object.entries(getCommandsByGroup())) {
3842
+ const width = commands.reduce((max, command) => Math.max(max, command.name.length), 0);
2710
3843
  console.log("");
3844
+ console.log(` ${bold(group.toUpperCase())}`);
3845
+ for (const command of commands) {
3846
+ console.log(` ${cyan(command.name.padEnd(width))} ${dim(command.description)}`);
3847
+ }
2711
3848
  }
3849
+ console.log("");
2712
3850
  } else {
2713
- console.log("\u2699\uFE0F Available Commands:");
3851
+ const width = Object.keys(COMMAND_REGISTRY).reduce((max, name) => Math.max(max, name.length), 0);
3852
+ console.log(` ${heading("Commands")}`);
2714
3853
  console.log("");
2715
3854
  for (const [name, info] of Object.entries(COMMAND_REGISTRY)) {
2716
- console.log(` ${name.padEnd(30)} - ${info.description}`);
3855
+ console.log(` ${cyan(name.padEnd(width))} ${dim(info.description)}`);
2717
3856
  }
2718
3857
  console.log("");
2719
3858
  }
2720
- console.log("Usage:");
2721
- console.log(" pj command list --group # Group by category");
2722
- console.log(" pj command describe <name> # Show command details");
3859
+ console.log(` ${dim("Usage")}`);
3860
+ console.log(` ${dim(glyph.pointer)} ${dim("pj command list --group")} ${dim("# group by category")}`);
3861
+ console.log(` ${dim(glyph.pointer)} ${dim("pj command describe <name>")} ${dim("# command details")}`);
3862
+ console.log("");
2723
3863
  });
2724
3864
  commandCmd.command("describe").argument("<name>", "Command name").description("Show detailed information about a command").action((name) => {
2725
3865
  const info = getCommandInfo(name);
2726
3866
  if (!info) {
2727
- console.error(`\u274C Command not found: ${name}`);
2728
- console.log(`Available commands: ${getCommandNames().join(", ")}`);
3867
+ console.error(`${xmark} Command not found: ${bold(name)}`);
3868
+ console.error(` ${dim("Available:")} ${getCommandNames().map((available) => cyan(available)).join(dim(", "))}`);
2729
3869
  process.exit(1);
2730
3870
  }
2731
- console.log(`\u2699\uFE0F Command: ${info.name}`);
3871
+ const usedIn = Object.entries(RECIPE_REGISTRY).filter(([, recipeInfo]) => recipeInfo.commands.includes(name)).map(([recipeName]) => recipeName);
2732
3872
  console.log("");
2733
- console.log(`Description: ${info.description}`);
2734
- console.log(`Group: ${info.group}`);
3873
+ console.log(` ${heading(info.name)}`);
3874
+ console.log(` ${dim(info.description)}`);
2735
3875
  console.log("");
2736
- console.log("This command is used in recipes:");
2737
- for (const [recipeName, recipeInfo] of Object.entries(RECIPE_REGISTRY)) {
2738
- if (recipeInfo.commands.includes(name)) {
2739
- console.log(` - ${recipeName}`);
2740
- }
2741
- }
3876
+ console.log(` ${dim("group".padEnd(7))} ${cyan(info.group)}`);
3877
+ console.log(` ${dim("recipes".padEnd(7))} ${usedIn.length ? usedIn.map((recipeName) => cyan(recipeName)).join(dim(", ")) : dim("(none)")}`);
3878
+ console.log("");
3879
+ console.log(` ${dim("Part of recipe execution (not run directly).")}`);
2742
3880
  console.log("");
2743
- console.log("Usage:");
2744
- console.log(` Part of recipe execution (not run directly)`);
2745
3881
  });
2746
3882
  commandCmd.command("create").argument("<name>", "Command name").argument("<prompt>", "Description of what the command should do").description("Create a new command from template (placeholder for STORY-005)").option("-t, --template <type>", "Template type (toml, json, yaml, dockerfile)").option("-m, --model <model>", "LLM model to use (OpenRouter)").action((name, prompt, options) => {
2747
- console.log("\u{1F6A7} Command generation coming in STORY-005!");
2748
3883
  console.log("");
2749
- console.log("Planned features:");
2750
- console.log(` - Generate ${name} from prompt: "${prompt}"`);
2751
- if (options.template) {
2752
- console.log(` - Template type: ${options.template}`);
2753
- }
2754
- if (options.model) {
2755
- console.log(` - LLM model: ${options.model}`);
2756
- }
3884
+ console.log(` ${yellow(glyph.warn)} ${bold("Command generation coming in STORY-005")}`);
3885
+ console.log("");
3886
+ console.log(` ${dim("Planned")}`);
3887
+ console.log(` ${cyan(glyph.bullet)} Generate ${bold(name)} from prompt: ${dim(`"${prompt}"`)}`);
3888
+ if (options.template) console.log(` ${cyan(glyph.bullet)} Template type: ${cyan(options.template)}`);
3889
+ if (options.model) console.log(` ${cyan(glyph.bullet)} LLM model: ${cyan(options.model)}`);
3890
+ console.log("");
3891
+ console.log(` ${dim("For now, manually create commands in src/commands/")}`);
2757
3892
  console.log("");
2758
- console.log("This feature will be implemented in the Template Generation System story.");
2759
- console.log("For now, manually create commands in src/commands/");
2760
3893
  });
2761
3894
  program.command("audit").argument("[repo]", "Path to repo to audit (default: cwd)").description("Deterministic parity audit against 33god project standard").option("--json", "Output machine-parseable JSON").action((repo, options) => {
2762
3895
  try {
@@ -2768,31 +3901,57 @@ program.command("audit").argument("[repo]", "Path to repo to audit (default: cwd
2768
3901
  }
2769
3902
  process.exit(report.ok ? 0 : 1);
2770
3903
  } catch (err) {
2771
- console.error("\u274C audit failed:", err);
3904
+ console.error(`${xmark} audit failed:`, err);
2772
3905
  process.exit(1);
2773
3906
  }
2774
3907
  });
2775
- program.command("migrate").argument("[rule-id]", "Rule ID to migrate (omit with --all to apply all)").argument("[repo]", "Path to repo (default: cwd)").description("Idempotent migration recipe for a parity rule (or --all)").option("--all", "Apply every migration recipe in order").option("--dry-run", "Preview changes without writing files").option("--json", "Output machine-parseable JSON").action((ruleId, repo, options) => {
3908
+ program.command("migrate").argument("[rule-id]", "Rule ID to migrate (omit to open interactive rule selector)").argument("[repo]", "Path to repo (default: cwd)").description("Idempotent migration recipe for a parity rule (or open the rule selector)").option("--all", "Apply every migration recipe in order").option("--dry-run", "Preview changes without writing files").option("--json", "Output machine-parseable JSON").action(async (ruleId, repo, options) => {
2776
3909
  try {
2777
3910
  const all = options.all ?? false;
2778
- if (!all && !ruleId) {
2779
- console.error("\u274C Provide a rule-id or use --all");
2780
- process.exit(1);
3911
+ const dryRun = options.dryRun ?? false;
3912
+ if (all) {
3913
+ let actualRepo = repo;
3914
+ if (ruleId && !actualRepo) {
3915
+ actualRepo = ruleId;
3916
+ }
3917
+ const report2 = runMigration(void 0, actualRepo, dryRun, true);
3918
+ printMigrationReport(report2, options.json);
3919
+ process.exit(report2.ok ? 0 : 1);
3920
+ }
3921
+ if (ruleId && repo) {
3922
+ if (!getParityRuleIds().includes(ruleId)) {
3923
+ console.error(`${xmark} Unknown parity rule: ${bold(ruleId)}`);
3924
+ process.exit(1);
3925
+ }
3926
+ const report2 = runMigration(ruleId, repo, dryRun, false);
3927
+ printMigrationReport(report2, options.json);
3928
+ process.exit(report2.ok ? 0 : 1);
2781
3929
  }
2782
- let actualRuleId = all ? void 0 : ruleId;
2783
- let actualRepo = repo;
2784
- if (all && ruleId && !actualRepo) {
2785
- actualRepo = ruleId;
3930
+ if (ruleId && getParityRuleIds().includes(ruleId)) {
3931
+ const report2 = runMigration(ruleId, void 0, dryRun, false);
3932
+ printMigrationReport(report2, options.json);
3933
+ process.exit(report2.ok ? 0 : 1);
2786
3934
  }
2787
- const report = runMigration(actualRuleId, actualRepo, options.dryRun ?? false, all);
2788
3935
  if (options.json) {
2789
- console.log(JSON.stringify(report, null, 2));
2790
- } else {
2791
- console.log(formatMigrationReport(report));
3936
+ console.error(`${xmark} JSON output requires a rule-id or --all`);
3937
+ process.exit(1);
3938
+ }
3939
+ if (!process.stdin.isTTY) {
3940
+ console.error(`${xmark} Provide a rule-id, use --all, or run in an interactive terminal`);
3941
+ process.exit(1);
2792
3942
  }
3943
+ const targetRepo = ruleId ?? repo;
3944
+ const audit = runAudit(targetRepo);
3945
+ const ruleIds = await promptForRuleIds(audit.rules);
3946
+ if (!ruleIds.length) {
3947
+ console.log(` ${cyan(glyph.info)} ${dim("No rules selected; nothing to migrate.")}`);
3948
+ process.exit(0);
3949
+ }
3950
+ const report = runMigrationForRules(ruleIds, targetRepo, dryRun);
3951
+ printMigrationReport(report, false);
2793
3952
  process.exit(report.ok ? 0 : 1);
2794
3953
  } catch (err) {
2795
- console.error("\u274C migrate failed:", err);
3954
+ console.error(`${xmark} migrate failed:`, err);
2796
3955
  process.exit(1);
2797
3956
  }
2798
3957
  });
@@ -2827,12 +3986,12 @@ program.command("hermes-agent").alias("hermes").description("Provision a Hermes
2827
3986
  try {
2828
3987
  const recipe = createRecipe("hermes-agent", context);
2829
3988
  if (!recipe) {
2830
- console.error("\u274C hermes-agent recipe not registered");
3989
+ console.error(`${xmark} hermes-agent recipe not registered`);
2831
3990
  process.exit(1);
2832
3991
  }
2833
3992
  await recipe.execute();
2834
3993
  } catch (err) {
2835
- console.error("\u274C hermes-agent failed:", err);
3994
+ console.error(`${xmark} hermes-agent failed:`, err);
2836
3995
  process.exit(1);
2837
3996
  }
2838
3997
  });
@@ -2850,14 +4009,15 @@ configCmd.command("bootstrap").description("Create ~/.config/hermes-agent-templa
2850
4009
  }
2851
4010
  });
2852
4011
  program.command("describe").description("Describe the current project (for AI context)").action(() => {
2853
- console.log("\u{1F50D} Project Description (placeholder for future enhancement)");
2854
4012
  console.log("");
2855
- console.log("This command will analyze the project and provide:");
2856
- console.log(" - Detected project type");
2857
- console.log(" - Installed subsystems");
2858
- console.log(" - Configuration files present");
2859
- console.log(" - Suggested next steps");
4013
+ console.log(` ${heading("Project description")} ${dim("(placeholder)")}`);
4014
+ console.log("");
4015
+ console.log(` ${dim("Will analyze the project and report:")}`);
4016
+ for (const item of ["Detected project type", "Installed subsystems", "Configuration files present", "Suggested next steps"]) {
4017
+ console.log(` ${cyan(glyph.bullet)} ${item}`);
4018
+ }
4019
+ console.log("");
4020
+ console.log(` ${dim("Coming soon.")}`);
2860
4021
  console.log("");
2861
- console.log("Coming soon!");
2862
4022
  });
2863
4023
  program.parse();