@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
@@ -713,7 +713,7 @@ function deriveProfileName(repo, role) {
713
713
  function detectTicketProvider(targetDir) {
714
714
  try {
715
715
  const t = JSON.parse(readFileSync(join4(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
716
- return t === "plane" || t === "linear" || t === "trello" ? t : void 0;
716
+ return t === "plane" || t === "trello" ? t : void 0;
717
717
  } catch {
718
718
  return void 0;
719
719
  }
@@ -1187,70 +1187,478 @@ var HermesAgentRecipe = class extends Recipe {
1187
1187
  };
1188
1188
 
1189
1189
  // src/commands/AgentHooksCommands.ts
1190
- import { homedir as homedir3 } from "node:os";
1191
- import { join as join8, dirname as dirname4 } from "node:path";
1192
- import { existsSync as existsSync6, cpSync, mkdirSync as mkdirSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "node:fs";
1190
+ import { homedir as homedir4 } from "node:os";
1191
+ import { join as join9, dirname as dirname5 } from "node:path";
1192
+ import { existsSync as existsSync7, cpSync, mkdirSync as mkdirSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
1193
1193
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1194
- function resolveTemplateRoot() {
1195
- const candidates = [];
1196
- if (process.env.PJANGLER_COMMONPROJECT_TEMPLATE) {
1197
- candidates.push(process.env.PJANGLER_COMMONPROJECT_TEMPLATE);
1194
+
1195
+ // src/project/index.ts
1196
+ import { spawnSync as spawnSync4 } from "node:child_process";
1197
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync2, renameSync, statSync, writeFileSync as writeFileSync3 } from "node:fs";
1198
+ import { homedir as homedir3 } from "node:os";
1199
+ import { basename as basename2, dirname as dirname4, join as join8, resolve } from "node:path";
1200
+ import YAML from "yaml";
1201
+ var PROJECT_REGISTRY_ENV = "PJ_PROJECT_REGISTRY";
1202
+ var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
1203
+ var KNOWN_SKILL_ROOTS = [
1204
+ "/home/delorenj/code/skillex/all-skills",
1205
+ "/home/delorenj/code/CoachingAgentFramework/.agents/skills",
1206
+ "/home/delorenj/code/pjangler/.agents/skills",
1207
+ join8(homedir3(), ".codex", "skills")
1208
+ ];
1209
+ function projectRegistryPath(env2 = process.env) {
1210
+ return expandHome(env2[PROJECT_REGISTRY_ENV] || join8(homedir3(), ".config", "pjangler", "projects.yaml"));
1211
+ }
1212
+ function emptyProjectRegistry() {
1213
+ return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
1214
+ }
1215
+ function loadProjectRegistry(path = projectRegistryPath()) {
1216
+ if (!existsSync6(path)) return emptyProjectRegistry();
1217
+ const raw = YAML.parse(readFileSync2(path, "utf8"));
1218
+ if (raw == null) return emptyProjectRegistry();
1219
+ if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
1220
+ const registry = raw;
1221
+ const normalized = {
1222
+ schema_version: Number(registry.schema_version ?? PROJECT_REGISTRY_SCHEMA_VERSION),
1223
+ projects: isRecord(registry.projects) ? registry.projects : {}
1224
+ };
1225
+ validateProjectRegistry(normalized);
1226
+ return normalized;
1227
+ }
1228
+ function saveProjectRegistry(registry, path = projectRegistryPath()) {
1229
+ validateProjectRegistry(registry);
1230
+ mkdirSync4(dirname4(path), { recursive: true });
1231
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
1232
+ writeFileSync3(temp, YAML.stringify(registry, { lineWidth: 0 }), "utf8");
1233
+ renameSync(temp, path);
1234
+ }
1235
+ function validateProjectRegistry(registry) {
1236
+ if (registry.schema_version !== PROJECT_REGISTRY_SCHEMA_VERSION) {
1237
+ throw new Error(`Unsupported project registry schema_version: ${registry.schema_version}`);
1198
1238
  }
1199
- try {
1200
- let dir = dirname4(fileURLToPath2(import.meta.url));
1201
- for (let i = 0; i < 8; i++) {
1202
- candidates.push(join8(dir, "templates", "commonproject", "template"));
1203
- const parent = dirname4(dir);
1204
- if (parent === dir) break;
1205
- dir = parent;
1239
+ if (!isRecord(registry.projects)) throw new Error("Project registry projects must be a mapping");
1240
+ const slugs = /* @__PURE__ */ new Set();
1241
+ const repoPaths = /* @__PURE__ */ new Map();
1242
+ const identifiers = /* @__PURE__ */ new Map();
1243
+ for (const [slug, project] of Object.entries(registry.projects)) {
1244
+ validateProjectRecord(project, slug);
1245
+ if (slugs.has(project.slug)) throw new Error(`Duplicate project slug: ${project.slug}`);
1246
+ slugs.add(project.slug);
1247
+ const repoKey = resolve(project.repo_path);
1248
+ const existingRepoSlug = repoPaths.get(repoKey);
1249
+ if (existingRepoSlug && existingRepoSlug !== slug) {
1250
+ throw new Error(`Duplicate project repo_path: ${project.repo_path} used by ${existingRepoSlug} and ${slug}`);
1251
+ }
1252
+ repoPaths.set(repoKey, slug);
1253
+ const identifier = project.ticket_provider.identifier?.toUpperCase();
1254
+ if (identifier) {
1255
+ const existingIdentifierSlug = identifiers.get(identifier);
1256
+ if (existingIdentifierSlug && existingIdentifierSlug !== slug) {
1257
+ throw new Error(`Duplicate project identifier: ${identifier} used by ${existingIdentifierSlug} and ${slug}`);
1258
+ }
1259
+ identifiers.set(identifier, slug);
1206
1260
  }
1207
- } catch {
1208
1261
  }
1209
- candidates.push(join8(homedir3(), "code", "pjangler", "templates", "commonproject", "template"));
1210
- for (const c of candidates) {
1211
- if (existsSync6(join8(c, ".agents", "hooks", "hooks.master.json"))) return c;
1262
+ }
1263
+ function normalizeTicketProvider(value) {
1264
+ const type = (value || "plane").trim().toLowerCase();
1265
+ if (type === "plane" || type === "trello") return type;
1266
+ throw new Error(`Unsupported ticket provider: ${value}. Supported providers: plane, trello`);
1267
+ }
1268
+ function buildTicketProviderBlock(input) {
1269
+ const type = normalizeTicketProvider(input.type);
1270
+ const boardId = input.boardId ?? "";
1271
+ if (type === "trello") {
1272
+ return {
1273
+ type,
1274
+ workspace: input.workspace ?? "",
1275
+ identifier: input.identifier,
1276
+ board_id: boardId,
1277
+ board_url: input.boardUrl ?? (boardId ? `https://trello.com/b/${boardId}` : ""),
1278
+ state: "planned"
1279
+ };
1212
1280
  }
1213
- throw new Error(
1214
- "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
1215
- );
1281
+ const workspace = input.workspace ?? "33god";
1282
+ return {
1283
+ type,
1284
+ workspace,
1285
+ identifier: input.identifier,
1286
+ board_id: boardId,
1287
+ board_url: input.boardUrl ?? (boardId ? `https://plane.delo.sh/${workspace}/projects/${boardId}/issues/` : ""),
1288
+ state: "planned"
1289
+ };
1216
1290
  }
1217
- var CopyAgentHooksTree = class extends Command {
1218
- async invoke() {
1219
- let templateRoot;
1220
- try {
1221
- templateRoot = resolveTemplateRoot();
1222
- } catch (e) {
1223
- return { success: false, message: `\u26A0\uFE0F ${e.message}` };
1291
+ function slugifyProjectName(value) {
1292
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
1293
+ }
1294
+ function deriveProjectIdentifier(value) {
1295
+ const compact = value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
1296
+ const identifier = compact.slice(0, 4) || "PROJ";
1297
+ return identifier.length >= 2 ? identifier : `${identifier}XX`.slice(0, 4);
1298
+ }
1299
+ function normalizeAgentRole(value) {
1300
+ return value?.trim() || "pm";
1301
+ }
1302
+ function resolveAgentHooksLayer(input, env2 = process.env) {
1303
+ if (typeof input === "boolean") return input;
1304
+ const override = env2.PJ_AGENT_HOOKS_LAYER;
1305
+ if (override === "0" || override === "false") return false;
1306
+ if (override === "1" || override === "true") return true;
1307
+ return !existsSync6(join8(homedir3(), ".agents", "hooks"));
1308
+ }
1309
+ function jsonStable(value) {
1310
+ return JSON.stringify(value);
1311
+ }
1312
+ function projectRecordEquivalent(a, b) {
1313
+ if (!a) return false;
1314
+ const { created_at: _aCreated, updated_at: _aUpdated, ...aComparable } = a;
1315
+ const { created_at: _bCreated, updated_at: _bUpdated, ...bComparable } = b;
1316
+ return jsonStable(aComparable) === jsonStable(bComparable);
1317
+ }
1318
+ function defaultProjectTargetDir(name, cwd = process.cwd()) {
1319
+ const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
1320
+ return resolve(dirname4(resolve(cwd)), compactName);
1321
+ }
1322
+ function resolveSourceSkillPath(sourceSkill) {
1323
+ if (!sourceSkill) return void 0;
1324
+ const expanded = expandHome(sourceSkill);
1325
+ const direct = resolve(expanded);
1326
+ if (existsSync6(direct)) return direct;
1327
+ const name = basename2(sourceSkill);
1328
+ for (const root of KNOWN_SKILL_ROOTS) {
1329
+ const candidate = join8(root, name);
1330
+ if (existsSync6(candidate)) return candidate;
1331
+ }
1332
+ const civilWarLetterifier = "/home/delorenj/code/skillex/all-skills/civilwar-letterifier";
1333
+ const hint = existsSync6(civilWarLetterifier) ? ` Did you mean ${civilWarLetterifier}?` : "";
1334
+ throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
1335
+ }
1336
+ function planProjectInit(input) {
1337
+ if (!input.name.trim()) throw new Error("Project name is required");
1338
+ const registryPath2 = resolve(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
1339
+ const registry = loadProjectRegistry(registryPath2);
1340
+ const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
1341
+ const slug = input.projectSlug ?? slugifyProjectName(input.name);
1342
+ const targetDir = resolve(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
1343
+ const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
1344
+ const existing = registry.projects[slug];
1345
+ const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
1346
+ const overwrite = input.overwrite ?? input.force ?? false;
1347
+ const agentRole = normalizeAgentRole(input.agentRole);
1348
+ const agents = input.provisionAgent ? {
1349
+ ...existing?.agents ?? {},
1350
+ [agentRole]: {
1351
+ role: agentRole,
1352
+ provisioning_state: "planned"
1224
1353
  }
1225
- const items = [
1226
- { rel: ".agents/hooks", dir: true },
1227
- { rel: ".agents/local.example.json", dir: false },
1228
- { rel: ".mise/scripts/link-project-skills-to-clis.sh", dir: false },
1229
- { rel: ".mise/scripts/unlink-project-skills-from-clis.sh", dir: false },
1230
- { rel: ".mise/scripts/hindsight-setup.sh", dir: false }
1231
- ];
1232
- const created = [];
1233
- const skipped = [];
1234
- for (const { rel, dir } of items) {
1235
- const src = join8(templateRoot, rel);
1236
- const dest = join8(this.context.targetDir, rel);
1237
- if (!existsSync6(src)) continue;
1238
- if (existsSync6(dest) && !this.context.force) {
1239
- skipped.push(rel);
1240
- continue;
1354
+ } : existing?.agents ?? {};
1355
+ const scaffold = input.scaffold ?? true;
1356
+ const candidateProject = {
1357
+ name: input.name,
1358
+ slug,
1359
+ repo_path: targetDir,
1360
+ description: input.description ?? "",
1361
+ status: "planned",
1362
+ source_artifacts: sourceSkillPath ? [{ kind: "skill", path: sourceSkillPath, package_name: input.packageName ?? slug }] : [],
1363
+ template: {
1364
+ commonproject: {
1365
+ enabled: true,
1366
+ primary_language: input.primaryLanguage ?? "python"
1241
1367
  }
1242
- if (!this.context.dryRun) {
1243
- mkdirSync4(dirname4(dest), { recursive: true });
1244
- cpSync(src, dest, { recursive: dir, force: true });
1368
+ },
1369
+ ticket_provider: buildTicketProviderBlock({
1370
+ type: input.ticketProvider ?? "plane",
1371
+ identifier,
1372
+ boardId: input.boardId ?? input.planeProjectId,
1373
+ boardUrl: input.boardUrl,
1374
+ workspace: input.boardWorkspace ?? input.planeWorkspace
1375
+ }),
1376
+ agents,
1377
+ created_at: existing?.created_at ?? now,
1378
+ updated_at: now
1379
+ };
1380
+ const project = {
1381
+ ...candidateProject,
1382
+ updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
1383
+ };
1384
+ validateNoDuplicateProject(registry, project, overwrite);
1385
+ const pjanglerRoot = resolve(input.pjanglerRoot ?? resolvePjanglerRoot());
1386
+ const manifest = projectManifestFromRegistryProject(project);
1387
+ const apply = input.apply ?? false;
1388
+ const live = input.live ?? false;
1389
+ const actions = [
1390
+ { kind: "registry.upsert", registryPath: registryPath2, slug, project }
1391
+ ];
1392
+ if (scaffold) {
1393
+ actions.push(buildCommonProjectCopierAction({
1394
+ pjanglerRoot,
1395
+ targetDir,
1396
+ projectName: project.name,
1397
+ projectDescription: project.description,
1398
+ projectSlug: project.slug,
1399
+ ticketProvider: project.ticket_provider.type,
1400
+ planeWorkspace: project.ticket_provider.workspace ?? "33god",
1401
+ planeProjectId: project.ticket_provider.board_id ?? "",
1402
+ ticketWorkspace: project.ticket_provider.workspace ?? "",
1403
+ boardId: project.ticket_provider.board_id ?? "",
1404
+ boardUrl: project.ticket_provider.board_url ?? "",
1405
+ projectIdentifier: identifier,
1406
+ primaryLanguage: project.template.commonproject.primary_language,
1407
+ agentHooksLayer: resolveAgentHooksLayer(input.agentHooksLayer),
1408
+ overwrite
1409
+ }));
1410
+ }
1411
+ actions.push(
1412
+ { kind: "project.write-manifest", path: join8(targetDir, ".project.json"), manifest },
1413
+ {
1414
+ kind: "ticket-provider.create-or-link",
1415
+ enabled: live,
1416
+ live,
1417
+ provider: project.ticket_provider.type,
1418
+ workspace: project.ticket_provider.workspace ?? "33god",
1419
+ identifier,
1420
+ state: live ? "planned" : "planned",
1421
+ reason: live ? void 0 : "network/cloud actions require --live"
1422
+ },
1423
+ {
1424
+ kind: "hermes.provision-agent",
1425
+ enabled: input.provisionAgent ?? false,
1426
+ local: !live,
1427
+ targetDir,
1428
+ targetRepo: slug,
1429
+ role: agentRole,
1430
+ context: {
1431
+ skipRuntimeRepo: !live,
1432
+ skipPlane: !live,
1433
+ skipBloodbank: !live,
1434
+ skipSystemd: !live || process.platform === "darwin"
1245
1435
  }
1246
- created.push(rel);
1247
1436
  }
1248
- const verb = this.context.dryRun ? "Would copy" : "Copied";
1249
- const tail = skipped.length ? ` (${skipped.length} already present \u2014 use --force to overwrite)` : "";
1250
- return {
1251
- success: created.length > 0,
1252
- message: this.formatMessage(`\u2705 ${verb} ${created.length} agent-hooks path(s)${tail}`)
1253
- };
1437
+ );
1438
+ return { ok: true, apply, dryRun: !apply, live, registryPath: registryPath2, project, manifest, actions };
1439
+ }
1440
+ function executeProjectInitPlan(plan) {
1441
+ const logs = [];
1442
+ const errors = [];
1443
+ const changedFiles = [];
1444
+ if (!plan.apply) return { ok: true, plan, logs, errors, changedFiles };
1445
+ const registry = loadProjectRegistry(plan.registryPath);
1446
+ let pendingRegistryAction;
1447
+ for (const action of plan.actions) {
1448
+ if (action.kind === "copier.copy.commonproject") {
1449
+ logs.push(
1450
+ action.data.agent_hooks_layer === "false" ? "commonproject: agent-hooks layer skipped (global ~/.agents/hooks detected \u2014 no per-user CLI injection)" : "commonproject: agent-hooks layer included"
1451
+ );
1452
+ mkdirSync4(dirname4(action.targetDir), { recursive: true });
1453
+ const result = spawnSync4(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
1454
+ if (result.stdout?.trim()) logs.push(result.stdout.trim());
1455
+ if (result.stderr?.trim()) logs.push(result.stderr.trim());
1456
+ if (result.error) {
1457
+ const code = result.error.code;
1458
+ errors.push(
1459
+ code === "ENOENT" ? "copier not found on PATH. Install with: uv tool install copier or pip install copier" : `copier failed: ${result.error.message}`
1460
+ );
1461
+ break;
1462
+ }
1463
+ if (result.status !== 0) {
1464
+ errors.push(`copier exited with status ${result.status ?? "unknown"}`);
1465
+ if (existsSync6(action.targetDir)) changedFiles.push(action.targetDir);
1466
+ break;
1467
+ }
1468
+ changedFiles.push(action.targetDir);
1469
+ } else if (action.kind === "project.write-manifest") {
1470
+ mkdirSync4(dirname4(action.path), { recursive: true });
1471
+ const next = `${JSON.stringify(action.manifest, null, 2)}
1472
+ `;
1473
+ const current = existsSync6(action.path) ? readFileSync2(action.path, "utf8") : void 0;
1474
+ if (current !== next) {
1475
+ writeFileSync3(action.path, next, "utf8");
1476
+ changedFiles.push(action.path);
1477
+ }
1478
+ } else if (action.kind === "registry.upsert") {
1479
+ pendingRegistryAction = action;
1480
+ } else if (action.kind === "ticket-provider.create-or-link") {
1481
+ logs.push(action.enabled ? "ticket-provider.create-or-link requires a live provider integration" : "ticket-provider.create-or-link skipped (requires --live)");
1482
+ } else if (action.kind === "hermes.provision-agent") {
1483
+ logs.push(action.enabled ? "hermes.provision-agent planned for the caller to execute" : "hermes.provision-agent skipped");
1484
+ }
1485
+ }
1486
+ if (pendingRegistryAction && errors.length === 0) {
1487
+ if (!projectRecordEquivalent(registry.projects[pendingRegistryAction.slug], pendingRegistryAction.project)) {
1488
+ registry.projects[pendingRegistryAction.slug] = pendingRegistryAction.project;
1489
+ saveProjectRegistry(registry, pendingRegistryAction.registryPath);
1490
+ changedFiles.push(pendingRegistryAction.registryPath);
1491
+ }
1492
+ }
1493
+ return { ok: errors.length === 0, plan, logs, errors, changedFiles };
1494
+ }
1495
+ function projectManifestFromRegistryProject(project) {
1496
+ const agents = Object.fromEntries(
1497
+ Object.entries(project.agents).map(([name, agent]) => [
1498
+ `${project.slug}-${name}`,
1499
+ {
1500
+ role: agent.role,
1501
+ role_dir: agent.role_dir,
1502
+ provisioning_state: agent.provisioning_state
1503
+ }
1504
+ ])
1505
+ );
1506
+ return {
1507
+ project_name: project.name,
1508
+ project_description: project.description,
1509
+ project_slug: project.slug,
1510
+ repo_path: project.repo_path,
1511
+ ticket_provider: {
1512
+ type: project.ticket_provider.type,
1513
+ workspace: project.ticket_provider.workspace ?? "",
1514
+ identifier: project.ticket_provider.identifier ?? "",
1515
+ board_id: project.ticket_provider.board_id ?? "",
1516
+ board_url: project.ticket_provider.board_url ?? "",
1517
+ state: project.ticket_provider.state
1518
+ },
1519
+ agents
1520
+ };
1521
+ }
1522
+ function getProject(registry, slug) {
1523
+ const project = registry.projects[slug];
1524
+ if (!project) throw new Error(`Project not found in registry: ${slug}`);
1525
+ return project;
1526
+ }
1527
+ function buildCommonProjectCopierAction(input) {
1528
+ const templateDir = join8(input.pjanglerRoot, "templates", "commonproject");
1529
+ const data = {
1530
+ project_name: input.projectName,
1531
+ project_description: input.projectDescription ?? "",
1532
+ project_slug: input.projectSlug,
1533
+ ticket_provider: input.ticketProvider,
1534
+ plane_workspace: input.planeWorkspace,
1535
+ plane_project_id: input.planeProjectId ?? "",
1536
+ ticket_workspace: input.ticketWorkspace ?? input.planeWorkspace,
1537
+ board_id: input.boardId ?? input.planeProjectId ?? "",
1538
+ board_url: input.boardUrl ?? "",
1539
+ project_identifier: input.projectIdentifier,
1540
+ primary_language: input.primaryLanguage,
1541
+ agent_hooks_layer: input.agentHooksLayer ?? true ? "true" : "false"
1542
+ };
1543
+ const command = ["copier", "copy", "--trust", templateDir, input.targetDir, "--defaults"];
1544
+ for (const [key, value] of Object.entries(data)) command.push("--data", `${key}=${value}`);
1545
+ if (input.overwrite) command.push("--overwrite");
1546
+ return {
1547
+ kind: "copier.copy.commonproject",
1548
+ cwd: input.pjanglerRoot,
1549
+ command,
1550
+ targetDir: input.targetDir,
1551
+ data,
1552
+ overwrite: input.overwrite
1553
+ };
1554
+ }
1555
+ function resolvePjanglerRoot() {
1556
+ let dir = dirname4(new URL(import.meta.url).pathname);
1557
+ while (dir !== dirname4(dir)) {
1558
+ if (existsSync6(join8(dir, "package.json")) && existsSync6(join8(dir, "templates", "commonproject", "copier.yml"))) return dir;
1559
+ dir = dirname4(dir);
1560
+ }
1561
+ return resolve(process.cwd());
1562
+ }
1563
+ function validateNoDuplicateProject(registry, project, overwrite) {
1564
+ const existingSameSlug = registry.projects[project.slug];
1565
+ if (existingSameSlug && !overwrite && resolve(existingSameSlug.repo_path) !== resolve(project.repo_path)) {
1566
+ throw new Error(`Project slug already exists in registry: ${project.slug}`);
1567
+ }
1568
+ for (const [slug, existing] of Object.entries(registry.projects)) {
1569
+ if (slug === project.slug) continue;
1570
+ if (resolve(existing.repo_path) === resolve(project.repo_path)) {
1571
+ throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
1572
+ }
1573
+ if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
1574
+ throw new Error(`Project identifier already registered by ${slug}: ${project.ticket_provider.identifier}`);
1575
+ }
1576
+ }
1577
+ }
1578
+ function validateProjectRecord(project, key) {
1579
+ if (!isRecord(project)) throw new Error(`Project ${key} must be a mapping`);
1580
+ if (!project.name) throw new Error(`Project ${key} missing name`);
1581
+ if (!project.slug) throw new Error(`Project ${key} missing slug`);
1582
+ if (project.slug !== key) throw new Error(`Project key ${key} does not match slug ${project.slug}`);
1583
+ if (!project.repo_path) throw new Error(`Project ${key} missing repo_path`);
1584
+ if (!Array.isArray(project.source_artifacts)) throw new Error(`Project ${key} source_artifacts must be a list`);
1585
+ if (!isRecord(project.ticket_provider)) throw new Error(`Project ${key} ticket_provider must be a mapping`);
1586
+ if (!isRecord(project.agents)) throw new Error(`Project ${key} agents must be a mapping`);
1587
+ }
1588
+ function expandHome(path) {
1589
+ if (path === "~") return homedir3();
1590
+ if (path.startsWith("~/")) return join8(homedir3(), path.slice(2));
1591
+ return path;
1592
+ }
1593
+ function isRecord(value) {
1594
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1595
+ }
1596
+
1597
+ // src/commands/AgentHooksCommands.ts
1598
+ 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.";
1599
+ function resolveTemplateRoot() {
1600
+ const candidates = [];
1601
+ if (process.env.PJANGLER_COMMONPROJECT_TEMPLATE) {
1602
+ candidates.push(process.env.PJANGLER_COMMONPROJECT_TEMPLATE);
1603
+ }
1604
+ try {
1605
+ let dir = dirname5(fileURLToPath2(import.meta.url));
1606
+ for (let i = 0; i < 8; i++) {
1607
+ candidates.push(join9(dir, "templates", "commonproject", "template"));
1608
+ const parent = dirname5(dir);
1609
+ if (parent === dir) break;
1610
+ dir = parent;
1611
+ }
1612
+ } catch {
1613
+ }
1614
+ candidates.push(join9(homedir4(), "code", "pjangler", "templates", "commonproject", "template"));
1615
+ for (const c of candidates) {
1616
+ if (existsSync7(join9(c, ".agents", "hooks", "hooks.master.json"))) return c;
1617
+ }
1618
+ throw new Error(
1619
+ "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
1620
+ );
1621
+ }
1622
+ var CopyAgentHooksTree = class extends Command {
1623
+ async invoke() {
1624
+ if (!resolveAgentHooksLayer()) {
1625
+ return { success: true, message: this.formatMessage(AGENT_HOOKS_SKIP_MESSAGE) };
1626
+ }
1627
+ let templateRoot;
1628
+ try {
1629
+ templateRoot = resolveTemplateRoot();
1630
+ } catch (e) {
1631
+ return { success: false, message: `\u26A0\uFE0F ${e.message}` };
1632
+ }
1633
+ const items = [
1634
+ { rel: ".agents/hooks", dir: true },
1635
+ { rel: ".agents/local.example.json", dir: false },
1636
+ { rel: ".mise/scripts/link-project-skills-to-clis.sh", dir: false },
1637
+ { rel: ".mise/scripts/unlink-project-skills-from-clis.sh", dir: false },
1638
+ { rel: ".mise/scripts/hindsight-setup.sh", dir: false }
1639
+ ];
1640
+ const created = [];
1641
+ const skipped = [];
1642
+ for (const { rel, dir } of items) {
1643
+ const src = join9(templateRoot, rel);
1644
+ const dest = join9(this.context.targetDir, rel);
1645
+ if (!existsSync7(src)) continue;
1646
+ if (existsSync7(dest) && !this.context.force) {
1647
+ skipped.push(rel);
1648
+ continue;
1649
+ }
1650
+ if (!this.context.dryRun) {
1651
+ mkdirSync5(dirname5(dest), { recursive: true });
1652
+ cpSync(src, dest, { recursive: dir, force: true });
1653
+ }
1654
+ created.push(rel);
1655
+ }
1656
+ const verb = this.context.dryRun ? "Would copy" : "Copied";
1657
+ const tail = skipped.length ? ` (${skipped.length} already present \u2014 use --force to overwrite)` : "";
1658
+ return {
1659
+ success: created.length > 0,
1660
+ message: this.formatMessage(`\u2705 ${verb} ${created.length} agent-hooks path(s)${tail}`)
1661
+ };
1254
1662
  }
1255
1663
  };
1256
1664
  var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
@@ -1258,14 +1666,17 @@ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
1258
1666
  static CR = "{{config_root}}";
1259
1667
  // mise's own runtime var — emitted literally
1260
1668
  async invoke() {
1261
- const misePath = join8(this.context.targetDir, "mise.toml");
1262
- if (!existsSync6(misePath)) {
1669
+ if (!resolveAgentHooksLayer()) {
1670
+ return { success: true, message: this.formatMessage(AGENT_HOOKS_SKIP_MESSAGE) };
1671
+ }
1672
+ const misePath = join9(this.context.targetDir, "mise.toml");
1673
+ if (!existsSync7(misePath)) {
1263
1674
  return {
1264
1675
  success: false,
1265
1676
  message: "\u26A0\uFE0F No mise.toml found \u2014 run `pjangler init mise` first, then re-run."
1266
1677
  };
1267
1678
  }
1268
- let content = readFileSync2(misePath, "utf8");
1679
+ let content = readFileSync3(misePath, "utf8");
1269
1680
  if (content.includes(_WireMiseAgentHooks.MARKER)) {
1270
1681
  return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for agent-hooks") };
1271
1682
  }
@@ -1341,7 +1752,7 @@ ${leaveBlock}`);
1341
1752
  ""
1342
1753
  ].join("\n");
1343
1754
  content = content.replace(/\n*$/, "\n") + appended;
1344
- if (!this.context.dryRun) writeFileSync3(misePath, content);
1755
+ if (!this.context.dryRun) writeFileSync4(misePath, content);
1345
1756
  if (wiredHooks) {
1346
1757
  return { success: true, message: this.formatMessage("\u2705 Wired mise.toml ([hooks] enter/leave + tasks)") };
1347
1758
  }
@@ -1495,18 +1906,18 @@ function createRecipe(name, context) {
1495
1906
  }
1496
1907
 
1497
1908
  // src/utils/version.ts
1498
- import { readFileSync as readFileSync3 } from "node:fs";
1499
- import { dirname as dirname5, join as join9 } from "node:path";
1909
+ import { readFileSync as readFileSync4 } from "node:fs";
1910
+ import { dirname as dirname6, join as join10 } from "node:path";
1500
1911
  import { fileURLToPath as fileURLToPath3 } from "node:url";
1501
1912
  var PJANGLER_VERSION = (() => {
1502
1913
  try {
1503
- let dir = dirname5(fileURLToPath3(import.meta.url));
1914
+ let dir = dirname6(fileURLToPath3(import.meta.url));
1504
1915
  for (let i = 0; i < 4; i++) {
1505
1916
  try {
1506
- const raw = readFileSync3(join9(dir, "package.json"), "utf8");
1917
+ const raw = readFileSync4(join10(dir, "package.json"), "utf8");
1507
1918
  return JSON.parse(raw).version ?? "0.0.0";
1508
1919
  } catch {
1509
- const parent = dirname5(dir);
1920
+ const parent = dirname6(dir);
1510
1921
  if (parent === dir) break;
1511
1922
  dir = parent;
1512
1923
  }
@@ -1517,11 +1928,11 @@ var PJANGLER_VERSION = (() => {
1517
1928
  })();
1518
1929
 
1519
1930
  // src/parity/index.ts
1520
- import { existsSync as existsSync7, lstatSync, mkdirSync as mkdirSync5, readFileSync as readFileSync4, readlinkSync, readdirSync, renameSync, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4, chmodSync as chmodSync2, copyFileSync } from "node:fs";
1521
- import { basename as basename2, dirname as dirname6, join as join10, relative, resolve } from "node:path";
1931
+ import { existsSync as existsSync8, lstatSync, mkdirSync as mkdirSync6, readFileSync as readFileSync5, readlinkSync, readdirSync, renameSync as renameSync2, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync5, chmodSync as chmodSync2, copyFileSync } from "node:fs";
1932
+ import { basename as basename3, dirname as dirname7, join as join11, relative, resolve as resolve2 } from "node:path";
1522
1933
  import { fileURLToPath as fileURLToPath4 } from "node:url";
1523
- import { homedir as homedir4 } from "node:os";
1524
- import { spawnSync as spawnSync4 } from "node:child_process";
1934
+ import { homedir as homedir5 } from "node:os";
1935
+ import { spawnSync as spawnSync5 } from "node:child_process";
1525
1936
  var LINK_AGENTFILES_BLOCK = `# This block will handle the linking of
1526
1937
  # agent files to the main AGENTS.md file.
1527
1938
  #
@@ -1589,13 +2000,13 @@ run = "{{config_root}}/.mise/scripts/versioning.sh check"
1589
2000
  description = "Force every versioned file up to the highest version"
1590
2001
  run = "{{config_root}}/.mise/scripts/versioning.sh sync"
1591
2002
  # <<< mise-versioning <<<`;
1592
- function resolvePjanglerRoot() {
1593
- let dir = dirname6(fileURLToPath4(import.meta.url));
1594
- while (dir !== dirname6(dir)) {
1595
- if (existsSync7(join10(dir, "package.json")) && existsSync7(join10(dir, "templates", "commonproject", "copier.yml"))) {
2003
+ function resolvePjanglerRoot2() {
2004
+ let dir = dirname7(fileURLToPath4(import.meta.url));
2005
+ while (dir !== dirname7(dir)) {
2006
+ if (existsSync8(join11(dir, "package.json")) && existsSync8(join11(dir, "templates", "commonproject", "copier.yml"))) {
1596
2007
  return dir;
1597
2008
  }
1598
- dir = dirname6(dir);
2009
+ dir = dirname7(dir);
1599
2010
  }
1600
2011
  throw new Error("Unable to resolve pjangler root");
1601
2012
  }
@@ -1603,17 +2014,17 @@ function normalizeNewlines(value) {
1603
2014
  return value.replace(/\r\n/g, "\n");
1604
2015
  }
1605
2016
  function readText(path) {
1606
- return normalizeNewlines(readFileSync4(path, "utf8"));
2017
+ return normalizeNewlines(readFileSync5(path, "utf8"));
1607
2018
  }
1608
2019
  function safeReadText(path) {
1609
- return existsSync7(path) ? readText(path) : null;
2020
+ return existsSync8(path) ? readText(path) : null;
1610
2021
  }
1611
2022
  function ensureParent(path) {
1612
- mkdirSync5(dirname6(path), { recursive: true });
2023
+ mkdirSync6(dirname7(path), { recursive: true });
1613
2024
  }
1614
2025
  function writeText(path, content) {
1615
2026
  ensureParent(path);
1616
- writeFileSync4(path, content);
2027
+ writeFileSync5(path, content);
1617
2028
  }
1618
2029
  function tryParseJson(text2) {
1619
2030
  if (!text2) return null;
@@ -1630,7 +2041,7 @@ function titleCaseSlug(slug) {
1630
2041
  return slug.split(/[-_]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
1631
2042
  }
1632
2043
  function readSymlinkTarget(path) {
1633
- if (!existsSync7(path)) return null;
2044
+ if (!existsSync8(path)) return null;
1634
2045
  try {
1635
2046
  return readlinkSync(path);
1636
2047
  } catch {
@@ -1638,7 +2049,7 @@ function readSymlinkTarget(path) {
1638
2049
  }
1639
2050
  }
1640
2051
  function ensureSymlink(path, target, dryRun) {
1641
- if (existsSync7(path)) {
2052
+ if (existsSync8(path)) {
1642
2053
  const stat = lstatSync(path);
1643
2054
  if (stat.isSymbolicLink()) {
1644
2055
  const current = readSymlinkTarget(path);
@@ -1655,21 +2066,21 @@ function ensureSymlink(path, target, dryRun) {
1655
2066
  return { changed: true };
1656
2067
  }
1657
2068
  function bootstrapAgentsFile(repoRoot, dryRun) {
1658
- const agentsPath = join10(repoRoot, "AGENTS.md");
1659
- if (existsSync7(agentsPath)) return { changedFiles: [], details: [] };
2069
+ const agentsPath = join11(repoRoot, "AGENTS.md");
2070
+ if (existsSync8(agentsPath)) return { changedFiles: [], details: [] };
1660
2071
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
1661
- const source = join10(repoRoot, file);
1662
- if (!existsSync7(source)) continue;
2072
+ const source = join11(repoRoot, file);
2073
+ if (!existsSync8(source)) continue;
1663
2074
  const stat = lstatSync(source);
1664
2075
  if (stat.isSymbolicLink()) continue;
1665
2076
  if (stat.isFile()) {
1666
- if (!dryRun) renameSync(source, agentsPath);
2077
+ if (!dryRun) renameSync2(source, agentsPath);
1667
2078
  return { changedFiles: [agentsPath], details: [`Moved ${file} to AGENTS.md before wiring agent-file symlinks`] };
1668
2079
  }
1669
2080
  return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
1670
2081
  }
1671
- const readmePath = join10(repoRoot, "README.md");
1672
- if (existsSync7(readmePath)) {
2082
+ const readmePath = join11(repoRoot, "README.md");
2083
+ if (existsSync8(readmePath)) {
1673
2084
  const stat = lstatSync(readmePath);
1674
2085
  if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
1675
2086
  if (!dryRun) copyFileSync(readmePath, agentsPath);
@@ -1708,12 +2119,12 @@ function yamlGet(text2, keyPath) {
1708
2119
  return "";
1709
2120
  }
1710
2121
  function discoverRoles(repoRoot) {
1711
- const rolesDir = join10(repoRoot, "agents", "hermes");
1712
- if (!existsSync7(rolesDir)) return [];
2122
+ const rolesDir = join11(repoRoot, "agents", "hermes");
2123
+ if (!existsSync8(rolesDir)) return [];
1713
2124
  return readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
1714
- const roleDir = join10(rolesDir, entry.name);
1715
- const roleYamlPath = join10(roleDir, "role.yaml");
1716
- if (!existsSync7(roleYamlPath)) return null;
2125
+ const roleDir = join11(rolesDir, entry.name);
2126
+ const roleYamlPath = join11(roleDir, "role.yaml");
2127
+ if (!existsSync8(roleYamlPath)) return null;
1717
2128
  const text2 = readText(roleYamlPath);
1718
2129
  const runtimeRepoRaw = yamlGet(text2, "runtime.github_repo");
1719
2130
  return {
@@ -1737,10 +2148,10 @@ function discoverRoles(repoRoot) {
1737
2148
  }).filter((value) => Boolean(value));
1738
2149
  }
1739
2150
  function registryPath(homeDir) {
1740
- return join10(homeDir, ".hermes", "agents-registry.yaml");
2151
+ return join11(homeDir, ".hermes", "agents-registry.yaml");
1741
2152
  }
1742
2153
  function systemctlUser(args) {
1743
- const result = spawnSync4("systemctl", ["--user", ...args], { encoding: "utf8" });
2154
+ const result = spawnSync5("systemctl", ["--user", ...args], { encoding: "utf8" });
1744
2155
  return {
1745
2156
  ok: result.status === 0,
1746
2157
  stdout: result.stdout.trim(),
@@ -1748,8 +2159,8 @@ function systemctlUser(args) {
1748
2159
  };
1749
2160
  }
1750
2161
  function templateScript(ctx, name) {
1751
- const source = join10(ctx.pjanglerRoot, ".mise", "scripts", name);
1752
- return existsSync7(source) ? readText(source) : void 0;
2162
+ const source = join11(ctx.pjanglerRoot, ".mise", "scripts", name);
2163
+ return existsSync8(source) ? readText(source) : void 0;
1753
2164
  }
1754
2165
  function templateVersioningScript(ctx) {
1755
2166
  return templateScript(ctx, "versioning.sh");
@@ -1759,14 +2170,14 @@ function templateLinkAgentfilesScript(ctx) {
1759
2170
  }
1760
2171
  function renderGeneratedProjectMiseToml(ctx, template) {
1761
2172
  const project = readProjectJson(ctx);
1762
- const projectName = String(project?.project_name ?? basename2(ctx.repoRoot) ?? "project");
2173
+ const projectName = String(project?.project_name ?? basename3(ctx.repoRoot) ?? "project");
1763
2174
  return template.replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
1764
2175
  }
1765
2176
  function ensureMiseTomlFromTemplate(ctx, changedFiles) {
1766
- const targetPath = join10(ctx.repoRoot, "mise.toml");
1767
- if (existsSync7(targetPath)) return false;
1768
- const sourcePath = join10(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
1769
- if (!existsSync7(sourcePath)) return false;
2177
+ const targetPath = join11(ctx.repoRoot, "mise.toml");
2178
+ if (existsSync8(targetPath)) return false;
2179
+ const sourcePath = join11(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
2180
+ if (!existsSync8(sourcePath)) return false;
1770
2181
  changedFiles.push(targetPath);
1771
2182
  if (!ctx.dryRun) {
1772
2183
  writeText(targetPath, renderGeneratedProjectMiseToml(ctx, readText(sourcePath)));
@@ -1774,8 +2185,8 @@ function ensureMiseTomlFromTemplate(ctx, changedFiles) {
1774
2185
  return true;
1775
2186
  }
1776
2187
  function templateVersionFilesConf(ctx, repoRoot) {
1777
- const packageJson = join10(repoRoot, "package.json");
1778
- 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";
2188
+ const packageJson = join11(repoRoot, "package.json");
2189
+ return existsSync8(packageJson) ? "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\njson package.json\ngittag .\n" : "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\ngittag .\n";
1779
2190
  }
1780
2191
  function replaceOrAppendManagedBlock(text2, startMarker, block, beforePattern) {
1781
2192
  if (startMarker.test(text2)) {
@@ -1799,7 +2210,7 @@ var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/herm
1799
2210
  function requiredMisePathEntries(ctx) {
1800
2211
  const required = [...BASE_MISE_PATH_ENTRIES];
1801
2212
  for (const candidate of CONDITIONAL_HERMES_PATHS) {
1802
- if (existsSync7(join10(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
2213
+ if (existsSync8(join11(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
1803
2214
  }
1804
2215
  return required;
1805
2216
  }
@@ -1948,12 +2359,12 @@ function upsertLinkAgentfilesBlock(text2, ctx) {
1948
2359
  return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
1949
2360
  }
1950
2361
  function readProjectJson(ctx) {
1951
- return tryParseJson(safeReadText(join10(ctx.repoRoot, ".project.json")));
2362
+ return tryParseJson(safeReadText(join11(ctx.repoRoot, ".project.json")));
1952
2363
  }
1953
2364
  function canonicalProjectJson(ctx) {
1954
2365
  const roles = discoverRoles(ctx.repoRoot);
1955
2366
  const existing = readProjectJson(ctx) ?? {};
1956
- const slug = String(existing.project_slug ?? slugifyRepoName(dirname6(ctx.repoRoot) === ctx.repoRoot ? ctx.repoRoot.split("/").pop() ?? "project" : ctx.repoRoot.split("/").pop() ?? "project"));
2367
+ const slug = String(existing.project_slug ?? slugifyRepoName(dirname7(ctx.repoRoot) === ctx.repoRoot ? ctx.repoRoot.split("/").pop() ?? "project" : ctx.repoRoot.split("/").pop() ?? "project"));
1957
2368
  const firstRole = roles[0];
1958
2369
  const ticketProvider = {
1959
2370
  type: String((existing.ticket_provider?.type ?? firstRole?.ticketProviderName ?? "plane") || "plane"),
@@ -1992,12 +2403,12 @@ function canonicalProjectJson(ctx) {
1992
2403
  };
1993
2404
  }
1994
2405
  function projectJsonFinding(ctx) {
1995
- const projectPath = join10(ctx.repoRoot, ".project.json");
1996
- const planeJsonPath = join10(ctx.repoRoot, ".plane.json");
2406
+ const projectPath = join11(ctx.repoRoot, ".project.json");
2407
+ const planeJsonPath = join11(ctx.repoRoot, ".plane.json");
1997
2408
  const details = [];
1998
2409
  const data = readProjectJson(ctx);
1999
2410
  const roles = discoverRoles(ctx.repoRoot);
2000
- if (!existsSync7(projectPath)) {
2411
+ if (!existsSync8(projectPath)) {
2001
2412
  return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json missing", details: [], fixable: true };
2002
2413
  }
2003
2414
  if (!data) {
@@ -2023,7 +2434,7 @@ function projectJsonFinding(ctx) {
2023
2434
  for (const key of ["type", "workspace", "identifier", "board_id", "board_url", "state"]) {
2024
2435
  if (!(key in ticketProvider)) details.push(`ticket_provider.${key} missing`);
2025
2436
  }
2026
- if (existsSync7(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
2437
+ if (existsSync8(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
2027
2438
  return {
2028
2439
  id: "sot.project-json",
2029
2440
  title: "Canonical .project.json",
@@ -2104,17 +2515,17 @@ exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" HERMES_OAUTH
2104
2515
  `.replace(/\u0010/g, "$");
2105
2516
  }
2106
2517
  function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip) {
2107
- if (!existsSync7(sourceDir)) return;
2108
- mkdirSync5(targetDir, { recursive: true });
2518
+ if (!existsSync8(sourceDir)) return;
2519
+ mkdirSync6(targetDir, { recursive: true });
2109
2520
  for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
2110
- const sourcePath = join10(sourceDir, entry.name);
2521
+ const sourcePath = join11(sourceDir, entry.name);
2111
2522
  if (skip?.(sourcePath)) continue;
2112
- const targetPath = join10(targetDir, entry.name);
2523
+ const targetPath = join11(targetDir, entry.name);
2113
2524
  if (entry.isDirectory()) {
2114
2525
  copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
2115
2526
  continue;
2116
2527
  }
2117
- if (existsSync7(targetPath)) continue;
2528
+ if (existsSync8(targetPath)) continue;
2118
2529
  changedFiles.push(targetPath);
2119
2530
  if (!dryRun) {
2120
2531
  ensureParent(targetPath);
@@ -2123,7 +2534,7 @@ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip)
2123
2534
  }
2124
2535
  }
2125
2536
  function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
2126
- const gitmodulesPath = join10(repoRoot, ".gitmodules");
2537
+ const gitmodulesPath = join11(repoRoot, ".gitmodules");
2127
2538
  const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
2128
2539
  const owner = role.runtimeOwner || "delorenj";
2129
2540
  const block = `[submodule "agents/hermes/${role.role}/runtime"]
@@ -2145,7 +2556,7 @@ function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
2145
2556
  repo: ${role.repo}
2146
2557
  role: ${role.role}
2147
2558
  display_name: ${JSON.stringify(role.displayName || role.agentId)}
2148
- project_path: ${ctxEscape(role.roleDir ? dirname6(dirname6(dirname6(role.roleDir))) : "")}
2559
+ project_path: ${ctxEscape(role.roleDir ? dirname7(dirname7(dirname7(role.roleDir))) : "")}
2149
2560
  role_dir: ${ctxEscape(role.roleDir)}
2150
2561
  profile_name: ${role.profileName || role.agentId}
2151
2562
  telegram:
@@ -2223,14 +2634,14 @@ var RULES = [
2223
2634
  id: "mise.config-root",
2224
2635
  title: "mise config_root + AGENTS link hooks",
2225
2636
  audit: (ctx) => {
2226
- const misePath = join10(ctx.repoRoot, "mise.toml");
2227
- if (!existsSync7(misePath)) {
2637
+ const misePath = join11(ctx.repoRoot, "mise.toml");
2638
+ if (!existsSync8(misePath)) {
2228
2639
  return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
2229
2640
  }
2230
2641
  const text2 = readText(misePath);
2231
2642
  const details = [];
2232
- const linkAgentfilesPath = join10(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2233
- if (!existsSync7(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2643
+ const linkAgentfilesPath = join11(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2644
+ if (!existsSync8(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2234
2645
  const pathValues = [...(text2.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2235
2646
  const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
2236
2647
  if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
@@ -2248,10 +2659,10 @@ var RULES = [
2248
2659
  };
2249
2660
  },
2250
2661
  migrate: (ctx, finding) => {
2251
- const path = join10(ctx.repoRoot, "mise.toml");
2662
+ const path = join11(ctx.repoRoot, "mise.toml");
2252
2663
  const changedFiles = [];
2253
2664
  const details = [];
2254
- if (!existsSync7(path)) {
2665
+ if (!existsSync8(path)) {
2255
2666
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2256
2667
  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: [] };
2257
2668
  }
@@ -2267,7 +2678,7 @@ var RULES = [
2267
2678
  if (!ctx.dryRun) writeText(path, next);
2268
2679
  text2 = next;
2269
2680
  }
2270
- const linkAgentfilesPath = join10(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2681
+ const linkAgentfilesPath = join11(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2271
2682
  const expectedScript = templateLinkAgentfilesScript(ctx);
2272
2683
  if (expectedScript === void 0) {
2273
2684
  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: [] };
@@ -2294,13 +2705,13 @@ var RULES = [
2294
2705
  title: "managed mise versioning block",
2295
2706
  audit: (ctx) => {
2296
2707
  const details = [];
2297
- const misePath = join10(ctx.repoRoot, "mise.toml");
2298
- const versioningPath = join10(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2299
- const manifestPath = join10(ctx.repoRoot, ".mise", "version-files.conf");
2708
+ const misePath = join11(ctx.repoRoot, "mise.toml");
2709
+ const versioningPath = join11(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2710
+ const manifestPath = join11(ctx.repoRoot, ".mise", "version-files.conf");
2300
2711
  const text2 = safeReadText(misePath);
2301
2712
  if (!text2?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
2302
- if (!existsSync7(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
2303
- if (!existsSync7(manifestPath)) details.push(".mise/version-files.conf missing");
2713
+ if (!existsSync8(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
2714
+ if (!existsSync8(manifestPath)) details.push(".mise/version-files.conf missing");
2304
2715
  return {
2305
2716
  id: "mise.versioning",
2306
2717
  title: "managed mise versioning block",
@@ -2313,8 +2724,8 @@ var RULES = [
2313
2724
  migrate: (ctx, finding) => {
2314
2725
  const changedFiles = [];
2315
2726
  const details = [];
2316
- const misePath = join10(ctx.repoRoot, "mise.toml");
2317
- if (!existsSync7(misePath)) {
2727
+ const misePath = join11(ctx.repoRoot, "mise.toml");
2728
+ if (!existsSync8(misePath)) {
2318
2729
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2319
2730
  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: [] };
2320
2731
  }
@@ -2329,7 +2740,7 @@ var RULES = [
2329
2740
  if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
2330
2741
  if (!ctx.dryRun) writeText(misePath, nextMise);
2331
2742
  }
2332
- const versioningPath = join10(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2743
+ const versioningPath = join11(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2333
2744
  const expectedScript = templateVersioningScript(ctx);
2334
2745
  if (expectedScript === void 0) {
2335
2746
  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: [] };
@@ -2341,7 +2752,7 @@ var RULES = [
2341
2752
  chmodSync2(versioningPath, 493);
2342
2753
  }
2343
2754
  }
2344
- const manifestPath = join10(ctx.repoRoot, ".mise", "version-files.conf");
2755
+ const manifestPath = join11(ctx.repoRoot, ".mise", "version-files.conf");
2345
2756
  const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
2346
2757
  if (safeReadText(manifestPath) !== expectedManifest) {
2347
2758
  changedFiles.push(manifestPath);
@@ -2361,9 +2772,9 @@ var RULES = [
2361
2772
  id: "sot.agent-symlinks",
2362
2773
  title: "AGENTS/CLAUDE/GEMINI symlink contract",
2363
2774
  audit: (ctx) => {
2364
- const agentsPath = join10(ctx.repoRoot, "AGENTS.md");
2365
- if (!existsSync7(agentsPath)) {
2366
- const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync7(join10(ctx.repoRoot, file)));
2775
+ const agentsPath = join11(ctx.repoRoot, "AGENTS.md");
2776
+ if (!existsSync8(agentsPath)) {
2777
+ const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync8(join11(ctx.repoRoot, file)));
2367
2778
  if (fallbackSources.length === 0) {
2368
2779
  return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
2369
2780
  }
@@ -2378,7 +2789,7 @@ var RULES = [
2378
2789
  }
2379
2790
  const details = [];
2380
2791
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2381
- const full = join10(ctx.repoRoot, file);
2792
+ const full = join11(ctx.repoRoot, file);
2382
2793
  const target = readSymlinkTarget(full);
2383
2794
  if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
2384
2795
  }
@@ -2402,7 +2813,7 @@ var RULES = [
2402
2813
  return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
2403
2814
  }
2404
2815
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2405
- const full = join10(ctx.repoRoot, file);
2816
+ const full = join11(ctx.repoRoot, file);
2406
2817
  const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
2407
2818
  if (result.blocked) blockedDetails.push(result.blocked);
2408
2819
  if (result.changed) changedFiles.push(full);
@@ -2424,7 +2835,7 @@ var RULES = [
2424
2835
  migrate: (ctx, finding) => {
2425
2836
  const changedFiles = [];
2426
2837
  const details = [];
2427
- const path = join10(ctx.repoRoot, ".project.json");
2838
+ const path = join11(ctx.repoRoot, ".project.json");
2428
2839
  const existing = readProjectJson(ctx) ?? {};
2429
2840
  const canonical = canonicalProjectJson(ctx);
2430
2841
  const merged = { ...existing, ...canonical };
@@ -2434,14 +2845,14 @@ var RULES = [
2434
2845
  changedFiles.push(path);
2435
2846
  if (!ctx.dryRun) writeText(path, expected);
2436
2847
  }
2437
- const planeJson = join10(ctx.repoRoot, ".plane.json");
2438
- if (existsSync7(planeJson)) {
2848
+ const planeJson = join11(ctx.repoRoot, ".plane.json");
2849
+ if (existsSync8(planeJson)) {
2439
2850
  const backup = `${planeJson}.migrated-backup`;
2440
- if (existsSync7(backup)) {
2851
+ if (existsSync8(backup)) {
2441
2852
  details.push(`cannot back up .plane.json because ${relative(ctx.repoRoot, backup)} already exists`);
2442
2853
  } else {
2443
2854
  changedFiles.push(backup);
2444
- if (!ctx.dryRun) renameSync(planeJson, backup);
2855
+ if (!ctx.dryRun) renameSync2(planeJson, backup);
2445
2856
  }
2446
2857
  }
2447
2858
  return {
@@ -2459,8 +2870,8 @@ var RULES = [
2459
2870
  title: ".env.op + gitignore secrets contract",
2460
2871
  audit: (ctx) => {
2461
2872
  const details = [];
2462
- const envOp = safeReadText(join10(ctx.repoRoot, ".env.op"));
2463
- const gitignore = safeReadText(join10(ctx.repoRoot, ".gitignore"));
2873
+ const envOp = safeReadText(join11(ctx.repoRoot, ".env.op"));
2874
+ const gitignore = safeReadText(join11(ctx.repoRoot, ".gitignore"));
2464
2875
  if (!envOp) {
2465
2876
  details.push(".env.op missing");
2466
2877
  } else {
@@ -2486,12 +2897,12 @@ var RULES = [
2486
2897
  migrate: (ctx, finding) => {
2487
2898
  const changedFiles = [];
2488
2899
  const details = [];
2489
- const envOpPath = join10(ctx.repoRoot, ".env.op");
2490
- if (!existsSync7(envOpPath)) {
2900
+ const envOpPath = join11(ctx.repoRoot, ".env.op");
2901
+ if (!existsSync8(envOpPath)) {
2491
2902
  changedFiles.push(envOpPath);
2492
- if (!ctx.dryRun) writeText(envOpPath, readText(join10(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2903
+ if (!ctx.dryRun) writeText(envOpPath, readText(join11(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2493
2904
  }
2494
- const gitignorePath = join10(ctx.repoRoot, ".gitignore");
2905
+ const gitignorePath = join11(ctx.repoRoot, ".gitignore");
2495
2906
  const gitignore = safeReadText(gitignorePath) ?? "";
2496
2907
  const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
2497
2908
  # NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
@@ -2518,7 +2929,7 @@ var RULES = [
2518
2929
  title: ".copier-answers.yml provenance + drift report",
2519
2930
  audit: (ctx) => {
2520
2931
  const details = [];
2521
- const path = join10(ctx.repoRoot, ".copier-answers.yml");
2932
+ const path = join11(ctx.repoRoot, ".copier-answers.yml");
2522
2933
  const text2 = safeReadText(path);
2523
2934
  const project = readProjectJson(ctx);
2524
2935
  if (!text2) {
@@ -2549,12 +2960,12 @@ var RULES = [
2549
2960
  const changedFiles = [];
2550
2961
  const project = canonicalProjectJson(ctx);
2551
2962
  const text2 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
2552
- _src_path: ${join10(ctx.pjanglerRoot, "templates", "commonproject")}
2963
+ _src_path: ${join11(ctx.pjanglerRoot, "templates", "commonproject")}
2553
2964
  project_description: ${String(project.project_description)}
2554
2965
  project_name: ${String(project.project_name)}
2555
2966
  ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2556
2967
  `;
2557
- const path = join10(ctx.repoRoot, ".copier-answers.yml");
2968
+ const path = join11(ctx.repoRoot, ".copier-answers.yml");
2558
2969
  if (safeReadText(path) !== text2) {
2559
2970
  changedFiles.push(path);
2560
2971
  if (!ctx.dryRun) writeText(path, text2);
@@ -2573,15 +2984,15 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2573
2984
  id: "bmad.scaffold",
2574
2985
  title: "BMAD modules/docs scaffold",
2575
2986
  audit: (ctx) => {
2576
- const sourceRoot = join10(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
2577
- const targetRoot = join10(ctx.repoRoot, "_bmad");
2987
+ const sourceRoot = join11(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
2988
+ const targetRoot = join11(ctx.repoRoot, "_bmad");
2578
2989
  const sentinels = [
2579
- join10("core", "config.yaml"),
2580
- join10("custom", "config.yaml"),
2581
- join10("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
2582
- join10("bmm", "workflows", "workflow-status", "workflow.yaml")
2990
+ join11("core", "config.yaml"),
2991
+ join11("custom", "config.yaml"),
2992
+ join11("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
2993
+ join11("bmm", "workflows", "workflow-status", "workflow.yaml")
2583
2994
  ];
2584
- const missing = sentinels.filter((file) => existsSync7(join10(sourceRoot, file)) && !existsSync7(join10(targetRoot, file)));
2995
+ const missing = sentinels.filter((file) => existsSync8(join11(sourceRoot, file)) && !existsSync8(join11(targetRoot, file)));
2585
2996
  return {
2586
2997
  id: "bmad.scaffold",
2587
2998
  title: "BMAD modules/docs scaffold",
@@ -2593,7 +3004,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2593
3004
  },
2594
3005
  migrate: (ctx, finding) => {
2595
3006
  const changedFiles = [];
2596
- copyMissingRecursive(join10(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join10(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
3007
+ copyMissingRecursive(join11(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join11(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
2597
3008
  return {
2598
3009
  id: finding.id,
2599
3010
  title: finding.title,
@@ -2615,11 +3026,11 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2615
3026
  }
2616
3027
  const details = [];
2617
3028
  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"]) {
2618
- if (!existsSync7(join10(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join10(role.roleDir, rel))}`);
3029
+ if (!existsSync8(join11(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join11(role.roleDir, rel))}`);
2619
3030
  }
2620
- const gitmodules = safeReadText(join10(ctx.repoRoot, ".gitmodules")) ?? "";
3031
+ const gitmodules = safeReadText(join11(ctx.repoRoot, ".gitmodules")) ?? "";
2621
3032
  if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing pm runtime submodule entry");
2622
- if (!profileMetaInheritsDefault(join10(role.roleDir, "runtime", "profile.yaml"))) {
3033
+ if (!profileMetaInheritsDefault(join11(role.roleDir, "runtime", "profile.yaml"))) {
2623
3034
  details.push("runtime/profile.yaml missing inherited default config metadata");
2624
3035
  }
2625
3036
  const registry = safeReadText(registryPath(ctx.homeDir));
@@ -2640,21 +3051,21 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2640
3051
  if (!role) {
2641
3052
  return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
2642
3053
  }
2643
- const templateRoleDir = join10(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
2644
- writeIfDifferent(join10(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
2645
- writeIfDifferent(join10(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
2646
- writeIfDifferent(join10(role.roleDir, ".gitignore"), readText(join10(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
2647
- copyMissingRecursive(join10(templateRoleDir, ".runtime-scaffold"), join10(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
2648
- copyMissingRecursive(join10(templateRoleDir, ".runtime-scaffold"), join10(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
2649
- copyMissingRecursive(join10(templateRoleDir, ".scripts"), join10(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
2650
- const promptSource = join10(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
2651
- const promptTarget = join10(role.roleDir, ".scripts", "sentinel.prompt.md");
2652
- if (existsSync7(promptSource) && !existsSync7(promptTarget)) {
3054
+ const templateRoleDir = join11(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
3055
+ writeIfDifferent(join11(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
3056
+ writeIfDifferent(join11(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
3057
+ writeIfDifferent(join11(role.roleDir, ".gitignore"), readText(join11(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
3058
+ copyMissingRecursive(join11(templateRoleDir, ".runtime-scaffold"), join11(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
3059
+ copyMissingRecursive(join11(templateRoleDir, ".runtime-scaffold"), join11(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
3060
+ copyMissingRecursive(join11(templateRoleDir, ".scripts"), join11(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
3061
+ const promptSource = join11(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
3062
+ const promptTarget = join11(role.roleDir, ".scripts", "sentinel.prompt.md");
3063
+ if (existsSync8(promptSource) && !existsSync8(promptTarget)) {
2653
3064
  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);
2654
3065
  writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
2655
3066
  }
2656
3067
  upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
2657
- const profileMetaUpdated = upsertInheritedProfileMeta(join10(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
3068
+ const profileMetaUpdated = upsertInheritedProfileMeta(join11(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
2658
3069
  if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
2659
3070
  const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
2660
3071
  if (registryUpdated) details.push(`updated ${registryUpdated}`);
@@ -2672,527 +3083,171 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2672
3083
  id: "systemd.sentinel",
2673
3084
  title: "Hermes systemd/sentinel units enabled + active",
2674
3085
  audit: (ctx) => {
2675
- const roles = discoverRoles(ctx.repoRoot);
2676
- if (!roles.length) {
2677
- return { id: "systemd.sentinel", title: "Hermes systemd/sentinel units enabled + active", status: "skip", summary: "No Hermes roles present", details: [], fixable: false };
2678
- }
2679
- const probe = systemctlUser(["is-system-running"]);
2680
- if (!probe.ok && !/running|degraded|starting|maintenance/.test(`${probe.stdout} ${probe.stderr}`)) {
2681
- return { id: "systemd.sentinel", title: "Hermes systemd/sentinel units enabled + active", status: "warn", summary: "systemd --user unavailable; unit state not auditable here", details: [], fixable: false };
2682
- }
2683
- const details = [];
2684
- for (const role of roles) {
2685
- for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
2686
- const state = checkUnit(unit);
2687
- if (!state.enabled || !state.active) details.push(`${unit} should be enabled+active`);
2688
- }
2689
- }
2690
- return {
2691
- id: "systemd.sentinel",
2692
- title: "Hermes systemd/sentinel units enabled + active",
2693
- status: details.length === 0 ? "pass" : "fail",
2694
- summary: details.length === 0 ? "Hermes user units are enabled and active" : `${details.length} systemd parity issue(s) detected`,
2695
- details,
2696
- fixable: true
2697
- };
2698
- },
2699
- migrate: (ctx, finding) => {
2700
- const roles = discoverRoles(ctx.repoRoot);
2701
- const changedFiles = [];
2702
- const details = [];
2703
- if (!roles.length) {
2704
- return { id: finding.id, title: finding.title, status: "blocked", summary: "No Hermes roles present", changedFiles, details };
2705
- }
2706
- const probe = systemctlUser(["is-system-running"]);
2707
- if (!probe.ok && !/running|degraded|starting|maintenance/.test(`${probe.stdout} ${probe.stderr}`)) {
2708
- return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
2709
- }
2710
- for (const role of roles) {
2711
- const sysDir = join10(ctx.homeDir, ".config", "systemd", "user");
2712
- const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
2713
- const allUnitsPresent = units.every((unit) => existsSync7(join10(sysDir, unit)));
2714
- if (allUnitsPresent) {
2715
- if (ctx.dryRun) {
2716
- details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
2717
- } else {
2718
- systemctlUser(["daemon-reload"]);
2719
- for (const unit of units) {
2720
- systemctlUser(["enable", "--now", unit]);
2721
- }
2722
- }
2723
- continue;
2724
- }
2725
- for (const script of [join10(role.roleDir, ".scripts", "70-systemd.sh")]) {
2726
- if (!script || !existsSync7(script)) continue;
2727
- if (ctx.dryRun) {
2728
- details.push(`would run: bash ${script}`);
2729
- } else {
2730
- const result = spawnSync4("bash", [script], { cwd: role.roleDir, encoding: "utf8" });
2731
- if (result.status !== 0) details.push(`script failed: ${script}: ${result.stderr.trim() || result.stdout.trim()}`);
2732
- }
2733
- }
2734
- }
2735
- return {
2736
- id: finding.id,
2737
- title: finding.title,
2738
- status: details.some((detail) => detail.includes("failed:")) ? "blocked" : details.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
2739
- summary: details.length ? ctx.dryRun ? "Planned systemd remediation commands" : "Attempted systemd remediation" : "No changes required",
2740
- changedFiles,
2741
- details
2742
- };
2743
- }
2744
- }
2745
- ];
2746
- function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
2747
- const normalized = content.endsWith("\n") ? content : `${content}
2748
- `;
2749
- if (safeReadText(path) === normalized) return;
2750
- changedFiles.push(path);
2751
- if (!dryRun) {
2752
- writeText(path, normalized);
2753
- if (mode) chmodSync2(path, mode);
2754
- }
2755
- }
2756
- function getParityRuleIds() {
2757
- return RULES.map((rule) => rule.id);
2758
- }
2759
- function runAudit(repoArg) {
2760
- const pjanglerRoot = resolvePjanglerRoot();
2761
- const ctx = {
2762
- repoRoot: resolve(repoArg ?? process.cwd()),
2763
- dryRun: true,
2764
- pjanglerRoot,
2765
- homeDir: homedir4()
2766
- };
2767
- const rules = RULES.map((rule) => rule.audit(ctx));
2768
- return {
2769
- repo: ctx.repoRoot,
2770
- ok: rules.every((rule) => rule.status === "pass" || rule.status === "skip"),
2771
- auditedAt: (/* @__PURE__ */ new Date()).toISOString(),
2772
- rules
2773
- };
2774
- }
2775
- function runMigrationForRules(ruleIds, repoArg, dryRun) {
2776
- const pjanglerRoot = resolvePjanglerRoot();
2777
- const ctx = {
2778
- repoRoot: resolve(repoArg ?? process.cwd()),
2779
- dryRun,
2780
- pjanglerRoot,
2781
- homeDir: homedir4()
2782
- };
2783
- const selected = RULES.filter((rule) => ruleIds.includes(rule.id));
2784
- if (!selected.length) {
2785
- throw new Error(`Unknown parity rules: ${ruleIds.join(", ")}`);
2786
- }
2787
- const results = selected.map((rule) => {
2788
- try {
2789
- return rule.migrate(ctx, rule.audit(ctx));
2790
- } catch (err) {
2791
- return {
2792
- id: rule.id,
2793
- title: rule.title,
2794
- status: "blocked",
2795
- summary: `migrate threw: ${err instanceof Error ? err.message : String(err)}`,
2796
- changedFiles: [],
2797
- details: []
2798
- };
2799
- }
2800
- });
2801
- const changedFiles = Array.from(new Set(results.flatMap((result) => result.changedFiles))).sort();
2802
- return {
2803
- repo: ctx.repoRoot,
2804
- dryRun,
2805
- ok: results.every((result) => result.status !== "blocked"),
2806
- selectedRules: selected.map((rule) => rule.id),
2807
- results,
2808
- changedFiles
2809
- };
2810
- }
2811
- function runMigration(selector, repoArg, dryRun, all) {
2812
- const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
2813
- return runMigrationForRules(ruleIds, repoArg, dryRun);
2814
- }
2815
- function prettyTimestamp(iso) {
2816
- const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/.exec(iso);
2817
- return match ? `${match[1]} ${match[2]} UTC` : iso;
2818
- }
2819
- function formatAuditReport(report) {
2820
- const counts = {};
2821
- for (const rule of report.rules) counts[rule.status] = (counts[rule.status] ?? 0) + 1;
2822
- const idWidth = report.rules.reduce((width, rule) => Math.max(width, rule.id.length), 0);
2823
- const tally = [];
2824
- if (counts.pass) tally.push(green(`${counts.pass} passed`));
2825
- if (counts.fail) tally.push(red(`${counts.fail} failed`));
2826
- if (counts.warn) tally.push(yellow(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`));
2827
- if (counts.skip) tally.push(gray(`${counts.skip} skipped`));
2828
- const overall = report.ok ? `${green(glyph.pass)} ${bold("Parity audit passed")}` : `${red(glyph.fail)} ${bold("Parity audit failed")}`;
2829
- const lines = [""];
2830
- lines.push(` ${overall}${tally.length ? ` ${dim(glyph.dot)} ${joinDot(tally)}` : ""}`);
2831
- lines.push(` ${dim(report.repo)} ${dim(glyph.dot)} ${dim(prettyTimestamp(report.auditedAt))}`);
2832
- lines.push("");
2833
- for (const rule of report.rules) {
2834
- const style = statusStyle(rule.status);
2835
- lines.push(` ${style.color(style.glyph)} ${style.color(rule.id.padEnd(idWidth))} ${rule.summary}`);
2836
- for (const detail of rule.details) lines.push(` ${dim(glyph.arrow)} ${dim(detail)}`);
2837
- }
2838
- lines.push("");
2839
- return lines.join("\n");
2840
- }
2841
-
2842
- // src/project/index.ts
2843
- import { spawnSync as spawnSync5 } from "node:child_process";
2844
- import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync5, renameSync as renameSync2, statSync, writeFileSync as writeFileSync5 } from "node:fs";
2845
- import { homedir as homedir5 } from "node:os";
2846
- import { basename as basename3, dirname as dirname7, join as join11, resolve as resolve2 } from "node:path";
2847
- import YAML from "yaml";
2848
- var PROJECT_REGISTRY_ENV = "PJ_PROJECT_REGISTRY";
2849
- var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
2850
- var KNOWN_SKILL_ROOTS = [
2851
- "/home/delorenj/code/skillex/all-skills",
2852
- "/home/delorenj/code/CoachingAgentFramework/.agents/skills",
2853
- "/home/delorenj/code/pjangler/.agents/skills",
2854
- join11(homedir5(), ".codex", "skills")
2855
- ];
2856
- function projectRegistryPath(env2 = process.env) {
2857
- return expandHome(env2[PROJECT_REGISTRY_ENV] || join11(homedir5(), ".config", "pjangler", "projects.yaml"));
2858
- }
2859
- function emptyProjectRegistry() {
2860
- return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
2861
- }
2862
- function loadProjectRegistry(path = projectRegistryPath()) {
2863
- if (!existsSync8(path)) return emptyProjectRegistry();
2864
- const raw = YAML.parse(readFileSync5(path, "utf8"));
2865
- if (raw == null) return emptyProjectRegistry();
2866
- if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
2867
- const registry = raw;
2868
- const normalized = {
2869
- schema_version: Number(registry.schema_version ?? PROJECT_REGISTRY_SCHEMA_VERSION),
2870
- projects: isRecord(registry.projects) ? registry.projects : {}
2871
- };
2872
- validateProjectRegistry(normalized);
2873
- return normalized;
2874
- }
2875
- function saveProjectRegistry(registry, path = projectRegistryPath()) {
2876
- validateProjectRegistry(registry);
2877
- mkdirSync6(dirname7(path), { recursive: true });
2878
- const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
2879
- writeFileSync5(temp, YAML.stringify(registry, { lineWidth: 0 }), "utf8");
2880
- renameSync2(temp, path);
2881
- }
2882
- function validateProjectRegistry(registry) {
2883
- if (registry.schema_version !== PROJECT_REGISTRY_SCHEMA_VERSION) {
2884
- throw new Error(`Unsupported project registry schema_version: ${registry.schema_version}`);
2885
- }
2886
- if (!isRecord(registry.projects)) throw new Error("Project registry projects must be a mapping");
2887
- const slugs = /* @__PURE__ */ new Set();
2888
- const repoPaths = /* @__PURE__ */ new Map();
2889
- const identifiers = /* @__PURE__ */ new Map();
2890
- for (const [slug, project] of Object.entries(registry.projects)) {
2891
- validateProjectRecord(project, slug);
2892
- if (slugs.has(project.slug)) throw new Error(`Duplicate project slug: ${project.slug}`);
2893
- slugs.add(project.slug);
2894
- const repoKey = resolve2(project.repo_path);
2895
- const existingRepoSlug = repoPaths.get(repoKey);
2896
- if (existingRepoSlug && existingRepoSlug !== slug) {
2897
- throw new Error(`Duplicate project repo_path: ${project.repo_path} used by ${existingRepoSlug} and ${slug}`);
2898
- }
2899
- repoPaths.set(repoKey, slug);
2900
- const identifier = project.ticket_provider.identifier?.toUpperCase();
2901
- if (identifier) {
2902
- const existingIdentifierSlug = identifiers.get(identifier);
2903
- if (existingIdentifierSlug && existingIdentifierSlug !== slug) {
2904
- throw new Error(`Duplicate project identifier: ${identifier} used by ${existingIdentifierSlug} and ${slug}`);
2905
- }
2906
- identifiers.set(identifier, slug);
2907
- }
2908
- }
2909
- }
2910
- function slugifyProjectName(value) {
2911
- return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
2912
- }
2913
- function deriveProjectIdentifier(value) {
2914
- const compact = value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
2915
- const identifier = compact.slice(0, 4) || "PROJ";
2916
- return identifier.length >= 2 ? identifier : `${identifier}XX`.slice(0, 4);
2917
- }
2918
- function normalizeAgentRole(value) {
2919
- return value?.trim() || "pm";
2920
- }
2921
- function jsonStable(value) {
2922
- return JSON.stringify(value);
2923
- }
2924
- function projectRecordEquivalent(a, b) {
2925
- if (!a) return false;
2926
- const { created_at: _aCreated, updated_at: _aUpdated, ...aComparable } = a;
2927
- const { created_at: _bCreated, updated_at: _bUpdated, ...bComparable } = b;
2928
- return jsonStable(aComparable) === jsonStable(bComparable);
2929
- }
2930
- function defaultProjectTargetDir(name, cwd = process.cwd()) {
2931
- const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
2932
- return resolve2(dirname7(resolve2(cwd)), compactName);
2933
- }
2934
- function resolveSourceSkillPath(sourceSkill) {
2935
- if (!sourceSkill) return void 0;
2936
- const expanded = expandHome(sourceSkill);
2937
- const direct = resolve2(expanded);
2938
- if (existsSync8(direct)) return direct;
2939
- const name = basename3(sourceSkill);
2940
- for (const root of KNOWN_SKILL_ROOTS) {
2941
- const candidate = join11(root, name);
2942
- if (existsSync8(candidate)) return candidate;
2943
- }
2944
- const civilWarLetterifier = "/home/delorenj/code/skillex/all-skills/civilwar-letterifier";
2945
- const hint = existsSync8(civilWarLetterifier) ? ` Did you mean ${civilWarLetterifier}?` : "";
2946
- throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
2947
- }
2948
- function planProjectInit(input) {
2949
- if (!input.name.trim()) throw new Error("Project name is required");
2950
- const registryPath2 = resolve2(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
2951
- const registry = loadProjectRegistry(registryPath2);
2952
- const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
2953
- const slug = input.projectSlug ?? slugifyProjectName(input.name);
2954
- const targetDir = resolve2(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
2955
- const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
2956
- const existing = registry.projects[slug];
2957
- const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
2958
- const overwrite = input.overwrite ?? input.force ?? false;
2959
- const agentRole = normalizeAgentRole(input.agentRole);
2960
- const agents = input.provisionAgent ? {
2961
- ...existing?.agents ?? {},
2962
- [agentRole]: {
2963
- role: agentRole,
2964
- provisioning_state: "planned"
2965
- }
2966
- } : existing?.agents ?? {};
2967
- const scaffold = input.scaffold ?? true;
2968
- const candidateProject = {
2969
- name: input.name,
2970
- slug,
2971
- repo_path: targetDir,
2972
- description: input.description ?? "",
2973
- status: "planned",
2974
- source_artifacts: sourceSkillPath ? [{ kind: "skill", path: sourceSkillPath, package_name: input.packageName ?? slug }] : [],
2975
- template: {
2976
- commonproject: {
2977
- enabled: true,
2978
- primary_language: input.primaryLanguage ?? "python"
2979
- }
2980
- },
2981
- ticket_provider: {
2982
- type: input.ticketProvider ?? "plane",
2983
- workspace: input.planeWorkspace ?? "33god",
2984
- identifier,
2985
- board_id: input.planeProjectId ?? "",
2986
- board_url: input.planeProjectId ? `https://plane.delo.sh/${input.planeWorkspace ?? "33god"}/projects/${input.planeProjectId}/issues/` : "",
2987
- state: input.live ? "planned" : "planned"
2988
- },
2989
- agents,
2990
- created_at: existing?.created_at ?? now,
2991
- updated_at: now
2992
- };
2993
- const project = {
2994
- ...candidateProject,
2995
- updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
2996
- };
2997
- validateNoDuplicateProject(registry, project, overwrite);
2998
- const pjanglerRoot = resolve2(input.pjanglerRoot ?? resolvePjanglerRoot2());
2999
- const manifest = projectManifestFromRegistryProject(project);
3000
- const apply = input.apply ?? false;
3001
- const live = input.live ?? false;
3002
- const actions = [
3003
- { kind: "registry.upsert", registryPath: registryPath2, slug, project }
3004
- ];
3005
- if (scaffold) {
3006
- actions.push(buildCommonProjectCopierAction({
3007
- pjanglerRoot,
3008
- targetDir,
3009
- projectName: project.name,
3010
- projectDescription: project.description,
3011
- projectSlug: project.slug,
3012
- ticketProvider: project.ticket_provider.type,
3013
- planeWorkspace: project.ticket_provider.workspace ?? "33god",
3014
- planeProjectId: project.ticket_provider.board_id ?? "",
3015
- projectIdentifier: identifier,
3016
- primaryLanguage: project.template.commonproject.primary_language,
3017
- overwrite
3018
- }));
3019
- }
3020
- actions.push(
3021
- { kind: "project.write-manifest", path: join11(targetDir, ".project.json"), manifest },
3022
- {
3023
- kind: "plane.create-or-link",
3024
- enabled: live,
3025
- live,
3026
- workspace: project.ticket_provider.workspace ?? "33god",
3027
- identifier,
3028
- state: live ? "planned" : "planned",
3029
- reason: live ? void 0 : "network/cloud actions require --live"
3030
- },
3031
- {
3032
- kind: "hermes.provision-agent",
3033
- enabled: input.provisionAgent ?? false,
3034
- local: !live,
3035
- targetDir,
3036
- targetRepo: slug,
3037
- role: agentRole,
3038
- context: {
3039
- skipRuntimeRepo: !live,
3040
- skipPlane: !live,
3041
- skipBloodbank: !live,
3042
- skipSystemd: !live || process.platform === "darwin"
3086
+ const roles = discoverRoles(ctx.repoRoot);
3087
+ if (!roles.length) {
3088
+ return { id: "systemd.sentinel", title: "Hermes systemd/sentinel units enabled + active", status: "skip", summary: "No Hermes roles present", details: [], fixable: false };
3043
3089
  }
3044
- }
3045
- );
3046
- return { ok: true, apply, dryRun: !apply, live, registryPath: registryPath2, project, manifest, actions };
3047
- }
3048
- function executeProjectInitPlan(plan) {
3049
- const logs = [];
3050
- const errors = [];
3051
- const changedFiles = [];
3052
- if (!plan.apply) return { ok: true, plan, logs, errors, changedFiles };
3053
- const registry = loadProjectRegistry(plan.registryPath);
3054
- let pendingRegistryAction;
3055
- for (const action of plan.actions) {
3056
- if (action.kind === "copier.copy.commonproject") {
3057
- mkdirSync6(dirname7(action.targetDir), { recursive: true });
3058
- const result = spawnSync5(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
3059
- if (result.stdout?.trim()) logs.push(result.stdout.trim());
3060
- if (result.stderr?.trim()) logs.push(result.stderr.trim());
3061
- if (result.error) {
3062
- const code = result.error.code;
3063
- errors.push(
3064
- code === "ENOENT" ? "copier not found on PATH. Install with: uv tool install copier or pip install copier" : `copier failed: ${result.error.message}`
3065
- );
3066
- break;
3090
+ const probe = systemctlUser(["is-system-running"]);
3091
+ if (!probe.ok && !/running|degraded|starting|maintenance/.test(`${probe.stdout} ${probe.stderr}`)) {
3092
+ return { id: "systemd.sentinel", title: "Hermes systemd/sentinel units enabled + active", status: "warn", summary: "systemd --user unavailable; unit state not auditable here", details: [], fixable: false };
3067
3093
  }
3068
- if (result.status !== 0) {
3069
- errors.push(`copier exited with status ${result.status ?? "unknown"}`);
3070
- if (existsSync8(action.targetDir)) changedFiles.push(action.targetDir);
3071
- break;
3094
+ const details = [];
3095
+ for (const role of roles) {
3096
+ for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
3097
+ const state = checkUnit(unit);
3098
+ if (!state.enabled || !state.active) details.push(`${unit} should be enabled+active`);
3099
+ }
3072
3100
  }
3073
- changedFiles.push(action.targetDir);
3074
- } else if (action.kind === "project.write-manifest") {
3075
- mkdirSync6(dirname7(action.path), { recursive: true });
3076
- const next = `${JSON.stringify(action.manifest, null, 2)}
3077
- `;
3078
- const current = existsSync8(action.path) ? readFileSync5(action.path, "utf8") : void 0;
3079
- if (current !== next) {
3080
- writeFileSync5(action.path, next, "utf8");
3081
- changedFiles.push(action.path);
3101
+ return {
3102
+ id: "systemd.sentinel",
3103
+ title: "Hermes systemd/sentinel units enabled + active",
3104
+ status: details.length === 0 ? "pass" : "fail",
3105
+ summary: details.length === 0 ? "Hermes user units are enabled and active" : `${details.length} systemd parity issue(s) detected`,
3106
+ details,
3107
+ fixable: true
3108
+ };
3109
+ },
3110
+ migrate: (ctx, finding) => {
3111
+ const roles = discoverRoles(ctx.repoRoot);
3112
+ const changedFiles = [];
3113
+ const details = [];
3114
+ if (!roles.length) {
3115
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "No Hermes roles present", changedFiles, details };
3082
3116
  }
3083
- } else if (action.kind === "registry.upsert") {
3084
- pendingRegistryAction = action;
3085
- } else if (action.kind === "plane.create-or-link") {
3086
- logs.push(action.enabled ? "plane.create-or-link requires a live provider integration" : "plane.create-or-link skipped (requires --live)");
3087
- } else if (action.kind === "hermes.provision-agent") {
3088
- logs.push(action.enabled ? "hermes.provision-agent planned for the caller to execute" : "hermes.provision-agent skipped");
3117
+ const probe = systemctlUser(["is-system-running"]);
3118
+ if (!probe.ok && !/running|degraded|starting|maintenance/.test(`${probe.stdout} ${probe.stderr}`)) {
3119
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
3120
+ }
3121
+ for (const role of roles) {
3122
+ const sysDir = join11(ctx.homeDir, ".config", "systemd", "user");
3123
+ const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
3124
+ const allUnitsPresent = units.every((unit) => existsSync8(join11(sysDir, unit)));
3125
+ if (allUnitsPresent) {
3126
+ if (ctx.dryRun) {
3127
+ details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
3128
+ } else {
3129
+ systemctlUser(["daemon-reload"]);
3130
+ for (const unit of units) {
3131
+ systemctlUser(["enable", "--now", unit]);
3132
+ }
3133
+ }
3134
+ continue;
3135
+ }
3136
+ for (const script of [join11(role.roleDir, ".scripts", "70-systemd.sh")]) {
3137
+ if (!script || !existsSync8(script)) continue;
3138
+ if (ctx.dryRun) {
3139
+ details.push(`would run: bash ${script}`);
3140
+ } else {
3141
+ const result = spawnSync5("bash", [script], { cwd: role.roleDir, encoding: "utf8" });
3142
+ if (result.status !== 0) details.push(`script failed: ${script}: ${result.stderr.trim() || result.stdout.trim()}`);
3143
+ }
3144
+ }
3145
+ }
3146
+ return {
3147
+ id: finding.id,
3148
+ title: finding.title,
3149
+ status: details.some((detail) => detail.includes("failed:")) ? "blocked" : details.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
3150
+ summary: details.length ? ctx.dryRun ? "Planned systemd remediation commands" : "Attempted systemd remediation" : "No changes required",
3151
+ changedFiles,
3152
+ details
3153
+ };
3089
3154
  }
3090
3155
  }
3091
- if (pendingRegistryAction && errors.length === 0) {
3092
- if (!projectRecordEquivalent(registry.projects[pendingRegistryAction.slug], pendingRegistryAction.project)) {
3093
- registry.projects[pendingRegistryAction.slug] = pendingRegistryAction.project;
3094
- saveProjectRegistry(registry, pendingRegistryAction.registryPath);
3095
- changedFiles.push(pendingRegistryAction.registryPath);
3096
- }
3156
+ ];
3157
+ function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
3158
+ const normalized = content.endsWith("\n") ? content : `${content}
3159
+ `;
3160
+ if (safeReadText(path) === normalized) return;
3161
+ changedFiles.push(path);
3162
+ if (!dryRun) {
3163
+ writeText(path, normalized);
3164
+ if (mode) chmodSync2(path, mode);
3097
3165
  }
3098
- return { ok: errors.length === 0, plan, logs, errors, changedFiles };
3099
- }
3100
- function projectManifestFromRegistryProject(project) {
3101
- const agents = Object.fromEntries(
3102
- Object.entries(project.agents).map(([name, agent]) => [
3103
- `${project.slug}-${name}`,
3104
- {
3105
- role: agent.role,
3106
- role_dir: agent.role_dir,
3107
- provisioning_state: agent.provisioning_state
3108
- }
3109
- ])
3110
- );
3111
- return {
3112
- project_name: project.name,
3113
- project_description: project.description,
3114
- project_slug: project.slug,
3115
- repo_path: project.repo_path,
3116
- ticket_provider: {
3117
- type: project.ticket_provider.type,
3118
- workspace: project.ticket_provider.workspace ?? "",
3119
- identifier: project.ticket_provider.identifier ?? "",
3120
- board_id: project.ticket_provider.board_id ?? "",
3121
- board_url: project.ticket_provider.board_url ?? "",
3122
- state: project.ticket_provider.state
3123
- },
3124
- agents
3125
- };
3126
3166
  }
3127
- function getProject(registry, slug) {
3128
- const project = registry.projects[slug];
3129
- if (!project) throw new Error(`Project not found in registry: ${slug}`);
3130
- return project;
3167
+ function getParityRuleIds() {
3168
+ return RULES.map((rule) => rule.id);
3131
3169
  }
3132
- function buildCommonProjectCopierAction(input) {
3133
- const templateDir = join11(input.pjanglerRoot, "templates", "commonproject");
3134
- const data = {
3135
- project_name: input.projectName,
3136
- project_description: input.projectDescription ?? "",
3137
- project_slug: input.projectSlug,
3138
- ticket_provider: input.ticketProvider,
3139
- plane_workspace: input.planeWorkspace,
3140
- plane_project_id: input.planeProjectId ?? "",
3141
- project_identifier: input.projectIdentifier,
3142
- primary_language: input.primaryLanguage
3170
+ function runAudit(repoArg) {
3171
+ const pjanglerRoot = resolvePjanglerRoot2();
3172
+ const ctx = {
3173
+ repoRoot: resolve2(repoArg ?? process.cwd()),
3174
+ dryRun: true,
3175
+ pjanglerRoot,
3176
+ homeDir: homedir5()
3143
3177
  };
3144
- const command = ["copier", "copy", "--trust", templateDir, input.targetDir, "--defaults"];
3145
- for (const [key, value] of Object.entries(data)) command.push("--data", `${key}=${value}`);
3146
- if (input.overwrite) command.push("--overwrite");
3178
+ const rules = RULES.map((rule) => rule.audit(ctx));
3147
3179
  return {
3148
- kind: "copier.copy.commonproject",
3149
- cwd: input.pjanglerRoot,
3150
- command,
3151
- targetDir: input.targetDir,
3152
- data,
3153
- overwrite: input.overwrite
3180
+ repo: ctx.repoRoot,
3181
+ ok: rules.every((rule) => rule.status === "pass" || rule.status === "skip"),
3182
+ auditedAt: (/* @__PURE__ */ new Date()).toISOString(),
3183
+ rules
3154
3184
  };
3155
3185
  }
3156
- function resolvePjanglerRoot2() {
3157
- let dir = dirname7(new URL(import.meta.url).pathname);
3158
- while (dir !== dirname7(dir)) {
3159
- if (existsSync8(join11(dir, "package.json")) && existsSync8(join11(dir, "templates", "commonproject", "copier.yml"))) return dir;
3160
- dir = dirname7(dir);
3161
- }
3162
- return resolve2(process.cwd());
3163
- }
3164
- function validateNoDuplicateProject(registry, project, overwrite) {
3165
- const existingSameSlug = registry.projects[project.slug];
3166
- if (existingSameSlug && !overwrite && resolve2(existingSameSlug.repo_path) !== resolve2(project.repo_path)) {
3167
- throw new Error(`Project slug already exists in registry: ${project.slug}`);
3186
+ function runMigrationForRules(ruleIds, repoArg, dryRun) {
3187
+ const pjanglerRoot = resolvePjanglerRoot2();
3188
+ const ctx = {
3189
+ repoRoot: resolve2(repoArg ?? process.cwd()),
3190
+ dryRun,
3191
+ pjanglerRoot,
3192
+ homeDir: homedir5()
3193
+ };
3194
+ const selected = RULES.filter((rule) => ruleIds.includes(rule.id));
3195
+ if (!selected.length) {
3196
+ throw new Error(`Unknown parity rules: ${ruleIds.join(", ")}`);
3168
3197
  }
3169
- for (const [slug, existing] of Object.entries(registry.projects)) {
3170
- if (slug === project.slug) continue;
3171
- if (resolve2(existing.repo_path) === resolve2(project.repo_path)) {
3172
- throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
3173
- }
3174
- if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
3175
- throw new Error(`Project identifier already registered by ${slug}: ${project.ticket_provider.identifier}`);
3198
+ const results = selected.map((rule) => {
3199
+ try {
3200
+ return rule.migrate(ctx, rule.audit(ctx));
3201
+ } catch (err) {
3202
+ return {
3203
+ id: rule.id,
3204
+ title: rule.title,
3205
+ status: "blocked",
3206
+ summary: `migrate threw: ${err instanceof Error ? err.message : String(err)}`,
3207
+ changedFiles: [],
3208
+ details: []
3209
+ };
3176
3210
  }
3177
- }
3211
+ });
3212
+ const changedFiles = Array.from(new Set(results.flatMap((result) => result.changedFiles))).sort();
3213
+ return {
3214
+ repo: ctx.repoRoot,
3215
+ dryRun,
3216
+ ok: results.every((result) => result.status !== "blocked"),
3217
+ selectedRules: selected.map((rule) => rule.id),
3218
+ results,
3219
+ changedFiles
3220
+ };
3178
3221
  }
3179
- function validateProjectRecord(project, key) {
3180
- if (!isRecord(project)) throw new Error(`Project ${key} must be a mapping`);
3181
- if (!project.name) throw new Error(`Project ${key} missing name`);
3182
- if (!project.slug) throw new Error(`Project ${key} missing slug`);
3183
- if (project.slug !== key) throw new Error(`Project key ${key} does not match slug ${project.slug}`);
3184
- if (!project.repo_path) throw new Error(`Project ${key} missing repo_path`);
3185
- if (!Array.isArray(project.source_artifacts)) throw new Error(`Project ${key} source_artifacts must be a list`);
3186
- if (!isRecord(project.ticket_provider)) throw new Error(`Project ${key} ticket_provider must be a mapping`);
3187
- if (!isRecord(project.agents)) throw new Error(`Project ${key} agents must be a mapping`);
3222
+ function runMigration(selector, repoArg, dryRun, all) {
3223
+ const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
3224
+ return runMigrationForRules(ruleIds, repoArg, dryRun);
3188
3225
  }
3189
- function expandHome(path) {
3190
- if (path === "~") return homedir5();
3191
- if (path.startsWith("~/")) return join11(homedir5(), path.slice(2));
3192
- return path;
3226
+ function prettyTimestamp(iso) {
3227
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/.exec(iso);
3228
+ return match ? `${match[1]} ${match[2]} UTC` : iso;
3193
3229
  }
3194
- function isRecord(value) {
3195
- return typeof value === "object" && value !== null && !Array.isArray(value);
3230
+ function formatAuditReport(report) {
3231
+ const counts = {};
3232
+ for (const rule of report.rules) counts[rule.status] = (counts[rule.status] ?? 0) + 1;
3233
+ const idWidth = report.rules.reduce((width, rule) => Math.max(width, rule.id.length), 0);
3234
+ const tally = [];
3235
+ if (counts.pass) tally.push(green(`${counts.pass} passed`));
3236
+ if (counts.fail) tally.push(red(`${counts.fail} failed`));
3237
+ if (counts.warn) tally.push(yellow(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`));
3238
+ if (counts.skip) tally.push(gray(`${counts.skip} skipped`));
3239
+ const overall = report.ok ? `${green(glyph.pass)} ${bold("Parity audit passed")}` : `${red(glyph.fail)} ${bold("Parity audit failed")}`;
3240
+ const lines = [""];
3241
+ lines.push(` ${overall}${tally.length ? ` ${dim(glyph.dot)} ${joinDot(tally)}` : ""}`);
3242
+ lines.push(` ${dim(report.repo)} ${dim(glyph.dot)} ${dim(prettyTimestamp(report.auditedAt))}`);
3243
+ lines.push("");
3244
+ for (const rule of report.rules) {
3245
+ const style = statusStyle(rule.status);
3246
+ lines.push(` ${style.color(style.glyph)} ${style.color(rule.id.padEnd(idWidth))} ${rule.summary}`);
3247
+ for (const detail of rule.details) lines.push(` ${dim(glyph.arrow)} ${dim(detail)}`);
3248
+ }
3249
+ lines.push("");
3250
+ return lines.join("\n");
3196
3251
  }
3197
3252
 
3198
3253
  // src/mcp-server.ts
@@ -3200,7 +3255,7 @@ var server = new McpServer({
3200
3255
  name: "pjangler-mcp",
3201
3256
  version: PJANGLER_VERSION
3202
3257
  });
3203
- var TICKET_PROVIDER_SCHEMA = z.enum(["plane", "linear", "trello"]);
3258
+ var TICKET_PROVIDER_SCHEMA = z.enum(["plane", "trello"]);
3204
3259
  function resolveTargetDir(targetDir) {
3205
3260
  const dir = resolve3(targetDir ?? process.cwd());
3206
3261
  if (!existsSync9(dir)) {
@@ -3391,6 +3446,9 @@ server.registerTool(
3391
3446
  projectDescription: z.string().optional(),
3392
3447
  projectSlug: z.string().optional(),
3393
3448
  ticketProvider: TICKET_PROVIDER_SCHEMA.optional(),
3449
+ boardId: z.string().optional(),
3450
+ boardUrl: z.string().optional(),
3451
+ workspace: z.string().optional(),
3394
3452
  planeWorkspace: z.string().optional(),
3395
3453
  planeProjectId: z.string().optional(),
3396
3454
  projectIdentifier: z.string().optional(),
@@ -3419,9 +3477,10 @@ server.registerTool(
3419
3477
  const dryRun = input.dryRun ?? true;
3420
3478
  const local = input.local ?? true;
3421
3479
  const skipPlane = input.skipPlane ?? true;
3422
- const planeProjectId = input.planeProjectId ?? "";
3423
- if (!skipPlane && !planeProjectId) {
3424
- throw new Error("planeProjectId is required when skipPlane=false; keep skipPlane=true for safe local bootstrap");
3480
+ const ticketProvider = input.ticketProvider ?? "plane";
3481
+ const boardId = input.boardId ?? input.planeProjectId ?? "";
3482
+ if (!skipPlane && ticketProvider === "plane" && !boardId) {
3483
+ throw new Error("boardId or planeProjectId is required when skipPlane=false for Plane; keep skipPlane=true for safe local bootstrap");
3425
3484
  }
3426
3485
  if (!dryRun && existsSync9(targetDir) && !overwrite) throw new Error(`Target already exists: ${targetDir} (set force/overwrite=true to re-render)`);
3427
3486
  const plan = planProjectInit({
@@ -3437,9 +3496,12 @@ server.registerTool(
3437
3496
  live: input.live ?? false,
3438
3497
  registryPath: input.registryPath,
3439
3498
  projectIdentifier: input.projectIdentifier ?? projectSlug.slice(0, 4).toUpperCase(),
3440
- ticketProvider: input.ticketProvider ?? "plane",
3499
+ ticketProvider,
3500
+ boardId,
3501
+ boardUrl: input.boardUrl,
3502
+ boardWorkspace: input.workspace ?? input.planeWorkspace,
3441
3503
  planeWorkspace: input.planeWorkspace ?? "33god",
3442
- planeProjectId,
3504
+ planeProjectId: input.planeProjectId,
3443
3505
  pjanglerRoot,
3444
3506
  overwrite
3445
3507
  });
@@ -3491,6 +3553,10 @@ server.registerTool(
3491
3553
  live: z.boolean().optional(),
3492
3554
  slug: z.string().optional(),
3493
3555
  identifier: z.string().optional(),
3556
+ ticketProvider: TICKET_PROVIDER_SCHEMA.optional(),
3557
+ boardId: z.string().optional(),
3558
+ boardUrl: z.string().optional(),
3559
+ workspace: z.string().optional(),
3494
3560
  registryPath: z.string().optional(),
3495
3561
  force: z.boolean().optional()
3496
3562
  }
@@ -3509,6 +3575,10 @@ server.registerTool(
3509
3575
  live: input.live ?? false,
3510
3576
  projectSlug: input.slug,
3511
3577
  projectIdentifier: input.identifier,
3578
+ ticketProvider: input.ticketProvider,
3579
+ boardId: input.boardId,
3580
+ boardUrl: input.boardUrl,
3581
+ boardWorkspace: input.workspace,
3512
3582
  registryPath: input.registryPath,
3513
3583
  force: input.force ?? false,
3514
3584
  overwrite: input.force ?? false
@@ -3616,7 +3686,7 @@ server.registerTool(
3616
3686
  "pjangler_deploy_hermes_agent",
3617
3687
  {
3618
3688
  title: "Deploy Hermes agent",
3619
- description: "Provision a Hermes agent role for @33god-projects. For safe MCP use local=true defaults skip runtime repo, Plane, Bloodbank, and systemd; opt out with local=false plus explicit skip flags.",
3689
+ description: "Provision a Hermes agent role for @33god-projects. For safe MCP use local=true defaults skip runtime repo, ticket-board creation, Bloodbank, and systemd; opt out with local=false plus explicit skip flags.",
3620
3690
  inputSchema: {
3621
3691
  targetDir: z.string(),
3622
3692
  targetRepo: z.string().optional(),