@delorenj/pjangler 1.2.4 → 1.2.6

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.
@@ -1187,111 +1187,488 @@ 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 slugifyProjectName(value) {
1264
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
1265
+ }
1266
+ function deriveProjectIdentifier(value) {
1267
+ const compact = value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
1268
+ const identifier = compact.slice(0, 4) || "PROJ";
1269
+ return identifier.length >= 2 ? identifier : `${identifier}XX`.slice(0, 4);
1270
+ }
1271
+ function normalizeAgentRole(value) {
1272
+ return value?.trim() || "pm";
1273
+ }
1274
+ function resolveAgentHooksLayer(input, env2 = process.env) {
1275
+ if (typeof input === "boolean") return input;
1276
+ const override = env2.PJ_AGENT_HOOKS_LAYER;
1277
+ if (override === "0" || override === "false") return false;
1278
+ if (override === "1" || override === "true") return true;
1279
+ return !existsSync6(join8(homedir3(), ".agents", "hooks"));
1280
+ }
1281
+ function jsonStable(value) {
1282
+ return JSON.stringify(value);
1283
+ }
1284
+ function projectRecordEquivalent(a, b) {
1285
+ if (!a) return false;
1286
+ const { created_at: _aCreated, updated_at: _aUpdated, ...aComparable } = a;
1287
+ const { created_at: _bCreated, updated_at: _bUpdated, ...bComparable } = b;
1288
+ return jsonStable(aComparable) === jsonStable(bComparable);
1289
+ }
1290
+ function defaultProjectTargetDir(name, cwd = process.cwd()) {
1291
+ const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
1292
+ return resolve(dirname4(resolve(cwd)), compactName);
1293
+ }
1294
+ function resolveSourceSkillPath(sourceSkill) {
1295
+ if (!sourceSkill) return void 0;
1296
+ const expanded = expandHome(sourceSkill);
1297
+ const direct = resolve(expanded);
1298
+ if (existsSync6(direct)) return direct;
1299
+ const name = basename2(sourceSkill);
1300
+ for (const root of KNOWN_SKILL_ROOTS) {
1301
+ const candidate = join8(root, name);
1302
+ if (existsSync6(candidate)) return candidate;
1212
1303
  }
1213
- throw new Error(
1214
- "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
1215
- );
1304
+ const civilWarLetterifier = "/home/delorenj/code/skillex/all-skills/civilwar-letterifier";
1305
+ const hint = existsSync6(civilWarLetterifier) ? ` Did you mean ${civilWarLetterifier}?` : "";
1306
+ throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
1216
1307
  }
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}` };
1308
+ function planProjectInit(input) {
1309
+ if (!input.name.trim()) throw new Error("Project name is required");
1310
+ const registryPath2 = resolve(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
1311
+ const registry = loadProjectRegistry(registryPath2);
1312
+ const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
1313
+ const slug = input.projectSlug ?? slugifyProjectName(input.name);
1314
+ const targetDir = resolve(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
1315
+ const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
1316
+ const existing = registry.projects[slug];
1317
+ const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
1318
+ const overwrite = input.overwrite ?? input.force ?? false;
1319
+ const agentRole = normalizeAgentRole(input.agentRole);
1320
+ const agents = input.provisionAgent ? {
1321
+ ...existing?.agents ?? {},
1322
+ [agentRole]: {
1323
+ role: agentRole,
1324
+ provisioning_state: "planned"
1224
1325
  }
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;
1241
- }
1242
- if (!this.context.dryRun) {
1243
- mkdirSync4(dirname4(dest), { recursive: true });
1244
- cpSync(src, dest, { recursive: dir, force: true });
1326
+ } : existing?.agents ?? {};
1327
+ const scaffold = input.scaffold ?? true;
1328
+ const candidateProject = {
1329
+ name: input.name,
1330
+ slug,
1331
+ repo_path: targetDir,
1332
+ description: input.description ?? "",
1333
+ status: "planned",
1334
+ source_artifacts: sourceSkillPath ? [{ kind: "skill", path: sourceSkillPath, package_name: input.packageName ?? slug }] : [],
1335
+ template: {
1336
+ commonproject: {
1337
+ enabled: true,
1338
+ primary_language: input.primaryLanguage ?? "python"
1245
1339
  }
1246
- created.push(rel);
1247
- }
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
- };
1340
+ },
1341
+ ticket_provider: {
1342
+ type: input.ticketProvider ?? "plane",
1343
+ workspace: input.planeWorkspace ?? "33god",
1344
+ identifier,
1345
+ board_id: input.planeProjectId ?? "",
1346
+ board_url: input.planeProjectId ? `https://plane.delo.sh/${input.planeWorkspace ?? "33god"}/projects/${input.planeProjectId}/issues/` : "",
1347
+ state: input.live ? "planned" : "planned"
1348
+ },
1349
+ agents,
1350
+ created_at: existing?.created_at ?? now,
1351
+ updated_at: now
1352
+ };
1353
+ const project = {
1354
+ ...candidateProject,
1355
+ updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
1356
+ };
1357
+ validateNoDuplicateProject(registry, project, overwrite);
1358
+ const pjanglerRoot = resolve(input.pjanglerRoot ?? resolvePjanglerRoot());
1359
+ const manifest = projectManifestFromRegistryProject(project);
1360
+ const apply = input.apply ?? false;
1361
+ const live = input.live ?? false;
1362
+ const actions = [
1363
+ { kind: "registry.upsert", registryPath: registryPath2, slug, project }
1364
+ ];
1365
+ if (scaffold) {
1366
+ actions.push(buildCommonProjectCopierAction({
1367
+ pjanglerRoot,
1368
+ targetDir,
1369
+ projectName: project.name,
1370
+ projectDescription: project.description,
1371
+ projectSlug: project.slug,
1372
+ ticketProvider: project.ticket_provider.type,
1373
+ planeWorkspace: project.ticket_provider.workspace ?? "33god",
1374
+ planeProjectId: project.ticket_provider.board_id ?? "",
1375
+ projectIdentifier: identifier,
1376
+ primaryLanguage: project.template.commonproject.primary_language,
1377
+ agentHooksLayer: resolveAgentHooksLayer(input.agentHooksLayer),
1378
+ overwrite
1379
+ }));
1254
1380
  }
