@delorenj/pjangler 1.2.1 → 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.
Files changed (3) hide show
  1. package/dist/index.js +1033 -103
  2. package/dist/mcp-server.js +669 -111
  3. package/package.json +3 -1
@@ -1,9 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/mcp-server.ts
4
- import { spawnSync as spawnSync5 } from "node:child_process";
5
- import { existsSync as existsSync8, mkdirSync as mkdirSync6, statSync } from "node:fs";
6
- import { basename as basename2, dirname as dirname7, join as join10, resolve as resolve2 } from "node:path";
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";
7
6
  import { fileURLToPath as fileURLToPath5 } from "node:url";
8
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -1452,7 +1451,7 @@ var PJANGLER_VERSION = (() => {
1452
1451
 
1453
1452
  // src/parity/index.ts
1454
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";
1455
- import { dirname as dirname6, join as join9, relative, resolve } from "node:path";
1454
+ import { basename as basename2, dirname as dirname6, join as join9, relative, resolve } from "node:path";
1456
1455
  import { fileURLToPath as fileURLToPath4 } from "node:url";
1457
1456
  import { homedir as homedir4 } from "node:os";
1458
1457
  import { spawnSync as spawnSync4 } from "node:child_process";
@@ -1472,6 +1471,28 @@ enter = [
1472
1471
  patterns = ["AGENTS.md"]
1473
1472
  task = "link-agentfiles"
1474
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
+
1475
1496
  [tasks.link-agentfiles]
1476
1497
  description = "Symlink all agent files to AGENTS.md"
1477
1498
  run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
@@ -1669,6 +1690,22 @@ function templateVersioningScript(ctx) {
1669
1690
  function templateLinkAgentfilesScript(ctx) {
1670
1691
  return templateScript(ctx, "link-agentfiles.sh");
1671
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)));
1706
+ }
1707
+ return true;
1708
+ }
1672
1709
  function templateVersionFilesConf(ctx, repoRoot) {
1673
1710
  const packageJson = join9(repoRoot, "package.json");
1674
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";
@@ -1724,23 +1761,125 @@ ${text3.replace(/^\s+/, "")}`;
1724
1761
  if (pathLine[0] === nextLine) return text3;
1725
1762
  return `${prefix}${section.replace(pathLine[0], nextLine)}${suffix}`;
1726
1763
  }
1727
- function upsertLinkAgentfilesBlock(text3, ctx) {
1728
- const withPath = upsertMisePath(text3, requiredMisePathEntries(ctx));
1729
- const existing = /# This block will handle the linking of[\s\S]*?\[tasks\.link-agentfiles\][\s\S]*?run = "\{\{config_root\}\}\/\.mise\/scripts\/link-agentfiles\.sh"/;
1730
- if (existing.test(withPath)) {
1731
- return withPath.replace(existing, LINK_AGENTFILES_BLOCK);
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
+ }
1786
+ }
1787
+ if (end === -1) end = lines.length;
1788
+ break;
1789
+ }
1790
+ if (start === -1) return text3;
1791
+ if (options?.includePrecedingComments) {
1792
+ while (start > 0 && lines[start - 1].trim().startsWith("#")) {
1793
+ start--;
1794
+ }
1732
1795
  }
1733
- const versioningIndex = withPath.indexOf("# >>> mise-versioning >>>");
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 >>>");
1734
1801
  if (versioningIndex >= 0) {
1735
- return `${withPath.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${LINK_AGENTFILES_BLOCK}
1802
+ return `${text3.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${block}
1736
1803
 
1737
- ${withPath.slice(versioningIndex)}`;
1804
+ ${text3.slice(versioningIndex)}`;
1738
1805
  }
1739
- return `${withPath.replace(/\s*$/, "")}
1806
+ return `${text3.replace(/\s*$/, "")}
1740
1807
 
1741
- ${LINK_AGENTFILES_BLOCK}
1808
+ ${block}
1742
1809
  `;
1743
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
+ }
1744
1883
  function readProjectJson(ctx) {
1745
1884
  return tryParseJson(safeReadText(join9(ctx.repoRoot, ".project.json")));
1746
1885
  }
@@ -1754,23 +1893,35 @@ function canonicalProjectJson(ctx) {
1754
1893
  workspace: String((existing.ticket_provider?.workspace ?? firstRole?.planeWorkspace ?? "") || ""),
1755
1894
  identifier: String((existing.ticket_provider?.identifier ?? firstRole?.ticketProviderIdentifier ?? "") || ""),
1756
1895
  board_id: String((existing.ticket_provider?.board_id ?? firstRole?.ticketProviderBoardId ?? "") || ""),
1757
- board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || "")
1896
+ board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || ""),
1897
+ state: String((existing.ticket_provider?.state ?? "planned") || "planned")
1758
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
+ }
1759
1918
  return {
1760
1919
  project_name: String(existing.project_name ?? titleCaseSlug(slug)),
1761
1920
  project_description: String(existing.project_description ?? ""),
1762
1921
  project_slug: slug,
1763
1922
  repo_path: ctx.repoRoot,
1764
1923
  ticket_provider: ticketProvider,
1765
- agents: Object.fromEntries(
1766
- roles.map((role) => [
1767
- role.agentId || `${slug}-${role.role}`,
1768
- {
1769
- role: role.role,
1770
- role_dir: relative(ctx.repoRoot, role.roleDir)
1771
- }
1772
- ])
1773
- )
1924
+ agents
1774
1925
  };
