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