1255
- };
1256
- var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
1257
- static MARKER = "# pjangler:agent-hooks";
1258
- static CR = "{{config_root}}";
1259
- // mise's own runtime var — emitted literally
1260
- async invoke() {
1261
- const misePath = join8(this.context.targetDir, "mise.toml");
1262
- if (!existsSync6(misePath)) {
1263
- return {
1264
- success: false,
1265
- message: "\u26A0\uFE0F No mise.toml found \u2014 run `pjangler init mise` first, then re-run."
1266
- };
1267
- }
1268
- let content = readFileSync2(misePath, "utf8");
1269
- if (content.includes(_WireMiseAgentHooks.MARKER)) {
1270
- return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for agent-hooks") };
1381
+ actions.push(
1382
+ { kind: "project.write-manifest", path: join8(targetDir, ".project.json"), manifest },
1383
+ {
1384
+ kind: "plane.create-or-link",
1385
+ enabled: live,
1386
+ live,
1387
+ workspace: project.ticket_provider.workspace ?? "33god",
1388
+ identifier,
1389
+ state: live ? "planned" : "planned",
1390
+ reason: live ? void 0 : "network/cloud actions require --live"
1391
+ },
1392
+ {
1393
+ kind: "hermes.provision-agent",
1394
+ enabled: input.provisionAgent ?? false,
1395
+ local: !live,
1396
+ targetDir,
1397
+ targetRepo: slug,
1398
+ role: agentRole,
1399
+ context: {
1400
+ skipRuntimeRepo: !live,
1401
+ skipPlane: !live,
1402
+ skipBloodbank: !live,
1403
+ skipSystemd: !live || process.platform === "darwin"
1404
+ }
1271
1405
  }
1272
- const cr = _WireMiseAgentHooks.CR;
1273
- const enterAdds = [
1274
- ` "${cr}/.mise/scripts/link-project-skills-to-clis.sh",`,
1275
- ` "${cr}/.agents/hooks/sync.py --install --quiet",`
1276
- ].join("\n");
1277
- const leaveBlock = [
1278
- "leave = [",
1279
- ` "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",`,
1280
- ` "${cr}/.agents/hooks/sync.py --uninstall --quiet",`,
1281
- "]"
1282
- ].join("\n");
1283
- let wiredHooks = false;
1284
- const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
1285
- if (enterRe.test(content)) {
1286
- content = content.replace(enterRe, (_m, head, close) => {
1287
- const sep = /[,[]\s*$/.test(head) ? "" : ",";
1288
- return `${head}${sep}
1289
- ${enterAdds}${close}`;
1290
- });
1291
- const leaveRe = /(leave\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
1292
- if (leaveRe.test(content)) {
1293
- content = content.replace(leaveRe, (_m, head, close) => {
1294
- const sep = /[,[]\s*$/.test(head) ? "" : ",";
1406
+ );
1407
+ return { ok: true, apply, dryRun: !apply, live, registryPath: registryPath2, project, manifest, actions };
1408
+ }
1409
+ function executeProjectInitPlan(plan) {
1410
+ const logs = [];
1411
+ const errors = [];
1412
+ const changedFiles = [];
1413
+ if (!plan.apply) return { ok: true, plan, logs, errors, changedFiles };
1414
+ const registry = loadProjectRegistry(plan.registryPath);
1415
+ let pendingRegistryAction;
1416
+ for (const action of plan.actions) {
1417
+ if (action.kind === "copier.copy.commonproject") {
1418
+ logs.push(
1419
+ 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"
1420
+ );
1421
+ mkdirSync4(dirname4(action.targetDir), { recursive: true });
1422
+ const result = spawnSync4(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
1423
+ if (result.stdout?.trim()) logs.push(result.stdout.trim());
1424
+ if (result.stderr?.trim()) logs.push(result.stderr.trim());
1425
+ if (result.error) {
1426
+ const code = result.error.code;
1427
+ errors.push(
1428
+ code === "ENOENT" ? "copier not found on PATH. Install with: uv tool install copier or pip install copier" : `copier failed: ${result.error.message}`
1429
+ );
1430
+ break;
1431
+ }
1432
+ if (result.status !== 0) {
1433
+ errors.push(`copier exited with status ${result.status ?? "unknown"}`);
1434
+ if (existsSync6(action.targetDir)) changedFiles.push(action.targetDir);
1435
+ break;
1436
+ }
1437
+ changedFiles.push(action.targetDir);
1438
+ } else if (action.kind === "project.write-manifest") {
1439
+ mkdirSync4(dirname4(action.path), { recursive: true });
1440
+ const next = `${JSON.stringify(action.manifest, null, 2)}
1441
+ `;
1442
+ const current = existsSync6(action.path) ? readFileSync2(action.path, "utf8") : void 0;
1443
+ if (current !== next) {
1444
+ writeFileSync3(action.path, next, "utf8");
1445
+ changedFiles.push(action.path);
1446
+ }
1447
+ } else if (action.kind === "registry.upsert") {
1448
+ pendingRegistryAction = action;
1449
+ } else if (action.kind === "plane.create-or-link") {
1450
+ logs.push(action.enabled ? "plane.create-or-link requires a live provider integration" : "plane.create-or-link skipped (requires --live)");
1451
+ } else if (action.kind === "hermes.provision-agent") {
1452
+ logs.push(action.enabled ? "hermes.provision-agent planned for the caller to execute" : "hermes.provision-agent skipped");
1453
+ }
1454
+ }
1455
+ if (pendingRegistryAction && errors.length === 0) {
1456
+ if (!projectRecordEquivalent(registry.projects[pendingRegistryAction.slug], pendingRegistryAction.project)) {
1457
+ registry.projects[pendingRegistryAction.slug] = pendingRegistryAction.project;
1458
+ saveProjectRegistry(registry, pendingRegistryAction.registryPath);
1459
+ changedFiles.push(pendingRegistryAction.registryPath);
1460
+ }
1461
+ }
1462
+ return { ok: errors.length === 0, plan, logs, errors, changedFiles };
1463
+ }
1464
+ function projectManifestFromRegistryProject(project) {
1465
+ const agents = Object.fromEntries(
1466
+ Object.entries(project.agents).map(([name, agent]) => [
1467
+ `${project.slug}-${name}`,
1468
+ {
1469
+ role: agent.role,
1470
+ role_dir: agent.role_dir,
1471
+ provisioning_state: agent.provisioning_state
1472
+ }
1473
+ ])
1474
+ );
1475
+ return {
1476
+ project_name: project.name,
1477
+ project_description: project.description,
1478
+ project_slug: project.slug,
1479
+ repo_path: project.repo_path,
1480
+ ticket_provider: {
1481
+ type: project.ticket_provider.type,
1482
+ workspace: project.ticket_provider.workspace ?? "",
1483
+ identifier: project.ticket_provider.identifier ?? "",
1484
+ board_id: project.ticket_provider.board_id ?? "",
1485
+ board_url: project.ticket_provider.board_url ?? "",
1486
+ state: project.ticket_provider.state
1487
+ },
1488
+ agents
1489
+ };
1490
+ }
1491
+ function getProject(registry, slug) {
1492
+ const project = registry.projects[slug];
1493
+ if (!project) throw new Error(`Project not found in registry: ${slug}`);
1494
+ return project;
1495
+ }
1496
+ function buildCommonProjectCopierAction(input) {
1497
+ const templateDir = join8(input.pjanglerRoot, "templates", "commonproject");
1498
+ const data = {
1499
+ project_name: input.projectName,
1500
+ project_description: input.projectDescription ?? "",
1501
+ project_slug: input.projectSlug,
1502
+ ticket_provider: input.ticketProvider,
1503
+ plane_workspace: input.planeWorkspace,
1504
+ plane_project_id: input.planeProjectId ?? "",
1505
+ project_identifier: input.projectIdentifier,
1506
+ primary_language: input.primaryLanguage,
1507
+ agent_hooks_layer: input.agentHooksLayer ?? true ? "true" : "false"
1508
+ };
1509
+ const command = ["copier", "copy", "--trust", templateDir, input.targetDir, "--defaults"];
1510
+ for (const [key, value] of Object.entries(data)) command.push("--data", `${key}=${value}`);
1511
+ if (input.overwrite) command.push("--overwrite");
1512
+ return {
1513
+ kind: "copier.copy.commonproject",
1514
+ cwd: input.pjanglerRoot,
1515
+ command,
1516
+ targetDir: input.targetDir,
1517
+ data,
1518
+ overwrite: input.overwrite
1519
+ };
1520
+ }
1521
+ function resolvePjanglerRoot() {
1522
+ let dir = dirname4(new URL(import.meta.url).pathname);
1523
+ while (dir !== dirname4(dir)) {
1524
+ if (existsSync6(join8(dir, "package.json")) && existsSync6(join8(dir, "templates", "commonproject", "copier.yml"))) return dir;
1525
+ dir = dirname4(dir);
1526
+ }
1527
+ return resolve(process.cwd());
1528
+ }
1529
+ function validateNoDuplicateProject(registry, project, overwrite) {
1530
+ const existingSameSlug = registry.projects[project.slug];
1531
+ if (existingSameSlug && !overwrite && resolve(existingSameSlug.repo_path) !== resolve(project.repo_path)) {
1532
+ throw new Error(`Project slug already exists in registry: ${project.slug}`);
1533
+ }
1534
+ for (const [slug, existing] of Object.entries(registry.projects)) {
1535
+ if (slug === project.slug) continue;
1536
+ if (resolve(existing.repo_path) === resolve(project.repo_path)) {
1537
+ throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
1538
+ }
1539
+ if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
1540
+ throw new Error(`Project identifier already registered by ${slug}: ${project.ticket_provider.identifier}`);
1541
+ }
1542
+ }
1543
+ }
1544
+ function validateProjectRecord(project, key) {
1545
+ if (!isRecord(project)) throw new Error(`Project ${key} must be a mapping`);
1546
+ if (!project.name) throw new Error(`Project ${key} missing name`);
1547
+ if (!project.slug) throw new Error(`Project ${key} missing slug`);
1548
+ if (project.slug !== key) throw new Error(`Project key ${key} does not match slug ${project.slug}`);
1549
+ if (!project.repo_path) throw new Error(`Project ${key} missing repo_path`);
1550
+ if (!Array.isArray(project.source_artifacts)) throw new Error(`Project ${key} source_artifacts must be a list`);
1551
+ if (!isRecord(project.ticket_provider)) throw new Error(`Project ${key} ticket_provider must be a mapping`);
1552
+ if (!isRecord(project.agents)) throw new Error(`Project ${key} agents must be a mapping`);
1553
+ }
1554
+ function expandHome(path) {
1555
+ if (path === "~") return homedir3();
1556
+ if (path.startsWith("~/")) return join8(homedir3(), path.slice(2));
1557
+ return path;
1558
+ }
1559
+ function isRecord(value) {
1560
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1561
+ }
1562
+
1563
+ // src/commands/AgentHooksCommands.ts
1564
+ 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.";
1565
+ function resolveTemplateRoot() {
1566
+ const candidates = [];
1567
+ if (process.env.PJANGLER_COMMONPROJECT_TEMPLATE) {
1568
+ candidates.push(process.env.PJANGLER_COMMONPROJECT_TEMPLATE);
1569
+ }
1570
+ try {
1571
+ let dir = dirname5(fileURLToPath2(import.meta.url));
1572
+ for (let i = 0; i < 8; i++) {
1573
+ candidates.push(join9(dir, "templates", "commonproject", "template"));
1574
+ const parent = dirname5(dir);
1575
+ if (parent === dir) break;
1576
+ dir = parent;
1577
+ }
1578
+ } catch {
1579
+ }
1580
+ candidates.push(join9(homedir4(), "code", "pjangler", "templates", "commonproject", "template"));
1581
+ for (const c of candidates) {
1582
+ if (existsSync7(join9(c, ".agents", "hooks", "hooks.master.json"))) return c;
1583
+ }
1584
+ throw new Error(
1585
+ "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
1586
+ );
1587
+ }
1588
+ var CopyAgentHooksTree = class extends Command {
1589
+ async invoke() {
1590
+ if (!resolveAgentHooksLayer()) {
1591
+ return { success: true, message: this.formatMessage(AGENT_HOOKS_SKIP_MESSAGE) };
1592
+ }
1593
+ let templateRoot;
1594
+ try {
1595
+ templateRoot = resolveTemplateRoot();
1596
+ } catch (e) {
1597
+ return { success: false, message: `\u26A0\uFE0F ${e.message}` };
1598
+ }
1599
+ const items = [
1600
+ { rel: ".agents/hooks", dir: true },
1601
+ { rel: ".agents/local.example.json", dir: false },
1602
+ { rel: ".mise/scripts/link-project-skills-to-clis.sh", dir: false },
1603
+ { rel: ".mise/scripts/unlink-project-skills-from-clis.sh", dir: false },
1604
+ { rel: ".mise/scripts/hindsight-setup.sh", dir: false }
1605
+ ];
1606
+ const created = [];
1607
+ const skipped = [];
1608
+ for (const { rel, dir } of items) {
1609
+ const src = join9(templateRoot, rel);
1610
+ const dest = join9(this.context.targetDir, rel);
1611
+ if (!existsSync7(src)) continue;
1612
+ if (existsSync7(dest) && !this.context.force) {
1613
+ skipped.push(rel);
1614
+ continue;
1615
+ }
1616
+ if (!this.context.dryRun) {
1617
+ mkdirSync5(dirname5(dest), { recursive: true });
1618
+ cpSync(src, dest, { recursive: dir, force: true });
1619
+ }
1620
+ created.push(rel);
1621
+ }
1622
+ const verb = this.context.dryRun ? "Would copy" : "Copied";
1623
+ const tail = skipped.length ? ` (${skipped.length} already present \u2014 use --force to overwrite)` : "";
1624
+ return {
1625
+ success: created.length > 0,
1626
+ message: this.formatMessage(`\u2705 ${verb} ${created.length} agent-hooks path(s)${tail}`)
1627
+ };
1628
+ }
1629
+ };
1630
+ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
1631
+ static MARKER = "# pjangler:agent-hooks";
1632
+ static CR = "{{config_root}}";
1633
+ // mise's own runtime var — emitted literally
1634
+ async invoke() {
1635
+ if (!resolveAgentHooksLayer()) {
1636
+ return { success: true, message: this.formatMessage(AGENT_HOOKS_SKIP_MESSAGE) };
1637
+ }
1638
+ const misePath = join9(this.context.targetDir, "mise.toml");
1639
+ if (!existsSync7(misePath)) {
1640
+ return {
1641
+ success: false,
1642
+ message: "\u26A0\uFE0F No mise.toml found \u2014 run `pjangler init mise` first, then re-run."
1643
+ };
1644
+ }
1645
+ let content = readFileSync3(misePath, "utf8");
1646
+ if (content.includes(_WireMiseAgentHooks.MARKER)) {
1647
+ return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for agent-hooks") };
1648
+ }
1649
+ const cr = _WireMiseAgentHooks.CR;
1650
+ const enterAdds = [
1651
+ ` "${cr}/.mise/scripts/link-project-skills-to-clis.sh",`,
1652
+ ` "${cr}/.agents/hooks/sync.py --install --quiet",`
1653
+ ].join("\n");
1654
+ const leaveBlock = [
1655
+ "leave = [",
1656
+ ` "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",`,
1657
+ ` "${cr}/.agents/hooks/sync.py --uninstall --quiet",`,
1658
+ "]"
1659
+ ].join("\n");
1660
+ let wiredHooks = false;
1661
+ const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
1662
+ if (enterRe.test(content)) {
1663
+ content = content.replace(enterRe, (_m, head, close) => {
1664
+ const sep = /[,[]\s*$/.test(head) ? "" : ",";
1665
+ return `${head}${sep}
1666
+ ${enterAdds}${close}`;
1667
+ });
1668
+ const leaveRe = /(leave\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
1669
+ if (leaveRe.test(content)) {
1670
+ content = content.replace(leaveRe, (_m, head, close) => {
1671
+ const sep = /[,[]\s*$/.test(head) ? "" : ",";
1295
1672
  return `${head}${sep}
1296
1673
  "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",
1297
1674
  "${cr}/.agents/hooks/sync.py --uninstall --quiet",${close}`;
@@ -1341,7 +1718,7 @@ ${leaveBlock}`);
1341
1718
  ""
1342
1719
  ].join("\n");
1343
1720
  content = content.replace(/\n*$/, "\n") + appended;
1344
- if (!this.context.dryRun) writeFileSync3(misePath, content);
1721
+ if (!this.context.dryRun) writeFileSync4(misePath, content);
1345
1722
  if (wiredHooks) {
1346
1723
  return { success: true, message: this.formatMessage("\u2705 Wired mise.toml ([hooks] enter/leave + tasks)") };
1347
1724
  }
@@ -1495,18 +1872,18 @@ function createRecipe(name, context) {
1495
1872
  }
1496
1873
 
1497
1874
  // src/utils/version.ts
1498
- import { readFileSync as readFileSync3 } from "node:fs";
1499
- import { dirname as dirname5, join as join9 } from "node:path";
1875
+ import { readFileSync as readFileSync4 } from "node:fs";
1876
+ import { dirname as dirname6, join as join10 } from "node:path";
1500
1877
  import { fileURLToPath as fileURLToPath3 } from "node:url";
1501
1878
  var PJANGLER_VERSION = (() => {
1502
1879
  try {
1503
- let dir = dirname5(fileURLToPath3(import.meta.url));
1880
+ let dir = dirname6(fileURLToPath3(import.meta.url));
1504
1881
  for (let i = 0; i < 4; i++) {
1505
1882
  try {
1506
- const raw = readFileSync3(join9(dir, "package.json"), "utf8");
1883
+ const raw = readFileSync4(join10(dir, "package.json"), "utf8");
1507
1884
  return JSON.parse(raw).version ?? "0.0.0";
1508
1885
  } catch {
1509
- const parent = dirname5(dir);
1886
+ const parent = dirname6(dir);
1510
1887
  if (parent === dir) break;
1511
1888
  dir = parent;
1512
1889
  }
@@ -1517,11 +1894,11 @@ var PJANGLER_VERSION = (() => {
1517
1894
  })();
1518
1895
 
1519
1896
  // 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";
1897
+ 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";
1898
+ import { basename as basename3, dirname as dirname7, join as join11, relative, resolve as resolve2 } from "node:path";
1522
1899
  import { fileURLToPath as fileURLToPath4 } from "node:url";
1523
- import { homedir as homedir4 } from "node:os";
1524
- import { spawnSync as spawnSync4 } from "node:child_process";
1900
+ import { homedir as homedir5 } from "node:os";
1901
+ import { spawnSync as spawnSync5 } from "node:child_process";
1525
1902
  var LINK_AGENTFILES_BLOCK = `# This block will handle the linking of
1526
1903
  # agent files to the main AGENTS.md file.
1527
1904
  #
@@ -1589,13 +1966,13 @@ run = "{{config_root}}/.mise/scripts/versioning.sh check"
1589
1966
  description = "Force every versioned file up to the highest version"
1590
1967
  run = "{{config_root}}/.mise/scripts/versioning.sh sync"
1591
1968
  # <<< 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"))) {
1969
+ function resolvePjanglerRoot2() {
1970
+ let dir = dirname7(fileURLToPath4(import.meta.url));
1971
+ while (dir !== dirname7(dir)) {
1972
+ if (existsSync8(join11(dir, "package.json")) && existsSync8(join11(dir, "templates", "commonproject", "copier.yml"))) {
1596
1973
  return dir;
1597
1974
  }
1598
- dir = dirname6(dir);
1975
+ dir = dirname7(dir);
1599
1976
  }
1600
1977
  throw new Error("Unable to resolve pjangler root");
1601
1978
  }
@@ -1603,17 +1980,17 @@ function normalizeNewlines(value) {
1603
1980
  return value.replace(/\r\n/g, "\n");
1604
1981
  }
1605
1982
  function readText(path) {
1606
- return normalizeNewlines(readFileSync4(path, "utf8"));
1983
+ return normalizeNewlines(readFileSync5(path, "utf8"));
1607
1984
  }
1608
1985
  function safeReadText(path) {
1609
- return existsSync7(path) ? readText(path) : null;
1986
+ return existsSync8(path) ? readText(path) : null;
1610
1987
  }
1611
1988
  function ensureParent(path) {
1612
- mkdirSync5(dirname6(path), { recursive: true });
1989
+ mkdirSync6(dirname7(path), { recursive: true });
1613
1990
  }
1614
1991
  function writeText(path, content) {
1615
1992
  ensureParent(path);
1616
- writeFileSync4(path, content);
1993
+ writeFileSync5(path, content);
1617
1994
  }
1618
1995
  function tryParseJson(text2) {
1619
1996
  if (!text2) return null;
@@ -1630,7 +2007,7 @@ function titleCaseSlug(slug) {
1630
2007
  return slug.split(/[-_]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
1631
2008
  }
1632
2009
  function readSymlinkTarget(path) {
1633
- if (!existsSync7(path)) return null;
2010
+ if (!existsSync8(path)) return null;
1634
2011
  try {
1635
2012
  return readlinkSync(path);
1636
2013
  } catch {
@@ -1638,7 +2015,7 @@ function readSymlinkTarget(path) {
1638
2015
  }
1639
2016
  }
1640
2017
  function ensureSymlink(path, target, dryRun) {
1641
- if (existsSync7(path)) {
2018
+ if (existsSync8(path)) {
1642
2019
  const stat = lstatSync(path);
1643
2020
  if (stat.isSymbolicLink()) {
1644
2021
  const current = readSymlinkTarget(path);
@@ -1655,21 +2032,21 @@ function ensureSymlink(path, target, dryRun) {
1655
2032
  return { changed: true };
1656
2033
  }
1657
2034
  function bootstrapAgentsFile(repoRoot, dryRun) {
1658
- const agentsPath = join10(repoRoot, "AGENTS.md");
1659
- if (existsSync7(agentsPath)) return { changedFiles: [], details: [] };
2035
+ const agentsPath = join11(repoRoot, "AGENTS.md");
2036
+ if (existsSync8(agentsPath)) return { changedFiles: [], details: [] };
1660
2037
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
1661
- const source = join10(repoRoot, file);
1662
- if (!existsSync7(source)) continue;
2038
+ const source = join11(repoRoot, file);
2039
+ if (!existsSync8(source)) continue;
1663
2040
  const stat = lstatSync(source);
1664
2041
  if (stat.isSymbolicLink()) continue;
1665
2042
  if (stat.isFile()) {
1666
- if (!dryRun) renameSync(source, agentsPath);
2043
+ if (!dryRun) renameSync2(source, agentsPath);
1667
2044
  return { changedFiles: [agentsPath], details: [`Moved ${file} to AGENTS.md before wiring agent-file symlinks`] };
1668
2045
  }
1669
2046
  return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
1670
2047
  }
1671
- const readmePath = join10(repoRoot, "README.md");
1672
- if (existsSync7(readmePath)) {
2048
+ const readmePath = join11(repoRoot, "README.md");
2049
+ if (existsSync8(readmePath)) {
1673
2050
  const stat = lstatSync(readmePath);
1674
2051
  if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
1675
2052
  if (!dryRun) copyFileSync(readmePath, agentsPath);
@@ -1708,12 +2085,12 @@ function yamlGet(text2, keyPath) {
1708
2085
  return "";
1709
2086
  }
1710
2087
  function discoverRoles(repoRoot) {
1711
- const rolesDir = join10(repoRoot, "agents", "hermes");
1712
- if (!existsSync7(rolesDir)) return [];
2088
+ const rolesDir = join11(repoRoot, "agents", "hermes");
2089
+ if (!existsSync8(rolesDir)) return [];
1713
2090
  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;
2091
+ const roleDir = join11(rolesDir, entry.name);
2092
+ const roleYamlPath = join11(roleDir, "role.yaml");
2093
+ if (!existsSync8(roleYamlPath)) return null;
1717
2094
  const text2 = readText(roleYamlPath);
1718
2095
  const runtimeRepoRaw = yamlGet(text2, "runtime.github_repo");
1719
2096
  return {
@@ -1737,10 +2114,10 @@ function discoverRoles(repoRoot) {
1737
2114
  }).filter((value) => Boolean(value));
1738
2115
  }
1739
2116
  function registryPath(homeDir) {
1740
- return join10(homeDir, ".hermes", "agents-registry.yaml");
2117
+ return join11(homeDir, ".hermes", "agents-registry.yaml");
1741
2118
  }
1742
2119
  function systemctlUser(args) {
1743
- const result = spawnSync4("systemctl", ["--user", ...args], { encoding: "utf8" });
2120
+ const result = spawnSync5("systemctl", ["--user", ...args], { encoding: "utf8" });
1744
2121
  return {
1745
2122
  ok: result.status === 0,
1746
2123
  stdout: result.stdout.trim(),
@@ -1748,8 +2125,8 @@ function systemctlUser(args) {
1748
2125
  };
1749
2126
  }
1750
2127
  function templateScript(ctx, name) {
1751
- const source = join10(ctx.pjanglerRoot, ".mise", "scripts", name);
1752
- return existsSync7(source) ? readText(source) : void 0;
2128
+ const source = join11(ctx.pjanglerRoot, ".mise", "scripts", name);
2129
+ return existsSync8(source) ? readText(source) : void 0;
1753
2130
  }
1754
2131
  function templateVersioningScript(ctx) {
1755
2132
  return templateScript(ctx, "versioning.sh");
@@ -1759,14 +2136,14 @@ function templateLinkAgentfilesScript(ctx) {
1759
2136
  }
1760
2137
  function renderGeneratedProjectMiseToml(ctx, template) {
1761
2138
  const project = readProjectJson(ctx);
1762
- const projectName = String(project?.project_name ?? basename2(ctx.repoRoot) ?? "project");
2139
+ const projectName = String(project?.project_name ?? basename3(ctx.repoRoot) ?? "project");
1763
2140
  return template.replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
1764
2141
  }
1765
2142
  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;
2143
+ const targetPath = join11(ctx.repoRoot, "mise.toml");
2144
+ if (existsSync8(targetPath)) return false;
2145
+ const sourcePath = join11(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
2146
+ if (!existsSync8(sourcePath)) return false;
1770
2147
  changedFiles.push(targetPath);
1771
2148
  if (!ctx.dryRun) {
1772
2149
  writeText(targetPath, renderGeneratedProjectMiseToml(ctx, readText(sourcePath)));
@@ -1774,8 +2151,8 @@ function ensureMiseTomlFromTemplate(ctx, changedFiles) {
1774
2151
  return true;
1775
2152
  }
1776
2153
  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";
2154
+ const packageJson = join11(repoRoot, "package.json");
2155
+ 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
2156
  }
1780
2157
  function replaceOrAppendManagedBlock(text2, startMarker, block, beforePattern) {
1781
2158
  if (startMarker.test(text2)) {
@@ -1799,7 +2176,7 @@ var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/herm
1799
2176
  function requiredMisePathEntries(ctx) {
1800
2177
  const required = [...BASE_MISE_PATH_ENTRIES];
1801
2178
  for (const candidate of CONDITIONAL_HERMES_PATHS) {
1802
- if (existsSync7(join10(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
2179
+ if (existsSync8(join11(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
1803
2180
  }
1804
2181
  return required;
1805
2182
  }
@@ -1948,12 +2325,12 @@ function upsertLinkAgentfilesBlock(text2, ctx) {
1948
2325
  return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
1949
2326
  }
1950
2327
  function readProjectJson(ctx) {
1951
- return tryParseJson(safeReadText(join10(ctx.repoRoot, ".project.json")));
2328
+ return tryParseJson(safeReadText(join11(ctx.repoRoot, ".project.json")));
1952
2329
  }
1953
2330
  function canonicalProjectJson(ctx) {
1954
2331
  const roles = discoverRoles(ctx.repoRoot);
1955
2332
  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"));
2333
+ const slug = String(existing.project_slug ?? slugifyRepoName(dirname7(ctx.repoRoot) === ctx.repoRoot ? ctx.repoRoot.split("/").pop() ?? "project" : ctx.repoRoot.split("/").pop() ?? "project"));
1957
2334
  const firstRole = roles[0];
1958
2335
  const ticketProvider = {
1959
2336
  type: String((existing.ticket_provider?.type ?? firstRole?.ticketProviderName ?? "plane") || "plane"),
@@ -1992,12 +2369,12 @@ function canonicalProjectJson(ctx) {
1992
2369
  };
1993
2370
  }
1994
2371
  function projectJsonFinding(ctx) {
1995
- const projectPath = join10(ctx.repoRoot, ".project.json");
1996
- const planeJsonPath = join10(ctx.repoRoot, ".plane.json");
2372
+ const projectPath = join11(ctx.repoRoot, ".project.json");
2373
+ const planeJsonPath = join11(ctx.repoRoot, ".plane.json");
1997
2374
  const details = [];
1998
2375
  const data = readProjectJson(ctx);
1999
2376
  const roles = discoverRoles(ctx.repoRoot);
2000
- if (!existsSync7(projectPath)) {
2377
+ if (!existsSync8(projectPath)) {
2001
2378
  return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json missing", details: [], fixable: true };
2002
2379
  }
2003
2380
  if (!data) {
@@ -2023,7 +2400,7 @@ function projectJsonFinding(ctx) {
2023
2400
  for (const key of ["type", "workspace", "identifier", "board_id", "board_url", "state"]) {
2024
2401
  if (!(key in ticketProvider)) details.push(`ticket_provider.${key} missing`);
2025
2402
  }
2026
- if (existsSync7(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
2403
+ if (existsSync8(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
2027
2404
  return {
2028
2405
  id: "sot.project-json",
2029
2406
  title: "Canonical .project.json",
@@ -2104,17 +2481,17 @@ exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" HERMES_OAUTH
2104
2481
  `.replace(/\u0010/g, "$");
2105
2482
  }
2106
2483
  function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip) {
2107
- if (!existsSync7(sourceDir)) return;
2108
- mkdirSync5(targetDir, { recursive: true });
2484
+ if (!existsSync8(sourceDir)) return;
2485
+ mkdirSync6(targetDir, { recursive: true });
2109
2486
  for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
2110
- const sourcePath = join10(sourceDir, entry.name);
2487
+ const sourcePath = join11(sourceDir, entry.name);
2111
2488
  if (skip?.(sourcePath)) continue;
2112
- const targetPath = join10(targetDir, entry.name);
2489
+ const targetPath = join11(targetDir, entry.name);
2113
2490
  if (entry.isDirectory()) {
2114
2491
  copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
2115
2492
  continue;
2116
2493
  }
2117
- if (existsSync7(targetPath)) continue;
2494
+ if (existsSync8(targetPath)) continue;
2118
2495
  changedFiles.push(targetPath);
2119
2496
  if (!dryRun) {
2120
2497
  ensureParent(targetPath);
@@ -2123,7 +2500,7 @@ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip)
2123
2500
  }
2124
2501
  }
2125
2502
  function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
2126
- const gitmodulesPath = join10(repoRoot, ".gitmodules");
2503
+ const gitmodulesPath = join11(repoRoot, ".gitmodules");
2127
2504
  const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
2128
2505
  const owner = role.runtimeOwner || "delorenj";
2129
2506
  const block = `[submodule "agents/hermes/${role.role}/runtime"]
@@ -2145,7 +2522,7 @@ function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
2145
2522
  repo: ${role.repo}
2146
2523
  role: ${role.role}
2147
2524
  display_name: ${JSON.stringify(role.displayName || role.agentId)}
2148
- project_path: ${ctxEscape(role.roleDir ? dirname6(dirname6(dirname6(role.roleDir))) : "")}
2525
+ project_path: ${ctxEscape(role.roleDir ? dirname7(dirname7(dirname7(role.roleDir))) : "")}
2149
2526
  role_dir: ${ctxEscape(role.roleDir)}
2150
2527
  profile_name: ${role.profileName || role.agentId}
2151
2528
  telegram:
@@ -2223,14 +2600,14 @@ var RULES = [
2223
2600
  id: "mise.config-root",
2224
2601
  title: "mise config_root + AGENTS link hooks",
2225
2602
  audit: (ctx) => {
2226
- const misePath = join10(ctx.repoRoot, "mise.toml");
2227
- if (!existsSync7(misePath)) {
2603
+ const misePath = join11(ctx.repoRoot, "mise.toml");
2604
+ if (!existsSync8(misePath)) {
2228
2605
  return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
2229
2606
  }
2230
2607
  const text2 = readText(misePath);
2231
2608
  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");
2609
+ const linkAgentfilesPath = join11(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2610
+ if (!existsSync8(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2234
2611
  const pathValues = [...(text2.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2235
2612
  const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
2236
2613
  if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
@@ -2248,10 +2625,10 @@ var RULES = [
2248
2625
  };
2249
2626
  },
2250
2627
  migrate: (ctx, finding) => {
2251
- const path = join10(ctx.repoRoot, "mise.toml");
2628
+ const path = join11(ctx.repoRoot, "mise.toml");
2252
2629
  const changedFiles = [];
2253
2630
  const details = [];
2254
- if (!existsSync7(path)) {
2631
+ if (!existsSync8(path)) {
2255
2632
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2256
2633
  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
2634
  }
@@ -2267,7 +2644,7 @@ var RULES = [
2267
2644
  if (!ctx.dryRun) writeText(path, next);
2268
2645
  text2 = next;
2269
2646
  }
2270
- const linkAgentfilesPath = join10(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2647
+ const linkAgentfilesPath = join11(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2271
2648
  const expectedScript = templateLinkAgentfilesScript(ctx);
2272
2649
  if (expectedScript === void 0) {
2273
2650
  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 +2671,13 @@ var RULES = [
2294
2671
  title: "managed mise versioning block",
2295
2672
  audit: (ctx) => {
2296
2673
  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");
2674
+ const misePath = join11(ctx.repoRoot, "mise.toml");
2675
+ const versioningPath = join11(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2676
+ const manifestPath = join11(ctx.repoRoot, ".mise", "version-files.conf");
2300
2677
  const text2 = safeReadText(misePath);
2301
2678
  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");
2679
+ if (!existsSync8(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
2680
+ if (!existsSync8(manifestPath)) details.push(".mise/version-files.conf missing");
2304
2681
  return {
2305
2682
  id: "mise.versioning",
2306
2683
  title: "managed mise versioning block",
@@ -2313,8 +2690,8 @@ var RULES = [
2313
2690
  migrate: (ctx, finding) => {
2314
2691
  const changedFiles = [];
2315
2692
  const details = [];
2316
- const misePath = join10(ctx.repoRoot, "mise.toml");
2317
- if (!existsSync7(misePath)) {
2693
+ const misePath = join11(ctx.repoRoot, "mise.toml");
2694
+ if (!existsSync8(misePath)) {
2318
2695
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2319
2696
  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
2697
  }
@@ -2329,7 +2706,7 @@ var RULES = [
2329
2706
  if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
2330
2707
  if (!ctx.dryRun) writeText(misePath, nextMise);
2331
2708
  }
2332
- const versioningPath = join10(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2709
+ const versioningPath = join11(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2333
2710
  const expectedScript = templateVersioningScript(ctx);
2334
2711
  if (expectedScript === void 0) {
2335
2712
  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 +2718,7 @@ var RULES = [
2341
2718
  chmodSync2(versioningPath, 493);
2342
2719
  }
2343
2720
  }
2344
- const manifestPath = join10(ctx.repoRoot, ".mise", "version-files.conf");
2721
+ const manifestPath = join11(ctx.repoRoot, ".mise", "version-files.conf");
2345
2722
  const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
2346
2723
  if (safeReadText(manifestPath) !== expectedManifest) {
2347
2724
  changedFiles.push(manifestPath);
@@ -2361,9 +2738,9 @@ var RULES = [
2361
2738
  id: "sot.agent-symlinks",
2362
2739
  title: "AGENTS/CLAUDE/GEMINI symlink contract",
2363
2740
  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)));
2741
+ const agentsPath = join11(ctx.repoRoot, "AGENTS.md");
2742
+ if (!existsSync8(agentsPath)) {
2743
+ const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync8(join11(ctx.repoRoot, file)));
2367
2744
  if (fallbackSources.length === 0) {
2368
2745
  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
2746
  }
@@ -2378,7 +2755,7 @@ var RULES = [
2378
2755
  }
2379
2756
  const details = [];
2380
2757
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2381
- const full = join10(ctx.repoRoot, file);
2758
+ const full = join11(ctx.repoRoot, file);
2382
2759
  const target = readSymlinkTarget(full);
2383
2760
  if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
2384
2761
  }
@@ -2402,7 +2779,7 @@ var RULES = [
2402
2779
  return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
2403
2780
  }
2404
2781
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2405
- const full = join10(ctx.repoRoot, file);
2782
+ const full = join11(ctx.repoRoot, file);
2406
2783
  const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
2407
2784
  if (result.blocked) blockedDetails.push(result.blocked);
2408
2785
  if (result.changed) changedFiles.push(full);
@@ -2424,7 +2801,7 @@ var RULES = [
2424
2801
  migrate: (ctx, finding) => {
2425
2802
  const changedFiles = [];
2426
2803
  const details = [];
2427
- const path = join10(ctx.repoRoot, ".project.json");
2804
+ const path = join11(ctx.repoRoot, ".project.json");
2428
2805
  const existing = readProjectJson(ctx) ?? {};
2429
2806
  const canonical = canonicalProjectJson(ctx);
2430
2807
  const merged = { ...existing, ...canonical };
@@ -2434,14 +2811,14 @@ var RULES = [
2434
2811
  changedFiles.push(path);
2435
2812
  if (!ctx.dryRun) writeText(path, expected);
2436
2813
  }
2437
- const planeJson = join10(ctx.repoRoot, ".plane.json");
2438
- if (existsSync7(planeJson)) {
2814
+ const planeJson = join11(ctx.repoRoot, ".plane.json");
2815
+ if (existsSync8(planeJson)) {
2439
2816
  const backup = `${planeJson}.migrated-backup`;
2440
- if (existsSync7(backup)) {
2817
+ if (existsSync8(backup)) {
2441
2818
  details.push(`cannot back up .plane.json because ${relative(ctx.repoRoot, backup)} already exists`);
2442
2819
  } else {
2443
2820
  changedFiles.push(backup);
2444
- if (!ctx.dryRun) renameSync(planeJson, backup);
2821
+ if (!ctx.dryRun) renameSync2(planeJson, backup);
2445
2822
  }
2446
2823
  }
2447
2824
  return {
@@ -2459,8 +2836,8 @@ var RULES = [
2459
2836
  title: ".env.op + gitignore secrets contract",
2460
2837
  audit: (ctx) => {
2461
2838
  const details = [];
2462
- const envOp = safeReadText(join10(ctx.repoRoot, ".env.op"));
2463
- const gitignore = safeReadText(join10(ctx.repoRoot, ".gitignore"));
2839
+ const envOp = safeReadText(join11(ctx.repoRoot, ".env.op"));
2840
+ const gitignore = safeReadText(join11(ctx.repoRoot, ".gitignore"));
2464
2841
  if (!envOp) {
2465
2842
  details.push(".env.op missing");
2466
2843
  } else {
@@ -2486,12 +2863,12 @@ var RULES = [
2486
2863
  migrate: (ctx, finding) => {
2487
2864
  const changedFiles = [];
2488
2865
  const details = [];
2489
- const envOpPath = join10(ctx.repoRoot, ".env.op");
2490
- if (!existsSync7(envOpPath)) {
2866
+ const envOpPath = join11(ctx.repoRoot, ".env.op");
2867
+ if (!existsSync8(envOpPath)) {
2491
2868
  changedFiles.push(envOpPath);
2492
- if (!ctx.dryRun) writeText(envOpPath, readText(join10(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2869
+ if (!ctx.dryRun) writeText(envOpPath, readText(join11(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2493
2870
  }
2494
- const gitignorePath = join10(ctx.repoRoot, ".gitignore");
2871
+ const gitignorePath = join11(ctx.repoRoot, ".gitignore");
2495
2872
  const gitignore = safeReadText(gitignorePath) ?? "";
2496
2873
  const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
2497
2874
  # NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
@@ -2518,7 +2895,7 @@ var RULES = [
2518
2895
  title: ".copier-answers.yml provenance + drift report",
2519
2896
  audit: (ctx) => {
2520
2897
  const details = [];
2521
- const path = join10(ctx.repoRoot, ".copier-answers.yml");
2898
+ const path = join11(ctx.repoRoot, ".copier-answers.yml");
2522
2899
  const text2 = safeReadText(path);
2523
2900
  const project = readProjectJson(ctx);
2524
2901
  if (!text2) {
@@ -2549,12 +2926,12 @@ var RULES = [
2549
2926
  const changedFiles = [];
2550
2927
  const project = canonicalProjectJson(ctx);
2551
2928
  const text2 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
2552
- _src_path: ${join10(ctx.pjanglerRoot, "templates", "commonproject")}
2929
+ _src_path: ${join11(ctx.pjanglerRoot, "templates", "commonproject")}
2553
2930
  project_description: ${String(project.project_description)}
2554
2931
  project_name: ${String(project.project_name)}
2555
2932
  ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2556
2933
  `;
2557
- const path = join10(ctx.repoRoot, ".copier-answers.yml");
2934
+ const path = join11(ctx.repoRoot, ".copier-answers.yml");
2558
2935
  if (safeReadText(path) !== text2) {
2559
2936
  changedFiles.push(path);
2560
2937
  if (!ctx.dryRun) writeText(path, text2);
@@ -2573,15 +2950,15 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2573
2950
  id: "bmad.scaffold",
2574
2951
  title: "BMAD modules/docs scaffold",
2575
2952
  audit: (ctx) => {
2576
- const sourceRoot = join10(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
2577
- const targetRoot = join10(ctx.repoRoot, "_bmad");
2953
+ const sourceRoot = join11(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
2954
+ const targetRoot = join11(ctx.repoRoot, "_bmad");
2578
2955
  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")
2956
+ join11("core", "config.yaml"),
2957
+ join11("custom", "config.yaml"),
2958
+ join11("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
2959
+ join11("bmm", "workflows", "workflow-status", "workflow.yaml")
2583
2960
  ];
2584
- const missing = sentinels.filter((file) => existsSync7(join10(sourceRoot, file)) && !existsSync7(join10(targetRoot, file)));
2961
+ const missing = sentinels.filter((file) => existsSync8(join11(sourceRoot, file)) && !existsSync8(join11(targetRoot, file)));
2585
2962
  return {
2586
2963
  id: "bmad.scaffold",
2587
2964
  title: "BMAD modules/docs scaffold",
@@ -2593,7 +2970,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2593
2970
  },
2594
2971
  migrate: (ctx, finding) => {
2595
2972
  const changedFiles = [];
2596
- copyMissingRecursive(join10(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join10(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
2973
+ copyMissingRecursive(join11(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join11(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
2597
2974
  return {
2598
2975
  id: finding.id,
2599
2976
  title: finding.title,
@@ -2615,11 +2992,11 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2615
2992
  }
2616
2993
  const details = [];
2617
2994
  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))}`);
2995
+ if (!existsSync8(join11(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join11(role.roleDir, rel))}`);
2619
2996
  }
2620
- const gitmodules = safeReadText(join10(ctx.repoRoot, ".gitmodules")) ?? "";
2997
+ const gitmodules = safeReadText(join11(ctx.repoRoot, ".gitmodules")) ?? "";
2621
2998
  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"))) {
2999
+ if (!profileMetaInheritsDefault(join11(role.roleDir, "runtime", "profile.yaml"))) {
2623
3000
  details.push("runtime/profile.yaml missing inherited default config metadata");
2624
3001
  }
2625
3002
  const registry = safeReadText(registryPath(ctx.homeDir));
@@ -2640,21 +3017,21 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2640
3017
  if (!role) {
2641
3018
  return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
2642
3019
  }
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)) {
3020
+ const templateRoleDir = join11(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
3021
+ writeIfDifferent(join11(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
3022
+ writeIfDifferent(join11(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
3023
+ writeIfDifferent(join11(role.roleDir, ".gitignore"), readText(join11(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
3024
+ copyMissingRecursive(join11(templateRoleDir, ".runtime-scaffold"), join11(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
3025
+ copyMissingRecursive(join11(templateRoleDir, ".runtime-scaffold"), join11(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
3026
+ copyMissingRecursive(join11(templateRoleDir, ".scripts"), join11(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
3027
+ const promptSource = join11(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
3028
+ const promptTarget = join11(role.roleDir, ".scripts", "sentinel.prompt.md");
3029
+ if (existsSync8(promptSource) && !existsSync8(promptTarget)) {
2653
3030
  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
3031
  writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
2655
3032
  }
2656
3033
  upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
2657
- const profileMetaUpdated = upsertInheritedProfileMeta(join10(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
3034
+ const profileMetaUpdated = upsertInheritedProfileMeta(join11(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
2658
3035
  if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
2659
3036
  const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
2660
3037
  if (registryUpdated) details.push(`updated ${registryUpdated}`);
@@ -2672,527 +3049,171 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2672
3049
  id: "systemd.sentinel",
2673
3050
  title: "Hermes systemd/sentinel units enabled + active",
2674
3051
  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"
3052
+ const roles = discoverRoles(ctx.repoRoot);
3053
+ if (!roles.length) {
3054
+ return { id: "systemd.sentinel", title: "Hermes systemd/sentinel units enabled + active", status: "skip", summary: "No Hermes roles present", details: [], fixable: false };
3043
3055
  }
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;
3056
+ const probe = systemctlUser(["is-system-running"]);
3057
+ if (!probe.ok && !/running|degraded|starting|maintenance/.test(`${probe.stdout} ${probe.stderr}`)) {
3058
+ 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
3059
  }
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;
3060
+ const details = [];
3061
+ for (const role of roles) {
3062
+ for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
3063
+ const state = checkUnit(unit);
3064
+ if (!state.enabled || !state.active) details.push(`${unit} should be enabled+active`);
3065
+ }
3072
3066
  }
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);
3067
+ return {
3068
+ id: "systemd.sentinel",
3069
+ title: "Hermes systemd/sentinel units enabled + active",
3070
+ status: details.length === 0 ? "pass" : "fail",
3071
+ summary: details.length === 0 ? "Hermes user units are enabled and active" : `${details.length} systemd parity issue(s) detected`,
3072
+ details,
3073
+ fixable: true
3074
+ };
3075
+ },
3076
+ migrate: (ctx, finding) => {
3077
+ const roles = discoverRoles(ctx.repoRoot);
3078
+ const changedFiles = [];
3079
+ const details = [];
3080
+ if (!roles.length) {
3081
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "No Hermes roles present", changedFiles, details };
3082
3082
  }
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");
3083
+ const probe = systemctlUser(["is-system-running"]);
3084
+ if (!probe.ok && !/running|degraded|starting|maintenance/.test(`${probe.stdout} ${probe.stderr}`)) {
3085
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
3086
+ }
3087
+ for (const role of roles) {
3088
+ const sysDir = join11(ctx.homeDir, ".config", "systemd", "user");
3089
+ const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
3090
+ const allUnitsPresent = units.every((unit) => existsSync8(join11(sysDir, unit)));
3091
+ if (allUnitsPresent) {
3092
+ if (ctx.dryRun) {
3093
+ details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
3094
+ } else {
3095
+ systemctlUser(["daemon-reload"]);
3096
+ for (const unit of units) {
3097
+ systemctlUser(["enable", "--now", unit]);
3098
+ }
3099
+ }
3100
+ continue;
3101
+ }
3102
+ for (const script of [join11(role.roleDir, ".scripts", "70-systemd.sh")]) {
3103
+ if (!script || !existsSync8(script)) continue;
3104
+ if (ctx.dryRun) {
3105
+ details.push(`would run: bash ${script}`);
3106
+ } else {
3107
+ const result = spawnSync5("bash", [script], { cwd: role.roleDir, encoding: "utf8" });
3108
+ if (result.status !== 0) details.push(`script failed: ${script}: ${result.stderr.trim() || result.stdout.trim()}`);
3109
+ }
3110
+ }
3111
+ }
3112
+ return {
3113
+ id: finding.id,
3114
+ title: finding.title,
3115
+ status: details.some((detail) => detail.includes("failed:")) ? "blocked" : details.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
3116
+ summary: details.length ? ctx.dryRun ? "Planned systemd remediation commands" : "Attempted systemd remediation" : "No changes required",
3117
+ changedFiles,
3118
+ details
3119
+ };
3089
3120
  }
3090
3121
  }
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
- }
3122
+ ];
3123
+ function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
3124
+ const normalized = content.endsWith("\n") ? content : `${content}
3125
+ `;
3126
+ if (safeReadText(path) === normalized) return;
3127
+ changedFiles.push(path);
3128
+ if (!dryRun) {
3129
+ writeText(path, normalized);
3130
+ if (mode) chmodSync2(path, mode);
3097
3131
  }
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
3132
  }
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;
3133
+ function getParityRuleIds() {
3134
+ return RULES.map((rule) => rule.id);
3131
3135
  }
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
3136
+ function runAudit(repoArg) {
3137
+ const pjanglerRoot = resolvePjanglerRoot2();
3138
+ const ctx = {
3139
+ repoRoot: resolve2(repoArg ?? process.cwd()),
3140
+ dryRun: true,
3141
+ pjanglerRoot,
3142
+ homeDir: homedir5()
3143
3143
  };
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");
3144
+ const rules = RULES.map((rule) => rule.audit(ctx));
3147
3145
  return {
3148
- kind: "copier.copy.commonproject",
3149
- cwd: input.pjanglerRoot,
3150
- command,
3151
- targetDir: input.targetDir,
3152
- data,
3153
- overwrite: input.overwrite
3146
+ repo: ctx.repoRoot,
3147
+ ok: rules.every((rule) => rule.status === "pass" || rule.status === "skip"),
3148
+ auditedAt: (/* @__PURE__ */ new Date()).toISOString(),
3149
+ rules
3154
3150
  };
3155
3151
  }
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}`);
3152
+ function runMigrationForRules(ruleIds, repoArg, dryRun) {
3153
+ const pjanglerRoot = resolvePjanglerRoot2();
3154
+ const ctx = {
3155
+ repoRoot: resolve2(repoArg ?? process.cwd()),
3156
+ dryRun,
3157
+ pjanglerRoot,
3158
+ homeDir: homedir5()
3159
+ };
3160
+ const selected = RULES.filter((rule) => ruleIds.includes(rule.id));
3161
+ if (!selected.length) {
3162
+ throw new Error(`Unknown parity rules: ${ruleIds.join(", ")}`);
3168
3163
  }
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}`);
3164
+ const results = selected.map((rule) => {
3165
+ try {
3166
+ return rule.migrate(ctx, rule.audit(ctx));
3167
+ } catch (err) {
3168
+ return {
3169
+ id: rule.id,
3170
+ title: rule.title,
3171
+ status: "blocked",
3172
+ summary: `migrate threw: ${err instanceof Error ? err.message : String(err)}`,
3173
+ changedFiles: [],
3174
+ details: []
3175
+ };
3176
3176
  }
3177
- }
3177
+ });
3178
+ const changedFiles = Array.from(new Set(results.flatMap((result) => result.changedFiles))).sort();
3179
+ return {
3180
+ repo: ctx.repoRoot,
3181
+ dryRun,
3182
+ ok: results.every((result) => result.status !== "blocked"),
3183
+ selectedRules: selected.map((rule) => rule.id),
3184
+ results,
3185
+ changedFiles
3186
+ };
3178
3187
  }
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`);
3188
+ function runMigration(selector, repoArg, dryRun, all) {
3189
+ const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
3190
+ return runMigrationForRules(ruleIds, repoArg, dryRun);
3188
3191
  }
3189
- function expandHome(path) {
3190
- if (path === "~") return homedir5();
3191
- if (path.startsWith("~/")) return join11(homedir5(), path.slice(2));
3192
- return path;
3192
+ function prettyTimestamp(iso) {
3193
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/.exec(iso);
3194
+ return match ? `${match[1]} ${match[2]} UTC` : iso;
3193
3195
  }
3194
- function isRecord(value) {
3195
- return typeof value === "object" && value !== null && !Array.isArray(value);
3196
+ function formatAuditReport(report) {
3197
+ const counts = {};
3198
+ for (const rule of report.rules) counts[rule.status] = (counts[rule.status] ?? 0) + 1;
3199
+ const idWidth = report.rules.reduce((width, rule) => Math.max(width, rule.id.length), 0);
3200
+ const tally = [];
3201
+ if (counts.pass) tally.push(green(`${counts.pass} passed`));
3202
+ if (counts.fail) tally.push(red(`${counts.fail} failed`));
3203
+ if (counts.warn) tally.push(yellow(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`));
3204
+ if (counts.skip) tally.push(gray(`${counts.skip} skipped`));
3205
+ const overall = report.ok ? `${green(glyph.pass)} ${bold("Parity audit passed")}` : `${red(glyph.fail)} ${bold("Parity audit failed")}`;
3206
+ const lines = [""];
3207
+ lines.push(` ${overall}${tally.length ? ` ${dim(glyph.dot)} ${joinDot(tally)}` : ""}`);
3208
+ lines.push(` ${dim(report.repo)} ${dim(glyph.dot)} ${dim(prettyTimestamp(report.auditedAt))}`);
3209
+ lines.push("");
3210
+ for (const rule of report.rules) {
3211
+ const style = statusStyle(rule.status);
3212
+ lines.push(` ${style.color(style.glyph)} ${style.color(rule.id.padEnd(idWidth))} ${rule.summary}`);
3213
+ for (const detail of rule.details) lines.push(` ${dim(glyph.arrow)} ${dim(detail)}`);
3214
+ }
3215
+ lines.push("");
3216
+ return lines.join("\n");
3196
3217
  }
3197
3218
 
3198
3219
  // src/mcp-server.ts