1775
1926
  }
1776
1927
  function projectJsonFinding(ctx) {
@@ -1802,7 +1953,7 @@ function projectJsonFinding(ctx) {
1802
1953
  }
1803
1954
  }
1804
1955
  const ticketProvider = data.ticket_provider ?? {};
1805
- for (const key of ["type", "workspace", "identifier", "board_id", "board_url"]) {
1956
+ for (const key of ["type", "workspace", "identifier", "board_id", "board_url", "state"]) {
1806
1957
  if (!(key in ticketProvider)) details.push(`ticket_provider.${key} missing`);
1807
1958
  }
1808
1959
  if (existsSync7(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
@@ -2032,13 +2183,20 @@ var RULES = [
2032
2183
  migrate: (ctx, finding) => {
2033
2184
  const path = join9(ctx.repoRoot, "mise.toml");
2034
2185
  const changedFiles = [];
2186
+ const details = [];
2035
2187
  if (!existsSync7(path)) {
2036
- return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing; initialize mise first", changedFiles, details: [] };
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
+ }
2037
2195
  }
2038
2196
  let text3 = readText(path);
2039
2197
  const next = upsertLinkAgentfilesBlock(text3, ctx);
2040
2198
  if (next !== text3) {
2041
- changedFiles.push(path);
2199
+ if (!changedFiles.includes(path)) changedFiles.push(path);
2042
2200
  if (!ctx.dryRun) writeText(path, next);
2043
2201
  text3 = next;
2044
2202
  }
@@ -2087,14 +2245,21 @@ var RULES = [
2087
2245
  },
2088
2246
  migrate: (ctx, finding) => {
2089
2247
  const changedFiles = [];
2248
+ const details = [];
2090
2249
  const misePath = join9(ctx.repoRoot, "mise.toml");
2091
2250
  if (!existsSync7(misePath)) {
2092
- return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing; cannot inject versioning block", changedFiles, details: [] };
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
+ }
2093
2258
  }
2094
2259
  const currentMise = readText(misePath);
2095
2260
  const nextMise = replaceOrAppendManagedBlock(currentMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
2096
2261
  if (nextMise !== currentMise) {
2097
- changedFiles.push(misePath);
2262
+ if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
2098
2263
  if (!ctx.dryRun) writeText(misePath, nextMise);
2099
2264
  }
2100
2265
  const versioningPath = join9(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
@@ -2234,7 +2399,8 @@ var RULES = [
2234
2399
  } else {
2235
2400
  const invalidLines = envOp.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).filter((line) => {
2236
2401
  const value = line.slice(line.indexOf("=") + 1).trim();
2237
- return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value);
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;
2238
2404
  });
2239
2405
  if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals: ${invalidLines.join(", ")}`);
2240
2406
  }
@@ -2539,7 +2705,7 @@ function runAudit(repoArg) {
2539
2705
  rules
2540
2706
  };
2541
2707
  }
2542
- function runMigration(selector, repoArg, dryRun, all) {
2708
+ function runMigrationForRules(ruleIds, repoArg, dryRun) {
2543
2709
  const pjanglerRoot = resolvePjanglerRoot();
2544
2710
  const ctx = {
2545
2711
  repoRoot: resolve(repoArg ?? process.cwd()),
@@ -2547,9 +2713,9 @@ function runMigration(selector, repoArg, dryRun, all) {
2547
2713
  pjanglerRoot,
2548
2714
  homeDir: homedir4()
2549
2715
  };
2550
- const selected = all ? RULES : RULES.filter((rule) => rule.id === selector);
2716
+ const selected = RULES.filter((rule) => ruleIds.includes(rule.id));
2551
2717
  if (!selected.length) {
2552
- throw new Error(`Unknown parity rule: ${selector}`);
2718
+ throw new Error(`Unknown parity rules: ${ruleIds.join(", ")}`);
2553
2719
  }
2554
2720
  const results = selected.map((rule) => {
2555
2721
  try {
@@ -2575,6 +2741,10 @@ function runMigration(selector, repoArg, dryRun, all) {
2575
2741
  changedFiles
2576
2742
  };
2577
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
+ }
2578
2748
  function formatAuditReport(report) {
2579
2749
  const lines = [`repo: ${report.repo}`, `ok: ${report.ok}`, `audited_at: ${report.auditedAt}`, "rules:"];
2580
2750
  for (const rule of report.rules) {
@@ -2585,6 +2755,362 @@ function formatAuditReport(report) {
2585
2755
  `;
2586
2756
  }
2587
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
+
2588
3114
  // src/mcp-server.ts
2589
3115
  var server = new McpServer({
2590
3116
  name: "pjangler-mcp",
@@ -2592,22 +3118,22 @@ var server = new McpServer({
2592
3118
  });
2593
3119
  var TICKET_PROVIDER_SCHEMA = z.enum(["plane", "linear", "trello"]);
2594
3120
  function resolveTargetDir(targetDir) {
2595
- const dir = resolve2(targetDir ?? process.cwd());
2596
- if (!existsSync8(dir)) {
3121
+ const dir = resolve3(targetDir ?? process.cwd());
3122
+ if (!existsSync9(dir)) {
2597
3123
  throw new Error(`Target directory does not exist: ${dir}`);
2598
3124
  }
2599
- if (!statSync(dir).isDirectory()) {
3125
+ if (!statSync2(dir).isDirectory()) {
2600
3126
  throw new Error(`Target path is not a directory: ${dir}`);
2601
3127
  }
2602
3128
  return dir;
2603
3129
  }
2604
- function resolvePjanglerRoot2() {
2605
- let dir = dirname7(fileURLToPath5(import.meta.url));
2606
- while (dir !== dirname7(dir)) {
2607
- if (existsSync8(join10(dir, "package.json")) && existsSync8(join10(dir, "templates", "commonproject", "copier.yml"))) {
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"))) {
2608
3134
  return dir;
2609
3135
  }
2610
- dir = dirname7(dir);
3136
+ dir = dirname8(dir);
2611
3137
  }
2612
3138
  throw new Error("Unable to resolve pjangler root");
2613
3139
  }
@@ -2676,26 +3202,6 @@ async function runRecipeWithCapture(recipeName, context) {
2676
3202
  console.error = origError;
2677
3203
  }
2678
3204
  }
2679
- function buildCommonProjectCopierAction(input) {
2680
- const data = {
2681
- project_name: input.projectName,
2682
- project_description: input.projectDescription ?? "",
2683
- project_slug: input.projectSlug,
2684
- ticket_provider: input.ticketProvider,
2685
- plane_workspace: input.planeWorkspace,
2686
- plane_project_id: input.planeProjectId ?? "",
2687
- project_identifier: input.projectIdentifier,
2688
- primary_language: input.primaryLanguage
2689
- };
2690
- const args = ["copy", "--trust", input.templateDir, input.targetDir, "--defaults"];
2691
- for (const [key, value] of Object.entries(data)) args.push("--data", `${key}=${value}`);
2692
- if (input.overwrite) args.push("--overwrite");
2693
- return {
2694
- kind: "copier.copy.commonproject",
2695
- command: ["copier", ...args],
2696
- data
2697
- };
2698
- }
2699
3205
  server.registerTool(
2700
3206
  "pjangler_list_capabilities",
2701
3207
  {
@@ -2718,7 +3224,7 @@ server.registerTool(
2718
3224
  parityRules: getParityRuleIds(),
2719
3225
  recommendedWorkflows: {
2720
3226
  existingProject: ["pjangler_audit_project", "pjangler_migrate_project"],
2721
- new33godProject: ["pjangler_bootstrap_33god_project", "pjangler_audit_project"],
3227
+ new33godProject: ["pjangler_project_init", "pjangler_bootstrap_33god_project", "pjangler_audit_project"],
2722
3228
  hermesAgentProvisioning: ["pjangler_deploy_hermes_agent", "pjangler_audit_project"]
2723
3229
  },
2724
3230
  skillSynergy: parityGuidance()
@@ -2812,16 +3318,19 @@ server.registerTool(
2812
3318
  local: z.boolean().optional(),
2813
3319
  force: z.boolean().optional(),
2814
3320
  overwrite: z.boolean().optional(),
2815
- dryRun: z.boolean().optional()
3321
+ dryRun: z.boolean().optional(),
3322
+ registryPath: z.string().optional(),
3323
+ sourceSkill: z.string().optional(),
3324
+ live: z.boolean().optional()
2816
3325
  }
2817
3326
  },
2818
3327
  async (input) => {
2819
3328
  try {
2820
- const pjanglerRoot = resolvePjanglerRoot2();
3329
+ const pjanglerRoot = resolvePjanglerRoot3();
2821
3330
  const projectSlug = input.projectSlug ?? slugify(input.projectName);
2822
- const parentDir = resolve2(input.parentDir ?? process.cwd());
2823
- if (!existsSync8(parentDir) || !statSync(parentDir).isDirectory()) throw new Error(`Parent directory does not exist: ${parentDir}`);
2824
- const targetDir = resolve2(input.targetDir ?? join10(parentDir, projectSlug));
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));
2825
3334
  const overwrite = input.overwrite ?? input.force ?? false;
2826
3335
  const dryRun = input.dryRun ?? true;
2827
3336
  const local = input.local ?? true;
@@ -2830,57 +3339,31 @@ server.registerTool(
2830
3339
  if (!skipPlane && !planeProjectId) {
2831
3340
  throw new Error("planeProjectId is required when skipPlane=false; keep skipPlane=true for safe local bootstrap");
2832
3341
  }
2833
- if (existsSync8(targetDir) && !overwrite) throw new Error(`Target already exists: ${targetDir} (set force/overwrite=true to re-render)`);
2834
- const templateDir = join10(pjanglerRoot, "templates", "commonproject");
2835
- const copierAction = buildCommonProjectCopierAction({
2836
- templateDir,
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,
2837
3346
  targetDir,
2838
- projectName: input.projectName,
2839
- projectDescription: input.projectDescription,
2840
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(),
2841
3356
  ticketProvider: input.ticketProvider ?? "plane",
2842
3357
  planeWorkspace: input.planeWorkspace ?? "33god",
2843
3358
  planeProjectId,
2844
- projectIdentifier: input.projectIdentifier ?? projectSlug.slice(0, 4).toUpperCase(),
2845
- primaryLanguage: input.primaryLanguage ?? "python",
3359
+ pjanglerRoot,
2846
3360
  overwrite
2847
3361
  });
2848
- const actions = [
2849
- { kind: "ensure.parent", path: dirname7(targetDir) },
2850
- copierAction
2851
- ];
2852
- if (input.provisionAgent) {
2853
- actions.push({
2854
- kind: "pjangler.recipe.hermes-agent",
2855
- targetDir,
2856
- context: {
2857
- targetRepo: projectSlug,
2858
- role: input.agentRole ?? "pm",
2859
- agentPurpose: input.agentPurpose ?? `Project manager for ${input.projectName}`,
2860
- local,
2861
- dryRun,
2862
- force: overwrite,
2863
- skipTelegram: true,
2864
- skipEmail: true,
2865
- skipRuntimeRepo: local,
2866
- skipPlane: skipPlane || local,
2867
- skipBloodbank: local,
2868
- skipSystemd: local || process.platform === "darwin"
2869
- }
2870
- });
2871
- }
2872
3362
  if (dryRun) {
2873
- return asText({ ok: true, dryRun, targetDir, projectSlug, actions, guidance: parityGuidance() });
2874
- }
2875
- const which = spawnSync5("which", ["copier"], { encoding: "utf8" });
2876
- if (which.status !== 0) throw new Error("copier not found on PATH. Install with: uv tool install copier or pip install copier");
2877
- mkdirSync6(dirname7(targetDir), { recursive: true });
2878
- const result = spawnSync5(copierAction.command[0], copierAction.command.slice(1), { encoding: "utf8", cwd: pjanglerRoot });
2879
- const logs = [result.stdout.trim()].filter(Boolean);
2880
- const errors = [result.stderr.trim()].filter(Boolean);
2881
- if (result.status !== 0) {
2882
- return asText({ ok: false, dryRun, targetDir, actions, logs, errors, exitCode: result.status });
3363
+ return asText({ ...plan, guidance: parityGuidance() });
2883
3364
  }
3365
+ const result = executeProjectInitPlan(plan);
3366
+ if (!result.ok) return asText({ ...result, guidance: parityGuidance() });
2884
3367
  let agentResult;
2885
3368
  if (input.provisionAgent) {
2886
3369
  const context = {
@@ -2901,7 +3384,82 @@ server.registerTool(
2901
3384
  };
2902
3385
  agentResult = await runRecipeWithCapture("hermes-agent", context);
2903
3386
  }
2904
- return asText({ ok: !agentResult || agentResult.success, dryRun, targetDir, projectSlug, actions, logs, errors, agentResult });
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));
2905
3463
  } catch (err) {
2906
3464
  return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
2907
3465
  }
@@ -3003,7 +3561,7 @@ server.registerTool(
3003
3561
  targetDir: resolvedTarget,
3004
3562
  yes: true,
3005
3563
  local,
3006
- targetRepo: input.targetRepo ?? basename2(resolvedTarget),
3564
+ targetRepo: input.targetRepo ?? basename4(resolvedTarget),
3007
3565
  role: input.role,
3008
3566
  agentPurpose: input.agentPurpose,
3009
3567
  soulTone: input.soulTone,