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