@delorenj/pjangler 1.1.5 → 1.2.2
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/.mise/scripts/link-agentfiles.sh +9 -0
- package/dist/index.js +1492 -358
- package/dist/mcp-server.js +2391 -290
- package/package.json +5 -2
- package/templates/commonproject/.mise/scripts/create-plane-project.sh +26 -0
- package/templates/commonproject/copier.yml +2 -0
- package/templates/commonproject/template/.agents/hooks/README.md +151 -0
- package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +63 -0
- package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-journal-write.sh +327 -0
- package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-recall.sh +180 -0
- package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-retain.sh +74 -0
- package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-session-end.sh +57 -0
- package/templates/commonproject/template/.agents/hooks/hindsight/lib/hindsight-bank.sh +57 -0
- package/templates/commonproject/template/.agents/hooks/hindsight/lib/hindsight-journal.sh +48 -0
- package/templates/commonproject/template/.agents/hooks/hooks.master.json +97 -0
- package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +30 -0
- package/templates/commonproject/template/.agents/hooks/lib/local-config.sh +51 -0
- package/templates/commonproject/template/.agents/hooks/reminder-for-skill-check.sh +20 -0
- package/templates/commonproject/template/.agents/hooks/sync.py +618 -0
- package/templates/commonproject/template/.agents/local.example.json +15 -0
- package/templates/commonproject/template/.mise/scripts/hindsight-setup.sh +62 -0
- package/templates/commonproject/template/.mise/scripts/link-project-skills-to-clis.sh +110 -0
- package/templates/commonproject/template/.mise/scripts/unlink-project-skills-from-clis.sh +45 -0
- package/templates/commonproject/template/mise.toml.jinja +42 -0
- package/templates/hermes-agent/.codegraph/daemon.pid +6 -0
- package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +10 -0
- package/templates/hermes-agent/README.md +16 -14
- package/templates/hermes-agent/copier.yml +7 -25
- package/templates/hermes-agent/docs/architecture.md +16 -18
- package/templates/hermes-agent/docs/fleet-control-plane/README.md +35 -0
- package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +253 -0
- package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +280 -0
- package/templates/hermes-agent/docs/fleet-control-plane/implementation-readiness-report-2026-06-27.md +118 -0
- package/templates/hermes-agent/docs/fleet-control-plane/prd.md +141 -0
- package/templates/hermes-agent/docs/operations.md +17 -19
- package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +17 -13
- package/templates/hermes-agent/docs/{scrum-master → sentinel}/README.md +47 -46
- package/templates/hermes-agent/docs/{scrum-master → sentinel}/architecture.md +39 -38
- package/templates/hermes-agent/docs/{scrum-master → sentinel}/development.md +63 -99
- package/templates/hermes-agent/docs/{scrum-master → sentinel}/providers.md +2 -2
- package/templates/hermes-agent/docs/sentinel.md +18 -0
- package/templates/hermes-agent/install-local.sh +22 -20
- package/templates/hermes-agent/runtime-scaffold/README.md +10 -9
- package/templates/hermes-agent/scripts/backfill-fleet-sot.sh +12 -21
- package/templates/hermes-agent/scripts/fleet-sync.sh +369 -0
- package/templates/hermes-agent/scripts/migrate-unify.sh +252 -0
- package/templates/hermes-agent/scripts/unify-pm.sh +148 -0
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +10 -9
- package/templates/hermes-agent/template/.scripts/01-config.sh +2 -3
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -2
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +47 -19
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +71 -27
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +3 -3
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +3 -3
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +48 -43
- package/templates/hermes-agent/template/.scripts/80-registry.sh +2 -2
- package/templates/hermes-agent/template/.scripts/99-summary.sh +3 -4
- package/templates/hermes-agent/template/.scripts/_lib.sh +4 -19
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +36 -0
- package/templates/hermes-agent/template/.scripts/config.example.toml +11 -5
- package/templates/hermes-agent/template/.scripts/{scrum-master/continuous-ticket-sentinel.sh → heartbeat.sh} +84 -31
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +1 -1
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +9 -3
- package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/bin/emit-event.py +5 -5
- package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/bin/issue-autonomous-review.sh +6 -6
- package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/bin/issue-close-gate.sh +2 -2
- package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/docs/autonomous-delegated-review.md +7 -7
- package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/docs/bloodbank-events.md +3 -3
- package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/docs/continuous-ticket-orchestration.md +6 -6
- package/templates/hermes-agent/template/.scripts/{scrum-master/continuous-ticket-sentinel.prompt.md.jinja → sentinel.prompt.md.jinja} +5 -4
- package/templates/hermes-agent/template/SOUL.md.jinja +3 -6
- package/templates/hermes-agent/template/hermes.jinja +6 -15
- package/templates/hermes-agent/template/role.yaml.jinja +9 -8
- package/templates/commonproject/.claude/commands/bmad/bmb/agents/agent-builder.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmb/agents/module-builder.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmb/agents/workflow-builder.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmb/workflows/agent.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmb/workflows/module.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmb/workflows/workflow.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/analyst.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/architect.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/dev.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/pm.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/quick-flow-solo-dev.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/sm.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/tea.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/tech-writer.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/ux-designer.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/check-implementation-readiness.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/code-review.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/correct-course.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-architecture.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-epics-and-stories.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-dataflow.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-diagram.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-flowchart.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-wireframe.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-product-brief.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-story.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-ux-design.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/dev-story.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/document-project.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/generate-project-context.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/prd.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/quick-dev.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/quick-spec.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/research.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/retrospective.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/sprint-planning.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/sprint-status.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-atdd.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-automate.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-ci.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-framework.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-nfr.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-test-design.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-test-review.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-trace.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/workflow-init.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/workflow-status.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/cis/agents/brainstorming-coach.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/cis/agents/creative-problem-solver.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/cis/agents/design-thinking-coach.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/cis/agents/innovation-strategist.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/cis/agents/presentation-master.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/cis/agents/storyteller.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/cis/workflows/design-thinking.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/cis/workflows/innovation-strategy.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/cis/workflows/problem-solving.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/cis/workflows/storytelling.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/core/agents/bmad-master.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/core/tasks/index-docs.md +0 -9
- package/templates/commonproject/.claude/commands/bmad/core/tasks/shard-doc.md +0 -9
- package/templates/commonproject/.claude/commands/bmad/core/workflows/brainstorming.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/core/workflows/party-mode.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/custom/workflows/ticket-lifecycle.md +0 -5
- package/templates/hermes-agent/docs/scrum-master-sentinel.md +0 -17
- package/templates/hermes-agent/template/.scripts/75-scrum-master.sh +0 -117
- package/templates/hermes-agent/template/.scripts/90-chain-scrum-master.sh +0 -45
package/dist/mcp-server.js
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
-
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
-
}) : x)(function(x) {
|
|
5
|
-
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
-
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
-
});
|
|
8
2
|
|
|
9
3
|
// src/mcp-server.ts
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
4
|
+
import { existsSync as existsSync9, statSync as statSync2 } from "node:fs";
|
|
5
|
+
import { basename as basename4, dirname as dirname8, join as join11, resolve as resolve3 } from "node:path";
|
|
6
|
+
import { fileURLToPath as fileURLToPath5 } from "node:url";
|
|
12
7
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
8
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
9
|
import { z } from "zod";
|
|
15
10
|
|
|
16
11
|
// src/commands/Command.ts
|
|
12
|
+
import { existsSync, writeFileSync, mkdirSync } from "fs";
|
|
13
|
+
import { join, dirname } from "path";
|
|
17
14
|
var Command = class {
|
|
18
15
|
context;
|
|
19
16
|
constructor(context) {
|
|
@@ -26,30 +23,24 @@ var Command = class {
|
|
|
26
23
|
return this.context.dryRun ? `[DRY RUN] ${message}` : message;
|
|
27
24
|
}
|
|
28
25
|
fileExists(filePath) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const fullPath = join7(this.context.targetDir, filePath);
|
|
32
|
-
return existsSync6(fullPath);
|
|
26
|
+
const fullPath = join(this.context.targetDir, filePath);
|
|
27
|
+
return existsSync(fullPath);
|
|
33
28
|
}
|
|
34
29
|
writeFile(filePath, content) {
|
|
35
30
|
if (this.context.dryRun) {
|
|
36
31
|
return;
|
|
37
32
|
}
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
mkdirSync3(dir, { recursive: true });
|
|
43
|
-
writeFileSync2(fullPath, content);
|
|
33
|
+
const fullPath = join(this.context.targetDir, filePath);
|
|
34
|
+
const dir = dirname(fullPath);
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
writeFileSync(fullPath, content);
|
|
44
37
|
}
|
|
45
38
|
createDirectory(dirPath) {
|
|
46
39
|
if (this.context.dryRun) {
|
|
47
40
|
return;
|
|
48
41
|
}
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
const fullPath = join7(this.context.targetDir, dirPath);
|
|
52
|
-
mkdirSync3(fullPath, { recursive: true });
|
|
42
|
+
const fullPath = join(this.context.targetDir, dirPath);
|
|
43
|
+
mkdirSync(fullPath, { recursive: true });
|
|
53
44
|
}
|
|
54
45
|
};
|
|
55
46
|
|
|
@@ -460,32 +451,32 @@ var NodeRecipe = class extends Recipe {
|
|
|
460
451
|
|
|
461
452
|
// src/commands/hermes/EnsureTemplateConfig.ts
|
|
462
453
|
import { homedir, platform } from "node:os";
|
|
463
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
464
|
-
import { join, dirname } from "node:path";
|
|
454
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
455
|
+
import { join as join2, dirname as dirname2 } from "node:path";
|
|
465
456
|
function resolveTemplateConfigPath() {
|
|
466
457
|
const fromEnv = process.env.HERMES_TEMPLATE_CONFIG;
|
|
467
458
|
if (fromEnv && fromEnv.trim()) return fromEnv.trim();
|
|
468
459
|
const xdg = process.env.XDG_CONFIG_HOME?.trim();
|
|
469
|
-
const base = xdg && xdg.length ? xdg :
|
|
470
|
-
return
|
|
460
|
+
const base = xdg && xdg.length ? xdg : join2(homedir(), ".config");
|
|
461
|
+
return join2(base, "hermes-agent-template", "config.toml");
|
|
471
462
|
}
|
|
472
463
|
function detectHermesBin(home) {
|
|
473
464
|
const candidates = [
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
465
|
+
join2(home, "code", "hermes-agent", "venv", "bin", "hermes"),
|
|
466
|
+
join2(home, "code", "hermes-agent", ".venv", "bin", "hermes"),
|
|
467
|
+
join2(home, ".local", "bin", "hermes")
|
|
477
468
|
];
|
|
478
469
|
for (const c of candidates) {
|
|
479
|
-
if (
|
|
470
|
+
if (existsSync2(c)) return c;
|
|
480
471
|
}
|
|
481
472
|
return candidates[0];
|
|
482
473
|
}
|
|
483
474
|
function renderHostConfig() {
|
|
484
475
|
const home = homedir();
|
|
485
476
|
const hermesBin = detectHermesBin(home);
|
|
486
|
-
const hermesRepo =
|
|
487
|
-
const scaffoldDir =
|
|
488
|
-
const skillsDir =
|
|
477
|
+
const hermesRepo = join2(home, "code", "hermes-agent");
|
|
478
|
+
const scaffoldDir = join2(home, "code", "hermes-agent-template", "runtime-scaffold");
|
|
479
|
+
const skillsDir = join2(home, ".agents", "skills");
|
|
489
480
|
return `# hermes-agent-template \u2014 host configuration
|
|
490
481
|
# Bootstrapped by \`pjangler config bootstrap\` for $HOME=${home} (platform=${platform()}).
|
|
491
482
|
#
|
|
@@ -529,7 +520,7 @@ var EnsureTemplateConfig = class extends Command {
|
|
|
529
520
|
const ctx = this.context;
|
|
530
521
|
const force = ctx.forceConfig === true || process.env.PJANGLER_FORCE_CONFIG === "1";
|
|
531
522
|
const path = resolveTemplateConfigPath();
|
|
532
|
-
const exists =
|
|
523
|
+
const exists = existsSync2(path);
|
|
533
524
|
if (exists && !force) {
|
|
534
525
|
console.log(`\u2713 Config present: ${path}`);
|
|
535
526
|
return { success: true, message: "" };
|
|
@@ -539,8 +530,8 @@ var EnsureTemplateConfig = class extends Command {
|
|
|
539
530
|
return { success: true, message: "" };
|
|
540
531
|
}
|
|
541
532
|
try {
|
|
542
|
-
|
|
543
|
-
|
|
533
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
534
|
+
writeFileSync2(path, renderHostConfig());
|
|
544
535
|
} catch (err) {
|
|
545
536
|
const msg = err instanceof Error ? err.message : String(err);
|
|
546
537
|
return { success: false, message: `\u2717 Failed to write ${path}: ${msg}` };
|
|
@@ -552,7 +543,7 @@ var EnsureTemplateConfig = class extends Command {
|
|
|
552
543
|
};
|
|
553
544
|
|
|
554
545
|
// src/commands/hermes/PromptForAgentConfig.ts
|
|
555
|
-
import { basename, join as
|
|
546
|
+
import { basename, join as join3 } from "node:path";
|
|
556
547
|
import { readFileSync } from "node:fs";
|
|
557
548
|
import * as p from "@clack/prompts";
|
|
558
549
|
|
|
@@ -560,12 +551,7 @@ import * as p from "@clack/prompts";
|
|
|
560
551
|
var HERMES_AGENT_TEMPLATE = "gh:delorenj/hermes-agent-template";
|
|
561
552
|
var SOUL_TONES = ["direct", "playful", "formal", "terse"];
|
|
562
553
|
var ROLE_CHOICES = [
|
|
563
|
-
{ value: "pm", label: "Project Manager (pm)", hint: "triage, planning, ticket authorship" },
|
|
564
|
-
{
|
|
565
|
-
value: "scrum-master",
|
|
566
|
-
label: "Scrum Master (Ticket Sentinel)",
|
|
567
|
-
hint: "continuous ticket sentinel + autonomous delegated review"
|
|
568
|
-
},
|
|
554
|
+
{ value: "pm", label: "Project Manager (pm)", hint: "triage, planning, ticket authorship, board reconciliation" },
|
|
569
555
|
{ value: "dev", label: "Developer (dev)", hint: "implements tickets" },
|
|
570
556
|
{ value: "review", label: "Reviewer (review)", hint: "adversarial code review" },
|
|
571
557
|
{ value: "ops", label: "Ops (ops)", hint: "deploy / infra" },
|
|
@@ -586,7 +572,7 @@ function deriveProfileName(repo, role) {
|
|
|
586
572
|
// src/commands/hermes/PromptForAgentConfig.ts
|
|
587
573
|
function detectTicketProvider(targetDir) {
|
|
588
574
|
try {
|
|
589
|
-
const t = JSON.parse(readFileSync(
|
|
575
|
+
const t = JSON.parse(readFileSync(join3(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
|
|
590
576
|
return t === "plane" || t === "linear" || t === "trello" ? t : void 0;
|
|
591
577
|
} catch {
|
|
592
578
|
return void 0;
|
|
@@ -605,7 +591,6 @@ var PromptForAgentConfig = class extends Command {
|
|
|
605
591
|
ctx.modelProvider ??= "";
|
|
606
592
|
ctx.modelName ??= "";
|
|
607
593
|
ctx.ticketProvider ??= detectTicketProvider(ctx.targetDir) ?? "plane";
|
|
608
|
-
ctx.withScrumMaster ??= false;
|
|
609
594
|
ctx.skipTelegram ??= true;
|
|
610
595
|
ctx.skipEmail ??= true;
|
|
611
596
|
ctx.agentId = deriveAgentId(ctx.targetRepo, ctx.role);
|
|
@@ -651,14 +636,6 @@ var PromptForAgentConfig = class extends Command {
|
|
|
651
636
|
if (p.isCancel(answer)) return this.cancelled();
|
|
652
637
|
ctx.ticketProvider = answer;
|
|
653
638
|
}
|
|
654
|
-
if (ctx.role === "pm" && ctx.withScrumMaster === void 0) {
|
|
655
|
-
const answer = await p.confirm({
|
|
656
|
-
message: "Also provision the paired Scrum Master (Ticket Sentinel) for this repo?",
|
|
657
|
-
initialValue: true
|
|
658
|
-
});
|
|
659
|
-
if (p.isCancel(answer)) return this.cancelled();
|
|
660
|
-
ctx.withScrumMaster = answer === true;
|
|
661
|
-
}
|
|
662
639
|
if (!ctx.agentPurpose) {
|
|
663
640
|
const answer = await p.text({
|
|
664
641
|
message: "One-line purpose",
|
|
@@ -731,21 +708,21 @@ var PromptForAgentConfig = class extends Command {
|
|
|
731
708
|
// src/commands/hermes/RunCopierTemplate.ts
|
|
732
709
|
import { spawnSync } from "node:child_process";
|
|
733
710
|
import { homedir as homedir2 } from "node:os";
|
|
734
|
-
import { join as
|
|
735
|
-
import { existsSync as
|
|
711
|
+
import { join as join4, dirname as dirname3 } from "node:path";
|
|
712
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3 } from "node:fs";
|
|
736
713
|
import { fileURLToPath } from "node:url";
|
|
737
714
|
import * as p2 from "@clack/prompts";
|
|
738
715
|
function resolveVendoredTemplate(name) {
|
|
739
716
|
let dir;
|
|
740
717
|
try {
|
|
741
|
-
dir =
|
|
718
|
+
dir = dirname3(fileURLToPath(import.meta.url));
|
|
742
719
|
} catch {
|
|
743
720
|
return void 0;
|
|
744
721
|
}
|
|
745
722
|
for (let i = 0; i < 8; i++) {
|
|
746
|
-
const candidate =
|
|
747
|
-
if (
|
|
748
|
-
const parent =
|
|
723
|
+
const candidate = join4(dir, "templates", name);
|
|
724
|
+
if (existsSync3(join4(candidate, "copier.yml"))) return candidate;
|
|
725
|
+
const parent = dirname3(dir);
|
|
749
726
|
if (parent === dir) break;
|
|
750
727
|
dir = parent;
|
|
751
728
|
}
|
|
@@ -757,14 +734,13 @@ var RunCopierTemplate = class extends Command {
|
|
|
757
734
|
const { targetRepo, role, agentPurpose, soulTone, modelProvider, modelName } = ctx;
|
|
758
735
|
const ticketProvider = ctx.ticketProvider ?? "plane";
|
|
759
736
|
const profileName = ctx.profileName ?? (targetRepo && role ? deriveProfileName(targetRepo, role) : void 0);
|
|
760
|
-
const withScrumMaster = role === "pm" && ctx.withScrumMaster === true;
|
|
761
737
|
if (!targetRepo || !role) {
|
|
762
738
|
return {
|
|
763
739
|
success: false,
|
|
764
740
|
message: "PromptForAgentConfig must run before RunCopierTemplate (targetRepo/role unset)"
|
|
765
741
|
};
|
|
766
742
|
}
|
|
767
|
-
const roleDir =
|
|
743
|
+
const roleDir = join4(ctx.targetDir, "agents", "hermes", role);
|
|
768
744
|
ctx.roleDir = roleDir;
|
|
769
745
|
ctx.runtimeRepo = `delorenj/agent-hm-${targetRepo}-${role}`;
|
|
770
746
|
const which = spawnSync("which", ["copier"], { encoding: "utf8" });
|
|
@@ -774,7 +750,7 @@ var RunCopierTemplate = class extends Command {
|
|
|
774
750
|
message: "\u2717 copier not found on PATH. Install with: `uv tool install copier` or `pip install copier`"
|
|
775
751
|
};
|
|
776
752
|
}
|
|
777
|
-
if (
|
|
753
|
+
if (existsSync3(join4(roleDir, "role.yaml")) && !ctx.force) {
|
|
778
754
|
if (ctx.yes) {
|
|
779
755
|
ctx.force = true;
|
|
780
756
|
} else {
|
|
@@ -801,9 +777,9 @@ var RunCopierTemplate = class extends Command {
|
|
|
801
777
|
SKIP_BLOODBANK: ctx.skipBloodbank ? "1" : "0",
|
|
802
778
|
SKIP_SYSTEMD: ctx.skipSystemd ? "1" : "0"
|
|
803
779
|
};
|
|
804
|
-
const LOCAL_TEMPLATE =
|
|
780
|
+
const LOCAL_TEMPLATE = join4(homedir2(), "code", "hermes-agent-template");
|
|
805
781
|
const vendored = resolveVendoredTemplate("hermes-agent");
|
|
806
|
-
const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (
|
|
782
|
+
const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync3(join4(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
|
|
807
783
|
const args = [
|
|
808
784
|
"copy",
|
|
809
785
|
templateSrc,
|
|
@@ -824,8 +800,6 @@ var RunCopierTemplate = class extends Command {
|
|
|
824
800
|
`soul_tone=${soulTone ?? "direct"}`,
|
|
825
801
|
"--data",
|
|
826
802
|
`ticket_provider=${ticketProvider}`,
|
|
827
|
-
"--data",
|
|
828
|
-
`with_scrum_master=${withScrumMaster}`,
|
|
829
803
|
"--trust",
|
|
830
804
|
"--vcs-ref=HEAD"
|
|
831
805
|
];
|
|
@@ -836,7 +810,7 @@ var RunCopierTemplate = class extends Command {
|
|
|
836
810
|
message: this.formatMessage(`Would run: copier ${args.join(" ")}`)
|
|
837
811
|
};
|
|
838
812
|
}
|
|
839
|
-
|
|
813
|
+
mkdirSync3(join4(ctx.targetDir, "agents", "hermes"), { recursive: true });
|
|
840
814
|
const spinner4 = p2.spinner();
|
|
841
815
|
spinner4.start(`Running copier copy (target: agents/hermes/${role})`);
|
|
842
816
|
const result = spawnSync("copier", args, {
|
|
@@ -861,8 +835,8 @@ var RunCopierTemplate = class extends Command {
|
|
|
861
835
|
|
|
862
836
|
// src/commands/hermes/WireTelegram.ts
|
|
863
837
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
864
|
-
import { join as
|
|
865
|
-
import { existsSync as
|
|
838
|
+
import { join as join5 } from "node:path";
|
|
839
|
+
import { existsSync as existsSync4, unlinkSync } from "node:fs";
|
|
866
840
|
import * as p3 from "@clack/prompts";
|
|
867
841
|
var WireTelegram = class extends Command {
|
|
868
842
|
async invoke() {
|
|
@@ -950,15 +924,15 @@ var WireTelegram = class extends Command {
|
|
|
950
924
|
if (p3.isCancel(allowedAnswer)) {
|
|
951
925
|
return { success: false, message: "\u2717 Aborted; Telegram step deferred." };
|
|
952
926
|
}
|
|
953
|
-
const script =
|
|
954
|
-
if (!
|
|
927
|
+
const script = join5(roleDir, ".scripts", "30-telegram.sh");
|
|
928
|
+
if (!existsSync4(script)) {
|
|
955
929
|
return {
|
|
956
930
|
success: false,
|
|
957
931
|
message: `\u2717 ${script} not found. Did copier finish? Re-run with --skip-runtime-repo=0 if you skipped it.`
|
|
958
932
|
};
|
|
959
933
|
}
|
|
960
|
-
const marker =
|
|
961
|
-
if (
|
|
934
|
+
const marker = join5(roleDir, ".scripts", ".done-30-telegram");
|
|
935
|
+
if (existsSync4(marker)) unlinkSync(marker);
|
|
962
936
|
const spinner4 = p3.spinner();
|
|
963
937
|
spinner4.start("Verifying token + wiring profile");
|
|
964
938
|
const result = spawnSync2("bash", [script], {
|
|
@@ -985,8 +959,8 @@ function cap(s) {
|
|
|
985
959
|
|
|
986
960
|
// src/commands/hermes/WireEmail.ts
|
|
987
961
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
988
|
-
import { join as
|
|
989
|
-
import { existsSync as
|
|
962
|
+
import { join as join6 } from "node:path";
|
|
963
|
+
import { existsSync as existsSync5, unlinkSync as unlinkSync2 } from "node:fs";
|
|
990
964
|
import * as p4 from "@clack/prompts";
|
|
991
965
|
var WireEmail = class extends Command {
|
|
992
966
|
async invoke() {
|
|
@@ -1001,8 +975,8 @@ var WireEmail = class extends Command {
|
|
|
1001
975
|
if (!targetRepo || !role || !roleDir) {
|
|
1002
976
|
return { success: false, message: "Cannot wire email: missing target_repo/role/roleDir" };
|
|
1003
977
|
}
|
|
1004
|
-
const script =
|
|
1005
|
-
if (!
|
|
978
|
+
const script = join6(roleDir, ".scripts", "50-email.sh");
|
|
979
|
+
if (!existsSync5(script)) {
|
|
1006
980
|
return { success: false, message: `\u2717 ${script} not found` };
|
|
1007
981
|
}
|
|
1008
982
|
let token = process.env.CF_EMAIL_ROUTING_TOKEN;
|
|
@@ -1064,8 +1038,8 @@ var WireEmail = class extends Command {
|
|
|
1064
1038
|
}
|
|
1065
1039
|
}
|
|
1066
1040
|
}
|
|
1067
|
-
const marker =
|
|
1068
|
-
if (
|
|
1041
|
+
const marker = join6(roleDir, ".scripts", ".done-50-email");
|
|
1042
|
+
if (existsSync5(marker)) unlinkSync2(marker);
|
|
1069
1043
|
const spinner4 = p4.spinner();
|
|
1070
1044
|
spinner4.start("Creating Cloudflare Email Routing rule");
|
|
1071
1045
|
const result = spawnSync3("bash", [script], {
|
|
@@ -1094,7 +1068,7 @@ var PrintHermesSummary = class extends Command {
|
|
|
1094
1068
|
const email = `${targetRepo}-${role}@delo.sh`;
|
|
1095
1069
|
const gw = `hermes-${agentId}-gateway.service`;
|
|
1096
1070
|
const csm = `hermes-${agentId}-consumer.service`;
|
|
1097
|
-
const
|
|
1071
|
+
const hb = `hermes-${agentId}-heartbeat.timer`;
|
|
1098
1072
|
const lines = [];
|
|
1099
1073
|
lines.push(`agent_id ${agentId}`);
|
|
1100
1074
|
lines.push(`role dir ${ctx.roleDir}`);
|
|
@@ -1104,7 +1078,7 @@ var PrintHermesSummary = class extends Command {
|
|
|
1104
1078
|
lines.push("");
|
|
1105
1079
|
lines.push("Start daemons:");
|
|
1106
1080
|
lines.push(` systemctl --user start ${csm}`);
|
|
1107
|
-
lines.push(` systemctl --user start ${
|
|
1081
|
+
lines.push(` systemctl --user start ${hb}`);
|
|
1108
1082
|
if (!skipTelegram) {
|
|
1109
1083
|
lines.push(` systemctl --user start ${gw}`);
|
|
1110
1084
|
} else {
|
|
@@ -1151,6 +1125,193 @@ var HermesAgentRecipe = class extends Recipe {
|
|
|
1151
1125
|
}
|
|
1152
1126
|
};
|
|
1153
1127
|
|
|
1128
|
+
// src/commands/AgentHooksCommands.ts
|
|
1129
|
+
import { homedir as homedir3 } from "node:os";
|
|
1130
|
+
import { join as join7, dirname as dirname4 } from "node:path";
|
|
1131
|
+
import { existsSync as existsSync6, cpSync, mkdirSync as mkdirSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
1132
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1133
|
+
function resolveTemplateRoot() {
|
|
1134
|
+
const candidates = [];
|
|
1135
|
+
if (process.env.PJANGLER_COMMONPROJECT_TEMPLATE) {
|
|
1136
|
+
candidates.push(process.env.PJANGLER_COMMONPROJECT_TEMPLATE);
|
|
1137
|
+
}
|
|
1138
|
+
try {
|
|
1139
|
+
let dir = dirname4(fileURLToPath2(import.meta.url));
|
|
1140
|
+
for (let i = 0; i < 8; i++) {
|
|
1141
|
+
candidates.push(join7(dir, "templates", "commonproject", "template"));
|
|
1142
|
+
const parent = dirname4(dir);
|
|
1143
|
+
if (parent === dir) break;
|
|
1144
|
+
dir = parent;
|
|
1145
|
+
}
|
|
1146
|
+
} catch {
|
|
1147
|
+
}
|
|
1148
|
+
candidates.push(join7(homedir3(), "code", "pjangler", "templates", "commonproject", "template"));
|
|
1149
|
+
for (const c of candidates) {
|
|
1150
|
+
if (existsSync6(join7(c, ".agents", "hooks", "hooks.master.json"))) return c;
|
|
1151
|
+
}
|
|
1152
|
+
throw new Error(
|
|
1153
|
+
"Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
var CopyAgentHooksTree = class extends Command {
|
|
1157
|
+
async invoke() {
|
|
1158
|
+
let templateRoot;
|
|
1159
|
+
try {
|
|
1160
|
+
templateRoot = resolveTemplateRoot();
|
|
1161
|
+
} catch (e) {
|
|
1162
|
+
return { success: false, message: `\u26A0\uFE0F ${e.message}` };
|
|
1163
|
+
}
|
|
1164
|
+
const items = [
|
|
1165
|
+
{ rel: ".agents/hooks", dir: true },
|
|
1166
|
+
{ rel: ".agents/local.example.json", dir: false },
|
|
1167
|
+
{ rel: ".mise/scripts/link-project-skills-to-clis.sh", dir: false },
|
|
1168
|
+
{ rel: ".mise/scripts/unlink-project-skills-from-clis.sh", dir: false },
|
|
1169
|
+
{ rel: ".mise/scripts/hindsight-setup.sh", dir: false }
|
|
1170
|
+
];
|
|
1171
|
+
const created = [];
|
|
1172
|
+
const skipped = [];
|
|
1173
|
+
for (const { rel, dir } of items) {
|
|
1174
|
+
const src = join7(templateRoot, rel);
|
|
1175
|
+
const dest = join7(this.context.targetDir, rel);
|
|
1176
|
+
if (!existsSync6(src)) continue;
|
|
1177
|
+
if (existsSync6(dest) && !this.context.force) {
|
|
1178
|
+
skipped.push(rel);
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
if (!this.context.dryRun) {
|
|
1182
|
+
mkdirSync4(dirname4(dest), { recursive: true });
|
|
1183
|
+
cpSync(src, dest, { recursive: dir, force: true });
|
|
1184
|
+
}
|
|
1185
|
+
created.push(rel);
|
|
1186
|
+
}
|
|
1187
|
+
const verb = this.context.dryRun ? "Would copy" : "Copied";
|
|
1188
|
+
const tail = skipped.length ? ` (${skipped.length} already present \u2014 use --force to overwrite)` : "";
|
|
1189
|
+
return {
|
|
1190
|
+
success: created.length > 0,
|
|
1191
|
+
message: this.formatMessage(`\u2705 ${verb} ${created.length} agent-hooks path(s)${tail}`)
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
|
|
1196
|
+
static MARKER = "# pjangler:agent-hooks";
|
|
1197
|
+
static CR = "{{config_root}}";
|
|
1198
|
+
// mise's own runtime var — emitted literally
|
|
1199
|
+
async invoke() {
|
|
1200
|
+
const misePath = join7(this.context.targetDir, "mise.toml");
|
|
1201
|
+
if (!existsSync6(misePath)) {
|
|
1202
|
+
return {
|
|
1203
|
+
success: false,
|
|
1204
|
+
message: "\u26A0\uFE0F No mise.toml found \u2014 run `pjangler init mise` first, then re-run."
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
let content = readFileSync2(misePath, "utf8");
|
|
1208
|
+
if (content.includes(_WireMiseAgentHooks.MARKER)) {
|
|
1209
|
+
return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for agent-hooks") };
|
|
1210
|
+
}
|
|
1211
|
+
const cr = _WireMiseAgentHooks.CR;
|
|
1212
|
+
const enterAdds = [
|
|
1213
|
+
` "${cr}/.mise/scripts/link-project-skills-to-clis.sh",`,
|
|
1214
|
+
` "${cr}/.agents/hooks/sync.py --install --quiet",`
|
|
1215
|
+
].join("\n");
|
|
1216
|
+
const leaveBlock = [
|
|
1217
|
+
"leave = [",
|
|
1218
|
+
` "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",`,
|
|
1219
|
+
` "${cr}/.agents/hooks/sync.py --uninstall --quiet",`,
|
|
1220
|
+
"]"
|
|
1221
|
+
].join("\n");
|
|
1222
|
+
let wiredHooks = false;
|
|
1223
|
+
const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
|
|
1224
|
+
if (enterRe.test(content)) {
|
|
1225
|
+
content = content.replace(enterRe, (_m, head, close) => {
|
|
1226
|
+
const sep = /[,[]\s*$/.test(head) ? "" : ",";
|
|
1227
|
+
return `${head}${sep}
|
|
1228
|
+
${enterAdds}${close}`;
|
|
1229
|
+
});
|
|
1230
|
+
const leaveRe = /(leave\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
|
|
1231
|
+
if (leaveRe.test(content)) {
|
|
1232
|
+
content = content.replace(leaveRe, (_m, head, close) => {
|
|
1233
|
+
const sep = /[,[]\s*$/.test(head) ? "" : ",";
|
|
1234
|
+
return `${head}${sep}
|
|
1235
|
+
"${cr}/.mise/scripts/unlink-project-skills-from-clis.sh",
|
|
1236
|
+
"${cr}/.agents/hooks/sync.py --uninstall --quiet",${close}`;
|
|
1237
|
+
});
|
|
1238
|
+
} else {
|
|
1239
|
+
content = content.replace(enterRe, (m) => `${m}
|
|
1240
|
+
${leaveBlock}`);
|
|
1241
|
+
}
|
|
1242
|
+
wiredHooks = true;
|
|
1243
|
+
}
|
|
1244
|
+
const appended = [
|
|
1245
|
+
"",
|
|
1246
|
+
_WireMiseAgentHooks.MARKER + " (generated \u2014 see .agents/hooks/README.md)",
|
|
1247
|
+
"[[watch_files]]",
|
|
1248
|
+
'patterns = [".agents/hooks/hooks.master.json"]',
|
|
1249
|
+
'task = "hooks-sync"',
|
|
1250
|
+
"",
|
|
1251
|
+
"[tasks.hooks-sync]",
|
|
1252
|
+
'description = "Fan out hooks.master.json to each agent CLI (claude/codex/kimi/hermes)"',
|
|
1253
|
+
`run = "${cr}/.agents/hooks/sync.py --install"`,
|
|
1254
|
+
"",
|
|
1255
|
+
"[tasks.hooks-check]",
|
|
1256
|
+
'description = "Drift gate: verify generated hook configs match hooks.master.json"',
|
|
1257
|
+
`run = "${cr}/.agents/hooks/sync.py --check"`,
|
|
1258
|
+
"",
|
|
1259
|
+
"[tasks.hooks-uninstall]",
|
|
1260
|
+
'description = "Remove per-user agent-hook injections (codex/kimi/hermes)"',
|
|
1261
|
+
`run = "${cr}/.agents/hooks/sync.py --uninstall"`,
|
|
1262
|
+
"",
|
|
1263
|
+
"[tasks.link-project-skills-to-clis]",
|
|
1264
|
+
'description = "Fan .agents/skills out to each agent CLI (honors local.json)"',
|
|
1265
|
+
`run = "${cr}/.mise/scripts/link-project-skills-to-clis.sh"`,
|
|
1266
|
+
"",
|
|
1267
|
+
"[tasks.unlink-project-skills-from-clis]",
|
|
1268
|
+
'description = "Remove project skill symlinks from shared per-CLI dirs"',
|
|
1269
|
+
`run = "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh"`,
|
|
1270
|
+
"",
|
|
1271
|
+
"[tasks.skills-relink]",
|
|
1272
|
+
'description = "Re-fan the project skill set to all CLIs"',
|
|
1273
|
+
`run = "${cr}/.mise/scripts/link-project-skills-to-clis.sh"`,
|
|
1274
|
+
"",
|
|
1275
|
+
"[tasks.hindsight-setup]",
|
|
1276
|
+
`description = "Provision this dev's shared project Hindsight key from 1Password into .env"`,
|
|
1277
|
+
`run = "${cr}/.mise/scripts/hindsight-setup.sh"`,
|
|
1278
|
+
"",
|
|
1279
|
+
_WireMiseAgentHooks.MARKER + ":end",
|
|
1280
|
+
""
|
|
1281
|
+
].join("\n");
|
|
1282
|
+
content = content.replace(/\n*$/, "\n") + appended;
|
|
1283
|
+
if (!this.context.dryRun) writeFileSync3(misePath, content);
|
|
1284
|
+
if (wiredHooks) {
|
|
1285
|
+
return { success: true, message: this.formatMessage("\u2705 Wired mise.toml ([hooks] enter/leave + tasks)") };
|
|
1286
|
+
}
|
|
1287
|
+
return {
|
|
1288
|
+
success: true,
|
|
1289
|
+
message: this.formatMessage(
|
|
1290
|
+
`\u2705 Added agent-hooks tasks to mise.toml.
|
|
1291
|
+
\u26A0\uFE0F Could not find a [hooks].enter array to extend \u2014 add these to your [hooks] block manually:
|
|
1292
|
+
enter += "${cr}/.mise/scripts/link-project-skills-to-clis.sh", "${cr}/.agents/hooks/sync.py --install --quiet"
|
|
1293
|
+
leave += "${cr}/.mise/scripts/unlink-project-skills-from-clis.sh", "${cr}/.agents/hooks/sync.py --uninstall --quiet"`
|
|
1294
|
+
)
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
|
|
1299
|
+
// src/recipes/AgentHooksRecipe.ts
|
|
1300
|
+
var AgentHooksRecipe = class extends Recipe {
|
|
1301
|
+
constructor(context) {
|
|
1302
|
+
super(context);
|
|
1303
|
+
this.addIngredient(CopyAgentHooksTree).addIngredient(WireMiseAgentHooks);
|
|
1304
|
+
}
|
|
1305
|
+
printNextSteps() {
|
|
1306
|
+
console.log("\u{1FA9D} Agent-hooks layer installed!");
|
|
1307
|
+
console.log(" Next steps:");
|
|
1308
|
+
console.log(" 1. mise run hooks-sync # generate .claude/settings.json + inject codex/kimi/hermes");
|
|
1309
|
+
console.log(" 2. git add .claude/settings.json .agents/hooks && commit (codex/kimi/hermes are per-dev)");
|
|
1310
|
+
console.log(" 3. mise run hindsight-setup # set HINDSIGHT_OP_KEY_REF to your 1Password item first");
|
|
1311
|
+
console.log(` 4. If you run a global agent system: echo '{"skills":{"defer_to_global":true}}' > .agents/local.json`);
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
|
|
1154
1315
|
// src/utils/registry.ts
|
|
1155
1316
|
var RECIPE_REGISTRY = {
|
|
1156
1317
|
mise: {
|
|
@@ -1184,9 +1345,27 @@ var RECIPE_REGISTRY = {
|
|
|
1184
1345
|
"WireEmail",
|
|
1185
1346
|
"PrintHermesSummary"
|
|
1186
1347
|
]
|
|
1348
|
+
},
|
|
1349
|
+
"agent-hooks": {
|
|
1350
|
+
name: "agent-hooks",
|
|
1351
|
+
description: "Retrofit the project-scoped agent-hooks + skill fan-out layer (Claude/Codex/Kimi/Hermes hooks via mise enter/leave)",
|
|
1352
|
+
class: AgentHooksRecipe,
|
|
1353
|
+
commands: ["CopyAgentHooksTree", "WireMiseAgentHooks"]
|
|
1187
1354
|
}
|
|
1188
1355
|
};
|
|
1189
1356
|
var COMMAND_REGISTRY = {
|
|
1357
|
+
CopyAgentHooksTree: {
|
|
1358
|
+
name: "CopyAgentHooksTree",
|
|
1359
|
+
description: "Copy the generic agent-hooks tree (hooks SSOT + sync engine + scripts) from the CommonProject template",
|
|
1360
|
+
group: "agent-hooks",
|
|
1361
|
+
class: CopyAgentHooksTree
|
|
1362
|
+
},
|
|
1363
|
+
WireMiseAgentHooks: {
|
|
1364
|
+
name: "WireMiseAgentHooks",
|
|
1365
|
+
description: "Merge agent-hooks enter/leave + tasks into an existing mise.toml (idempotent)",
|
|
1366
|
+
group: "agent-hooks",
|
|
1367
|
+
class: WireMiseAgentHooks
|
|
1368
|
+
},
|
|
1190
1369
|
AddDockerfile: {
|
|
1191
1370
|
name: "AddDockerfile",
|
|
1192
1371
|
description: "Create Dockerfile for containerization",
|
|
@@ -1249,18 +1428,18 @@ function createRecipe(name, context) {
|
|
|
1249
1428
|
}
|
|
1250
1429
|
|
|
1251
1430
|
// src/utils/version.ts
|
|
1252
|
-
import { readFileSync as
|
|
1253
|
-
import { dirname as
|
|
1254
|
-
import { fileURLToPath as
|
|
1431
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
1432
|
+
import { dirname as dirname5, join as join8 } from "node:path";
|
|
1433
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
1255
1434
|
var PJANGLER_VERSION = (() => {
|
|
1256
1435
|
try {
|
|
1257
|
-
let dir =
|
|
1436
|
+
let dir = dirname5(fileURLToPath3(import.meta.url));
|
|
1258
1437
|
for (let i = 0; i < 4; i++) {
|
|
1259
1438
|
try {
|
|
1260
|
-
const raw =
|
|
1439
|
+
const raw = readFileSync3(join8(dir, "package.json"), "utf8");
|
|
1261
1440
|
return JSON.parse(raw).version ?? "0.0.0";
|
|
1262
1441
|
} catch {
|
|
1263
|
-
const parent =
|
|
1442
|
+
const parent = dirname5(dir);
|
|
1264
1443
|
if (parent === dir) break;
|
|
1265
1444
|
dir = parent;
|
|
1266
1445
|
}
|
|
@@ -1270,236 +1449,2158 @@ var PJANGLER_VERSION = (() => {
|
|
|
1270
1449
|
return "0.0.0";
|
|
1271
1450
|
})();
|
|
1272
1451
|
|
|
1273
|
-
// src/
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1452
|
+
// src/parity/index.ts
|
|
1453
|
+
import { existsSync as existsSync7, lstatSync, mkdirSync as mkdirSync5, readFileSync as readFileSync4, readlinkSync, readdirSync, renameSync, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4, chmodSync, copyFileSync } from "node:fs";
|
|
1454
|
+
import { basename as basename2, dirname as dirname6, join as join9, relative, resolve } from "node:path";
|
|
1455
|
+
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
1456
|
+
import { homedir as homedir4 } from "node:os";
|
|
1457
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
1458
|
+
var LINK_AGENTFILES_BLOCK = `# This block will handle the linking of
|
|
1459
|
+
# agent files to the main AGENTS.md file.
|
|
1460
|
+
#
|
|
1461
|
+
# TODO: Ensure this works for all levels of nesting.
|
|
1462
|
+
# i.e. All linked agent files MUST be siblings at
|
|
1463
|
+
# any given level of nesting.
|
|
1464
|
+
[hooks]
|
|
1465
|
+
enter = [
|
|
1466
|
+
"{{config_root}}/.mise/scripts/link-agentfiles.sh",
|
|
1467
|
+
"op inject -i .env.op > .env",
|
|
1468
|
+
]
|
|
1469
|
+
|
|
1470
|
+
[[watch_files]]
|
|
1471
|
+
patterns = ["AGENTS.md"]
|
|
1472
|
+
task = "link-agentfiles"
|
|
1473
|
+
|
|
1474
|
+
[tasks.link-agentfiles]
|
|
1475
|
+
description = "Symlink all agent files to AGENTS.md"
|
|
1476
|
+
run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
|
|
1477
|
+
var LINK_AGENTFILES_HOOK_ENTRIES = [
|
|
1478
|
+
"{{config_root}}/.mise/scripts/link-agentfiles.sh",
|
|
1479
|
+
"op inject -i .env.op > .env"
|
|
1480
|
+
];
|
|
1481
|
+
var LINK_AGENTFILES_HOOKS_BLOCK = `# This block will handle the linking of
|
|
1482
|
+
# agent files to the main AGENTS.md file.
|
|
1483
|
+
#
|
|
1484
|
+
# TODO: Ensure this works for all levels of nesting.
|
|
1485
|
+
# i.e. All linked agent files MUST be siblings at
|
|
1486
|
+
# any given level of nesting.
|
|
1487
|
+
[hooks]
|
|
1488
|
+
enter = [
|
|
1489
|
+
"{{config_root}}/.mise/scripts/link-agentfiles.sh",
|
|
1490
|
+
"op inject -i .env.op > .env",
|
|
1491
|
+
]`;
|
|
1492
|
+
var LINK_AGENTFILES_WATCH_TASK_BLOCK = `[[watch_files]]
|
|
1493
|
+
patterns = ["AGENTS.md"]
|
|
1494
|
+
task = "link-agentfiles"
|
|
1495
|
+
|
|
1496
|
+
[tasks.link-agentfiles]
|
|
1497
|
+
description = "Symlink all agent files to AGENTS.md"
|
|
1498
|
+
run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
|
|
1499
|
+
var VERSIONING_BLOCK = `# >>> mise-versioning >>> (managed block \u2014 do not edit by hand; re-run init to update)
|
|
1500
|
+
[tasks."version"]
|
|
1501
|
+
description = "Print the current version (vX.Y.Z)"
|
|
1502
|
+
run = "{{config_root}}/.mise/scripts/versioning.sh current"
|
|
1503
|
+
|
|
1504
|
+
[tasks."version:bump"]
|
|
1505
|
+
description = "Bump patch version: vX.Y.Z -> vX.Y.(Z+1)"
|
|
1506
|
+
alias = "version:bump-patch"
|
|
1507
|
+
run = "{{config_root}}/.mise/scripts/versioning.sh bump patch"
|
|
1508
|
+
|
|
1509
|
+
[tasks."version:bump-minor"]
|
|
1510
|
+
description = "Bump minor version: vX.Y.Z -> vX.(Y+1).0"
|
|
1511
|
+
run = "{{config_root}}/.mise/scripts/versioning.sh bump minor"
|
|
1512
|
+
|
|
1513
|
+
[tasks."version:bump-major"]
|
|
1514
|
+
description = "Bump major version: vX.Y.Z -> v(X+1).0.0"
|
|
1515
|
+
run = "{{config_root}}/.mise/scripts/versioning.sh bump major"
|
|
1516
|
+
|
|
1517
|
+
[tasks."version:check"]
|
|
1518
|
+
description = "Verify every versioned file is in parity"
|
|
1519
|
+
run = "{{config_root}}/.mise/scripts/versioning.sh check"
|
|
1520
|
+
|
|
1521
|
+
[tasks."version:sync"]
|
|
1522
|
+
description = "Force every versioned file up to the highest version"
|
|
1523
|
+
run = "{{config_root}}/.mise/scripts/versioning.sh sync"
|
|
1524
|
+
# <<< mise-versioning <<<`;
|
|
1525
|
+
function resolvePjanglerRoot() {
|
|
1526
|
+
let dir = dirname6(fileURLToPath4(import.meta.url));
|
|
1527
|
+
while (dir !== dirname6(dir)) {
|
|
1528
|
+
if (existsSync7(join9(dir, "package.json")) && existsSync7(join9(dir, "templates", "commonproject", "copier.yml"))) {
|
|
1529
|
+
return dir;
|
|
1530
|
+
}
|
|
1531
|
+
dir = dirname6(dir);
|
|
1285
1532
|
}
|
|
1286
|
-
|
|
1533
|
+
throw new Error("Unable to resolve pjangler root");
|
|
1287
1534
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1535
|
+
function normalizeNewlines(value) {
|
|
1536
|
+
return value.replace(/\r\n/g, "\n");
|
|
1537
|
+
}
|
|
1538
|
+
function readText(path) {
|
|
1539
|
+
return normalizeNewlines(readFileSync4(path, "utf8"));
|
|
1540
|
+
}
|
|
1541
|
+
function safeReadText(path) {
|
|
1542
|
+
return existsSync7(path) ? readText(path) : null;
|
|
1543
|
+
}
|
|
1544
|
+
function ensureParent(path) {
|
|
1545
|
+
mkdirSync5(dirname6(path), { recursive: true });
|
|
1546
|
+
}
|
|
1547
|
+
function writeText(path, content) {
|
|
1548
|
+
ensureParent(path);
|
|
1549
|
+
writeFileSync4(path, content);
|
|
1550
|
+
}
|
|
1551
|
+
function tryParseJson(text3) {
|
|
1552
|
+
if (!text3) return null;
|
|
1553
|
+
try {
|
|
1554
|
+
return JSON.parse(text3);
|
|
1555
|
+
} catch {
|
|
1556
|
+
return null;
|
|
1296
1557
|
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
};
|
|
1558
|
+
}
|
|
1559
|
+
function slugifyRepoName(name) {
|
|
1560
|
+
return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
1561
|
+
}
|
|
1562
|
+
function titleCaseSlug(slug) {
|
|
1563
|
+
return slug.split(/[-_]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
1564
|
+
}
|
|
1565
|
+
function readSymlinkTarget(path) {
|
|
1566
|
+
if (!existsSync7(path)) return null;
|
|
1307
1567
|
try {
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
return { success, logs, errors };
|
|
1312
|
-
} catch (err) {
|
|
1313
|
-
errors.push(err instanceof Error ? err.message : String(err));
|
|
1314
|
-
return { success: false, logs, errors };
|
|
1315
|
-
} finally {
|
|
1316
|
-
console.log = origLog;
|
|
1317
|
-
console.error = origError;
|
|
1568
|
+
return readlinkSync(path);
|
|
1569
|
+
} catch {
|
|
1570
|
+
return null;
|
|
1318
1571
|
}
|
|
1319
1572
|
}
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
})),
|
|
1334
|
-
commands: Object.values(COMMAND_REGISTRY).map((c) => ({
|
|
1335
|
-
name: c.name,
|
|
1336
|
-
description: c.description,
|
|
1337
|
-
group: c.group
|
|
1338
|
-
}))
|
|
1339
|
-
};
|
|
1340
|
-
return {
|
|
1341
|
-
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
|
|
1342
|
-
};
|
|
1573
|
+
function ensureSymlink(path, target, dryRun) {
|
|
1574
|
+
if (existsSync7(path)) {
|
|
1575
|
+
const stat = lstatSync(path);
|
|
1576
|
+
if (stat.isSymbolicLink()) {
|
|
1577
|
+
const current = readSymlinkTarget(path);
|
|
1578
|
+
if (current === target) return { changed: false };
|
|
1579
|
+
if (!dryRun) {
|
|
1580
|
+
unlinkSync3(path);
|
|
1581
|
+
symlinkSync(target, path);
|
|
1582
|
+
}
|
|
1583
|
+
return { changed: true };
|
|
1584
|
+
}
|
|
1585
|
+
return { changed: false, blocked: `${relative(process.cwd(), path) || path} exists and is not a symlink` };
|
|
1343
1586
|
}
|
|
1344
|
-
);
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1587
|
+
if (!dryRun) symlinkSync(target, path);
|
|
1588
|
+
return { changed: true };
|
|
1589
|
+
}
|
|
1590
|
+
function bootstrapAgentsFile(repoRoot, dryRun) {
|
|
1591
|
+
const agentsPath = join9(repoRoot, "AGENTS.md");
|
|
1592
|
+
if (existsSync7(agentsPath)) return { changedFiles: [], details: [] };
|
|
1593
|
+
for (const file of ["CLAUDE.md", "GEMINI.md"]) {
|
|
1594
|
+
const source = join9(repoRoot, file);
|
|
1595
|
+
if (!existsSync7(source)) continue;
|
|
1596
|
+
const stat = lstatSync(source);
|
|
1597
|
+
if (stat.isSymbolicLink()) continue;
|
|
1598
|
+
if (stat.isFile()) {
|
|
1599
|
+
if (!dryRun) renameSync(source, agentsPath);
|
|
1600
|
+
return { changedFiles: [agentsPath], details: [`Moved ${file} to AGENTS.md before wiring agent-file symlinks`] };
|
|
1352
1601
|
}
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1602
|
+
return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
|
|
1603
|
+
}
|
|
1604
|
+
const readmePath = join9(repoRoot, "README.md");
|
|
1605
|
+
if (existsSync7(readmePath)) {
|
|
1606
|
+
const stat = lstatSync(readmePath);
|
|
1607
|
+
if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
|
|
1608
|
+
if (!dryRun) copyFileSync(readmePath, agentsPath);
|
|
1609
|
+
return { changedFiles: [agentsPath], details: ["Copied README.md to AGENTS.md before wiring agent-file symlinks"] };
|
|
1610
|
+
}
|
|
1611
|
+
return { changedFiles: [], details: [], blocked: "AGENTS.md missing and no CLAUDE.md, GEMINI.md, or README.md source exists" };
|
|
1612
|
+
}
|
|
1613
|
+
function yamlGet(text3, keyPath) {
|
|
1614
|
+
const parts = keyPath.split(".");
|
|
1615
|
+
const lines = text3.split("\n");
|
|
1616
|
+
let start = 0;
|
|
1617
|
+
let indent = 0;
|
|
1618
|
+
for (let idx = 0; idx < parts.length; idx += 1) {
|
|
1619
|
+
const key = parts[idx];
|
|
1620
|
+
let found = false;
|
|
1621
|
+
for (let i = start; i < lines.length; i += 1) {
|
|
1622
|
+
const line = lines[i];
|
|
1623
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
1624
|
+
const match = line.match(/^(\s*)([^:#]+):\s*(.*)$/);
|
|
1625
|
+
if (!match) continue;
|
|
1626
|
+
const currentIndent = match[1].length;
|
|
1627
|
+
const currentKey = match[2].trim();
|
|
1628
|
+
const rest = match[3].trim();
|
|
1629
|
+
if (idx > 0 && currentIndent < indent) break;
|
|
1630
|
+
if (currentIndent !== indent || currentKey !== key) continue;
|
|
1631
|
+
found = true;
|
|
1632
|
+
if (idx === parts.length - 1) {
|
|
1633
|
+
return rest.replace(/^['"]|['"]$/g, "").trim();
|
|
1634
|
+
}
|
|
1635
|
+
start = i + 1;
|
|
1636
|
+
indent = currentIndent + 2;
|
|
1637
|
+
break;
|
|
1361
1638
|
}
|
|
1639
|
+
if (!found) return "";
|
|
1640
|
+
}
|
|
1641
|
+
return "";
|
|
1642
|
+
}
|
|
1643
|
+
function discoverRoles(repoRoot) {
|
|
1644
|
+
const rolesDir = join9(repoRoot, "agents", "hermes");
|
|
1645
|
+
if (!existsSync7(rolesDir)) return [];
|
|
1646
|
+
return readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
|
|
1647
|
+
const roleDir = join9(rolesDir, entry.name);
|
|
1648
|
+
const roleYamlPath = join9(roleDir, "role.yaml");
|
|
1649
|
+
if (!existsSync7(roleYamlPath)) return null;
|
|
1650
|
+
const text3 = readText(roleYamlPath);
|
|
1651
|
+
const runtimeRepoRaw = yamlGet(text3, "runtime.github_repo");
|
|
1362
1652
|
return {
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1653
|
+
role: yamlGet(text3, "role") || entry.name,
|
|
1654
|
+
roleDir,
|
|
1655
|
+
roleYamlPath,
|
|
1656
|
+
repo: yamlGet(text3, "repo"),
|
|
1657
|
+
agentId: yamlGet(text3, "agent_id"),
|
|
1658
|
+
profileName: yamlGet(text3, "profile") || yamlGet(text3, "agent_id"),
|
|
1659
|
+
displayName: yamlGet(text3, "display_name"),
|
|
1660
|
+
purpose: yamlGet(text3, "purpose"),
|
|
1661
|
+
botHandle: yamlGet(text3, "telegram.bot_username"),
|
|
1662
|
+
runtimeRepo: runtimeRepoRaw.includes("/") ? runtimeRepoRaw.split("/").slice(-1)[0] ?? runtimeRepoRaw : runtimeRepoRaw,
|
|
1663
|
+
runtimeOwner: yamlGet(text3, "runtime.github_owner"),
|
|
1664
|
+
planeWorkspace: yamlGet(text3, "ticket_provider.workspace") || yamlGet(text3, "plane.workspace"),
|
|
1665
|
+
ticketProviderName: yamlGet(text3, "ticket_provider.name"),
|
|
1666
|
+
ticketProviderBoardId: yamlGet(text3, "ticket_provider.board_id"),
|
|
1667
|
+
ticketProviderBoardUrl: yamlGet(text3, "ticket_provider.board_url"),
|
|
1668
|
+
ticketProviderIdentifier: yamlGet(text3, "plane.identifier")
|
|
1377
1669
|
};
|
|
1670
|
+
}).filter((value) => Boolean(value));
|
|
1671
|
+
}
|
|
1672
|
+
function registryPath(homeDir) {
|
|
1673
|
+
return join9(homeDir, ".hermes", "agents-registry.yaml");
|
|
1674
|
+
}
|
|
1675
|
+
function systemctlUser(args) {
|
|
1676
|
+
const result = spawnSync4("systemctl", ["--user", ...args], { encoding: "utf8" });
|
|
1677
|
+
return {
|
|
1678
|
+
ok: result.status === 0,
|
|
1679
|
+
stdout: result.stdout.trim(),
|
|
1680
|
+
stderr: result.stderr.trim()
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
function templateScript(ctx, name) {
|
|
1684
|
+
const source = join9(ctx.pjanglerRoot, ".mise", "scripts", name);
|
|
1685
|
+
return existsSync7(source) ? readText(source) : void 0;
|
|
1686
|
+
}
|
|
1687
|
+
function templateVersioningScript(ctx) {
|
|
1688
|
+
return templateScript(ctx, "versioning.sh");
|
|
1689
|
+
}
|
|
1690
|
+
function templateLinkAgentfilesScript(ctx) {
|
|
1691
|
+
return templateScript(ctx, "link-agentfiles.sh");
|
|
1692
|
+
}
|
|
1693
|
+
function renderGeneratedProjectMiseToml(ctx, template) {
|
|
1694
|
+
const project = readProjectJson(ctx);
|
|
1695
|
+
const projectName = String(project?.project_name ?? basename2(ctx.repoRoot) ?? "project");
|
|
1696
|
+
return template.replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
|
|
1697
|
+
}
|
|
1698
|
+
function ensureMiseTomlFromTemplate(ctx, changedFiles) {
|
|
1699
|
+
const targetPath = join9(ctx.repoRoot, "mise.toml");
|
|
1700
|
+
if (existsSync7(targetPath)) return false;
|
|
1701
|
+
const sourcePath = join9(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
|
|
1702
|
+
if (!existsSync7(sourcePath)) return false;
|
|
1703
|
+
changedFiles.push(targetPath);
|
|
1704
|
+
if (!ctx.dryRun) {
|
|
1705
|
+
writeText(targetPath, renderGeneratedProjectMiseToml(ctx, readText(sourcePath)));
|
|
1378
1706
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1707
|
+
return true;
|
|
1708
|
+
}
|
|
1709
|
+
function templateVersionFilesConf(ctx, repoRoot) {
|
|
1710
|
+
const packageJson = join9(repoRoot, "package.json");
|
|
1711
|
+
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";
|
|
1712
|
+
}
|
|
1713
|
+
function replaceOrAppendManagedBlock(text3, startMarker, block, beforePattern) {
|
|
1714
|
+
if (startMarker.test(text3)) {
|
|
1715
|
+
return text3.replace(/# >>> mise-versioning >>>[\s\S]*?# <<< mise-versioning <<</, block);
|
|
1716
|
+
}
|
|
1717
|
+
if (beforePattern) {
|
|
1718
|
+
const match = text3.match(beforePattern);
|
|
1719
|
+
if (match && typeof match.index === "number") {
|
|
1720
|
+
return `${text3.slice(0, match.index).replace(/\s*$/, "\n\n")}${block}
|
|
1721
|
+
|
|
1722
|
+
${text3.slice(match.index)}`;
|
|
1390
1723
|
}
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1724
|
+
}
|
|
1725
|
+
return `${text3.replace(/\s*$/, "")}
|
|
1726
|
+
|
|
1727
|
+
${block}
|
|
1728
|
+
`;
|
|
1729
|
+
}
|
|
1730
|
+
var BASE_MISE_PATH_ENTRIES = [".mise/scripts", "agents/hermes/pm"];
|
|
1731
|
+
var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/hermes"];
|
|
1732
|
+
function requiredMisePathEntries(ctx) {
|
|
1733
|
+
const required = [...BASE_MISE_PATH_ENTRIES];
|
|
1734
|
+
for (const candidate of CONDITIONAL_HERMES_PATHS) {
|
|
1735
|
+
if (existsSync7(join9(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
|
|
1736
|
+
}
|
|
1737
|
+
return required;
|
|
1738
|
+
}
|
|
1739
|
+
function upsertMisePath(text3, required = BASE_MISE_PATH_ENTRIES) {
|
|
1740
|
+
const render = (values) => `_.path = [${values.map((value) => JSON.stringify(value)).join(", ")}]`;
|
|
1741
|
+
const envMatch = text3.match(/(^|\n)(\[env\][\s\S]*?)(?=\n\[[^\]]+\]|$)/);
|
|
1742
|
+
if (!envMatch || typeof envMatch.index !== "number") {
|
|
1743
|
+
return `[env]
|
|
1744
|
+
${render(required)}
|
|
1745
|
+
|
|
1746
|
+
${text3.replace(/^\s+/, "")}`;
|
|
1747
|
+
}
|
|
1748
|
+
const prefix = text3.slice(0, envMatch.index + envMatch[1].length);
|
|
1749
|
+
const section = envMatch[2];
|
|
1750
|
+
const suffix = text3.slice(envMatch.index + envMatch[1].length + section.length);
|
|
1751
|
+
const pathLine = section.match(/^_\.path\s*=\s*\[([^\]]*)\]\s*$/m);
|
|
1752
|
+
if (!pathLine) {
|
|
1753
|
+
return `${prefix}${section.replace(/\n?$/, "\n")}${render(required)}${suffix}`;
|
|
1754
|
+
}
|
|
1755
|
+
const current = [...pathLine[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
|
|
1756
|
+
const merged = [...current];
|
|
1757
|
+
for (const value of required) {
|
|
1758
|
+
if (!merged.includes(value)) merged.push(value);
|
|
1759
|
+
}
|
|
1760
|
+
const nextLine = render(merged);
|
|
1761
|
+
if (pathLine[0] === nextLine) return text3;
|
|
1762
|
+
return `${prefix}${section.replace(pathLine[0], nextLine)}${suffix}`;
|
|
1763
|
+
}
|
|
1764
|
+
function removeTomlSection(text3, headerPattern, marker, options) {
|
|
1765
|
+
const lines = text3.split("\n");
|
|
1766
|
+
let start = -1;
|
|
1767
|
+
let end = -1;
|
|
1768
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1769
|
+
if (!headerPattern.test(lines[i])) continue;
|
|
1770
|
+
if (marker) {
|
|
1771
|
+
let hasMarker = false;
|
|
1772
|
+
for (let j = i + 1; j < lines.length && !/^\[[^\]]+\]/.test(lines[j]); j++) {
|
|
1773
|
+
if (marker.test(lines[j])) {
|
|
1774
|
+
hasMarker = true;
|
|
1775
|
+
break;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
if (!hasMarker) continue;
|
|
1779
|
+
}
|
|
1780
|
+
start = i;
|
|
1781
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
1782
|
+
if (/^\[[^\]]+\]/.test(lines[j])) {
|
|
1783
|
+
end = j;
|
|
1784
|
+
break;
|
|
1785
|
+
}
|
|
1425
1786
|
}
|
|
1787
|
+
if (end === -1) end = lines.length;
|
|
1788
|
+
break;
|
|
1426
1789
|
}
|
|
1427
|
-
);
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
title: "Deploy Hermes agent",
|
|
1432
|
-
description: "Provision a Hermes agent role with an inherited named profile and optional integrations (Telegram, email, runtime repo, Plane, Bloodbank, systemd).",
|
|
1433
|
-
inputSchema: {
|
|
1434
|
-
targetDir: z.string(),
|
|
1435
|
-
targetRepo: z.string(),
|
|
1436
|
-
role: z.string(),
|
|
1437
|
-
agentPurpose: z.string().optional(),
|
|
1438
|
-
soulTone: z.enum(["direct", "playful", "formal", "terse"]).optional(),
|
|
1439
|
-
modelProvider: z.string().optional(),
|
|
1440
|
-
modelName: z.string().optional(),
|
|
1441
|
-
force: z.boolean().optional(),
|
|
1442
|
-
dryRun: z.boolean().optional(),
|
|
1443
|
-
skipTelegram: z.boolean().optional(),
|
|
1444
|
-
skipEmail: z.boolean().optional(),
|
|
1445
|
-
skipRuntimeRepo: z.boolean().optional(),
|
|
1446
|
-
skipPlane: z.boolean().optional(),
|
|
1447
|
-
skipBloodbank: z.boolean().optional(),
|
|
1448
|
-
skipSystemd: z.boolean().optional()
|
|
1790
|
+
if (start === -1) return text3;
|
|
1791
|
+
if (options?.includePrecedingComments) {
|
|
1792
|
+
while (start > 0 && lines[start - 1].trim().startsWith("#")) {
|
|
1793
|
+
start--;
|
|
1449
1794
|
}
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1795
|
+
}
|
|
1796
|
+
const result = lines.slice(0, start).concat(lines.slice(end)).join("\n");
|
|
1797
|
+
return result.replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
|
|
1798
|
+
}
|
|
1799
|
+
function insertTomlBlockBeforeVersioning(text3, block) {
|
|
1800
|
+
const versioningIndex = text3.indexOf("# >>> mise-versioning >>>");
|
|
1801
|
+
if (versioningIndex >= 0) {
|
|
1802
|
+
return `${text3.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${block}
|
|
1803
|
+
|
|
1804
|
+
${text3.slice(versioningIndex)}`;
|
|
1805
|
+
}
|
|
1806
|
+
return `${text3.replace(/\s*$/, "")}
|
|
1807
|
+
|
|
1808
|
+
${block}
|
|
1809
|
+
`;
|
|
1810
|
+
}
|
|
1811
|
+
function extractTomlStrings(text3) {
|
|
1812
|
+
const values = [];
|
|
1813
|
+
const stringPattern = /"((?:\\.|[^"\\])*)"|'([^']*)'/g;
|
|
1814
|
+
for (const match of text3.matchAll(stringPattern)) {
|
|
1815
|
+
if (match[1] !== void 0) {
|
|
1816
|
+
try {
|
|
1817
|
+
values.push(JSON.parse(`"${match[1]}"`));
|
|
1818
|
+
} catch {
|
|
1819
|
+
values.push(match[1]);
|
|
1820
|
+
}
|
|
1821
|
+
} else if (match[2] !== void 0) {
|
|
1822
|
+
values.push(match[2]);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
return values;
|
|
1826
|
+
}
|
|
1827
|
+
function isManagedHookEntry(value) {
|
|
1828
|
+
const trimmed = value.trim();
|
|
1829
|
+
return trimmed === "op inject -i .env.op > .env" || /(^|\/)link-agentfiles\.sh$/.test(trimmed);
|
|
1830
|
+
}
|
|
1831
|
+
function renderHookEntries(entries, indent = "") {
|
|
1832
|
+
return [
|
|
1833
|
+
`${indent}enter = [`,
|
|
1834
|
+
...entries.map((entry) => `${indent} ${JSON.stringify(entry)},`),
|
|
1835
|
+
`${indent}]`
|
|
1836
|
+
];
|
|
1837
|
+
}
|
|
1838
|
+
function upsertLinkAgentfilesHooks(text3) {
|
|
1839
|
+
const lines = text3.split("\n");
|
|
1840
|
+
const hooksStart = lines.findIndex((line) => /^\[hooks\]$/.test(line.trim()));
|
|
1841
|
+
if (hooksStart === -1) return insertTomlBlockBeforeVersioning(text3, LINK_AGENTFILES_HOOKS_BLOCK);
|
|
1842
|
+
let hooksEnd = lines.length;
|
|
1843
|
+
for (let i = hooksStart + 1; i < lines.length; i++) {
|
|
1844
|
+
if (/^\[[^\]]+\]/.test(lines[i].trim())) {
|
|
1845
|
+
hooksEnd = i;
|
|
1846
|
+
break;
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
let enterStart = -1;
|
|
1850
|
+
let enterEnd = -1;
|
|
1851
|
+
for (let i = hooksStart + 1; i < hooksEnd; i++) {
|
|
1852
|
+
if (!/^\s*enter\s*=/.test(lines[i])) continue;
|
|
1853
|
+
enterStart = i;
|
|
1854
|
+
enterEnd = i + 1;
|
|
1855
|
+
const afterEquals = lines[i].slice(lines[i].indexOf("=") + 1);
|
|
1856
|
+
if (afterEquals.includes("[") && !afterEquals.includes("]")) {
|
|
1857
|
+
while (enterEnd < hooksEnd && !lines[enterEnd].includes("]")) enterEnd++;
|
|
1858
|
+
if (enterEnd < hooksEnd) enterEnd++;
|
|
1859
|
+
}
|
|
1860
|
+
break;
|
|
1861
|
+
}
|
|
1862
|
+
const existingBlock = enterStart >= 0 ? lines.slice(enterStart, enterEnd).join("\n") : "";
|
|
1863
|
+
const preserved = extractTomlStrings(existingBlock).filter((entry) => !isManagedHookEntry(entry));
|
|
1864
|
+
const merged = [...LINK_AGENTFILES_HOOK_ENTRIES];
|
|
1865
|
+
for (const entry of preserved) {
|
|
1866
|
+
if (!merged.includes(entry)) merged.push(entry);
|
|
1867
|
+
}
|
|
1868
|
+
const indent = enterStart >= 0 ? lines[enterStart].match(/^\s*/)?.[0] ?? "" : "";
|
|
1869
|
+
const rendered = renderHookEntries(merged, indent);
|
|
1870
|
+
if (enterStart >= 0) {
|
|
1871
|
+
return lines.slice(0, enterStart).concat(rendered, lines.slice(enterEnd)).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
|
|
1872
|
+
}
|
|
1873
|
+
return lines.slice(0, hooksStart + 1).concat(rendered, lines.slice(hooksStart + 1)).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
|
|
1874
|
+
}
|
|
1875
|
+
function upsertLinkAgentfilesBlock(text3, ctx) {
|
|
1876
|
+
const withPath = upsertMisePath(text3, requiredMisePathEntries(ctx));
|
|
1877
|
+
if (withPath.includes(LINK_AGENTFILES_BLOCK)) return withPath;
|
|
1878
|
+
let cleaned = removeTomlSection(withPath, /^\[tasks\.link-agentfiles\]$/, /link-agentfiles/, { includePrecedingComments: false });
|
|
1879
|
+
cleaned = removeTomlSection(cleaned, /^\[\[watch_files\]\]$/, /AGENTS\.md/, { includePrecedingComments: false });
|
|
1880
|
+
cleaned = upsertLinkAgentfilesHooks(cleaned);
|
|
1881
|
+
return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
|
|
1882
|
+
}
|
|
1883
|
+
function readProjectJson(ctx) {
|
|
1884
|
+
return tryParseJson(safeReadText(join9(ctx.repoRoot, ".project.json")));
|
|
1885
|
+
}
|
|
1886
|
+
function canonicalProjectJson(ctx) {
|
|
1887
|
+
const roles = discoverRoles(ctx.repoRoot);
|
|
1888
|
+
const existing = readProjectJson(ctx) ?? {};
|
|
1889
|
+
const slug = String(existing.project_slug ?? slugifyRepoName(dirname6(ctx.repoRoot) === ctx.repoRoot ? ctx.repoRoot.split("/").pop() ?? "project" : ctx.repoRoot.split("/").pop() ?? "project"));
|
|
1890
|
+
const firstRole = roles[0];
|
|
1891
|
+
const ticketProvider = {
|
|
1892
|
+
type: String((existing.ticket_provider?.type ?? firstRole?.ticketProviderName ?? "plane") || "plane"),
|
|
1893
|
+
workspace: String((existing.ticket_provider?.workspace ?? firstRole?.planeWorkspace ?? "") || ""),
|
|
1894
|
+
identifier: String((existing.ticket_provider?.identifier ?? firstRole?.ticketProviderIdentifier ?? "") || ""),
|
|
1895
|
+
board_id: String((existing.ticket_provider?.board_id ?? firstRole?.ticketProviderBoardId ?? "") || ""),
|
|
1896
|
+
board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || ""),
|
|
1897
|
+
state: String((existing.ticket_provider?.state ?? "planned") || "planned")
|
|
1898
|
+
};
|
|
1899
|
+
const existingAgents = existing.agents ?? {};
|
|
1900
|
+
const discoveredAgents = Object.fromEntries(
|
|
1901
|
+
roles.map((role) => [
|
|
1902
|
+
role.agentId || `${slug}-${role.role}`,
|
|
1903
|
+
{
|
|
1904
|
+
role: role.role,
|
|
1905
|
+
role_dir: relative(ctx.repoRoot, role.roleDir)
|
|
1906
|
+
}
|
|
1907
|
+
])
|
|
1908
|
+
);
|
|
1909
|
+
const agents = { ...existingAgents };
|
|
1910
|
+
for (const [agentId, discovered] of Object.entries(discoveredAgents)) {
|
|
1911
|
+
const existingAgent = existingAgents[agentId] ?? {};
|
|
1912
|
+
agents[agentId] = {
|
|
1913
|
+
role: discovered.role,
|
|
1914
|
+
role_dir: discovered.role_dir,
|
|
1915
|
+
provisioning_state: existingAgent.provisioning_state
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
return {
|
|
1919
|
+
project_name: String(existing.project_name ?? titleCaseSlug(slug)),
|
|
1920
|
+
project_description: String(existing.project_description ?? ""),
|
|
1921
|
+
project_slug: slug,
|
|
1922
|
+
repo_path: ctx.repoRoot,
|
|
1923
|
+
ticket_provider: ticketProvider,
|
|
1924
|
+
agents
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
function projectJsonFinding(ctx) {
|
|
1928
|
+
const projectPath = join9(ctx.repoRoot, ".project.json");
|
|
1929
|
+
const planeJsonPath = join9(ctx.repoRoot, ".plane.json");
|
|
1930
|
+
const details = [];
|
|
1931
|
+
const data = readProjectJson(ctx);
|
|
1932
|
+
const roles = discoverRoles(ctx.repoRoot);
|
|
1933
|
+
if (!existsSync7(projectPath)) {
|
|
1934
|
+
return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json missing", details: [], fixable: true };
|
|
1935
|
+
}
|
|
1936
|
+
if (!data) {
|
|
1937
|
+
return { id: "sot.project-json", title: "Canonical .project.json", status: "fail", summary: ".project.json is not valid JSON", details: [], fixable: true };
|
|
1938
|
+
}
|
|
1939
|
+
for (const key of ["project_name", "project_description", "project_slug", "repo_path", "ticket_provider", "agents"]) {
|
|
1940
|
+
if (!(key in data)) details.push(`missing key: ${key}`);
|
|
1941
|
+
}
|
|
1942
|
+
if (data.repo_path !== ctx.repoRoot) details.push(`repo_path should be ${ctx.repoRoot}`);
|
|
1943
|
+
const agents = data.agents ?? {};
|
|
1944
|
+
for (const role of roles) {
|
|
1945
|
+
const agent = agents[role.agentId];
|
|
1946
|
+
if (!agent) {
|
|
1947
|
+
details.push(`agents.${role.agentId} missing`);
|
|
1948
|
+
continue;
|
|
1949
|
+
}
|
|
1950
|
+
if (agent.role !== role.role) details.push(`agents.${role.agentId}.role should be ${role.role}`);
|
|
1951
|
+
if (agent.role_dir !== relative(ctx.repoRoot, role.roleDir)) {
|
|
1952
|
+
details.push(`agents.${role.agentId}.role_dir should be ${relative(ctx.repoRoot, role.roleDir)}`);
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
const ticketProvider = data.ticket_provider ?? {};
|
|
1956
|
+
for (const key of ["type", "workspace", "identifier", "board_id", "board_url", "state"]) {
|
|
1957
|
+
if (!(key in ticketProvider)) details.push(`ticket_provider.${key} missing`);
|
|
1958
|
+
}
|
|
1959
|
+
if (existsSync7(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
|
|
1960
|
+
return {
|
|
1961
|
+
id: "sot.project-json",
|
|
1962
|
+
title: "Canonical .project.json",
|
|
1963
|
+
status: details.length === 0 ? "pass" : "fail",
|
|
1964
|
+
summary: details.length === 0 ? ".project.json matches canonical parity contract" : `${details.length} parity issue(s) detected`,
|
|
1965
|
+
details,
|
|
1966
|
+
fixable: true
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
function renderSoul(role) {
|
|
1970
|
+
const telegram = role.botHandle ? `@${role.botHandle}` : "(unwired)";
|
|
1971
|
+
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.";
|
|
1972
|
+
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.`;
|
|
1973
|
+
const runtimeOwner = role.runtimeOwner || "delorenj";
|
|
1974
|
+
return `# ${role.displayName || role.agentId}
|
|
1975
|
+
|
|
1976
|
+
You are **${role.displayName || role.agentId}** \u2014 a Hermes agent provisioned to work inside the
|
|
1977
|
+
\`${role.repo}\` repository.
|
|
1978
|
+
|
|
1979
|
+
## Identity
|
|
1980
|
+
|
|
1981
|
+
| | |
|
|
1982
|
+
| --- | --- |
|
|
1983
|
+
| Agent ID | \`${role.agentId}\` |
|
|
1984
|
+
| Profile | \`${role.profileName || role.agentId}\` |
|
|
1985
|
+
| Repo | \`${role.repo}\` |
|
|
1986
|
+
| Role | \`${role.role}\` |
|
|
1987
|
+
| Telegram | \`${telegram}\` |
|
|
1988
|
+
| Purpose | ${role.purpose || `${role.role} agent for ${role.repo}`} |
|
|
1989
|
+
|
|
1990
|
+
## Scope
|
|
1991
|
+
|
|
1992
|
+
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.
|
|
1993
|
+
|
|
1994
|
+
## Tone
|
|
1995
|
+
|
|
1996
|
+
${tone}
|
|
1997
|
+
|
|
1998
|
+
## Role-specific behavior
|
|
1999
|
+
|
|
2000
|
+
${roleSpecific}
|
|
2001
|
+
|
|
2002
|
+
## Memory hygiene
|
|
2003
|
+
|
|
2004
|
+
Your memory is the submodule at \`./runtime/memories/\`. Use durable memory deliberately and keep \`memories/MEMORY.md\` current.
|
|
2005
|
+
`;
|
|
2006
|
+
}
|
|
2007
|
+
function renderHermesWrapper(role) {
|
|
2008
|
+
return `#!/usr/bin/env bash
|
|
2009
|
+
# Launcher for ${role.agentId}. Resolves HERMES_HOME to the runtime submodule.
|
|
2010
|
+
|
|
2011
|
+
set -euo pipefail
|
|
2012
|
+
|
|
2013
|
+
ROLE_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
2014
|
+
RUNTIME_HOME="$ROLE_DIR/runtime"
|
|
2015
|
+
|
|
2016
|
+
FLEET_ENV="{HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
|
|
2017
|
+
if [[ -f "$FLEET_ENV" ]]; then
|
|
2018
|
+
# shellcheck disable=SC1090
|
|
2019
|
+
source "$FLEET_ENV"
|
|
2020
|
+
fi
|
|
2021
|
+
|
|
2022
|
+
HERMES_BIN="{HERMES_BIN:-{HERMES_FLEET_BIN:-/home/delorenj/code/hermes-agent/.venv/bin/hermes}}"
|
|
2023
|
+
HERMES_OAUTH_FILE="{HERMES_OAUTH_FILE:-{HERMES_FLEET_OAUTH_FILE:-$HOME/.hermes/auth.json}}"
|
|
2024
|
+
CODEX_HOME="{CODEX_HOME:-{HERMES_FLEET_CODEX_HOME:-$HOME/.codex}}"
|
|
2025
|
+
|
|
2026
|
+
FLEET_HOME="{HERMES_FLEET_HOME:-$HOME/.hermes}"
|
|
2027
|
+
PROFILE_NAME="{HERMES_PROFILE_NAME:-${role.profileName || role.agentId}}"
|
|
2028
|
+
HERMES_HOME="$RUNTIME_HOME"
|
|
2029
|
+
|
|
2030
|
+
if [[ ! -d "$RUNTIME_HOME" ]]; then
|
|
2031
|
+
echo "hermes: runtime submodule not initialized at $RUNTIME_HOME" >&2
|
|
2032
|
+
echo " fix: git submodule update --init --recursive" >&2
|
|
2033
|
+
exit 1
|
|
2034
|
+
fi
|
|
2035
|
+
|
|
2036
|
+
exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" HERMES_OAUTH_FILE="$HERMES_OAUTH_FILE" CODEX_HOME="$CODEX_HOME" "$HERMES_BIN" "$@"
|
|
2037
|
+
`.replace(/\u0010/g, "$");
|
|
2038
|
+
}
|
|
2039
|
+
function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip) {
|
|
2040
|
+
if (!existsSync7(sourceDir)) return;
|
|
2041
|
+
mkdirSync5(targetDir, { recursive: true });
|
|
2042
|
+
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
|
|
2043
|
+
const sourcePath = join9(sourceDir, entry.name);
|
|
2044
|
+
if (skip?.(sourcePath)) continue;
|
|
2045
|
+
const targetPath = join9(targetDir, entry.name);
|
|
2046
|
+
if (entry.isDirectory()) {
|
|
2047
|
+
copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
|
|
2048
|
+
continue;
|
|
2049
|
+
}
|
|
2050
|
+
if (existsSync7(targetPath)) continue;
|
|
2051
|
+
changedFiles.push(targetPath);
|
|
2052
|
+
if (!dryRun) {
|
|
2053
|
+
ensureParent(targetPath);
|
|
2054
|
+
copyFileSync(sourcePath, targetPath);
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
|
|
2059
|
+
const gitmodulesPath = join9(repoRoot, ".gitmodules");
|
|
2060
|
+
const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
|
|
2061
|
+
const owner = role.runtimeOwner || "delorenj";
|
|
2062
|
+
const block = `[submodule "agents/hermes/${role.role}/runtime"]
|
|
2063
|
+
path = agents/hermes/${role.role}/runtime
|
|
2064
|
+
url = git@github.com:${owner}/${repoName}.git
|
|
2065
|
+
`;
|
|
2066
|
+
const current = safeReadText(gitmodulesPath) ?? "";
|
|
2067
|
+
const header = `[submodule "agents/hermes/${role.role}/runtime"]`;
|
|
2068
|
+
if (current.includes(header)) return [];
|
|
2069
|
+
changedFiles.push(gitmodulesPath);
|
|
2070
|
+
if (!dryRun) writeText(gitmodulesPath, `${current.replace(/\s*$/, "")}${current.trim() ? "\n" : ""}${block}`);
|
|
2071
|
+
return [gitmodulesPath];
|
|
2072
|
+
}
|
|
2073
|
+
function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
|
|
2074
|
+
const path = registryPath(homeDir);
|
|
2075
|
+
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";
|
|
2076
|
+
if (current.includes(`${role.agentId}:`)) return null;
|
|
2077
|
+
const block = ` ${role.agentId}:
|
|
2078
|
+
repo: ${role.repo}
|
|
2079
|
+
role: ${role.role}
|
|
2080
|
+
display_name: ${JSON.stringify(role.displayName || role.agentId)}
|
|
2081
|
+
project_path: ${ctxEscape(role.roleDir ? dirname6(dirname6(dirname6(role.roleDir))) : "")}
|
|
2082
|
+
role_dir: ${ctxEscape(role.roleDir)}
|
|
2083
|
+
profile_name: ${role.profileName || role.agentId}
|
|
2084
|
+
telegram:
|
|
2085
|
+
bot_username: ${ctxEscape(role.botHandle)}
|
|
2086
|
+
plane:
|
|
2087
|
+
workspace: ${ctxEscape(role.planeWorkspace)}
|
|
2088
|
+
project_id: ${ctxEscape(role.ticketProviderBoardId)}
|
|
2089
|
+
identifier: ${ctxEscape(role.ticketProviderIdentifier)}
|
|
2090
|
+
runtime_repo: ${ctxEscape(role.runtimeRepo)}
|
|
2091
|
+
systemd:
|
|
2092
|
+
gateway_unit: hermes-${role.agentId}-gateway.service
|
|
2093
|
+
consumer_unit: hermes-${role.agentId}-consumer.service
|
|
2094
|
+
heartbeat_timer: hermes-${role.agentId}-heartbeat.timer
|
|
2095
|
+
`;
|
|
2096
|
+
const next = current.includes("agents: {}") ? current.replace("agents: {}", `agents:
|
|
2097
|
+
${block}`) : `${current.replace(/\s*$/, "\n")}${block}`;
|
|
2098
|
+
changedFiles.push(path);
|
|
2099
|
+
if (!dryRun) writeText(path, next);
|
|
2100
|
+
return path;
|
|
2101
|
+
}
|
|
2102
|
+
function profileMetaInheritsDefault(path) {
|
|
2103
|
+
const text3 = safeReadText(path);
|
|
2104
|
+
return Boolean(
|
|
2105
|
+
text3 && /^config:\s*$/m.test(text3) && /^\s+inherit_from:\s*default\s*$/m.test(text3) && /^\s+save_mode:\s*delta\s*$/m.test(text3)
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
function upsertInheritedProfileMeta(path, changedFiles, dryRun) {
|
|
2109
|
+
const current = safeReadText(path) ?? "";
|
|
2110
|
+
const lines = current.split("\n");
|
|
2111
|
+
let next;
|
|
2112
|
+
const start = lines.findIndex((line) => /^config:\s*$/.test(line));
|
|
2113
|
+
if (!current.trim()) {
|
|
2114
|
+
next = "config:\n inherit_from: default\n save_mode: delta\n";
|
|
2115
|
+
} else if (start === -1) {
|
|
2116
|
+
next = `${current.replace(/\s*$/, "\n")}config:
|
|
2117
|
+
inherit_from: default
|
|
2118
|
+
save_mode: delta
|
|
2119
|
+
`;
|
|
2120
|
+
} else {
|
|
2121
|
+
let end = start + 1;
|
|
2122
|
+
while (end < lines.length && !/^[^#\s][^:]*:\s*/.test(lines[end] ?? "")) end++;
|
|
2123
|
+
let hasInherit = false;
|
|
2124
|
+
let hasSave = false;
|
|
2125
|
+
for (let idx = start + 1; idx < end; idx++) {
|
|
2126
|
+
if (/^\s+inherit_from:\s*/.test(lines[idx] ?? "")) {
|
|
2127
|
+
lines[idx] = " inherit_from: default";
|
|
2128
|
+
hasInherit = true;
|
|
2129
|
+
} else if (/^\s+save_mode:\s*/.test(lines[idx] ?? "")) {
|
|
2130
|
+
lines[idx] = " save_mode: delta";
|
|
2131
|
+
hasSave = true;
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
const inserts = [];
|
|
2135
|
+
if (!hasInherit) inserts.push(" inherit_from: default");
|
|
2136
|
+
if (!hasSave) inserts.push(" save_mode: delta");
|
|
2137
|
+
if (inserts.length) lines.splice(end, 0, ...inserts);
|
|
2138
|
+
next = lines.join("\n");
|
|
2139
|
+
if (!next.endsWith("\n")) next += "\n";
|
|
2140
|
+
}
|
|
2141
|
+
if (next === current) return null;
|
|
2142
|
+
changedFiles.push(path);
|
|
2143
|
+
if (!dryRun) writeText(path, next);
|
|
2144
|
+
return path;
|
|
2145
|
+
}
|
|
2146
|
+
function ctxEscape(value) {
|
|
2147
|
+
return JSON.stringify(value || "");
|
|
2148
|
+
}
|
|
2149
|
+
function checkUnit(unit) {
|
|
2150
|
+
const enabled = systemctlUser(["is-enabled", unit]).ok;
|
|
2151
|
+
const active = systemctlUser(["is-active", unit]).ok;
|
|
2152
|
+
return { enabled, active };
|
|
2153
|
+
}
|
|
2154
|
+
var RULES = [
|
|
2155
|
+
{
|
|
2156
|
+
id: "mise.config-root",
|
|
2157
|
+
title: "mise config_root + AGENTS link hooks",
|
|
2158
|
+
audit: (ctx) => {
|
|
2159
|
+
const misePath = join9(ctx.repoRoot, "mise.toml");
|
|
2160
|
+
if (!existsSync7(misePath)) {
|
|
2161
|
+
return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
|
|
2162
|
+
}
|
|
2163
|
+
const text3 = readText(misePath);
|
|
2164
|
+
const details = [];
|
|
2165
|
+
const linkAgentfilesPath = join9(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
|
|
2166
|
+
if (!existsSync7(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
|
|
2167
|
+
const pathValues = [...(text3.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
|
|
2168
|
+
const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
|
|
2169
|
+
if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
|
|
2170
|
+
if (!text3.includes('"{{config_root}}/.mise/scripts/link-agentfiles.sh"')) details.push("link-agentfiles must use raw {{config_root}} guard");
|
|
2171
|
+
if (!text3.includes("op inject -i .env.op > .env")) details.push("[hooks].enter must materialize .env from .env.op");
|
|
2172
|
+
if (!text3.includes('patterns = ["AGENTS.md"]')) details.push("watch_files must monitor AGENTS.md");
|
|
2173
|
+
if (!text3.includes('task = "link-agentfiles"')) details.push("watch_files must dispatch link-agentfiles task");
|
|
2174
|
+
return {
|
|
2175
|
+
id: "mise.config-root",
|
|
2176
|
+
title: "mise config_root + AGENTS link hooks",
|
|
2177
|
+
status: details.length === 0 ? "pass" : "fail",
|
|
2178
|
+
summary: details.length === 0 ? "mise AGENTS-linking parity verified" : `${details.length} issue(s) detected in mise AGENTS-linking contract`,
|
|
2179
|
+
details,
|
|
2180
|
+
fixable: true
|
|
2181
|
+
};
|
|
2182
|
+
},
|
|
2183
|
+
migrate: (ctx, finding) => {
|
|
2184
|
+
const path = join9(ctx.repoRoot, "mise.toml");
|
|
2185
|
+
const changedFiles = [];
|
|
2186
|
+
const details = [];
|
|
2187
|
+
if (!existsSync7(path)) {
|
|
2188
|
+
if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
|
|
2189
|
+
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: [] };
|
|
2190
|
+
}
|
|
2191
|
+
details.push("Initialized mise.toml from generated-project template");
|
|
2192
|
+
if (ctx.dryRun) {
|
|
2193
|
+
return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
let text3 = readText(path);
|
|
2197
|
+
const next = upsertLinkAgentfilesBlock(text3, ctx);
|
|
2198
|
+
if (next !== text3) {
|
|
2199
|
+
if (!changedFiles.includes(path)) changedFiles.push(path);
|
|
2200
|
+
if (!ctx.dryRun) writeText(path, next);
|
|
2201
|
+
text3 = next;
|
|
2202
|
+
}
|
|
2203
|
+
const linkAgentfilesPath = join9(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
|
|
2204
|
+
const expectedScript = templateLinkAgentfilesScript(ctx);
|
|
2205
|
+
if (expectedScript === void 0) {
|
|
2206
|
+
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: [] };
|
|
2207
|
+
}
|
|
2208
|
+
if (safeReadText(linkAgentfilesPath) !== expectedScript) {
|
|
2209
|
+
changedFiles.push(linkAgentfilesPath);
|
|
2210
|
+
if (!ctx.dryRun) {
|
|
2211
|
+
writeText(linkAgentfilesPath, expectedScript);
|
|
2212
|
+
chmodSync(linkAgentfilesPath, 493);
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
return {
|
|
2216
|
+
id: finding.id,
|
|
2217
|
+
title: finding.title,
|
|
2218
|
+
status: changedFiles.length ? "applied" : "noop",
|
|
2219
|
+
summary: changedFiles.length ? "Updated mise AGENTS-linking contract" : "No changes required",
|
|
2220
|
+
changedFiles,
|
|
2221
|
+
details: changedFiles.length ? ["Normalized hooks/watch_files/tasks.link-agentfiles block and script"] : []
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
},
|
|
2225
|
+
{
|
|
2226
|
+
id: "mise.versioning",
|
|
2227
|
+
title: "managed mise versioning block",
|
|
2228
|
+
audit: (ctx) => {
|
|
2229
|
+
const details = [];
|
|
2230
|
+
const misePath = join9(ctx.repoRoot, "mise.toml");
|
|
2231
|
+
const versioningPath = join9(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
|
|
2232
|
+
const manifestPath = join9(ctx.repoRoot, ".mise", "version-files.conf");
|
|
2233
|
+
const text3 = safeReadText(misePath);
|
|
2234
|
+
if (!text3?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
|
|
2235
|
+
if (!existsSync7(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
|
|
2236
|
+
if (!existsSync7(manifestPath)) details.push(".mise/version-files.conf missing");
|
|
2237
|
+
return {
|
|
2238
|
+
id: "mise.versioning",
|
|
2239
|
+
title: "managed mise versioning block",
|
|
2240
|
+
status: details.length === 0 ? "pass" : "fail",
|
|
2241
|
+
summary: details.length === 0 ? "mise versioning parity verified" : `${details.length} versioning issue(s) detected`,
|
|
2242
|
+
details,
|
|
2243
|
+
fixable: true
|
|
2244
|
+
};
|
|
2245
|
+
},
|
|
2246
|
+
migrate: (ctx, finding) => {
|
|
2247
|
+
const changedFiles = [];
|
|
2248
|
+
const details = [];
|
|
2249
|
+
const misePath = join9(ctx.repoRoot, "mise.toml");
|
|
2250
|
+
if (!existsSync7(misePath)) {
|
|
2251
|
+
if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
|
|
2252
|
+
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: [] };
|
|
2253
|
+
}
|
|
2254
|
+
details.push("Initialized mise.toml from generated-project template");
|
|
2255
|
+
if (ctx.dryRun) {
|
|
2256
|
+
return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
const currentMise = readText(misePath);
|
|
2260
|
+
const nextMise = replaceOrAppendManagedBlock(currentMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
|
|
2261
|
+
if (nextMise !== currentMise) {
|
|
2262
|
+
if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
|
|
2263
|
+
if (!ctx.dryRun) writeText(misePath, nextMise);
|
|
2264
|
+
}
|
|
2265
|
+
const versioningPath = join9(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
|
|
2266
|
+
const expectedScript = templateVersioningScript(ctx);
|
|
2267
|
+
if (expectedScript === void 0) {
|
|
2268
|
+
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: [] };
|
|
2269
|
+
}
|
|
2270
|
+
if (safeReadText(versioningPath) !== expectedScript) {
|
|
2271
|
+
changedFiles.push(versioningPath);
|
|
2272
|
+
if (!ctx.dryRun) {
|
|
2273
|
+
writeText(versioningPath, expectedScript);
|
|
2274
|
+
chmodSync(versioningPath, 493);
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
const manifestPath = join9(ctx.repoRoot, ".mise", "version-files.conf");
|
|
2278
|
+
const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
|
|
2279
|
+
if (safeReadText(manifestPath) !== expectedManifest) {
|
|
2280
|
+
changedFiles.push(manifestPath);
|
|
2281
|
+
if (!ctx.dryRun) writeText(manifestPath, expectedManifest);
|
|
2282
|
+
}
|
|
2283
|
+
return {
|
|
2284
|
+
id: finding.id,
|
|
2285
|
+
title: finding.title,
|
|
2286
|
+
status: changedFiles.length ? "applied" : "noop",
|
|
2287
|
+
summary: changedFiles.length ? "Versioning block/script/manifest normalized" : "No changes required",
|
|
2288
|
+
changedFiles,
|
|
2289
|
+
details: []
|
|
2290
|
+
};
|
|
2291
|
+
}
|
|
2292
|
+
},
|
|
2293
|
+
{
|
|
2294
|
+
id: "sot.agent-symlinks",
|
|
2295
|
+
title: "AGENTS/CLAUDE/GEMINI symlink contract",
|
|
2296
|
+
audit: (ctx) => {
|
|
2297
|
+
const agentsPath = join9(ctx.repoRoot, "AGENTS.md");
|
|
2298
|
+
if (!existsSync7(agentsPath)) {
|
|
2299
|
+
const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync7(join9(ctx.repoRoot, file)));
|
|
2300
|
+
if (fallbackSources.length === 0) {
|
|
2301
|
+
return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
|
|
2302
|
+
}
|
|
2303
|
+
return {
|
|
2304
|
+
id: "sot.agent-symlinks",
|
|
2305
|
+
title: "AGENTS/CLAUDE/GEMINI symlink contract",
|
|
2306
|
+
status: "fail",
|
|
2307
|
+
summary: "AGENTS.md missing but can be derived from existing project documentation",
|
|
2308
|
+
details: [`AGENTS.md can be created from ${fallbackSources[0]}`],
|
|
2309
|
+
fixable: true
|
|
2310
|
+
};
|
|
2311
|
+
}
|
|
2312
|
+
const details = [];
|
|
2313
|
+
for (const file of ["CLAUDE.md", "GEMINI.md"]) {
|
|
2314
|
+
const full = join9(ctx.repoRoot, file);
|
|
2315
|
+
const target = readSymlinkTarget(full);
|
|
2316
|
+
if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
|
|
2317
|
+
}
|
|
2318
|
+
return {
|
|
2319
|
+
id: "sot.agent-symlinks",
|
|
2320
|
+
title: "AGENTS/CLAUDE/GEMINI symlink contract",
|
|
2321
|
+
status: details.length === 0 ? "pass" : "fail",
|
|
2322
|
+
summary: details.length === 0 ? "Agent documentation symlinks are in parity" : `${details.length} symlink issue(s) detected`,
|
|
2323
|
+
details,
|
|
2324
|
+
fixable: true
|
|
2325
|
+
};
|
|
2326
|
+
},
|
|
2327
|
+
migrate: (ctx, finding) => {
|
|
2328
|
+
const changedFiles = [];
|
|
2329
|
+
const details = [];
|
|
2330
|
+
const blockedDetails = [];
|
|
2331
|
+
const bootstrap = bootstrapAgentsFile(ctx.repoRoot, ctx.dryRun);
|
|
2332
|
+
changedFiles.push(...bootstrap.changedFiles);
|
|
2333
|
+
details.push(...bootstrap.details);
|
|
2334
|
+
if (bootstrap.blocked) {
|
|
2335
|
+
return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
|
|
2336
|
+
}
|
|
2337
|
+
for (const file of ["CLAUDE.md", "GEMINI.md"]) {
|
|
2338
|
+
const full = join9(ctx.repoRoot, file);
|
|
2339
|
+
const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
|
|
2340
|
+
if (result.blocked) blockedDetails.push(result.blocked);
|
|
2341
|
+
if (result.changed) changedFiles.push(full);
|
|
2342
|
+
}
|
|
2343
|
+
return {
|
|
2344
|
+
id: finding.id,
|
|
2345
|
+
title: finding.title,
|
|
2346
|
+
status: blockedDetails.length ? "blocked" : changedFiles.length ? "applied" : "noop",
|
|
2347
|
+
summary: blockedDetails.length ? "One or more files could not be replaced safely" : changedFiles.length ? "Symlink contract repaired" : "No changes required",
|
|
2348
|
+
changedFiles,
|
|
2349
|
+
details: [...details, ...blockedDetails]
|
|
2350
|
+
};
|
|
2351
|
+
}
|
|
2352
|
+
},
|
|
2353
|
+
{
|
|
2354
|
+
id: "sot.project-json",
|
|
2355
|
+
title: "Canonical .project.json",
|
|
2356
|
+
audit: projectJsonFinding,
|
|
2357
|
+
migrate: (ctx, finding) => {
|
|
2358
|
+
const changedFiles = [];
|
|
2359
|
+
const details = [];
|
|
2360
|
+
const path = join9(ctx.repoRoot, ".project.json");
|
|
2361
|
+
const existing = readProjectJson(ctx) ?? {};
|
|
2362
|
+
const canonical = canonicalProjectJson(ctx);
|
|
2363
|
+
const merged = { ...existing, ...canonical };
|
|
2364
|
+
const expected = `${JSON.stringify(merged, null, 2)}
|
|
2365
|
+
`;
|
|
2366
|
+
if (safeReadText(path) !== expected) {
|
|
2367
|
+
changedFiles.push(path);
|
|
2368
|
+
if (!ctx.dryRun) writeText(path, expected);
|
|
2369
|
+
}
|
|
2370
|
+
const planeJson = join9(ctx.repoRoot, ".plane.json");
|
|
2371
|
+
if (existsSync7(planeJson)) {
|
|
2372
|
+
const backup = `${planeJson}.migrated-backup`;
|
|
2373
|
+
if (existsSync7(backup)) {
|
|
2374
|
+
details.push(`cannot back up .plane.json because ${relative(ctx.repoRoot, backup)} already exists`);
|
|
2375
|
+
} else {
|
|
2376
|
+
changedFiles.push(backup);
|
|
2377
|
+
if (!ctx.dryRun) renameSync(planeJson, backup);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
return {
|
|
2381
|
+
id: finding.id,
|
|
2382
|
+
title: finding.title,
|
|
2383
|
+
status: details.length ? "blocked" : changedFiles.length ? "applied" : "noop",
|
|
2384
|
+
summary: details.length ? "Project SOT partially blocked" : changedFiles.length ? "Canonical .project.json written" : "No changes required",
|
|
2385
|
+
changedFiles,
|
|
2386
|
+
details
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
},
|
|
2390
|
+
{
|
|
2391
|
+
id: "secrets.env-op",
|
|
2392
|
+
title: ".env.op + gitignore secrets contract",
|
|
2393
|
+
audit: (ctx) => {
|
|
2394
|
+
const details = [];
|
|
2395
|
+
const envOp = safeReadText(join9(ctx.repoRoot, ".env.op"));
|
|
2396
|
+
const gitignore = safeReadText(join9(ctx.repoRoot, ".gitignore"));
|
|
2397
|
+
if (!envOp) {
|
|
2398
|
+
details.push(".env.op missing");
|
|
2399
|
+
} else {
|
|
2400
|
+
const invalidLines = envOp.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).filter((line) => {
|
|
2401
|
+
const value = line.slice(line.indexOf("=") + 1).trim();
|
|
2402
|
+
const quotedLiteral = /^"[^"\r\n]*"$/.test(value) || /^'[^'\r\n]*'$/.test(value);
|
|
2403
|
+
return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value) && !quotedLiteral;
|
|
2404
|
+
});
|
|
2405
|
+
if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals: ${invalidLines.join(", ")}`);
|
|
2406
|
+
}
|
|
2407
|
+
if (!gitignore?.includes(".env\n") && !gitignore?.includes(".env\r\n")) details.push(".gitignore should ignore .env");
|
|
2408
|
+
if (!gitignore?.includes(".env.*")) details.push(".gitignore should ignore .env.*");
|
|
2409
|
+
if (!gitignore?.includes("!.env.op")) details.push(".gitignore should unignore .env.op");
|
|
2410
|
+
return {
|
|
2411
|
+
id: "secrets.env-op",
|
|
2412
|
+
title: ".env.op + gitignore secrets contract",
|
|
2413
|
+
status: details.length === 0 ? "pass" : "fail",
|
|
2414
|
+
summary: details.length === 0 ? "Secret reference file and ignore rules are in parity" : `${details.length} env parity issue(s) detected`,
|
|
2415
|
+
details,
|
|
2416
|
+
fixable: true
|
|
2417
|
+
};
|
|
2418
|
+
},
|
|
2419
|
+
migrate: (ctx, finding) => {
|
|
2420
|
+
const changedFiles = [];
|
|
2421
|
+
const details = [];
|
|
2422
|
+
const envOpPath = join9(ctx.repoRoot, ".env.op");
|
|
2423
|
+
if (!existsSync7(envOpPath)) {
|
|
2424
|
+
changedFiles.push(envOpPath);
|
|
2425
|
+
if (!ctx.dryRun) writeText(envOpPath, readText(join9(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
|
|
2426
|
+
}
|
|
2427
|
+
const gitignorePath = join9(ctx.repoRoot, ".gitignore");
|
|
2428
|
+
const gitignore = safeReadText(gitignorePath) ?? "";
|
|
2429
|
+
const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
|
|
2430
|
+
# NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
|
|
2431
|
+
.env
|
|
2432
|
+
.env.*
|
|
2433
|
+
!.env.op
|
|
2434
|
+
`;
|
|
2435
|
+
if (!gitignore.includes("!.env.op") || !gitignore.includes(".env.*")) {
|
|
2436
|
+
changedFiles.push(gitignorePath);
|
|
2437
|
+
if (!ctx.dryRun) writeText(gitignorePath, `${gitignore.replace(/\s*$/, "")}${gitignore.trim() ? "\n\n" : ""}${requiredBlock}`);
|
|
2438
|
+
}
|
|
2439
|
+
return {
|
|
2440
|
+
id: finding.id,
|
|
2441
|
+
title: finding.title,
|
|
2442
|
+
status: details.length ? "blocked" : changedFiles.length ? "applied" : "noop",
|
|
2443
|
+
summary: details.length ? "Manual cleanup still required" : changedFiles.length ? "Wrote .env.op/gitignore parity files" : "No changes required",
|
|
2444
|
+
changedFiles,
|
|
2445
|
+
details
|
|
2446
|
+
};
|
|
2447
|
+
}
|
|
2448
|
+
},
|
|
2449
|
+
{
|
|
2450
|
+
id: "provenance.copier",
|
|
2451
|
+
title: ".copier-answers.yml provenance + drift report",
|
|
2452
|
+
audit: (ctx) => {
|
|
2453
|
+
const details = [];
|
|
2454
|
+
const path = join9(ctx.repoRoot, ".copier-answers.yml");
|
|
2455
|
+
const text3 = safeReadText(path);
|
|
2456
|
+
const project = readProjectJson(ctx);
|
|
2457
|
+
if (!text3) {
|
|
2458
|
+
details.push(".copier-answers.yml missing");
|
|
2459
|
+
} else {
|
|
2460
|
+
if (!text3.startsWith("# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY")) details.push("missing Copier overwrite warning header");
|
|
2461
|
+
if (!text3.includes("_src_path:")) details.push("_src_path missing");
|
|
2462
|
+
if (project?.project_name) {
|
|
2463
|
+
const nameMatch = text3.match(/project_name:\s*(.+)/);
|
|
2464
|
+
if (!nameMatch || nameMatch[1]?.trim() !== String(project.project_name)) details.push("project_name drift between .copier-answers.yml and .project.json");
|
|
2465
|
+
}
|
|
2466
|
+
if (project?.project_description) {
|
|
2467
|
+
const descMatch = text3.match(/project_description:\s*([\s\S]*?)(?=\n\w|$)/);
|
|
2468
|
+
const yamlDesc = descMatch?.[1]?.replace(/\n\s+/g, " ").trim() ?? "";
|
|
2469
|
+
if (yamlDesc !== String(project.project_description)) details.push("project_description drift between .copier-answers.yml and .project.json");
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
return {
|
|
2473
|
+
id: "provenance.copier",
|
|
2474
|
+
title: ".copier-answers.yml provenance + drift report",
|
|
2475
|
+
status: details.length === 0 ? "pass" : "fail",
|
|
2476
|
+
summary: details.length === 0 ? "Copier provenance is in parity" : `${details.length} provenance issue(s) detected`,
|
|
2477
|
+
details,
|
|
2478
|
+
fixable: true
|
|
2479
|
+
};
|
|
2480
|
+
},
|
|
2481
|
+
migrate: (ctx, finding) => {
|
|
2482
|
+
const changedFiles = [];
|
|
2483
|
+
const project = canonicalProjectJson(ctx);
|
|
2484
|
+
const text3 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
|
|
2485
|
+
_src_path: ${join9(ctx.pjanglerRoot, "templates", "commonproject")}
|
|
2486
|
+
project_description: ${String(project.project_description)}
|
|
2487
|
+
project_name: ${String(project.project_name)}
|
|
2488
|
+
ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
|
|
2489
|
+
`;
|
|
2490
|
+
const path = join9(ctx.repoRoot, ".copier-answers.yml");
|
|
2491
|
+
if (safeReadText(path) !== text3) {
|
|
2492
|
+
changedFiles.push(path);
|
|
2493
|
+
if (!ctx.dryRun) writeText(path, text3);
|
|
2494
|
+
}
|
|
2495
|
+
return {
|
|
2496
|
+
id: finding.id,
|
|
2497
|
+
title: finding.title,
|
|
2498
|
+
status: changedFiles.length ? "applied" : "noop",
|
|
2499
|
+
summary: changedFiles.length ? "Copier provenance file refreshed" : "No changes required",
|
|
2500
|
+
changedFiles,
|
|
2501
|
+
details: []
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
},
|
|
2505
|
+
{
|
|
2506
|
+
id: "bmad.scaffold",
|
|
2507
|
+
title: "BMAD modules/docs scaffold",
|
|
2508
|
+
audit: (ctx) => {
|
|
2509
|
+
const sourceRoot = join9(ctx.pjanglerRoot, "templates", "commonproject", "_bmad");
|
|
2510
|
+
const targetRoot = join9(ctx.repoRoot, "_bmad");
|
|
2511
|
+
const sentinels = [
|
|
2512
|
+
join9("core", "config.yaml"),
|
|
2513
|
+
join9("custom", "config.yaml"),
|
|
2514
|
+
join9("custom", "workflows", "ticket-lifecycle", "workflow.yaml"),
|
|
2515
|
+
join9("bmm", "workflows", "workflow-status", "workflow.yaml")
|
|
2516
|
+
];
|
|
2517
|
+
const missing = sentinels.filter((file) => existsSync7(join9(sourceRoot, file)) && !existsSync7(join9(targetRoot, file)));
|
|
2518
|
+
return {
|
|
2519
|
+
id: "bmad.scaffold",
|
|
2520
|
+
title: "BMAD modules/docs scaffold",
|
|
2521
|
+
status: missing.length === 0 ? "pass" : "fail",
|
|
2522
|
+
summary: missing.length === 0 ? "BMAD scaffold parity verified" : `${missing.length} BMAD sentinel file(s) missing`,
|
|
2523
|
+
details: missing.map((file) => `_bmad/${file}`),
|
|
2524
|
+
fixable: true
|
|
2525
|
+
};
|
|
2526
|
+
},
|
|
2527
|
+
migrate: (ctx, finding) => {
|
|
2528
|
+
const changedFiles = [];
|
|
2529
|
+
copyMissingRecursive(join9(ctx.pjanglerRoot, "templates", "commonproject", "_bmad"), join9(ctx.repoRoot, "_bmad"), changedFiles, ctx.dryRun);
|
|
2530
|
+
return {
|
|
2531
|
+
id: finding.id,
|
|
2532
|
+
title: finding.title,
|
|
2533
|
+
status: changedFiles.length ? "applied" : "noop",
|
|
2534
|
+
summary: changedFiles.length ? "Copied missing BMAD scaffold files" : "No changes required",
|
|
2535
|
+
changedFiles,
|
|
2536
|
+
details: []
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
},
|
|
2540
|
+
{
|
|
2541
|
+
id: "hermes.pm-scaffold",
|
|
2542
|
+
title: "Hermes PM scaffold parity",
|
|
2543
|
+
audit: (ctx) => {
|
|
2544
|
+
const roles = discoverRoles(ctx.repoRoot);
|
|
2545
|
+
const role = roles.find((item) => item.role === "pm");
|
|
2546
|
+
if (!role) {
|
|
2547
|
+
return { id: "hermes.pm-scaffold", title: "Hermes PM scaffold parity", status: "skip", summary: "No pm role present", details: [], fixable: false };
|
|
2548
|
+
}
|
|
2549
|
+
const details = [];
|
|
2550
|
+
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"]) {
|
|
2551
|
+
if (!existsSync7(join9(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join9(role.roleDir, rel))}`);
|
|
2552
|
+
}
|
|
2553
|
+
const gitmodules = safeReadText(join9(ctx.repoRoot, ".gitmodules")) ?? "";
|
|
2554
|
+
if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing pm runtime submodule entry");
|
|
2555
|
+
if (!profileMetaInheritsDefault(join9(role.roleDir, "runtime", "profile.yaml"))) {
|
|
2556
|
+
details.push("runtime/profile.yaml missing inherited default config metadata");
|
|
2557
|
+
}
|
|
2558
|
+
const registry = safeReadText(registryPath(ctx.homeDir));
|
|
2559
|
+
if (!registry?.includes(`${role.agentId}:`)) details.push(`fleet registry missing ${role.agentId}`);
|
|
2560
|
+
return {
|
|
2561
|
+
id: "hermes.pm-scaffold",
|
|
2562
|
+
title: "Hermes PM scaffold parity",
|
|
2563
|
+
status: details.length === 0 ? "pass" : "fail",
|
|
2564
|
+
summary: details.length === 0 ? "PM scaffold parity verified" : `${details.length} PM scaffold issue(s) detected`,
|
|
2565
|
+
details,
|
|
2566
|
+
fixable: true
|
|
2567
|
+
};
|
|
2568
|
+
},
|
|
2569
|
+
migrate: (ctx, finding) => {
|
|
2570
|
+
const role = discoverRoles(ctx.repoRoot).find((item) => item.role === "pm");
|
|
2571
|
+
const changedFiles = [];
|
|
2572
|
+
const details = [];
|
|
2573
|
+
if (!role) {
|
|
2574
|
+
return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
|
|
2575
|
+
}
|
|
2576
|
+
const templateRoleDir = join9(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
|
|
2577
|
+
writeIfDifferent(join9(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
|
|
2578
|
+
writeIfDifferent(join9(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
|
|
2579
|
+
writeIfDifferent(join9(role.roleDir, ".gitignore"), readText(join9(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
|
|
2580
|
+
copyMissingRecursive(join9(templateRoleDir, ".runtime-scaffold"), join9(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
|
|
2581
|
+
copyMissingRecursive(join9(templateRoleDir, ".runtime-scaffold"), join9(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
|
|
2582
|
+
copyMissingRecursive(join9(templateRoleDir, ".scripts"), join9(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
|
|
2583
|
+
const promptSource = join9(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
|
|
2584
|
+
const promptTarget = join9(role.roleDir, ".scripts", "sentinel.prompt.md");
|
|
2585
|
+
if (existsSync7(promptSource) && !existsSync7(promptTarget)) {
|
|
2586
|
+
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);
|
|
2587
|
+
writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
|
|
2588
|
+
}
|
|
2589
|
+
upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
|
|
2590
|
+
const profileMetaUpdated = upsertInheritedProfileMeta(join9(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
|
|
2591
|
+
if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
|
|
2592
|
+
const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
|
|
2593
|
+
if (registryUpdated) details.push(`updated ${registryUpdated}`);
|
|
2594
|
+
return {
|
|
2595
|
+
id: finding.id,
|
|
2596
|
+
title: finding.title,
|
|
2597
|
+
status: changedFiles.length ? "applied" : "noop",
|
|
2598
|
+
summary: changedFiles.length ? "PM scaffold normalized" : "No changes required",
|
|
2599
|
+
changedFiles,
|
|
2600
|
+
details
|
|
2601
|
+
};
|
|
2602
|
+
}
|
|
2603
|
+
},
|
|
2604
|
+
{
|
|
2605
|
+
id: "systemd.sentinel",
|
|
2606
|
+
title: "Hermes systemd/sentinel units enabled + active",
|
|
2607
|
+
audit: (ctx) => {
|
|
2608
|
+
const roles = discoverRoles(ctx.repoRoot);
|
|
2609
|
+
if (!roles.length) {
|
|
2610
|
+
return { id: "systemd.sentinel", title: "Hermes systemd/sentinel units enabled + active", status: "skip", summary: "No Hermes roles present", details: [], fixable: false };
|
|
2611
|
+
}
|
|
2612
|
+
const probe = systemctlUser(["is-system-running"]);
|
|
2613
|
+
if (!probe.ok && !/running|degraded|starting|maintenance/.test(`${probe.stdout} ${probe.stderr}`)) {
|
|
2614
|
+
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 };
|
|
2615
|
+
}
|
|
2616
|
+
const details = [];
|
|
2617
|
+
for (const role of roles) {
|
|
2618
|
+
for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
|
|
2619
|
+
const state = checkUnit(unit);
|
|
2620
|
+
if (!state.enabled || !state.active) details.push(`${unit} should be enabled+active`);
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
return {
|
|
2624
|
+
id: "systemd.sentinel",
|
|
2625
|
+
title: "Hermes systemd/sentinel units enabled + active",
|
|
2626
|
+
status: details.length === 0 ? "pass" : "fail",
|
|
2627
|
+
summary: details.length === 0 ? "Hermes user units are enabled and active" : `${details.length} systemd parity issue(s) detected`,
|
|
2628
|
+
details,
|
|
2629
|
+
fixable: true
|
|
2630
|
+
};
|
|
2631
|
+
},
|
|
2632
|
+
migrate: (ctx, finding) => {
|
|
2633
|
+
const roles = discoverRoles(ctx.repoRoot);
|
|
2634
|
+
const changedFiles = [];
|
|
2635
|
+
const details = [];
|
|
2636
|
+
if (!roles.length) {
|
|
2637
|
+
return { id: finding.id, title: finding.title, status: "blocked", summary: "No Hermes roles present", changedFiles, details };
|
|
2638
|
+
}
|
|
2639
|
+
const probe = systemctlUser(["is-system-running"]);
|
|
2640
|
+
if (!probe.ok && !/running|degraded|starting|maintenance/.test(`${probe.stdout} ${probe.stderr}`)) {
|
|
2641
|
+
return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
|
|
2642
|
+
}
|
|
2643
|
+
for (const role of roles) {
|
|
2644
|
+
const sysDir = join9(ctx.homeDir, ".config", "systemd", "user");
|
|
2645
|
+
const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
|
|
2646
|
+
const allUnitsPresent = units.every((unit) => existsSync7(join9(sysDir, unit)));
|
|
2647
|
+
if (allUnitsPresent) {
|
|
2648
|
+
if (ctx.dryRun) {
|
|
2649
|
+
details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
|
|
2650
|
+
} else {
|
|
2651
|
+
systemctlUser(["daemon-reload"]);
|
|
2652
|
+
for (const unit of units) {
|
|
2653
|
+
systemctlUser(["enable", "--now", unit]);
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
continue;
|
|
2657
|
+
}
|
|
2658
|
+
for (const script of [join9(role.roleDir, ".scripts", "70-systemd.sh")]) {
|
|
2659
|
+
if (!script || !existsSync7(script)) continue;
|
|
2660
|
+
if (ctx.dryRun) {
|
|
2661
|
+
details.push(`would run: bash ${script}`);
|
|
2662
|
+
} else {
|
|
2663
|
+
const result = spawnSync4("bash", [script], { cwd: role.roleDir, encoding: "utf8" });
|
|
2664
|
+
if (result.status !== 0) details.push(`script failed: ${script}: ${result.stderr.trim() || result.stdout.trim()}`);
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
return {
|
|
2669
|
+
id: finding.id,
|
|
2670
|
+
title: finding.title,
|
|
2671
|
+
status: details.some((detail) => detail.includes("failed:")) ? "blocked" : details.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
|
|
2672
|
+
summary: details.length ? ctx.dryRun ? "Planned systemd remediation commands" : "Attempted systemd remediation" : "No changes required",
|
|
2673
|
+
changedFiles,
|
|
2674
|
+
details
|
|
2675
|
+
};
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
];
|
|
2679
|
+
function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
|
|
2680
|
+
const normalized = content.endsWith("\n") ? content : `${content}
|
|
2681
|
+
`;
|
|
2682
|
+
if (safeReadText(path) === normalized) return;
|
|
2683
|
+
changedFiles.push(path);
|
|
2684
|
+
if (!dryRun) {
|
|
2685
|
+
writeText(path, normalized);
|
|
2686
|
+
if (mode) chmodSync(path, mode);
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
function getParityRuleIds() {
|
|
2690
|
+
return RULES.map((rule) => rule.id);
|
|
2691
|
+
}
|
|
2692
|
+
function runAudit(repoArg) {
|
|
2693
|
+
const pjanglerRoot = resolvePjanglerRoot();
|
|
2694
|
+
const ctx = {
|
|
2695
|
+
repoRoot: resolve(repoArg ?? process.cwd()),
|
|
2696
|
+
dryRun: true,
|
|
2697
|
+
pjanglerRoot,
|
|
2698
|
+
homeDir: homedir4()
|
|
2699
|
+
};
|
|
2700
|
+
const rules = RULES.map((rule) => rule.audit(ctx));
|
|
2701
|
+
return {
|
|
2702
|
+
repo: ctx.repoRoot,
|
|
2703
|
+
ok: rules.every((rule) => rule.status === "pass" || rule.status === "skip"),
|
|
2704
|
+
auditedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2705
|
+
rules
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
function runMigrationForRules(ruleIds, repoArg, dryRun) {
|
|
2709
|
+
const pjanglerRoot = resolvePjanglerRoot();
|
|
2710
|
+
const ctx = {
|
|
2711
|
+
repoRoot: resolve(repoArg ?? process.cwd()),
|
|
2712
|
+
dryRun,
|
|
2713
|
+
pjanglerRoot,
|
|
2714
|
+
homeDir: homedir4()
|
|
2715
|
+
};
|
|
2716
|
+
const selected = RULES.filter((rule) => ruleIds.includes(rule.id));
|
|
2717
|
+
if (!selected.length) {
|
|
2718
|
+
throw new Error(`Unknown parity rules: ${ruleIds.join(", ")}`);
|
|
2719
|
+
}
|
|
2720
|
+
const results = selected.map((rule) => {
|
|
2721
|
+
try {
|
|
2722
|
+
return rule.migrate(ctx, rule.audit(ctx));
|
|
2723
|
+
} catch (err) {
|
|
2724
|
+
return {
|
|
2725
|
+
id: rule.id,
|
|
2726
|
+
title: rule.title,
|
|
2727
|
+
status: "blocked",
|
|
2728
|
+
summary: `migrate threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
2729
|
+
changedFiles: [],
|
|
2730
|
+
details: []
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
});
|
|
2734
|
+
const changedFiles = Array.from(new Set(results.flatMap((result) => result.changedFiles))).sort();
|
|
2735
|
+
return {
|
|
2736
|
+
repo: ctx.repoRoot,
|
|
2737
|
+
dryRun,
|
|
2738
|
+
ok: results.every((result) => result.status !== "blocked"),
|
|
2739
|
+
selectedRules: selected.map((rule) => rule.id),
|
|
2740
|
+
results,
|
|
2741
|
+
changedFiles
|
|
2742
|
+
};
|
|
2743
|
+
}
|
|
2744
|
+
function runMigration(selector, repoArg, dryRun, all) {
|
|
2745
|
+
const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
|
|
2746
|
+
return runMigrationForRules(ruleIds, repoArg, dryRun);
|
|
2747
|
+
}
|
|
2748
|
+
function formatAuditReport(report) {
|
|
2749
|
+
const lines = [`repo: ${report.repo}`, `ok: ${report.ok}`, `audited_at: ${report.auditedAt}`, "rules:"];
|
|
2750
|
+
for (const rule of report.rules) {
|
|
2751
|
+
lines.push(`- ${rule.id} [${rule.status}] ${rule.summary}`);
|
|
2752
|
+
for (const detail of rule.details) lines.push(` - ${detail}`);
|
|
2753
|
+
}
|
|
2754
|
+
return `${lines.join("\n")}
|
|
2755
|
+
`;
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
// src/project/index.ts
|
|
2759
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
2760
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync5, renameSync as renameSync2, statSync, writeFileSync as writeFileSync5 } from "node:fs";
|
|
2761
|
+
import { homedir as homedir5 } from "node:os";
|
|
2762
|
+
import { basename as basename3, dirname as dirname7, join as join10, resolve as resolve2 } from "node:path";
|
|
2763
|
+
import YAML from "yaml";
|
|
2764
|
+
var PROJECT_REGISTRY_ENV = "PJ_PROJECT_REGISTRY";
|
|
2765
|
+
var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
|
|
2766
|
+
var KNOWN_SKILL_ROOTS = [
|
|
2767
|
+
"/home/delorenj/code/skillex/all-skills",
|
|
2768
|
+
"/home/delorenj/code/CoachingAgentFramework/.agents/skills",
|
|
2769
|
+
"/home/delorenj/code/pjangler/.agents/skills",
|
|
2770
|
+
join10(homedir5(), ".codex", "skills")
|
|
2771
|
+
];
|
|
2772
|
+
function projectRegistryPath(env = process.env) {
|
|
2773
|
+
return expandHome(env[PROJECT_REGISTRY_ENV] || join10(homedir5(), ".config", "pjangler", "projects.yaml"));
|
|
2774
|
+
}
|
|
2775
|
+
function emptyProjectRegistry() {
|
|
2776
|
+
return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
|
|
2777
|
+
}
|
|
2778
|
+
function loadProjectRegistry(path = projectRegistryPath()) {
|
|
2779
|
+
if (!existsSync8(path)) return emptyProjectRegistry();
|
|
2780
|
+
const raw = YAML.parse(readFileSync5(path, "utf8"));
|
|
2781
|
+
if (raw == null) return emptyProjectRegistry();
|
|
2782
|
+
if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
|
|
2783
|
+
const registry = raw;
|
|
2784
|
+
const normalized = {
|
|
2785
|
+
schema_version: Number(registry.schema_version ?? PROJECT_REGISTRY_SCHEMA_VERSION),
|
|
2786
|
+
projects: isRecord(registry.projects) ? registry.projects : {}
|
|
2787
|
+
};
|
|
2788
|
+
validateProjectRegistry(normalized);
|
|
2789
|
+
return normalized;
|
|
2790
|
+
}
|
|
2791
|
+
function saveProjectRegistry(registry, path = projectRegistryPath()) {
|
|
2792
|
+
validateProjectRegistry(registry);
|
|
2793
|
+
mkdirSync6(dirname7(path), { recursive: true });
|
|
2794
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
2795
|
+
writeFileSync5(temp, YAML.stringify(registry, { lineWidth: 0 }), "utf8");
|
|
2796
|
+
renameSync2(temp, path);
|
|
2797
|
+
}
|
|
2798
|
+
function validateProjectRegistry(registry) {
|
|
2799
|
+
if (registry.schema_version !== PROJECT_REGISTRY_SCHEMA_VERSION) {
|
|
2800
|
+
throw new Error(`Unsupported project registry schema_version: ${registry.schema_version}`);
|
|
2801
|
+
}
|
|
2802
|
+
if (!isRecord(registry.projects)) throw new Error("Project registry projects must be a mapping");
|
|
2803
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
2804
|
+
const repoPaths = /* @__PURE__ */ new Map();
|
|
2805
|
+
const identifiers = /* @__PURE__ */ new Map();
|
|
2806
|
+
for (const [slug, project] of Object.entries(registry.projects)) {
|
|
2807
|
+
validateProjectRecord(project, slug);
|
|
2808
|
+
if (slugs.has(project.slug)) throw new Error(`Duplicate project slug: ${project.slug}`);
|
|
2809
|
+
slugs.add(project.slug);
|
|
2810
|
+
const repoKey = resolve2(project.repo_path);
|
|
2811
|
+
const existingRepoSlug = repoPaths.get(repoKey);
|
|
2812
|
+
if (existingRepoSlug && existingRepoSlug !== slug) {
|
|
2813
|
+
throw new Error(`Duplicate project repo_path: ${project.repo_path} used by ${existingRepoSlug} and ${slug}`);
|
|
2814
|
+
}
|
|
2815
|
+
repoPaths.set(repoKey, slug);
|
|
2816
|
+
const identifier = project.ticket_provider.identifier?.toUpperCase();
|
|
2817
|
+
if (identifier) {
|
|
2818
|
+
const existingIdentifierSlug = identifiers.get(identifier);
|
|
2819
|
+
if (existingIdentifierSlug && existingIdentifierSlug !== slug) {
|
|
2820
|
+
throw new Error(`Duplicate project identifier: ${identifier} used by ${existingIdentifierSlug} and ${slug}`);
|
|
2821
|
+
}
|
|
2822
|
+
identifiers.set(identifier, slug);
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
function slugifyProjectName(value) {
|
|
2827
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
2828
|
+
}
|
|
2829
|
+
function deriveProjectIdentifier(value) {
|
|
2830
|
+
const compact = value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
|
|
2831
|
+
const identifier = compact.slice(0, 4) || "PROJ";
|
|
2832
|
+
return identifier.length >= 2 ? identifier : `${identifier}XX`.slice(0, 4);
|
|
2833
|
+
}
|
|
2834
|
+
function normalizeAgentRole(value) {
|
|
2835
|
+
return value?.trim() || "pm";
|
|
2836
|
+
}
|
|
2837
|
+
function jsonStable(value) {
|
|
2838
|
+
return JSON.stringify(value);
|
|
2839
|
+
}
|
|
2840
|
+
function projectRecordEquivalent(a, b) {
|
|
2841
|
+
if (!a) return false;
|
|
2842
|
+
const { created_at: _aCreated, updated_at: _aUpdated, ...aComparable } = a;
|
|
2843
|
+
const { created_at: _bCreated, updated_at: _bUpdated, ...bComparable } = b;
|
|
2844
|
+
return jsonStable(aComparable) === jsonStable(bComparable);
|
|
2845
|
+
}
|
|
2846
|
+
function defaultProjectTargetDir(name, cwd = process.cwd()) {
|
|
2847
|
+
const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
|
|
2848
|
+
return resolve2(dirname7(resolve2(cwd)), compactName);
|
|
2849
|
+
}
|
|
2850
|
+
function resolveSourceSkillPath(sourceSkill) {
|
|
2851
|
+
if (!sourceSkill) return void 0;
|
|
2852
|
+
const expanded = expandHome(sourceSkill);
|
|
2853
|
+
const direct = resolve2(expanded);
|
|
2854
|
+
if (existsSync8(direct)) return direct;
|
|
2855
|
+
const name = basename3(sourceSkill);
|
|
2856
|
+
for (const root of KNOWN_SKILL_ROOTS) {
|
|
2857
|
+
const candidate = join10(root, name);
|
|
2858
|
+
if (existsSync8(candidate)) return candidate;
|
|
2859
|
+
}
|
|
2860
|
+
const civilWarLetterifier = "/home/delorenj/code/skillex/all-skills/civilwar-letterifier";
|
|
2861
|
+
const hint = existsSync8(civilWarLetterifier) ? ` Did you mean ${civilWarLetterifier}?` : "";
|
|
2862
|
+
throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
|
|
2863
|
+
}
|
|
2864
|
+
function planProjectInit(input) {
|
|
2865
|
+
if (!input.name.trim()) throw new Error("Project name is required");
|
|
2866
|
+
const registryPath2 = resolve2(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
|
|
2867
|
+
const registry = loadProjectRegistry(registryPath2);
|
|
2868
|
+
const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
|
|
2869
|
+
const slug = input.projectSlug ?? slugifyProjectName(input.name);
|
|
2870
|
+
const targetDir = resolve2(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
|
|
2871
|
+
const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
|
|
2872
|
+
const existing = registry.projects[slug];
|
|
2873
|
+
const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
|
|
2874
|
+
const overwrite = input.overwrite ?? input.force ?? false;
|
|
2875
|
+
const agentRole = normalizeAgentRole(input.agentRole);
|
|
2876
|
+
const agents = input.provisionAgent ? {
|
|
2877
|
+
...existing?.agents ?? {},
|
|
2878
|
+
[agentRole]: {
|
|
2879
|
+
role: agentRole,
|
|
2880
|
+
provisioning_state: "planned"
|
|
2881
|
+
}
|
|
2882
|
+
} : existing?.agents ?? {};
|
|
2883
|
+
const scaffold = input.scaffold ?? true;
|
|
2884
|
+
const candidateProject = {
|
|
2885
|
+
name: input.name,
|
|
2886
|
+
slug,
|
|
2887
|
+
repo_path: targetDir,
|
|
2888
|
+
description: input.description ?? "",
|
|
2889
|
+
status: "planned",
|
|
2890
|
+
source_artifacts: sourceSkillPath ? [{ kind: "skill", path: sourceSkillPath, package_name: input.packageName ?? slug }] : [],
|
|
2891
|
+
template: {
|
|
2892
|
+
commonproject: {
|
|
2893
|
+
enabled: true,
|
|
2894
|
+
primary_language: input.primaryLanguage ?? "python"
|
|
2895
|
+
}
|
|
2896
|
+
},
|
|
2897
|
+
ticket_provider: {
|
|
2898
|
+
type: input.ticketProvider ?? "plane",
|
|
2899
|
+
workspace: input.planeWorkspace ?? "33god",
|
|
2900
|
+
identifier,
|
|
2901
|
+
board_id: input.planeProjectId ?? "",
|
|
2902
|
+
board_url: input.planeProjectId ? `https://plane.delo.sh/${input.planeWorkspace ?? "33god"}/projects/${input.planeProjectId}/issues/` : "",
|
|
2903
|
+
state: input.live ? "planned" : "planned"
|
|
2904
|
+
},
|
|
2905
|
+
agents,
|
|
2906
|
+
created_at: existing?.created_at ?? now,
|
|
2907
|
+
updated_at: now
|
|
2908
|
+
};
|
|
2909
|
+
const project = {
|
|
2910
|
+
...candidateProject,
|
|
2911
|
+
updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
|
|
2912
|
+
};
|
|
2913
|
+
validateNoDuplicateProject(registry, project, overwrite);
|
|
2914
|
+
const pjanglerRoot = resolve2(input.pjanglerRoot ?? resolvePjanglerRoot2());
|
|
2915
|
+
const manifest = projectManifestFromRegistryProject(project);
|
|
2916
|
+
const apply = input.apply ?? false;
|
|
2917
|
+
const live = input.live ?? false;
|
|
2918
|
+
const actions = [
|
|
2919
|
+
{ kind: "registry.upsert", registryPath: registryPath2, slug, project }
|
|
2920
|
+
];
|
|
2921
|
+
if (scaffold) {
|
|
2922
|
+
actions.push(buildCommonProjectCopierAction({
|
|
2923
|
+
pjanglerRoot,
|
|
2924
|
+
targetDir,
|
|
2925
|
+
projectName: project.name,
|
|
2926
|
+
projectDescription: project.description,
|
|
2927
|
+
projectSlug: project.slug,
|
|
2928
|
+
ticketProvider: project.ticket_provider.type,
|
|
2929
|
+
planeWorkspace: project.ticket_provider.workspace ?? "33god",
|
|
2930
|
+
planeProjectId: project.ticket_provider.board_id ?? "",
|
|
2931
|
+
projectIdentifier: identifier,
|
|
2932
|
+
primaryLanguage: project.template.commonproject.primary_language,
|
|
2933
|
+
overwrite
|
|
2934
|
+
}));
|
|
2935
|
+
}
|
|
2936
|
+
actions.push(
|
|
2937
|
+
{ kind: "project.write-manifest", path: join10(targetDir, ".project.json"), manifest },
|
|
2938
|
+
{
|
|
2939
|
+
kind: "plane.create-or-link",
|
|
2940
|
+
enabled: live,
|
|
2941
|
+
live,
|
|
2942
|
+
workspace: project.ticket_provider.workspace ?? "33god",
|
|
2943
|
+
identifier,
|
|
2944
|
+
state: live ? "planned" : "planned",
|
|
2945
|
+
reason: live ? void 0 : "network/cloud actions require --live"
|
|
2946
|
+
},
|
|
2947
|
+
{
|
|
2948
|
+
kind: "hermes.provision-agent",
|
|
2949
|
+
enabled: input.provisionAgent ?? false,
|
|
2950
|
+
local: !live,
|
|
2951
|
+
targetDir,
|
|
2952
|
+
targetRepo: slug,
|
|
2953
|
+
role: agentRole,
|
|
2954
|
+
context: {
|
|
2955
|
+
skipRuntimeRepo: !live,
|
|
2956
|
+
skipPlane: !live,
|
|
2957
|
+
skipBloodbank: !live,
|
|
2958
|
+
skipSystemd: !live || process.platform === "darwin"
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
);
|
|
2962
|
+
return { ok: true, apply, dryRun: !apply, live, registryPath: registryPath2, project, manifest, actions };
|
|
2963
|
+
}
|
|
2964
|
+
function executeProjectInitPlan(plan) {
|
|
2965
|
+
const logs = [];
|
|
2966
|
+
const errors = [];
|
|
2967
|
+
const changedFiles = [];
|
|
2968
|
+
if (!plan.apply) return { ok: true, plan, logs, errors, changedFiles };
|
|
2969
|
+
const registry = loadProjectRegistry(plan.registryPath);
|
|
2970
|
+
let pendingRegistryAction;
|
|
2971
|
+
for (const action of plan.actions) {
|
|
2972
|
+
if (action.kind === "copier.copy.commonproject") {
|
|
2973
|
+
mkdirSync6(dirname7(action.targetDir), { recursive: true });
|
|
2974
|
+
const result = spawnSync5(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
|
|
2975
|
+
if (result.stdout?.trim()) logs.push(result.stdout.trim());
|
|
2976
|
+
if (result.stderr?.trim()) logs.push(result.stderr.trim());
|
|
2977
|
+
if (result.error) {
|
|
2978
|
+
const code = result.error.code;
|
|
2979
|
+
errors.push(
|
|
2980
|
+
code === "ENOENT" ? "copier not found on PATH. Install with: uv tool install copier or pip install copier" : `copier failed: ${result.error.message}`
|
|
2981
|
+
);
|
|
2982
|
+
break;
|
|
2983
|
+
}
|
|
2984
|
+
if (result.status !== 0) {
|
|
2985
|
+
errors.push(`copier exited with status ${result.status ?? "unknown"}`);
|
|
2986
|
+
if (existsSync8(action.targetDir)) changedFiles.push(action.targetDir);
|
|
2987
|
+
break;
|
|
2988
|
+
}
|
|
2989
|
+
changedFiles.push(action.targetDir);
|
|
2990
|
+
} else if (action.kind === "project.write-manifest") {
|
|
2991
|
+
mkdirSync6(dirname7(action.path), { recursive: true });
|
|
2992
|
+
const next = `${JSON.stringify(action.manifest, null, 2)}
|
|
2993
|
+
`;
|
|
2994
|
+
const current = existsSync8(action.path) ? readFileSync5(action.path, "utf8") : void 0;
|
|
2995
|
+
if (current !== next) {
|
|
2996
|
+
writeFileSync5(action.path, next, "utf8");
|
|
2997
|
+
changedFiles.push(action.path);
|
|
2998
|
+
}
|
|
2999
|
+
} else if (action.kind === "registry.upsert") {
|
|
3000
|
+
pendingRegistryAction = action;
|
|
3001
|
+
} else if (action.kind === "plane.create-or-link") {
|
|
3002
|
+
logs.push(action.enabled ? "plane.create-or-link requires a live provider integration" : "plane.create-or-link skipped (requires --live)");
|
|
3003
|
+
} else if (action.kind === "hermes.provision-agent") {
|
|
3004
|
+
logs.push(action.enabled ? "hermes.provision-agent planned for the caller to execute" : "hermes.provision-agent skipped");
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
if (pendingRegistryAction && errors.length === 0) {
|
|
3008
|
+
if (!projectRecordEquivalent(registry.projects[pendingRegistryAction.slug], pendingRegistryAction.project)) {
|
|
3009
|
+
registry.projects[pendingRegistryAction.slug] = pendingRegistryAction.project;
|
|
3010
|
+
saveProjectRegistry(registry, pendingRegistryAction.registryPath);
|
|
3011
|
+
changedFiles.push(pendingRegistryAction.registryPath);
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
return { ok: errors.length === 0, plan, logs, errors, changedFiles };
|
|
3015
|
+
}
|
|
3016
|
+
function projectManifestFromRegistryProject(project) {
|
|
3017
|
+
const agents = Object.fromEntries(
|
|
3018
|
+
Object.entries(project.agents).map(([name, agent]) => [
|
|
3019
|
+
`${project.slug}-${name}`,
|
|
3020
|
+
{
|
|
3021
|
+
role: agent.role,
|
|
3022
|
+
role_dir: agent.role_dir,
|
|
3023
|
+
provisioning_state: agent.provisioning_state
|
|
3024
|
+
}
|
|
3025
|
+
])
|
|
3026
|
+
);
|
|
3027
|
+
return {
|
|
3028
|
+
project_name: project.name,
|
|
3029
|
+
project_description: project.description,
|
|
3030
|
+
project_slug: project.slug,
|
|
3031
|
+
repo_path: project.repo_path,
|
|
3032
|
+
ticket_provider: {
|
|
3033
|
+
type: project.ticket_provider.type,
|
|
3034
|
+
workspace: project.ticket_provider.workspace ?? "",
|
|
3035
|
+
identifier: project.ticket_provider.identifier ?? "",
|
|
3036
|
+
board_id: project.ticket_provider.board_id ?? "",
|
|
3037
|
+
board_url: project.ticket_provider.board_url ?? "",
|
|
3038
|
+
state: project.ticket_provider.state
|
|
3039
|
+
},
|
|
3040
|
+
agents
|
|
3041
|
+
};
|
|
3042
|
+
}
|
|
3043
|
+
function getProject(registry, slug) {
|
|
3044
|
+
const project = registry.projects[slug];
|
|
3045
|
+
if (!project) throw new Error(`Project not found in registry: ${slug}`);
|
|
3046
|
+
return project;
|
|
3047
|
+
}
|
|
3048
|
+
function buildCommonProjectCopierAction(input) {
|
|
3049
|
+
const templateDir = join10(input.pjanglerRoot, "templates", "commonproject");
|
|
3050
|
+
const data = {
|
|
3051
|
+
project_name: input.projectName,
|
|
3052
|
+
project_description: input.projectDescription ?? "",
|
|
3053
|
+
project_slug: input.projectSlug,
|
|
3054
|
+
ticket_provider: input.ticketProvider,
|
|
3055
|
+
plane_workspace: input.planeWorkspace,
|
|
3056
|
+
plane_project_id: input.planeProjectId ?? "",
|
|
3057
|
+
project_identifier: input.projectIdentifier,
|
|
3058
|
+
primary_language: input.primaryLanguage
|
|
3059
|
+
};
|
|
3060
|
+
const command = ["copier", "copy", "--trust", templateDir, input.targetDir, "--defaults"];
|
|
3061
|
+
for (const [key, value] of Object.entries(data)) command.push("--data", `${key}=${value}`);
|
|
3062
|
+
if (input.overwrite) command.push("--overwrite");
|
|
3063
|
+
return {
|
|
3064
|
+
kind: "copier.copy.commonproject",
|
|
3065
|
+
cwd: input.pjanglerRoot,
|
|
3066
|
+
command,
|
|
3067
|
+
targetDir: input.targetDir,
|
|
3068
|
+
data,
|
|
3069
|
+
overwrite: input.overwrite
|
|
3070
|
+
};
|
|
3071
|
+
}
|
|
3072
|
+
function resolvePjanglerRoot2() {
|
|
3073
|
+
let dir = dirname7(new URL(import.meta.url).pathname);
|
|
3074
|
+
while (dir !== dirname7(dir)) {
|
|
3075
|
+
if (existsSync8(join10(dir, "package.json")) && existsSync8(join10(dir, "templates", "commonproject", "copier.yml"))) return dir;
|
|
3076
|
+
dir = dirname7(dir);
|
|
3077
|
+
}
|
|
3078
|
+
return resolve2(process.cwd());
|
|
3079
|
+
}
|
|
3080
|
+
function validateNoDuplicateProject(registry, project, overwrite) {
|
|
3081
|
+
const existingSameSlug = registry.projects[project.slug];
|
|
3082
|
+
if (existingSameSlug && !overwrite && resolve2(existingSameSlug.repo_path) !== resolve2(project.repo_path)) {
|
|
3083
|
+
throw new Error(`Project slug already exists in registry: ${project.slug}`);
|
|
3084
|
+
}
|
|
3085
|
+
for (const [slug, existing] of Object.entries(registry.projects)) {
|
|
3086
|
+
if (slug === project.slug) continue;
|
|
3087
|
+
if (resolve2(existing.repo_path) === resolve2(project.repo_path)) {
|
|
3088
|
+
throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
|
|
3089
|
+
}
|
|
3090
|
+
if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
|
|
3091
|
+
throw new Error(`Project identifier already registered by ${slug}: ${project.ticket_provider.identifier}`);
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
function validateProjectRecord(project, key) {
|
|
3096
|
+
if (!isRecord(project)) throw new Error(`Project ${key} must be a mapping`);
|
|
3097
|
+
if (!project.name) throw new Error(`Project ${key} missing name`);
|
|
3098
|
+
if (!project.slug) throw new Error(`Project ${key} missing slug`);
|
|
3099
|
+
if (project.slug !== key) throw new Error(`Project key ${key} does not match slug ${project.slug}`);
|
|
3100
|
+
if (!project.repo_path) throw new Error(`Project ${key} missing repo_path`);
|
|
3101
|
+
if (!Array.isArray(project.source_artifacts)) throw new Error(`Project ${key} source_artifacts must be a list`);
|
|
3102
|
+
if (!isRecord(project.ticket_provider)) throw new Error(`Project ${key} ticket_provider must be a mapping`);
|
|
3103
|
+
if (!isRecord(project.agents)) throw new Error(`Project ${key} agents must be a mapping`);
|
|
3104
|
+
}
|
|
3105
|
+
function expandHome(path) {
|
|
3106
|
+
if (path === "~") return homedir5();
|
|
3107
|
+
if (path.startsWith("~/")) return join10(homedir5(), path.slice(2));
|
|
3108
|
+
return path;
|
|
3109
|
+
}
|
|
3110
|
+
function isRecord(value) {
|
|
3111
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
// src/mcp-server.ts
|
|
3115
|
+
var server = new McpServer({
|
|
3116
|
+
name: "pjangler-mcp",
|
|
3117
|
+
version: PJANGLER_VERSION
|
|
3118
|
+
});
|
|
3119
|
+
var TICKET_PROVIDER_SCHEMA = z.enum(["plane", "linear", "trello"]);
|
|
3120
|
+
function resolveTargetDir(targetDir) {
|
|
3121
|
+
const dir = resolve3(targetDir ?? process.cwd());
|
|
3122
|
+
if (!existsSync9(dir)) {
|
|
3123
|
+
throw new Error(`Target directory does not exist: ${dir}`);
|
|
3124
|
+
}
|
|
3125
|
+
if (!statSync2(dir).isDirectory()) {
|
|
3126
|
+
throw new Error(`Target path is not a directory: ${dir}`);
|
|
3127
|
+
}
|
|
3128
|
+
return dir;
|
|
3129
|
+
}
|
|
3130
|
+
function resolvePjanglerRoot3() {
|
|
3131
|
+
let dir = dirname8(fileURLToPath5(import.meta.url));
|
|
3132
|
+
while (dir !== dirname8(dir)) {
|
|
3133
|
+
if (existsSync9(join11(dir, "package.json")) && existsSync9(join11(dir, "templates", "commonproject", "copier.yml"))) {
|
|
3134
|
+
return dir;
|
|
3135
|
+
}
|
|
3136
|
+
dir = dirname8(dir);
|
|
3137
|
+
}
|
|
3138
|
+
throw new Error("Unable to resolve pjangler root");
|
|
3139
|
+
}
|
|
3140
|
+
function slugify(value) {
|
|
3141
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
3142
|
+
}
|
|
3143
|
+
function asText(payload) {
|
|
3144
|
+
return { content: [{ type: "text", text: typeof payload === "string" ? payload : JSON.stringify(payload, null, 2) }] };
|
|
3145
|
+
}
|
|
3146
|
+
function auditSummary(report) {
|
|
3147
|
+
const counts = report.rules.reduce((acc, rule) => {
|
|
3148
|
+
acc[rule.status] = (acc[rule.status] ?? 0) + 1;
|
|
3149
|
+
return acc;
|
|
3150
|
+
}, {});
|
|
3151
|
+
const nextActions = report.rules.filter((rule) => (rule.status === "fail" || rule.status === "warn") && rule.fixable).map((rule) => `pjangler_migrate_project ${rule.id}`);
|
|
3152
|
+
return { counts, nextActions };
|
|
3153
|
+
}
|
|
3154
|
+
function migrationSummary(report) {
|
|
3155
|
+
const counts = report.results.reduce((acc, result) => {
|
|
3156
|
+
acc[result.status] = (acc[result.status] ?? 0) + 1;
|
|
3157
|
+
return acc;
|
|
3158
|
+
}, {});
|
|
3159
|
+
return { counts, changedFileCount: report.changedFiles.length };
|
|
3160
|
+
}
|
|
3161
|
+
function parityGuidance() {
|
|
3162
|
+
return {
|
|
3163
|
+
skill: "@33god-projects",
|
|
3164
|
+
guidance: "Use these tools before editing a project so the repo SOT, agent files, mise hooks, and Hermes role scaffold are current.",
|
|
3165
|
+
workflows: [
|
|
3166
|
+
"audit -> pjangler_audit_project",
|
|
3167
|
+
"migrate -> pjangler_migrate_project",
|
|
3168
|
+
"bootstrap -> pjangler_bootstrap_33god_project",
|
|
3169
|
+
"agent provisioning -> pjangler_deploy_hermes_agent"
|
|
3170
|
+
]
|
|
3171
|
+
};
|
|
3172
|
+
}
|
|
3173
|
+
async function runRecipeWithCapture(recipeName, context) {
|
|
3174
|
+
const recipe = createRecipe(recipeName, context);
|
|
3175
|
+
if (!recipe) {
|
|
3176
|
+
return {
|
|
3177
|
+
success: false,
|
|
3178
|
+
logs: [],
|
|
3179
|
+
errors: [`Unknown recipe: ${recipeName}. Available: ${getRecipeNames().join(", ")}`]
|
|
3180
|
+
};
|
|
3181
|
+
}
|
|
3182
|
+
const logs = [];
|
|
3183
|
+
const errors = [];
|
|
3184
|
+
const origLog = console.log;
|
|
3185
|
+
const origError = console.error;
|
|
3186
|
+
console.log = (...args) => {
|
|
3187
|
+
logs.push(args.map((a) => String(a)).join(" "));
|
|
3188
|
+
};
|
|
3189
|
+
console.error = (...args) => {
|
|
3190
|
+
errors.push(args.map((a) => String(a)).join(" "));
|
|
3191
|
+
};
|
|
3192
|
+
try {
|
|
3193
|
+
await recipe.execute();
|
|
3194
|
+
const combined = [...logs, ...errors].join("\n");
|
|
3195
|
+
const success = !combined.match(/(^|\n)✗/);
|
|
3196
|
+
return { success, logs, errors };
|
|
3197
|
+
} catch (err) {
|
|
3198
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
3199
|
+
return { success: false, logs, errors };
|
|
3200
|
+
} finally {
|
|
3201
|
+
console.log = origLog;
|
|
3202
|
+
console.error = origError;
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
server.registerTool(
|
|
3206
|
+
"pjangler_list_capabilities",
|
|
3207
|
+
{
|
|
3208
|
+
title: "List pjangler capabilities",
|
|
3209
|
+
description: "Returns available recipes, commands, parity rules, workflows, and @33god-projects tool guidance.",
|
|
3210
|
+
inputSchema: {}
|
|
3211
|
+
},
|
|
3212
|
+
async () => {
|
|
3213
|
+
const payload = {
|
|
3214
|
+
recipes: Object.values(RECIPE_REGISTRY).map((r) => ({
|
|
3215
|
+
name: r.name,
|
|
3216
|
+
description: r.description,
|
|
3217
|
+
commands: r.commands
|
|
3218
|
+
})),
|
|
3219
|
+
commands: Object.values(COMMAND_REGISTRY).map((c) => ({
|
|
3220
|
+
name: c.name,
|
|
3221
|
+
description: c.description,
|
|
3222
|
+
group: c.group
|
|
3223
|
+
})),
|
|
3224
|
+
parityRules: getParityRuleIds(),
|
|
3225
|
+
recommendedWorkflows: {
|
|
3226
|
+
existingProject: ["pjangler_audit_project", "pjangler_migrate_project"],
|
|
3227
|
+
new33godProject: ["pjangler_project_init", "pjangler_bootstrap_33god_project", "pjangler_audit_project"],
|
|
3228
|
+
hermesAgentProvisioning: ["pjangler_deploy_hermes_agent", "pjangler_audit_project"]
|
|
3229
|
+
},
|
|
3230
|
+
skillSynergy: parityGuidance()
|
|
3231
|
+
};
|
|
3232
|
+
return asText(payload);
|
|
3233
|
+
}
|
|
3234
|
+
);
|
|
3235
|
+
server.registerTool(
|
|
3236
|
+
"pjangler_list_parity_rules",
|
|
3237
|
+
{
|
|
3238
|
+
title: "List pjangler parity rules",
|
|
3239
|
+
description: "Returns parity rule ids plus brief @33god-projects guidance.",
|
|
3240
|
+
inputSchema: {}
|
|
3241
|
+
},
|
|
3242
|
+
async () => asText({ parityRules: getParityRuleIds(), guidance: parityGuidance() })
|
|
3243
|
+
);
|
|
3244
|
+
server.registerTool(
|
|
3245
|
+
"pjangler_audit_project",
|
|
3246
|
+
{
|
|
3247
|
+
title: "Audit project parity",
|
|
3248
|
+
description: "Runs pjangler parity audit for a project and returns structured findings with summary counts and next actions.",
|
|
3249
|
+
inputSchema: {
|
|
3250
|
+
targetDir: z.string().optional(),
|
|
3251
|
+
json: z.boolean().optional()
|
|
3252
|
+
}
|
|
3253
|
+
},
|
|
3254
|
+
async ({ targetDir, json }) => {
|
|
3255
|
+
try {
|
|
3256
|
+
const resolvedTarget = resolveTargetDir(targetDir);
|
|
3257
|
+
const report = runAudit(resolvedTarget);
|
|
3258
|
+
const payload = { ...report, summary: auditSummary(report), guidance: parityGuidance() };
|
|
3259
|
+
return asText(json === false ? formatAuditReport(report) : payload);
|
|
3260
|
+
} catch (err) {
|
|
3261
|
+
return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
);
|
|
3265
|
+
server.registerTool(
|
|
3266
|
+
"pjangler_migrate_project",
|
|
3267
|
+
{
|
|
3268
|
+
title: "Migrate project parity",
|
|
3269
|
+
description: "Runs one pjangler parity migration rule, or all rules, against a project.",
|
|
3270
|
+
inputSchema: {
|
|
3271
|
+
targetDir: z.string().optional(),
|
|
3272
|
+
ruleId: z.string().optional(),
|
|
3273
|
+
all: z.boolean().optional(),
|
|
3274
|
+
dryRun: z.boolean().optional()
|
|
3275
|
+
}
|
|
3276
|
+
},
|
|
3277
|
+
async ({ targetDir, ruleId, all, dryRun }) => {
|
|
3278
|
+
try {
|
|
3279
|
+
const runAll = all ?? false;
|
|
3280
|
+
if (!runAll && !ruleId) throw new Error("Either ruleId or all=true is required");
|
|
3281
|
+
if (runAll && ruleId) throw new Error("Pass either ruleId or all=true, not both");
|
|
3282
|
+
const resolvedTarget = resolveTargetDir(targetDir);
|
|
3283
|
+
const report = runMigration(ruleId, resolvedTarget, dryRun ?? true, runAll);
|
|
3284
|
+
return asText({
|
|
3285
|
+
ok: report.ok,
|
|
3286
|
+
repo: report.repo,
|
|
3287
|
+
dryRun: report.dryRun,
|
|
3288
|
+
selectedRules: report.selectedRules,
|
|
3289
|
+
changedFiles: report.changedFiles,
|
|
3290
|
+
results: report.results,
|
|
3291
|
+
summary: migrationSummary(report)
|
|
3292
|
+
});
|
|
3293
|
+
} catch (err) {
|
|
3294
|
+
return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
);
|
|
3298
|
+
server.registerTool(
|
|
3299
|
+
"pjangler_bootstrap_33god_project",
|
|
3300
|
+
{
|
|
3301
|
+
title: "Bootstrap a new @33god project",
|
|
3302
|
+
description: "Create a new CommonProject-based 33god repo with optional local Hermes agent provisioning. Dry-run is safe and does not require copier.",
|
|
3303
|
+
inputSchema: {
|
|
3304
|
+
parentDir: z.string().optional(),
|
|
3305
|
+
targetDir: z.string().optional(),
|
|
3306
|
+
projectName: z.string(),
|
|
3307
|
+
projectDescription: z.string().optional(),
|
|
3308
|
+
projectSlug: z.string().optional(),
|
|
3309
|
+
ticketProvider: TICKET_PROVIDER_SCHEMA.optional(),
|
|
3310
|
+
planeWorkspace: z.string().optional(),
|
|
3311
|
+
planeProjectId: z.string().optional(),
|
|
3312
|
+
projectIdentifier: z.string().optional(),
|
|
3313
|
+
primaryLanguage: z.string().optional(),
|
|
3314
|
+
skipPlane: z.boolean().optional(),
|
|
3315
|
+
provisionAgent: z.boolean().optional(),
|
|
3316
|
+
agentRole: z.string().optional(),
|
|
3317
|
+
agentPurpose: z.string().optional(),
|
|
3318
|
+
local: z.boolean().optional(),
|
|
3319
|
+
force: z.boolean().optional(),
|
|
3320
|
+
overwrite: z.boolean().optional(),
|
|
3321
|
+
dryRun: z.boolean().optional(),
|
|
3322
|
+
registryPath: z.string().optional(),
|
|
3323
|
+
sourceSkill: z.string().optional(),
|
|
3324
|
+
live: z.boolean().optional()
|
|
3325
|
+
}
|
|
3326
|
+
},
|
|
3327
|
+
async (input) => {
|
|
3328
|
+
try {
|
|
3329
|
+
const pjanglerRoot = resolvePjanglerRoot3();
|
|
3330
|
+
const projectSlug = input.projectSlug ?? slugify(input.projectName);
|
|
3331
|
+
const parentDir = resolve3(input.parentDir ?? process.cwd());
|
|
3332
|
+
if (!existsSync9(parentDir) || !statSync2(parentDir).isDirectory()) throw new Error(`Parent directory does not exist: ${parentDir}`);
|
|
3333
|
+
const targetDir = resolve3(input.targetDir ?? join11(parentDir, projectSlug));
|
|
3334
|
+
const overwrite = input.overwrite ?? input.force ?? false;
|
|
3335
|
+
const dryRun = input.dryRun ?? true;
|
|
3336
|
+
const local = input.local ?? true;
|
|
3337
|
+
const skipPlane = input.skipPlane ?? true;
|
|
3338
|
+
const planeProjectId = input.planeProjectId ?? "";
|
|
3339
|
+
if (!skipPlane && !planeProjectId) {
|
|
3340
|
+
throw new Error("planeProjectId is required when skipPlane=false; keep skipPlane=true for safe local bootstrap");
|
|
3341
|
+
}
|
|
3342
|
+
if (!dryRun && existsSync9(targetDir) && !overwrite) throw new Error(`Target already exists: ${targetDir} (set force/overwrite=true to re-render)`);
|
|
3343
|
+
const plan = planProjectInit({
|
|
3344
|
+
name: input.projectName,
|
|
3345
|
+
description: input.projectDescription,
|
|
3346
|
+
targetDir,
|
|
3347
|
+
projectSlug,
|
|
3348
|
+
sourceSkill: input.sourceSkill,
|
|
3349
|
+
primaryLanguage: input.primaryLanguage ?? "python",
|
|
3350
|
+
provisionAgent: input.provisionAgent ?? false,
|
|
3351
|
+
agentRole: input.agentRole ?? "pm",
|
|
3352
|
+
apply: !dryRun,
|
|
3353
|
+
live: input.live ?? false,
|
|
3354
|
+
registryPath: input.registryPath,
|
|
3355
|
+
projectIdentifier: input.projectIdentifier ?? projectSlug.slice(0, 4).toUpperCase(),
|
|
3356
|
+
ticketProvider: input.ticketProvider ?? "plane",
|
|
3357
|
+
planeWorkspace: input.planeWorkspace ?? "33god",
|
|
3358
|
+
planeProjectId,
|
|
3359
|
+
pjanglerRoot,
|
|
3360
|
+
overwrite
|
|
3361
|
+
});
|
|
3362
|
+
if (dryRun) {
|
|
3363
|
+
return asText({ ...plan, guidance: parityGuidance() });
|
|
3364
|
+
}
|
|
3365
|
+
const result = executeProjectInitPlan(plan);
|
|
3366
|
+
if (!result.ok) return asText({ ...result, guidance: parityGuidance() });
|
|
3367
|
+
let agentResult;
|
|
3368
|
+
if (input.provisionAgent) {
|
|
3369
|
+
const context = {
|
|
3370
|
+
targetDir,
|
|
3371
|
+
yes: true,
|
|
3372
|
+
targetRepo: projectSlug,
|
|
3373
|
+
role: input.agentRole ?? "pm",
|
|
3374
|
+
agentPurpose: input.agentPurpose ?? `Project manager for ${input.projectName}`,
|
|
3375
|
+
local,
|
|
3376
|
+
force: overwrite,
|
|
3377
|
+
dryRun: false,
|
|
3378
|
+
skipTelegram: true,
|
|
3379
|
+
skipEmail: true,
|
|
3380
|
+
skipRuntimeRepo: local,
|
|
3381
|
+
skipPlane: skipPlane || local,
|
|
3382
|
+
skipBloodbank: local,
|
|
3383
|
+
skipSystemd: local || process.platform === "darwin"
|
|
3384
|
+
};
|
|
3385
|
+
agentResult = await runRecipeWithCapture("hermes-agent", context);
|
|
3386
|
+
}
|
|
3387
|
+
return asText({ ...result, ok: result.ok && (!agentResult || agentResult.success), agentResult, guidance: parityGuidance() });
|
|
3388
|
+
} catch (err) {
|
|
3389
|
+
return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
);
|
|
3393
|
+
server.registerTool(
|
|
3394
|
+
"pjangler_project_init",
|
|
3395
|
+
{
|
|
3396
|
+
title: "Initialize a pjangler project",
|
|
3397
|
+
description: "Plan or apply a registry-backed CommonProject project init. Dry-run is the default; writes require apply=true and live actions require live=true.",
|
|
3398
|
+
inputSchema: {
|
|
3399
|
+
name: z.string(),
|
|
3400
|
+
description: z.string().optional(),
|
|
3401
|
+
targetDir: z.string().optional(),
|
|
3402
|
+
sourceSkill: z.string().optional(),
|
|
3403
|
+
primaryLanguage: z.string().optional(),
|
|
3404
|
+
provisionAgent: z.boolean().optional(),
|
|
3405
|
+
agentRole: z.string().optional(),
|
|
3406
|
+
apply: z.boolean().optional(),
|
|
3407
|
+
live: z.boolean().optional(),
|
|
3408
|
+
slug: z.string().optional(),
|
|
3409
|
+
identifier: z.string().optional(),
|
|
3410
|
+
registryPath: z.string().optional(),
|
|
3411
|
+
force: z.boolean().optional()
|
|
3412
|
+
}
|
|
3413
|
+
},
|
|
3414
|
+
async (input) => {
|
|
3415
|
+
try {
|
|
3416
|
+
const plan = planProjectInit({
|
|
3417
|
+
name: input.name,
|
|
3418
|
+
description: input.description,
|
|
3419
|
+
targetDir: input.targetDir,
|
|
3420
|
+
sourceSkill: input.sourceSkill,
|
|
3421
|
+
primaryLanguage: input.primaryLanguage,
|
|
3422
|
+
provisionAgent: input.provisionAgent ?? false,
|
|
3423
|
+
agentRole: input.agentRole,
|
|
3424
|
+
apply: input.apply ?? false,
|
|
3425
|
+
live: input.live ?? false,
|
|
3426
|
+
projectSlug: input.slug,
|
|
3427
|
+
projectIdentifier: input.identifier,
|
|
3428
|
+
registryPath: input.registryPath,
|
|
3429
|
+
force: input.force ?? false,
|
|
3430
|
+
overwrite: input.force ?? false
|
|
3431
|
+
});
|
|
3432
|
+
if (!input.apply) return asText(plan);
|
|
3433
|
+
return asText(executeProjectInitPlan(plan));
|
|
3434
|
+
} catch (err) {
|
|
3435
|
+
return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
);
|
|
3439
|
+
server.registerTool(
|
|
3440
|
+
"pjangler_project_list",
|
|
3441
|
+
{
|
|
3442
|
+
title: "List pjangler registry projects",
|
|
3443
|
+
description: "Return projects from the pjangler central registry.",
|
|
3444
|
+
inputSchema: {
|
|
3445
|
+
registryPath: z.string().optional()
|
|
3446
|
+
}
|
|
3447
|
+
},
|
|
3448
|
+
async ({ registryPath: registryPath2 }) => asText(loadProjectRegistry(registryPath2 ?? projectRegistryPath()))
|
|
3449
|
+
);
|
|
3450
|
+
server.registerTool(
|
|
3451
|
+
"pjangler_project_show",
|
|
3452
|
+
{
|
|
3453
|
+
title: "Show a pjangler registry project",
|
|
3454
|
+
description: "Return one project by slug from the pjangler central registry.",
|
|
3455
|
+
inputSchema: {
|
|
3456
|
+
slug: z.string(),
|
|
3457
|
+
registryPath: z.string().optional()
|
|
3458
|
+
}
|
|
3459
|
+
},
|
|
3460
|
+
async ({ slug, registryPath: registryPath2 }) => {
|
|
3461
|
+
try {
|
|
3462
|
+
return asText(getProject(loadProjectRegistry(registryPath2 ?? projectRegistryPath()), slug));
|
|
3463
|
+
} catch (err) {
|
|
3464
|
+
return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
);
|
|
3468
|
+
server.registerTool(
|
|
3469
|
+
"pjangler_describe_recipe",
|
|
3470
|
+
{
|
|
3471
|
+
title: "Describe recipe",
|
|
3472
|
+
description: "Returns metadata for a specific pjangler recipe.",
|
|
3473
|
+
inputSchema: {
|
|
3474
|
+
recipe: z.string()
|
|
3475
|
+
}
|
|
3476
|
+
},
|
|
3477
|
+
async ({ recipe }) => {
|
|
3478
|
+
const info = getRecipeInfo(recipe);
|
|
3479
|
+
if (!info) {
|
|
3480
|
+
return {
|
|
3481
|
+
isError: true,
|
|
3482
|
+
content: [{ type: "text", text: `Recipe not found: ${recipe}` }]
|
|
3483
|
+
};
|
|
3484
|
+
}
|
|
3485
|
+
return asText({
|
|
3486
|
+
name: info.name,
|
|
3487
|
+
description: info.description,
|
|
3488
|
+
commands: info.commands
|
|
3489
|
+
});
|
|
3490
|
+
}
|
|
3491
|
+
);
|
|
3492
|
+
server.registerTool(
|
|
3493
|
+
"pjangler_run_recipe",
|
|
3494
|
+
{
|
|
3495
|
+
title: "Run recipe",
|
|
3496
|
+
description: "Executes any pjangler recipe against a target directory.",
|
|
3497
|
+
inputSchema: {
|
|
3498
|
+
recipe: z.enum(getRecipeNames()),
|
|
3499
|
+
targetDir: z.string().optional(),
|
|
3500
|
+
force: z.boolean().optional(),
|
|
3501
|
+
dryRun: z.boolean().optional()
|
|
3502
|
+
}
|
|
3503
|
+
},
|
|
3504
|
+
async ({ recipe, targetDir, force, dryRun }) => {
|
|
3505
|
+
try {
|
|
3506
|
+
const resolvedTarget = resolveTargetDir(targetDir);
|
|
3507
|
+
const context = {
|
|
3508
|
+
targetDir: resolvedTarget,
|
|
3509
|
+
force: force ?? false,
|
|
3510
|
+
dryRun: dryRun ?? false
|
|
3511
|
+
};
|
|
3512
|
+
const result = await runRecipeWithCapture(recipe, context);
|
|
3513
|
+
return {
|
|
3514
|
+
isError: !result.success,
|
|
3515
|
+
...asText({
|
|
3516
|
+
success: result.success,
|
|
3517
|
+
recipe,
|
|
3518
|
+
targetDir: resolvedTarget,
|
|
3519
|
+
logs: result.logs,
|
|
3520
|
+
errors: result.errors
|
|
3521
|
+
})
|
|
3522
|
+
};
|
|
3523
|
+
} catch (err) {
|
|
3524
|
+
return {
|
|
3525
|
+
isError: true,
|
|
3526
|
+
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }]
|
|
3527
|
+
};
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
);
|
|
3531
|
+
server.registerTool(
|
|
3532
|
+
"pjangler_deploy_hermes_agent",
|
|
3533
|
+
{
|
|
3534
|
+
title: "Deploy Hermes agent",
|
|
3535
|
+
description: "Provision a Hermes agent role for @33god-projects. For safe MCP use local=true defaults skip runtime repo, Plane, Bloodbank, and systemd; opt out with local=false plus explicit skip flags.",
|
|
3536
|
+
inputSchema: {
|
|
3537
|
+
targetDir: z.string(),
|
|
3538
|
+
targetRepo: z.string().optional(),
|
|
3539
|
+
role: z.enum(["pm", "dev", "review", "ops", "qa"]),
|
|
3540
|
+
agentPurpose: z.string().optional(),
|
|
3541
|
+
soulTone: z.enum(["direct", "playful", "formal", "terse"]).optional(),
|
|
3542
|
+
modelProvider: z.string().optional(),
|
|
3543
|
+
modelName: z.string().optional(),
|
|
3544
|
+
local: z.boolean().optional(),
|
|
3545
|
+
force: z.boolean().optional(),
|
|
3546
|
+
dryRun: z.boolean().optional(),
|
|
3547
|
+
skipTelegram: z.boolean().optional(),
|
|
3548
|
+
skipEmail: z.boolean().optional(),
|
|
3549
|
+
skipRuntimeRepo: z.boolean().optional(),
|
|
3550
|
+
skipPlane: z.boolean().optional(),
|
|
3551
|
+
skipBloodbank: z.boolean().optional(),
|
|
3552
|
+
skipSystemd: z.boolean().optional(),
|
|
3553
|
+
ticketProvider: TICKET_PROVIDER_SCHEMA.optional()
|
|
3554
|
+
}
|
|
3555
|
+
},
|
|
3556
|
+
async (input) => {
|
|
3557
|
+
try {
|
|
3558
|
+
const resolvedTarget = resolveTargetDir(input.targetDir);
|
|
3559
|
+
const local = input.local ?? true;
|
|
3560
|
+
const context = {
|
|
3561
|
+
targetDir: resolvedTarget,
|
|
3562
|
+
yes: true,
|
|
3563
|
+
local,
|
|
3564
|
+
targetRepo: input.targetRepo ?? basename4(resolvedTarget),
|
|
3565
|
+
role: input.role,
|
|
3566
|
+
agentPurpose: input.agentPurpose,
|
|
1460
3567
|
soulTone: input.soulTone,
|
|
1461
3568
|
modelProvider: input.modelProvider,
|
|
1462
3569
|
modelName: input.modelName,
|
|
3570
|
+
ticketProvider: input.ticketProvider,
|
|
1463
3571
|
force: input.force ?? false,
|
|
1464
3572
|
dryRun: input.dryRun ?? false,
|
|
1465
3573
|
skipTelegram: input.skipTelegram ?? true,
|
|
1466
3574
|
skipEmail: input.skipEmail ?? true,
|
|
1467
|
-
skipRuntimeRepo: input.skipRuntimeRepo ??
|
|
1468
|
-
skipPlane: input.skipPlane ??
|
|
1469
|
-
skipBloodbank: input.skipBloodbank ??
|
|
1470
|
-
skipSystemd: input.skipSystemd ??
|
|
3575
|
+
skipRuntimeRepo: input.skipRuntimeRepo ?? local,
|
|
3576
|
+
skipPlane: input.skipPlane ?? local,
|
|
3577
|
+
skipBloodbank: input.skipBloodbank ?? local,
|
|
3578
|
+
skipSystemd: input.skipSystemd ?? (local || process.platform === "darwin")
|
|
1471
3579
|
};
|
|
1472
3580
|
const result = await runRecipeWithCapture("hermes-agent", context);
|
|
1473
3581
|
return {
|
|
1474
3582
|
isError: !result.success,
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
errors: result.errors
|
|
1497
|
-
},
|
|
1498
|
-
null,
|
|
1499
|
-
2
|
|
1500
|
-
)
|
|
1501
|
-
}
|
|
1502
|
-
]
|
|
3583
|
+
...asText({
|
|
3584
|
+
success: result.success,
|
|
3585
|
+
recipe: "hermes-agent",
|
|
3586
|
+
targetDir: resolvedTarget,
|
|
3587
|
+
guidance: parityGuidance(),
|
|
3588
|
+
context: {
|
|
3589
|
+
targetRepo: context.targetRepo,
|
|
3590
|
+
role: context.role,
|
|
3591
|
+
local: context.local,
|
|
3592
|
+
dryRun: context.dryRun,
|
|
3593
|
+
force: context.force,
|
|
3594
|
+
skipTelegram: context.skipTelegram,
|
|
3595
|
+
skipEmail: context.skipEmail,
|
|
3596
|
+
skipRuntimeRepo: context.skipRuntimeRepo,
|
|
3597
|
+
skipPlane: context.skipPlane,
|
|
3598
|
+
skipBloodbank: context.skipBloodbank,
|
|
3599
|
+
skipSystemd: context.skipSystemd
|
|
3600
|
+
},
|
|
3601
|
+
logs: result.logs,
|
|
3602
|
+
errors: result.errors
|
|
3603
|
+
})
|
|
1503
3604
|
};
|
|
1504
3605
|
} catch (err) {
|
|
1505
3606
|
return {
|