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