@delorenj/pjangler 1.2.4 → 1.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/index.js +797 -737
  2. package/dist/mcp-server.js +753 -683
  3. package/package.json +1 -1
  4. package/templates/commonproject/AGENTS.md +3 -3
  5. package/templates/commonproject/README.md +12 -11
  6. package/templates/commonproject/copier.yml +58 -10
  7. package/templates/commonproject/mise.toml +3 -3
  8. package/templates/commonproject/template/.agents/hooks/sync.py +12 -1
  9. package/templates/commonproject/template/.agents/local.example.json +3 -1
  10. package/templates/commonproject/template/.project.json.jinja +13 -6
  11. package/templates/commonproject/template/mise.toml.jinja +8 -4
  12. package/templates/hermes-agent/README.md +8 -8
  13. package/templates/hermes-agent/copier.yml +3 -4
  14. package/templates/hermes-agent/docs/architecture.md +4 -4
  15. package/templates/hermes-agent/docs/operations.md +2 -2
  16. package/templates/hermes-agent/docs/sentinel/README.md +6 -8
  17. package/templates/hermes-agent/docs/sentinel/architecture.md +2 -1
  18. package/templates/hermes-agent/docs/sentinel/development.md +13 -12
  19. package/templates/hermes-agent/docs/sentinel/providers.md +12 -32
  20. package/templates/hermes-agent/install-local.sh +6 -13
  21. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +14 -37
  22. package/templates/hermes-agent/template/.scripts/70-systemd.sh +2 -0
  23. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +6 -2
  24. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +2 -2
  25. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +1 -1
  26. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +6 -1
  27. package/templates/hermes-agent/template/SOUL.md.jinja +9 -3
  28. package/templates/hermes-agent/template/role.yaml.jinja +3 -6
  29. package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -51
  30. package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -176
