@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.
package/dist/index.js CHANGED
@@ -1201,2093 +1201,2114 @@ var HermesAgentRecipe = class extends Recipe {
1201
1201
  };
1202
1202
 
1203
1203
  // src/commands/AgentHooksCommands.ts
1204
- import { homedir as homedir3 } from "node:os";
1205
- import { join as join8, dirname as dirname4 } from "node:path";
1206
- import { existsSync as existsSync6, cpSync, mkdirSync as mkdirSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "node:fs";
1204
+ import { homedir as homedir4 } from "node:os";
1205
+ import { join as join9, dirname as dirname5 } from "node:path";
1206
+ import { existsSync as existsSync7, cpSync, mkdirSync as mkdirSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
1207
1207
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1208
- function resolveTemplateRoot() {
1209
- const candidates = [];
1210
- if (process.env.PJANGLER_COMMONPROJECT_TEMPLATE) {
1211
- candidates.push(process.env.PJANGLER_COMMONPROJECT_TEMPLATE);
1208
+
1209
+ // src/project/index.ts
1210
+ import { spawnSync as spawnSync4 } from "node:child_process";
1211
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync2, renameSync, statSync, writeFileSync as writeFileSync3 } from "node:fs";
1212
+ import { homedir as homedir3 } from "node:os";
1213
+ import { basename as basename2, dirname as dirname4, join as join8, resolve } from "node:path";
1214
+ import YAML from "yaml";
1215
+ var PROJECT_REGISTRY_ENV = "PJ_PROJECT_REGISTRY";
1216
+ var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
1217
+ var KNOWN_SKILL_ROOTS = [
1218
+ "/home/delorenj/code/skillex/all-skills",
1219
+ "/home/delorenj/code/CoachingAgentFramework/.agents/skills",
1220
+ "/home/delorenj/code/pjangler/.agents/skills",
1221
+ join8(homedir3(), ".codex", "skills")
1222
+ ];
1223
+ function projectRegistryPath(env2 = process.env) {
1224
+ return expandHome(env2[PROJECT_REGISTRY_ENV] || join8(homedir3(), ".config", "pjangler", "projects.yaml"));
1225
+ }
1226
+ function emptyProjectRegistry() {
1227
+ return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
1228
+ }
1229
+ function loadProjectRegistry(path = projectRegistryPath()) {
1230
+ if (!existsSync6(path)) return emptyProjectRegistry();
1231
+ const raw = YAML.parse(readFileSync2(path, "utf8"));
1232
+ if (raw == null) return emptyProjectRegistry();
1233
+ if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
1234
+ const registry = raw;
1235
+ const normalized = {
1236
+ schema_version: Number(registry.schema_version ?? PROJECT_REGISTRY_SCHEMA_VERSION),
1237
+ projects: isRecord(registry.projects) ? registry.projects : {}
1238
+ };
1239
+ validateProjectRegistry(normalized);
1240
+ return normalized;
1241
+ }
1242
+ function saveProjectRegistry(registry, path = projectRegistryPath()) {
1243
+ validateProjectRegistry(registry);
1244
+ mkdirSync4(dirname4(path), { recursive: true });
1245
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
1246
+ writeFileSync3(temp, YAML.stringify(registry, { lineWidth: 0 }), "utf8");
1247
+ renameSync(temp, path);
1248
+ }
1249
+ function validateProjectRegistry(registry) {
1250
+ if (registry.schema_version !== PROJECT_REGISTRY_SCHEMA_VERSION) {
1251
+ throw new Error(`Unsupported project registry schema_version: ${registry.schema_version}`);
1212
1252
  }
1213
- try {
1214
- let dir = dirname4(fileURLToPath2(import.meta.url));
1215
- for (let i = 0; i < 8; i++) {
1216
- candidates.push(join8(dir, "templates", "commonproject", "template"));
1217
- const parent = dirname4(dir);
1218
- if (parent === dir) break;
1219
- dir = parent;
1253
+ if (!isRecord(registry.projects)) throw new Error("Project registry projects must be a mapping");
1254
+ const slugs = /* @__PURE__ */ new Set();
1255
+ const repoPaths = /* @__PURE__ */ new Map();
1256
+ const identifiers = /* @__PURE__ */ new Map();
1257
+ for (const [slug, project] of Object.entries(registry.projects)) {
1258
+ validateProjectRecord(project, slug);
1259
+ if (slugs.has(project.slug)) throw new Error(`Duplicate project slug: ${project.slug}`);
1260
+ slugs.add(project.slug);
1261
+ const repoKey = resolve(project.repo_path);
1262
+ const existingRepoSlug = repoPaths.get(repoKey);
1263
+ if (existingRepoSlug && existingRepoSlug !== slug) {
1264
+ throw new Error(`Duplicate project repo_path: ${project.repo_path} used by ${existingRepoSlug} and ${slug}`);
1265
+ }
1266
+ repoPaths.set(repoKey, slug);
1267
+ const identifier = project.ticket_provider.identifier?.toUpperCase();
1268
+ if (identifier) {
1269
+ const existingIdentifierSlug = identifiers.get(identifier);
1270
+ if (existingIdentifierSlug && existingIdentifierSlug !== slug) {
1271
+ throw new Error(`Duplicate project identifier: ${identifier} used by ${existingIdentifierSlug} and ${slug}`);
1272
+ }
1273
+ identifiers.set(identifier, slug);
1220
1274
  }
1221
- } catch {
1222
1275
  }
1223
- candidates.push(join8(homedir3(), "code", "pjangler", "templates", "commonproject", "template"));
1224
- for (const c of candidates) {
1225
- if (existsSync6(join8(c, ".agents", "hooks", "hooks.master.json"))) return c;
1276
+ }
1277
+ function slugifyProjectName(value) {
1278
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
1279
+ }
1280
+ function deriveProjectIdentifier(value) {
1281
+ const compact = value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
1282
+ const identifier = compact.slice(0, 4) || "PROJ";
1283
+ return identifier.length >= 2 ? identifier : `${identifier}XX`.slice(0, 4);
1284
+ }
1285
+ function normalizeAgentRole(value) {
1286
+ return value?.trim() || "pm";
1287
+ }
1288
+ function resolveAgentHooksLayer(input, env2 = process.env) {
1289
+ if (typeof input === "boolean") return input;
1290
+ const override = env2.PJ_AGENT_HOOKS_LAYER;
1291
+ if (override === "0" || override === "false") return false;
1292
+ if (override === "1" || override === "true") return true;
1293
+ return !existsSync6(join8(homedir3(), ".agents", "hooks"));
1294
+ }
1295
+ function jsonStable(value) {
1296
+ return JSON.stringify(value);
1297
+ }
1298
+ function projectRecordEquivalent(a, b) {
1299
+ if (!a) return false;
1300
+ const { created_at: _aCreated, updated_at: _aUpdated, ...aComparable } = a;
1301
+ const { created_at: _bCreated, updated_at: _bUpdated, ...bComparable } = b;
1302
+ return jsonStable(aComparable) === jsonStable(bComparable);
1303
+ }
1304
+ function defaultProjectTargetDir(name, cwd = process.cwd()) {
1305
+ const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
1306
+ return resolve(dirname4(resolve(cwd)), compactName);
1307
+ }
1308
+ function resolveSourceSkillPath(sourceSkill) {
1309
+ if (!sourceSkill) return void 0;
1310
+ const expanded = expandHome(sourceSkill);
1311
+ const direct = resolve(expanded);
1312
+ if (existsSync6(direct)) return direct;
1313
+ const name = basename2(sourceSkill);
1314
+ for (const root of KNOWN_SKILL_ROOTS) {
1315
+ const candidate = join8(root, name);
1316
+ if (existsSync6(candidate)) return candidate;
1226
1317
  }
1227
- throw new Error(
1228
- "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
1229
- );
1318
+ const civilWarLetterifier = "/home/delorenj/code/skillex/all-skills/civilwar-letterifier";
1319
+ const hint = existsSync6(civilWarLetterifier) ? ` Did you mean ${civilWarLetterifier}?` : "";
1320
+ throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
1230
1321
  }
1231
- var CopyAgentHooksTree = class extends Command {
1232
- async invoke() {
1233
- let templateRoot;
1234
- try {
1235
- templateRoot = resolveTemplateRoot();
1236
- } catch (e) {
1237
- return { success: false, message: `\u26A0\uFE0F ${e.message}` };
1322
+ function planProjectInit(input) {
1323
+ if (!input.name.trim()) throw new Error("Project name is required");
1324
+ const registryPath2 = resolve(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
1325
+ const registry = loadProjectRegistry(registryPath2);
1326
+ const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
1327
+ const slug = input.projectSlug ?? slugifyProjectName(input.name);
1328
+ const targetDir = resolve(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
1329
+ const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
1330
+ const existing = registry.projects[slug];
1331
+ const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
1332
+ const overwrite = input.overwrite ?? input.force ?? false;
1333
+ const agentRole = normalizeAgentRole(input.agentRole);
1334
+ const agents = input.provisionAgent ? {
1335
+ ...existing?.agents ?? {},
1336
+ [agentRole]: {
1337
+ role: agentRole,
1338
+ provisioning_state: "planned"
1238
1339
  }
1239
- const items = [
1240
- { rel: ".agents/hooks", dir: true },
1241
- { rel: ".agents/local.example.json", dir: false },
1242
- { rel: ".mise/scripts/link-project-skills-to-clis.sh", dir: false },
1243
- { rel: ".mise/scripts/unlink-project-skills-from-clis.sh", dir: false },
1244
- { rel: ".mise/scripts/hindsight-setup.sh", dir: false }
1245
- ];
1246
- const created = [];
1247
- const skipped = [];
1248
- for (const { rel, dir } of items) {
1249
- const src = join8(templateRoot, rel);
1250
- const dest = join8(this.context.targetDir, rel);
1251
- if (!existsSync6(src)) continue;
1252
- if (existsSync6(dest) && !this.context.force) {
1253
- skipped.push(rel);
1254
- continue;
1255
- }
1256
- if (!this.context.dryRun) {
1257
- mkdirSync4(dirname4(dest), { recursive: true });
1258
- cpSync(src, dest, { recursive: dir, force: true });
1340
+ } : existing?.agents ?? {};
1341
+ const scaffold = input.scaffold ?? true;
1342
+ const candidateProject = {
1343
+ name: input.name,
1344
+ slug,
1345
+ repo_path: targetDir,
1346
+ description: input.description ?? "",
1347
+ status: "planned",
1348
+ source_artifacts: sourceSkillPath ? [{ kind: "skill", path: sourceSkillPath, package_name: input.packageName ?? slug }] : [],
1349
+ template: {
1350
+ commonproject: {
1351
+ enabled: true,
1352
+ primary_language: input.primaryLanguage ?? "python"
1259
1353
  }
1260
- created.push(rel);
1261
- }
1262
- const verb = this.context.dryRun ? "Would copy" : "Copied";
1263
- const tail = skipped.length ? ` (${skipped.length} already present \u2014 use --force to overwrite)` : "";
1264
- return {
1265
- success: created.length > 0,
1266
- message: this.formatMessage(`\u2705 ${verb} ${created.length} agent-hooks path(s)${tail}`)
1267
- };
1354
+ },
1355
+ ticket_provider: {
1356
+ type: input.ticketProvider ?? "plane",
1357
+ workspace: input.planeWorkspace ?? "33god",
1358
+ identifier,
1359
+ board_id: input.planeProjectId ?? "",
1360
+ board_url: input.planeProjectId ? `https://plane.delo.sh/${input.planeWorkspace ?? "33god"}/projects/${input.planeProjectId}/issues/` : "",
1361
+ state: input.live ? "planned" : "planned"
1362
+ },
1363
+ agents,
1364
+ created_at: existing?.created_at ?? now,
1365
+ updated_at: now
1366
+ };
1367
+ const project = {
1368
+ ...candidateProject,
1369
+ updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
1370
+ };
1371
+ validateNoDuplicateProject(registry, project, overwrite);
1372
+ const pjanglerRoot = resolve(input.pjanglerRoot ?? resolvePjanglerRoot());
1373
+ const manifest = projectManifestFromRegistryProject(project);
1374
+ const apply = input.apply ?? false;
1375
+ const live = input.live ?? false;
1376
+ const actions = [
1377
+ { kind: "registry.upsert", registryPath: registryPath2, slug, project }
1378
+ ];
1379
+ if (scaffold) {
1380
+ actions.push(buildCommonProjectCopierAction({
1381
+ pjanglerRoot,
1382
+ targetDir,
1383
+ projectName: project.name,
1384
+ projectDescription: project.description,
1385
+ projectSlug: project.slug,
1386
+ ticketProvider: project.ticket_provider.type,
1387
+ planeWorkspace: project.ticket_provider.workspace ?? "33god",
1388
+ planeProjectId: project.ticket_provider.board_id ?? "",
1389
+ projectIdentifier: identifier,
1390
+ primaryLanguage: project.template.commonproject.primary_language,
1391
+ agentHooksLayer: resolveAgentHooksLayer(input.agentHooksLayer),
1392
+ overwrite
1393
+ }));
1268
1394
  }
1269
- };
1270
- var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
1271
- static MARKER = "# pjangler:agent-hooks";
1272
- static CR = "{{config_root}}";
1273
- // mise's own runtime var — emitted literally
1274
- async invoke() {
1275
- const misePath = join8(this.context.targetDir, "mise.toml");
1276
- if (!existsSync6(misePath)) {
1277
- return {
1278
- success: false,
1279
- message: "\u26A0\uFE0F No mise.toml found \u2014 run `pjangler init mise` first, then re-run."
1280
- };
1281
- }
1282
- let content = readFileSync2(misePath, "utf8");
1283
- if (content.includes(_WireMiseAgentHooks.MARKER)) {
1284
- return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for agent-hooks") };
1395
+ actions.push(
1396
+ { kind: "project.write-manifest", path: join8(targetDir, ".project.json"), manifest },
1397
+ {
1398
+ kind: "plane.create-or-link",
1399
+ enabled: live,
1400
+ live,
1401
+ workspace: project.ticket_provider.workspace ?? "33god",
1402
+ identifier,
1403
+ state: live ? "planned" : "planned",
1404
+ reason: live ? void 0 : "network/cloud actions require --live"
1405
+ },
1406
+ {
1407
+ kind: "hermes.provision-agent",
1408
+ enabled: input.provisionAgent ?? false,
1409
+ local: !live,
1410
+ targetDir,
1411
+ targetRepo: slug,
1412
+ role: agentRole,
1413
+ context: {
1414
+ skipRuntimeRepo: !live,
1415
+ skipPlane: !live,
1416
+ skipBloodbank: !live,
1417
+ skipSystemd: !live || process.platform === "darwin"
1418
+ }
1285
1419
  }
1286
- const cr = _WireMiseAgentHooks.CR;
1287
- const enterAdds = [
1288
- ` "${cr}/.mise/scripts/link-project-skills-to-clis.sh",`,
1289
- ` "${cr}/.agents/hooks/sync.py --install --quiet",`
1290
- ].join("\n");
1291
- const leaveBlock = [
1292
- "leave = [",
1293
- ` "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",`,
1294
- ` "${cr}/.agents/hooks/sync.py --uninstall --quiet",`,
1295
- "]"
1296
- ].join("\n");
1297
- let wiredHooks = false;
1298
- const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
1299
- if (enterRe.test(content)) {
1300
- content = content.replace(enterRe, (_m, head, close) => {
1301
- const sep = /[,[]\s*$/.test(head) ? "" : ",";
1302
- return `${head}${sep}
1303
- ${enterAdds}${close}`;
1304
- });
1305
- const leaveRe = /(leave\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
1306
- if (leaveRe.test(content)) {
1307
- content = content.replace(leaveRe, (_m, head, close) => {
1308
- const sep = /[,[]\s*$/.test(head) ? "" : ",";
1309
- return `${head}${sep}
1310
- "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",
1311
- "${cr}/.agents/hooks/sync.py --uninstall --quiet",${close}`;
1312
- });
1313
- } else {
1314
- content = content.replace(enterRe, (m) => `${m}
1315
- ${leaveBlock}`);
1420
+ );
1421
+ return { ok: true, apply, dryRun: !apply, live, registryPath: registryPath2, project, manifest, actions };
1422
+ }
1423
+ function executeProjectInitPlan(plan) {
1424
+ const logs = [];
1425
+ const errors = [];
1426
+ const changedFiles = [];
1427
+ if (!plan.apply) return { ok: true, plan, logs, errors, changedFiles };
1428
+ const registry = loadProjectRegistry(plan.registryPath);
1429
+ let pendingRegistryAction;
1430
+ for (const action of plan.actions) {
1431
+ if (action.kind === "copier.copy.commonproject") {
1432
+ logs.push(
1433
+ 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"
1434
+ );
1435
+ mkdirSync4(dirname4(action.targetDir), { recursive: true });
1436
+ const result = spawnSync4(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
1437
+ if (result.stdout?.trim()) logs.push(result.stdout.trim());
1438
+ if (result.stderr?.trim()) logs.push(result.stderr.trim());
1439
+ if (result.error) {
1440
+ const code = result.error.code;
1441
+ errors.push(
1442
+ code === "ENOENT" ? "copier not found on PATH. Install with: uv tool install copier or pip install copier" : `copier failed: ${result.error.message}`
1443
+ );
1444
+ break;
1316
1445
  }
1317
- wiredHooks = true;
1446
+ if (result.status !== 0) {
1447
+ errors.push(`copier exited with status ${result.status ?? "unknown"}`);
1448
+ if (existsSync6(action.targetDir)) changedFiles.push(action.targetDir);
1449
+ break;
1450
+ }
1451
+ changedFiles.push(action.targetDir);
1452
+ } else if (action.kind === "project.write-manifest") {
1453
+ mkdirSync4(dirname4(action.path), { recursive: true });
1454
+ const next = `${JSON.stringify(action.manifest, null, 2)}
1455
+ `;
1456
+ const current = existsSync6(action.path) ? readFileSync2(action.path, "utf8") : void 0;
1457
+ if (current !== next) {
1458
+ writeFileSync3(action.path, next, "utf8");
1459
+ changedFiles.push(action.path);
1460
+ }
1461
+ } else if (action.kind === "registry.upsert") {
1462
+ pendingRegistryAction = action;
1463
+ } else if (action.kind === "plane.create-or-link") {
1464
+ logs.push(action.enabled ? "plane.create-or-link requires a live provider integration" : "plane.create-or-link skipped (requires --live)");
1465
+ } else if (action.kind === "hermes.provision-agent") {
1466
+ logs.push(action.enabled ? "hermes.provision-agent planned for the caller to execute" : "hermes.provision-agent skipped");
1318
1467
  }
1319
- const appended = [
1320
- "",
1321
- _WireMiseAgentHooks.MARKER + " (generated \u2014 see .agents/hooks/README.md)",
1322
- "[[watch_files]]",
1323
- 'patterns = [".agents/hooks/hooks.master.json"]',
1324
- 'task = "hooks-sync"',
1325
- "",
1326
- "[tasks.hooks-sync]",
1327
- 'description = "Fan out hooks.master.json to each agent CLI (claude/codex/kimi/hermes)"',
1328
- `run = "${cr}/.agents/hooks/sync.py --install"`,
1329
- "",
1330
- "[tasks.hooks-check]",
1331
- 'description = "Drift gate: verify generated hook configs match hooks.master.json"',
1332
- `run = "${cr}/.agents/hooks/sync.py --check"`,
1333
- "",
1334
- "[tasks.hooks-uninstall]",
1335
- 'description = "Remove per-user agent-hook injections (codex/kimi/hermes)"',
1336
- `run = "${cr}/.agents/hooks/sync.py --uninstall"`,
1337
- "",
1338
- "[tasks.link-project-skills-to-clis]",
1339
- 'description = "Fan .agents/skills out to each agent CLI (honors local.json)"',
1340
- `run = "${cr}/.mise/scripts/link-project-skills-to-clis.sh"`,
1341
- "",
1342
- "[tasks.unlink-project-skills-from-clis]",
1343
- 'description = "Remove project skill symlinks from shared per-CLI dirs"',
1344
- `run = "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh"`,
1345
- "",
1346
- "[tasks.skills-relink]",
1347
- 'description = "Re-fan the project skill set to all CLIs"',
1348
- `run = "${cr}/.mise/scripts/link-project-skills-to-clis.sh"`,
1349
- "",
1350
- "[tasks.hindsight-setup]",
1351
- `description = "Provision this dev's shared project Hindsight key from 1Password into .env"`,
1352
- `run = "${cr}/.mise/scripts/hindsight-setup.sh"`,
1353
- "",
1354
- _WireMiseAgentHooks.MARKER + ":end",
1355
- ""
1356
- ].join("\n");
1357
- content = content.replace(/\n*$/, "\n") + appended;
1358
- if (!this.context.dryRun) writeFileSync3(misePath, content);
1359
- if (wiredHooks) {
1360
- return { success: true, message: this.formatMessage("\u2705 Wired mise.toml ([hooks] enter/leave + tasks)") };
1468
+ }
1469
+ if (pendingRegistryAction && errors.length === 0) {
1470
+ if (!projectRecordEquivalent(registry.projects[pendingRegistryAction.slug], pendingRegistryAction.project)) {
1471
+ registry.projects[pendingRegistryAction.slug] = pendingRegistryAction.project;
1472
+ saveProjectRegistry(registry, pendingRegistryAction.registryPath);
1473
+ changedFiles.push(pendingRegistryAction.registryPath);
1361
1474
  }
1362
- return {
1363
- success: true,
1364
- message: this.formatMessage(
1365
- `\u2705 Added agent-hooks tasks to mise.toml.
1366
- \u26A0\uFE0F Could not find a [hooks].enter array to extend \u2014 add these to your [hooks] block manually:
1367
- enter += "${cr}/.mise/scripts/link-project-skills-to-clis.sh", "${cr}/.agents/hooks/sync.py --install --quiet"
1368
- leave += "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh", "${cr}/.agents/hooks/sync.py --uninstall --quiet"`
1369
- )
1370
- };
1371
1475
  }
1372
- };
1373
-
1374
- // src/recipes/AgentHooksRecipe.ts
1375
- var AgentHooksRecipe = class extends Recipe {
1376
- constructor(context) {
1377
- super(context);
1378
- this.addIngredient(CopyAgentHooksTree).addIngredient(WireMiseAgentHooks);
1476
+ return { ok: errors.length === 0, plan, logs, errors, changedFiles };
1477
+ }
1478
+ function projectManifestFromRegistryProject(project) {
1479
+ const agents = Object.fromEntries(
1480
+ Object.entries(project.agents).map(([name, agent]) => [
1481
+ `${project.slug}-${name}`,
1482
+ {
1483
+ role: agent.role,
1484
+ role_dir: agent.role_dir,
1485
+ provisioning_state: agent.provisioning_state
1486
+ }
1487
+ ])
1488
+ );
1489
+ return {
1490
+ project_name: project.name,
1491
+ project_description: project.description,
1492
+ project_slug: project.slug,
1493
+ repo_path: project.repo_path,
1494
+ ticket_provider: {
1495
+ type: project.ticket_provider.type,
1496
+ workspace: project.ticket_provider.workspace ?? "",
1497
+ identifier: project.ticket_provider.identifier ?? "",
1498
+ board_id: project.ticket_provider.board_id ?? "",
1499
+ board_url: project.ticket_provider.board_url ?? "",
1500
+ state: project.ticket_provider.state
1501
+ },
1502
+ agents
1503
+ };
1504
+ }
1505
+ function formatProjectInitPlan(plan) {
1506
+ const lines = [""];
1507
+ const title = `${bold(plan.project.name)} ${dim(`(${plan.project.slug})`)}`;
1508
+ lines.push(` ${cyan(bold(glyph.chevron))} ${title}${plan.dryRun ? ` ${dim(glyph.dot)} ${yellow("dry run")}` : ""}`);
1509
+ lines.push(` ${dim("registry".padEnd(8))} ${dim(plan.registryPath)}`);
1510
+ lines.push(` ${dim("target".padEnd(8))} ${dim(plan.project.repo_path)}`);
1511
+ lines.push("");
1512
+ lines.push(` ${bold("Actions")} ${dim(`(${plan.actions.length})`)}`);
1513
+ if (!plan.actions.length) lines.push(` ${dim("(nothing to do)")}`);
1514
+ for (const action of plan.actions) {
1515
+ lines.push(` ${cyan(glyph.bullet)} ${action.kind}`);
1516
+ if (action.kind === "copier.copy.commonproject") lines.push(` ${dim(`target: ${action.targetDir}`)}`);
1517
+ if (action.kind === "project.write-manifest") lines.push(` ${dim(`path: ${action.path}`)}`);
1518
+ if (action.kind === "plane.create-or-link" && action.reason) lines.push(` ${dim(`note: ${action.reason}`)}`);
1379
1519
  }
1380
- printNextSteps() {
1381
- console.log("\u{1FA9D} Agent-hooks layer installed!");
1382
- console.log(" Next steps:");
1383
- console.log(" 1. mise run hooks-sync # generate .claude/settings.json + inject codex/kimi/hermes");
1384
- console.log(" 2. git add .claude/settings.json .agents/hooks && commit (codex/kimi/hermes are per-dev)");
1385
- console.log(" 3. mise run hindsight-setup # set HINDSIGHT_OP_KEY_REF to your 1Password item first");
1386
- console.log(` 4. If you run a global agent system: echo '{"skills":{"defer_to_global":true}}' > .agents/local.json`);
1520
+ lines.push("");
1521
+ return lines.join("\n");
1522
+ }
1523
+ function formatProjectList(registry) {
1524
+ const projects = Object.values(registry.projects).sort((a, b) => a.slug.localeCompare(b.slug));
1525
+ if (!projects.length) return `
1526
+ ${dim("No projects registered.")}
1527
+ `;
1528
+ const slugWidth = projects.reduce((width, project) => Math.max(width, project.slug.length), 0);
1529
+ const idWidth = projects.reduce((width, project) => Math.max(width, String(project.ticket_provider.identifier ?? "").length), 0);
1530
+ const statusWidth = projects.reduce((width, project) => Math.max(width, project.status.length), 0);
1531
+ const lines = ["", ` ${bold("Projects")} ${dim(`(${projects.length})`)}`, ""];
1532
+ for (const project of projects) {
1533
+ const slug = bold(project.slug.padEnd(slugWidth));
1534
+ const identifier = cyan(String(project.ticket_provider.identifier ?? "").padEnd(idWidth));
1535
+ const status = projectStatusColor(project.status)(project.status.padEnd(statusWidth));
1536
+ lines.push(` ${slug} ${identifier} ${status} ${dim(project.repo_path)}`);
1387
1537
  }
1388
- };
1389
-
1390
- // src/utils/registry.ts
1391
- var RECIPE_REGISTRY = {
1392
- mise: {
1393
- name: "mise",
1394
- description: "Mise task runner and environment setup",
1395
- class: MiseRecipe,
1396
- commands: ["AddMiseToml", "AddDotenv", "AddMiseTasksStructure", "AddMiseBaseToml", "AddMiseBaseScript", "AddMiseCodegraphScript"]
1397
- },
1398
- docker: {
1399
- name: "docker",
1400
- description: "Docker containerization setup",
1401
- class: DockerRecipe,
1402
- commands: ["AddDockerfile", "AddDockerCompose", "AddDockerignore"]
1403
- },
1404
- node: {
1405
- name: "node",
1406
- description: "Node.js project template",
1407
- class: NodeRecipe,
1408
- commands: ["NodeCommands"]
1409
- // Placeholder - actual commands in NodeCommands.ts
1410
- },
1411
- "hermes-agent": {
1412
- name: "hermes-agent",
1413
- description: "Add a Hermes agent role to this repo (copier + BotFather + CF email + submodule)",
1414
- class: HermesAgentRecipe,
1415
- commands: [
1416
- "EnsureTemplateConfig",
1417
- "PromptForAgentConfig",
1418
- "RunCopierTemplate",
1419
- "WireTelegram",
1420
- "WireEmail",
1421
- "PrintHermesSummary"
1422
- ]
1423
- },
1424
- "agent-hooks": {
1425
- name: "agent-hooks",
1426
- description: "Retrofit the project-scoped agent-hooks + skill fan-out layer (Claude/Codex/Kimi/Hermes hooks via mise enter/leave)",
1427
- class: AgentHooksRecipe,
1428
- commands: ["CopyAgentHooksTree", "WireMiseAgentHooks"]
1538
+ lines.push("");
1539
+ return lines.join("\n");
1540
+ }
1541
+ function getProject(registry, slug) {
1542
+ const project = registry.projects[slug];
1543
+ if (!project) throw new Error(`Project not found in registry: ${slug}`);
1544
+ return project;
1545
+ }
1546
+ function doctorProjectRegistry(registryPath2 = projectRegistryPath(), slug) {
1547
+ const issues = [];
1548
+ const registry = loadProjectRegistry(registryPath2);
1549
+ const projects = slug ? [[slug, getProject(registry, slug)]] : Object.entries(registry.projects);
1550
+ for (const [projectSlug, project] of projects) {
1551
+ if (!existsSync6(project.repo_path)) {
1552
+ issues.push({ level: "warn", slug: projectSlug, message: `repo_path does not exist: ${project.repo_path}` });
1553
+ } else if (!statSync(project.repo_path).isDirectory()) {
1554
+ issues.push({ level: "error", slug: projectSlug, message: `repo_path is not a directory: ${project.repo_path}` });
1555
+ } else {
1556
+ const manifestPath = join8(project.repo_path, ".project.json");
1557
+ if (!existsSync6(manifestPath)) issues.push({ level: "warn", slug: projectSlug, message: ".project.json is missing" });
1558
+ }
1559
+ for (const artifact of project.source_artifacts) {
1560
+ if (artifact.path && !existsSync6(artifact.path)) {
1561
+ issues.push({ level: "warn", slug: projectSlug, message: `source artifact missing: ${artifact.path}` });
1562
+ }
1563
+ }
1429
1564
  }
1430
- };
1431
- var COMMAND_REGISTRY = {
1432
- CopyAgentHooksTree: {
1433
- name: "CopyAgentHooksTree",
1434
- description: "Copy the generic agent-hooks tree (hooks SSOT + sync engine + scripts) from the CommonProject template",
1435
- group: "agent-hooks",
1436
- class: CopyAgentHooksTree
1437
- },
1438
- WireMiseAgentHooks: {
1439
- name: "WireMiseAgentHooks",
1440
- description: "Merge agent-hooks enter/leave + tasks into an existing mise.toml (idempotent)",
1441
- group: "agent-hooks",
1442
- class: WireMiseAgentHooks
1443
- },
1444
- AddDockerfile: {
1445
- name: "AddDockerfile",
1446
- description: "Create Dockerfile for containerization",
1447
- group: "docker",
1448
- class: AddDockerfile
1449
- },
1450
- AddDockerCompose: {
1451
- name: "AddDockerCompose",
1452
- description: "Create docker-compose.yml for multi-service setup",
1453
- group: "docker",
1454
- class: AddDockerCompose
1455
- },
1456
- AddDockerignore: {
1457
- name: "AddDockerignore",
1458
- description: "Create .dockerignore file",
1459
- group: "docker",
1460
- class: AddDockerignore
1461
- },
1462
- AddMiseToml: {
1463
- name: "AddMiseToml",
1464
- description: "Create mise.toml for version management",
1465
- group: "mise",
1466
- class: AddMiseToml
1467
- },
1468
- AddMiseBaseToml: {
1469
- name: "AddMiseBaseToml",
1470
- description: "Create base mise configuration",
1471
- group: "mise",
1472
- class: AddMiseBaseToml
1473
- },
1474
- AddMiseTasksStructure: {
1475
- name: "AddMiseTasksStructure",
1476
- description: "Create .mise/tasks directory structure",
1477
- group: "mise",
1478
- class: AddMiseTasksStructure
1479
- },
1480
- AddMiseBaseScript: {
1481
- name: "AddMiseBaseScript",
1482
- description: "Create base mise task scripts",
1483
- group: "mise",
1484
- class: AddMiseBaseScript
1485
- },
1486
- AddMiseCodegraphScript: {
1487
- name: "AddMiseCodegraphScript",
1488
- description: "Create .mise/scripts/codegraph.sh enter hook",
1489
- group: "mise",
1490
- class: AddMiseCodegraphScript
1491
- },
1492
- AddDotenv: {
1493
- name: "AddDotenv",
1494
- description: "Create .env.example file",
1495
- group: "environment",
1496
- class: AddDotenv
1497
- }
1498
- };
1499
- function getRecipeNames() {
1500
- return Object.keys(RECIPE_REGISTRY);
1501
- }
1502
- function getRecipeInfo(name) {
1503
- return RECIPE_REGISTRY[name] || null;
1504
- }
1505
- function getCommandNames() {
1506
- return Object.keys(COMMAND_REGISTRY);
1565
+ return {
1566
+ ok: !issues.some((issue) => issue.level === "error"),
1567
+ registryPath: registryPath2,
1568
+ checkedProjects: projects.map(([projectSlug]) => projectSlug),
1569
+ issues
1570
+ };
1507
1571
  }
1508
- function getCommandInfo(name) {
1509
- return COMMAND_REGISTRY[name] || null;
1572
+ function buildCommonProjectCopierAction(input) {
1573
+ const templateDir = join8(input.pjanglerRoot, "templates", "commonproject");
1574
+ const data = {
1575
+ project_name: input.projectName,
1576
+ project_description: input.projectDescription ?? "",
1577
+ project_slug: input.projectSlug,
1578
+ ticket_provider: input.ticketProvider,
1579
+ plane_workspace: input.planeWorkspace,
1580
+ plane_project_id: input.planeProjectId ?? "",
1581
+ project_identifier: input.projectIdentifier,
1582
+ primary_language: input.primaryLanguage,
1583
+ agent_hooks_layer: input.agentHooksLayer ?? true ? "true" : "false"
1584
+ };
1585
+ const command = ["copier", "copy", "--trust", templateDir, input.targetDir, "--defaults"];
1586
+ for (const [key, value] of Object.entries(data)) command.push("--data", `${key}=${value}`);
1587
+ if (input.overwrite) command.push("--overwrite");
1588
+ return {
1589
+ kind: "copier.copy.commonproject",
1590
+ cwd: input.pjanglerRoot,
1591
+ command,
1592
+ targetDir: input.targetDir,
1593
+ data,
1594
+ overwrite: input.overwrite
1595
+ };
1510
1596
  }
1511
- function getCommandsByGroup() {
1512
- const grouped = {};
1513
- for (const cmdInfo of Object.values(COMMAND_REGISTRY)) {
1514
- if (!grouped[cmdInfo.group]) {
1515
- grouped[cmdInfo.group] = [];
1516
- }
1517
- grouped[cmdInfo.group].push(cmdInfo);
1597
+ function resolvePjanglerRoot() {
1598
+ let dir = dirname4(new URL(import.meta.url).pathname);
1599
+ while (dir !== dirname4(dir)) {
1600
+ if (existsSync6(join8(dir, "package.json")) && existsSync6(join8(dir, "templates", "commonproject", "copier.yml"))) return dir;
1601
+ dir = dirname4(dir);
1518
1602
  }
1519
- return grouped;
1520
- }
1521
- function createRecipe(name, context) {
1522
- const info = getRecipeInfo(name);
1523
- if (!info) return null;
1524
- return new info.class(context);
1603
+ return resolve(process.cwd());
1525
1604
  }
1526
-
1527
- // src/index.ts
1528
- import { cancel as cancel2, multiselect, text as text2, isCancel as isCancel5 } from "@clack/prompts";
1529
-
1530
- // src/parity/index.ts
1531
- import { existsSync as existsSync7, lstatSync, mkdirSync as mkdirSync5, readFileSync as readFileSync3, readlinkSync, readdirSync, renameSync, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4, chmodSync as chmodSync2, copyFileSync } from "node:fs";
1532
- import { basename as basename2, dirname as dirname5, join as join9, relative, resolve } from "node:path";
1533
- import { fileURLToPath as fileURLToPath3 } from "node:url";
1534
- import { homedir as homedir4 } from "node:os";
1535
- import { spawnSync as spawnSync4 } from "node:child_process";
1536
- var LINK_AGENTFILES_BLOCK = `# This block will handle the linking of
1537
- # agent files to the main AGENTS.md file.
1538
- #
1539
- # TODO: Ensure this works for all levels of nesting.
1540
- # i.e. All linked agent files MUST be siblings at
1541
- # any given level of nesting.
1542
- [hooks]
1543
- enter = [
1544
- "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1545
- "op inject -i .env.op > .env",
1546
- ]
1547
-
1548
- [[watch_files]]
1549
- patterns = ["AGENTS.md"]
1550
- task = "link-agentfiles"
1551
-
1552
- [tasks.link-agentfiles]
1553
- description = "Symlink all agent files to AGENTS.md"
1554
- run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
1555
- var LINK_AGENTFILES_HOOK_ENTRIES = [
1556
- "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1557
- "op inject -i .env.op > .env"
1558
- ];
1559
- var LINK_AGENTFILES_HOOKS_BLOCK = `# This block will handle the linking of
1560
- # agent files to the main AGENTS.md file.
1561
- #
1562
- # TODO: Ensure this works for all levels of nesting.
1563
- # i.e. All linked agent files MUST be siblings at
1564
- # any given level of nesting.
1565
- [hooks]
1566
- enter = [
1567
- "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1568
- "op inject -i .env.op > .env",
1569
- ]`;
1570
- var LINK_AGENTFILES_WATCH_TASK_BLOCK = `[[watch_files]]
1571
- patterns = ["AGENTS.md"]
1572
- task = "link-agentfiles"
1573
-
1574
- [tasks.link-agentfiles]
1575
- description = "Symlink all agent files to AGENTS.md"
1576
- run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
1577
- var VERSIONING_BLOCK = `# >>> mise-versioning >>> (managed block \u2014 do not edit by hand; re-run init to update)
1578
- [tasks."version"]
1579
- description = "Print the current version (vX.Y.Z)"
1580
- run = "{{config_root}}/.mise/scripts/versioning.sh current"
1581
-
1582
- [tasks."version:bump"]
1583
- description = "Bump patch version: vX.Y.Z -> vX.Y.(Z+1)"
1584
- alias = "version:bump-patch"
1585
- run = "{{config_root}}/.mise/scripts/versioning.sh bump patch"
1586
-
1587
- [tasks."version:bump-minor"]
1588
- description = "Bump minor version: vX.Y.Z -> vX.(Y+1).0"
1589
- run = "{{config_root}}/.mise/scripts/versioning.sh bump minor"
1590
-
1591
- [tasks."version:bump-major"]
1592
- description = "Bump major version: vX.Y.Z -> v(X+1).0.0"
1593
- run = "{{config_root}}/.mise/scripts/versioning.sh bump major"
1594
-
1595
- [tasks."version:check"]
1596
- description = "Verify every versioned file is in parity"
1597
- run = "{{config_root}}/.mise/scripts/versioning.sh check"
1598
-
1599
- [tasks."version:sync"]
1600
- description = "Force every versioned file up to the highest version"
1601
- run = "{{config_root}}/.mise/scripts/versioning.sh sync"
1602
- # <<< mise-versioning <<<`;
1603
- function resolvePjanglerRoot() {
1604
- let dir = dirname5(fileURLToPath3(import.meta.url));
1605
- while (dir !== dirname5(dir)) {
1606
- if (existsSync7(join9(dir, "package.json")) && existsSync7(join9(dir, "templates", "commonproject", "copier.yml"))) {
1607
- return dir;
1605
+ function validateNoDuplicateProject(registry, project, overwrite) {
1606
+ const existingSameSlug = registry.projects[project.slug];
1607
+ if (existingSameSlug && !overwrite && resolve(existingSameSlug.repo_path) !== resolve(project.repo_path)) {
1608
+ throw new Error(`Project slug already exists in registry: ${project.slug}`);
1609
+ }
1610
+ for (const [slug, existing] of Object.entries(registry.projects)) {
1611
+ if (slug === project.slug) continue;
1612
+ if (resolve(existing.repo_path) === resolve(project.repo_path)) {
1613
+ throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
1614
+ }
1615
+ if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
1616
+ throw new Error(`Project identifier already registered by ${slug}: ${project.ticket_provider.identifier}`);
1608
1617
  }
1609
- dir = dirname5(dir);
1610
1618
  }
1611
- throw new Error("Unable to resolve pjangler root");
1612
- }
1613
- function normalizeNewlines(value) {
1614
- return value.replace(/\r\n/g, "\n");
1615
- }
1616
- function readText(path) {
1617
- return normalizeNewlines(readFileSync3(path, "utf8"));
1618
1619
  }
1619
- function safeReadText(path) {
1620
- return existsSync7(path) ? readText(path) : null;
1620
+ function validateProjectRecord(project, key) {
1621
+ if (!isRecord(project)) throw new Error(`Project ${key} must be a mapping`);
1622
+ if (!project.name) throw new Error(`Project ${key} missing name`);
1623
+ if (!project.slug) throw new Error(`Project ${key} missing slug`);
1624
+ if (project.slug !== key) throw new Error(`Project key ${key} does not match slug ${project.slug}`);
1625
+ if (!project.repo_path) throw new Error(`Project ${key} missing repo_path`);
1626
+ if (!Array.isArray(project.source_artifacts)) throw new Error(`Project ${key} source_artifacts must be a list`);
1627
+ if (!isRecord(project.ticket_provider)) throw new Error(`Project ${key} ticket_provider must be a mapping`);
1628
+ if (!isRecord(project.agents)) throw new Error(`Project ${key} agents must be a mapping`);
1621
1629
  }
1622
- function ensureParent(path) {
1623
- mkdirSync5(dirname5(path), { recursive: true });
1630
+ function expandHome(path) {
1631
+ if (path === "~") return homedir3();
1632
+ if (path.startsWith("~/")) return join8(homedir3(), path.slice(2));
1633
+ return path;
1624
1634
  }
1625
- function writeText(path, content) {
1626
- ensureParent(path);
1627
- writeFileSync4(path, content);
1635
+ function isRecord(value) {
1636
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1628
1637
  }
1629
- function tryParseJson(text3) {
1630
- if (!text3) return null;
1631
- try {
1632
- return JSON.parse(text3);
1633
- } catch {
1634
- return null;
1638
+
1639
+ // src/commands/AgentHooksCommands.ts
1640
+ 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.";
1641
+ function resolveTemplateRoot() {
1642
+ const candidates = [];
1643
+ if (process.env.PJANGLER_COMMONPROJECT_TEMPLATE) {
1644
+ candidates.push(process.env.PJANGLER_COMMONPROJECT_TEMPLATE);
1635
1645
  }
1636
- }
1637
- function slugifyRepoName(name) {
1638
- return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
1639
- }
1640
- function titleCaseSlug(slug) {
1641
- return slug.split(/[-_]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
1642
- }
1643
- function readSymlinkTarget(path) {
1644
- if (!existsSync7(path)) return null;
1645
1646
  try {
1646
- return readlinkSync(path);
1647
+ let dir = dirname5(fileURLToPath2(import.meta.url));
1648
+ for (let i = 0; i < 8; i++) {
1649
+ candidates.push(join9(dir, "templates", "commonproject", "template"));
1650
+ const parent = dirname5(dir);
1651
+ if (parent === dir) break;
1652
+ dir = parent;
1653
+ }
1647
1654
  } catch {
1648
- return null;
1649
1655
  }
1656
+ candidates.push(join9(homedir4(), "code", "pjangler", "templates", "commonproject", "template"));
1657
+ for (const c of candidates) {
1658
+ if (existsSync7(join9(c, ".agents", "hooks", "hooks.master.json"))) return c;
1659
+ }
1660
+ throw new Error(
1661
+ "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
1662
+ );
1650
1663
  }
1651
- function ensureSymlink(path, target, dryRun) {
1652
- if (existsSync7(path)) {
1653
- const stat = lstatSync(path);
1654
- if (stat.isSymbolicLink()) {
1655
- const current = readSymlinkTarget(path);
1656
- if (current === target) return { changed: false };
1657
- if (!dryRun) {
1658
- unlinkSync3(path);
1659
- symlinkSync(target, path);
1660
- }
1661
- return { changed: true };
1664
+ var CopyAgentHooksTree = class extends Command {
1665
+ async invoke() {
1666
+ if (!resolveAgentHooksLayer()) {
1667
+ return { success: true, message: this.formatMessage(AGENT_HOOKS_SKIP_MESSAGE) };
1662
1668
  }
1663
- return { changed: false, blocked: `${relative(process.cwd(), path) || path} exists and is not a symlink` };
1664
- }
1665
- if (!dryRun) symlinkSync(target, path);
1666
- return { changed: true };
1667
- }
1668
- function bootstrapAgentsFile(repoRoot, dryRun) {
1669
- const agentsPath = join9(repoRoot, "AGENTS.md");
1670
- if (existsSync7(agentsPath)) return { changedFiles: [], details: [] };
1671
- for (const file of ["CLAUDE.md", "GEMINI.md"]) {
1672
- const source = join9(repoRoot, file);
1673
- if (!existsSync7(source)) continue;
1674
- const stat = lstatSync(source);
1675
- if (stat.isSymbolicLink()) continue;
1676
- if (stat.isFile()) {
1677
- if (!dryRun) renameSync(source, agentsPath);
1678
- return { changedFiles: [agentsPath], details: [`Moved ${file} to AGENTS.md before wiring agent-file symlinks`] };
1669
+ let templateRoot;
1670
+ try {
1671
+ templateRoot = resolveTemplateRoot();
1672
+ } catch (e) {
1673
+ return { success: false, message: `\u26A0\uFE0F ${e.message}` };
1679
1674
  }
1680
- return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
1681
- }
1682
- const readmePath = join9(repoRoot, "README.md");
1683
- if (existsSync7(readmePath)) {
1684
- const stat = lstatSync(readmePath);
1685
- if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
1686
- if (!dryRun) copyFileSync(readmePath, agentsPath);
1687
- return { changedFiles: [agentsPath], details: ["Copied README.md to AGENTS.md before wiring agent-file symlinks"] };
1688
- }
1689
- return { changedFiles: [], details: [], blocked: "AGENTS.md missing and no CLAUDE.md, GEMINI.md, or README.md source exists" };
1690
- }
1691
- function yamlGet(text3, keyPath) {
1692
- const parts = keyPath.split(".");
1693
- const lines = text3.split("\n");
1694
- let start = 0;
1695
- let indent = 0;
1696
- for (let idx = 0; idx < parts.length; idx += 1) {
1697
- const key = parts[idx];
1698
- let found = false;
1699
- for (let i = start; i < lines.length; i += 1) {
1700
- const line = lines[i];
1701
- if (!line.trim() || line.trim().startsWith("#")) continue;
1702
- const match = line.match(/^(\s*)([^:#]+):\s*(.*)$/);
1703
- if (!match) continue;
1704
- const currentIndent = match[1].length;
1705
- const currentKey = match[2].trim();
1706
- const rest = match[3].trim();
1707
- if (idx > 0 && currentIndent < indent) break;
1708
- if (currentIndent !== indent || currentKey !== key) continue;
1709
- found = true;
1710
- if (idx === parts.length - 1) {
1711
- return rest.replace(/^['"]|['"]$/g, "").trim();
1675
+ const items = [
1676
+ { rel: ".agents/hooks", dir: true },
1677
+ { rel: ".agents/local.example.json", dir: false },
1678
+ { rel: ".mise/scripts/link-project-skills-to-clis.sh", dir: false },
1679
+ { rel: ".mise/scripts/unlink-project-skills-from-clis.sh", dir: false },
1680
+ { rel: ".mise/scripts/hindsight-setup.sh", dir: false }
1681
+ ];
1682
+ const created = [];
1683
+ const skipped = [];
1684
+ for (const { rel, dir } of items) {
1685
+ const src = join9(templateRoot, rel);
1686
+ const dest = join9(this.context.targetDir, rel);
1687
+ if (!existsSync7(src)) continue;
1688
+ if (existsSync7(dest) && !this.context.force) {
1689
+ skipped.push(rel);
1690
+ continue;
1712
1691
  }
1713
- start = i + 1;
1714
- indent = currentIndent + 2;
1715
- break;
1692
+ if (!this.context.dryRun) {
1693
+ mkdirSync5(dirname5(dest), { recursive: true });
1694
+ cpSync(src, dest, { recursive: dir, force: true });
1695
+ }
1696
+ created.push(rel);
1716
1697
  }
1717
- if (!found) return "";
1718
- }
1719
- return "";
1720
- }
1721
- function discoverRoles(repoRoot) {
1722
- const rolesDir = join9(repoRoot, "agents", "hermes");
1723
- if (!existsSync7(rolesDir)) return [];
1724
- return readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
1725
- const roleDir = join9(rolesDir, entry.name);
1726
- const roleYamlPath = join9(roleDir, "role.yaml");
1727
- if (!existsSync7(roleYamlPath)) return null;
1728
- const text3 = readText(roleYamlPath);
1729
- const runtimeRepoRaw = yamlGet(text3, "runtime.github_repo");
1698
+ const verb = this.context.dryRun ? "Would copy" : "Copied";
1699
+ const tail = skipped.length ? ` (${skipped.length} already present \u2014 use --force to overwrite)` : "";
1730
1700
  return {
1731
- role: yamlGet(text3, "role") || entry.name,
1732
- roleDir,
1733
- roleYamlPath,
1734
- repo: yamlGet(text3, "repo"),
1735
- agentId: yamlGet(text3, "agent_id"),
1736
- profileName: yamlGet(text3, "profile") || yamlGet(text3, "agent_id"),
1737
- displayName: yamlGet(text3, "display_name"),
1738
- purpose: yamlGet(text3, "purpose"),
1739
- botHandle: yamlGet(text3, "telegram.bot_username"),
1740
- runtimeRepo: runtimeRepoRaw.includes("/") ? runtimeRepoRaw.split("/").slice(-1)[0] ?? runtimeRepoRaw : runtimeRepoRaw,
1741
- runtimeOwner: yamlGet(text3, "runtime.github_owner"),
1742
- planeWorkspace: yamlGet(text3, "ticket_provider.workspace") || yamlGet(text3, "plane.workspace"),
1743
- ticketProviderName: yamlGet(text3, "ticket_provider.name"),
1744
- ticketProviderBoardId: yamlGet(text3, "ticket_provider.board_id"),
1745
- ticketProviderBoardUrl: yamlGet(text3, "ticket_provider.board_url"),
1746
- ticketProviderIdentifier: yamlGet(text3, "plane.identifier")
1701
+ success: created.length > 0,
1702
+ message: this.formatMessage(`\u2705 ${verb} ${created.length} agent-hooks path(s)${tail}`)
1747
1703
  };
1748
- }).filter((value) => Boolean(value));
1749
- }
1750
- function registryPath(homeDir) {
1751
- return join9(homeDir, ".hermes", "agents-registry.yaml");
1752
- }
1753
- function systemctlUser(args) {
1754
- const result = spawnSync4("systemctl", ["--user", ...args], { encoding: "utf8" });
1755
- return {
1756
- ok: result.status === 0,
1757
- stdout: result.stdout.trim(),
1758
- stderr: result.stderr.trim()
1759
- };
1760
- }
1761
- function templateScript(ctx, name) {
1762
- const source = join9(ctx.pjanglerRoot, ".mise", "scripts", name);
1763
- return existsSync7(source) ? readText(source) : void 0;
1764
- }
1765
- function templateVersioningScript(ctx) {
1766
- return templateScript(ctx, "versioning.sh");
1767
- }
1768
- function templateLinkAgentfilesScript(ctx) {
1769
- return templateScript(ctx, "link-agentfiles.sh");
1770
- }
1771
- function renderGeneratedProjectMiseToml(ctx, template) {
1772
- const project = readProjectJson(ctx);
1773
- const projectName = String(project?.project_name ?? basename2(ctx.repoRoot) ?? "project");
1774
- return template.replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
1775
- }
1776
- function ensureMiseTomlFromTemplate(ctx, changedFiles) {
1777
- const targetPath = join9(ctx.repoRoot, "mise.toml");
1778
- if (existsSync7(targetPath)) return false;
1779
- const sourcePath = join9(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
1780
- if (!existsSync7(sourcePath)) return false;
1781
- changedFiles.push(targetPath);
1782
- if (!ctx.dryRun) {
1783
- writeText(targetPath, renderGeneratedProjectMiseToml(ctx, readText(sourcePath)));
1784
- }
1785
- return true;
1786
- }
1787
- function templateVersionFilesConf(ctx, repoRoot) {
1788
- const packageJson = join9(repoRoot, "package.json");
1789
- return existsSync7(packageJson) ? "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\njson package.json\ngittag .\n" : "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\ngittag .\n";
1790
- }
1791
- function replaceOrAppendManagedBlock(text3, startMarker, block, beforePattern) {
1792
- if (startMarker.test(text3)) {
1793
- return text3.replace(/# >>> mise-versioning >>>[\s\S]*?# <<< mise-versioning <<</, block);
1794
1704
  }
1795
- if (beforePattern) {
1796
- const match = text3.match(beforePattern);
1797
- if (match && typeof match.index === "number") {
1798
- return `${text3.slice(0, match.index).replace(/\s*$/, "\n\n")}${block}
1799
-
1800
- ${text3.slice(match.index)}`;
1705
+ };
1706
+ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
1707
+ static MARKER = "# pjangler:agent-hooks";
1708
+ static CR = "{{config_root}}";
1709
+ // mise's own runtime var — emitted literally
1710
+ async invoke() {
1711
+ if (!resolveAgentHooksLayer()) {
1712
+ return { success: true, message: this.formatMessage(AGENT_HOOKS_SKIP_MESSAGE) };
1801
1713
  }
1802
- }
1803
- return `${text3.replace(/\s*$/, "")}
1804
-
1805
- ${block}
1806
- `;
1807
- }
1808
- var BASE_MISE_PATH_ENTRIES = [".mise/scripts", "agents/hermes/pm"];
1809
- var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/hermes"];
1810
- function requiredMisePathEntries(ctx) {
1811
- const required = [...BASE_MISE_PATH_ENTRIES];
1812
- for (const candidate of CONDITIONAL_HERMES_PATHS) {
1813
- if (existsSync7(join9(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
1814
- }
1815
- return required;
1816
- }
1817
- function upsertMisePath(text3, required = BASE_MISE_PATH_ENTRIES) {
1818
- const render = (values) => `_.path = [${values.map((value) => JSON.stringify(value)).join(", ")}]`;
1819
- const envMatch = text3.match(/(^|\n)(\[env\][\s\S]*?)(?=\n\[[^\]]+\]|$)/);
1820
- if (!envMatch || typeof envMatch.index !== "number") {
1821
- return `[env]
1822
- ${render(required)}
1823
-
1824
- ${text3.replace(/^\s+/, "")}`;
1825
- }
1826
- const prefix = text3.slice(0, envMatch.index + envMatch[1].length);
1827
- const section = envMatch[2];
1828
- const suffix = text3.slice(envMatch.index + envMatch[1].length + section.length);
1829
- const pathLine = section.match(/^_\.path\s*=\s*\[([^\]]*)\]\s*$/m);
1830
- if (!pathLine) {
1831
- return `${prefix}${section.replace(/\n?$/, "\n")}${render(required)}${suffix}`;
1832
- }
1833
- const current = [...pathLine[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
1834
- const merged = [...current];
1835
- for (const value of required) {
1836
- if (!merged.includes(value)) merged.push(value);
1837
- }
1838
- const nextLine = render(merged);
1839
- if (pathLine[0] === nextLine) return text3;
1840
- return `${prefix}${section.replace(pathLine[0], nextLine)}${suffix}`;
1841
- }
1842
- function removeTomlSection(text3, headerPattern, marker, options) {
1843
- const lines = text3.split("\n");
1844
- let start = -1;
1845
- let end = -1;
1846
- for (let i = 0; i < lines.length; i++) {
1847
- if (!headerPattern.test(lines[i])) continue;
1848
- if (marker) {
1849
- let hasMarker = false;
1850
- for (let j = i + 1; j < lines.length && !/^\[[^\]]+\]/.test(lines[j]); j++) {
1851
- if (marker.test(lines[j])) {
1852
- hasMarker = true;
1853
- break;
1854
- }
1855
- }
1856
- if (!hasMarker) continue;
1714
+ const misePath = join9(this.context.targetDir, "mise.toml");
1715
+ if (!existsSync7(misePath)) {
1716
+ return {
1717
+ success: false,
1718
+ message: "\u26A0\uFE0F No mise.toml found \u2014 run `pjangler init mise` first, then re-run."
1719
+ };
1857
1720
  }
1858
- start = i;
1859
- for (let j = i + 1; j < lines.length; j++) {
1860
- if (/^\[[^\]]+\]/.test(lines[j])) {
1861
- end = j;
1862
- break;
1863
- }
1721
+ let content = readFileSync3(misePath, "utf8");
1722
+ if (content.includes(_WireMiseAgentHooks.MARKER)) {
1723
+ return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for agent-hooks") };
1864
1724
  }
1865
- if (end === -1) end = lines.length;
1866
- break;
1867
- }
1868
- if (start === -1) return text3;
1869
- if (options?.includePrecedingComments) {
1870
- while (start > 0 && lines[start - 1].trim().startsWith("#")) {
1871
- start--;
1725
+ const cr = _WireMiseAgentHooks.CR;
1726
+ const enterAdds = [
1727
+ ` "${cr}/.mise/scripts/link-project-skills-to-clis.sh",`,
1728
+ ` "${cr}/.agents/hooks/sync.py --install --quiet",`
1729
+ ].join("\n");
1730
+ const leaveBlock = [
1731
+ "leave = [",
1732
+ ` "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",`,
1733
+ ` "${cr}/.agents/hooks/sync.py --uninstall --quiet",`,
1734
+ "]"
1735
+ ].join("\n");
1736
+ let wiredHooks = false;
1737
+ const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
1738
+ if (enterRe.test(content)) {
1739
+ content = content.replace(enterRe, (_m, head, close) => {
1740
+ const sep = /[,[]\s*$/.test(head) ? "" : ",";
1741
+ return `${head}${sep}
1742
+ ${enterAdds}${close}`;
1743
+ });
1744
+ const leaveRe = /(leave\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
1745
+ if (leaveRe.test(content)) {
1746
+ content = content.replace(leaveRe, (_m, head, close) => {
1747
+ const sep = /[,[]\s*$/.test(head) ? "" : ",";
1748
+ return `${head}${sep}
1749
+ "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",
1750
+ "${cr}/.agents/hooks/sync.py --uninstall --quiet",${close}`;
1751
+ });
1752
+ } else {
1753
+ content = content.replace(enterRe, (m) => `${m}
1754
+ ${leaveBlock}`);
1755
+ }
1756
+ wiredHooks = true;
1872
1757
  }
1873
- }
1874
- const result = lines.slice(0, start).concat(lines.slice(end)).join("\n");
1875
- return result.replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
1876
- }
1877
- function insertTomlBlockBeforeVersioning(text3, block) {
1878
- const versioningIndex = text3.indexOf("# >>> mise-versioning >>>");
1879
- if (versioningIndex >= 0) {
1880
- return `${text3.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${block}
1881
-
1882
- ${text3.slice(versioningIndex)}`;
1883
- }
1884
- return `${text3.replace(/\s*$/, "")}
1885
-
1886
- ${block}
1887
- `;
1888
- }
1889
- function extractTomlStrings(text3) {
1890
- const values = [];
1891
- const stringPattern = /"((?:\\.|[^"\\])*)"|'([^']*)'/g;
1892
- for (const match of text3.matchAll(stringPattern)) {
1893
- if (match[1] !== void 0) {
1894
- try {
1895
- values.push(JSON.parse(`"${match[1]}"`));
1896
- } catch {
1897
- values.push(match[1]);
1898
- }
1899
- } else if (match[2] !== void 0) {
1900
- values.push(match[2]);
1758
+ const appended = [
1759
+ "",
1760
+ _WireMiseAgentHooks.MARKER + " (generated \u2014 see .agents/hooks/README.md)",
1761
+ "[[watch_files]]",
1762
+ 'patterns = [".agents/hooks/hooks.master.json"]',
1763
+ 'task = "hooks-sync"',
1764
+ "",
1765
+ "[tasks.hooks-sync]",
1766
+ 'description = "Fan out hooks.master.json to each agent CLI (claude/codex/kimi/hermes)"',
1767
+ `run = "${cr}/.agents/hooks/sync.py --install"`,
1768
+ "",
1769
+ "[tasks.hooks-check]",
1770
+ 'description = "Drift gate: verify generated hook configs match hooks.master.json"',
1771
+ `run = "${cr}/.agents/hooks/sync.py --check"`,
1772
+ "",
1773
+ "[tasks.hooks-uninstall]",
1774
+ 'description = "Remove per-user agent-hook injections (codex/kimi/hermes)"',
1775
+ `run = "${cr}/.agents/hooks/sync.py --uninstall"`,
1776
+ "",
1777
+ "[tasks.link-project-skills-to-clis]",
1778
+ 'description = "Fan .agents/skills out to each agent CLI (honors local.json)"',
1779
+ `run = "${cr}/.mise/scripts/link-project-skills-to-clis.sh"`,
1780
+ "",
1781
+ "[tasks.unlink-project-skills-from-clis]",
1782
+ 'description = "Remove project skill symlinks from shared per-CLI dirs"',
1783
+ `run = "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh"`,
1784
+ "",
1785
+ "[tasks.skills-relink]",
1786
+ 'description = "Re-fan the project skill set to all CLIs"',
1787
+ `run = "${cr}/.mise/scripts/link-project-skills-to-clis.sh"`,
1788
+ "",
1789
+ "[tasks.hindsight-setup]",
1790
+ `description = "Provision this dev's shared project Hindsight key from 1Password into .env"`,
1791
+ `run = "${cr}/.mise/scripts/hindsight-setup.sh"`,
1792
+ "",
1793
+ _WireMiseAgentHooks.MARKER + ":end",
1794
+ ""
1795
+ ].join("\n");
1796
+ content = content.replace(/\n*$/, "\n") + appended;
1797
+ if (!this.context.dryRun) writeFileSync4(misePath, content);
1798
+ if (wiredHooks) {
1799
+ return { success: true, message: this.formatMessage("\u2705 Wired mise.toml ([hooks] enter/leave + tasks)") };
1901
1800
  }
1801
+ return {
1802
+ success: true,
1803
+ message: this.formatMessage(
1804
+ `\u2705 Added agent-hooks tasks to mise.toml.
1805
+ \u26A0\uFE0F Could not find a [hooks].enter array to extend \u2014 add these to your [hooks] block manually:
1806
+ enter += "${cr}/.mise/scripts/link-project-skills-to-clis.sh", "${cr}/.agents/hooks/sync.py --install --quiet"
1807
+ leave += "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh", "${cr}/.agents/hooks/sync.py --uninstall --quiet"`
1808
+ )
1809
+ };
1902
1810
  }
1903
- return values;
1904
- }
1905
- function isManagedHookEntry(value) {
1906
- const trimmed = value.trim();
1907
- return trimmed === "op inject -i .env.op > .env" || /(^|\/)link-agentfiles\.sh$/.test(trimmed);
1908
- }
1909
- function renderHookEntries(entries, indent = "") {
1910
- return [
1911
- `${indent}enter = [`,
1912
- ...entries.map((entry) => `${indent} ${JSON.stringify(entry)},`),
1913
- `${indent}]`
1914
- ];
1915
- }
1916
- function upsertLinkAgentfilesHooks(text3) {
1917
- const lines = text3.split("\n");
1918
- const hooksStart = lines.findIndex((line) => /^\[hooks\]$/.test(line.trim()));
1919
- if (hooksStart === -1) return insertTomlBlockBeforeVersioning(text3, LINK_AGENTFILES_HOOKS_BLOCK);
1920
- let hooksEnd = lines.length;
1921
- for (let i = hooksStart + 1; i < lines.length; i++) {
1922
- if (/^\[[^\]]+\]/.test(lines[i].trim())) {
1923
- hooksEnd = i;
1924
- break;
1925
- }
1811
+ };
1812
+
1813
+ // src/recipes/AgentHooksRecipe.ts
1814
+ var AgentHooksRecipe = class extends Recipe {
1815
+ constructor(context) {
1816
+ super(context);
1817
+ this.addIngredient(CopyAgentHooksTree).addIngredient(WireMiseAgentHooks);
1926
1818
  }
1927
- let enterStart = -1;
1928
- let enterEnd = -1;
1929
- for (let i = hooksStart + 1; i < hooksEnd; i++) {
1930
- if (!/^\s*enter\s*=/.test(lines[i])) continue;
1931
- enterStart = i;
1932
- enterEnd = i + 1;
1933
- const afterEquals = lines[i].slice(lines[i].indexOf("=") + 1);
1934
- if (afterEquals.includes("[") && !afterEquals.includes("]")) {
1935
- while (enterEnd < hooksEnd && !lines[enterEnd].includes("]")) enterEnd++;
1936
- if (enterEnd < hooksEnd) enterEnd++;
1937
- }
1938
- break;
1819
+ printNextSteps() {
1820
+ console.log("\u{1FA9D} Agent-hooks layer installed!");
1821
+ console.log(" Next steps:");
1822
+ console.log(" 1. mise run hooks-sync # generate .claude/settings.json + inject codex/kimi/hermes");
1823
+ console.log(" 2. git add .claude/settings.json .agents/hooks && commit (codex/kimi/hermes are per-dev)");
1824
+ console.log(" 3. mise run hindsight-setup # set HINDSIGHT_OP_KEY_REF to your 1Password item first");
1825
+ console.log(` 4. If you run a global agent system: echo '{"skills":{"defer_to_global":true}}' > .agents/local.json`);
1939
1826
  }
1940
- const existingBlock = enterStart >= 0 ? lines.slice(enterStart, enterEnd).join("\n") : "";
1941
- const preserved = extractTomlStrings(existingBlock).filter((entry) => !isManagedHookEntry(entry));
1942
- const merged = [...LINK_AGENTFILES_HOOK_ENTRIES];
1943
- for (const entry of preserved) {
1944
- if (!merged.includes(entry)) merged.push(entry);
1827
+ };
1828
+
1829
+ // src/utils/registry.ts
1830
+ var RECIPE_REGISTRY = {
1831
+ mise: {
1832
+ name: "mise",
1833
+ description: "Mise task runner and environment setup",
1834
+ class: MiseRecipe,
1835
+ commands: ["AddMiseToml", "AddDotenv", "AddMiseTasksStructure", "AddMiseBaseToml", "AddMiseBaseScript", "AddMiseCodegraphScript"]
1836
+ },
1837
+ docker: {
1838
+ name: "docker",
1839
+ description: "Docker containerization setup",
1840
+ class: DockerRecipe,
1841
+ commands: ["AddDockerfile", "AddDockerCompose", "AddDockerignore"]
1842
+ },
1843
+ node: {
1844
+ name: "node",
1845
+ description: "Node.js project template",
1846
+ class: NodeRecipe,
1847
+ commands: ["NodeCommands"]
1848
+ // Placeholder - actual commands in NodeCommands.ts
1849
+ },
1850
+ "hermes-agent": {
1851
+ name: "hermes-agent",
1852
+ description: "Add a Hermes agent role to this repo (copier + BotFather + CF email + submodule)",
1853
+ class: HermesAgentRecipe,
1854
+ commands: [
1855
+ "EnsureTemplateConfig",
1856
+ "PromptForAgentConfig",
1857
+ "RunCopierTemplate",
1858
+ "WireTelegram",
1859
+ "WireEmail",
1860
+ "PrintHermesSummary"
1861
+ ]
1862
+ },
1863
+ "agent-hooks": {
1864
+ name: "agent-hooks",
1865
+ description: "Retrofit the project-scoped agent-hooks + skill fan-out layer (Claude/Codex/Kimi/Hermes hooks via mise enter/leave)",
1866
+ class: AgentHooksRecipe,
1867
+ commands: ["CopyAgentHooksTree", "WireMiseAgentHooks"]
1945
1868
  }
1946
- const indent = enterStart >= 0 ? lines[enterStart].match(/^\s*/)?.[0] ?? "" : "";
1947
- const rendered = renderHookEntries(merged, indent);
1948
- if (enterStart >= 0) {
1949
- return lines.slice(0, enterStart).concat(rendered, lines.slice(enterEnd)).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
1869
+ };
1870
+ var COMMAND_REGISTRY = {
1871
+ CopyAgentHooksTree: {
1872
+ name: "CopyAgentHooksTree",
1873
+ description: "Copy the generic agent-hooks tree (hooks SSOT + sync engine + scripts) from the CommonProject template",
1874
+ group: "agent-hooks",
1875
+ class: CopyAgentHooksTree
1876
+ },
1877
+ WireMiseAgentHooks: {
1878
+ name: "WireMiseAgentHooks",
1879
+ description: "Merge agent-hooks enter/leave + tasks into an existing mise.toml (idempotent)",
1880
+ group: "agent-hooks",
1881
+ class: WireMiseAgentHooks
1882
+ },
1883
+ AddDockerfile: {
1884
+ name: "AddDockerfile",
1885
+ description: "Create Dockerfile for containerization",
1886
+ group: "docker",
1887
+ class: AddDockerfile
1888
+ },
1889
+ AddDockerCompose: {
1890
+ name: "AddDockerCompose",
1891
+ description: "Create docker-compose.yml for multi-service setup",
1892
+ group: "docker",
1893
+ class: AddDockerCompose
1894
+ },
1895
+ AddDockerignore: {
1896
+ name: "AddDockerignore",
1897
+ description: "Create .dockerignore file",
1898
+ group: "docker",
1899
+ class: AddDockerignore
1900
+ },
1901
+ AddMiseToml: {
1902
+ name: "AddMiseToml",
1903
+ description: "Create mise.toml for version management",
1904
+ group: "mise",
1905
+ class: AddMiseToml
1906
+ },
1907
+ AddMiseBaseToml: {
1908
+ name: "AddMiseBaseToml",
1909
+ description: "Create base mise configuration",
1910
+ group: "mise",
1911
+ class: AddMiseBaseToml
1912
+ },
1913
+ AddMiseTasksStructure: {
1914
+ name: "AddMiseTasksStructure",
1915
+ description: "Create .mise/tasks directory structure",
1916
+ group: "mise",
1917
+ class: AddMiseTasksStructure
1918
+ },
1919
+ AddMiseBaseScript: {
1920
+ name: "AddMiseBaseScript",
1921
+ description: "Create base mise task scripts",
1922
+ group: "mise",
1923
+ class: AddMiseBaseScript
1924
+ },
1925
+ AddMiseCodegraphScript: {
1926
+ name: "AddMiseCodegraphScript",
1927
+ description: "Create .mise/scripts/codegraph.sh enter hook",
1928
+ group: "mise",
1929
+ class: AddMiseCodegraphScript
1930
+ },
1931
+ AddDotenv: {
1932
+ name: "AddDotenv",
1933
+ description: "Create .env.example file",
1934
+ group: "environment",
1935
+ class: AddDotenv
1950
1936
  }
1951
- return lines.slice(0, hooksStart + 1).concat(rendered, lines.slice(hooksStart + 1)).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
1937
+ };
1938
+ function getRecipeNames() {
1939
+ return Object.keys(RECIPE_REGISTRY);
1952
1940
  }
1953
- function upsertLinkAgentfilesBlock(text3, ctx) {
1954
- const withPath = upsertMisePath(text3, requiredMisePathEntries(ctx));
1955
- if (withPath.includes(LINK_AGENTFILES_BLOCK)) return withPath;
1956
- let cleaned = removeTomlSection(withPath, /^\[tasks\.link-agentfiles\]$/, /link-agentfiles/, { includePrecedingComments: false });
1957
- cleaned = removeTomlSection(cleaned, /^\[\[watch_files\]\]$/, /AGENTS\.md/, { includePrecedingComments: false });
1958
- cleaned = upsertLinkAgentfilesHooks(cleaned);
1959
- return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
1941
+ function getRecipeInfo(name) {
1942
+ return RECIPE_REGISTRY[name] || null;
1960
1943
  }
1961
- function readProjectJson(ctx) {
1962
- return tryParseJson(safeReadText(join9(ctx.repoRoot, ".project.json")));
1944
+ function getCommandNames() {
1945
+ return Object.keys(COMMAND_REGISTRY);
1963
1946
  }
1964
- function canonicalProjectJson(ctx) {
1965
- const roles = discoverRoles(ctx.repoRoot);
1966
- const existing = readProjectJson(ctx) ?? {};
1967
- const slug = String(existing.project_slug ?? slugifyRepoName(dirname5(ctx.repoRoot) === ctx.repoRoot ? ctx.repoRoot.split("/").pop() ?? "project" : ctx.repoRoot.split("/").pop() ?? "project"));
1968
- const firstRole = roles[0];
1969
- const ticketProvider = {
1970
- type: String((existing.ticket_provider?.type ?? firstRole?.ticketProviderName ?? "plane") || "plane"),
1971
- workspace: String((existing.ticket_provider?.workspace ?? firstRole?.planeWorkspace ?? "") || ""),
1972
- identifier: String((existing.ticket_provider?.identifier ?? firstRole?.ticketProviderIdentifier ?? "") || ""),
1973
- board_id: String((existing.ticket_provider?.board_id ?? firstRole?.ticketProviderBoardId ?? "") || ""),
1974
- board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || ""),
1975
- state: String((existing.ticket_provider?.state ?? "planned") || "planned")
1976
- };
1977
- const existingAgents = existing.agents ?? {};
1978
- const discoveredAgents = Object.fromEntries(
1979
- roles.map((role) => [
1980
- role.agentId || `${slug}-${role.role}`,
1981
- {
1982
- role: role.role,
1983
- role_dir: relative(ctx.repoRoot, role.roleDir)
1984
- }
1985
- ])
1986
- );
1987
- const agents = { ...existingAgents };
1988
- for (const [agentId, discovered] of Object.entries(discoveredAgents)) {
1989
- const existingAgent = existingAgents[agentId] ?? {};
1990
- agents[agentId] = {
1991
- role: discovered.role,
1992
- role_dir: discovered.role_dir,
1993
- provisioning_state: existingAgent.provisioning_state
1994
- };
1995
- }
1996
- return {
1997
- project_name: String(existing.project_name ?? titleCaseSlug(slug)),
1998
- project_description: String(existing.project_description ?? ""),
1999
- project_slug: slug,
2000
- repo_path: ctx.repoRoot,
2001
- ticket_provider: ticketProvider,
2002
- agents
2003
- };
1947
+ function getCommandInfo(name) {
1948
+ return COMMAND_REGISTRY[name] || null;
2004
1949
  }
2005
- function projectJsonFinding(ctx) {
2006
- const projectPath = join9(ctx.repoRoot, ".project.json");
2007
- const planeJsonPath = join9(ctx.repoRoot, ".plane.json");
2008
- const details = [];
2009
- const data = readProjectJson(ctx);
2010
- const roles = discoverRoles(ctx.repoRoot);
2011
- if (!existsSync7(projectPath)) {
2012
- return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json missing", details: [], fixable: true };
2013
- }
2014
- if (!data) {
2015
- return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json is not valid JSON", details: [], fixable: true };
2016
- }
2017
- for (const key of ["project_name", "project_description", "project_slug", "repo_path", "ticket_provider", "agents"]) {
2018
- if (!(key in data)) details.push(`missing key: ${key}`);
2019
- }
2020
- if (data.repo_path !== ctx.repoRoot) details.push(`repo_path should be ${ctx.repoRoot}`);
2021
- const agents = data.agents ?? {};
2022
- for (const role of roles) {
2023
- const agent = agents[role.agentId];
2024
- if (!agent) {
2025
- details.push(`agents.${role.agentId} missing`);
2026
- continue;
2027
- }
2028
- if (agent.role !== role.role) details.push(`agents.${role.agentId}.role should be ${role.role}`);
2029
- if (agent.role_dir !== relative(ctx.repoRoot, role.roleDir)) {
2030
- details.push(`agents.${role.agentId}.role_dir should be ${relative(ctx.repoRoot, role.roleDir)}`);
1950
+ function getCommandsByGroup() {
1951
+ const grouped = {};
1952
+ for (const cmdInfo of Object.values(COMMAND_REGISTRY)) {
1953
+ if (!grouped[cmdInfo.group]) {
1954
+ grouped[cmdInfo.group] = [];
2031
1955
  }
1956
+ grouped[cmdInfo.group].push(cmdInfo);
2032
1957
  }
2033
- const ticketProvider = data.ticket_provider ?? {};
2034
- for (const key of ["type", "workspace", "identifier", "board_id", "board_url", "state"]) {
2035
- if (!(key in ticketProvider)) details.push(`ticket_provider.${key} missing`);
2036
- }
2037
- if (existsSync7(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
2038
- return {
2039
- id: "sot.project-json",
2040
- title: "Canonical .project.json",
2041
- status: details.length === 0 ? "pass" : "fail",
2042
- summary: details.length === 0 ? ".project.json matches canonical parity contract" : `${details.length} parity issue(s) detected`,
2043
- details,
2044
- fixable: true
2045
- };
1958
+ return grouped;
1959
+ }
1960
+ function createRecipe(name, context) {
1961
+ const info = getRecipeInfo(name);
1962
+ if (!info) return null;
1963
+ return new info.class(context);
2046
1964
  }
2047
- function renderSoul(role) {
2048
- const telegram = role.botHandle ? `@${role.botHandle}` : "(unwired)";
2049
- const tone = role.role === "pm" ? `Direct and brief. Decision-forward. No throat-clearing, no apologies, no "I'll help you with that" preambles.` : "Direct and brief.";
2050
- const roleSpecific = role.role === "pm" ? `You are the project manager. You triage incoming work, create or refine tickets, and delegate implementation. You do not ship product code. A systemd heartbeat checkpoints your runtime; when this repo opts into reconciliation (\`reconcile.enabled\` in role.yaml), the same heartbeat also runs your continuous board-reconciliation pass out-of-band (\`.scripts/sentinel.prompt.md\`, \`--source cron\`), kept separate from your interactive session memory.` : `You operate as the ${role.role} agent for this repo.`;
2051
- const runtimeOwner = role.runtimeOwner || "delorenj";
2052
- return `# ${role.displayName || role.agentId}
2053
-
2054
- You are **${role.displayName || role.agentId}** \u2014 a Hermes agent provisioned to work inside the
2055
- \`${role.repo}\` repository.
2056
1965
 
2057
- ## Identity
1966
+ // src/index.ts
1967
+ import { cancel as cancel2, multiselect, text as text2, isCancel as isCancel5 } from "@clack/prompts";
2058
1968
 
2059
- | | |
2060
- | --- | --- |
2061
- | Agent ID | \`${role.agentId}\` |
2062
- | Profile | \`${role.profileName || role.agentId}\` |
2063
- | Repo | \`${role.repo}\` |
2064
- | Role | \`${role.role}\` |
2065
- | Telegram | \`${telegram}\` |
2066
- | Purpose | ${role.purpose || `${role.role} agent for ${role.repo}`} |
1969
+ // src/parity/index.ts
1970
+ import { existsSync as existsSync8, lstatSync, mkdirSync as mkdirSync6, readFileSync as readFileSync4, readlinkSync, readdirSync, renameSync as renameSync2, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync5, chmodSync as chmodSync2, copyFileSync } from "node:fs";
1971
+ import { basename as basename3, dirname as dirname6, join as join10, relative, resolve as resolve2 } from "node:path";
1972
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
1973
+ import { homedir as homedir5 } from "node:os";
1974
+ import { spawnSync as spawnSync5 } from "node:child_process";
1975
+ var LINK_AGENTFILES_BLOCK = `# This block will handle the linking of
1976
+ # agent files to the main AGENTS.md file.
1977
+ #
1978
+ # TODO: Ensure this works for all levels of nesting.
1979
+ # i.e. All linked agent files MUST be siblings at
1980
+ # any given level of nesting.
1981
+ [hooks]
1982
+ enter = [
1983
+ "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1984
+ "op inject -i .env.op > .env",
1985
+ ]
2067
1986
 
2068
- ## Scope
1987
+ [[watch_files]]
1988
+ patterns = ["AGENTS.md"]
1989
+ task = "link-agentfiles"
2069
1990
 
2070
- You operate only within the working directory of \`${role.repo}\`. Your HERMES_HOME is the runtime submodule at \`./runtime/\` (repo \`${runtimeOwner}/${role.runtimeRepo}\`), which \`~/.hermes/profiles/${role.profileName || role.agentId}\` symlinks to (so \`--profile\` invocations resolve here too); Hermes loads its \`config.yaml\` directly. Secrets, SOUL, memories, skills, sessions, gateway state, and runtime files all live local to that runtime.
1991
+ [tasks.link-agentfiles]
1992
+ description = "Symlink all agent files to AGENTS.md"
1993
+ run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
1994
+ var LINK_AGENTFILES_HOOK_ENTRIES = [
1995
+ "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1996
+ "op inject -i .env.op > .env"
1997
+ ];
1998
+ var LINK_AGENTFILES_HOOKS_BLOCK = `# This block will handle the linking of
1999
+ # agent files to the main AGENTS.md file.
2000
+ #
2001
+ # TODO: Ensure this works for all levels of nesting.
2002
+ # i.e. All linked agent files MUST be siblings at
2003
+ # any given level of nesting.
2004
+ [hooks]
2005
+ enter = [
2006
+ "{{config_root}}/.mise/scripts/link-agentfiles.sh",
2007
+ "op inject -i .env.op > .env",
2008
+ ]`;
2009
+ var LINK_AGENTFILES_WATCH_TASK_BLOCK = `[[watch_files]]
2010
+ patterns = ["AGENTS.md"]
2011
+ task = "link-agentfiles"
2071
2012
 
2072
- ## Tone
2013
+ [tasks.link-agentfiles]
2014
+ description = "Symlink all agent files to AGENTS.md"
2015
+ run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
2016
+ var VERSIONING_BLOCK = `# >>> mise-versioning >>> (managed block \u2014 do not edit by hand; re-run init to update)
2017
+ [tasks."version"]
2018
+ description = "Print the current version (vX.Y.Z)"
2019
+ run = "{{config_root}}/.mise/scripts/versioning.sh current"
2073
2020
 
2074
- ${tone}
2021
+ [tasks."version:bump"]
2022
+ description = "Bump patch version: vX.Y.Z -> vX.Y.(Z+1)"
2023
+ alias = "version:bump-patch"
2024
+ run = "{{config_root}}/.mise/scripts/versioning.sh bump patch"
2075
2025
 
2076
- ## Role-specific behavior
2026
+ [tasks."version:bump-minor"]
2027
+ description = "Bump minor version: vX.Y.Z -> vX.(Y+1).0"
2028
+ run = "{{config_root}}/.mise/scripts/versioning.sh bump minor"
2077
2029
 
2078
- ${roleSpecific}
2030
+ [tasks."version:bump-major"]
2031
+ description = "Bump major version: vX.Y.Z -> v(X+1).0.0"
2032
+ run = "{{config_root}}/.mise/scripts/versioning.sh bump major"
2079
2033
 
2080
- ## Memory hygiene
2034
+ [tasks."version:check"]
2035
+ description = "Verify every versioned file is in parity"
2036
+ run = "{{config_root}}/.mise/scripts/versioning.sh check"
2081
2037
 
2082
- Your memory is the submodule at \`./runtime/memories/\`. Use durable memory deliberately and keep \`memories/MEMORY.md\` current.
2083
- `;
2038
+ [tasks."version:sync"]
2039
+ description = "Force every versioned file up to the highest version"
2040
+ run = "{{config_root}}/.mise/scripts/versioning.sh sync"
2041
+ # <<< mise-versioning <<<`;
2042
+ function resolvePjanglerRoot2() {
2043
+ let dir = dirname6(fileURLToPath3(import.meta.url));
2044
+ while (dir !== dirname6(dir)) {
2045
+ if (existsSync8(join10(dir, "package.json")) && existsSync8(join10(dir, "templates", "commonproject", "copier.yml"))) {
2046
+ return dir;
2047
+ }
2048
+ dir = dirname6(dir);
2049
+ }
2050
+ throw new Error("Unable to resolve pjangler root");
2084
2051
  }
2085
- function renderHermesWrapper(role) {
2086
- return `#!/usr/bin/env bash
2087
- # Launcher for ${role.agentId}. Resolves HERMES_HOME to the runtime submodule.
2088
-
2089
- set -euo pipefail
2090
-
2091
- ROLE_DIR="$(cd "$(dirname "$0")" && pwd)"
2092
- RUNTIME_HOME="$ROLE_DIR/runtime"
2093
-
2094
- FLEET_ENV="{HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
2095
- if [[ -f "$FLEET_ENV" ]]; then
2096
- # shellcheck disable=SC1090
2097
- source "$FLEET_ENV"
2098
- fi
2099
-
2100
- HERMES_BIN="{HERMES_BIN:-{HERMES_FLEET_BIN:-/home/delorenj/code/hermes-agent/.venv/bin/hermes}}"
2101
- HERMES_OAUTH_FILE="{HERMES_OAUTH_FILE:-{HERMES_FLEET_OAUTH_FILE:-$HOME/.hermes/auth.json}}"
2102
- CODEX_HOME="{CODEX_HOME:-{HERMES_FLEET_CODEX_HOME:-$HOME/.codex}}"
2103
-
2104
- FLEET_HOME="{HERMES_FLEET_HOME:-$HOME/.hermes}"
2105
- PROFILE_NAME="{HERMES_PROFILE_NAME:-${role.profileName || role.agentId}}"
2106
- HERMES_HOME="$RUNTIME_HOME"
2107
-
2108
- if [[ ! -d "$RUNTIME_HOME" ]]; then
2109
- echo "hermes: runtime submodule not initialized at $RUNTIME_HOME" >&2
2110
- echo " fix: git submodule update --init --recursive" >&2
2111
- exit 1
2112
- fi
2113
-
2114
- exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" HERMES_OAUTH_FILE="$HERMES_OAUTH_FILE" CODEX_HOME="$CODEX_HOME" "$HERMES_BIN" "$@"
2115
- `.replace(/\u0010/g, "$");
2052
+ function normalizeNewlines(value) {
2053
+ return value.replace(/\r\n/g, "\n");
2116
2054
  }
2117
- function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip) {
2118
- if (!existsSync7(sourceDir)) return;
2119
- mkdirSync5(targetDir, { recursive: true });
2120
- for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
2121
- const sourcePath = join9(sourceDir, entry.name);
2122
- if (skip?.(sourcePath)) continue;
2123
- const targetPath = join9(targetDir, entry.name);
2124
- if (entry.isDirectory()) {
2125
- copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
2126
- continue;
2127
- }
2128
- if (existsSync7(targetPath)) continue;
2129
- changedFiles.push(targetPath);
2130
- if (!dryRun) {
2131
- ensureParent(targetPath);
2132
- copyFileSync(sourcePath, targetPath);
2133
- }
2055
+ function readText(path) {
2056
+ return normalizeNewlines(readFileSync4(path, "utf8"));
2057
+ }
2058
+ function safeReadText(path) {
2059
+ return existsSync8(path) ? readText(path) : null;
2060
+ }
2061
+ function ensureParent(path) {
2062
+ mkdirSync6(dirname6(path), { recursive: true });
2063
+ }
2064
+ function writeText(path, content) {
2065
+ ensureParent(path);
2066
+ writeFileSync5(path, content);
2067
+ }
2068
+ function tryParseJson(text3) {
2069
+ if (!text3) return null;
2070
+ try {
2071
+ return JSON.parse(text3);
2072
+ } catch {
2073
+ return null;
2134
2074
  }
2135
2075
  }
2136
- function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
2137
- const gitmodulesPath = join9(repoRoot, ".gitmodules");
2138
- const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
2139
- const owner = role.runtimeOwner || "delorenj";
2140
- const block = `[submodule "agents/hermes/${role.role}/runtime"]
2141
- path = agents/hermes/${role.role}/runtime
2142
- url = git@github.com:${owner}/${repoName}.git
2143
- `;
2144
- const current = safeReadText(gitmodulesPath) ?? "";
2145
- const header = `[submodule "agents/hermes/${role.role}/runtime"]`;
2146
- if (current.includes(header)) return [];
2147
- changedFiles.push(gitmodulesPath);
2148
- if (!dryRun) writeText(gitmodulesPath, `${current.replace(/\s*$/, "")}${current.trim() ? "\n" : ""}${block}`);
2149
- return [gitmodulesPath];
2076
+ function slugifyRepoName(name) {
2077
+ return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
2150
2078
  }
2151
- function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
2152
- const path = registryPath(homeDir);
2153
- const current = safeReadText(path) ?? "# Hermes agent fleet registry.\n# One entry per provisioned agent. Managed by hermes-agent-template/.scripts/80-registry.sh.\nschema_version: 1\nagents: {}\n";
2154
- if (current.includes(`${role.agentId}:`)) return null;
2155
- const block = ` ${role.agentId}:
2156
- repo: ${role.repo}
2157
- role: ${role.role}
2158
- display_name: ${JSON.stringify(role.displayName || role.agentId)}
2159
- project_path: ${ctxEscape(role.roleDir ? dirname5(dirname5(dirname5(role.roleDir))) : "")}
2160
- role_dir: ${ctxEscape(role.roleDir)}
2161
- profile_name: ${role.profileName || role.agentId}
2162
- telegram:
2163
- bot_username: ${ctxEscape(role.botHandle)}
2164
- plane:
2165
- workspace: ${ctxEscape(role.planeWorkspace)}
2166
- project_id: ${ctxEscape(role.ticketProviderBoardId)}
2167
- identifier: ${ctxEscape(role.ticketProviderIdentifier)}
2168
- runtime_repo: ${ctxEscape(role.runtimeRepo)}
2169
- systemd:
2170
- gateway_unit: hermes-${role.agentId}-gateway.service
2171
- consumer_unit: hermes-${role.agentId}-consumer.service
2172
- heartbeat_timer: hermes-${role.agentId}-heartbeat.timer
2173
- `;
2174
- const next = current.includes("agents: {}") ? current.replace("agents: {}", `agents:
2175
- ${block}`) : `${current.replace(/\s*$/, "\n")}${block}`;
2176
- changedFiles.push(path);
2177
- if (!dryRun) writeText(path, next);
2178
- return path;
2079
+ function titleCaseSlug(slug) {
2080
+ return slug.split(/[-_]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
2179
2081
  }
2180
- function profileMetaInheritsDefault(path) {
2181
- const text3 = safeReadText(path);
2182
- return Boolean(
2183
- text3 && /^config:\s*$/m.test(text3) && /^\s+inherit_from:\s*default\s*$/m.test(text3) && /^\s+save_mode:\s*delta\s*$/m.test(text3)
2184
- );
2082
+ function readSymlinkTarget(path) {
2083
+ if (!existsSync8(path)) return null;
2084
+ try {
2085
+ return readlinkSync(path);
2086
+ } catch {
2087
+ return null;
2088
+ }
2185
2089
  }
2186
- function upsertInheritedProfileMeta(path, changedFiles, dryRun) {
2187
- const current = safeReadText(path) ?? "";
2188
- const lines = current.split("\n");
2189
- let next;
2190
- const start = lines.findIndex((line) => /^config:\s*$/.test(line));
2191
- if (!current.trim()) {
2192
- next = "config:\n inherit_from: default\n save_mode: delta\n";
2193
- } else if (start === -1) {
2194
- next = `${current.replace(/\s*$/, "\n")}config:
2195
- inherit_from: default
2196
- save_mode: delta
2197
- `;
2198
- } else {
2199
- let end = start + 1;
2200
- while (end < lines.length && !/^[^#\s][^:]*:\s*/.test(lines[end] ?? "")) end++;
2201
- let hasInherit = false;
2202
- let hasSave = false;
2203
- for (let idx = start + 1; idx < end; idx++) {
2204
- if (/^\s+inherit_from:\s*/.test(lines[idx] ?? "")) {
2205
- lines[idx] = " inherit_from: default";
2206
- hasInherit = true;
2207
- } else if (/^\s+save_mode:\s*/.test(lines[idx] ?? "")) {
2208
- lines[idx] = " save_mode: delta";
2209
- hasSave = true;
2090
+ function ensureSymlink(path, target, dryRun) {
2091
+ if (existsSync8(path)) {
2092
+ const stat = lstatSync(path);
2093
+ if (stat.isSymbolicLink()) {
2094
+ const current = readSymlinkTarget(path);
2095
+ if (current === target) return { changed: false };
2096
+ if (!dryRun) {
2097
+ unlinkSync3(path);
2098
+ symlinkSync(target, path);
2210
2099
  }
2100
+ return { changed: true };
2211
2101
  }
2212
- const inserts = [];
2213
- if (!hasInherit) inserts.push(" inherit_from: default");
2214
- if (!hasSave) inserts.push(" save_mode: delta");
2215
- if (inserts.length) lines.splice(end, 0, ...inserts);
2216
- next = lines.join("\n");
2217
- if (!next.endsWith("\n")) next += "\n";
2102
+ return { changed: false, blocked: `${relative(process.cwd(), path) || path} exists and is not a symlink` };
2218
2103
  }
2219
- if (next === current) return null;
2220
- changedFiles.push(path);
2221
- if (!dryRun) writeText(path, next);
2222
- return path;
2223
- }
2224
- function ctxEscape(value) {
2225
- return JSON.stringify(value || "");
2226
- }
2227
- function checkUnit(unit) {
2228
- const enabled = systemctlUser(["is-enabled", unit]).ok;
2229
- const active = systemctlUser(["is-active", unit]).ok;
2230
- return { enabled, active };
2104
+ if (!dryRun) symlinkSync(target, path);
2105
+ return { changed: true };
2231
2106
  }
2232
- var RULES = [
2233
- {
2234
- id: "mise.config-root",
2235
- title: "mise config_root + AGENTS link hooks",
2236
- audit: (ctx) => {
2237
- const misePath = join9(ctx.repoRoot, "mise.toml");
2238
- if (!existsSync7(misePath)) {
2239
- return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
2240
- }
2241
- const text3 = readText(misePath);
2242
- const details = [];
2243
- const linkAgentfilesPath = join9(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2244
- if (!existsSync7(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2245
- const pathValues = [...(text3.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2246
- const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
2247
- if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
2248
- if (!text3.includes('"{{config_root}}/.mise/scripts/link-agentfiles.sh"')) details.push("link-agentfiles must use raw {{config_root}} guard");
2249
- if (!text3.includes("op inject -i .env.op > .env")) details.push("[hooks].enter must materialize .env from .env.op");
2250
- if (!text3.includes('patterns = ["AGENTS.md"]')) details.push("watch_files must monitor AGENTS.md");
2251
- if (!text3.includes('task = "link-agentfiles"')) details.push("watch_files must dispatch link-agentfiles task");
2252
- return {
2253
- id: "mise.config-root",
2254
- title: "mise config_root + AGENTS link hooks",
2255
- status: details.length === 0 ? "pass" : "fail",
2256
- summary: details.length === 0 ? "mise AGENTS-linking parity verified" : `${details.length} issue(s) detected in mise AGENTS-linking contract`,
2257
- details,
2258
- fixable: true
2259
- };
2260
- },
2261
- migrate: (ctx, finding) => {
2262
- const path = join9(ctx.repoRoot, "mise.toml");
2263
- const changedFiles = [];
2264
- const details = [];
2265
- if (!existsSync7(path)) {
2266
- if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2267
- return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing and no generated-project mise template available to initialize from", changedFiles, details: [] };
2268
- }
2269
- details.push("Initialized mise.toml from generated-project template");
2270
- if (ctx.dryRun) {
2271
- return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
2272
- }
2273
- }
2274
- let text3 = readText(path);
2275
- const next = upsertLinkAgentfilesBlock(text3, ctx);
2276
- if (next !== text3) {
2277
- if (!changedFiles.includes(path)) changedFiles.push(path);
2278
- if (!ctx.dryRun) writeText(path, next);
2279
- text3 = next;
2280
- }
2281
- const linkAgentfilesPath = join9(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2282
- const expectedScript = templateLinkAgentfilesScript(ctx);
2283
- if (expectedScript === void 0) {
2284
- 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: [] };
2285
- }
2286
- if (safeReadText(linkAgentfilesPath) !== expectedScript) {
2287
- changedFiles.push(linkAgentfilesPath);
2288
- if (!ctx.dryRun) {
2289
- writeText(linkAgentfilesPath, expectedScript);
2290
- chmodSync2(linkAgentfilesPath, 493);
2291
- }
2292
- }
2293
- return {
2294
- id: finding.id,
2295
- title: finding.title,
2296
- status: changedFiles.length ? "applied" : "noop",
2297
- summary: changedFiles.length ? "Updated mise AGENTS-linking contract" : "No changes required",
2298
- changedFiles,
2299
- details: changedFiles.length ? ["Normalized hooks/watch_files/tasks.link-agentfiles block and script"] : []
2300
- };
2107
+ function bootstrapAgentsFile(repoRoot, dryRun) {
2108
+ const agentsPath = join10(repoRoot, "AGENTS.md");
2109
+ if (existsSync8(agentsPath)) return { changedFiles: [], details: [] };
2110
+ for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2111
+ const source = join10(repoRoot, file);
2112
+ if (!existsSync8(source)) continue;
2113
+ const stat = lstatSync(source);
2114
+ if (stat.isSymbolicLink()) continue;
2115
+ if (stat.isFile()) {
2116
+ if (!dryRun) renameSync2(source, agentsPath);
2117
+ return { changedFiles: [agentsPath], details: [`Moved ${file} to AGENTS.md before wiring agent-file symlinks`] };
2301
2118
  }
2302
- },
2303
- {
2304
- id: "mise.versioning",
2305
- title: "managed mise versioning block",
2306
- audit: (ctx) => {
2307
- const details = [];
2308
- const misePath = join9(ctx.repoRoot, "mise.toml");
2309
- const versioningPath = join9(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2310
- const manifestPath = join9(ctx.repoRoot, ".mise", "version-files.conf");
2311
- const text3 = safeReadText(misePath);
2312
- if (!text3?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
2313
- if (!existsSync7(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
2314
- if (!existsSync7(manifestPath)) details.push(".mise/version-files.conf missing");
2315
- return {
2316
- id: "mise.versioning",
2317
- title: "managed mise versioning block",
2318
- status: details.length === 0 ? "pass" : "fail",
2319
- summary: details.length === 0 ? "mise versioning parity verified" : `${details.length} versioning issue(s) detected`,
2320
- details,
2321
- fixable: true
2322
- };
2323
- },
2324
- migrate: (ctx, finding) => {
2325
- const changedFiles = [];
2326
- const details = [];
2327
- const misePath = join9(ctx.repoRoot, "mise.toml");
2328
- if (!existsSync7(misePath)) {
2329
- if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2330
- return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing and no generated-project mise template available to initialize from", changedFiles, details: [] };
2331
- }
2332
- details.push("Initialized mise.toml from generated-project template");
2333
- if (ctx.dryRun) {
2334
- return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
2335
- }
2336
- }
2337
- const currentMise = readText(misePath);
2338
- const nextMise = replaceOrAppendManagedBlock(currentMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
2339
- if (nextMise !== currentMise) {
2340
- if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
2341
- if (!ctx.dryRun) writeText(misePath, nextMise);
2342
- }
2343
- const versioningPath = join9(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2344
- const expectedScript = templateVersioningScript(ctx);
2345
- if (expectedScript === void 0) {
2346
- 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: [] };
2347
- }
2348
- if (safeReadText(versioningPath) !== expectedScript) {
2349
- changedFiles.push(versioningPath);
2350
- if (!ctx.dryRun) {
2351
- writeText(versioningPath, expectedScript);
2352
- chmodSync2(versioningPath, 493);
2353
- }
2354
- }
2355
- const manifestPath = join9(ctx.repoRoot, ".mise", "version-files.conf");
2356
- const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
2357
- if (safeReadText(manifestPath) !== expectedManifest) {
2358
- changedFiles.push(manifestPath);
2359
- if (!ctx.dryRun) writeText(manifestPath, expectedManifest);
2119
+ return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
2120
+ }
2121
+ const readmePath = join10(repoRoot, "README.md");
2122
+ if (existsSync8(readmePath)) {
2123
+ const stat = lstatSync(readmePath);
2124
+ if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
2125
+ if (!dryRun) copyFileSync(readmePath, agentsPath);
2126
+ return { changedFiles: [agentsPath], details: ["Copied README.md to AGENTS.md before wiring agent-file symlinks"] };
2127
+ }
2128
+ return { changedFiles: [], details: [], blocked: "AGENTS.md missing and no CLAUDE.md, GEMINI.md, or README.md source exists" };
2129
+ }
2130
+ function yamlGet(text3, keyPath) {
2131
+ const parts = keyPath.split(".");
2132
+ const lines = text3.split("\n");
2133
+ let start = 0;
2134
+ let indent = 0;
2135
+ for (let idx = 0; idx < parts.length; idx += 1) {
2136
+ const key = parts[idx];
2137
+ let found = false;
2138
+ for (let i = start; i < lines.length; i += 1) {
2139
+ const line = lines[i];
2140
+ if (!line.trim() || line.trim().startsWith("#")) continue;
2141
+ const match = line.match(/^(\s*)([^:#]+):\s*(.*)$/);
2142
+ if (!match) continue;
2143
+ const currentIndent = match[1].length;
2144
+ const currentKey = match[2].trim();
2145
+ const rest = match[3].trim();
2146
+ if (idx > 0 && currentIndent < indent) break;
2147
+ if (currentIndent !== indent || currentKey !== key) continue;
2148
+ found = true;
2149
+ if (idx === parts.length - 1) {
2150
+ return rest.replace(/^['"]|['"]$/g, "").trim();
2360
2151
  }
2361
- return {
2362
- id: finding.id,
2363
- title: finding.title,
2364
- status: changedFiles.length ? "applied" : "noop",
2365
- summary: changedFiles.length ? "Versioning block/script/manifest normalized" : "No changes required",
2366
- changedFiles,
2367
- details: []
2368
- };
2152
+ start = i + 1;
2153
+ indent = currentIndent + 2;
2154
+ break;
2369
2155
  }
2370
- },
2371
- {
2372
- id: "sot.agent-symlinks",
2373
- title: "AGENTS/CLAUDE/GEMINI symlink contract",
2374
- audit: (ctx) => {
2375
- const agentsPath = join9(ctx.repoRoot, "AGENTS.md");
2376
- if (!existsSync7(agentsPath)) {
2377
- const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync7(join9(ctx.repoRoot, file)));
2378
- if (fallbackSources.length === 0) {
2379
- return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
2380
- }
2381
- return {
2382
- id: "sot.agent-symlinks",
2383
- title: "AGENTS/CLAUDE/GEMINI symlink contract",
2384
- status: "fail",
2385
- summary: "AGENTS.md missing but can be derived from existing project documentation",
2386
- details: [`AGENTS.md can be created from ${fallbackSources[0]}`],
2387
- fixable: true
2388
- };
2389
- }
2390
- const details = [];
2391
- for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2392
- const full = join9(ctx.repoRoot, file);
2393
- const target = readSymlinkTarget(full);
2394
- if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
2395
- }
2396
- return {
2397
- id: "sot.agent-symlinks",
2398
- title: "AGENTS/CLAUDE/GEMINI symlink contract",
2399
- status: details.length === 0 ? "pass" : "fail",
2400
- summary: details.length === 0 ? "Agent documentation symlinks are in parity" : `${details.length} symlink issue(s) detected`,
2401
- details,
2402
- fixable: true
2403
- };
2404
- },
2405
- migrate: (ctx, finding) => {
2406
- const changedFiles = [];
2407
- const details = [];
2408
- const blockedDetails = [];
2409
- const bootstrap = bootstrapAgentsFile(ctx.repoRoot, ctx.dryRun);
2410
- changedFiles.push(...bootstrap.changedFiles);
2411
- details.push(...bootstrap.details);
2412
- if (bootstrap.blocked) {
2413
- return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
2414
- }
2415
- for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2416
- const full = join9(ctx.repoRoot, file);
2417
- const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
2418
- if (result.blocked) blockedDetails.push(result.blocked);
2419
- if (result.changed) changedFiles.push(full);
2420
- }
2421
- return {
2422
- id: finding.id,
2423
- title: finding.title,
2424
- status: blockedDetails.length ? "blocked" : changedFiles.length ? "applied" : "noop",
2425
- summary: blockedDetails.length ? "One or more files could not be replaced safely" : changedFiles.length ? "Symlink contract repaired" : "No changes required",
2426
- changedFiles,
2427
- details: [...details, ...blockedDetails]
2428
- };
2156
+ if (!found) return "";
2157
+ }
2158
+ return "";
2159
+ }
2160
+ function discoverRoles(repoRoot) {
2161
+ const rolesDir = join10(repoRoot, "agents", "hermes");
2162
+ if (!existsSync8(rolesDir)) return [];
2163
+ return readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
2164
+ const roleDir = join10(rolesDir, entry.name);
2165
+ const roleYamlPath = join10(roleDir, "role.yaml");
2166
+ if (!existsSync8(roleYamlPath)) return null;
2167
+ const text3 = readText(roleYamlPath);
2168
+ const runtimeRepoRaw = yamlGet(text3, "runtime.github_repo");
2169
+ return {
2170
+ role: yamlGet(text3, "role") || entry.name,
2171
+ roleDir,
2172
+ roleYamlPath,
2173
+ repo: yamlGet(text3, "repo"),
2174
+ agentId: yamlGet(text3, "agent_id"),
2175
+ profileName: yamlGet(text3, "profile") || yamlGet(text3, "agent_id"),
2176
+ displayName: yamlGet(text3, "display_name"),
2177
+ purpose: yamlGet(text3, "purpose"),
2178
+ botHandle: yamlGet(text3, "telegram.bot_username"),
2179
+ runtimeRepo: runtimeRepoRaw.includes("/") ? runtimeRepoRaw.split("/").slice(-1)[0] ?? runtimeRepoRaw : runtimeRepoRaw,
2180
+ runtimeOwner: yamlGet(text3, "runtime.github_owner"),
2181
+ planeWorkspace: yamlGet(text3, "ticket_provider.workspace") || yamlGet(text3, "plane.workspace"),
2182
+ ticketProviderName: yamlGet(text3, "ticket_provider.name"),
2183
+ ticketProviderBoardId: yamlGet(text3, "ticket_provider.board_id"),
2184
+ ticketProviderBoardUrl: yamlGet(text3, "ticket_provider.board_url"),
2185
+ ticketProviderIdentifier: yamlGet(text3, "plane.identifier")
2186
+ };
2187
+ }).filter((value) => Boolean(value));
2188
+ }
2189
+ function registryPath(homeDir) {
2190
+ return join10(homeDir, ".hermes", "agents-registry.yaml");
2191
+ }
2192
+ function systemctlUser(args) {
2193
+ const result = spawnSync5("systemctl", ["--user", ...args], { encoding: "utf8" });
2194
+ return {
2195
+ ok: result.status === 0,
2196
+ stdout: result.stdout.trim(),
2197
+ stderr: result.stderr.trim()
2198
+ };
2199
+ }
2200
+ function templateScript(ctx, name) {
2201
+ const source = join10(ctx.pjanglerRoot, ".mise", "scripts", name);
2202
+ return existsSync8(source) ? readText(source) : void 0;
2203
+ }
2204
+ function templateVersioningScript(ctx) {
2205
+ return templateScript(ctx, "versioning.sh");
2206
+ }
2207
+ function templateLinkAgentfilesScript(ctx) {
2208
+ return templateScript(ctx, "link-agentfiles.sh");
2209
+ }
2210
+ function renderGeneratedProjectMiseToml(ctx, template) {
2211
+ const project = readProjectJson(ctx);
2212
+ const projectName = String(project?.project_name ?? basename3(ctx.repoRoot) ?? "project");
2213
+ return template.replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
2214
+ }
2215
+ function ensureMiseTomlFromTemplate(ctx, changedFiles) {
2216
+ const targetPath = join10(ctx.repoRoot, "mise.toml");
2217
+ if (existsSync8(targetPath)) return false;
2218
+ const sourcePath = join10(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
2219
+ if (!existsSync8(sourcePath)) return false;
2220
+ changedFiles.push(targetPath);
2221
+ if (!ctx.dryRun) {
2222
+ writeText(targetPath, renderGeneratedProjectMiseToml(ctx, readText(sourcePath)));
2223
+ }
2224
+ return true;
2225
+ }
2226
+ function templateVersionFilesConf(ctx, repoRoot) {
2227
+ const packageJson = join10(repoRoot, "package.json");
2228
+ 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";
2229
+ }
2230
+ function replaceOrAppendManagedBlock(text3, startMarker, block, beforePattern) {
2231
+ if (startMarker.test(text3)) {
2232
+ return text3.replace(/# >>> mise-versioning >>>[\s\S]*?# <<< mise-versioning <<</, block);
2233
+ }
2234
+ if (beforePattern) {
2235
+ const match = text3.match(beforePattern);
2236
+ if (match && typeof match.index === "number") {
2237
+ return `${text3.slice(0, match.index).replace(/\s*$/, "\n\n")}${block}
2238
+
2239
+ ${text3.slice(match.index)}`;
2429
2240
  }
2430
- },
2431
- {
2432
- id: "sot.project-json",
2433
- title: "Canonical .project.json",
2434
- audit: projectJsonFinding,
2435
- migrate: (ctx, finding) => {
2436
- const changedFiles = [];
2437
- const details = [];
2438
- const path = join9(ctx.repoRoot, ".project.json");
2439
- const existing = readProjectJson(ctx) ?? {};
2440
- const canonical = canonicalProjectJson(ctx);
2441
- const merged = { ...existing, ...canonical };
2442
- const expected = `${JSON.stringify(merged, null, 2)}
2241
+ }
2242
+ return `${text3.replace(/\s*$/, "")}
2243
+
2244
+ ${block}
2443
2245
  `;
2444
- if (safeReadText(path) !== expected) {
2445
- changedFiles.push(path);
2446
- if (!ctx.dryRun) writeText(path, expected);
2447
- }
2448
- const planeJson = join9(ctx.repoRoot, ".plane.json");
2449
- if (existsSync7(planeJson)) {
2450
- const backup = `${planeJson}.migrated-backup`;
2451
- if (existsSync7(backup)) {
2452
- details.push(`cannot back up .plane.json because ${relative(ctx.repoRoot, backup)} already exists`);
2453
- } else {
2454
- changedFiles.push(backup);
2455
- if (!ctx.dryRun) renameSync(planeJson, backup);
2246
+ }
2247
+ var BASE_MISE_PATH_ENTRIES = [".mise/scripts", "agents/hermes/pm"];
2248
+ var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/hermes"];
2249
+ function requiredMisePathEntries(ctx) {
2250
+ const required = [...BASE_MISE_PATH_ENTRIES];
2251
+ for (const candidate of CONDITIONAL_HERMES_PATHS) {
2252
+ if (existsSync8(join10(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
2253
+ }
2254
+ return required;
2255
+ }
2256
+ function upsertMisePath(text3, required = BASE_MISE_PATH_ENTRIES) {
2257
+ const render = (values) => `_.path = [${values.map((value) => JSON.stringify(value)).join(", ")}]`;
2258
+ const envMatch = text3.match(/(^|\n)(\[env\][\s\S]*?)(?=\n\[[^\]]+\]|$)/);
2259
+ if (!envMatch || typeof envMatch.index !== "number") {
2260
+ return `[env]
2261
+ ${render(required)}
2262
+
2263
+ ${text3.replace(/^\s+/, "")}`;
2264
+ }
2265
+ const prefix = text3.slice(0, envMatch.index + envMatch[1].length);
2266
+ const section = envMatch[2];
2267
+ const suffix = text3.slice(envMatch.index + envMatch[1].length + section.length);
2268
+ const pathLine = section.match(/^_\.path\s*=\s*\[([^\]]*)\]\s*$/m);
2269
+ if (!pathLine) {
2270
+ return `${prefix}${section.replace(/\n?$/, "\n")}${render(required)}${suffix}`;
2271
+ }
2272
+ const current = [...pathLine[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2273
+ const merged = [...current];
2274
+ for (const value of required) {
2275
+ if (!merged.includes(value)) merged.push(value);
2276
+ }
2277
+ const nextLine = render(merged);
2278
+ if (pathLine[0] === nextLine) return text3;
2279
+ return `${prefix}${section.replace(pathLine[0], nextLine)}${suffix}`;
2280
+ }
2281
+ function removeTomlSection(text3, headerPattern, marker, options) {
2282
+ const lines = text3.split("\n");
2283
+ let start = -1;
2284
+ let end = -1;
2285
+ for (let i = 0; i < lines.length; i++) {
2286
+ if (!headerPattern.test(lines[i])) continue;
2287
+ if (marker) {
2288
+ let hasMarker = false;
2289
+ for (let j = i + 1; j < lines.length && !/^\[[^\]]+\]/.test(lines[j]); j++) {
2290
+ if (marker.test(lines[j])) {
2291
+ hasMarker = true;
2292
+ break;
2456
2293
  }
2457
2294
  }
2458
- return {
2459
- id: finding.id,
2460
- title: finding.title,
2461
- status: details.length ? "blocked" : changedFiles.length ? "applied" : "noop",
2462
- summary: details.length ? "Project SOT partially blocked" : changedFiles.length ? "Canonical .project.json written" : "No changes required",
2463
- changedFiles,
2464
- details
2465
- };
2295
+ if (!hasMarker) continue;
2466
2296
  }
2467
- },
2468
- {
2469
- id: "secrets.env-op",
2470
- title: ".env.op + gitignore secrets contract",
2471
- audit: (ctx) => {
2472
- const details = [];
2473
- const envOp = safeReadText(join9(ctx.repoRoot, ".env.op"));
2474
- const gitignore = safeReadText(join9(ctx.repoRoot, ".gitignore"));
2475
- if (!envOp) {
2476
- details.push(".env.op missing");
2477
- } else {
2478
- const invalidLines = envOp.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).filter((line) => {
2479
- const value = line.slice(line.indexOf("=") + 1).trim();
2480
- const quotedLiteral = /^"[^"\r\n]*"$/.test(value) || /^'[^'\r\n]*'$/.test(value);
2481
- return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value) && !quotedLiteral;
2482
- });
2483
- if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals: ${invalidLines.join(", ")}`);
2484
- }
2485
- if (!gitignore?.includes(".env\n") && !gitignore?.includes(".env\r\n")) details.push(".gitignore should ignore .env");
2486
- if (!gitignore?.includes(".env.*")) details.push(".gitignore should ignore .env.*");
2487
- if (!gitignore?.includes("!.env.op")) details.push(".gitignore should unignore .env.op");
2488
- return {
2489
- id: "secrets.env-op",
2490
- title: ".env.op + gitignore secrets contract",
2491
- status: details.length === 0 ? "pass" : "fail",
2492
- summary: details.length === 0 ? "Secret reference file and ignore rules are in parity" : `${details.length} env parity issue(s) detected`,
2493
- details,
2494
- fixable: true
2495
- };
2496
- },
2497
- migrate: (ctx, finding) => {
2498
- const changedFiles = [];
2499
- const details = [];
2500
- const envOpPath = join9(ctx.repoRoot, ".env.op");
2501
- if (!existsSync7(envOpPath)) {
2502
- changedFiles.push(envOpPath);
2503
- if (!ctx.dryRun) writeText(envOpPath, readText(join9(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2504
- }
2505
- const gitignorePath = join9(ctx.repoRoot, ".gitignore");
2506
- const gitignore = safeReadText(gitignorePath) ?? "";
2507
- const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
2508
- # NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
2509
- .env
2510
- .env.*
2511
- !.env.op
2512
- `;
2513
- if (!gitignore.includes("!.env.op") || !gitignore.includes(".env.*")) {
2514
- changedFiles.push(gitignorePath);
2515
- if (!ctx.dryRun) writeText(gitignorePath, `${gitignore.replace(/\s*$/, "")}${gitignore.trim() ? "\n\n" : ""}${requiredBlock}`);
2297
+ start = i;
2298
+ for (let j = i + 1; j < lines.length; j++) {
2299
+ if (/^\[[^\]]+\]/.test(lines[j])) {
2300
+ end = j;
2301
+ break;
2516
2302
  }
2517
- return {
2518
- id: finding.id,
2519
- title: finding.title,
2520
- status: details.length ? "blocked" : changedFiles.length ? "applied" : "noop",
2521
- summary: details.length ? "Manual cleanup still required" : changedFiles.length ? "Wrote .env.op/gitignore parity files" : "No changes required",
2522
- changedFiles,
2523
- details
2524
- };
2525
2303
  }
2526
- },
2527
- {
2528
- id: "provenance.copier",
2529
- title: ".copier-answers.yml provenance + drift report",
2530
- audit: (ctx) => {
2531
- const details = [];
2532
- const path = join9(ctx.repoRoot, ".copier-answers.yml");
2533
- const text3 = safeReadText(path);
2534
- const project = readProjectJson(ctx);
2535
- if (!text3) {
2536
- details.push(".copier-answers.yml missing");
2537
- } else {
2538
- if (!text3.startsWith("# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY")) details.push("missing Copier overwrite warning header");
2539
- if (!text3.includes("_src_path:")) details.push("_src_path missing");
2540
- if (project?.project_name) {
2541
- const nameMatch = text3.match(/project_name:\s*(.+)/);
2542
- if (!nameMatch || nameMatch[1]?.trim() !== String(project.project_name)) details.push("project_name drift between .copier-answers.yml and .project.json");
2543
- }
2544
- if (project?.project_description) {
2545
- const descMatch = text3.match(/project_description:\s*([\s\S]*?)(?=\n\w|$)/);
2546
- const yamlDesc = descMatch?.[1]?.replace(/\n\s+/g, " ").trim() ?? "";
2547
- if (yamlDesc !== String(project.project_description)) details.push("project_description drift between .copier-answers.yml and .project.json");
2548
- }
2549
- }
2550
- return {
2551
- id: "provenance.copier",
2552
- title: ".copier-answers.yml provenance + drift report",
2553
- status: details.length === 0 ? "pass" : "fail",
2554
- summary: details.length === 0 ? "Copier provenance is in parity" : `${details.length} provenance issue(s) detected`,
2555
- details,
2556
- fixable: true
2557
- };
2558
- },
2559
- migrate: (ctx, finding) => {
2560
- const changedFiles = [];
2561
- const project = canonicalProjectJson(ctx);
2562
- const text3 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
2563
- _src_path: ${join9(ctx.pjanglerRoot, "templates", "commonproject")}
2564
- project_description: ${String(project.project_description)}
2565
- project_name: ${String(project.project_name)}
2566
- ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2567
- `;
2568
- const path = join9(ctx.repoRoot, ".copier-answers.yml");
2569
- if (safeReadText(path) !== text3) {
2570
- changedFiles.push(path);
2571
- if (!ctx.dryRun) writeText(path, text3);
2572
- }
2573
- return {
2574
- id: finding.id,
2575
- title: finding.title,
2576
- status: changedFiles.length ? "applied" : "noop",
2577
- summary: changedFiles.length ? "Copier provenance file refreshed" : "No changes required",
2578
- changedFiles,
2579
- details: []
2580
- };
2581
- }
2582
- },
2583
- {
2584
- id: "bmad.scaffold",
2585
- title: "BMAD modules/docs scaffold",
2586
- audit: (ctx) => {
2587
- const sourceRoot = join9(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
2588
- const targetRoot = join9(ctx.repoRoot, "_bmad");
2589
- const sentinels = [
2590
- join9("core", "config.yaml"),
2591
- join9("custom", "config.yaml"),
2592
- join9("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
2593
- join9("bmm", "workflows", "workflow-status", "workflow.yaml")
2594
- ];
2595
- const missing = sentinels.filter((file) => existsSync7(join9(sourceRoot, file)) && !existsSync7(join9(targetRoot, file)));
2596
- return {
2597
- id: "bmad.scaffold",
2598
- title: "BMAD modules/docs scaffold",
2599
- status: missing.length === 0 ? "pass" : "fail",
2600
- summary: missing.length === 0 ? "BMAD scaffold parity verified" : `${missing.length} BMAD sentinel file(s) missing`,
2601
- details: missing.map((file) => `_bmad/${file}`),
2602
- fixable: true
2603
- };
2604
- },
2605
- migrate: (ctx, finding) => {
2606
- const changedFiles = [];
2607
- copyMissingRecursive(join9(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join9(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
2608
- return {
2609
- id: finding.id,
2610
- title: finding.title,
2611
- status: changedFiles.length ? "applied" : "noop",
2612
- summary: changedFiles.length ? "Copied missing BMAD scaffold files" : "No changes required",
2613
- changedFiles,
2614
- details: []
2615
- };
2616
- }
2617
- },
2618
- {
2619
- id: "hermes.pm-scaffold",
2620
- title: "Hermes PM scaffold parity",
2621
- audit: (ctx) => {
2622
- const roles = discoverRoles(ctx.repoRoot);
2623
- const role = roles.find((item) => item.role === "pm");
2624
- if (!role) {
2625
- return { id: "hermes.pm-scaffold", title: "Hermes PM scaffold parity", status: "skip", summary: "No pm role present", details: [], fixable: false };
2626
- }
2627
- const details = [];
2628
- for (const rel of ["role.yaml", "SOUL.md", "hermes", ".gitignore", ".scripts/70-systemd.sh", ".scripts/heartbeat.sh", ".scripts/checkpoint.sh", ".runtime-scaffold/README.md", "runtime/memories/MEMORY.md", "runtime/bloodbank-consumer.py"]) {
2629
- if (!existsSync7(join9(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join9(role.roleDir, rel))}`);
2630
- }
2631
- const gitmodules = safeReadText(join9(ctx.repoRoot, ".gitmodules")) ?? "";
2632
- if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing pm runtime submodule entry");
2633
- if (!profileMetaInheritsDefault(join9(role.roleDir, "runtime", "profile.yaml"))) {
2634
- details.push("runtime/profile.yaml missing inherited default config metadata");
2635
- }
2636
- const registry = safeReadText(registryPath(ctx.homeDir));
2637
- if (!registry?.includes(`${role.agentId}:`)) details.push(`fleet registry missing ${role.agentId}`);
2638
- return {
2639
- id: "hermes.pm-scaffold",
2640
- title: "Hermes PM scaffold parity",
2641
- status: details.length === 0 ? "pass" : "fail",
2642
- summary: details.length === 0 ? "PM scaffold parity verified" : `${details.length} PM scaffold issue(s) detected`,
2643
- details,
2644
- fixable: true
2645
- };
2646
- },
2647
- migrate: (ctx, finding) => {
2648
- const role = discoverRoles(ctx.repoRoot).find((item) => item.role === "pm");
2649
- const changedFiles = [];
2650
- const details = [];
2651
- if (!role) {
2652
- return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
2653
- }
2654
- const templateRoleDir = join9(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
2655
- writeIfDifferent(join9(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
2656
- writeIfDifferent(join9(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
2657
- writeIfDifferent(join9(role.roleDir, ".gitignore"), readText(join9(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
2658
- copyMissingRecursive(join9(templateRoleDir, ".runtime-scaffold"), join9(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
2659
- copyMissingRecursive(join9(templateRoleDir, ".runtime-scaffold"), join9(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
2660
- copyMissingRecursive(join9(templateRoleDir, ".scripts"), join9(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
2661
- const promptSource = join9(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
2662
- const promptTarget = join9(role.roleDir, ".scripts", "sentinel.prompt.md");
2663
- if (existsSync7(promptSource) && !existsSync7(promptTarget)) {
2664
- const prompt = readText(promptSource).replace(/\{\{ agent_id \}\}/g, role.agentId).replace(/\{\{ role \}\}/g, role.role).replace(/\{\{ target_repo \}\}/g, role.repo).replace(/\{\{ display_name \}\}/g, role.displayName || role.agentId);
2665
- writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
2666
- }
2667
- upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
2668
- const profileMetaUpdated = upsertInheritedProfileMeta(join9(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
2669
- if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
2670
- const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
2671
- if (registryUpdated) details.push(`updated ${registryUpdated}`);
2672
- return {
2673
- id: finding.id,
2674
- title: finding.title,
2675
- status: changedFiles.length ? "applied" : "noop",
2676
- summary: changedFiles.length ? "PM scaffold normalized" : "No changes required",
2677
- changedFiles,
2678
- details
2679
- };
2680
- }
2681
- },
2682
- {
2683
- id: "systemd.sentinel",
2684
- title: "Hermes systemd/sentinel units enabled + active",
2685
- audit: (ctx) => {
2686
- const roles = discoverRoles(ctx.repoRoot);
2687
- if (!roles.length) {
2688
- return { id: "systemd.sentinel", title: "Hermes systemd/sentinel units enabled + active", status: "skip", summary: "No Hermes roles present", details: [], fixable: false };
2689
- }
2690
- const probe = systemctlUser(["is-system-running"]);
2691
- if (!probe.ok && !/running|degraded|starting|maintenance/.test(`${probe.stdout} ${probe.stderr}`)) {
2692
- 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 };
2693
- }
2694
- const details = [];
2695
- for (const role of roles) {
2696
- for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
2697
- const state = checkUnit(unit);
2698
- if (!state.enabled || !state.active) details.push(`${unit} should be enabled+active`);
2699
- }
2700
- }
2701
- return {
2702
- id: "systemd.sentinel",
2703
- title: "Hermes systemd/sentinel units enabled + active",
2704
- status: details.length === 0 ? "pass" : "fail",
2705
- summary: details.length === 0 ? "Hermes user units are enabled and active" : `${details.length} systemd parity issue(s) detected`,
2706
- details,
2707
- fixable: true
2708
- };
2709
- },
2710
- migrate: (ctx, finding) => {
2711
- const roles = discoverRoles(ctx.repoRoot);
2712
- const changedFiles = [];
2713
- const details = [];
2714
- if (!roles.length) {
2715
- return { id: finding.id, title: finding.title, status: "blocked", summary: "No Hermes roles present", changedFiles, details };
2716
- }
2717
- const probe = systemctlUser(["is-system-running"]);
2718
- if (!probe.ok && !/running|degraded|starting|maintenance/.test(`${probe.stdout} ${probe.stderr}`)) {
2719
- return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
2720
- }
2721
- for (const role of roles) {
2722
- const sysDir = join9(ctx.homeDir, ".config", "systemd", "user");
2723
- const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
2724
- const allUnitsPresent = units.every((unit) => existsSync7(join9(sysDir, unit)));
2725
- if (allUnitsPresent) {
2726
- if (ctx.dryRun) {
2727
- details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
2728
- } else {
2729
- systemctlUser(["daemon-reload"]);
2730
- for (const unit of units) {
2731
- systemctlUser(["enable", "--now", unit]);
2732
- }
2733
- }
2734
- continue;
2735
- }
2736
- for (const script of [join9(role.roleDir, ".scripts", "70-systemd.sh")]) {
2737
- if (!script || !existsSync7(script)) continue;
2738
- if (ctx.dryRun) {
2739
- details.push(`would run: bash ${script}`);
2740
- } else {
2741
- const result = spawnSync4("bash", [script], { cwd: role.roleDir, encoding: "utf8" });
2742
- if (result.status !== 0) details.push(`script failed: ${script}: ${result.stderr.trim() || result.stdout.trim()}`);
2743
- }
2744
- }
2745
- }
2746
- return {
2747
- id: finding.id,
2748
- title: finding.title,
2749
- status: details.some((detail) => detail.includes("failed:")) ? "blocked" : details.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
2750
- summary: details.length ? ctx.dryRun ? "Planned systemd remediation commands" : "Attempted systemd remediation" : "No changes required",
2751
- changedFiles,
2752
- details
2753
- };
2304
+ if (end === -1) end = lines.length;
2305
+ break;
2306
+ }
2307
+ if (start === -1) return text3;
2308
+ if (options?.includePrecedingComments) {
2309
+ while (start > 0 && lines[start - 1].trim().startsWith("#")) {
2310
+ start--;
2754
2311
  }
2755
2312
  }
2756
- ];
2757
- function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
2758
- const normalized = content.endsWith("\n") ? content : `${content}
2759
- `;
2760
- if (safeReadText(path) === normalized) return;
2761
- changedFiles.push(path);
2762
- if (!dryRun) {
2763
- writeText(path, normalized);
2764
- if (mode) chmodSync2(path, mode);
2313
+ const result = lines.slice(0, start).concat(lines.slice(end)).join("\n");
2314
+ return result.replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
2315
+ }
2316
+ function insertTomlBlockBeforeVersioning(text3, block) {
2317
+ const versioningIndex = text3.indexOf("# >>> mise-versioning >>>");
2318
+ if (versioningIndex >= 0) {
2319
+ return `${text3.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${block}
2320
+
2321
+ ${text3.slice(versioningIndex)}`;
2765
2322
  }
2323
+ return `${text3.replace(/\s*$/, "")}
2324
+
2325
+ ${block}
2326
+ `;
2766
2327
  }
2767
- function getParityRuleIds() {
2768
- return RULES.map((rule) => rule.id);
2328
+ function extractTomlStrings(text3) {
2329
+ const values = [];
2330
+ const stringPattern = /"((?:\\.|[^"\\])*)"|'([^']*)'/g;
2331
+ for (const match of text3.matchAll(stringPattern)) {
2332
+ if (match[1] !== void 0) {
2333
+ try {
2334
+ values.push(JSON.parse(`"${match[1]}"`));
2335
+ } catch {
2336
+ values.push(match[1]);
2337
+ }
2338
+ } else if (match[2] !== void 0) {
2339
+ values.push(match[2]);
2340
+ }
2341
+ }
2342
+ return values;
2769
2343
  }
2770
- function runAudit(repoArg) {
2771
- const pjanglerRoot = resolvePjanglerRoot();
2772
- const ctx = {
2773
- repoRoot: resolve(repoArg ?? process.cwd()),
2774
- dryRun: true,
2775
- pjanglerRoot,
2776
- homeDir: homedir4()
2777
- };
2778
- const rules = RULES.map((rule) => rule.audit(ctx));
2779
- return {
2780
- repo: ctx.repoRoot,
2781
- ok: rules.every((rule) => rule.status === "pass" || rule.status === "skip"),
2782
- auditedAt: (/* @__PURE__ */ new Date()).toISOString(),
2783
- rules
2784
- };
2344
+ function isManagedHookEntry(value) {
2345
+ const trimmed = value.trim();
2346
+ return trimmed === "op inject -i .env.op > .env" || /(^|\/)link-agentfiles\.sh$/.test(trimmed);
2785
2347
  }
2786
- function runMigrationForRules(ruleIds, repoArg, dryRun) {
2787
- const pjanglerRoot = resolvePjanglerRoot();
2788
- const ctx = {
2789
- repoRoot: resolve(repoArg ?? process.cwd()),
2790
- dryRun,
2791
- pjanglerRoot,
2792
- homeDir: homedir4()
2793
- };
2794
- const selected = RULES.filter((rule) => ruleIds.includes(rule.id));
2795
- if (!selected.length) {
2796
- throw new Error(`Unknown parity rules: ${ruleIds.join(", ")}`);
2348
+ function renderHookEntries(entries, indent = "") {
2349
+ return [
2350
+ `${indent}enter = [`,
2351
+ ...entries.map((entry) => `${indent} ${JSON.stringify(entry)},`),
2352
+ `${indent}]`
2353
+ ];
2354
+ }
2355
+ function upsertLinkAgentfilesHooks(text3) {
2356
+ const lines = text3.split("\n");
2357
+ const hooksStart = lines.findIndex((line) => /^\[hooks\]$/.test(line.trim()));
2358
+ if (hooksStart === -1) return insertTomlBlockBeforeVersioning(text3, LINK_AGENTFILES_HOOKS_BLOCK);
2359
+ let hooksEnd = lines.length;
2360
+ for (let i = hooksStart + 1; i < lines.length; i++) {
2361
+ if (/^\[[^\]]+\]/.test(lines[i].trim())) {
2362
+ hooksEnd = i;
2363
+ break;
2364
+ }
2797
2365
  }
2798
- const results = selected.map((rule) => {
2799
- try {
2800
- return rule.migrate(ctx, rule.audit(ctx));
2801
- } catch (err) {
2802
- return {
2803
- id: rule.id,
2804
- title: rule.title,
2805
- status: "blocked",
2806
- summary: `migrate threw: ${err instanceof Error ? err.message : String(err)}`,
2807
- changedFiles: [],
2808
- details: []
2809
- };
2366
+ let enterStart = -1;
2367
+ let enterEnd = -1;
2368
+ for (let i = hooksStart + 1; i < hooksEnd; i++) {
2369
+ if (!/^\s*enter\s*=/.test(lines[i])) continue;
2370
+ enterStart = i;
2371
+ enterEnd = i + 1;
2372
+ const afterEquals = lines[i].slice(lines[i].indexOf("=") + 1);
2373
+ if (afterEquals.includes("[") && !afterEquals.includes("]")) {
2374
+ while (enterEnd < hooksEnd && !lines[enterEnd].includes("]")) enterEnd++;
2375
+ if (enterEnd < hooksEnd) enterEnd++;
2810
2376
  }
2811
- });
2812
- const changedFiles = Array.from(new Set(results.flatMap((result) => result.changedFiles))).sort();
2813
- return {
2814
- repo: ctx.repoRoot,
2815
- dryRun,
2816
- ok: results.every((result) => result.status !== "blocked"),
2817
- selectedRules: selected.map((rule) => rule.id),
2818
- results,
2819
- changedFiles
2820
- };
2821
- }
2822
- function runMigration(selector, repoArg, dryRun, all) {
2823
- const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
2824
- return runMigrationForRules(ruleIds, repoArg, dryRun);
2825
- }
2826
- function prettyTimestamp(iso) {
2827
- const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/.exec(iso);
2828
- return match ? `${match[1]} ${match[2]} UTC` : iso;
2829
- }
2830
- function formatAuditReport(report) {
2831
- const counts = {};
2832
- for (const rule of report.rules) counts[rule.status] = (counts[rule.status] ?? 0) + 1;
2833
- const idWidth = report.rules.reduce((width, rule) => Math.max(width, rule.id.length), 0);
2834
- const tally = [];
2835
- if (counts.pass) tally.push(green(`${counts.pass} passed`));
2836
- if (counts.fail) tally.push(red(`${counts.fail} failed`));
2837
- if (counts.warn) tally.push(yellow(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`));
2838
- if (counts.skip) tally.push(gray(`${counts.skip} skipped`));
2839
- const overall = report.ok ? `${green(glyph.pass)} ${bold("Parity audit passed")}` : `${red(glyph.fail)} ${bold("Parity audit failed")}`;
2840
- const lines = [""];
2841
- lines.push(` ${overall}${tally.length ? ` ${dim(glyph.dot)} ${joinDot(tally)}` : ""}`);
2842
- lines.push(` ${dim(report.repo)} ${dim(glyph.dot)} ${dim(prettyTimestamp(report.auditedAt))}`);
2843
- lines.push("");
2844
- for (const rule of report.rules) {
2845
- const style = statusStyle(rule.status);
2846
- lines.push(` ${style.color(style.glyph)} ${style.color(rule.id.padEnd(idWidth))} ${rule.summary}`);
2847
- for (const detail of rule.details) lines.push(` ${dim(glyph.arrow)} ${dim(detail)}`);
2377
+ break;
2848
2378
  }
2849
- lines.push("");
2850
- return lines.join("\n");
2851
- }
2852
- function formatMigrationReport(report) {
2853
- const idWidth = report.results.reduce((width, result) => Math.max(width, result.id.length), 0);
2854
- const overall = report.ok ? `${green(glyph.pass)} ${bold(report.dryRun ? "Migration preview complete" : "Migration complete")}` : `${red(glyph.fail)} ${bold("Migration finished with blockers")}`;
2855
- const lines = [""];
2856
- lines.push(` ${overall}${report.dryRun ? ` ${dim(glyph.dot)} ${yellow("dry run")}` : ""}`);
2857
- lines.push(` ${dim(report.repo)}`);
2858
- if (report.selectedRules.length) lines.push(` ${dim(`rules: ${report.selectedRules.join(", ")}`)}`);
2859
- lines.push("");
2860
- for (const result of report.results) {
2861
- const style = statusStyle(result.status);
2862
- lines.push(` ${style.color(style.glyph)} ${style.color(result.id.padEnd(idWidth))} ${result.summary} ${dim(`[${style.label}]`)}`);
2863
- for (const detail of result.details) lines.push(` ${dim(glyph.arrow)} ${dim(detail)}`);
2864
- for (const file of result.changedFiles) lines.push(` ${green(glyph.add)} ${file}`);
2379
+ const existingBlock = enterStart >= 0 ? lines.slice(enterStart, enterEnd).join("\n") : "";
2380
+ const preserved = extractTomlStrings(existingBlock).filter((entry) => !isManagedHookEntry(entry));
2381
+ const merged = [...LINK_AGENTFILES_HOOK_ENTRIES];
2382
+ for (const entry of preserved) {
2383
+ if (!merged.includes(entry)) merged.push(entry);
2865
2384
  }
2866
- if (report.changedFiles.length) {
2867
- lines.push("");
2868
- lines.push(` ${bold(`Changed files (${report.changedFiles.length})`)}`);
2869
- for (const file of report.changedFiles) lines.push(` ${green(glyph.add)} ${file}`);
2385
+ const indent = enterStart >= 0 ? lines[enterStart].match(/^\s*/)?.[0] ?? "" : "";
2386
+ const rendered = renderHookEntries(merged, indent);
2387
+ if (enterStart >= 0) {
2388
+ return lines.slice(0, enterStart).concat(rendered, lines.slice(enterEnd)).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
2870
2389
  }
2871
- lines.push("");
2872
- return lines.join("\n");
2390
+ return lines.slice(0, hooksStart + 1).concat(rendered, lines.slice(hooksStart + 1)).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
2873
2391
  }
2874
-
2875
- // src/project/index.ts
2876
- import { spawnSync as spawnSync5 } from "node:child_process";
2877
- import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync4, renameSync as renameSync2, statSync, writeFileSync as writeFileSync5 } from "node:fs";
2878
- import { homedir as homedir5 } from "node:os";
2879
- import { basename as basename3, dirname as dirname6, join as join10, resolve as resolve2 } from "node:path";
2880
- import YAML from "yaml";
2881
- var PROJECT_REGISTRY_ENV = "PJ_PROJECT_REGISTRY";
2882
- var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
2883
- var KNOWN_SKILL_ROOTS = [
2884
- "/home/delorenj/code/skillex/all-skills",
2885
- "/home/delorenj/code/CoachingAgentFramework/.agents/skills",
2886
- "/home/delorenj/code/pjangler/.agents/skills",
2887
- join10(homedir5(), ".codex", "skills")
2888
- ];
2889
- function projectRegistryPath(env2 = process.env) {
2890
- return expandHome(env2[PROJECT_REGISTRY_ENV] || join10(homedir5(), ".config", "pjangler", "projects.yaml"));
2392
+ function upsertLinkAgentfilesBlock(text3, ctx) {
2393
+ const withPath = upsertMisePath(text3, requiredMisePathEntries(ctx));
2394
+ if (withPath.includes(LINK_AGENTFILES_BLOCK)) return withPath;
2395
+ let cleaned = removeTomlSection(withPath, /^\[tasks\.link-agentfiles\]$/, /link-agentfiles/, { includePrecedingComments: false });
2396
+ cleaned = removeTomlSection(cleaned, /^\[\[watch_files\]\]$/, /AGENTS\.md/, { includePrecedingComments: false });
2397
+ cleaned = upsertLinkAgentfilesHooks(cleaned);
2398
+ return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
2891
2399
  }
2892
- function emptyProjectRegistry() {
2893
- return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
2400
+ function readProjectJson(ctx) {
2401
+ return tryParseJson(safeReadText(join10(ctx.repoRoot, ".project.json")));
2894
2402
  }
2895
- function loadProjectRegistry(path = projectRegistryPath()) {
2896
- if (!existsSync8(path)) return emptyProjectRegistry();
2897
- const raw = YAML.parse(readFileSync4(path, "utf8"));
2898
- if (raw == null) return emptyProjectRegistry();
2899
- if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
2900
- const registry = raw;
2901
- const normalized = {
2902
- schema_version: Number(registry.schema_version ?? PROJECT_REGISTRY_SCHEMA_VERSION),
2903
- projects: isRecord(registry.projects) ? registry.projects : {}
2403
+ function canonicalProjectJson(ctx) {
2404
+ const roles = discoverRoles(ctx.repoRoot);
2405
+ const existing = readProjectJson(ctx) ?? {};
2406
+ const slug = String(existing.project_slug ?? slugifyRepoName(dirname6(ctx.repoRoot) === ctx.repoRoot ? ctx.repoRoot.split("/").pop() ?? "project" : ctx.repoRoot.split("/").pop() ?? "project"));
2407
+ const firstRole = roles[0];
2408
+ const ticketProvider = {
2409
+ type: String((existing.ticket_provider?.type ?? firstRole?.ticketProviderName ?? "plane") || "plane"),
2410
+ workspace: String((existing.ticket_provider?.workspace ?? firstRole?.planeWorkspace ?? "") || ""),
2411
+ identifier: String((existing.ticket_provider?.identifier ?? firstRole?.ticketProviderIdentifier ?? "") || ""),
2412
+ board_id: String((existing.ticket_provider?.board_id ?? firstRole?.ticketProviderBoardId ?? "") || ""),
2413
+ board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || ""),
2414
+ state: String((existing.ticket_provider?.state ?? "planned") || "planned")
2415
+ };
2416
+ const existingAgents = existing.agents ?? {};
2417
+ const discoveredAgents = Object.fromEntries(
2418
+ roles.map((role) => [
2419
+ role.agentId || `${slug}-${role.role}`,
2420
+ {
2421
+ role: role.role,
2422
+ role_dir: relative(ctx.repoRoot, role.roleDir)
2423
+ }
2424
+ ])
2425
+ );
2426
+ const agents = { ...existingAgents };
2427
+ for (const [agentId, discovered] of Object.entries(discoveredAgents)) {
2428
+ const existingAgent = existingAgents[agentId] ?? {};
2429
+ agents[agentId] = {
2430
+ role: discovered.role,
2431
+ role_dir: discovered.role_dir,
2432
+ provisioning_state: existingAgent.provisioning_state
2433
+ };
2434
+ }
2435
+ return {
2436
+ project_name: String(existing.project_name ?? titleCaseSlug(slug)),
2437
+ project_description: String(existing.project_description ?? ""),
2438
+ project_slug: slug,
2439
+ repo_path: ctx.repoRoot,
2440
+ ticket_provider: ticketProvider,
2441
+ agents
2904
2442
  };
2905
- validateProjectRegistry(normalized);
2906
- return normalized;
2907
- }
2908
- function saveProjectRegistry(registry, path = projectRegistryPath()) {
2909
- validateProjectRegistry(registry);
2910
- mkdirSync6(dirname6(path), { recursive: true });
2911
- const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
2912
- writeFileSync5(temp, YAML.stringify(registry, { lineWidth: 0 }), "utf8");
2913
- renameSync2(temp, path);
2914
2443
  }
2915
- function validateProjectRegistry(registry) {
2916
- if (registry.schema_version !== PROJECT_REGISTRY_SCHEMA_VERSION) {
2917
- throw new Error(`Unsupported project registry schema_version: ${registry.schema_version}`);
2444
+ function projectJsonFinding(ctx) {
2445
+ const projectPath = join10(ctx.repoRoot, ".project.json");
2446
+ const planeJsonPath = join10(ctx.repoRoot, ".plane.json");
2447
+ const details = [];
2448
+ const data = readProjectJson(ctx);
2449
+ const roles = discoverRoles(ctx.repoRoot);
2450
+ if (!existsSync8(projectPath)) {
2451
+ return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json missing", details: [], fixable: true };
2918
2452
  }
2919
- if (!isRecord(registry.projects)) throw new Error("Project registry projects must be a mapping");
2920
- const slugs = /* @__PURE__ */ new Set();
2921
- const repoPaths = /* @__PURE__ */ new Map();
2922
- const identifiers = /* @__PURE__ */ new Map();
2923
- for (const [slug, project] of Object.entries(registry.projects)) {
2924
- validateProjectRecord(project, slug);
2925
- if (slugs.has(project.slug)) throw new Error(`Duplicate project slug: ${project.slug}`);
2926
- slugs.add(project.slug);
2927
- const repoKey = resolve2(project.repo_path);
2928
- const existingRepoSlug = repoPaths.get(repoKey);
2929
- if (existingRepoSlug && existingRepoSlug !== slug) {
2930
- throw new Error(`Duplicate project repo_path: ${project.repo_path} used by ${existingRepoSlug} and ${slug}`);
2453
+ if (!data) {
2454
+ return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json is not valid JSON", details: [], fixable: true };
2455
+ }
2456
+ for (const key of ["project_name", "project_description", "project_slug", "repo_path", "ticket_provider", "agents"]) {
2457
+ if (!(key in data)) details.push(`missing key: ${key}`);
2458
+ }
2459
+ if (data.repo_path !== ctx.repoRoot) details.push(`repo_path should be ${ctx.repoRoot}`);
2460
+ const agents = data.agents ?? {};
2461
+ for (const role of roles) {
2462
+ const agent = agents[role.agentId];
2463
+ if (!agent) {
2464
+ details.push(`agents.${role.agentId} missing`);
2465
+ continue;
2931
2466
  }
2932
- repoPaths.set(repoKey, slug);
2933
- const identifier = project.ticket_provider.identifier?.toUpperCase();
2934
- if (identifier) {
2935
- const existingIdentifierSlug = identifiers.get(identifier);
2936
- if (existingIdentifierSlug && existingIdentifierSlug !== slug) {
2937
- throw new Error(`Duplicate project identifier: ${identifier} used by ${existingIdentifierSlug} and ${slug}`);
2938
- }
2939
- identifiers.set(identifier, slug);
2467
+ if (agent.role !== role.role) details.push(`agents.${role.agentId}.role should be ${role.role}`);
2468
+ if (agent.role_dir !== relative(ctx.repoRoot, role.roleDir)) {
2469
+ details.push(`agents.${role.agentId}.role_dir should be ${relative(ctx.repoRoot, role.roleDir)}`);
2940
2470
  }
2941
2471
  }
2472
+ const ticketProvider = data.ticket_provider ?? {};
2473
+ for (const key of ["type", "workspace", "identifier", "board_id", "board_url", "state"]) {
2474
+ if (!(key in ticketProvider)) details.push(`ticket_provider.${key} missing`);
2475
+ }
2476
+ if (existsSync8(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
2477
+ return {
2478
+ id: "sot.project-json",
2479
+ title: "Canonical .project.json",
2480
+ status: details.length === 0 ? "pass" : "fail",
2481
+ summary: details.length === 0 ? ".project.json matches canonical parity contract" : `${details.length} parity issue(s) detected`,
2482
+ details,
2483
+ fixable: true
2484
+ };
2942
2485
  }
2943
- function slugifyProjectName(value) {
2944
- return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
2945
- }
2946
- function deriveProjectIdentifier(value) {
2947
- const compact = value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
2948
- const identifier = compact.slice(0, 4) || "PROJ";
2949
- return identifier.length >= 2 ? identifier : `${identifier}XX`.slice(0, 4);
2950
- }
2951
- function normalizeAgentRole(value) {
2952
- return value?.trim() || "pm";
2486
+ function renderSoul(role) {
2487
+ const telegram = role.botHandle ? `@${role.botHandle}` : "(unwired)";
2488
+ const tone = role.role === "pm" ? `Direct and brief. Decision-forward. No throat-clearing, no apologies, no "I'll help you with that" preambles.` : "Direct and brief.";
2489
+ const roleSpecific = role.role === "pm" ? `You are the project manager. You triage incoming work, create or refine tickets, and delegate implementation. You do not ship product code. A systemd heartbeat checkpoints your runtime; when this repo opts into reconciliation (\`reconcile.enabled\` in role.yaml), the same heartbeat also runs your continuous board-reconciliation pass out-of-band (\`.scripts/sentinel.prompt.md\`, \`--source cron\`), kept separate from your interactive session memory.` : `You operate as the ${role.role} agent for this repo.`;
2490
+ const runtimeOwner = role.runtimeOwner || "delorenj";
2491
+ return `# ${role.displayName || role.agentId}
2492
+
2493
+ You are **${role.displayName || role.agentId}** \u2014 a Hermes agent provisioned to work inside the
2494
+ \`${role.repo}\` repository.
2495
+
2496
+ ## Identity
2497
+
2498
+ | | |
2499
+ | --- | --- |
2500
+ | Agent ID | \`${role.agentId}\` |
2501
+ | Profile | \`${role.profileName || role.agentId}\` |
2502
+ | Repo | \`${role.repo}\` |
2503
+ | Role | \`${role.role}\` |
2504
+ | Telegram | \`${telegram}\` |
2505
+ | Purpose | ${role.purpose || `${role.role} agent for ${role.repo}`} |
2506
+
2507
+ ## Scope
2508
+
2509
+ You operate only within the working directory of \`${role.repo}\`. Your HERMES_HOME is the runtime submodule at \`./runtime/\` (repo \`${runtimeOwner}/${role.runtimeRepo}\`), which \`~/.hermes/profiles/${role.profileName || role.agentId}\` symlinks to (so \`--profile\` invocations resolve here too); Hermes loads its \`config.yaml\` directly. Secrets, SOUL, memories, skills, sessions, gateway state, and runtime files all live local to that runtime.
2510
+
2511
+ ## Tone
2512
+
2513
+ ${tone}
2514
+
2515
+ ## Role-specific behavior
2516
+
2517
+ ${roleSpecific}
2518
+
2519
+ ## Memory hygiene
2520
+
2521
+ Your memory is the submodule at \`./runtime/memories/\`. Use durable memory deliberately and keep \`memories/MEMORY.md\` current.
2522
+ `;
2953
2523
  }
2954
- function jsonStable(value) {
2955
- return JSON.stringify(value);
2524
+ function renderHermesWrapper(role) {
2525
+ return `#!/usr/bin/env bash
2526
+ # Launcher for ${role.agentId}. Resolves HERMES_HOME to the runtime submodule.
2527
+
2528
+ set -euo pipefail
2529
+
2530
+ ROLE_DIR="$(cd "$(dirname "$0")" && pwd)"
2531
+ RUNTIME_HOME="$ROLE_DIR/runtime"
2532
+
2533
+ FLEET_ENV="{HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
2534
+ if [[ -f "$FLEET_ENV" ]]; then
2535
+ # shellcheck disable=SC1090
2536
+ source "$FLEET_ENV"
2537
+ fi
2538
+
2539
+ HERMES_BIN="{HERMES_BIN:-{HERMES_FLEET_BIN:-/home/delorenj/code/hermes-agent/.venv/bin/hermes}}"
2540
+ HERMES_OAUTH_FILE="{HERMES_OAUTH_FILE:-{HERMES_FLEET_OAUTH_FILE:-$HOME/.hermes/auth.json}}"
2541
+ CODEX_HOME="{CODEX_HOME:-{HERMES_FLEET_CODEX_HOME:-$HOME/.codex}}"
2542
+
2543
+ FLEET_HOME="{HERMES_FLEET_HOME:-$HOME/.hermes}"
2544
+ PROFILE_NAME="{HERMES_PROFILE_NAME:-${role.profileName || role.agentId}}"
2545
+ HERMES_HOME="$RUNTIME_HOME"
2546
+
2547
+ if [[ ! -d "$RUNTIME_HOME" ]]; then
2548
+ echo "hermes: runtime submodule not initialized at $RUNTIME_HOME" >&2
2549
+ echo " fix: git submodule update --init --recursive" >&2
2550
+ exit 1
2551
+ fi
2552
+
2553
+ exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" HERMES_OAUTH_FILE="$HERMES_OAUTH_FILE" CODEX_HOME="$CODEX_HOME" "$HERMES_BIN" "$@"
2554
+ `.replace(/\u0010/g, "$");
2956
2555
  }
2957
- function projectRecordEquivalent(a, b) {
2958
- if (!a) return false;
2959
- const { created_at: _aCreated, updated_at: _aUpdated, ...aComparable } = a;
2960
- const { created_at: _bCreated, updated_at: _bUpdated, ...bComparable } = b;
2961
- return jsonStable(aComparable) === jsonStable(bComparable);
2556
+ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip) {
2557
+ if (!existsSync8(sourceDir)) return;
2558
+ mkdirSync6(targetDir, { recursive: true });
2559
+ for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
2560
+ const sourcePath = join10(sourceDir, entry.name);
2561
+ if (skip?.(sourcePath)) continue;
2562
+ const targetPath = join10(targetDir, entry.name);
2563
+ if (entry.isDirectory()) {
2564
+ copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
2565
+ continue;
2566
+ }
2567
+ if (existsSync8(targetPath)) continue;
2568
+ changedFiles.push(targetPath);
2569
+ if (!dryRun) {
2570
+ ensureParent(targetPath);
2571
+ copyFileSync(sourcePath, targetPath);
2572
+ }
2573
+ }
2962
2574
  }
2963
- function defaultProjectTargetDir(name, cwd = process.cwd()) {
2964
- const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
2965
- return resolve2(dirname6(resolve2(cwd)), compactName);
2575
+ function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
2576
+ const gitmodulesPath = join10(repoRoot, ".gitmodules");
2577
+ const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
2578
+ const owner = role.runtimeOwner || "delorenj";
2579
+ const block = `[submodule "agents/hermes/${role.role}/runtime"]
2580
+ path = agents/hermes/${role.role}/runtime
2581
+ url = git@github.com:${owner}/${repoName}.git
2582
+ `;
2583
+ const current = safeReadText(gitmodulesPath) ?? "";
2584
+ const header = `[submodule "agents/hermes/${role.role}/runtime"]`;
2585
+ if (current.includes(header)) return [];
2586
+ changedFiles.push(gitmodulesPath);
2587
+ if (!dryRun) writeText(gitmodulesPath, `${current.replace(/\s*$/, "")}${current.trim() ? "\n" : ""}${block}`);
2588
+ return [gitmodulesPath];
2966
2589
  }
2967
- function resolveSourceSkillPath(sourceSkill) {
2968
- if (!sourceSkill) return void 0;
2969
- const expanded = expandHome(sourceSkill);
2970
- const direct = resolve2(expanded);
2971
- if (existsSync8(direct)) return direct;
2972
- const name = basename3(sourceSkill);
2973
- for (const root of KNOWN_SKILL_ROOTS) {
2974
- const candidate = join10(root, name);
2975
- if (existsSync8(candidate)) return candidate;
2590
+ function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
2591
+ const path = registryPath(homeDir);
2592
+ const current = safeReadText(path) ?? "# Hermes agent fleet registry.\n# One entry per provisioned agent. Managed by hermes-agent-template/.scripts/80-registry.sh.\nschema_version: 1\nagents: {}\n";
2593
+ if (current.includes(`${role.agentId}:`)) return null;
2594
+ const block = ` ${role.agentId}:
2595
+ repo: ${role.repo}
2596
+ role: ${role.role}
2597
+ display_name: ${JSON.stringify(role.displayName || role.agentId)}
2598
+ project_path: ${ctxEscape(role.roleDir ? dirname6(dirname6(dirname6(role.roleDir))) : "")}
2599
+ role_dir: ${ctxEscape(role.roleDir)}
2600
+ profile_name: ${role.profileName || role.agentId}
2601
+ telegram:
2602
+ bot_username: ${ctxEscape(role.botHandle)}
2603
+ plane:
2604
+ workspace: ${ctxEscape(role.planeWorkspace)}
2605
+ project_id: ${ctxEscape(role.ticketProviderBoardId)}
2606
+ identifier: ${ctxEscape(role.ticketProviderIdentifier)}
2607
+ runtime_repo: ${ctxEscape(role.runtimeRepo)}
2608
+ systemd:
2609
+ gateway_unit: hermes-${role.agentId}-gateway.service
2610
+ consumer_unit: hermes-${role.agentId}-consumer.service
2611
+ heartbeat_timer: hermes-${role.agentId}-heartbeat.timer
2612
+ `;
2613
+ const next = current.includes("agents: {}") ? current.replace("agents: {}", `agents:
2614
+ ${block}`) : `${current.replace(/\s*$/, "\n")}${block}`;
2615
+ changedFiles.push(path);
2616
+ if (!dryRun) writeText(path, next);
2617
+ return path;
2618
+ }
2619
+ function profileMetaInheritsDefault(path) {
2620
+ const text3 = safeReadText(path);
2621
+ return Boolean(
2622
+ text3 && /^config:\s*$/m.test(text3) && /^\s+inherit_from:\s*default\s*$/m.test(text3) && /^\s+save_mode:\s*delta\s*$/m.test(text3)
2623
+ );
2624
+ }
2625
+ function upsertInheritedProfileMeta(path, changedFiles, dryRun) {
2626
+ const current = safeReadText(path) ?? "";
2627
+ const lines = current.split("\n");
2628
+ let next;
2629
+ const start = lines.findIndex((line) => /^config:\s*$/.test(line));
2630
+ if (!current.trim()) {
2631
+ next = "config:\n inherit_from: default\n save_mode: delta\n";
2632
+ } else if (start === -1) {
2633
+ next = `${current.replace(/\s*$/, "\n")}config:
2634
+ inherit_from: default
2635
+ save_mode: delta
2636
+ `;
2637
+ } else {
2638
+ let end = start + 1;
2639
+ while (end < lines.length && !/^[^#\s][^:]*:\s*/.test(lines[end] ?? "")) end++;
2640
+ let hasInherit = false;
2641
+ let hasSave = false;
2642
+ for (let idx = start + 1; idx < end; idx++) {
2643
+ if (/^\s+inherit_from:\s*/.test(lines[idx] ?? "")) {
2644
+ lines[idx] = " inherit_from: default";
2645
+ hasInherit = true;
2646
+ } else if (/^\s+save_mode:\s*/.test(lines[idx] ?? "")) {
2647
+ lines[idx] = " save_mode: delta";
2648
+ hasSave = true;
2649
+ }
2650
+ }
2651
+ const inserts = [];
2652
+ if (!hasInherit) inserts.push(" inherit_from: default");
2653
+ if (!hasSave) inserts.push(" save_mode: delta");
2654
+ if (inserts.length) lines.splice(end, 0, ...inserts);
2655
+ next = lines.join("\n");
2656
+ if (!next.endsWith("\n")) next += "\n";
2976
2657
  }
2977
- const civilWarLetterifier = "/home/delorenj/code/skillex/all-skills/civilwar-letterifier";
2978
- const hint = existsSync8(civilWarLetterifier) ? ` Did you mean ${civilWarLetterifier}?` : "";
2979
- throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
2658
+ if (next === current) return null;
2659
+ changedFiles.push(path);
2660
+ if (!dryRun) writeText(path, next);
2661
+ return path;
2980
2662
  }
2981
- function planProjectInit(input) {
2982
- if (!input.name.trim()) throw new Error("Project name is required");
2983
- const registryPath2 = resolve2(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
2984
- const registry = loadProjectRegistry(registryPath2);
2985
- const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
2986
- const slug = input.projectSlug ?? slugifyProjectName(input.name);
2987
- const targetDir = resolve2(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
2988
- const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
2989
- const existing = registry.projects[slug];
2990
- const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
2991
- const overwrite = input.overwrite ?? input.force ?? false;
2992
- const agentRole = normalizeAgentRole(input.agentRole);
2993
- const agents = input.provisionAgent ? {
2994
- ...existing?.agents ?? {},
2995
- [agentRole]: {
2996
- role: agentRole,
2997
- provisioning_state: "planned"
2663
+ function ctxEscape(value) {
2664
+ return JSON.stringify(value || "");
2665
+ }
2666
+ function checkUnit(unit) {
2667
+ const enabled = systemctlUser(["is-enabled", unit]).ok;
2668
+ const active = systemctlUser(["is-active", unit]).ok;
2669
+ return { enabled, active };
2670
+ }
2671
+ var RULES = [
2672
+ {
2673
+ id: "mise.config-root",
2674
+ title: "mise config_root + AGENTS link hooks",
2675
+ audit: (ctx) => {
2676
+ const misePath = join10(ctx.repoRoot, "mise.toml");
2677
+ if (!existsSync8(misePath)) {
2678
+ return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
2679
+ }
2680
+ const text3 = readText(misePath);
2681
+ const details = [];
2682
+ const linkAgentfilesPath = join10(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2683
+ if (!existsSync8(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2684
+ const pathValues = [...(text3.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2685
+ const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
2686
+ if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
2687
+ if (!text3.includes('"{{config_root}}/.mise/scripts/link-agentfiles.sh"')) details.push("link-agentfiles must use raw {{config_root}} guard");
2688
+ if (!text3.includes("op inject -i .env.op > .env")) details.push("[hooks].enter must materialize .env from .env.op");
2689
+ if (!text3.includes('patterns = ["AGENTS.md"]')) details.push("watch_files must monitor AGENTS.md");
2690
+ if (!text3.includes('task = "link-agentfiles"')) details.push("watch_files must dispatch link-agentfiles task");
2691
+ return {
2692
+ id: "mise.config-root",
2693
+ title: "mise config_root + AGENTS link hooks",
2694
+ status: details.length === 0 ? "pass" : "fail",
2695
+ summary: details.length === 0 ? "mise AGENTS-linking parity verified" : `${details.length} issue(s) detected in mise AGENTS-linking contract`,
2696
+ details,
2697
+ fixable: true
2698
+ };
2699
+ },
2700
+ migrate: (ctx, finding) => {
2701
+ const path = join10(ctx.repoRoot, "mise.toml");
2702
+ const changedFiles = [];
2703
+ const details = [];
2704
+ if (!existsSync8(path)) {
2705
+ if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2706
+ 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: [] };
2707
+ }
2708
+ details.push("Initialized mise.toml from generated-project template");
2709
+ if (ctx.dryRun) {
2710
+ return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
2711
+ }
2712
+ }
2713
+ let text3 = readText(path);
2714
+ const next = upsertLinkAgentfilesBlock(text3, ctx);
2715
+ if (next !== text3) {
2716
+ if (!changedFiles.includes(path)) changedFiles.push(path);
2717
+ if (!ctx.dryRun) writeText(path, next);
2718
+ text3 = next;
2719
+ }
2720
+ const linkAgentfilesPath = join10(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2721
+ const expectedScript = templateLinkAgentfilesScript(ctx);
2722
+ if (expectedScript === void 0) {
2723
+ 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: [] };
2724
+ }
2725
+ if (safeReadText(linkAgentfilesPath) !== expectedScript) {
2726
+ changedFiles.push(linkAgentfilesPath);
2727
+ if (!ctx.dryRun) {
2728
+ writeText(linkAgentfilesPath, expectedScript);
2729
+ chmodSync2(linkAgentfilesPath, 493);
2730
+ }
2731
+ }
2732
+ return {
2733
+ id: finding.id,
2734
+ title: finding.title,
2735
+ status: changedFiles.length ? "applied" : "noop",
2736
+ summary: changedFiles.length ? "Updated mise AGENTS-linking contract" : "No changes required",
2737
+ changedFiles,
2738
+ details: changedFiles.length ? ["Normalized hooks/watch_files/tasks.link-agentfiles block and script"] : []
2739
+ };
2998
2740
  }
2999
- } : existing?.agents ?? {};
3000
- const scaffold = input.scaffold ?? true;
3001
- const candidateProject = {
3002
- name: input.name,
3003
- slug,
3004
- repo_path: targetDir,
3005
- description: input.description ?? "",
3006
- status: "planned",
3007
- source_artifacts: sourceSkillPath ? [{ kind: "skill", path: sourceSkillPath, package_name: input.packageName ?? slug }] : [],
3008
- template: {
3009
- commonproject: {
3010
- enabled: true,
3011
- primary_language: input.primaryLanguage ?? "python"
2741
+ },
2742
+ {
2743
+ id: "mise.versioning",
2744
+ title: "managed mise versioning block",
2745
+ audit: (ctx) => {
2746
+ const details = [];
2747
+ const misePath = join10(ctx.repoRoot, "mise.toml");
2748
+ const versioningPath = join10(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2749
+ const manifestPath = join10(ctx.repoRoot, ".mise", "version-files.conf");
2750
+ const text3 = safeReadText(misePath);
2751
+ if (!text3?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
2752
+ if (!existsSync8(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
2753
+ if (!existsSync8(manifestPath)) details.push(".mise/version-files.conf missing");
2754
+ return {
2755
+ id: "mise.versioning",
2756
+ title: "managed mise versioning block",
2757
+ status: details.length === 0 ? "pass" : "fail",
2758
+ summary: details.length === 0 ? "mise versioning parity verified" : `${details.length} versioning issue(s) detected`,
2759
+ details,
2760
+ fixable: true
2761
+ };
2762
+ },
2763
+ migrate: (ctx, finding) => {
2764
+ const changedFiles = [];
2765
+ const details = [];
2766
+ const misePath = join10(ctx.repoRoot, "mise.toml");
2767
+ if (!existsSync8(misePath)) {
2768
+ if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2769
+ 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: [] };
2770
+ }
2771
+ details.push("Initialized mise.toml from generated-project template");
2772
+ if (ctx.dryRun) {
2773
+ return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
2774
+ }
2775
+ }
2776
+ const currentMise = readText(misePath);
2777
+ const nextMise = replaceOrAppendManagedBlock(currentMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
2778
+ if (nextMise !== currentMise) {
2779
+ if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
2780
+ if (!ctx.dryRun) writeText(misePath, nextMise);
2781
+ }
2782
+ const versioningPath = join10(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2783
+ const expectedScript = templateVersioningScript(ctx);
2784
+ if (expectedScript === void 0) {
2785
+ 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: [] };
2786
+ }
2787
+ if (safeReadText(versioningPath) !== expectedScript) {
2788
+ changedFiles.push(versioningPath);
2789
+ if (!ctx.dryRun) {
2790
+ writeText(versioningPath, expectedScript);
2791
+ chmodSync2(versioningPath, 493);
2792
+ }
2793
+ }
2794
+ const manifestPath = join10(ctx.repoRoot, ".mise", "version-files.conf");
2795
+ const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
2796
+ if (safeReadText(manifestPath) !== expectedManifest) {
2797
+ changedFiles.push(manifestPath);
2798
+ if (!ctx.dryRun) writeText(manifestPath, expectedManifest);
2799
+ }
2800
+ return {
2801
+ id: finding.id,
2802
+ title: finding.title,
2803
+ status: changedFiles.length ? "applied" : "noop",
2804
+ summary: changedFiles.length ? "Versioning block/script/manifest normalized" : "No changes required",
2805
+ changedFiles,
2806
+ details: []
2807
+ };
2808
+ }
2809
+ },
2810
+ {
2811
+ id: "sot.agent-symlinks",
2812
+ title: "AGENTS/CLAUDE/GEMINI symlink contract",
2813
+ audit: (ctx) => {
2814
+ const agentsPath = join10(ctx.repoRoot, "AGENTS.md");
2815
+ if (!existsSync8(agentsPath)) {
2816
+ const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync8(join10(ctx.repoRoot, file)));
2817
+ if (fallbackSources.length === 0) {
2818
+ return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
2819
+ }
2820
+ return {
2821
+ id: "sot.agent-symlinks",
2822
+ title: "AGENTS/CLAUDE/GEMINI symlink contract",
2823
+ status: "fail",
2824
+ summary: "AGENTS.md missing but can be derived from existing project documentation",
2825
+ details: [`AGENTS.md can be created from ${fallbackSources[0]}`],
2826
+ fixable: true
2827
+ };
2828
+ }
2829
+ const details = [];
2830
+ for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2831
+ const full = join10(ctx.repoRoot, file);
2832
+ const target = readSymlinkTarget(full);
2833
+ if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
2834
+ }
2835
+ return {
2836
+ id: "sot.agent-symlinks",
2837
+ title: "AGENTS/CLAUDE/GEMINI symlink contract",
2838
+ status: details.length === 0 ? "pass" : "fail",
2839
+ summary: details.length === 0 ? "Agent documentation symlinks are in parity" : `${details.length} symlink issue(s) detected`,
2840
+ details,
2841
+ fixable: true
2842
+ };
2843
+ },
2844
+ migrate: (ctx, finding) => {
2845
+ const changedFiles = [];
2846
+ const details = [];
2847
+ const blockedDetails = [];
2848
+ const bootstrap = bootstrapAgentsFile(ctx.repoRoot, ctx.dryRun);
2849
+ changedFiles.push(...bootstrap.changedFiles);
2850
+ details.push(...bootstrap.details);
2851
+ if (bootstrap.blocked) {
2852
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
2853
+ }
2854
+ for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2855
+ const full = join10(ctx.repoRoot, file);
2856
+ const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
2857
+ if (result.blocked) blockedDetails.push(result.blocked);
2858
+ if (result.changed) changedFiles.push(full);
2859
+ }
2860
+ return {
2861
+ id: finding.id,
2862
+ title: finding.title,
2863
+ status: blockedDetails.length ? "blocked" : changedFiles.length ? "applied" : "noop",
2864
+ summary: blockedDetails.length ? "One or more files could not be replaced safely" : changedFiles.length ? "Symlink contract repaired" : "No changes required",
2865
+ changedFiles,
2866
+ details: [...details, ...blockedDetails]
2867
+ };
2868
+ }
2869
+ },
2870
+ {
2871
+ id: "sot.project-json",
2872
+ title: "Canonical .project.json",
2873
+ audit: projectJsonFinding,
2874
+ migrate: (ctx, finding) => {
2875
+ const changedFiles = [];
2876
+ const details = [];
2877
+ const path = join10(ctx.repoRoot, ".project.json");
2878
+ const existing = readProjectJson(ctx) ?? {};
2879
+ const canonical = canonicalProjectJson(ctx);
2880
+ const merged = { ...existing, ...canonical };
2881
+ const expected = `${JSON.stringify(merged, null, 2)}
2882
+ `;
2883
+ if (safeReadText(path) !== expected) {
2884
+ changedFiles.push(path);
2885
+ if (!ctx.dryRun) writeText(path, expected);
2886
+ }
2887
+ const planeJson = join10(ctx.repoRoot, ".plane.json");
2888
+ if (existsSync8(planeJson)) {
2889
+ const backup = `${planeJson}.migrated-backup`;
2890
+ if (existsSync8(backup)) {
2891
+ details.push(`cannot back up .plane.json because ${relative(ctx.repoRoot, backup)} already exists`);
2892
+ } else {
2893
+ changedFiles.push(backup);
2894
+ if (!ctx.dryRun) renameSync2(planeJson, backup);
2895
+ }
2896
+ }
2897
+ return {
2898
+ id: finding.id,
2899
+ title: finding.title,
2900
+ status: details.length ? "blocked" : changedFiles.length ? "applied" : "noop",
2901
+ summary: details.length ? "Project SOT partially blocked" : changedFiles.length ? "Canonical .project.json written" : "No changes required",
2902
+ changedFiles,
2903
+ details
2904
+ };
2905
+ }
2906
+ },
2907
+ {
2908
+ id: "secrets.env-op",
2909
+ title: ".env.op + gitignore secrets contract",
2910
+ audit: (ctx) => {
2911
+ const details = [];
2912
+ const envOp = safeReadText(join10(ctx.repoRoot, ".env.op"));
2913
+ const gitignore = safeReadText(join10(ctx.repoRoot, ".gitignore"));
2914
+ if (!envOp) {
2915
+ details.push(".env.op missing");
2916
+ } else {
2917
+ const invalidLines = envOp.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).filter((line) => {
2918
+ const value = line.slice(line.indexOf("=") + 1).trim();
2919
+ const quotedLiteral = /^"[^"\r\n]*"$/.test(value) || /^'[^'\r\n]*'$/.test(value);
2920
+ return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value) && !quotedLiteral;
2921
+ });
2922
+ if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals: ${invalidLines.join(", ")}`);
2923
+ }
2924
+ if (!gitignore?.includes(".env\n") && !gitignore?.includes(".env\r\n")) details.push(".gitignore should ignore .env");
2925
+ if (!gitignore?.includes(".env.*")) details.push(".gitignore should ignore .env.*");
2926
+ if (!gitignore?.includes("!.env.op")) details.push(".gitignore should unignore .env.op");
2927
+ return {
2928
+ id: "secrets.env-op",
2929
+ title: ".env.op + gitignore secrets contract",
2930
+ status: details.length === 0 ? "pass" : "fail",
2931
+ summary: details.length === 0 ? "Secret reference file and ignore rules are in parity" : `${details.length} env parity issue(s) detected`,
2932
+ details,
2933
+ fixable: true
2934
+ };
2935
+ },
2936
+ migrate: (ctx, finding) => {
2937
+ const changedFiles = [];
2938
+ const details = [];
2939
+ const envOpPath = join10(ctx.repoRoot, ".env.op");
2940
+ if (!existsSync8(envOpPath)) {
2941
+ changedFiles.push(envOpPath);
2942
+ if (!ctx.dryRun) writeText(envOpPath, readText(join10(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
2943
+ }
2944
+ const gitignorePath = join10(ctx.repoRoot, ".gitignore");
2945
+ const gitignore = safeReadText(gitignorePath) ?? "";
2946
+ const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
2947
+ # NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
2948
+ .env
2949
+ .env.*
2950
+ !.env.op
2951
+ `;
2952
+ if (!gitignore.includes("!.env.op") || !gitignore.includes(".env.*")) {
2953
+ changedFiles.push(gitignorePath);
2954
+ if (!ctx.dryRun) writeText(gitignorePath, `${gitignore.replace(/\s*$/, "")}${gitignore.trim() ? "\n\n" : ""}${requiredBlock}`);
2955
+ }
2956
+ return {
2957
+ id: finding.id,
2958
+ title: finding.title,
2959
+ status: details.length ? "blocked" : changedFiles.length ? "applied" : "noop",
2960
+ summary: details.length ? "Manual cleanup still required" : changedFiles.length ? "Wrote .env.op/gitignore parity files" : "No changes required",
2961
+ changedFiles,
2962
+ details
2963
+ };
2964
+ }
2965
+ },
2966
+ {
2967
+ id: "provenance.copier",
2968
+ title: ".copier-answers.yml provenance + drift report",
2969
+ audit: (ctx) => {
2970
+ const details = [];
2971
+ const path = join10(ctx.repoRoot, ".copier-answers.yml");
2972
+ const text3 = safeReadText(path);
2973
+ const project = readProjectJson(ctx);
2974
+ if (!text3) {
2975
+ details.push(".copier-answers.yml missing");
2976
+ } else {
2977
+ if (!text3.startsWith("# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY")) details.push("missing Copier overwrite warning header");
2978
+ if (!text3.includes("_src_path:")) details.push("_src_path missing");
2979
+ if (project?.project_name) {
2980
+ const nameMatch = text3.match(/project_name:\s*(.+)/);
2981
+ if (!nameMatch || nameMatch[1]?.trim() !== String(project.project_name)) details.push("project_name drift between .copier-answers.yml and .project.json");
2982
+ }
2983
+ if (project?.project_description) {
2984
+ const descMatch = text3.match(/project_description:\s*([\s\S]*?)(?=\n\w|$)/);
2985
+ const yamlDesc = descMatch?.[1]?.replace(/\n\s+/g, " ").trim() ?? "";
2986
+ if (yamlDesc !== String(project.project_description)) details.push("project_description drift between .copier-answers.yml and .project.json");
2987
+ }
2988
+ }
2989
+ return {
2990
+ id: "provenance.copier",
2991
+ title: ".copier-answers.yml provenance + drift report",
2992
+ status: details.length === 0 ? "pass" : "fail",
2993
+ summary: details.length === 0 ? "Copier provenance is in parity" : `${details.length} provenance issue(s) detected`,
2994
+ details,
2995
+ fixable: true
2996
+ };
2997
+ },
2998
+ migrate: (ctx, finding) => {
2999
+ const changedFiles = [];
3000
+ const project = canonicalProjectJson(ctx);
3001
+ const text3 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
3002
+ _src_path: ${join10(ctx.pjanglerRoot, "templates", "commonproject")}
3003
+ project_description: ${String(project.project_description)}
3004
+ project_name: ${String(project.project_name)}
3005
+ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
3006
+ `;
3007
+ const path = join10(ctx.repoRoot, ".copier-answers.yml");
3008
+ if (safeReadText(path) !== text3) {
3009
+ changedFiles.push(path);
3010
+ if (!ctx.dryRun) writeText(path, text3);
3012
3011
  }
3012
+ return {
3013
+ id: finding.id,
3014
+ title: finding.title,
3015
+ status: changedFiles.length ? "applied" : "noop",
3016
+ summary: changedFiles.length ? "Copier provenance file refreshed" : "No changes required",
3017
+ changedFiles,
3018
+ details: []
3019
+ };
3020
+ }
3021
+ },
3022
+ {
3023
+ id: "bmad.scaffold",
3024
+ title: "BMAD modules/docs scaffold",
3025
+ audit: (ctx) => {
3026
+ const sourceRoot = join10(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
3027
+ const targetRoot = join10(ctx.repoRoot, "_bmad");
3028
+ const sentinels = [
3029
+ join10("core", "config.yaml"),
3030
+ join10("custom", "config.yaml"),
3031
+ join10("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
3032
+ join10("bmm", "workflows", "workflow-status", "workflow.yaml")
3033
+ ];
3034
+ const missing = sentinels.filter((file) => existsSync8(join10(sourceRoot, file)) && !existsSync8(join10(targetRoot, file)));
3035
+ return {
3036
+ id: "bmad.scaffold",
3037
+ title: "BMAD modules/docs scaffold",
3038
+ status: missing.length === 0 ? "pass" : "fail",
3039
+ summary: missing.length === 0 ? "BMAD scaffold parity verified" : `${missing.length} BMAD sentinel file(s) missing`,
3040
+ details: missing.map((file) => `_bmad/${file}`),
3041
+ fixable: true
3042
+ };
3013
3043
  },
3014
- ticket_provider: {
3015
- type: input.ticketProvider ?? "plane",
3016
- workspace: input.planeWorkspace ?? "33god",
3017
- identifier,
3018
- board_id: input.planeProjectId ?? "",
3019
- board_url: input.planeProjectId ? `https://plane.delo.sh/${input.planeWorkspace ?? "33god"}/projects/${input.planeProjectId}/issues/` : "",
3020
- state: input.live ? "planned" : "planned"
3021
- },
3022
- agents,
3023
- created_at: existing?.created_at ?? now,
3024
- updated_at: now
3025
- };
3026
- const project = {
3027
- ...candidateProject,
3028
- updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
3029
- };
3030
- validateNoDuplicateProject(registry, project, overwrite);
3031
- const pjanglerRoot = resolve2(input.pjanglerRoot ?? resolvePjanglerRoot2());
3032
- const manifest = projectManifestFromRegistryProject(project);
3033
- const apply = input.apply ?? false;
3034
- const live = input.live ?? false;
3035
- const actions = [
3036
- { kind: "registry.upsert", registryPath: registryPath2, slug, project }
3037
- ];
3038
- if (scaffold) {
3039
- actions.push(buildCommonProjectCopierAction({
3040
- pjanglerRoot,
3041
- targetDir,
3042
- projectName: project.name,
3043
- projectDescription: project.description,
3044
- projectSlug: project.slug,
3045
- ticketProvider: project.ticket_provider.type,
3046
- planeWorkspace: project.ticket_provider.workspace ?? "33god",
3047
- planeProjectId: project.ticket_provider.board_id ?? "",
3048
- projectIdentifier: identifier,
3049
- primaryLanguage: project.template.commonproject.primary_language,
3050
- overwrite
3051
- }));
3052
- }
3053
- actions.push(
3054
- { kind: "project.write-manifest", path: join10(targetDir, ".project.json"), manifest },
3055
- {
3056
- kind: "plane.create-or-link",
3057
- enabled: live,
3058
- live,
3059
- workspace: project.ticket_provider.workspace ?? "33god",
3060
- identifier,
3061
- state: live ? "planned" : "planned",
3062
- reason: live ? void 0 : "network/cloud actions require --live"
3044
+ migrate: (ctx, finding) => {
3045
+ const changedFiles = [];
3046
+ copyMissingRecursive(join10(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join10(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
3047
+ return {
3048
+ id: finding.id,
3049
+ title: finding.title,
3050
+ status: changedFiles.length ? "applied" : "noop",
3051
+ summary: changedFiles.length ? "Copied missing BMAD scaffold files" : "No changes required",
3052
+ changedFiles,
3053
+ details: []
3054
+ };
3055
+ }
3056
+ },
3057
+ {
3058
+ id: "hermes.pm-scaffold",
3059
+ title: "Hermes PM scaffold parity",
3060
+ audit: (ctx) => {
3061
+ const roles = discoverRoles(ctx.repoRoot);
3062
+ const role = roles.find((item) => item.role === "pm");
3063
+ if (!role) {
3064
+ return { id: "hermes.pm-scaffold", title: "Hermes PM scaffold parity", status: "skip", summary: "No pm role present", details: [], fixable: false };
3065
+ }
3066
+ const details = [];
3067
+ 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"]) {
3068
+ if (!existsSync8(join10(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join10(role.roleDir, rel))}`);
3069
+ }
3070
+ const gitmodules = safeReadText(join10(ctx.repoRoot, ".gitmodules")) ?? "";
3071
+ if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing pm runtime submodule entry");
3072
+ if (!profileMetaInheritsDefault(join10(role.roleDir, "runtime", "profile.yaml"))) {
3073
+ details.push("runtime/profile.yaml missing inherited default config metadata");
3074
+ }
3075
+ const registry = safeReadText(registryPath(ctx.homeDir));
3076
+ if (!registry?.includes(`${role.agentId}:`)) details.push(`fleet registry missing ${role.agentId}`);
3077
+ return {
3078
+ id: "hermes.pm-scaffold",
3079
+ title: "Hermes PM scaffold parity",
3080
+ status: details.length === 0 ? "pass" : "fail",
3081
+ summary: details.length === 0 ? "PM scaffold parity verified" : `${details.length} PM scaffold issue(s) detected`,
3082
+ details,
3083
+ fixable: true
3084
+ };
3063
3085
  },
3064
- {
3065
- kind: "hermes.provision-agent",
3066
- enabled: input.provisionAgent ?? false,
3067
- local: !live,
3068
- targetDir,
3069
- targetRepo: slug,
3070
- role: agentRole,
3071
- context: {
3072
- skipRuntimeRepo: !live,
3073
- skipPlane: !live,
3074
- skipBloodbank: !live,
3075
- skipSystemd: !live || process.platform === "darwin"
3086
+ migrate: (ctx, finding) => {
3087
+ const role = discoverRoles(ctx.repoRoot).find((item) => item.role === "pm");
3088
+ const changedFiles = [];
3089
+ const details = [];
3090
+ if (!role) {
3091
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
3092
+ }
3093
+ const templateRoleDir = join10(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
3094
+ writeIfDifferent(join10(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
3095
+ writeIfDifferent(join10(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
3096
+ writeIfDifferent(join10(role.roleDir, ".gitignore"), readText(join10(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
3097
+ copyMissingRecursive(join10(templateRoleDir, ".runtime-scaffold"), join10(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
3098
+ copyMissingRecursive(join10(templateRoleDir, ".runtime-scaffold"), join10(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
3099
+ copyMissingRecursive(join10(templateRoleDir, ".scripts"), join10(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
3100
+ const promptSource = join10(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
3101
+ const promptTarget = join10(role.roleDir, ".scripts", "sentinel.prompt.md");
3102
+ if (existsSync8(promptSource) && !existsSync8(promptTarget)) {
3103
+ 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);
3104
+ writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
3076
3105
  }
3106
+ upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
3107
+ const profileMetaUpdated = upsertInheritedProfileMeta(join10(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
3108
+ if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
3109
+ const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
3110
+ if (registryUpdated) details.push(`updated ${registryUpdated}`);
3111
+ return {
3112
+ id: finding.id,
3113
+ title: finding.title,
3114
+ status: changedFiles.length ? "applied" : "noop",
3115
+ summary: changedFiles.length ? "PM scaffold normalized" : "No changes required",
3116
+ changedFiles,
3117
+ details
3118
+ };
3077
3119
  }
3078
- );
3079
- return { ok: true, apply, dryRun: !apply, live, registryPath: registryPath2, project, manifest, actions };
3080
- }
3081
- function executeProjectInitPlan(plan) {
3082
- const logs = [];
3083
- const errors = [];
3084
- const changedFiles = [];
3085
- if (!plan.apply) return { ok: true, plan, logs, errors, changedFiles };
3086
- const registry = loadProjectRegistry(plan.registryPath);
3087
- let pendingRegistryAction;
3088
- for (const action of plan.actions) {
3089
- if (action.kind === "copier.copy.commonproject") {
3090
- mkdirSync6(dirname6(action.targetDir), { recursive: true });
3091
- const result = spawnSync5(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
3092
- if (result.stdout?.trim()) logs.push(result.stdout.trim());
3093
- if (result.stderr?.trim()) logs.push(result.stderr.trim());
3094
- if (result.error) {
3095
- const code = result.error.code;
3096
- errors.push(
3097
- code === "ENOENT" ? "copier not found on PATH. Install with: uv tool install copier or pip install copier" : `copier failed: ${result.error.message}`
3098
- );
3099
- break;
3120
+ },
3121
+ {
3122
+ id: "systemd.sentinel",
3123
+ title: "Hermes systemd/sentinel units enabled + active",
3124
+ audit: (ctx) => {
3125
+ const roles = discoverRoles(ctx.repoRoot);
3126
+ if (!roles.length) {
3127
+ return { id: "systemd.sentinel", title: "Hermes systemd/sentinel units enabled + active", status: "skip", summary: "No Hermes roles present", details: [], fixable: false };
3100
3128
  }
3101
- if (result.status !== 0) {
3102
- errors.push(`copier exited with status ${result.status ?? "unknown"}`);
3103
- if (existsSync8(action.targetDir)) changedFiles.push(action.targetDir);
3104
- break;
3129
+ const probe = systemctlUser(["is-system-running"]);
3130
+ if (!probe.ok && !/running|degraded|starting|maintenance/.test(`${probe.stdout} ${probe.stderr}`)) {
3131
+ 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 };
3105
3132
  }
3106
- changedFiles.push(action.targetDir);
3107
- } else if (action.kind === "project.write-manifest") {
3108
- mkdirSync6(dirname6(action.path), { recursive: true });
3109
- const next = `${JSON.stringify(action.manifest, null, 2)}
3110
- `;
3111
- const current = existsSync8(action.path) ? readFileSync4(action.path, "utf8") : void 0;
3112
- if (current !== next) {
3113
- writeFileSync5(action.path, next, "utf8");
3114
- changedFiles.push(action.path);
3133
+ const details = [];
3134
+ for (const role of roles) {
3135
+ for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
3136
+ const state = checkUnit(unit);
3137
+ if (!state.enabled || !state.active) details.push(`${unit} should be enabled+active`);
3138
+ }
3115
3139
  }
3116
- } else if (action.kind === "registry.upsert") {
3117
- pendingRegistryAction = action;
3118
- } else if (action.kind === "plane.create-or-link") {
3119
- logs.push(action.enabled ? "plane.create-or-link requires a live provider integration" : "plane.create-or-link skipped (requires --live)");
3120
- } else if (action.kind === "hermes.provision-agent") {
3121
- logs.push(action.enabled ? "hermes.provision-agent planned for the caller to execute" : "hermes.provision-agent skipped");
3122
- }
3123
- }
3124
- if (pendingRegistryAction && errors.length === 0) {
3125
- if (!projectRecordEquivalent(registry.projects[pendingRegistryAction.slug], pendingRegistryAction.project)) {
3126
- registry.projects[pendingRegistryAction.slug] = pendingRegistryAction.project;
3127
- saveProjectRegistry(registry, pendingRegistryAction.registryPath);
3128
- changedFiles.push(pendingRegistryAction.registryPath);
3129
- }
3130
- }
3131
- return { ok: errors.length === 0, plan, logs, errors, changedFiles };
3132
- }
3133
- function projectManifestFromRegistryProject(project) {
3134
- const agents = Object.fromEntries(
3135
- Object.entries(project.agents).map(([name, agent]) => [
3136
- `${project.slug}-${name}`,
3137
- {
3138
- role: agent.role,
3139
- role_dir: agent.role_dir,
3140
- provisioning_state: agent.provisioning_state
3140
+ return {
3141
+ id: "systemd.sentinel",
3142
+ title: "Hermes systemd/sentinel units enabled + active",
3143
+ status: details.length === 0 ? "pass" : "fail",
3144
+ summary: details.length === 0 ? "Hermes user units are enabled and active" : `${details.length} systemd parity issue(s) detected`,
3145
+ details,
3146
+ fixable: true
3147
+ };
3148
+ },
3149
+ migrate: (ctx, finding) => {
3150
+ const roles = discoverRoles(ctx.repoRoot);
3151
+ const changedFiles = [];
3152
+ const details = [];
3153
+ if (!roles.length) {
3154
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "No Hermes roles present", changedFiles, details };
3155
+ }
3156
+ const probe = systemctlUser(["is-system-running"]);
3157
+ if (!probe.ok && !/running|degraded|starting|maintenance/.test(`${probe.stdout} ${probe.stderr}`)) {
3158
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
3159
+ }
3160
+ for (const role of roles) {
3161
+ const sysDir = join10(ctx.homeDir, ".config", "systemd", "user");
3162
+ const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
3163
+ const allUnitsPresent = units.every((unit) => existsSync8(join10(sysDir, unit)));
3164
+ if (allUnitsPresent) {
3165
+ if (ctx.dryRun) {
3166
+ details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
3167
+ } else {
3168
+ systemctlUser(["daemon-reload"]);
3169
+ for (const unit of units) {
3170
+ systemctlUser(["enable", "--now", unit]);
3171
+ }
3172
+ }
3173
+ continue;
3174
+ }
3175
+ for (const script of [join10(role.roleDir, ".scripts", "70-systemd.sh")]) {
3176
+ if (!script || !existsSync8(script)) continue;
3177
+ if (ctx.dryRun) {
3178
+ details.push(`would run: bash ${script}`);
3179
+ } else {
3180
+ const result = spawnSync5("bash", [script], { cwd: role.roleDir, encoding: "utf8" });
3181
+ if (result.status !== 0) details.push(`script failed: ${script}: ${result.stderr.trim() || result.stdout.trim()}`);
3182
+ }
3183
+ }
3141
3184
  }
3142
- ])
3143
- );
3144
- return {
3145
- project_name: project.name,
3146
- project_description: project.description,
3147
- project_slug: project.slug,
3148
- repo_path: project.repo_path,
3149
- ticket_provider: {
3150
- type: project.ticket_provider.type,
3151
- workspace: project.ticket_provider.workspace ?? "",
3152
- identifier: project.ticket_provider.identifier ?? "",
3153
- board_id: project.ticket_provider.board_id ?? "",
3154
- board_url: project.ticket_provider.board_url ?? "",
3155
- state: project.ticket_provider.state
3156
- },
3157
- agents
3158
- };
3159
- }
3160
- function formatProjectInitPlan(plan) {
3161
- const lines = [""];
3162
- const title = `${bold(plan.project.name)} ${dim(`(${plan.project.slug})`)}`;
3163
- lines.push(` ${cyan(bold(glyph.chevron))} ${title}${plan.dryRun ? ` ${dim(glyph.dot)} ${yellow("dry run")}` : ""}`);
3164
- lines.push(` ${dim("registry".padEnd(8))} ${dim(plan.registryPath)}`);
3165
- lines.push(` ${dim("target".padEnd(8))} ${dim(plan.project.repo_path)}`);
3166
- lines.push("");
3167
- lines.push(` ${bold("Actions")} ${dim(`(${plan.actions.length})`)}`);
3168
- if (!plan.actions.length) lines.push(` ${dim("(nothing to do)")}`);
3169
- for (const action of plan.actions) {
3170
- lines.push(` ${cyan(glyph.bullet)} ${action.kind}`);
3171
- if (action.kind === "copier.copy.commonproject") lines.push(` ${dim(`target: ${action.targetDir}`)}`);
3172
- if (action.kind === "project.write-manifest") lines.push(` ${dim(`path: ${action.path}`)}`);
3173
- if (action.kind === "plane.create-or-link" && action.reason) lines.push(` ${dim(`note: ${action.reason}`)}`);
3185
+ return {
3186
+ id: finding.id,
3187
+ title: finding.title,
3188
+ status: details.some((detail) => detail.includes("failed:")) ? "blocked" : details.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
3189
+ summary: details.length ? ctx.dryRun ? "Planned systemd remediation commands" : "Attempted systemd remediation" : "No changes required",
3190
+ changedFiles,
3191
+ details
3192
+ };
3193
+ }
3174
3194
  }
3175
- lines.push("");
3176
- return lines.join("\n");
3177
- }
3178
- function formatProjectList(registry) {
3179
- const projects = Object.values(registry.projects).sort((a, b) => a.slug.localeCompare(b.slug));
3180
- if (!projects.length) return `
3181
- ${dim("No projects registered.")}
3195
+ ];
3196
+ function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
3197
+ const normalized = content.endsWith("\n") ? content : `${content}
3182
3198
  `;
3183
- const slugWidth = projects.reduce((width, project) => Math.max(width, project.slug.length), 0);
3184
- const idWidth = projects.reduce((width, project) => Math.max(width, String(project.ticket_provider.identifier ?? "").length), 0);
3185
- const statusWidth = projects.reduce((width, project) => Math.max(width, project.status.length), 0);
3186
- const lines = ["", ` ${bold("Projects")} ${dim(`(${projects.length})`)}`, ""];
3187
- for (const project of projects) {
3188
- const slug = bold(project.slug.padEnd(slugWidth));
3189
- const identifier = cyan(String(project.ticket_provider.identifier ?? "").padEnd(idWidth));
3190
- const status = projectStatusColor(project.status)(project.status.padEnd(statusWidth));
3191
- lines.push(` ${slug} ${identifier} ${status} ${dim(project.repo_path)}`);
3199
+ if (safeReadText(path) === normalized) return;
3200
+ changedFiles.push(path);
3201
+ if (!dryRun) {
3202
+ writeText(path, normalized);
3203
+ if (mode) chmodSync2(path, mode);
3192
3204
  }
3193
- lines.push("");
3194
- return lines.join("\n");
3195
3205
  }
3196
- function getProject(registry, slug) {
3197
- const project = registry.projects[slug];
3198
- if (!project) throw new Error(`Project not found in registry: ${slug}`);
3199
- return project;
3206
+ function getParityRuleIds() {
3207
+ return RULES.map((rule) => rule.id);
3200
3208
  }
3201
- function doctorProjectRegistry(registryPath2 = projectRegistryPath(), slug) {
3202
- const issues = [];
3203
- const registry = loadProjectRegistry(registryPath2);
3204
- const projects = slug ? [[slug, getProject(registry, slug)]] : Object.entries(registry.projects);
3205
- for (const [projectSlug, project] of projects) {
3206
- if (!existsSync8(project.repo_path)) {
3207
- issues.push({ level: "warn", slug: projectSlug, message: `repo_path does not exist: ${project.repo_path}` });
3208
- } else if (!statSync(project.repo_path).isDirectory()) {
3209
- issues.push({ level: "error", slug: projectSlug, message: `repo_path is not a directory: ${project.repo_path}` });
3210
- } else {
3211
- const manifestPath = join10(project.repo_path, ".project.json");
3212
- if (!existsSync8(manifestPath)) issues.push({ level: "warn", slug: projectSlug, message: ".project.json is missing" });
3213
- }
3214
- for (const artifact of project.source_artifacts) {
3215
- if (artifact.path && !existsSync8(artifact.path)) {
3216
- issues.push({ level: "warn", slug: projectSlug, message: `source artifact missing: ${artifact.path}` });
3217
- }
3218
- }
3219
- }
3209
+ function runAudit(repoArg) {
3210
+ const pjanglerRoot = resolvePjanglerRoot2();
3211
+ const ctx = {
3212
+ repoRoot: resolve2(repoArg ?? process.cwd()),
3213
+ dryRun: true,
3214
+ pjanglerRoot,
3215
+ homeDir: homedir5()
3216
+ };
3217
+ const rules = RULES.map((rule) => rule.audit(ctx));
3220
3218
  return {
3221
- ok: !issues.some((issue) => issue.level === "error"),
3222
- registryPath: registryPath2,
3223
- checkedProjects: projects.map(([projectSlug]) => projectSlug),
3224
- issues
3219
+ repo: ctx.repoRoot,
3220
+ ok: rules.every((rule) => rule.status === "pass" || rule.status === "skip"),
3221
+ auditedAt: (/* @__PURE__ */ new Date()).toISOString(),
3222
+ rules
3225
3223
  };
3226
3224
  }
3227
- function buildCommonProjectCopierAction(input) {
3228
- const templateDir = join10(input.pjanglerRoot, "templates", "commonproject");
3229
- const data = {
3230
- project_name: input.projectName,
3231
- project_description: input.projectDescription ?? "",
3232
- project_slug: input.projectSlug,
3233
- ticket_provider: input.ticketProvider,
3234
- plane_workspace: input.planeWorkspace,
3235
- plane_project_id: input.planeProjectId ?? "",
3236
- project_identifier: input.projectIdentifier,
3237
- primary_language: input.primaryLanguage
3225
+ function runMigrationForRules(ruleIds, repoArg, dryRun) {
3226
+ const pjanglerRoot = resolvePjanglerRoot2();
3227
+ const ctx = {
3228
+ repoRoot: resolve2(repoArg ?? process.cwd()),
3229
+ dryRun,
3230
+ pjanglerRoot,
3231
+ homeDir: homedir5()
3238
3232
  };
3239
- const command = ["copier", "copy", "--trust", templateDir, input.targetDir, "--defaults"];
3240
- for (const [key, value] of Object.entries(data)) command.push("--data", `${key}=${value}`);
3241
- if (input.overwrite) command.push("--overwrite");
3233
+ const selected = RULES.filter((rule) => ruleIds.includes(rule.id));
3234
+ if (!selected.length) {
3235
+ throw new Error(`Unknown parity rules: ${ruleIds.join(", ")}`);
3236
+ }
3237
+ const results = selected.map((rule) => {
3238
+ try {
3239
+ return rule.migrate(ctx, rule.audit(ctx));
3240
+ } catch (err) {
3241
+ return {
3242
+ id: rule.id,
3243
+ title: rule.title,
3244
+ status: "blocked",
3245
+ summary: `migrate threw: ${err instanceof Error ? err.message : String(err)}`,
3246
+ changedFiles: [],
3247
+ details: []
3248
+ };
3249
+ }
3250
+ });
3251
+ const changedFiles = Array.from(new Set(results.flatMap((result) => result.changedFiles))).sort();
3242
3252
  return {
3243
- kind: "copier.copy.commonproject",
3244
- cwd: input.pjanglerRoot,
3245
- command,
3246
- targetDir: input.targetDir,
3247
- data,
3248
- overwrite: input.overwrite
3253
+ repo: ctx.repoRoot,
3254
+ dryRun,
3255
+ ok: results.every((result) => result.status !== "blocked"),
3256
+ selectedRules: selected.map((rule) => rule.id),
3257
+ results,
3258
+ changedFiles
3249
3259
  };
3250
3260
  }
3251
- function resolvePjanglerRoot2() {
3252
- let dir = dirname6(new URL(import.meta.url).pathname);
3253
- while (dir !== dirname6(dir)) {
3254
- if (existsSync8(join10(dir, "package.json")) && existsSync8(join10(dir, "templates", "commonproject", "copier.yml"))) return dir;
3255
- dir = dirname6(dir);
3261
+ function runMigration(selector, repoArg, dryRun, all) {
3262
+ const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
3263
+ return runMigrationForRules(ruleIds, repoArg, dryRun);
3264
+ }
3265
+ function prettyTimestamp(iso) {
3266
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/.exec(iso);
3267
+ return match ? `${match[1]} ${match[2]} UTC` : iso;
3268
+ }
3269
+ function formatAuditReport(report) {
3270
+ const counts = {};
3271
+ for (const rule of report.rules) counts[rule.status] = (counts[rule.status] ?? 0) + 1;
3272
+ const idWidth = report.rules.reduce((width, rule) => Math.max(width, rule.id.length), 0);
3273
+ const tally = [];
3274
+ if (counts.pass) tally.push(green(`${counts.pass} passed`));
3275
+ if (counts.fail) tally.push(red(`${counts.fail} failed`));
3276
+ if (counts.warn) tally.push(yellow(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`));
3277
+ if (counts.skip) tally.push(gray(`${counts.skip} skipped`));
3278
+ const overall = report.ok ? `${green(glyph.pass)} ${bold("Parity audit passed")}` : `${red(glyph.fail)} ${bold("Parity audit failed")}`;
3279
+ const lines = [""];
3280
+ lines.push(` ${overall}${tally.length ? ` ${dim(glyph.dot)} ${joinDot(tally)}` : ""}`);
3281
+ lines.push(` ${dim(report.repo)} ${dim(glyph.dot)} ${dim(prettyTimestamp(report.auditedAt))}`);
3282
+ lines.push("");
3283
+ for (const rule of report.rules) {
3284
+ const style = statusStyle(rule.status);
3285
+ lines.push(` ${style.color(style.glyph)} ${style.color(rule.id.padEnd(idWidth))} ${rule.summary}`);
3286
+ for (const detail of rule.details) lines.push(` ${dim(glyph.arrow)} ${dim(detail)}`);
3256
3287
  }
3257
- return resolve2(process.cwd());
3288
+ lines.push("");
3289
+ return lines.join("\n");
3258
3290
  }
3259
- function validateNoDuplicateProject(registry, project, overwrite) {
3260
- const existingSameSlug = registry.projects[project.slug];
3261
- if (existingSameSlug && !overwrite && resolve2(existingSameSlug.repo_path) !== resolve2(project.repo_path)) {
3262
- throw new Error(`Project slug already exists in registry: ${project.slug}`);
3291
+ function formatMigrationReport(report) {
3292
+ const idWidth = report.results.reduce((width, result) => Math.max(width, result.id.length), 0);
3293
+ const overall = report.ok ? `${green(glyph.pass)} ${bold(report.dryRun ? "Migration preview complete" : "Migration complete")}` : `${red(glyph.fail)} ${bold("Migration finished with blockers")}`;
3294
+ const lines = [""];
3295
+ lines.push(` ${overall}${report.dryRun ? ` ${dim(glyph.dot)} ${yellow("dry run")}` : ""}`);
3296
+ lines.push(` ${dim(report.repo)}`);
3297
+ if (report.selectedRules.length) lines.push(` ${dim(`rules: ${report.selectedRules.join(", ")}`)}`);
3298
+ lines.push("");
3299
+ for (const result of report.results) {
3300
+ const style = statusStyle(result.status);
3301
+ lines.push(` ${style.color(style.glyph)} ${style.color(result.id.padEnd(idWidth))} ${result.summary} ${dim(`[${style.label}]`)}`);
3302
+ for (const detail of result.details) lines.push(` ${dim(glyph.arrow)} ${dim(detail)}`);
3303
+ for (const file of result.changedFiles) lines.push(` ${green(glyph.add)} ${file}`);
3263
3304
  }
3264
- for (const [slug, existing] of Object.entries(registry.projects)) {
3265
- if (slug === project.slug) continue;
3266
- if (resolve2(existing.repo_path) === resolve2(project.repo_path)) {
3267
- throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
3268
- }
3269
- if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
3270
- throw new Error(`Project identifier already registered by ${slug}: ${project.ticket_provider.identifier}`);
3271
- }
3305
+ if (report.changedFiles.length) {
3306
+ lines.push("");
3307
+ lines.push(` ${bold(`Changed files (${report.changedFiles.length})`)}`);
3308
+ for (const file of report.changedFiles) lines.push(` ${green(glyph.add)} ${file}`);
3272
3309
  }
3273
- }
3274
- function validateProjectRecord(project, key) {
3275
- if (!isRecord(project)) throw new Error(`Project ${key} must be a mapping`);
3276
- if (!project.name) throw new Error(`Project ${key} missing name`);
3277
- if (!project.slug) throw new Error(`Project ${key} missing slug`);
3278
- if (project.slug !== key) throw new Error(`Project key ${key} does not match slug ${project.slug}`);
3279
- if (!project.repo_path) throw new Error(`Project ${key} missing repo_path`);
3280
- if (!Array.isArray(project.source_artifacts)) throw new Error(`Project ${key} source_artifacts must be a list`);
3281
- if (!isRecord(project.ticket_provider)) throw new Error(`Project ${key} ticket_provider must be a mapping`);
3282
- if (!isRecord(project.agents)) throw new Error(`Project ${key} agents must be a mapping`);
3283
- }
3284
- function expandHome(path) {
3285
- if (path === "~") return homedir5();
3286
- if (path.startsWith("~/")) return join10(homedir5(), path.slice(2));
3287
- return path;
3288
- }
3289
- function isRecord(value) {
3290
- return typeof value === "object" && value !== null && !Array.isArray(value);
3310
+ lines.push("");
3311
+ return lines.join("\n");
3291
3312
  }
3292
3313
 
3293
3314
  // src/utils/version.ts
@@ -3498,26 +3519,39 @@ async function resolveProjectInitTarget(name, options) {
3498
3519
  identifier: options.identifier ?? defaults.identifier
3499
3520
  };
3500
3521
  }
3501
- var program = new Command3();
3502
- program.name("pjangler").description("Project subsystem bootstrapper CLI").version(PJANGLER_VERSION);
3503
- program.command("init").argument("<subsystem>", "Subsystem to initialize").description("Initialize a project subsystem").option("--dry-run", "Preview changes without writing files").option("-f, --force", "Overwrite existing files").action(async (subsystem, options) => {
3522
+ async function runRecipeSubsystem(name, options) {
3504
3523
  const context = {
3505
3524
  targetDir: process.cwd(),
3506
3525
  force: options.force || false,
3507
3526
  dryRun: options.dryRun || false
3508
3527
  };
3509
3528
  try {
3510
- const recipe = createRecipe(subsystem, context);
3529
+ const recipe = createRecipe(name, context);
3511
3530
  if (!recipe) {
3512
- console.error(`${xmark} Unknown subsystem: ${bold(subsystem)}`);
3531
+ console.error(`${xmark} Unknown subsystem: ${bold(name)}`);
3513
3532
  console.error(` ${dim("Available:")} ${getRecipeNames().map((available) => cyan(available)).join(dim(", "))}`);
3514
3533
  process.exit(1);
3515
3534
  }
3516
3535
  await recipe.execute();
3517
3536
  } catch (error) {
3518
- console.error(`${xmark} Error initializing ${bold(subsystem)}:`, error);
3537
+ console.error(`${xmark} Error scaffolding ${bold(name)}:`, error);
3519
3538
  process.exit(1);
3520
3539
  }
3540
+ }
3541
+ var program = new Command3();
3542
+ program.name("pjangler").description("Project subsystem bootstrapper CLI").version(PJANGLER_VERSION);
3543
+ program.command("init").argument("[name]", "Project name to bootstrap (omit inside an existing git repo)").description("Bootstrap a project: registry entry + CommonProject scaffold + .project.json").option("--description <text>", "Project description").option("--target-dir <path>", "Target repo path").option("--source-skill <path>", "Source skill/template provenance path").option("--primary-language <language>", "Primary language for CommonProject rendering", "python").option("--provision-agent", "Plan local Hermes PM agent provisioning").option("--agent-role <role>", "Hermes agent role to plan when --provision-agent is set", "pm").option("--apply", "Write the registry and render the repo scaffold").option("--dry-run", "Preview changes without writing files (default)").option("--live", "Allow live/network/cloud provisioning actions").option("--slug <slug>", "Project registry slug override").option("--identifier <identifier>", "Ticket identifier override").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("-f, --force", "Allow replacing an existing registry entry and re-rendering files").option("-y, --yes", "Apply every proposed operation without prompting").option("--no-tui", "Disable interactive prompts").option("--json", "Output machine-parseable JSON").action(async (name, options) => {
3544
+ if (name && getRecipeNames().includes(name)) {
3545
+ if (!options.json) {
3546
+ console.error(`${yellow(glyph.warn)} ${dim(`"pjangler init ${name}" is deprecated \u2014 use "pjangler add ${name}". Forwarding\u2026`)}`);
3547
+ }
3548
+ await runRecipeSubsystem(name, { force: options.force, dryRun: options.dryRun });
3549
+ return;
3550
+ }
3551
+ await runProjectInit(name, options);
3552
+ });
3553
+ program.command("add").argument("<subsystem>", "Subsystem to scaffold (mise, docker, node, agent-hooks, \u2026)").description("Scaffold a subsystem/component into the current repo").option("--dry-run", "Preview changes without writing files").option("-f, --force", "Overwrite existing files").action(async (subsystem, options) => {
3554
+ await runRecipeSubsystem(subsystem, options);
3521
3555
  });
3522
3556
  program.command("list").description("List available subsystems").action(() => {
3523
3557
  const width = Object.keys(RECIPE_REGISTRY).reduce((max, name) => Math.max(max, name.length), 0);
@@ -3529,13 +3563,17 @@ program.command("list").description("List available subsystems").action(() => {
3529
3563
  }
3530
3564
  console.log("");
3531
3565
  console.log(` ${dim("Examples")}`);
3532
- for (const example of ["pj init mise", "pj init docker", "pj init node"]) {
3566
+ for (const example of ["pj add mise", "pj add docker", "pj add node"]) {
3533
3567
  console.log(` ${dim(glyph.pointer)} ${dim(example)}`);
3534
3568
  }
3535
3569
  console.log("");
3536
3570
  });
3537
3571
  var projectCmd = program.command("project").description("Manage the pjangler project registry");
3538
- projectCmd.command("init").argument("[name]", "Project display name").description("Plan or apply a registry-backed CommonProject initialization or legacy repo sync").option("--description <text>", "Project description").option("--target-dir <path>", "Target repo path").option("--source-skill <path>", "Source skill/template provenance path").option("--primary-language <language>", "Primary language for CommonProject rendering", "python").option("--provision-agent", "Plan local Hermes PM agent provisioning").option("--agent-role <role>", "Hermes agent role to plan when --provision-agent is set", "pm").option("--apply", "Write the registry and render the repo scaffold").option("--dry-run", "Preview changes without writing files (default)").option("--live", "Allow live/network/cloud provisioning actions").option("--slug <slug>", "Project registry slug override").option("--identifier <identifier>", "Ticket identifier override").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("-f, --force", "Allow replacing an existing registry entry and re-rendering files").option("-y, --yes", "Apply every proposed operation without prompting").option("--no-tui", "Disable interactive prompts").option("--json", "Output machine-parseable JSON").action(async (name, options) => {
3572
+ projectCmd.command("init").argument("[name]", "Project display name").description("Plan or apply a registry-backed CommonProject initialization or legacy repo sync").option("--description <text>", "Project description").option("--target-dir <path>", "Target repo path").option("--source-skill <path>", "Source skill/template provenance path").option("--primary-language <language>", "Primary language for CommonProject rendering", "python").option("--provision-agent", "Plan local Hermes PM agent provisioning").option("--agent-role <role>", "Hermes agent role to plan when --provision-agent is set", "pm").option("--apply", "Write the registry and render the repo scaffold").option("--dry-run", "Preview changes without writing files (default)").option("--live", "Allow live/network/cloud provisioning actions").option("--slug <slug>", "Project registry slug override").option("--identifier <identifier>", "Ticket identifier override").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("-f, --force", "Allow replacing an existing registry entry and re-rendering files").option("-y, --yes", "Apply every proposed operation without prompting").option("--no-tui", "Disable interactive prompts").option("--json", "Output machine-parseable JSON").action((name, options) => {
3573
+ if (!options.json) console.error(`${yellow(glyph.warn)} ${dim('"pjangler project init" is deprecated \u2014 use "pjangler init".')}`);
3574
+ return runProjectInit(name, options);
3575
+ });
3576
+ async function runProjectInit(name, options) {
3539
3577
  try {
3540
3578
  const target = await resolveProjectInitTarget(name, options);
3541
3579
  const interactive = isInteractiveProjectInit(options);
@@ -3635,7 +3673,7 @@ projectCmd.command("init").argument("[name]", "Project display name").descriptio
3635
3673
  }
3636
3674
  process.exit(1);
3637
3675
  }
3638
- });
3676
+ }
3639
3677
  projectCmd.command("list").description("List projects in the pjangler registry").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("--json", "Output machine-parseable JSON").action((options) => {
3640
3678
  try {
3641
3679
  const registry = loadProjectRegistry(options.registry ?? projectRegistryPath());
@@ -3720,27 +3758,11 @@ recipeCmd.command("describe").argument("<name>", "Recipe name").description("Sho
3720
3758
  console.log("");
3721
3759
  console.log(` ${dim("Usage")}`);
3722
3760
  console.log(` ${dim(glyph.pointer)} ${dim(`pj recipe run ${name}`)}`);
3723
- console.log(` ${dim(glyph.pointer)} ${dim(`pj init ${name}`)}`);
3761
+ console.log(` ${dim(glyph.pointer)} ${dim(`pj add ${name}`)}`);
3724
3762
  console.log("");
3725
3763
  });
3726
3764
  recipeCmd.command("run").argument("<name>", "Recipe name").description("Execute a specific recipe").option("--dry-run", "Preview changes without writing files").option("-f, --force", "Overwrite existing files").action(async (name, options) => {
3727
- const context = {
3728
- targetDir: process.cwd(),
3729
- force: options.force || false,
3730
- dryRun: options.dryRun || false
3731
- };
3732
- try {
3733
- const recipe = createRecipe(name, context);
3734
- if (!recipe) {
3735
- console.error(`${xmark} Recipe not found: ${bold(name)}`);
3736
- console.error(` ${dim("Available:")} ${getRecipeNames().map((available) => cyan(available)).join(dim(", "))}`);
3737
- process.exit(1);
3738
- }
3739
- await recipe.execute();
3740
- } catch (error) {
3741
- console.error(`${xmark} Error running recipe ${bold(name)}:`, error);
3742
- process.exit(1);
3743
- }
3765
+ await runRecipeSubsystem(name, options);
3744
3766
  });
3745
3767
  var commandCmd = program.command("command").alias("cmd").description("Manage pjangler commands");
3746
3768
  commandCmd.command("list").description("List all available commands").option("-g, --group", "Group commands by category").action((options) => {