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