package/dist/index.js CHANGED
@@ -727,7 +727,7 @@ import * as p from "@clack/prompts";
727
727
  function detectTicketProvider(targetDir) {
728
728
  try {
729
729
  const t = JSON.parse(readFileSync(join4(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
730
- return t === "plane" || t === "linear" || t === "trello" ? t : void 0;
730
+ return t === "plane" || t === "trello" ? t : void 0;
731
731
  } catch {
732
732
  return void 0;
733
733
  }
@@ -1201,70 +1201,540 @@ var HermesAgentRecipe = class extends Recipe {
1201
1201
  };
1202
1202
 
1203
1203
  // src/commands/AgentHooksCommands.ts
1204
- import { homedir as homedir3 } from "node:os";
1205
- import { join as join8, dirname as dirname4 } from "node:path";
1206
- import { existsSync as existsSync6, cpSync, mkdirSync as mkdirSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "node:fs";
1204
+ import { homedir as homedir4 } from "node:os";
1205
+ import { join as join9, dirname as dirname5 } from "node:path";
1206
+ import { existsSync as existsSync7, cpSync, mkdirSync as mkdirSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
1207
1207
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1208
- function resolveTemplateRoot() {
1209
- const candidates = [];
1210
- if (process.env.PJANGLER_COMMONPROJECT_TEMPLATE) {
1211
- candidates.push(process.env.PJANGLER_COMMONPROJECT_TEMPLATE);
1208
+
1209
+ // src/project/index.ts
1210
+ import { spawnSync as spawnSync4 } from "node:child_process";
1211
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync2, renameSync, statSync, writeFileSync as writeFileSync3 } from "node:fs";
1212
+ import { homedir as homedir3 } from "node:os";
1213
+ import { basename as basename2, dirname as dirname4, join as join8, resolve } from "node:path";
1214
+ import YAML from "yaml";
1215
+ var PROJECT_REGISTRY_ENV = "PJ_PROJECT_REGISTRY";
1216
+ var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
1217
+ var KNOWN_SKILL_ROOTS = [
1218
+ "/home/delorenj/code/skillex/all-skills",
1219
+ "/home/delorenj/code/CoachingAgentFramework/.agents/skills",
1220
+ "/home/delorenj/code/pjangler/.agents/skills",
1221
+ join8(homedir3(), ".codex", "skills")
1222
+ ];
1223
+ function projectRegistryPath(env2 = process.env) {
1224
+ return expandHome(env2[PROJECT_REGISTRY_ENV] || join8(homedir3(), ".config", "pjangler", "projects.yaml"));
1225
+ }
1226
+ function emptyProjectRegistry() {
1227
+ return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
1228
+ }
1229
+ function loadProjectRegistry(path = projectRegistryPath()) {
1230
+ if (!existsSync6(path)) return emptyProjectRegistry();
1231
+ const raw = YAML.parse(readFileSync2(path, "utf8"));
1232
+ if (raw == null) return emptyProjectRegistry();
1233
+ if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
1234
+ const registry = raw;
1235
+ const normalized = {
1236
+ schema_version: Number(registry.schema_version ?? PROJECT_REGISTRY_SCHEMA_VERSION),
1237
+ projects: isRecord(registry.projects) ? registry.projects : {}
1238
+ };
1239
+ validateProjectRegistry(normalized);
1240
+ return normalized;
1241
+ }
1242
+ function saveProjectRegistry(registry, path = projectRegistryPath()) {
1243
+ validateProjectRegistry(registry);
1244
+ mkdirSync4(dirname4(path), { recursive: true });
1245
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
1246
+ writeFileSync3(temp, YAML.stringify(registry, { lineWidth: 0 }), "utf8");
1247
+ renameSync(temp, path);
1248
+ }
1249
+ function validateProjectRegistry(registry) {
1250
+ if (registry.schema_version !== PROJECT_REGISTRY_SCHEMA_VERSION) {
1251
+ throw new Error(`Unsupported project registry schema_version: ${registry.schema_version}`);
1212
1252
  }
1213
- try {
1214
- let dir = dirname4(fileURLToPath2(import.meta.url));
1215
- for (let i = 0; i < 8; i++) {
1216
- candidates.push(join8(dir, "templates", "commonproject", "template"));
1217
- const parent = dirname4(dir);
1218
- if (parent === dir) break;
1219
- dir = parent;
1253
+ if (!isRecord(registry.projects)) throw new Error("Project registry projects must be a mapping");
1254
+ const slugs = /* @__PURE__ */ new Set();
1255
+ const repoPaths = /* @__PURE__ */ new Map();
1256
+ const identifiers = /* @__PURE__ */ new Map();
1257
+ for (const [slug, project] of Object.entries(registry.projects)) {
1258
+ validateProjectRecord(project, slug);
1259
+ if (slugs.has(project.slug)) throw new Error(`Duplicate project slug: ${project.slug}`);
1260
+ slugs.add(project.slug);
1261
+ const repoKey = resolve(project.repo_path);
1262
+ const existingRepoSlug = repoPaths.get(repoKey);
1263
+ if (existingRepoSlug && existingRepoSlug !== slug) {
1264
+ throw new Error(`Duplicate project repo_path: ${project.repo_path} used by ${existingRepoSlug} and ${slug}`);
1265
+ }
1266
+ repoPaths.set(repoKey, slug);
1267
+ const identifier = project.ticket_provider.identifier?.toUpperCase();
1268
+ if (identifier) {
1269
+ const existingIdentifierSlug = identifiers.get(identifier);
1270
+ if (existingIdentifierSlug && existingIdentifierSlug !== slug) {
1271
+ throw new Error(`Duplicate project identifier: ${identifier} used by ${existingIdentifierSlug} and ${slug}`);
1272
+ }
1273
+ identifiers.set(identifier, slug);
1220
1274
  }
1221
- } catch {
1222
1275
  }
1223
- candidates.push(join8(homedir3(), "code", "pjangler", "templates", "commonproject", "template"));
1224
- for (const c of candidates) {
1225
- if (existsSync6(join8(c, ".agents", "hooks", "hooks.master.json"))) return c;
1276
+ }
1277
+ function normalizeTicketProvider(value) {
1278
+ const type = (value || "plane").trim().toLowerCase();
1279
+ if (type === "plane" || type === "trello") return type;
1280
+ throw new Error(`Unsupported ticket provider: ${value}. Supported providers: plane, trello`);
1281
+ }
1282
+ function buildTicketProviderBlock(input) {
1283
+ const type = normalizeTicketProvider(input.type);
1284
+ const boardId = input.boardId ?? "";
1285
+ if (type === "trello") {
1286
+ return {
1287
+ type,
1288
+ workspace: input.workspace ?? "",
1289
+ identifier: input.identifier,
1290
+ board_id: boardId,
1291
+ board_url: input.boardUrl ?? (boardId ? `https://trello.com/b/${boardId}` : ""),
1292
+ state: "planned"
1293
+ };
1226
1294
  }
1227
- throw new Error(
1228
- "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
1229
- );
1295
+ const workspace = input.workspace ?? "33god";
1296
+ return {
1297
+ type,
1298
+ workspace,
1299
+ identifier: input.identifier,
1300
+ board_id: boardId,
1301
+ board_url: input.boardUrl ?? (boardId ? `https://plane.delo.sh/${workspace}/projects/${boardId}/issues/` : ""),
1302
+ state: "planned"
1303
+ };
1230
1304
  }
1231
- var CopyAgentHooksTree = class extends Command {
1232
- async invoke() {
1233
- let templateRoot;
1234
- try {
1235
- templateRoot = resolveTemplateRoot();
1236
- } catch (e) {
1237
- return { success: false, message: `\u26A0\uFE0F ${e.message}` };
1305
+ function slugifyProjectName(value) {
1306
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
1307
+ }
1308
+ function deriveProjectIdentifier(value) {
1309
+ const compact = value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
1310
+ const identifier = compact.slice(0, 4) || "PROJ";
1311
+ return identifier.length >= 2 ? identifier : `${identifier}XX`.slice(0, 4);
1312
+ }
1313
+ function normalizeAgentRole(value) {
1314
+ return value?.trim() || "pm";
1315
+ }
1316
+ function resolveAgentHooksLayer(input, env2 = process.env) {
1317
+ if (typeof input === "boolean") return input;
1318
+ const override = env2.PJ_AGENT_HOOKS_LAYER;
1319
+ if (override === "0" || override === "false") return false;
1320
+ if (override === "1" || override === "true") return true;
1321
+ return !existsSync6(join8(homedir3(), ".agents", "hooks"));
1322
+ }
1323
+ function jsonStable(value) {
1324
+ return JSON.stringify(value);
1325
+ }
1326
+ function projectRecordEquivalent(a, b) {
1327
+ if (!a) return false;
1328
+ const { created_at: _aCreated, updated_at: _aUpdated, ...aComparable } = a;
1329
+ const { created_at: _bCreated, updated_at: _bUpdated, ...bComparable } = b;
1330
+ return jsonStable(aComparable) === jsonStable(bComparable);
1331
+ }
1332
+ function defaultProjectTargetDir(name, cwd = process.cwd()) {
1333
+ const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
1334
+ return resolve(dirname4(resolve(cwd)), compactName);
1335
+ }
1336
+ function resolveSourceSkillPath(sourceSkill) {
1337
+ if (!sourceSkill) return void 0;
1338
+ const expanded = expandHome(sourceSkill);
1339
+ const direct = resolve(expanded);
1340
+ if (existsSync6(direct)) return direct;
1341
+ const name = basename2(sourceSkill);
1342
+ for (const root of KNOWN_SKILL_ROOTS) {
1343
+ const candidate = join8(root, name);
1344
+ if (existsSync6(candidate)) return candidate;
1345
+ }
1346
+ const civilWarLetterifier = "/home/delorenj/code/skillex/all-skills/civilwar-letterifier";
1347
+ const hint = existsSync6(civilWarLetterifier) ? ` Did you mean ${civilWarLetterifier}?` : "";
1348
+ throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
1349
+ }
1350
+ function planProjectInit(input) {
1351
+ if (!input.name.trim()) throw new Error("Project name is required");
1352
+ const registryPath2 = resolve(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
1353
+ const registry = loadProjectRegistry(registryPath2);
1354
+ const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
1355
+ const slug = input.projectSlug ?? slugifyProjectName(input.name);
1356
+ const targetDir = resolve(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
1357
+ const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
1358
+ const existing = registry.projects[slug];
1359
+ const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
1360
+ const overwrite = input.overwrite ?? input.force ?? false;
1361
+ const agentRole = normalizeAgentRole(input.agentRole);
1362
+ const agents = input.provisionAgent ? {
1363
+ ...existing?.agents ?? {},
1364
+ [agentRole]: {
1365
+ role: agentRole,
1366
+ provisioning_state: "planned"
1238
1367
  }
1239
- const items = [
1240
- { rel: ".agents/hooks", dir: true },
1241
- { rel: ".agents/local.example.json", dir: false },
1242
- { rel: ".mise/scripts/link-project-skills-to-clis.sh", dir: false },
1243
- { rel: ".mise/scripts/unlink-project-skills-from-clis.sh", dir: false },
1244
- { rel: ".mise/scripts/hindsight-setup.sh", dir: false }
1245
- ];
1246
- const created = [];
1247
- const skipped = [];
1248
- for (const { rel, dir } of items) {
1249
- const src = join8(templateRoot, rel);
1250
- const dest = join8(this.context.targetDir, rel);
1251
- if (!existsSync6(src)) continue;
1252
- if (existsSync6(dest) && !this.context.force) {
1253
- skipped.push(rel);
1254
- continue;
1368
+ } : existing?.agents ?? {};
1369
+ const scaffold = input.scaffold ?? true;
1370
+ const candidateProject = {
1371
+ name: input.name,
1372
+ slug,
1373
+ repo_path: targetDir,
1374
+ description: input.description ?? "",
1375
+ status: "planned",
1376
+ source_artifacts: sourceSkillPath ? [{ kind: "skill", path: sourceSkillPath, package_name: input.packageName ?? slug }] : [],
1377
+ template: {
1378
+ commonproject: {
1379
+ enabled: true,
1380
+ primary_language: input.primaryLanguage ?? "python"
1255
1381
  }
1256
- if (!this.context.dryRun) {
1257
- mkdirSync4(dirname4(dest), { recursive: true });
1258
- cpSync(src, dest, { recursive: dir, force: true });
1382
+ },
1383
+ ticket_provider: buildTicketProviderBlock({
1384
+ type: input.ticketProvider ?? "plane",
1385
+ identifier,
1386
+ boardId: input.boardId ?? input.planeProjectId,
1387
+ boardUrl: input.boardUrl,
1388
+ workspace: input.boardWorkspace ?? input.planeWorkspace
1389
+ }),
1390
+ agents,
1391
+ created_at: existing?.created_at ?? now,
1392
+ updated_at: now
1393
+ };
1394
+ const project = {
1395
+ ...candidateProject,
1396
+ updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
1397
+ };
1398
+ validateNoDuplicateProject(registry, project, overwrite);
1399
+ const pjanglerRoot = resolve(input.pjanglerRoot ?? resolvePjanglerRoot());
1400
+ const manifest = projectManifestFromRegistryProject(project);
1401
+ const apply = input.apply ?? false;
1402
+ const live = input.live ?? false;
1403
+ const actions = [
1404
+ { kind: "registry.upsert", registryPath: registryPath2, slug, project }
1405
+ ];
1406
+ if (scaffold) {
1407
+ actions.push(buildCommonProjectCopierAction({
1408
+ pjanglerRoot,
1409
+ targetDir,
1410
+ projectName: project.name,
1411
+ projectDescription: project.description,
1412
+ projectSlug: project.slug,
1413
+ ticketProvider: project.ticket_provider.type,
1414
+ planeWorkspace: project.ticket_provider.workspace ?? "33god",
1415
+ planeProjectId: project.ticket_provider.board_id ?? "",
1416
+ ticketWorkspace: project.ticket_provider.workspace ?? "",
1417
+ boardId: project.ticket_provider.board_id ?? "",
1418
+ boardUrl: project.ticket_provider.board_url ?? "",
1419
+ projectIdentifier: identifier,
1420
+ primaryLanguage: project.template.commonproject.primary_language,
1421
+ agentHooksLayer: resolveAgentHooksLayer(input.agentHooksLayer),
1422
+ overwrite
1423
+ }));
1424
+ }
1425
+ actions.push(
1426
+ { kind: "project.write-manifest", path: join8(targetDir, ".project.json"), manifest },
1427
+ {
1428
+ kind: "ticket-provider.create-or-link",
1429
+ enabled: live,
1430
+ live,
1431
+ provider: project.ticket_provider.type,
1432
+ workspace: project.ticket_provider.workspace ?? "33god",
1433
+ identifier,
1434
+ state: live ? "planned" : "planned",
1435
+ reason: live ? void 0 : "network/cloud actions require --live"
1436
+ },
1437
+ {
1438
+ kind: "hermes.provision-agent",
1439
+ enabled: input.provisionAgent ?? false,
1440
+ local: !live,
1441
+ targetDir,
1442
+ targetRepo: slug,
1443
+ role: agentRole,
1444
+ context: {
1445
+ skipRuntimeRepo: !live,
1446
+ skipPlane: !live,
1447
+ skipBloodbank: !live,
1448
+ skipSystemd: !live || process.platform === "darwin"
1259
1449
  }
1260
- created.push(rel);
1261
1450
  }
1262
- const verb = this.context.dryRun ? "Would copy" : "Copied";
1263
- const tail = skipped.length ? ` (${skipped.length} already present \u2014 use --force to overwrite)` : "";
1264
- return {
1265
- success: created.length > 0,
1266
- message: this.formatMessage(`\u2705 ${verb} ${created.length} agent-hooks path(s)${tail}`)
1267
- };
1451
+ );
1452
+ return { ok: true, apply, dryRun: !apply, live, registryPath: registryPath2, project, manifest, actions };
1453
+ }
1454
+ function executeProjectInitPlan(plan) {
1455
+ const logs = [];
1456
+ const errors = [];
1457
+ const changedFiles = [];
1458
+ if (!plan.apply) return { ok: true, plan, logs, errors, changedFiles };
1459
+ const registry = loadProjectRegistry(plan.registryPath);
1460
+ let pendingRegistryAction;
1461
+ for (const action of plan.actions) {
1462
+ if (action.kind === "copier.copy.commonproject") {
1463
+ logs.push(
1464
+ action.data.agent_hooks_layer === "false" ? "commonproject: agent-hooks layer skipped (global ~/.agents/hooks detected \u2014 no per-user CLI injection)" : "commonproject: agent-hooks layer included"
1465
+ );
1466
+ mkdirSync4(dirname4(action.targetDir), { recursive: true });
1467
+ const result = spawnSync4(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
1468
+ if (result.stdout?.trim()) logs.push(result.stdout.trim());
1469
+ if (result.stderr?.trim()) logs.push(result.stderr.trim());
1470
+ if (result.error) {
1471
+ const code = result.error.code;
1472
+ errors.push(
1473
+ code === "ENOENT" ? "copier not found on PATH. Install with: uv tool install copier or pip install copier" : `copier failed: ${result.error.message}`
1474
+ );
1475
+ break;
1476
+ }
1477
+ if (result.status !== 0) {
1478
+ errors.push(`copier exited with status ${result.status ?? "unknown"}`);
1479
+ if (existsSync6(action.targetDir)) changedFiles.push(action.targetDir);
1480
+ break;
1481
+ }
1482
+ changedFiles.push(action.targetDir);
1483
+ } else if (action.kind === "project.write-manifest") {
1484
+ mkdirSync4(dirname4(action.path), { recursive: true });
1485
+ const next = `${JSON.stringify(action.manifest, null, 2)}
1486
+ `;
1487
+ const current = existsSync6(action.path) ? readFileSync2(action.path, "utf8") : void 0;
1488
+ if (current !== next) {
1489
+ writeFileSync3(action.path, next, "utf8");
1490
+ changedFiles.push(action.path);
1491
+ }
1492
+ } else if (action.kind === "registry.upsert") {
1493
+ pendingRegistryAction = action;
1494
+ } else if (action.kind === "ticket-provider.create-or-link") {
1495
+ logs.push(action.enabled ? "ticket-provider.create-or-link requires a live provider integration" : "ticket-provider.create-or-link skipped (requires --live)");
1496
+ } else if (action.kind === "hermes.provision-agent") {
1497
+ logs.push(action.enabled ? "hermes.provision-agent planned for the caller to execute" : "hermes.provision-agent skipped");
1498
+ }
1499
+ }
1500
+ if (pendingRegistryAction && errors.length === 0) {
1501
+ if (!projectRecordEquivalent(registry.projects[pendingRegistryAction.slug], pendingRegistryAction.project)) {
1502
+ registry.projects[pendingRegistryAction.slug] = pendingRegistryAction.project;
1503
+ saveProjectRegistry(registry, pendingRegistryAction.registryPath);
1504
+ changedFiles.push(pendingRegistryAction.registryPath);
1505
+ }
1506
+ }
1507
+ return { ok: errors.length === 0, plan, logs, errors, changedFiles };
1508
+ }
1509
+ function projectManifestFromRegistryProject(project) {
1510
+ const agents = Object.fromEntries(
1511
+ Object.entries(project.agents).map(([name, agent]) => [
1512
+ `${project.slug}-${name}`,
1513
+ {
1514
+ role: agent.role,
1515
+ role_dir: agent.role_dir,
1516
+ provisioning_state: agent.provisioning_state
1517
+ }
1518
+ ])
1519
+ );
1520
+ return {
1521
+ project_name: project.name,
1522
+ project_description: project.description,
1523
+ project_slug: project.slug,
1524
+ repo_path: project.repo_path,
1525
+ ticket_provider: {
1526
+ type: project.ticket_provider.type,
1527
+ workspace: project.ticket_provider.workspace ?? "",
1528
+ identifier: project.ticket_provider.identifier ?? "",
1529
+ board_id: project.ticket_provider.board_id ?? "",
1530
+ board_url: project.ticket_provider.board_url ?? "",
1531
+ state: project.ticket_provider.state
1532
+ },
1533
+ agents
1534
+ };
1535
+ }
1536
+ function formatProjectInitPlan(plan) {
1537
+ const lines = [""];
1538
+ const title = `${bold(plan.project.name)} ${dim(`(${plan.project.slug})`)}`;
1539
+ lines.push(` ${cyan(bold(glyph.chevron))} ${title}${plan.dryRun ? ` ${dim(glyph.dot)} ${yellow("dry run")}` : ""}`);
1540
+ lines.push(` ${dim("registry".padEnd(8))} ${dim(plan.registryPath)}`);
1541
+ lines.push(` ${dim("target".padEnd(8))} ${dim(plan.project.repo_path)}`);
1542
+ lines.push("");
1543
+ lines.push(` ${bold("Actions")} ${dim(`(${plan.actions.length})`)}`);
1544
+ if (!plan.actions.length) lines.push(` ${dim("(nothing to do)")}`);
1545
+ for (const action of plan.actions) {
1546
+ lines.push(` ${cyan(glyph.bullet)} ${action.kind}`);
1547
+ if (action.kind === "copier.copy.commonproject") lines.push(` ${dim(`target: ${action.targetDir}`)}`);
1548
+ if (action.kind === "project.write-manifest") lines.push(` ${dim(`path: ${action.path}`)}`);
1549
+ if (action.kind === "ticket-provider.create-or-link" && action.reason) lines.push(` ${dim(`note: ${action.reason}`)}`);
1550
+ }
1551
+ lines.push("");
1552
+ return lines.join("\n");
1553
+ }
1554
+ function formatProjectList(registry) {
1555
+ const projects = Object.values(registry.projects).sort((a, b) => a.slug.localeCompare(b.slug));
1556
+ if (!projects.length) return `
1557
+ ${dim("No projects registered.")}
1558
+ `;
1559
+ const slugWidth = projects.reduce((width, project) => Math.max(width, project.slug.length), 0);
1560
+ const idWidth = projects.reduce((width, project) => Math.max(width, String(project.ticket_provider.identifier ?? "").length), 0);
1561
+ const statusWidth = projects.reduce((width, project) => Math.max(width, project.status.length), 0);
1562
+ const lines = ["", ` ${bold("Projects")} ${dim(`(${projects.length})`)}`, ""];
1563
+ for (const project of projects) {
1564
+ const slug = bold(project.slug.padEnd(slugWidth));
1565
+ const identifier = cyan(String(project.ticket_provider.identifier ?? "").padEnd(idWidth));
1566
+ const status = projectStatusColor(project.status)(project.status.padEnd(statusWidth));
1567
+ lines.push(` ${slug} ${identifier} ${status} ${dim(project.repo_path)}`);
1568
+ }
1569
+ lines.push("");
1570
+ return lines.join("\n");
1571
+ }
1572
+ function getProject(registry, slug) {
1573
+ const project = registry.projects[slug];
1574
+ if (!project) throw new Error(`Project not found in registry: ${slug}`);
1575
+ return project;
1576
+ }
1577
+ function doctorProjectRegistry(registryPath2 = projectRegistryPath(), slug) {
1578
+ const issues = [];
1579
+ const registry = loadProjectRegistry(registryPath2);
1580
+ const projects = slug ? [[slug, getProject(registry, slug)]] : Object.entries(registry.projects);
1581
+ for (const [projectSlug, project] of projects) {
1582
+ if (!existsSync6(project.repo_path)) {
1583
+ issues.push({ level: "warn", slug: projectSlug, message: `repo_path does not exist: ${project.repo_path}` });
1584
+ } else if (!statSync(project.repo_path).isDirectory()) {
1585
+ issues.push({ level: "error", slug: projectSlug, message: `repo_path is not a directory: ${project.repo_path}` });
1586
+ } else {
1587
+ const manifestPath = join8(project.repo_path, ".project.json");
1588
+ if (!existsSync6(manifestPath)) issues.push({ level: "warn", slug: projectSlug, message: ".project.json is missing" });
1589
+ }
1590
+ for (const artifact of project.source_artifacts) {
1591
+ if (artifact.path && !existsSync6(artifact.path)) {
1592
+ issues.push({ level: "warn", slug: projectSlug, message: `source artifact missing: ${artifact.path}` });
1593
+ }
1594
+ }
1595
+ }
1596
+ return {
1597
+ ok: !issues.some((issue) => issue.level === "error"),
1598
+ registryPath: registryPath2,
1599
+ checkedProjects: projects.map(([projectSlug]) => projectSlug),
1600
+ issues
1601
+ };
1602
+ }
1603
+ function buildCommonProjectCopierAction(input) {
1604
+ const templateDir = join8(input.pjanglerRoot, "templates", "commonproject");
1605
+ const data = {
1606
+ project_name: input.projectName,
1607
+ project_description: input.projectDescription ?? "",
1608
+ project_slug: input.projectSlug,
1609
+ ticket_provider: input.ticketProvider,
1610
+ plane_workspace: input.planeWorkspace,
1611
+ plane_project_id: input.planeProjectId ?? "",
1612
+ ticket_workspace: input.ticketWorkspace ?? input.planeWorkspace,
1613
+ board_id: input.boardId ?? input.planeProjectId ?? "",
1614
+ board_url: input.boardUrl ?? "",
1615
+ project_identifier: input.projectIdentifier,
1616
+ primary_language: input.primaryLanguage,
1617
+ agent_hooks_layer: input.agentHooksLayer ?? true ? "true" : "false"
1618
+ };
1619
+ const command = ["copier", "copy", "--trust", templateDir, input.targetDir, "--defaults"];
1620
+ for (const [key, value] of Object.entries(data)) command.push("--data", `${key}=${value}`);
1621
+ if (input.overwrite) command.push("--overwrite");
1622
+ return {
1623
+ kind: "copier.copy.commonproject",
1624
+ cwd: input.pjanglerRoot,
1625
+ command,
1626
+ targetDir: input.targetDir,
1627
+ data,
1628
+ overwrite: input.overwrite
1629
+ };
1630
+ }
1631
+ function resolvePjanglerRoot() {
1632
+ let dir = dirname4(new URL(import.meta.url).pathname);
1633
+ while (dir !== dirname4(dir)) {
1634
+ if (existsSync6(join8(dir, "package.json")) && existsSync6(join8(dir, "templates", "commonproject", "copier.yml"))) return dir;
1635
+ dir = dirname4(dir);
1636
+ }
1637
+ return resolve(process.cwd());
1638
+ }
1639
+ function validateNoDuplicateProject(registry, project, overwrite) {
1640
+ const existingSameSlug = registry.projects[project.slug];
1641
+ if (existingSameSlug && !overwrite && resolve(existingSameSlug.repo_path) !== resolve(project.repo_path)) {
1642
+ throw new Error(`Project slug already exists in registry: ${project.slug}`);
1643
+ }
1644
+ for (const [slug, existing] of Object.entries(registry.projects)) {
1645
+ if (slug === project.slug) continue;
1646
+ if (resolve(existing.repo_path) === resolve(project.repo_path)) {
1647
+ throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
1648
+ }
1649
+ if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
1650
+ throw new Error(`Project identifier already registered by ${slug}: ${project.ticket_provider.identifier}`);
1651
+ }
1652
+ }
1653
+ }
1654
+ function validateProjectRecord(project, key) {
1655
+ if (!isRecord(project)) throw new Error(`Project ${key} must be a mapping`);
1656
+ if (!project.name) throw new Error(`Project ${key} missing name`);
1657
+ if (!project.slug) throw new Error(`Project ${key} missing slug`);
1658
+ if (project.slug !== key) throw new Error(`Project key ${key} does not match slug ${project.slug}`);
1659
+ if (!project.repo_path) throw new Error(`Project ${key} missing repo_path`);
1660
+ if (!Array.isArray(project.source_artifacts)) throw new Error(`Project ${key} source_artifacts must be a list`);
1661
+ if (!isRecord(project.ticket_provider)) throw new Error(`Project ${key} ticket_provider must be a mapping`);
1662
+ if (!isRecord(project.agents)) throw new Error(`Project ${key} agents must be a mapping`);
1663
+ }
1664
+ function expandHome(path) {
1665
+ if (path === "~") return homedir3();
1666
+ if (path.startsWith("~/")) return join8(homedir3(), path.slice(2));
1667
+ return path;
1668
+ }
1669
+ function isRecord(value) {
1670
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1671
+ }
1672
+
1673
+ // src/commands/AgentHooksCommands.ts
1674
+ var AGENT_HOOKS_SKIP_MESSAGE = "\u21B7 agent-hooks layer skipped: global ~/.agents/hooks detected (these hooks already run globally).\n Set PJ_AGENT_HOOKS_LAYER=1 to install the project-scoped layer anyway.";
1675
+ function resolveTemplateRoot() {
1676
+ const candidates = [];
1677
+ if (process.env.PJANGLER_COMMONPROJECT_TEMPLATE) {
1678
+ candidates.push(process.env.PJANGLER_COMMONPROJECT_TEMPLATE);
1679
+ }
1680
+ try {
1681
+ let dir = dirname5(fileURLToPath2(import.meta.url));
1682
+ for (let i = 0; i < 8; i++) {
1683
+ candidates.push(join9(dir, "templates", "commonproject", "template"));
1684
+ const parent = dirname5(dir);
1685
+ if (parent === dir) break;
1686
+ dir = parent;
1687
+ }
1688
+ } catch {
1689
+ }
1690
+ candidates.push(join9(homedir4(), "code", "pjangler", "templates", "commonproject", "template"));
1691
+ for (const c of candidates) {
1692
+ if (existsSync7(join9(c, ".agents", "hooks", "hooks.master.json"))) return c;
1693
+ }
1694
+ throw new Error(
1695
+ "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
1696
+ );
1697
+ }
1698
+ var CopyAgentHooksTree = class extends Command {
1699
+ async invoke() {
1700
+ if (!resolveAgentHooksLayer()) {
1701
+ return { success: true, message: this.formatMessage(AGENT_HOOKS_SKIP_MESSAGE) };
1702
+ }
1703
+ let templateRoot;
1704
+ try {
1705
+ templateRoot = resolveTemplateRoot();
1706
+ } catch (e) {
1707
+ return { success: false, message: `\u26A0\uFE0F ${e.message}` };
1708
+ }
1709
+ const items = [
1710
+ { rel: ".agents/hooks", dir: true },
1711
+ { rel: ".agents/local.example.json", dir: false },
1712
+ { rel: ".mise/scripts/link-project-skills-to-clis.sh", dir: false },
1713
+ { rel: ".mise/scripts/unlink-project-skills-from-clis.sh", dir: false },
1714
+ { rel: ".mise/scripts/hindsight-setup.sh", dir: false }
1715
+ ];
1716
+ const created = [];
1717
+ const skipped = [];
1718
+ for (const { rel, dir } of items) {
1719
+ const src = join9(templateRoot, rel);
1720
+ const dest = join9(this.context.targetDir, rel);
1721
+ if (!existsSync7(src)) continue;
1722
+ if (existsSync7(dest) && !this.context.force) {
1723
+ skipped.push(rel);
1724
+ continue;
1725
+ }
1726
+ if (!this.context.dryRun) {
1727
+ mkdirSync5(dirname5(dest), { recursive: true });
1728
+ cpSync(src, dest, { recursive: dir, force: true });
1729
+ }
1730
+ created.push(rel);
1731
+ }
1732
+ const verb = this.context.dryRun ? "Would copy" : "Copied";
1733
+ const tail = skipped.length ? ` (${skipped.length} already present \u2014 use --force to overwrite)` : "";
1734
+ return {
1735
+ success: created.length > 0,
1736
+ message: this.formatMessage(`\u2705 ${verb} ${created.length} agent-hooks path(s)${tail}`)
1737
+ };
1268
1738
  }
1269
1739
  };
1270
1740
  var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
@@ -1272,14 +1742,17 @@ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
1272
1742
  static CR = "{{config_root}}";
1273
1743
  // mise's own runtime var — emitted literally
1274
1744
  async invoke() {
1275
- const misePath = join8(this.context.targetDir, "mise.toml");
1276
- if (!existsSync6(misePath)) {
1745
+ if (!resolveAgentHooksLayer()) {
1746
+ return { success: true, message: this.formatMessage(AGENT_HOOKS_SKIP_MESSAGE) };
1747
+ }
1748
+ const misePath = join9(this.context.targetDir, "mise.toml");
1749
+ if (!existsSync7(misePath)) {
1277
1750
  return {
1278
1751
  success: false,
1279
1752
  message: "\u26A0\uFE0F No mise.toml found \u2014 run `pjangler init mise` first, then re-run."
1280
1753
  };
1281
1754
  }
1282
- let content = readFileSync2(misePath, "utf8");
1755
+ let content = readFileSync3(misePath, "utf8");
1283
1756
  if (content.includes(_WireMiseAgentHooks.MARKER)) {
1284
1757
  return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for agent-hooks") };
1285
1758
  }
@@ -1355,7 +1828,7 @@ ${leaveBlock}`);
1355
1828
  ""
1356
1829
  ].join("\n");
1357
1830
  content = content.replace(/\n*$/, "\n") + appended;
1358
- if (!this.context.dryRun) writeFileSync3(misePath, content);
1831
+ if (!this.context.dryRun) writeFileSync4(misePath, content);
1359
1832
  if (wiredHooks) {
1360
1833
  return { success: true, message: this.formatMessage("\u2705 Wired mise.toml ([hooks] enter/leave + tasks)") };
1361
1834
  }
@@ -1528,11 +2001,11 @@ function createRecipe(name, context) {
1528
2001
  import { cancel as cancel2, multiselect, text as text2, isCancel as isCancel5 } from "@clack/prompts";
1529
2002
 
1530
2003
  // src/parity/index.ts
1531
- 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";
1532
- import { basename as basename2, dirname as dirname5, join as join9, relative, resolve } from "node:path";
2004
+ import { existsSync as existsSync8, lstatSync, mkdirSync as mkdirSync6, readFileSync as readFileSync4, readlinkSync, readdirSync, renameSync as renameSync2, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync5, chmodSync as chmodSync2, copyFileSync } from "node:fs";
2005
+ import { basename as basename3, dirname as dirname6, join as join10, relative, resolve as resolve2 } from "node:path";
1533
2006
  import { fileURLToPath as fileURLToPath3 } from "node:url";
1534
- import { homedir as homedir4 } from "node:os";
1535
- import { spawnSync as spawnSync4 } from "node:child_process";
2007
+ import { homedir as homedir5 } from "node:os";
2008
+ import { spawnSync as spawnSync5 } from "node:child_process";
1536
2009
  var LINK_AGENTFILES_BLOCK = `# This block will handle the linking of
1537
2010
  # agent files to the main AGENTS.md file.
1538
2011
  #
@@ -1600,13 +2073,13 @@ run = "{{config_root}}/.mise/scripts/versioning.sh check"
1600
2073
  description = "Force every versioned file up to the highest version"
1601
2074
  run = "{{config_root}}/.mise/scripts/versioning.sh sync"
1602
2075
  # <<< mise-versioning <<<`;
1603
- function resolvePjanglerRoot() {
1604
- let dir = dirname5(fileURLToPath3(import.meta.url));
1605
- while (dir !== dirname5(dir)) {
1606
- if (existsSync7(join9(dir, "package.json")) && existsSync7(join9(dir, "templates", "commonproject", "copier.yml"))) {
2076
+ function resolvePjanglerRoot2() {
2077
+ let dir = dirname6(fileURLToPath3(import.meta.url));
2078
+ while (dir !== dirname6(dir)) {
2079
+ if (existsSync8(join10(dir, "package.json")) && existsSync8(join10(dir, "templates", "commonproject", "copier.yml"))) {
1607
2080
  return dir;
1608
2081
  }
1609
- dir = dirname5(dir);
2082
+ dir = dirname6(dir);
1610
2083
  }
1611
2084
  throw new Error("Unable to resolve pjangler root");
1612
2085
  }
@@ -1614,17 +2087,17 @@ function normalizeNewlines(value) {
1614
2087
  return value.replace(/\r\n/g, "\n");
1615
2088
  }
1616
2089
  function readText(path) {
1617
- return normalizeNewlines(readFileSync3(path, "utf8"));
2090
+ return normalizeNewlines(readFileSync4(path, "utf8"));
1618
2091
  }
1619
2092
  function safeReadText(path) {
1620
- return existsSync7(path) ? readText(path) : null;
2093
+ return existsSync8(path) ? readText(path) : null;
1621
2094
  }
1622
2095
  function ensureParent(path) {
1623
- mkdirSync5(dirname5(path), { recursive: true });
2096
+ mkdirSync6(dirname6(path), { recursive: true });
1624
2097
  }
1625
2098
  function writeText(path, content) {
1626
2099
  ensureParent(path);
1627
- writeFileSync4(path, content);
2100
+ writeFileSync5(path, content);
1628
2101
  }
1629
2102
  function tryParseJson(text3) {
1630
2103
  if (!text3) return null;
@@ -1641,7 +2114,7 @@ function titleCaseSlug(slug) {
1641
2114
  return slug.split(/[-_]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
1642
2115
  }
1643
2116
  function readSymlinkTarget(path) {
1644
- if (!existsSync7(path)) return null;
2117
+ if (!existsSync8(path)) return null;
1645
2118
  try {
1646
2119
  return readlinkSync(path);
1647
2120
  } catch {
@@ -1649,7 +2122,7 @@ function readSymlinkTarget(path) {
1649
2122
  }
1650
2123
  }
1651
2124
  function ensureSymlink(path, target, dryRun) {
1652
- if (existsSync7(path)) {
2125
+ if (existsSync8(path)) {
1653
2126
  const stat = lstatSync(path);
1654
2127
  if (stat.isSymbolicLink()) {
1655
2128
  const current = readSymlinkTarget(path);
@@ -1666,21 +2139,21 @@ function ensureSymlink(path, target, dryRun) {
1666
2139
  return { changed: true };
1667
2140
  }
1668
2141
  function bootstrapAgentsFile(repoRoot, dryRun) {
1669
- const agentsPath = join9(repoRoot, "AGENTS.md");
1670
- if (existsSync7(agentsPath)) return { changedFiles: [], details: [] };
2142
+ const agentsPath = join10(repoRoot, "AGENTS.md");
2143
+ if (existsSync8(agentsPath)) return { changedFiles: [], details: [] };
1671
2144
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
1672
- const source = join9(repoRoot, file);
1673
- if (!existsSync7(source)) continue;
2145
+ const source = join10(repoRoot, file);
2146
+ if (!existsSync8(source)) continue;
1674
2147
  const stat = lstatSync(source);
1675
2148
  if (stat.isSymbolicLink()) continue;
1676
2149
  if (stat.isFile()) {
1677
- if (!dryRun) renameSync(source, agentsPath);
2150
+ if (!dryRun) renameSync2(source, agentsPath);
1678
2151
  return { changedFiles: [agentsPath], details: [`Moved ${file} to AGENTS.md before wiring agent-file symlinks`] };
1679
2152
  }
1680
2153
  return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
1681
2154
  }
1682
- const readmePath = join9(repoRoot, "README.md");
1683
- if (existsSync7(readmePath)) {
2155
+ const readmePath = join10(repoRoot, "README.md");
2156
+ if (existsSync8(readmePath)) {
1684
2157
  const stat = lstatSync(readmePath);
1685
2158
  if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
1686
2159
  if (!dryRun) copyFileSync(readmePath, agentsPath);
@@ -1719,12 +2192,12 @@ function yamlGet(text3, keyPath) {
1719
2192
  return "";
1720
2193
  }
1721
2194
  function discoverRoles(repoRoot) {
1722
- const rolesDir = join9(repoRoot, "agents", "hermes");
1723
- if (!existsSync7(rolesDir)) return [];
2195
+ const rolesDir = join10(repoRoot, "agents", "hermes");
2196
+ if (!existsSync8(rolesDir)) return [];
1724
2197
  return readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
1725
- const roleDir = join9(rolesDir, entry.name);
1726
- const roleYamlPath = join9(roleDir, "role.yaml");
1727
- if (!existsSync7(roleYamlPath)) return null;
2198
+ const roleDir = join10(rolesDir, entry.name);
2199
+ const roleYamlPath = join10(roleDir, "role.yaml");
2200
+ if (!existsSync8(roleYamlPath)) return null;
1728
2201
  const text3 = readText(roleYamlPath);
1729
2202
  const runtimeRepoRaw = yamlGet(text3, "runtime.github_repo");
1730
2203
  return {
@@ -1748,10 +2221,10 @@ function discoverRoles(repoRoot) {
1748
2221
  }).filter((value) => Boolean(value));
1749
2222
  }
1750
2223
  function registryPath(homeDir) {
1751
- return join9(homeDir, ".hermes", "agents-registry.yaml");
2224
+ return join10(homeDir, ".hermes", "agents-registry.yaml");
1752
2225
  }
1753
2226
  function systemctlUser(args) {
1754
- const result = spawnSync4("systemctl", ["--user", ...args], { encoding: "utf8" });
2227
+ const result = spawnSync5("systemctl", ["--user", ...args], { encoding: "utf8" });
1755
2228
  return {
1756
2229
  ok: result.status === 0,
1757
2230
  stdout: result.stdout.trim(),
@@ -1759,8 +2232,8 @@ function systemctlUser(args) {
1759
2232
  };
1760
2233
  }
1761
2234
  function templateScript(ctx, name) {
1762
- const source = join9(ctx.pjanglerRoot, ".mise", "scripts", name);
1763
- return existsSync7(source) ? readText(source) : void 0;
2235
+ const source = join10(ctx.pjanglerRoot, ".mise", "scripts", name);
2236
+ return existsSync8(source) ? readText(source) : void 0;
1764
2237
  }
1765
2238
  function templateVersioningScript(ctx) {
1766
2239
  return templateScript(ctx, "versioning.sh");
@@ -1770,14 +2243,14 @@ function templateLinkAgentfilesScript(ctx) {
1770
2243
  }
1771
2244
  function renderGeneratedProjectMiseToml(ctx, template) {
1772
2245
  const project = readProjectJson(ctx);
1773
- const projectName = String(project?.project_name ?? basename2(ctx.repoRoot) ?? "project");
2246
+ const projectName = String(project?.project_name ?? basename3(ctx.repoRoot) ?? "project");
1774
2247
  return template.replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
1775
2248
  }
1776
2249
  function ensureMiseTomlFromTemplate(ctx, changedFiles) {
1777
- const targetPath = join9(ctx.repoRoot, "mise.toml");
1778
- if (existsSync7(targetPath)) return false;
1779
- const sourcePath = join9(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
1780
- if (!existsSync7(sourcePath)) return false;
2250
+ const targetPath = join10(ctx.repoRoot, "mise.toml");
2251
+ if (existsSync8(targetPath)) return false;
2252
+ const sourcePath = join10(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
2253
+ if (!existsSync8(sourcePath)) return false;
1781
2254
  changedFiles.push(targetPath);
1782
2255
  if (!ctx.dryRun) {
1783
2256
  writeText(targetPath, renderGeneratedProjectMiseToml(ctx, readText(sourcePath)));
@@ -1785,8 +2258,8 @@ function ensureMiseTomlFromTemplate(ctx, changedFiles) {
1785
2258
  return true;
1786
2259
  }
1787
2260
  function templateVersionFilesConf(ctx, repoRoot) {
1788
- const packageJson = join9(repoRoot, "package.json");
1789
- 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";
2261
+ const packageJson = join10(repoRoot, "package.json");
2262
+ return existsSync8(packageJson) ? "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\njson package.json\ngittag .\n" : "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\ngittag .\n";
1790
2263
  }
1791
2264
  function replaceOrAppendManagedBlock(text3, startMarker, block, beforePattern) {
1792
2265
  if (startMarker.test(text3)) {
@@ -1810,7 +2283,7 @@ var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/herm
1810
2283
  function requiredMisePathEntries(ctx) {
1811
2284
  const required = [...BASE_MISE_PATH_ENTRIES];
1812
2285
  for (const candidate of CONDITIONAL_HERMES_PATHS) {
1813
- if (existsSync7(join9(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
2286
+ if (existsSync8(join10(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
1814
2287
  }
1815
2288
  return required;
1816
2289
  }
@@ -1959,12 +2432,12 @@ function upsertLinkAgentfilesBlock(text3, ctx) {
1959
2432
  return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
1960
2433
  }
1961
2434
  function readProjectJson(ctx) {
1962
- return tryParseJson(safeReadText(join9(ctx.repoRoot, ".project.json")));
2435
+ return tryParseJson(safeReadText(join10(ctx.repoRoot, ".project.json")));
1963
2436
  }
1964
2437
  function canonicalProjectJson(ctx) {
1965
2438
  const roles = discoverRoles(ctx.repoRoot);
1966
2439
  const existing = readProjectJson(ctx) ?? {};
1967
- const slug = String(existing.project_slug ?? slugifyRepoName(dirname5(ctx.repoRoot) === ctx.repoRoot ? ctx.repoRoot.split("/").pop() ?? "project" : ctx.repoRoot.split("/").pop() ?? "project"));
2440
+ const slug = String(existing.project_slug ?? slugifyRepoName(dirname6(ctx.repoRoot) === ctx.repoRoot ? ctx.repoRoot.split("/").pop() ?? "project" : ctx.repoRoot.split("/").pop() ?? "project"));
1968
2441
  const firstRole = roles[0];
1969
2442
  const ticketProvider = {
1970
2443
  type: String((existing.ticket_provider?.type ?? firstRole?.ticketProviderName ?? "plane") || "plane"),
@@ -2003,12 +2476,12 @@ function canonicalProjectJson(ctx) {
2003
2476
  };
2004
2477
  }
2005
2478
  function projectJsonFinding(ctx) {
2006
- const projectPath = join9(ctx.repoRoot, ".project.json");
2007
- const planeJsonPath = join9(ctx.repoRoot, ".plane.json");
2479
+ const projectPath = join10(ctx.repoRoot, ".project.json");
2480
+ const planeJsonPath = join10(ctx.repoRoot, ".plane.json");
2008
2481
  const details = [];
2009
2482
  const data = readProjectJson(ctx);
2010
2483
  const roles = discoverRoles(ctx.repoRoot);
2011
- if (!existsSync7(projectPath)) {
2484
+ if (!existsSync8(projectPath)) {
2012
2485
  return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json missing", details: [], fixable: true };
2013
2486
  }
2014
2487
  if (!data) {
@@ -2034,7 +2507,7 @@ function projectJsonFinding(ctx) {
2034
2507
  for (const key of ["type", "workspace", "identifier", "board_id", "board_url", "state"]) {
2035
2508
  if (!(key in ticketProvider)) details.push(`ticket_provider.${key} missing`);
2036
2509
  }
2037
- if (existsSync7(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
2510
+ if (existsSync8(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
2038
2511
  return {
2039
2512
  id: "sot.project-json",
2040
2513
  title: "Canonical .project.json",
@@ -2115,17 +2588,17 @@ exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" HERMES_OAUTH
2115
2588
  `.replace(/\u0010/g, "$");
2116
2589
  }
2117
2590
  function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip) {
2118
- if (!existsSync7(sourceDir)) return;
2119
- mkdirSync5(targetDir, { recursive: true });
2591
+ if (!existsSync8(sourceDir)) return;
2592
+ mkdirSync6(targetDir, { recursive: true });
2120
2593
  for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
2121
- const sourcePath = join9(sourceDir, entry.name);
2594
+ const sourcePath = join10(sourceDir, entry.name);
2122
2595
  if (skip?.(sourcePath)) continue;
2123
- const targetPath = join9(targetDir, entry.name);
2596
+ const targetPath = join10(targetDir, entry.name);
2124
2597
  if (entry.isDirectory()) {
2125
2598
  copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
2126
2599
  continue;
2127
2600
  }
2128
- if (existsSync7(targetPath)) continue;
2601
+ if (existsSync8(targetPath)) continue;
2129
2602
  changedFiles.push(targetPath);
2130
2603
  if (!dryRun) {
2131
2604
  ensureParent(targetPath);
@@ -2134,7 +2607,7 @@ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip)
2134
2607
  }
2135
2608
  }
2136
2609
  function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
2137
- const gitmodulesPath = join9(repoRoot, ".gitmodules");
2610
+ const gitmodulesPath = join10(repoRoot, ".gitmodules");
2138
2611
  const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
2139
2612
  const owner = role.runtimeOwner || "delorenj";
2140
2613
  const block = `[submodule "agents/hermes/${role.role}/runtime"]
@@ -2156,7 +2629,7 @@ function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
2156
2629
  repo: ${role.repo}
2157
2630
  role: ${role.role}
2158
2631
  display_name: ${JSON.stringify(role.displayName || role.agentId)}
2159
- project_path: ${ctxEscape(role.roleDir ? dirname5(dirname5(dirname5(role.roleDir))) : "")}
2632
+ project_path: ${ctxEscape(role.roleDir ? dirname6(dirname6(dirname6(role.roleDir))) : "")}
2160
2633
  role_dir: ${ctxEscape(role.roleDir)}
2161
2634
  profile_name: ${role.profileName || role.agentId}
2162
2635
  telegram:
@@ -2234,14 +2707,14 @@ var RULES = [
2234
2707
  id: "mise.config-root",
2235
2708
  title: "mise config_root + AGENTS link hooks",
2236
2709
  audit: (ctx) => {
2237
- const misePath = join9(ctx.repoRoot, "mise.toml");
2238
- if (!existsSync7(misePath)) {
2710
+ const misePath = join10(ctx.repoRoot, "mise.toml");
2711
+ if (!existsSync8(misePath)) {
2239
2712
  return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
2240
2713
  }
2241
2714
  const text3 = readText(misePath);
2242
2715
  const details = [];
2243
- const linkAgentfilesPath = join9(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2244
- if (!existsSync7(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2716
+ const linkAgentfilesPath = join10(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2717
+ if (!existsSync8(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2245
2718
  const pathValues = [...(text3.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2246
2719
  const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
2247
2720
  if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
@@ -2259,10 +2732,10 @@ var RULES = [
2259
2732
  };
2260
2733
  },
2261
2734
  migrate: (ctx, finding) => {
2262
- const path = join9(ctx.repoRoot, "mise.toml");
2735
+ const path = join10(ctx.repoRoot, "mise.toml");
2263
2736
  const changedFiles = [];
2264
2737
  const details = [];
2265
- if (!existsSync7(path)) {
2738
+ if (!existsSync8(path)) {
2266
2739
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2267
2740
  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: [] };
2268
2741
  }
@@ -2278,7 +2751,7 @@ var RULES = [
2278
2751
  if (!ctx.dryRun) writeText(path, next);
2279
2752
  text3 = next;
2280
2753
  }
2281
- const linkAgentfilesPath = join9(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2754
+ const linkAgentfilesPath = join10(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2282
2755
  const expectedScript = templateLinkAgentfilesScript(ctx);
2283
2756
  if (expectedScript === void 0) {
2284
2757
  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: [] };
@@ -2305,13 +2778,13 @@ var RULES = [
2305
2778
  title: "managed mise versioning block",
2306
2779
  audit: (ctx) => {
2307
2780
  const details = [];
2308
- const misePath = join9(ctx.repoRoot, "mise.toml");
2309
- const versioningPath = join9(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2310
- const manifestPath = join9(ctx.repoRoot, ".mise", "version-files.conf");
2781
+ const misePath = join10(ctx.repoRoot, "mise.toml");
2782
+ const versioningPath = join10(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2783
+ const manifestPath = join10(ctx.repoRoot, ".mise", "version-files.conf");
2311
2784
  const text3 = safeReadText(misePath);
2312
2785
  if (!text3?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
2313
- if (!existsSync7(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
2314
- if (!existsSync7(manifestPath)) details.push(".mise/version-files.conf missing");
2786
+ if (!existsSync8(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
2787
+ if (!existsSync8(manifestPath)) details.push(".mise/version-files.conf missing");
2315
2788
  return {
2316
2789
  id: "mise.versioning",
2317
2790
  title: "managed mise versioning block",
@@ -2324,8 +2797,8 @@ var RULES = [
2324
2797
  migrate: (ctx, finding) => {
2325
2798
  const changedFiles = [];
2326
2799
  const details = [];
2327
- const misePath = join9(ctx.repoRoot, "mise.toml");
2328
- if (!existsSync7(misePath)) {
2800
+ const misePath = join10(ctx.repoRoot, "mise.toml");
2801
+ if (!existsSync8(misePath)) {
2329
2802
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2330
2803
  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: [] };
2331
2804
  }
@@ -2340,7 +2813,7 @@ var RULES = [
2340
2813
  if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
2341
2814
  if (!ctx.dryRun) writeText(misePath, nextMise);
2342
2815
  }
2343
- const versioningPath = join9(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2816
+ const versioningPath = join10(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2344
2817
  const expectedScript = templateVersioningScript(ctx);
2345
2818
  if (expectedScript === void 0) {
2346
2819
  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: [] };
@@ -2352,7 +2825,7 @@ var RULES = [
2352
2825
  chmodSync2(versioningPath, 493);
2353
2826
  }
2354
2827
  }
2355
- const manifestPath = join9(ctx.repoRoot, ".mise", "version-files.conf");
2828
+ const manifestPath = join10(ctx.repoRoot, ".mise", "version-files.conf");
2356
2829
  const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
2357
2830
  if (safeReadText(manifestPath) !== expectedManifest) {
2358
2831
  changedFiles.push(manifestPath);
@@ -2372,9 +2845,9 @@ var RULES = [
2372
2845
  id: "sot.agent-symlinks",
2373
2846
  title: "AGENTS/CLAUDE/GEMINI symlink contract",
2374
2847
  audit: (ctx) => {
2375
- const agentsPath = join9(ctx.repoRoot, "AGENTS.md");
2376
- if (!existsSync7(agentsPath)) {
2377
- const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync7(join9(ctx.repoRoot, file)));
2848
+ const agentsPath = join10(ctx.repoRoot, "AGENTS.md");
2849
+ if (!existsSync8(agentsPath)) {
2850
+ const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync8(join10(ctx.repoRoot, file)));
2378
2851
  if (fallbackSources.length === 0) {
2379
2852
  return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
2380
2853
  }
@@ -2389,7 +2862,7 @@ var RULES = [
2389
2862
  }
2390
2863
  const details = [];
2391
2864
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2392
- const full = join9(ctx.repoRoot, file);
2865
+ const full = join10(ctx.repoRoot, file);
2393
2866
  const target = readSymlinkTarget(full);
2394
2867
  if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
2395
2868
  }
@@ -2413,7 +2886,7 @@ var RULES = [
2413
2886
  return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
2414
2887
  }
2415
2888
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2416
- const full = join9(ctx.repoRoot, file);
2889
+ const full = join10(ctx.repoRoot, file);
2417
2890
  const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
2418
2891
  if (result.blocked) blockedDetails.push(result.blocked);
2419
2892
  if (result.changed) changedFiles.push(full);
@@ -2435,7 +2908,7 @@ var RULES = [
2435
2908
  migrate: (ctx, finding) => {
2436
2909
  const changedFiles = [];
2437
2910
  const details = [];
2438
- const path = join9(ctx.repoRoot, ".project.json");
2911
+ const path = join10(ctx.repoRoot, ".project.json");
2439
2912
  const existing = readProjectJson(ctx) ?? {};
2440
2913
  const canonical = canonicalProjectJson(ctx);
2441
2914
  const merged = { ...existing, ...canonical };
@@ -2445,14 +2918,14 @@ var RULES = [
2445
2918
  changedFiles.push(path);
2446
2919
  if (!ctx.dryRun) writeText(path, expected);
2447
2920
  }
2448
- const planeJson = join9(ctx.repoRoot, ".plane.json");
2449
- if (existsSync7(planeJson)) {
2921
+ const planeJson = join10(ctx.repoRoot, ".plane.json");
2922
+ if (existsSync8(planeJson)) {
2450
2923
  const backup = `${planeJson}.migrated-backup`;
2451
- if (existsSync7(backup)) {
2924
+ if (existsSync8(backup)) {
2452
2925
  details.push(`cannot back up .plane.json because ${relative(ctx.repoRoot, backup)} already exists`);
2453
2926
  } else {
2454
2927
  changedFiles.push(backup);
2455
- if (!ctx.dryRun) renameSync(planeJson, backup);
2928
+ if (!ctx.dryRun) renameSync2(planeJson, backup);
2456
2929
  }
2457
2930
  }
2458
2931
  return {
@@ -2470,8 +2943,8 @@ var RULES = [
2470
2943
  title: ".env.op + gitignore secrets contract",
2471
2944
  audit: (ctx) => {
2472
2945
  const details = [];
2473
- const envOp = safeReadText(join9(ctx.repoRoot, ".env.op"));
2474
- const gitignore = safeReadText(join9(ctx.repoRoot, ".gitignore"));
2946
+ const envOp = safeReadText(join10(ctx.repoRoot, ".env.op"));
2947
+ const gitignore = safeReadText(join10(ctx.repoRoot, ".gitignore"));
2475
2948
  if (!envOp) {
2476
2949
  details.push(".env.op missing");
2477
2950
  } else {
@@ -2497,12 +2970,12 @@ var RULES = [
2497
2970
  migrate: (ctx, finding) => {
2498
2971
  const changedFiles = [];
2499
2972
  const details = [];
2500
- const envOpPath = join9(ctx.repoRoot, ".env.op");
2501
- if (!existsSync7(envOpPath)) {
2973
+ const envOpPath = join10(ctx.repoRoot, ".env.op");
2974
+ if (!existsSync8(envOpPath)) {
2502
2975
  changedFiles.push(envOpPath);
2503
- if (!ctx.dryRun) writeText(envOpPath, readText(join9(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2976
+ if (!ctx.dryRun) writeText(envOpPath, readText(join10(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2504
2977
  }
2505
- const gitignorePath = join9(ctx.repoRoot, ".gitignore");
2978
+ const gitignorePath = join10(ctx.repoRoot, ".gitignore");
2506
2979
  const gitignore = safeReadText(gitignorePath) ?? "";
2507
2980
  const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
2508
2981
  # NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
@@ -2529,7 +3002,7 @@ var RULES = [
2529
3002
  title: ".copier-answers.yml provenance + drift report",
2530
3003
  audit: (ctx) => {
2531
3004
  const details = [];
2532
- const path = join9(ctx.repoRoot, ".copier-answers.yml");
3005
+ const path = join10(ctx.repoRoot, ".copier-answers.yml");
2533
3006
  const text3 = safeReadText(path);
2534
3007
  const project = readProjectJson(ctx);
2535
3008
  if (!text3) {
@@ -2560,12 +3033,12 @@ var RULES = [
2560
3033
  const changedFiles = [];
2561
3034
  const project = canonicalProjectJson(ctx);
2562
3035
  const text3 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
2563
- _src_path: ${join9(ctx.pjanglerRoot, "templates", "commonproject")}
3036
+ _src_path: ${join10(ctx.pjanglerRoot, "templates", "commonproject")}
2564
3037
  project_description: ${String(project.project_description)}
2565
3038
  project_name: ${String(project.project_name)}
2566
3039
  ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2567
3040
  `;
2568
- const path = join9(ctx.repoRoot, ".copier-answers.yml");
3041
+ const path = join10(ctx.repoRoot, ".copier-answers.yml");
2569
3042
  if (safeReadText(path) !== text3) {
2570
3043
  changedFiles.push(path);
2571
3044
  if (!ctx.dryRun) writeText(path, text3);
@@ -2584,15 +3057,15 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2584
3057
  id: "bmad.scaffold",
2585
3058
  title: "BMAD modules/docs scaffold",
2586
3059
  audit: (ctx) => {
2587
- const sourceRoot = join9(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
2588
- const targetRoot = join9(ctx.repoRoot, "_bmad");
3060
+ const sourceRoot = join10(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
3061
+ const targetRoot = join10(ctx.repoRoot, "_bmad");
2589
3062
  const sentinels = [
2590
- join9("core", "config.yaml"),
2591
- join9("custom", "config.yaml"),
2592
- join9("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
2593
- join9("bmm", "workflows", "workflow-status", "workflow.yaml")
3063
+ join10("core", "config.yaml"),
3064
+ join10("custom", "config.yaml"),
3065
+ join10("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
3066
+ join10("bmm", "workflows", "workflow-status", "workflow.yaml")
2594
3067
  ];
2595
- const missing = sentinels.filter((file) => existsSync7(join9(sourceRoot, file)) && !existsSync7(join9(targetRoot, file)));
3068
+ const missing = sentinels.filter((file) => existsSync8(join10(sourceRoot, file)) && !existsSync8(join10(targetRoot, file)));
2596
3069
  return {
2597
3070
  id: "bmad.scaffold",
2598
3071
  title: "BMAD modules/docs scaffold",
@@ -2604,7 +3077,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2604
3077
  },
2605
3078
  migrate: (ctx, finding) => {
2606
3079
  const changedFiles = [];
2607
- copyMissingRecursive(join9(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join9(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
3080
+ copyMissingRecursive(join10(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join10(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
2608
3081
  return {
2609
3082
  id: finding.id,
2610
3083
  title: finding.title,
@@ -2626,11 +3099,11 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2626
3099
  }
2627
3100
  const details = [];
2628
3101
  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"]) {
2629
- if (!existsSync7(join9(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join9(role.roleDir, rel))}`);
3102
+ if (!existsSync8(join10(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join10(role.roleDir, rel))}`);
2630
3103
  }
2631
- const gitmodules = safeReadText(join9(ctx.repoRoot, ".gitmodules")) ?? "";
3104
+ const gitmodules = safeReadText(join10(ctx.repoRoot, ".gitmodules")) ?? "";
2632
3105
  if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing pm runtime submodule entry");
2633
- if (!profileMetaInheritsDefault(join9(role.roleDir, "runtime", "profile.yaml"))) {
3106
+ if (!profileMetaInheritsDefault(join10(role.roleDir, "runtime", "profile.yaml"))) {
2634
3107
  details.push("runtime/profile.yaml missing inherited default config metadata");
2635
3108
  }
2636
3109
  const registry = safeReadText(registryPath(ctx.homeDir));
@@ -2651,21 +3124,21 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2651
3124
  if (!role) {
2652
3125
  return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
2653
3126
  }
2654
- const templateRoleDir = join9(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
2655
- writeIfDifferent(join9(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
2656
- writeIfDifferent(join9(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
2657
- writeIfDifferent(join9(role.roleDir, ".gitignore"), readText(join9(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
2658
- copyMissingRecursive(join9(templateRoleDir, ".runtime-scaffold"), join9(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
2659
- copyMissingRecursive(join9(templateRoleDir, ".runtime-scaffold"), join9(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
2660
- copyMissingRecursive(join9(templateRoleDir, ".scripts"), join9(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
2661
- const promptSource = join9(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
2662
- const promptTarget = join9(role.roleDir, ".scripts", "sentinel.prompt.md");
2663
- if (existsSync7(promptSource) && !existsSync7(promptTarget)) {
3127
+ const templateRoleDir = join10(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
3128
+ writeIfDifferent(join10(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
3129
+ writeIfDifferent(join10(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
3130
+ writeIfDifferent(join10(role.roleDir, ".gitignore"), readText(join10(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
3131
+ copyMissingRecursive(join10(templateRoleDir, ".runtime-scaffold"), join10(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
3132
+ copyMissingRecursive(join10(templateRoleDir, ".runtime-scaffold"), join10(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
3133
+ copyMissingRecursive(join10(templateRoleDir, ".scripts"), join10(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
3134
+ const promptSource = join10(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
3135
+ const promptTarget = join10(role.roleDir, ".scripts", "sentinel.prompt.md");
3136
+ if (existsSync8(promptSource) && !existsSync8(promptTarget)) {
2664
3137
  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);
2665
3138
  writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
2666
3139
  }
2667
3140
  upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
2668
- const profileMetaUpdated = upsertInheritedProfileMeta(join9(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
3141
+ const profileMetaUpdated = upsertInheritedProfileMeta(join10(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
2669
3142
  if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
2670
3143
  const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
2671
3144
  if (registryUpdated) details.push(`updated ${registryUpdated}`);
@@ -2719,9 +3192,9 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2719
3192
  return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
2720
3193
  }
2721
3194
  for (const role of roles) {
2722
- const sysDir = join9(ctx.homeDir, ".config", "systemd", "user");
3195
+ const sysDir = join10(ctx.homeDir, ".config", "systemd", "user");
2723
3196
  const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
2724
- const allUnitsPresent = units.every((unit) => existsSync7(join9(sysDir, unit)));
3197
+ const allUnitsPresent = units.every((unit) => existsSync8(join10(sysDir, unit)));
2725
3198
  if (allUnitsPresent) {
2726
3199
  if (ctx.dryRun) {
2727
3200
  details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
@@ -2733,561 +3206,143 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2733
3206
  }
2734
3207
  continue;
2735
3208
  }
2736
- for (const script of [join9(role.roleDir, ".scripts", "70-systemd.sh")]) {
2737
- if (!script || !existsSync7(script)) continue;
3209
+ for (const script of [join10(role.roleDir, ".scripts", "70-systemd.sh")]) {
3210
+ if (!script || !existsSync8(script)) continue;
2738
3211
  if (ctx.dryRun) {
2739
- details.push(`would run: bash ${script}`);
2740
- } else {
2741
- const result = spawnSync4("bash", [script], { cwd: role.roleDir, encoding: "utf8" });
2742
- if (result.status !== 0) details.push(`script failed: ${script}: ${result.stderr.trim() || result.stdout.trim()}`);
2743
- }
2744
- }
2745
- }
2746
- return {
2747
- id: finding.id,
2748
- title: finding.title,
2749
- status: details.some((detail) => detail.includes("failed:")) ? "blocked" : details.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
2750
- summary: details.length ? ctx.dryRun ? "Planned systemd remediation commands" : "Attempted systemd remediation" : "No changes required",
2751
- changedFiles,
2752
- details
2753
- };
2754
- }
2755
- }
2756
- ];
2757
- function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
2758
- const normalized = content.endsWith("\n") ? content : `${content}
2759
- `;
2760
- if (safeReadText(path) === normalized) return;
2761
- changedFiles.push(path);
2762
- if (!dryRun) {
2763
- writeText(path, normalized);
2764
- if (mode) chmodSync2(path, mode);
2765
- }
2766
- }
2767
- function getParityRuleIds() {
2768
- return RULES.map((rule) => rule.id);
2769
- }
2770
- function runAudit(repoArg) {
2771
- const pjanglerRoot = resolvePjanglerRoot();
2772
- const ctx = {
2773
- repoRoot: resolve(repoArg ?? process.cwd()),
2774
- dryRun: true,
2775
- pjanglerRoot,
2776
- homeDir: homedir4()
2777
- };
2778
- const rules = RULES.map((rule) => rule.audit(ctx));
2779
- return {
2780
- repo: ctx.repoRoot,
2781
- ok: rules.every((rule) => rule.status === "pass" || rule.status === "skip"),
2782
- auditedAt: (/* @__PURE__ */ new Date()).toISOString(),
2783
- rules
2784
- };
2785
- }
2786
- function runMigrationForRules(ruleIds, repoArg, dryRun) {
2787
- const pjanglerRoot = resolvePjanglerRoot();
2788
- const ctx = {
2789
- repoRoot: resolve(repoArg ?? process.cwd()),
2790
- dryRun,
2791
- pjanglerRoot,
2792
- homeDir: homedir4()
2793
- };
2794
- const selected = RULES.filter((rule) => ruleIds.includes(rule.id));
2795
- if (!selected.length) {
2796
- throw new Error(`Unknown parity rules: ${ruleIds.join(", ")}`);
2797
- }
2798
- const results = selected.map((rule) => {
2799
- try {
2800
- return rule.migrate(ctx, rule.audit(ctx));
2801
- } catch (err) {
2802
- return {
2803
- id: rule.id,
2804
- title: rule.title,
2805
- status: "blocked",
2806
- summary: `migrate threw: ${err instanceof Error ? err.message : String(err)}`,
2807
- changedFiles: [],
2808
- details: []
2809
- };
2810
- }
2811
- });
2812
- const changedFiles = Array.from(new Set(results.flatMap((result) => result.changedFiles))).sort();
2813
- return {
2814
- repo: ctx.repoRoot,
2815
- dryRun,
2816
- ok: results.every((result) => result.status !== "blocked"),
2817
- selectedRules: selected.map((rule) => rule.id),
2818
- results,
2819
- changedFiles
2820
- };
2821
- }
2822
- function runMigration(selector, repoArg, dryRun, all) {
2823
- const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
2824
- return runMigrationForRules(ruleIds, repoArg, dryRun);
2825
- }
2826
- function prettyTimestamp(iso) {
2827
- const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/.exec(iso);
2828
- return match ? `${match[1]} ${match[2]} UTC` : iso;
2829
- }
2830
- function formatAuditReport(report) {
2831
- const counts = {};
2832
- for (const rule of report.rules) counts[rule.status] = (counts[rule.status] ?? 0) + 1;
2833
- const idWidth = report.rules.reduce((width, rule) => Math.max(width, rule.id.length), 0);
2834
- const tally = [];
2835
- if (counts.pass) tally.push(green(`${counts.pass} passed`));
2836
- if (counts.fail) tally.push(red(`${counts.fail} failed`));
2837
- if (counts.warn) tally.push(yellow(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`));
2838
- if (counts.skip) tally.push(gray(`${counts.skip} skipped`));
2839
- const overall = report.ok ? `${green(glyph.pass)} ${bold("Parity audit passed")}` : `${red(glyph.fail)} ${bold("Parity audit failed")}`;
2840
- const lines = [""];
2841
- lines.push(` ${overall}${tally.length ? ` ${dim(glyph.dot)} ${joinDot(tally)}` : ""}`);
2842
- lines.push(` ${dim(report.repo)} ${dim(glyph.dot)} ${dim(prettyTimestamp(report.auditedAt))}`);
2843
- lines.push("");
2844
- for (const rule of report.rules) {
2845
- const style = statusStyle(rule.status);
2846
- lines.push(` ${style.color(style.glyph)} ${style.color(rule.id.padEnd(idWidth))} ${rule.summary}`);
2847
- for (const detail of rule.details) lines.push(` ${dim(glyph.arrow)} ${dim(detail)}`);
2848
- }
2849
- lines.push("");
2850
- return lines.join("\n");
2851
- }
2852
- function formatMigrationReport(report) {
2853
- const idWidth = report.results.reduce((width, result) => Math.max(width, result.id.length), 0);
2854
- const overall = report.ok ? `${green(glyph.pass)} ${bold(report.dryRun ? "Migration preview complete" : "Migration complete")}` : `${red(glyph.fail)} ${bold("Migration finished with blockers")}`;
2855
- const lines = [""];
2856
- lines.push(` ${overall}${report.dryRun ? ` ${dim(glyph.dot)} ${yellow("dry run")}` : ""}`);
2857
- lines.push(` ${dim(report.repo)}`);
2858
- if (report.selectedRules.length) lines.push(` ${dim(`rules: ${report.selectedRules.join(", ")}`)}`);
2859
- lines.push("");
2860
- for (const result of report.results) {
2861
- const style = statusStyle(result.status);
2862
- lines.push(` ${style.color(style.glyph)} ${style.color(result.id.padEnd(idWidth))} ${result.summary} ${dim(`[${style.label}]`)}`);
2863
- for (const detail of result.details) lines.push(` ${dim(glyph.arrow)} ${dim(detail)}`);
2864
- for (const file of result.changedFiles) lines.push(` ${green(glyph.add)} ${file}`);
2865
- }
2866
- if (report.changedFiles.length) {
2867
- lines.push("");
2868
- lines.push(` ${bold(`Changed files (${report.changedFiles.length})`)}`);
2869
- for (const file of report.changedFiles) lines.push(` ${green(glyph.add)} ${file}`);
2870
- }
2871
- lines.push("");
2872
- return lines.join("\n");
2873
- }
2874
-
2875
- // src/project/index.ts
2876
- import { spawnSync as spawnSync5 } from "node:child_process";
2877
- import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync4, renameSync as renameSync2, statSync, writeFileSync as writeFileSync5 } from "node:fs";
2878
- import { homedir as homedir5 } from "node:os";
2879
- import { basename as basename3, dirname as dirname6, join as join10, resolve as resolve2 } from "node:path";
2880
- import YAML from "yaml";
2881
- var PROJECT_REGISTRY_ENV = "PJ_PROJECT_REGISTRY";
2882
- var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
2883
- var KNOWN_SKILL_ROOTS = [
2884
- "/home/delorenj/code/skillex/all-skills",
2885
- "/home/delorenj/code/CoachingAgentFramework/.agents/skills",
2886
- "/home/delorenj/code/pjangler/.agents/skills",
2887
- join10(homedir5(), ".codex", "skills")
2888
- ];
2889
- function projectRegistryPath(env2 = process.env) {
2890
- return expandHome(env2[PROJECT_REGISTRY_ENV] || join10(homedir5(), ".config", "pjangler", "projects.yaml"));
2891
- }
2892
- function emptyProjectRegistry() {
2893
- return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
2894
- }
2895
- function loadProjectRegistry(path = projectRegistryPath()) {
2896
- if (!existsSync8(path)) return emptyProjectRegistry();
2897
- const raw = YAML.parse(readFileSync4(path, "utf8"));
2898
- if (raw == null) return emptyProjectRegistry();
2899
- if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
2900
- const registry = raw;
2901
- const normalized = {
2902
- schema_version: Number(registry.schema_version ?? PROJECT_REGISTRY_SCHEMA_VERSION),
2903
- projects: isRecord(registry.projects) ? registry.projects : {}
2904
- };
2905
- validateProjectRegistry(normalized);
2906
- return normalized;
2907
- }
2908
- function saveProjectRegistry(registry, path = projectRegistryPath()) {
2909
- validateProjectRegistry(registry);
2910
- mkdirSync6(dirname6(path), { recursive: true });
2911
- const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
2912
- writeFileSync5(temp, YAML.stringify(registry, { lineWidth: 0 }), "utf8");
2913
- renameSync2(temp, path);
2914
- }
2915
- function validateProjectRegistry(registry) {
2916
- if (registry.schema_version !== PROJECT_REGISTRY_SCHEMA_VERSION) {
2917
- throw new Error(`Unsupported project registry schema_version: ${registry.schema_version}`);
2918
- }
2919
- if (!isRecord(registry.projects)) throw new Error("Project registry projects must be a mapping");
2920
- const slugs = /* @__PURE__ */ new Set();
2921
- const repoPaths = /* @__PURE__ */ new Map();
2922
- const identifiers = /* @__PURE__ */ new Map();
2923
- for (const [slug, project] of Object.entries(registry.projects)) {
2924
- validateProjectRecord(project, slug);
2925
- if (slugs.has(project.slug)) throw new Error(`Duplicate project slug: ${project.slug}`);
2926
- slugs.add(project.slug);
2927
- const repoKey = resolve2(project.repo_path);
2928
- const existingRepoSlug = repoPaths.get(repoKey);
2929
- if (existingRepoSlug && existingRepoSlug !== slug) {
2930
- throw new Error(`Duplicate project repo_path: ${project.repo_path} used by ${existingRepoSlug} and ${slug}`);
2931
- }
2932
- repoPaths.set(repoKey, slug);
2933
- const identifier = project.ticket_provider.identifier?.toUpperCase();
2934
- if (identifier) {
2935
- const existingIdentifierSlug = identifiers.get(identifier);
2936
- if (existingIdentifierSlug && existingIdentifierSlug !== slug) {
2937
- throw new Error(`Duplicate project identifier: ${identifier} used by ${existingIdentifierSlug} and ${slug}`);
2938
- }
2939
- identifiers.set(identifier, slug);
2940
- }
2941
- }
2942
- }
2943
- function slugifyProjectName(value) {
2944
- return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
2945
- }
2946
- function deriveProjectIdentifier(value) {
2947
- const compact = value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
2948
- const identifier = compact.slice(0, 4) || "PROJ";
2949
- return identifier.length >= 2 ? identifier : `${identifier}XX`.slice(0, 4);
2950
- }
2951
- function normalizeAgentRole(value) {
2952
- return value?.trim() || "pm";
2953
- }
2954
- function jsonStable(value) {
2955
- return JSON.stringify(value);
2956
- }
2957
- function projectRecordEquivalent(a, b) {
2958
- if (!a) return false;
2959
- const { created_at: _aCreated, updated_at: _aUpdated, ...aComparable } = a;
2960
- const { created_at: _bCreated, updated_at: _bUpdated, ...bComparable } = b;
2961
- return jsonStable(aComparable) === jsonStable(bComparable);
2962
- }
2963
- function defaultProjectTargetDir(name, cwd = process.cwd()) {
2964
- const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
2965
- return resolve2(dirname6(resolve2(cwd)), compactName);
2966
- }
2967
- function resolveSourceSkillPath(sourceSkill) {
2968
- if (!sourceSkill) return void 0;
2969
- const expanded = expandHome(sourceSkill);
2970
- const direct = resolve2(expanded);
2971
- if (existsSync8(direct)) return direct;
2972
- const name = basename3(sourceSkill);
2973
- for (const root of KNOWN_SKILL_ROOTS) {
2974
- const candidate = join10(root, name);
2975
- if (existsSync8(candidate)) return candidate;
2976
- }
2977
- const civilWarLetterifier = "/home/delorenj/code/skillex/all-skills/civilwar-letterifier";
2978
- const hint = existsSync8(civilWarLetterifier) ? ` Did you mean ${civilWarLetterifier}?` : "";
2979
- throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
2980
- }
2981
- function planProjectInit(input) {
2982
- if (!input.name.trim()) throw new Error("Project name is required");
2983
- const registryPath2 = resolve2(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
2984
- const registry = loadProjectRegistry(registryPath2);
2985
- const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
2986
- const slug = input.projectSlug ?? slugifyProjectName(input.name);
2987
- const targetDir = resolve2(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
2988
- const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
2989
- const existing = registry.projects[slug];
2990
- const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
2991
- const overwrite = input.overwrite ?? input.force ?? false;
2992
- const agentRole = normalizeAgentRole(input.agentRole);
2993
- const agents = input.provisionAgent ? {
2994
- ...existing?.agents ?? {},
2995
- [agentRole]: {
2996
- role: agentRole,
2997
- provisioning_state: "planned"
2998
- }
2999
- } : existing?.agents ?? {};
3000
- const scaffold = input.scaffold ?? true;
3001
- const candidateProject = {
3002
- name: input.name,
3003
- slug,
3004
- repo_path: targetDir,
3005
- description: input.description ?? "",
3006
- status: "planned",
3007
- source_artifacts: sourceSkillPath ? [{ kind: "skill", path: sourceSkillPath, package_name: input.packageName ?? slug }] : [],
3008
- template: {
3009
- commonproject: {
3010
- enabled: true,
3011
- primary_language: input.primaryLanguage ?? "python"
3012
- }
3013
- },
3014
- ticket_provider: {
3015
- type: input.ticketProvider ?? "plane",
3016
- workspace: input.planeWorkspace ?? "33god",
3017
- identifier,
3018
- board_id: input.planeProjectId ?? "",
3019
- board_url: input.planeProjectId ? `https://plane.delo.sh/${input.planeWorkspace ?? "33god"}/projects/${input.planeProjectId}/issues/` : "",
3020
- state: input.live ? "planned" : "planned"
3021
- },
3022
- agents,
3023
- created_at: existing?.created_at ?? now,
3024
- updated_at: now
3025
- };
3026
- const project = {
3027
- ...candidateProject,
3028
- updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
3029
- };
3030
- validateNoDuplicateProject(registry, project, overwrite);
3031
- const pjanglerRoot = resolve2(input.pjanglerRoot ?? resolvePjanglerRoot2());
3032
- const manifest = projectManifestFromRegistryProject(project);
3033
- const apply = input.apply ?? false;
3034
- const live = input.live ?? false;
3035
- const actions = [
3036
- { kind: "registry.upsert", registryPath: registryPath2, slug, project }
3037
- ];
3038
- if (scaffold) {
3039
- actions.push(buildCommonProjectCopierAction({
3040
- pjanglerRoot,
3041
- targetDir,
3042
- projectName: project.name,
3043
- projectDescription: project.description,
3044
- projectSlug: project.slug,
3045
- ticketProvider: project.ticket_provider.type,
3046
- planeWorkspace: project.ticket_provider.workspace ?? "33god",
3047
- planeProjectId: project.ticket_provider.board_id ?? "",
3048
- projectIdentifier: identifier,
3049
- primaryLanguage: project.template.commonproject.primary_language,
3050
- overwrite
3051
- }));
3052
- }
3053
- actions.push(
3054
- { kind: "project.write-manifest", path: join10(targetDir, ".project.json"), manifest },
3055
- {
3056
- kind: "plane.create-or-link",
3057
- enabled: live,
3058
- live,
3059
- workspace: project.ticket_provider.workspace ?? "33god",
3060
- identifier,
3061
- state: live ? "planned" : "planned",
3062
- reason: live ? void 0 : "network/cloud actions require --live"
3063
- },
3064
- {
3065
- kind: "hermes.provision-agent",
3066
- enabled: input.provisionAgent ?? false,
3067
- local: !live,
3068
- targetDir,
3069
- targetRepo: slug,
3070
- role: agentRole,
3071
- context: {
3072
- skipRuntimeRepo: !live,
3073
- skipPlane: !live,
3074
- skipBloodbank: !live,
3075
- skipSystemd: !live || process.platform === "darwin"
3076
- }
3077
- }
3078
- );
3079
- return { ok: true, apply, dryRun: !apply, live, registryPath: registryPath2, project, manifest, actions };
3080
- }
3081
- function executeProjectInitPlan(plan) {
3082
- const logs = [];
3083
- const errors = [];
3084
- const changedFiles = [];
3085
- if (!plan.apply) return { ok: true, plan, logs, errors, changedFiles };
3086
- const registry = loadProjectRegistry(plan.registryPath);
3087
- let pendingRegistryAction;
3088
- for (const action of plan.actions) {
3089
- if (action.kind === "copier.copy.commonproject") {
3090
- mkdirSync6(dirname6(action.targetDir), { recursive: true });
3091
- const result = spawnSync5(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
3092
- if (result.stdout?.trim()) logs.push(result.stdout.trim());
3093
- if (result.stderr?.trim()) logs.push(result.stderr.trim());
3094
- if (result.error) {
3095
- const code = result.error.code;
3096
- errors.push(
3097
- code === "ENOENT" ? "copier not found on PATH. Install with: uv tool install copier or pip install copier" : `copier failed: ${result.error.message}`
3098
- );
3099
- break;
3100
- }
3101
- if (result.status !== 0) {
3102
- errors.push(`copier exited with status ${result.status ?? "unknown"}`);
3103
- if (existsSync8(action.targetDir)) changedFiles.push(action.targetDir);
3104
- break;
3105
- }
3106
- changedFiles.push(action.targetDir);
3107
- } else if (action.kind === "project.write-manifest") {
3108
- mkdirSync6(dirname6(action.path), { recursive: true });
3109
- const next = `${JSON.stringify(action.manifest, null, 2)}
3110
- `;
3111
- const current = existsSync8(action.path) ? readFileSync4(action.path, "utf8") : void 0;
3112
- if (current !== next) {
3113
- writeFileSync5(action.path, next, "utf8");
3114
- changedFiles.push(action.path);
3115
- }
3116
- } else if (action.kind === "registry.upsert") {
3117
- pendingRegistryAction = action;
3118
- } else if (action.kind === "plane.create-or-link") {
3119
- logs.push(action.enabled ? "plane.create-or-link requires a live provider integration" : "plane.create-or-link skipped (requires --live)");
3120
- } else if (action.kind === "hermes.provision-agent") {
3121
- logs.push(action.enabled ? "hermes.provision-agent planned for the caller to execute" : "hermes.provision-agent skipped");
3122
- }
3123
- }
3124
- if (pendingRegistryAction && errors.length === 0) {
3125
- if (!projectRecordEquivalent(registry.projects[pendingRegistryAction.slug], pendingRegistryAction.project)) {
3126
- registry.projects[pendingRegistryAction.slug] = pendingRegistryAction.project;
3127
- saveProjectRegistry(registry, pendingRegistryAction.registryPath);
3128
- changedFiles.push(pendingRegistryAction.registryPath);
3129
- }
3130
- }
3131
- return { ok: errors.length === 0, plan, logs, errors, changedFiles };
3132
- }
3133
- function projectManifestFromRegistryProject(project) {
3134
- const agents = Object.fromEntries(
3135
- Object.entries(project.agents).map(([name, agent]) => [
3136
- `${project.slug}-${name}`,
3137
- {
3138
- role: agent.role,
3139
- role_dir: agent.role_dir,
3140
- provisioning_state: agent.provisioning_state
3212
+ details.push(`would run: bash ${script}`);
3213
+ } else {
3214
+ const result = spawnSync5("bash", [script], { cwd: role.roleDir, encoding: "utf8" });
3215
+ if (result.status !== 0) details.push(`script failed: ${script}: ${result.stderr.trim() || result.stdout.trim()}`);
3216
+ }
3217
+ }
3141
3218
  }
3142
- ])
3143
- );
3144
- return {
3145
- project_name: project.name,
3146
- project_description: project.description,
3147
- project_slug: project.slug,
3148
- repo_path: project.repo_path,
3149
- ticket_provider: {
3150
- type: project.ticket_provider.type,
3151
- workspace: project.ticket_provider.workspace ?? "",
3152
- identifier: project.ticket_provider.identifier ?? "",
3153
- board_id: project.ticket_provider.board_id ?? "",
3154
- board_url: project.ticket_provider.board_url ?? "",
3155
- state: project.ticket_provider.state
3156
- },
3157
- agents
3158
- };
3159
- }
3160
- function formatProjectInitPlan(plan) {
3161
- const lines = [""];
3162
- const title = `${bold(plan.project.name)} ${dim(`(${plan.project.slug})`)}`;
3163
- lines.push(` ${cyan(bold(glyph.chevron))} ${title}${plan.dryRun ? ` ${dim(glyph.dot)} ${yellow("dry run")}` : ""}`);
3164
- lines.push(` ${dim("registry".padEnd(8))} ${dim(plan.registryPath)}`);
3165
- lines.push(` ${dim("target".padEnd(8))} ${dim(plan.project.repo_path)}`);
3166
- lines.push("");
3167
- lines.push(` ${bold("Actions")} ${dim(`(${plan.actions.length})`)}`);
3168
- if (!plan.actions.length) lines.push(` ${dim("(nothing to do)")}`);
3169
- for (const action of plan.actions) {
3170
- lines.push(` ${cyan(glyph.bullet)} ${action.kind}`);
3171
- if (action.kind === "copier.copy.commonproject") lines.push(` ${dim(`target: ${action.targetDir}`)}`);
3172
- if (action.kind === "project.write-manifest") lines.push(` ${dim(`path: ${action.path}`)}`);
3173
- if (action.kind === "plane.create-or-link" && action.reason) lines.push(` ${dim(`note: ${action.reason}`)}`);
3219
+ return {
3220
+ id: finding.id,
3221
+ title: finding.title,
3222
+ status: details.some((detail) => detail.includes("failed:")) ? "blocked" : details.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
3223
+ summary: details.length ? ctx.dryRun ? "Planned systemd remediation commands" : "Attempted systemd remediation" : "No changes required",
3224
+ changedFiles,
3225
+ details
3226
+ };
3227
+ }
3174
3228
  }
3175
- lines.push("");
3176
- return lines.join("\n");
3177
- }
3178
- function formatProjectList(registry) {
3179
- const projects = Object.values(registry.projects).sort((a, b) => a.slug.localeCompare(b.slug));
3180
- if (!projects.length) return `
3181
- ${dim("No projects registered.")}
3229
+ ];
3230
+ function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
3231
+ const normalized = content.endsWith("\n") ? content : `${content}
3182
3232
  `;
3183
- const slugWidth = projects.reduce((width, project) => Math.max(width, project.slug.length), 0);
3184
- const idWidth = projects.reduce((width, project) => Math.max(width, String(project.ticket_provider.identifier ?? "").length), 0);
3185
- const statusWidth = projects.reduce((width, project) => Math.max(width, project.status.length), 0);
3186
- const lines = ["", ` ${bold("Projects")} ${dim(`(${projects.length})`)}`, ""];
3187
- for (const project of projects) {
3188
- const slug = bold(project.slug.padEnd(slugWidth));
3189
- const identifier = cyan(String(project.ticket_provider.identifier ?? "").padEnd(idWidth));
3190
- const status = projectStatusColor(project.status)(project.status.padEnd(statusWidth));
3191
- lines.push(` ${slug} ${identifier} ${status} ${dim(project.repo_path)}`);
3233
+ if (safeReadText(path) === normalized) return;
3234
+ changedFiles.push(path);
3235
+ if (!dryRun) {
3236
+ writeText(path, normalized);
3237
+ if (mode) chmodSync2(path, mode);
3192
3238
  }
3193
- lines.push("");
3194
- return lines.join("\n");
3195
3239
  }
3196
- function getProject(registry, slug) {
3197
- const project = registry.projects[slug];
3198
- if (!project) throw new Error(`Project not found in registry: ${slug}`);
3199
- return project;
3240
+ function getParityRuleIds() {
3241
+ return RULES.map((rule) => rule.id);
3200
3242
  }
3201
- function doctorProjectRegistry(registryPath2 = projectRegistryPath(), slug) {
3202
- const issues = [];
3203
- const registry = loadProjectRegistry(registryPath2);
3204
- const projects = slug ? [[slug, getProject(registry, slug)]] : Object.entries(registry.projects);
3205
- for (const [projectSlug, project] of projects) {
3206
- if (!existsSync8(project.repo_path)) {
3207
- issues.push({ level: "warn", slug: projectSlug, message: `repo_path does not exist: ${project.repo_path}` });
3208
- } else if (!statSync(project.repo_path).isDirectory()) {
3209
- issues.push({ level: "error", slug: projectSlug, message: `repo_path is not a directory: ${project.repo_path}` });
3210
- } else {
3211
- const manifestPath = join10(project.repo_path, ".project.json");
3212
- if (!existsSync8(manifestPath)) issues.push({ level: "warn", slug: projectSlug, message: ".project.json is missing" });
3213
- }
3214
- for (const artifact of project.source_artifacts) {
3215
- if (artifact.path && !existsSync8(artifact.path)) {
3216
- issues.push({ level: "warn", slug: projectSlug, message: `source artifact missing: ${artifact.path}` });
3217
- }
3218
- }
3219
- }
3243
+ function runAudit(repoArg) {
3244
+ const pjanglerRoot = resolvePjanglerRoot2();
3245
+ const ctx = {
3246
+ repoRoot: resolve2(repoArg ?? process.cwd()),
3247
+ dryRun: true,
3248
+ pjanglerRoot,
3249
+ homeDir: homedir5()
3250
+ };
3251
+ const rules = RULES.map((rule) => rule.audit(ctx));
3220
3252
  return {
3221
- ok: !issues.some((issue) => issue.level === "error"),
3222
- registryPath: registryPath2,
3223
- checkedProjects: projects.map(([projectSlug]) => projectSlug),
3224
- issues
3253
+ repo: ctx.repoRoot,
3254
+ ok: rules.every((rule) => rule.status === "pass" || rule.status === "skip"),
3255
+ auditedAt: (/* @__PURE__ */ new Date()).toISOString(),
3256
+ rules
3225
3257
  };
3226
3258
  }
3227
- function buildCommonProjectCopierAction(input) {
3228
- const templateDir = join10(input.pjanglerRoot, "templates", "commonproject");
3229
- const data = {
3230
- project_name: input.projectName,
3231
- project_description: input.projectDescription ?? "",
3232
- project_slug: input.projectSlug,
3233
- ticket_provider: input.ticketProvider,
3234
- plane_workspace: input.planeWorkspace,
3235
- plane_project_id: input.planeProjectId ?? "",
3236
- project_identifier: input.projectIdentifier,
3237
- primary_language: input.primaryLanguage
3259
+ function runMigrationForRules(ruleIds, repoArg, dryRun) {
3260
+ const pjanglerRoot = resolvePjanglerRoot2();
3261
+ const ctx = {
3262
+ repoRoot: resolve2(repoArg ?? process.cwd()),
3263
+ dryRun,
3264
+ pjanglerRoot,
3265
+ homeDir: homedir5()
3238
3266
  };
3239
- const command = ["copier", "copy", "--trust", templateDir, input.targetDir, "--defaults"];
3240
- for (const [key, value] of Object.entries(data)) command.push("--data", `${key}=${value}`);
3241
- if (input.overwrite) command.push("--overwrite");
3267
+ const selected = RULES.filter((rule) => ruleIds.includes(rule.id));
3268
+ if (!selected.length) {
3269
+ throw new Error(`Unknown parity rules: ${ruleIds.join(", ")}`);
3270
+ }
3271
+ const results = selected.map((rule) => {
3272
+ try {
3273
+ return rule.migrate(ctx, rule.audit(ctx));
3274
+ } catch (err) {
3275
+ return {
3276
+ id: rule.id,
3277
+ title: rule.title,
3278
+ status: "blocked",
3279
+ summary: `migrate threw: ${err instanceof Error ? err.message : String(err)}`,
3280
+ changedFiles: [],
3281
+ details: []
3282
+ };
3283
+ }
3284
+ });
3285
+ const changedFiles = Array.from(new Set(results.flatMap((result) => result.changedFiles))).sort();
3242
3286
  return {
3243
- kind: "copier.copy.commonproject",
3244
- cwd: input.pjanglerRoot,
3245
- command,
3246
- targetDir: input.targetDir,
3247
- data,
3248
- overwrite: input.overwrite
3287
+ repo: ctx.repoRoot,
3288
+ dryRun,
3289
+ ok: results.every((result) => result.status !== "blocked"),
3290
+ selectedRules: selected.map((rule) => rule.id),
3291
+ results,
3292
+ changedFiles
3249
3293
  };
3250
3294
  }
3251
- function resolvePjanglerRoot2() {
3252
- let dir = dirname6(new URL(import.meta.url).pathname);
3253
- while (dir !== dirname6(dir)) {
3254
- if (existsSync8(join10(dir, "package.json")) && existsSync8(join10(dir, "templates", "commonproject", "copier.yml"))) return dir;
3255
- dir = dirname6(dir);
3295
+ function runMigration(selector, repoArg, dryRun, all) {
3296
+ const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
3297
+ return runMigrationForRules(ruleIds, repoArg, dryRun);
3298
+ }
3299
+ function prettyTimestamp(iso) {
3300
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/.exec(iso);
3301
+ return match ? `${match[1]} ${match[2]} UTC` : iso;
3302
+ }
3303
+ function formatAuditReport(report) {
3304
+ const counts = {};
3305
+ for (const rule of report.rules) counts[rule.status] = (counts[rule.status] ?? 0) + 1;
3306
+ const idWidth = report.rules.reduce((width, rule) => Math.max(width, rule.id.length), 0);
3307
+ const tally = [];
3308
+ if (counts.pass) tally.push(green(`${counts.pass} passed`));
3309
+ if (counts.fail) tally.push(red(`${counts.fail} failed`));
3310
+ if (counts.warn) tally.push(yellow(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`));
3311
+ if (counts.skip) tally.push(gray(`${counts.skip} skipped`));
3312
+ const overall = report.ok ? `${green(glyph.pass)} ${bold("Parity audit passed")}` : `${red(glyph.fail)} ${bold("Parity audit failed")}`;
3313
+ const lines = [""];
3314
+ lines.push(` ${overall}${tally.length ? ` ${dim(glyph.dot)} ${joinDot(tally)}` : ""}`);
3315
+ lines.push(` ${dim(report.repo)} ${dim(glyph.dot)} ${dim(prettyTimestamp(report.auditedAt))}`);
3316
+ lines.push("");
3317
+ for (const rule of report.rules) {
3318
+ const style = statusStyle(rule.status);
3319
+ lines.push(` ${style.color(style.glyph)} ${style.color(rule.id.padEnd(idWidth))} ${rule.summary}`);
3320
+ for (const detail of rule.details) lines.push(` ${dim(glyph.arrow)} ${dim(detail)}`);
3256
3321
  }
3257
- return resolve2(process.cwd());
3322
+ lines.push("");
3323
+ return lines.join("\n");
3258
3324
  }
3259
- function validateNoDuplicateProject(registry, project, overwrite) {
3260
- const existingSameSlug = registry.projects[project.slug];
3261
- if (existingSameSlug && !overwrite && resolve2(existingSameSlug.repo_path) !== resolve2(project.repo_path)) {
3262
- throw new Error(`Project slug already exists in registry: ${project.slug}`);
3325
+ function formatMigrationReport(report) {
3326
+ const idWidth = report.results.reduce((width, result) => Math.max(width, result.id.length), 0);
3327
+ const overall = report.ok ? `${green(glyph.pass)} ${bold(report.dryRun ? "Migration preview complete" : "Migration complete")}` : `${red(glyph.fail)} ${bold("Migration finished with blockers")}`;
3328
+ const lines = [""];
3329
+ lines.push(` ${overall}${report.dryRun ? ` ${dim(glyph.dot)} ${yellow("dry run")}` : ""}`);
3330
+ lines.push(` ${dim(report.repo)}`);
3331
+ if (report.selectedRules.length) lines.push(` ${dim(`rules: ${report.selectedRules.join(", ")}`)}`);
3332
+ lines.push("");
3333
+ for (const result of report.results) {
3334
+ const style = statusStyle(result.status);
3335
+ lines.push(` ${style.color(style.glyph)} ${style.color(result.id.padEnd(idWidth))} ${result.summary} ${dim(`[${style.label}]`)}`);
3336
+ for (const detail of result.details) lines.push(` ${dim(glyph.arrow)} ${dim(detail)}`);
3337
+ for (const file of result.changedFiles) lines.push(` ${green(glyph.add)} ${file}`);
3263
3338
  }
3264
- for (const [slug, existing] of Object.entries(registry.projects)) {
3265
- if (slug === project.slug) continue;
3266
- if (resolve2(existing.repo_path) === resolve2(project.repo_path)) {
3267
- throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
3268
- }
3269
- if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
3270
- throw new Error(`Project identifier already registered by ${slug}: ${project.ticket_provider.identifier}`);
3271
- }
3339
+ if (report.changedFiles.length) {
3340
+ lines.push("");
3341
+ lines.push(` ${bold(`Changed files (${report.changedFiles.length})`)}`);
3342
+ for (const file of report.changedFiles) lines.push(` ${green(glyph.add)} ${file}`);
3272
3343
  }
3273
- }
3274
- function validateProjectRecord(project, key) {
3275
- if (!isRecord(project)) throw new Error(`Project ${key} must be a mapping`);
3276
- if (!project.name) throw new Error(`Project ${key} missing name`);
3277
- if (!project.slug) throw new Error(`Project ${key} missing slug`);
3278
- if (project.slug !== key) throw new Error(`Project key ${key} does not match slug ${project.slug}`);
3279
- if (!project.repo_path) throw new Error(`Project ${key} missing repo_path`);
3280
- if (!Array.isArray(project.source_artifacts)) throw new Error(`Project ${key} source_artifacts must be a list`);
3281
- if (!isRecord(project.ticket_provider)) throw new Error(`Project ${key} ticket_provider must be a mapping`);
3282
- if (!isRecord(project.agents)) throw new Error(`Project ${key} agents must be a mapping`);
3283
- }
3284
- function expandHome(path) {
3285
- if (path === "~") return homedir5();
3286
- if (path.startsWith("~/")) return join10(homedir5(), path.slice(2));
3287
- return path;
3288
- }
3289
- function isRecord(value) {
3290
- return typeof value === "object" && value !== null && !Array.isArray(value);
3344
+ lines.push("");
3345
+ return lines.join("\n");
3291
3346
  }
3292
3347
 
3293
3348
  // src/utils/version.ts
@@ -3395,7 +3450,7 @@ function projectInitActionLabel(kind) {
3395
3450
  return "Render CommonProject scaffold";
3396
3451
  case "project.write-manifest":
3397
3452
  return "Write repo-local .project.json projection";
3398
- case "plane.create-or-link":
3453
+ case "ticket-provider.create-or-link":
3399
3454
  return "Create/link ticket provider project";
3400
3455
  case "hermes.provision-agent":
3401
3456
  return "Provision Hermes agent";
@@ -3421,7 +3476,7 @@ function actionNeedsRun(plan, kind, syncMode) {
3421
3476
  return !existsSync9(action.path) || readFileSync6(action.path, "utf8") !== next;
3422
3477
  }
3423
3478
  if (kind === "copier.copy.commonproject") return true;
3424
- if (kind === "plane.create-or-link") return plan.actions.some((action) => action.kind === kind && action.enabled);
3479
+ if (kind === "ticket-provider.create-or-link") return plan.actions.some((action) => action.kind === kind && action.enabled);
3425
3480
  if (kind === "hermes.provision-agent") return plan.actions.some((action) => action.kind === kind && action.enabled);
3426
3481
  return true;
3427
3482
  }
@@ -3498,26 +3553,39 @@ async function resolveProjectInitTarget(name, options) {
3498
3553
  identifier: options.identifier ?? defaults.identifier
3499
3554
  };
3500
3555
  }
3501
- var program = new Command3();
3502
- program.name("pjangler").description("Project subsystem bootstrapper CLI").version(PJANGLER_VERSION);
3503
- 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) => {
3556
+ async function runRecipeSubsystem(name, options) {
3504
3557
  const context = {
3505
3558
  targetDir: process.cwd(),
3506
3559
  force: options.force || false,
3507
3560
  dryRun: options.dryRun || false
3508
3561
  };
3509
3562
  try {
3510
- const recipe = createRecipe(subsystem, context);
3563
+ const recipe = createRecipe(name, context);
3511
3564
  if (!recipe) {
3512
- console.error(`${xmark} Unknown subsystem: ${bold(subsystem)}`);
3565
+ console.error(`${xmark} Unknown subsystem: ${bold(name)}`);
3513
3566
  console.error(` ${dim("Available:")} ${getRecipeNames().map((available) => cyan(available)).join(dim(", "))}`);
3514
3567
  process.exit(1);
3515
3568
  }
3516
3569
  await recipe.execute();
3517
3570
  } catch (error) {
3518
- console.error(`${xmark} Error initializing ${bold(subsystem)}:`, error);
3571
+ console.error(`${xmark} Error scaffolding ${bold(name)}:`, error);
3519
3572
  process.exit(1);
3520
3573
  }
3574
+ }
3575
+ var program = new Command3();
3576
+ program.name("pjangler").description("Project subsystem bootstrapper CLI").version(PJANGLER_VERSION);
3577
+ program.command("init").argument("[name]", "Project name to bootstrap (omit inside an existing git repo)").description("Bootstrap a project: registry entry + CommonProject scaffold + .project.json").option("--description <text>", "Project description").option("--target-dir <path>", "Target repo path").option("--source-skill <path>", "Source skill/template provenance path").option("--primary-language <language>", "Primary language for CommonProject rendering", "python").option("--provision-agent", "Plan local Hermes PM agent provisioning").option("--agent-role <role>", "Hermes agent role to plan when --provision-agent is set", "pm").option("--apply", "Write the registry and render the repo scaffold").option("--dry-run", "Preview changes without writing files (default)").option("--live", "Allow live/network/cloud provisioning actions").option("--slug <slug>", "Project registry slug override").option("--identifier <identifier>", "Ticket identifier override").option("--ticket-provider <type>", "Ticket provider: plane | trello", "plane").option("--board-id <id>", "Board id (Plane project UUID or Trello board id)").option("--board-url <url>", "Board URL override (derived from provider + board-id if omitted)").option("--workspace <name>", "Ticket workspace/org (Plane workspace; blank for Trello)").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("-f, --force", "Allow replacing an existing registry entry and re-rendering files").option("-y, --yes", "Apply every proposed operation without prompting").option("--no-tui", "Disable interactive prompts").option("--json", "Output machine-parseable JSON").action(async (name, options) => {
3578
+ if (name && getRecipeNames().includes(name)) {
3579
+ if (!options.json) {
3580
+ console.error(`${yellow(glyph.warn)} ${dim(`"pjangler init ${name}" is deprecated \u2014 use "pjangler add ${name}". Forwarding\u2026`)}`);
3581
+ }
3582
+ await runRecipeSubsystem(name, { force: options.force, dryRun: options.dryRun });
3583
+ return;
3584
+ }
3585
+ await runProjectInit(name, options);
3586
+ });
3587
+ program.command("add").argument("<subsystem>", "Subsystem to scaffold (mise, docker, node, agent-hooks, \u2026)").description("Scaffold a subsystem/component into the current repo").option("--dry-run", "Preview changes without writing files").option("-f, --force", "Overwrite existing files").action(async (subsystem, options) => {
3588
+ await runRecipeSubsystem(subsystem, options);
3521
3589
  });
3522
3590
  program.command("list").description("List available subsystems").action(() => {
3523
3591
  const width = Object.keys(RECIPE_REGISTRY).reduce((max, name) => Math.max(max, name.length), 0);
@@ -3529,13 +3597,17 @@ program.command("list").description("List available subsystems").action(() => {
3529
3597
  }
3530
3598
  console.log("");
3531
3599
  console.log(` ${dim("Examples")}`);
3532
- for (const example of ["pj init mise", "pj init docker", "pj init node"]) {
3600
+ for (const example of ["pj add mise", "pj add docker", "pj add node"]) {
3533
3601
  console.log(` ${dim(glyph.pointer)} ${dim(example)}`);
3534
3602
  }
3535
3603
  console.log("");
3536
3604
  });
3537
3605
  var projectCmd = program.command("project").description("Manage the pjangler project registry");
3538
- 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) => {
3606
+ projectCmd.command("init").argument("[name]", "Project display name").description("Plan or apply a registry-backed CommonProject initialization or legacy repo sync").option("--description <text>", "Project description").option("--target-dir <path>", "Target repo path").option("--source-skill <path>", "Source skill/template provenance path").option("--primary-language <language>", "Primary language for CommonProject rendering", "python").option("--provision-agent", "Plan local Hermes PM agent provisioning").option("--agent-role <role>", "Hermes agent role to plan when --provision-agent is set", "pm").option("--apply", "Write the registry and render the repo scaffold").option("--dry-run", "Preview changes without writing files (default)").option("--live", "Allow live/network/cloud provisioning actions").option("--slug <slug>", "Project registry slug override").option("--identifier <identifier>", "Ticket identifier override").option("--ticket-provider <type>", "Ticket provider: plane | trello", "plane").option("--board-id <id>", "Board id (Plane project UUID or Trello board id)").option("--board-url <url>", "Board URL override (derived from provider + board-id if omitted)").option("--workspace <name>", "Ticket workspace/org (Plane workspace; blank for Trello)").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("-f, --force", "Allow replacing an existing registry entry and re-rendering files").option("-y, --yes", "Apply every proposed operation without prompting").option("--no-tui", "Disable interactive prompts").option("--json", "Output machine-parseable JSON").action((name, options) => {
3607
+ if (!options.json) console.error(`${yellow(glyph.warn)} ${dim('"pjangler project init" is deprecated \u2014 use "pjangler init".')}`);
3608
+ return runProjectInit(name, options);
3609
+ });
3610
+ async function runProjectInit(name, options) {
3539
3611
  try {
3540
3612
  const target = await resolveProjectInitTarget(name, options);
3541
3613
  const interactive = isInteractiveProjectInit(options);
@@ -3552,6 +3624,10 @@ projectCmd.command("init").argument("[name]", "Project display name").descriptio
3552
3624
  live: options.live ?? false,
3553
3625
  projectSlug: target.slug,
3554
3626
  projectIdentifier: target.identifier,
3627
+ ticketProvider: options.ticketProvider,
3628
+ boardId: options.boardId,
3629
+ boardUrl: options.boardUrl,
3630
+ boardWorkspace: options.workspace,
3555
3631
  registryPath: options.registry,
3556
3632
  force: options.force ?? false,
3557
3633
  overwrite: options.force ?? false,
@@ -3635,7 +3711,7 @@ projectCmd.command("init").argument("[name]", "Project display name").descriptio
3635
3711
  }
3636
3712
  process.exit(1);
3637
3713
  }
3638
- });
3714
+ }
3639
3715
  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) => {
3640
3716
  try {
3641
3717
  const registry = loadProjectRegistry(options.registry ?? projectRegistryPath());
@@ -3720,27 +3796,11 @@ recipeCmd.command("describe").argument("<name>", "Recipe name").description("Sho
3720
3796
  console.log("");
3721
3797
  console.log(` ${dim("Usage")}`);
3722
3798
  console.log(` ${dim(glyph.pointer)} ${dim(`pj recipe run ${name}`)}`);
3723
- console.log(` ${dim(glyph.pointer)} ${dim(`pj init ${name}`)}`);
3799
+ console.log(` ${dim(glyph.pointer)} ${dim(`pj add ${name}`)}`);
3724
3800
  console.log("");
3725
3801
  });
3726
3802
  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) => {
3727
- const context = {
3728
- targetDir: process.cwd(),
3729
- force: options.force || false,
3730
- dryRun: options.dryRun || false
3731
- };
3732
- try {
3733
- const recipe = createRecipe(name, context);
3734
- if (!recipe) {
3735
- console.error(`${xmark} Recipe not found: ${bold(name)}`);
3736
- console.error(` ${dim("Available:")} ${getRecipeNames().map((available) => cyan(available)).join(dim(", "))}`);
3737
- process.exit(1);
3738
- }
3739
- await recipe.execute();
3740
- } catch (error) {
3741
- console.error(`${xmark} Error running recipe ${bold(name)}:`, error);
3742
- process.exit(1);
3743
- }
3803
+ await runRecipeSubsystem(name, options);
3744
3804
  });
3745
3805
  var commandCmd = program.command("command").alias("cmd").description("Manage pjangler commands");
3746
3806
  commandCmd.command("list").description("List all available commands").option("-g, --group", "Group commands by category").action((options) => {
@@ -3864,7 +3924,7 @@ program.command("migrate").argument("[rule-id]", "Rule ID to migrate (omit to op
3864
3924
  process.exit(1);
3865
3925
  }
3866
3926
  });
3867
- program.command("hermes-agent").alias("hermes").description("Provision the PM agent for the current repo (defaults everything; only asks about Telegram)").option("-y, --yes", "Non-interactive: accept all defaults (also skips the Telegram prompt)").option("--target-repo <name>", "Target repo name (default: basename of cwd)").option("--role <role>", "Agent role override (default: pm \u2014 the only role in the fleet)").option("--purpose <text>", 'One-line agent purpose (default: "pm agent for <repo>")').option(`--tone <tone>`, `Personality tone (default: direct; ${SOUL_TONES.join(" | ")})`).option("--model-provider <name>", 'Inference provider override ("" = inherit shared default profile)').option("--model-name <name>", 'Model name override ("" = inherit shared default profile)').option("--skip-telegram", "Skip the Telegram wire-up (no BotFather prompt)").option("--email", "Also provision the delo.sh email address (off by default; never prompted)").option("--skip-runtime-repo", "Skip creating the per-agent runtime GH repo").option("--skip-plane", "Skip creating the Plane project").option("--skip-bloodbank", "Skip installing the Bloodbank NATS consumer").option("--skip-systemd", "Skip installing systemd --user units").option("--local", "Local-only: skip runtime repo, Plane, Bloodbank, and systemd (safe for laptops/macOS/non-technical operators)").option("--force-config", "Regenerate ~/.config/hermes-agent-template/config.toml even if it exists").option("--dry-run", "Preview what would run; don't execute copier").option("-f, --force", "Re-render even if agents/hermes/<role>/role.yaml already exists").action(async (options) => {
3927
+ program.command("hermes-agent").alias("hermes").description("Provision the PM agent for the current repo (defaults everything; only asks about Telegram)").option("-y, --yes", "Non-interactive: accept all defaults (also skips the Telegram prompt)").option("--target-repo <name>", "Target repo name (default: basename of cwd)").option("--role <role>", "Agent role override (default: pm \u2014 the only role in the fleet)").option("--purpose <text>", 'One-line agent purpose (default: "pm agent for <repo>")').option(`--tone <tone>`, `Personality tone (default: direct; ${SOUL_TONES.join(" | ")})`).option("--model-provider <name>", 'Inference provider override ("" = inherit shared default profile)').option("--model-name <name>", 'Model name override ("" = inherit shared default profile)').option("--skip-telegram", "Skip the Telegram wire-up (no BotFather prompt)").option("--email", "Also provision the delo.sh email address (off by default; never prompted)").option("--skip-runtime-repo", "Skip creating the per-agent runtime GH repo").option("--skip-plane", "Skip creating or linking the ticket board").option("--skip-bloodbank", "Skip installing the Bloodbank NATS consumer").option("--skip-systemd", "Skip installing systemd --user units").option("--local", "Local-only: skip runtime repo, ticket-board creation, Bloodbank, and systemd (safe for laptops/macOS/non-technical operators)").option("--force-config", "Regenerate ~/.config/hermes-agent-template/config.toml even if it exists").option("--dry-run", "Preview what would run; don't execute copier").option("-f, --force", "Re-render even if agents/hermes/<role>/role.yaml already exists").action(async (options) => {
3868
3928
  const isDarwin = process.platform === "darwin";
3869
3929
  const local = options.local ?? false;
3870
3930
  const context = {