@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
package/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import { spawnSync as spawnSync6 } from "node:child_process";
5
+ import { existsSync as existsSync9, readFileSync as readFileSync6, statSync as statSync2 } from "node:fs";
6
+ import { basename as basename4, join as join11, resolve as resolve3 } from "node:path";
4
7
  import { Command as Command3 } from "commander";
5
8
 
6
9
  // src/commands/hermes/types.ts
@@ -1438,9 +1441,12 @@ function createRecipe(name, context) {
1438
1441
  return new info.class(context);
1439
1442
  }
1440
1443
 
1444
+ // src/index.ts
1445
+ import { cancel as cancel2, multiselect, text as text3, isCancel as isCancel5 } from "@clack/prompts";
1446
+
1441
1447
  // src/parity/index.ts
1442
1448
  import { existsSync as existsSync7, lstatSync, mkdirSync as mkdirSync5, readFileSync as readFileSync3, readlinkSync, readdirSync, renameSync, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4, chmodSync, copyFileSync } from "node:fs";
1443
- import { dirname as dirname5, join as join8, relative, resolve } from "node:path";
1449
+ import { basename as basename2, dirname as dirname5, join as join8, relative, resolve } from "node:path";
1444
1450
  import { fileURLToPath as fileURLToPath3 } from "node:url";
1445
1451
  import { homedir as homedir4 } from "node:os";
1446
1452
  import { spawnSync as spawnSync4 } from "node:child_process";
@@ -1460,6 +1466,28 @@ enter = [
1460
1466
  patterns = ["AGENTS.md"]
1461
1467
  task = "link-agentfiles"
1462
1468
 
1469
+ [tasks.link-agentfiles]
1470
+ description = "Symlink all agent files to AGENTS.md"
1471
+ run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
1472
+ var LINK_AGENTFILES_HOOK_ENTRIES = [
1473
+ "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1474
+ "op inject -i .env.op > .env"
1475
+ ];
1476
+ var LINK_AGENTFILES_HOOKS_BLOCK = `# This block will handle the linking of
1477
+ # agent files to the main AGENTS.md file.
1478
+ #
1479
+ # TODO: Ensure this works for all levels of nesting.
1480
+ # i.e. All linked agent files MUST be siblings at
1481
+ # any given level of nesting.
1482
+ [hooks]
1483
+ enter = [
1484
+ "{{config_root}}/.mise/scripts/link-agentfiles.sh",
1485
+ "op inject -i .env.op > .env",
1486
+ ]`;
1487
+ var LINK_AGENTFILES_WATCH_TASK_BLOCK = `[[watch_files]]
1488
+ patterns = ["AGENTS.md"]
1489
+ task = "link-agentfiles"
1490
+
1463
1491
  [tasks.link-agentfiles]
1464
1492
  description = "Symlink all agent files to AGENTS.md"
1465
1493
  run = "{{config_root}}/.mise/scripts/link-agentfiles.sh"`;
@@ -1515,10 +1543,10 @@ function writeText(path, content) {
1515
1543
  ensureParent(path);
1516
1544
  writeFileSync4(path, content);
1517
1545
  }
1518
- function tryParseJson(text3) {
1519
- if (!text3) return null;
1546
+ function tryParseJson(text4) {
1547
+ if (!text4) return null;
1520
1548
  try {
1521
- return JSON.parse(text3);
1549
+ return JSON.parse(text4);
1522
1550
  } catch {
1523
1551
  return null;
1524
1552
  }
@@ -1577,9 +1605,9 @@ function bootstrapAgentsFile(repoRoot, dryRun) {
1577
1605
  }
1578
1606
  return { changedFiles: [], details: [], blocked: "AGENTS.md missing and no CLAUDE.md, GEMINI.md, or README.md source exists" };
1579
1607
  }
1580
- function yamlGet(text3, keyPath) {
1608
+ function yamlGet(text4, keyPath) {
1581
1609
  const parts = keyPath.split(".");
1582
- const lines = text3.split("\n");
1610
+ const lines = text4.split("\n");
1583
1611
  let start = 0;
1584
1612
  let indent = 0;
1585
1613
  for (let idx = 0; idx < parts.length; idx += 1) {
@@ -1614,25 +1642,25 @@ function discoverRoles(repoRoot) {
1614
1642
  const roleDir = join8(rolesDir, entry.name);
1615
1643
  const roleYamlPath = join8(roleDir, "role.yaml");
1616
1644
  if (!existsSync7(roleYamlPath)) return null;
1617
- const text3 = readText(roleYamlPath);
1618
- const runtimeRepoRaw = yamlGet(text3, "runtime.github_repo");
1645
+ const text4 = readText(roleYamlPath);
1646
+ const runtimeRepoRaw = yamlGet(text4, "runtime.github_repo");
1619
1647
  return {
1620
- role: yamlGet(text3, "role") || entry.name,
1648
+ role: yamlGet(text4, "role") || entry.name,
1621
1649
  roleDir,
1622
1650
  roleYamlPath,
1623
- repo: yamlGet(text3, "repo"),
1624
- agentId: yamlGet(text3, "agent_id"),
1625
- profileName: yamlGet(text3, "profile") || yamlGet(text3, "agent_id"),
1626
- displayName: yamlGet(text3, "display_name"),
1627
- purpose: yamlGet(text3, "purpose"),
1628
- botHandle: yamlGet(text3, "telegram.bot_username"),
1651
+ repo: yamlGet(text4, "repo"),
1652
+ agentId: yamlGet(text4, "agent_id"),
1653
+ profileName: yamlGet(text4, "profile") || yamlGet(text4, "agent_id"),
1654
+ displayName: yamlGet(text4, "display_name"),
1655
+ purpose: yamlGet(text4, "purpose"),
1656
+ botHandle: yamlGet(text4, "telegram.bot_username"),
1629
1657
  runtimeRepo: runtimeRepoRaw.includes("/") ? runtimeRepoRaw.split("/").slice(-1)[0] ?? runtimeRepoRaw : runtimeRepoRaw,
1630
- runtimeOwner: yamlGet(text3, "runtime.github_owner"),
1631
- planeWorkspace: yamlGet(text3, "ticket_provider.workspace") || yamlGet(text3, "plane.workspace"),
1632
- ticketProviderName: yamlGet(text3, "ticket_provider.name"),
1633
- ticketProviderBoardId: yamlGet(text3, "ticket_provider.board_id"),
1634
- ticketProviderBoardUrl: yamlGet(text3, "ticket_provider.board_url"),
1635
- ticketProviderIdentifier: yamlGet(text3, "plane.identifier")
1658
+ runtimeOwner: yamlGet(text4, "runtime.github_owner"),
1659
+ planeWorkspace: yamlGet(text4, "ticket_provider.workspace") || yamlGet(text4, "plane.workspace"),
1660
+ ticketProviderName: yamlGet(text4, "ticket_provider.name"),
1661
+ ticketProviderBoardId: yamlGet(text4, "ticket_provider.board_id"),
1662
+ ticketProviderBoardUrl: yamlGet(text4, "ticket_provider.board_url"),
1663
+ ticketProviderIdentifier: yamlGet(text4, "plane.identifier")
1636
1664
  };
1637
1665
  }).filter((value) => Boolean(value));
1638
1666
  }
@@ -1657,23 +1685,39 @@ function templateVersioningScript(ctx) {
1657
1685
  function templateLinkAgentfilesScript(ctx) {
1658
1686
  return templateScript(ctx, "link-agentfiles.sh");
1659
1687
  }
1688
+ function renderGeneratedProjectMiseToml(ctx, template) {
1689
+ const project = readProjectJson(ctx);
1690
+ const projectName = String(project?.project_name ?? basename2(ctx.repoRoot) ?? "project");
1691
+ return template.replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
1692
+ }
1693
+ function ensureMiseTomlFromTemplate(ctx, changedFiles) {
1694
+ const targetPath = join8(ctx.repoRoot, "mise.toml");
1695
+ if (existsSync7(targetPath)) return false;
1696
+ const sourcePath = join8(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
1697
+ if (!existsSync7(sourcePath)) return false;
1698
+ changedFiles.push(targetPath);
1699
+ if (!ctx.dryRun) {
1700
+ writeText(targetPath, renderGeneratedProjectMiseToml(ctx, readText(sourcePath)));
1701
+ }
1702
+ return true;
1703
+ }
1660
1704
  function templateVersionFilesConf(ctx, repoRoot) {
1661
1705
  const packageJson = join8(repoRoot, "package.json");
1662
1706
  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";
1663
1707
  }
1664
- function replaceOrAppendManagedBlock(text3, startMarker, block, beforePattern) {
1665
- if (startMarker.test(text3)) {
1666
- return text3.replace(/# >>> mise-versioning >>>[\s\S]*?# <<< mise-versioning <<</, block);
1708
+ function replaceOrAppendManagedBlock(text4, startMarker, block, beforePattern) {
1709
+ if (startMarker.test(text4)) {
1710
+ return text4.replace(/# >>> mise-versioning >>>[\s\S]*?# <<< mise-versioning <<</, block);
1667
1711
  }
1668
1712
  if (beforePattern) {
1669
- const match = text3.match(beforePattern);
1713
+ const match = text4.match(beforePattern);
1670
1714
  if (match && typeof match.index === "number") {
1671
- return `${text3.slice(0, match.index).replace(/\s*$/, "\n\n")}${block}
1715
+ return `${text4.slice(0, match.index).replace(/\s*$/, "\n\n")}${block}
1672
1716
 
1673
- ${text3.slice(match.index)}`;
1717
+ ${text4.slice(match.index)}`;
1674
1718
  }
1675
1719
  }
1676
- return `${text3.replace(/\s*$/, "")}
1720
+ return `${text4.replace(/\s*$/, "")}
1677
1721
 
1678
1722
  ${block}
1679
1723
  `;
@@ -1687,18 +1731,18 @@ function requiredMisePathEntries(ctx) {
1687
1731
  }
1688
1732
  return required;
1689
1733
  }
1690
- function upsertMisePath(text3, required = BASE_MISE_PATH_ENTRIES) {
1734
+ function upsertMisePath(text4, required = BASE_MISE_PATH_ENTRIES) {
1691
1735
  const render = (values) => `_.path = [${values.map((value) => JSON.stringify(value)).join(", ")}]`;
1692
- const envMatch = text3.match(/(^|\n)(\[env\][\s\S]*?)(?=\n\[[^\]]+\]|$)/);
1736
+ const envMatch = text4.match(/(^|\n)(\[env\][\s\S]*?)(?=\n\[[^\]]+\]|$)/);
1693
1737
  if (!envMatch || typeof envMatch.index !== "number") {
1694
1738
  return `[env]
1695
1739
  ${render(required)}
1696
1740
 
1697
- ${text3.replace(/^\s+/, "")}`;
1741
+ ${text4.replace(/^\s+/, "")}`;
1698
1742
  }
1699
- const prefix = text3.slice(0, envMatch.index + envMatch[1].length);
1743
+ const prefix = text4.slice(0, envMatch.index + envMatch[1].length);
1700
1744
  const section = envMatch[2];
1701
- const suffix = text3.slice(envMatch.index + envMatch[1].length + section.length);
1745
+ const suffix = text4.slice(envMatch.index + envMatch[1].length + section.length);
1702
1746
  const pathLine = section.match(/^_\.path\s*=\s*\[([^\]]*)\]\s*$/m);
1703
1747
  if (!pathLine) {
1704
1748
  return `${prefix}${section.replace(/\n?$/, "\n")}${render(required)}${suffix}`;
@@ -1709,26 +1753,128 @@ ${text3.replace(/^\s+/, "")}`;
1709
1753
  if (!merged.includes(value)) merged.push(value);
1710
1754
  }
1711
1755
  const nextLine = render(merged);
1712
- if (pathLine[0] === nextLine) return text3;
1756
+ if (pathLine[0] === nextLine) return text4;
1713
1757
  return `${prefix}${section.replace(pathLine[0], nextLine)}${suffix}`;
1714
1758
  }
1715
- function upsertLinkAgentfilesBlock(text3, ctx) {
1716
- const withPath = upsertMisePath(text3, requiredMisePathEntries(ctx));
1717
- const existing = /# This block will handle the linking of[\s\S]*?\[tasks\.link-agentfiles\][\s\S]*?run = "\{\{config_root\}\}\/\.mise\/scripts\/link-agentfiles\.sh"/;
1718
- if (existing.test(withPath)) {
1719
- return withPath.replace(existing, LINK_AGENTFILES_BLOCK);
1759
+ function removeTomlSection(text4, headerPattern, marker, options) {
1760
+ const lines = text4.split("\n");
1761
+ let start = -1;
1762
+ let end = -1;
1763
+ for (let i = 0; i < lines.length; i++) {
1764
+ if (!headerPattern.test(lines[i])) continue;
1765
+ if (marker) {
1766
+ let hasMarker = false;
1767
+ for (let j = i + 1; j < lines.length && !/^\[[^\]]+\]/.test(lines[j]); j++) {
1768
+ if (marker.test(lines[j])) {
1769
+ hasMarker = true;
1770
+ break;
1771
+ }
1772
+ }
1773
+ if (!hasMarker) continue;
1774
+ }
1775
+ start = i;
1776
+ for (let j = i + 1; j < lines.length; j++) {
1777
+ if (/^\[[^\]]+\]/.test(lines[j])) {
1778
+ end = j;
1779
+ break;
1780
+ }
1781
+ }
1782
+ if (end === -1) end = lines.length;
1783
+ break;
1720
1784
  }
1721
- const versioningIndex = withPath.indexOf("# >>> mise-versioning >>>");
1785
+ if (start === -1) return text4;
1786
+ if (options?.includePrecedingComments) {
1787
+ while (start > 0 && lines[start - 1].trim().startsWith("#")) {
1788
+ start--;
1789
+ }
1790
+ }
1791
+ const result = lines.slice(0, start).concat(lines.slice(end)).join("\n");
1792
+ return result.replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
1793
+ }
1794
+ function insertTomlBlockBeforeVersioning(text4, block) {
1795
+ const versioningIndex = text4.indexOf("# >>> mise-versioning >>>");
1722
1796
  if (versioningIndex >= 0) {
1723
- return `${withPath.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${LINK_AGENTFILES_BLOCK}
1797
+ return `${text4.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${block}
1724
1798
 
1725
- ${withPath.slice(versioningIndex)}`;
1799
+ ${text4.slice(versioningIndex)}`;
1726
1800
  }
1727
- return `${withPath.replace(/\s*$/, "")}
1801
+ return `${text4.replace(/\s*$/, "")}
1728
1802
 
1729
- ${LINK_AGENTFILES_BLOCK}
1803
+ ${block}
1730
1804
  `;
1731
1805
  }
1806
+ function extractTomlStrings(text4) {
1807
+ const values = [];
1808
+ const stringPattern = /"((?:\\.|[^"\\])*)"|'([^']*)'/g;
1809
+ for (const match of text4.matchAll(stringPattern)) {
1810
+ if (match[1] !== void 0) {
1811
+ try {
1812
+ values.push(JSON.parse(`"${match[1]}"`));
1813
+ } catch {
1814
+ values.push(match[1]);
1815
+ }
1816
+ } else if (match[2] !== void 0) {
1817
+ values.push(match[2]);
1818
+ }
1819
+ }
1820
+ return values;
1821
+ }
1822
+ function isManagedHookEntry(value) {
1823
+ const trimmed = value.trim();
1824
+ return trimmed === "op inject -i .env.op > .env" || /(^|\/)link-agentfiles\.sh$/.test(trimmed);
1825
+ }
1826
+ function renderHookEntries(entries, indent = "") {
1827
+ return [
1828
+ `${indent}enter = [`,
1829
+ ...entries.map((entry) => `${indent} ${JSON.stringify(entry)},`),
1830
+ `${indent}]`
1831
+ ];
1832
+ }
1833
+ function upsertLinkAgentfilesHooks(text4) {
1834
+ const lines = text4.split("\n");
1835
+ const hooksStart = lines.findIndex((line) => /^\[hooks\]$/.test(line.trim()));
1836
+ if (hooksStart === -1) return insertTomlBlockBeforeVersioning(text4, LINK_AGENTFILES_HOOKS_BLOCK);
1837
+ let hooksEnd = lines.length;
1838
+ for (let i = hooksStart + 1; i < lines.length; i++) {
1839
+ if (/^\[[^\]]+\]/.test(lines[i].trim())) {
1840
+ hooksEnd = i;
1841
+ break;
1842
+ }
1843
+ }
1844
+ let enterStart = -1;
1845
+ let enterEnd = -1;
1846
+ for (let i = hooksStart + 1; i < hooksEnd; i++) {
1847
+ if (!/^\s*enter\s*=/.test(lines[i])) continue;
1848
+ enterStart = i;
1849
+ enterEnd = i + 1;
1850
+ const afterEquals = lines[i].slice(lines[i].indexOf("=") + 1);
1851
+ if (afterEquals.includes("[") && !afterEquals.includes("]")) {
1852
+ while (enterEnd < hooksEnd && !lines[enterEnd].includes("]")) enterEnd++;
1853
+ if (enterEnd < hooksEnd) enterEnd++;
1854
+ }
1855
+ break;
1856
+ }
1857
+ const existingBlock = enterStart >= 0 ? lines.slice(enterStart, enterEnd).join("\n") : "";
1858
+ const preserved = extractTomlStrings(existingBlock).filter((entry) => !isManagedHookEntry(entry));
1859
+ const merged = [...LINK_AGENTFILES_HOOK_ENTRIES];
1860
+ for (const entry of preserved) {
1861
+ if (!merged.includes(entry)) merged.push(entry);
1862
+ }
1863
+ const indent = enterStart >= 0 ? lines[enterStart].match(/^\s*/)?.[0] ?? "" : "";
1864
+ const rendered = renderHookEntries(merged, indent);
1865
+ if (enterStart >= 0) {
1866
+ return lines.slice(0, enterStart).concat(rendered, lines.slice(enterEnd)).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
1867
+ }
1868
+ return lines.slice(0, hooksStart + 1).concat(rendered, lines.slice(hooksStart + 1)).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
1869
+ }
1870
+ function upsertLinkAgentfilesBlock(text4, ctx) {
1871
+ const withPath = upsertMisePath(text4, requiredMisePathEntries(ctx));
1872
+ if (withPath.includes(LINK_AGENTFILES_BLOCK)) return withPath;
1873
+ let cleaned = removeTomlSection(withPath, /^\[tasks\.link-agentfiles\]$/, /link-agentfiles/, { includePrecedingComments: false });
1874
+ cleaned = removeTomlSection(cleaned, /^\[\[watch_files\]\]$/, /AGENTS\.md/, { includePrecedingComments: false });
1875
+ cleaned = upsertLinkAgentfilesHooks(cleaned);
1876
+ return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
1877
+ }
1732
1878
  function readProjectJson(ctx) {
1733
1879
  return tryParseJson(safeReadText(join8(ctx.repoRoot, ".project.json")));
1734
1880
  }
@@ -1742,23 +1888,35 @@ function canonicalProjectJson(ctx) {
1742
1888
  workspace: String((existing.ticket_provider?.workspace ?? firstRole?.planeWorkspace ?? "") || ""),
1743
1889
  identifier: String((existing.ticket_provider?.identifier ?? firstRole?.ticketProviderIdentifier ?? "") || ""),
1744
1890
  board_id: String((existing.ticket_provider?.board_id ?? firstRole?.ticketProviderBoardId ?? "") || ""),
1745
- board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || "")
1891
+ board_url: String((existing.ticket_provider?.board_url ?? firstRole?.ticketProviderBoardUrl ?? "") || ""),
1892
+ state: String((existing.ticket_provider?.state ?? "planned") || "planned")
1746
1893
  };
1894
+ const existingAgents = existing.agents ?? {};
1895
+ const discoveredAgents = Object.fromEntries(
1896
+ roles.map((role) => [
1897
+ role.agentId || `${slug}-${role.role}`,
1898
+ {
1899
+ role: role.role,
1900
+ role_dir: relative(ctx.repoRoot, role.roleDir)
1901
+ }
1902
+ ])
1903
+ );
1904
+ const agents = { ...existingAgents };
1905
+ for (const [agentId, discovered] of Object.entries(discoveredAgents)) {
1906
+ const existingAgent = existingAgents[agentId] ?? {};
1907
+ agents[agentId] = {
1908
+ role: discovered.role,
1909
+ role_dir: discovered.role_dir,
1910
+ provisioning_state: existingAgent.provisioning_state
1911
+ };
1912
+ }
1747
1913
  return {
1748
1914
  project_name: String(existing.project_name ?? titleCaseSlug(slug)),
1749
1915
  project_description: String(existing.project_description ?? ""),
1750
1916
  project_slug: slug,
1751
1917
  repo_path: ctx.repoRoot,
1752
1918
  ticket_provider: ticketProvider,
1753
- agents: Object.fromEntries(
1754
- roles.map((role) => [
1755
- role.agentId || `${slug}-${role.role}`,
1756
- {
1757
- role: role.role,
1758
- role_dir: relative(ctx.repoRoot, role.roleDir)
1759
- }
1760
- ])
1761
- )
1919
+ agents
1762
1920
  };
1763
1921
  }
1764
1922
  function projectJsonFinding(ctx) {
@@ -1790,7 +1948,7 @@ function projectJsonFinding(ctx) {
1790
1948
  }
1791
1949
  }
1792
1950
  const ticketProvider = data.ticket_provider ?? {};
1793
- for (const key of ["type", "workspace", "identifier", "board_id", "board_url"]) {
1951
+ for (const key of ["type", "workspace", "identifier", "board_id", "board_url", "state"]) {
1794
1952
  if (!(key in ticketProvider)) details.push(`ticket_provider.${key} missing`);
1795
1953
  }
1796
1954
  if (existsSync7(planeJsonPath)) details.push(".plane.json should not exist once .project.json is canonical");
@@ -1937,9 +2095,9 @@ ${block}`) : `${current.replace(/\s*$/, "\n")}${block}`;
1937
2095
  return path;
1938
2096
  }
1939
2097
  function profileMetaInheritsDefault(path) {
1940
- const text3 = safeReadText(path);
2098
+ const text4 = safeReadText(path);
1941
2099
  return Boolean(
1942
- text3 && /^config:\s*$/m.test(text3) && /^\s+inherit_from:\s*default\s*$/m.test(text3) && /^\s+save_mode:\s*delta\s*$/m.test(text3)
2100
+ text4 && /^config:\s*$/m.test(text4) && /^\s+inherit_from:\s*default\s*$/m.test(text4) && /^\s+save_mode:\s*delta\s*$/m.test(text4)
1943
2101
  );
1944
2102
  }
1945
2103
  function upsertInheritedProfileMeta(path, changedFiles, dryRun) {
@@ -1997,17 +2155,17 @@ var RULES = [
1997
2155
  if (!existsSync7(misePath)) {
1998
2156
  return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
1999
2157
  }
2000
- const text3 = readText(misePath);
2158
+ const text4 = readText(misePath);
2001
2159
  const details = [];
2002
2160
  const linkAgentfilesPath = join8(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2003
2161
  if (!existsSync7(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
2004
- const pathValues = [...(text3.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2162
+ const pathValues = [...(text4.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
2005
2163
  const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
2006
2164
  if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
2007
- if (!text3.includes('"{{config_root}}/.mise/scripts/link-agentfiles.sh"')) details.push("link-agentfiles must use raw {{config_root}} guard");
2008
- if (!text3.includes("op inject -i .env.op > .env")) details.push("[hooks].enter must materialize .env from .env.op");
2009
- if (!text3.includes('patterns = ["AGENTS.md"]')) details.push("watch_files must monitor AGENTS.md");
2010
- if (!text3.includes('task = "link-agentfiles"')) details.push("watch_files must dispatch link-agentfiles task");
2165
+ if (!text4.includes('"{{config_root}}/.mise/scripts/link-agentfiles.sh"')) details.push("link-agentfiles must use raw {{config_root}} guard");
2166
+ if (!text4.includes("op inject -i .env.op > .env")) details.push("[hooks].enter must materialize .env from .env.op");
2167
+ if (!text4.includes('patterns = ["AGENTS.md"]')) details.push("watch_files must monitor AGENTS.md");
2168
+ if (!text4.includes('task = "link-agentfiles"')) details.push("watch_files must dispatch link-agentfiles task");
2011
2169
  return {
2012
2170
  id: "mise.config-root",
2013
2171
  title: "mise config_root + AGENTS link hooks",
@@ -2020,15 +2178,22 @@ var RULES = [
2020
2178
  migrate: (ctx, finding) => {
2021
2179
  const path = join8(ctx.repoRoot, "mise.toml");
2022
2180
  const changedFiles = [];
2181
+ const details = [];
2023
2182
  if (!existsSync7(path)) {
2024
- return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing; initialize mise first", changedFiles, details: [] };
2183
+ if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2184
+ 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: [] };
2185
+ }
2186
+ details.push("Initialized mise.toml from generated-project template");
2187
+ if (ctx.dryRun) {
2188
+ return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
2189
+ }
2025
2190
  }
2026
- let text3 = readText(path);
2027
- const next = upsertLinkAgentfilesBlock(text3, ctx);
2028
- if (next !== text3) {
2029
- changedFiles.push(path);
2191
+ let text4 = readText(path);
2192
+ const next = upsertLinkAgentfilesBlock(text4, ctx);
2193
+ if (next !== text4) {
2194
+ if (!changedFiles.includes(path)) changedFiles.push(path);
2030
2195
  if (!ctx.dryRun) writeText(path, next);
2031
- text3 = next;
2196
+ text4 = next;
2032
2197
  }
2033
2198
  const linkAgentfilesPath = join8(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
2034
2199
  const expectedScript = templateLinkAgentfilesScript(ctx);
@@ -2060,8 +2225,8 @@ var RULES = [
2060
2225
  const misePath = join8(ctx.repoRoot, "mise.toml");
2061
2226
  const versioningPath = join8(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
2062
2227
  const manifestPath = join8(ctx.repoRoot, ".mise", "version-files.conf");
2063
- const text3 = safeReadText(misePath);
2064
- if (!text3?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
2228
+ const text4 = safeReadText(misePath);
2229
+ if (!text4?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
2065
2230
  if (!existsSync7(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
2066
2231
  if (!existsSync7(manifestPath)) details.push(".mise/version-files.conf missing");
2067
2232
  return {
@@ -2075,14 +2240,21 @@ var RULES = [
2075
2240
  },
2076
2241
  migrate: (ctx, finding) => {
2077
2242
  const changedFiles = [];
2243
+ const details = [];
2078
2244
  const misePath = join8(ctx.repoRoot, "mise.toml");
2079
2245
  if (!existsSync7(misePath)) {
2080
- return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing; cannot inject versioning block", changedFiles, details: [] };
2246
+ if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
2247
+ 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: [] };
2248
+ }
2249
+ details.push("Initialized mise.toml from generated-project template");
2250
+ if (ctx.dryRun) {
2251
+ return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
2252
+ }
2081
2253
  }
2082
2254
  const currentMise = readText(misePath);
2083
2255
  const nextMise = replaceOrAppendManagedBlock(currentMise, /# >>> mise-versioning >>>/, VERSIONING_BLOCK, /^\[tasks\.build\]/m);
2084
2256
  if (nextMise !== currentMise) {
2085
- changedFiles.push(misePath);
2257
+ if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
2086
2258
  if (!ctx.dryRun) writeText(misePath, nextMise);
2087
2259
  }
2088
2260
  const versioningPath = join8(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
@@ -2222,7 +2394,8 @@ var RULES = [
2222
2394
  } else {
2223
2395
  const invalidLines = envOp.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).filter((line) => {
2224
2396
  const value = line.slice(line.indexOf("=") + 1).trim();
2225
- return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value);
2397
+ const quotedLiteral = /^"[^"\r\n]*"$/.test(value) || /^'[^'\r\n]*'$/.test(value);
2398
+ return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value) && !quotedLiteral;
2226
2399
  });
2227
2400
  if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals: ${invalidLines.join(", ")}`);
2228
2401
  }
@@ -2274,19 +2447,19 @@ var RULES = [
2274
2447
  audit: (ctx) => {
2275
2448
  const details = [];
2276
2449
  const path = join8(ctx.repoRoot, ".copier-answers.yml");
2277
- const text3 = safeReadText(path);
2450
+ const text4 = safeReadText(path);
2278
2451
  const project = readProjectJson(ctx);
2279
- if (!text3) {
2452
+ if (!text4) {
2280
2453
  details.push(".copier-answers.yml missing");
2281
2454
  } else {
2282
- if (!text3.startsWith("# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY")) details.push("missing Copier overwrite warning header");
2283
- if (!text3.includes("_src_path:")) details.push("_src_path missing");
2455
+ if (!text4.startsWith("# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY")) details.push("missing Copier overwrite warning header");
2456
+ if (!text4.includes("_src_path:")) details.push("_src_path missing");
2284
2457
  if (project?.project_name) {
2285
- const nameMatch = text3.match(/project_name:\s*(.+)/);
2458
+ const nameMatch = text4.match(/project_name:\s*(.+)/);
2286
2459
  if (!nameMatch || nameMatch[1]?.trim() !== String(project.project_name)) details.push("project_name drift between .copier-answers.yml and .project.json");
2287
2460
  }
2288
2461
  if (project?.project_description) {
2289
- const descMatch = text3.match(/project_description:\s*([\s\S]*?)(?=\n\w|$)/);
2462
+ const descMatch = text4.match(/project_description:\s*([\s\S]*?)(?=\n\w|$)/);
2290
2463
  const yamlDesc = descMatch?.[1]?.replace(/\n\s+/g, " ").trim() ?? "";
2291
2464
  if (yamlDesc !== String(project.project_description)) details.push("project_description drift between .copier-answers.yml and .project.json");
2292
2465
  }
@@ -2303,16 +2476,16 @@ var RULES = [
2303
2476
  migrate: (ctx, finding) => {
2304
2477
  const changedFiles = [];
2305
2478
  const project = canonicalProjectJson(ctx);
2306
- const text3 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
2479
+ const text4 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
2307
2480
  _src_path: ${join8(ctx.pjanglerRoot, "templates", "commonproject")}
2308
2481
  project_description: ${String(project.project_description)}
2309
2482
  project_name: ${String(project.project_name)}
2310
2483
  ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
2311
2484
  `;
2312
2485
  const path = join8(ctx.repoRoot, ".copier-answers.yml");
2313
- if (safeReadText(path) !== text3) {
2486
+ if (safeReadText(path) !== text4) {
2314
2487
  changedFiles.push(path);
2315
- if (!ctx.dryRun) writeText(path, text3);
2488
+ if (!ctx.dryRun) writeText(path, text4);
2316
2489
  }
2317
2490
  return {
2318
2491
  id: finding.id,
@@ -2508,6 +2681,9 @@ function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
2508
2681
  if (mode) chmodSync(path, mode);
2509
2682
  }
2510
2683
  }
2684
+ function getParityRuleIds() {
2685
+ return RULES.map((rule) => rule.id);
2686
+ }
2511
2687
  function runAudit(repoArg) {
2512
2688
  const pjanglerRoot = resolvePjanglerRoot();
2513
2689
  const ctx = {
@@ -2524,7 +2700,7 @@ function runAudit(repoArg) {
2524
2700
  rules
2525
2701
  };
2526
2702
  }
2527
- function runMigration(selector, repoArg, dryRun, all) {
2703
+ function runMigrationForRules(ruleIds, repoArg, dryRun) {
2528
2704
  const pjanglerRoot = resolvePjanglerRoot();
2529
2705
  const ctx = {
2530
2706
  repoRoot: resolve(repoArg ?? process.cwd()),
@@ -2532,9 +2708,9 @@ function runMigration(selector, repoArg, dryRun, all) {
2532
2708
  pjanglerRoot,
2533
2709
  homeDir: homedir4()
2534
2710
  };
2535
- const selected = all ? RULES : RULES.filter((rule) => rule.id === selector);
2711
+ const selected = RULES.filter((rule) => ruleIds.includes(rule.id));
2536
2712
  if (!selected.length) {
2537
- throw new Error(`Unknown parity rule: ${selector}`);
2713
+ throw new Error(`Unknown parity rules: ${ruleIds.join(", ")}`);
2538
2714
  }
2539
2715
  const results = selected.map((rule) => {
2540
2716
  try {
@@ -2560,6 +2736,10 @@ function runMigration(selector, repoArg, dryRun, all) {
2560
2736
  changedFiles
2561
2737
  };
2562
2738
  }
2739
+ function runMigration(selector, repoArg, dryRun, all) {
2740
+ const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
2741
+ return runMigrationForRules(ruleIds, repoArg, dryRun);
2742
+ }
2563
2743
  function formatAuditReport(report) {
2564
2744
  const lines = [`repo: ${report.repo}`, `ok: ${report.ok}`, `audited_at: ${report.auditedAt}`, "rules:"];
2565
2745
  for (const rule of report.rules) {
@@ -2584,19 +2764,421 @@ function formatMigrationReport(report) {
2584
2764
  `;
2585
2765
  }
2586
2766
 
2767
+ // src/project/index.ts
2768
+ import { spawnSync as spawnSync5 } from "node:child_process";
2769
+ import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync4, renameSync as renameSync2, statSync, writeFileSync as writeFileSync5 } from "node:fs";
2770
+ import { homedir as homedir5 } from "node:os";
2771
+ import { basename as basename3, dirname as dirname6, join as join9, resolve as resolve2 } from "node:path";
2772
+ import YAML from "yaml";
2773
+ var PROJECT_REGISTRY_ENV = "PJ_PROJECT_REGISTRY";
2774
+ var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
2775
+ var KNOWN_SKILL_ROOTS = [
2776
+ "/home/delorenj/code/skillex/all-skills",
2777
+ "/home/delorenj/code/CoachingAgentFramework/.agents/skills",
2778
+ "/home/delorenj/code/pjangler/.agents/skills",
2779
+ join9(homedir5(), ".codex", "skills")
2780
+ ];
2781
+ function projectRegistryPath(env = process.env) {
2782
+ return expandHome(env[PROJECT_REGISTRY_ENV] || join9(homedir5(), ".config", "pjangler", "projects.yaml"));
2783
+ }
2784
+ function emptyProjectRegistry() {
2785
+ return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
2786
+ }
2787
+ function loadProjectRegistry(path = projectRegistryPath()) {
2788
+ if (!existsSync8(path)) return emptyProjectRegistry();
2789
+ const raw = YAML.parse(readFileSync4(path, "utf8"));
2790
+ if (raw == null) return emptyProjectRegistry();
2791
+ if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
2792
+ const registry = raw;
2793
+ const normalized = {
2794
+ schema_version: Number(registry.schema_version ?? PROJECT_REGISTRY_SCHEMA_VERSION),
2795
+ projects: isRecord(registry.projects) ? registry.projects : {}
2796
+ };
2797
+ validateProjectRegistry(normalized);
2798
+ return normalized;
2799
+ }
2800
+ function saveProjectRegistry(registry, path = projectRegistryPath()) {
2801
+ validateProjectRegistry(registry);
2802
+ mkdirSync6(dirname6(path), { recursive: true });
2803
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
2804
+ writeFileSync5(temp, YAML.stringify(registry, { lineWidth: 0 }), "utf8");
2805
+ renameSync2(temp, path);
2806
+ }
2807
+ function validateProjectRegistry(registry) {
2808
+ if (registry.schema_version !== PROJECT_REGISTRY_SCHEMA_VERSION) {
2809
+ throw new Error(`Unsupported project registry schema_version: ${registry.schema_version}`);
2810
+ }
2811
+ if (!isRecord(registry.projects)) throw new Error("Project registry projects must be a mapping");
2812
+ const slugs = /* @__PURE__ */ new Set();
2813
+ const repoPaths = /* @__PURE__ */ new Map();
2814
+ const identifiers = /* @__PURE__ */ new Map();
2815
+ for (const [slug, project] of Object.entries(registry.projects)) {
2816
+ validateProjectRecord(project, slug);
2817
+ if (slugs.has(project.slug)) throw new Error(`Duplicate project slug: ${project.slug}`);
2818
+ slugs.add(project.slug);
2819
+ const repoKey = resolve2(project.repo_path);
2820
+ const existingRepoSlug = repoPaths.get(repoKey);
2821
+ if (existingRepoSlug && existingRepoSlug !== slug) {
2822
+ throw new Error(`Duplicate project repo_path: ${project.repo_path} used by ${existingRepoSlug} and ${slug}`);
2823
+ }
2824
+ repoPaths.set(repoKey, slug);
2825
+ const identifier = project.ticket_provider.identifier?.toUpperCase();
2826
+ if (identifier) {
2827
+ const existingIdentifierSlug = identifiers.get(identifier);
2828
+ if (existingIdentifierSlug && existingIdentifierSlug !== slug) {
2829
+ throw new Error(`Duplicate project identifier: ${identifier} used by ${existingIdentifierSlug} and ${slug}`);
2830
+ }
2831
+ identifiers.set(identifier, slug);
2832
+ }
2833
+ }
2834
+ }
2835
+ function slugifyProjectName(value) {
2836
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
2837
+ }
2838
+ function deriveProjectIdentifier(value) {
2839
+ const compact = value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
2840
+ const identifier = compact.slice(0, 4) || "PROJ";
2841
+ return identifier.length >= 2 ? identifier : `${identifier}XX`.slice(0, 4);
2842
+ }
2843
+ function normalizeAgentRole(value) {
2844
+ return value?.trim() || "pm";
2845
+ }
2846
+ function jsonStable(value) {
2847
+ return JSON.stringify(value);
2848
+ }
2849
+ function projectRecordEquivalent(a, b) {
2850
+ if (!a) return false;
2851
+ const { created_at: _aCreated, updated_at: _aUpdated, ...aComparable } = a;
2852
+ const { created_at: _bCreated, updated_at: _bUpdated, ...bComparable } = b;
2853
+ return jsonStable(aComparable) === jsonStable(bComparable);
2854
+ }
2855
+ function defaultProjectTargetDir(name, cwd = process.cwd()) {
2856
+ const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
2857
+ return resolve2(dirname6(resolve2(cwd)), compactName);
2858
+ }
2859
+ function resolveSourceSkillPath(sourceSkill) {
2860
+ if (!sourceSkill) return void 0;
2861
+ const expanded = expandHome(sourceSkill);
2862
+ const direct = resolve2(expanded);
2863
+ if (existsSync8(direct)) return direct;
2864
+ const name = basename3(sourceSkill);
2865
+ for (const root of KNOWN_SKILL_ROOTS) {
2866
+ const candidate = join9(root, name);
2867
+ if (existsSync8(candidate)) return candidate;
2868
+ }
2869
+ const civilWarLetterifier = "/home/delorenj/code/skillex/all-skills/civilwar-letterifier";
2870
+ const hint = existsSync8(civilWarLetterifier) ? ` Did you mean ${civilWarLetterifier}?` : "";
2871
+ throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
2872
+ }
2873
+ function planProjectInit(input) {
2874
+ if (!input.name.trim()) throw new Error("Project name is required");
2875
+ const registryPath2 = resolve2(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
2876
+ const registry = loadProjectRegistry(registryPath2);
2877
+ const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
2878
+ const slug = input.projectSlug ?? slugifyProjectName(input.name);
2879
+ const targetDir = resolve2(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
2880
+ const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
2881
+ const existing = registry.projects[slug];
2882
+ const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
2883
+ const overwrite = input.overwrite ?? input.force ?? false;
2884
+ const agentRole = normalizeAgentRole(input.agentRole);
2885
+ const agents = input.provisionAgent ? {
2886
+ ...existing?.agents ?? {},
2887
+ [agentRole]: {
2888
+ role: agentRole,
2889
+ provisioning_state: "planned"
2890
+ }
2891
+ } : existing?.agents ?? {};
2892
+ const scaffold = input.scaffold ?? true;
2893
+ const candidateProject = {
2894
+ name: input.name,
2895
+ slug,
2896
+ repo_path: targetDir,
2897
+ description: input.description ?? "",
2898
+ status: "planned",
2899
+ source_artifacts: sourceSkillPath ? [{ kind: "skill", path: sourceSkillPath, package_name: input.packageName ?? slug }] : [],
2900
+ template: {
2901
+ commonproject: {
2902
+ enabled: true,
2903
+ primary_language: input.primaryLanguage ?? "python"
2904
+ }
2905
+ },
2906
+ ticket_provider: {
2907
+ type: input.ticketProvider ?? "plane",
2908
+ workspace: input.planeWorkspace ?? "33god",
2909
+ identifier,
2910
+ board_id: input.planeProjectId ?? "",
2911
+ board_url: input.planeProjectId ? `https://plane.delo.sh/${input.planeWorkspace ?? "33god"}/projects/${input.planeProjectId}/issues/` : "",
2912
+ state: input.live ? "planned" : "planned"
2913
+ },
2914
+ agents,
2915
+ created_at: existing?.created_at ?? now,
2916
+ updated_at: now
2917
+ };
2918
+ const project = {
2919
+ ...candidateProject,
2920
+ updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
2921
+ };
2922
+ validateNoDuplicateProject(registry, project, overwrite);
2923
+ const pjanglerRoot = resolve2(input.pjanglerRoot ?? resolvePjanglerRoot2());
2924
+ const manifest = projectManifestFromRegistryProject(project);
2925
+ const apply = input.apply ?? false;
2926
+ const live = input.live ?? false;
2927
+ const actions = [
2928
+ { kind: "registry.upsert", registryPath: registryPath2, slug, project }
2929
+ ];
2930
+ if (scaffold) {
2931
+ actions.push(buildCommonProjectCopierAction({
2932
+ pjanglerRoot,
2933
+ targetDir,
2934
+ projectName: project.name,
2935
+ projectDescription: project.description,
2936
+ projectSlug: project.slug,
2937
+ ticketProvider: project.ticket_provider.type,
2938
+ planeWorkspace: project.ticket_provider.workspace ?? "33god",
2939
+ planeProjectId: project.ticket_provider.board_id ?? "",
2940
+ projectIdentifier: identifier,
2941
+ primaryLanguage: project.template.commonproject.primary_language,
2942
+ overwrite
2943
+ }));
2944
+ }
2945
+ actions.push(
2946
+ { kind: "project.write-manifest", path: join9(targetDir, ".project.json"), manifest },
2947
+ {
2948
+ kind: "plane.create-or-link",
2949
+ enabled: live,
2950
+ live,
2951
+ workspace: project.ticket_provider.workspace ?? "33god",
2952
+ identifier,
2953
+ state: live ? "planned" : "planned",
2954
+ reason: live ? void 0 : "network/cloud actions require --live"
2955
+ },
2956
+ {
2957
+ kind: "hermes.provision-agent",
2958
+ enabled: input.provisionAgent ?? false,
2959
+ local: !live,
2960
+ targetDir,
2961
+ targetRepo: slug,
2962
+ role: agentRole,
2963
+ context: {
2964
+ skipRuntimeRepo: !live,
2965
+ skipPlane: !live,
2966
+ skipBloodbank: !live,
2967
+ skipSystemd: !live || process.platform === "darwin"
2968
+ }
2969
+ }
2970
+ );
2971
+ return { ok: true, apply, dryRun: !apply, live, registryPath: registryPath2, project, manifest, actions };
2972
+ }
2973
+ function executeProjectInitPlan(plan) {
2974
+ const logs = [];
2975
+ const errors = [];
2976
+ const changedFiles = [];
2977
+ if (!plan.apply) return { ok: true, plan, logs, errors, changedFiles };
2978
+ const registry = loadProjectRegistry(plan.registryPath);
2979
+ let pendingRegistryAction;
2980
+ for (const action of plan.actions) {
2981
+ if (action.kind === "copier.copy.commonproject") {
2982
+ mkdirSync6(dirname6(action.targetDir), { recursive: true });
2983
+ const result = spawnSync5(action.command[0], action.command.slice(1), { encoding: "utf8", cwd: action.cwd });
2984
+ if (result.stdout?.trim()) logs.push(result.stdout.trim());
2985
+ if (result.stderr?.trim()) logs.push(result.stderr.trim());
2986
+ if (result.error) {
2987
+ const code = result.error.code;
2988
+ errors.push(
2989
+ code === "ENOENT" ? "copier not found on PATH. Install with: uv tool install copier or pip install copier" : `copier failed: ${result.error.message}`
2990
+ );
2991
+ break;
2992
+ }
2993
+ if (result.status !== 0) {
2994
+ errors.push(`copier exited with status ${result.status ?? "unknown"}`);
2995
+ if (existsSync8(action.targetDir)) changedFiles.push(action.targetDir);
2996
+ break;
2997
+ }
2998
+ changedFiles.push(action.targetDir);
2999
+ } else if (action.kind === "project.write-manifest") {
3000
+ mkdirSync6(dirname6(action.path), { recursive: true });
3001
+ const next = `${JSON.stringify(action.manifest, null, 2)}
3002
+ `;
3003
+ const current = existsSync8(action.path) ? readFileSync4(action.path, "utf8") : void 0;
3004
+ if (current !== next) {
3005
+ writeFileSync5(action.path, next, "utf8");
3006
+ changedFiles.push(action.path);
3007
+ }
3008
+ } else if (action.kind === "registry.upsert") {
3009
+ pendingRegistryAction = action;
3010
+ } else if (action.kind === "plane.create-or-link") {
3011
+ logs.push(action.enabled ? "plane.create-or-link requires a live provider integration" : "plane.create-or-link skipped (requires --live)");
3012
+ } else if (action.kind === "hermes.provision-agent") {
3013
+ logs.push(action.enabled ? "hermes.provision-agent planned for the caller to execute" : "hermes.provision-agent skipped");
3014
+ }
3015
+ }
3016
+ if (pendingRegistryAction && errors.length === 0) {
3017
+ if (!projectRecordEquivalent(registry.projects[pendingRegistryAction.slug], pendingRegistryAction.project)) {
3018
+ registry.projects[pendingRegistryAction.slug] = pendingRegistryAction.project;
3019
+ saveProjectRegistry(registry, pendingRegistryAction.registryPath);
3020
+ changedFiles.push(pendingRegistryAction.registryPath);
3021
+ }
3022
+ }
3023
+ return { ok: errors.length === 0, plan, logs, errors, changedFiles };
3024
+ }
3025
+ function projectManifestFromRegistryProject(project) {
3026
+ const agents = Object.fromEntries(
3027
+ Object.entries(project.agents).map(([name, agent]) => [
3028
+ `${project.slug}-${name}`,
3029
+ {
3030
+ role: agent.role,
3031
+ role_dir: agent.role_dir,
3032
+ provisioning_state: agent.provisioning_state
3033
+ }
3034
+ ])
3035
+ );
3036
+ return {
3037
+ project_name: project.name,
3038
+ project_description: project.description,
3039
+ project_slug: project.slug,
3040
+ repo_path: project.repo_path,
3041
+ ticket_provider: {
3042
+ type: project.ticket_provider.type,
3043
+ workspace: project.ticket_provider.workspace ?? "",
3044
+ identifier: project.ticket_provider.identifier ?? "",
3045
+ board_id: project.ticket_provider.board_id ?? "",
3046
+ board_url: project.ticket_provider.board_url ?? "",
3047
+ state: project.ticket_provider.state
3048
+ },
3049
+ agents
3050
+ };
3051
+ }
3052
+ function formatProjectInitPlan(plan) {
3053
+ const lines = [
3054
+ `${plan.dryRun ? "[DRY RUN] " : ""}Project init plan: ${plan.project.name} (${plan.project.slug})`,
3055
+ `Registry: ${plan.registryPath}`,
3056
+ `Target: ${plan.project.repo_path}`,
3057
+ "Actions:"
3058
+ ];
3059
+ for (const action of plan.actions) {
3060
+ lines.push(` - ${action.kind}`);
3061
+ if (action.kind === "copier.copy.commonproject") lines.push(` target: ${action.targetDir}`);
3062
+ if (action.kind === "project.write-manifest") lines.push(` path: ${action.path}`);
3063
+ if (action.kind === "plane.create-or-link" && action.reason) lines.push(` note: ${action.reason}`);
3064
+ }
3065
+ return lines.join("\n");
3066
+ }
3067
+ function formatProjectList(registry) {
3068
+ const projects = Object.values(registry.projects).sort((a, b) => a.slug.localeCompare(b.slug));
3069
+ if (!projects.length) return "No projects registered.";
3070
+ return projects.map((project) => `${project.slug.padEnd(18)} ${String(project.ticket_provider.identifier ?? "").padEnd(6)} ${project.status.padEnd(8)} ${project.repo_path}`).join("\n");
3071
+ }
3072
+ function getProject(registry, slug) {
3073
+ const project = registry.projects[slug];
3074
+ if (!project) throw new Error(`Project not found in registry: ${slug}`);
3075
+ return project;
3076
+ }
3077
+ function doctorProjectRegistry(registryPath2 = projectRegistryPath(), slug) {
3078
+ const issues = [];
3079
+ const registry = loadProjectRegistry(registryPath2);
3080
+ const projects = slug ? [[slug, getProject(registry, slug)]] : Object.entries(registry.projects);
3081
+ for (const [projectSlug, project] of projects) {
3082
+ if (!existsSync8(project.repo_path)) {
3083
+ issues.push({ level: "warn", slug: projectSlug, message: `repo_path does not exist: ${project.repo_path}` });
3084
+ } else if (!statSync(project.repo_path).isDirectory()) {
3085
+ issues.push({ level: "error", slug: projectSlug, message: `repo_path is not a directory: ${project.repo_path}` });
3086
+ } else {
3087
+ const manifestPath = join9(project.repo_path, ".project.json");
3088
+ if (!existsSync8(manifestPath)) issues.push({ level: "warn", slug: projectSlug, message: ".project.json is missing" });
3089
+ }
3090
+ for (const artifact of project.source_artifacts) {
3091
+ if (artifact.path && !existsSync8(artifact.path)) {
3092
+ issues.push({ level: "warn", slug: projectSlug, message: `source artifact missing: ${artifact.path}` });
3093
+ }
3094
+ }
3095
+ }
3096
+ return {
3097
+ ok: !issues.some((issue) => issue.level === "error"),
3098
+ registryPath: registryPath2,
3099
+ checkedProjects: projects.map(([projectSlug]) => projectSlug),
3100
+ issues
3101
+ };
3102
+ }
3103
+ function buildCommonProjectCopierAction(input) {
3104
+ const templateDir = join9(input.pjanglerRoot, "templates", "commonproject");
3105
+ const data = {
3106
+ project_name: input.projectName,
3107
+ project_description: input.projectDescription ?? "",
3108
+ project_slug: input.projectSlug,
3109
+ ticket_provider: input.ticketProvider,
3110
+ plane_workspace: input.planeWorkspace,
3111
+ plane_project_id: input.planeProjectId ?? "",
3112
+ project_identifier: input.projectIdentifier,
3113
+ primary_language: input.primaryLanguage
3114
+ };
3115
+ const command = ["copier", "copy", "--trust", templateDir, input.targetDir, "--defaults"];
3116
+ for (const [key, value] of Object.entries(data)) command.push("--data", `${key}=${value}`);
3117
+ if (input.overwrite) command.push("--overwrite");
3118
+ return {
3119
+ kind: "copier.copy.commonproject",
3120
+ cwd: input.pjanglerRoot,
3121
+ command,
3122
+ targetDir: input.targetDir,
3123
+ data,
3124
+ overwrite: input.overwrite
3125
+ };
3126
+ }
3127
+ function resolvePjanglerRoot2() {
3128
+ let dir = dirname6(new URL(import.meta.url).pathname);
3129
+ while (dir !== dirname6(dir)) {
3130
+ if (existsSync8(join9(dir, "package.json")) && existsSync8(join9(dir, "templates", "commonproject", "copier.yml"))) return dir;
3131
+ dir = dirname6(dir);
3132
+ }
3133
+ return resolve2(process.cwd());
3134
+ }
3135
+ function validateNoDuplicateProject(registry, project, overwrite) {
3136
+ const existingSameSlug = registry.projects[project.slug];
3137
+ if (existingSameSlug && !overwrite && resolve2(existingSameSlug.repo_path) !== resolve2(project.repo_path)) {
3138
+ throw new Error(`Project slug already exists in registry: ${project.slug}`);
3139
+ }
3140
+ for (const [slug, existing] of Object.entries(registry.projects)) {
3141
+ if (slug === project.slug) continue;
3142
+ if (resolve2(existing.repo_path) === resolve2(project.repo_path)) {
3143
+ throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
3144
+ }
3145
+ if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
3146
+ throw new Error(`Project identifier already registered by ${slug}: ${project.ticket_provider.identifier}`);
3147
+ }
3148
+ }
3149
+ }
3150
+ function validateProjectRecord(project, key) {
3151
+ if (!isRecord(project)) throw new Error(`Project ${key} must be a mapping`);
3152
+ if (!project.name) throw new Error(`Project ${key} missing name`);
3153
+ if (!project.slug) throw new Error(`Project ${key} missing slug`);
3154
+ if (project.slug !== key) throw new Error(`Project key ${key} does not match slug ${project.slug}`);
3155
+ if (!project.repo_path) throw new Error(`Project ${key} missing repo_path`);
3156
+ if (!Array.isArray(project.source_artifacts)) throw new Error(`Project ${key} source_artifacts must be a list`);
3157
+ if (!isRecord(project.ticket_provider)) throw new Error(`Project ${key} ticket_provider must be a mapping`);
3158
+ if (!isRecord(project.agents)) throw new Error(`Project ${key} agents must be a mapping`);
3159
+ }
3160
+ function expandHome(path) {
3161
+ if (path === "~") return homedir5();
3162
+ if (path.startsWith("~/")) return join9(homedir5(), path.slice(2));
3163
+ return path;
3164
+ }
3165
+ function isRecord(value) {
3166
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3167
+ }
3168
+
2587
3169
  // src/utils/version.ts
2588
- import { readFileSync as readFileSync4 } from "node:fs";
2589
- import { dirname as dirname6, join as join9 } from "node:path";
3170
+ import { readFileSync as readFileSync5 } from "node:fs";
3171
+ import { dirname as dirname7, join as join10 } from "node:path";
2590
3172
  import { fileURLToPath as fileURLToPath4 } from "node:url";
2591
3173
  var PJANGLER_VERSION = (() => {
2592
3174
  try {
2593
- let dir = dirname6(fileURLToPath4(import.meta.url));
3175
+ let dir = dirname7(fileURLToPath4(import.meta.url));
2594
3176
  for (let i = 0; i < 4; i++) {
2595
3177
  try {
2596
- const raw = readFileSync4(join9(dir, "package.json"), "utf8");
3178
+ const raw = readFileSync5(join10(dir, "package.json"), "utf8");
2597
3179
  return JSON.parse(raw).version ?? "0.0.0";
2598
3180
  } catch {
2599
- const parent = dirname6(dir);
3181
+ const parent = dirname7(dir);
2600
3182
  if (parent === dir) break;
2601
3183
  dir = parent;
2602
3184
  }
@@ -2607,6 +3189,190 @@ var PJANGLER_VERSION = (() => {
2607
3189
  })();
2608
3190
 
2609
3191
  // src/index.ts
3192
+ function printMigrationReport(report, asJson) {
3193
+ if (asJson) {
3194
+ console.log(JSON.stringify(report, null, 2));
3195
+ } else {
3196
+ console.log(formatMigrationReport(report));
3197
+ }
3198
+ }
3199
+ async function promptForRuleIds(rules) {
3200
+ const options = rules.filter((rule) => rule.fixable).map((rule) => ({
3201
+ value: rule.id,
3202
+ label: `${rule.id} [${rule.status}] ${rule.title}`,
3203
+ hint: rule.summary
3204
+ }));
3205
+ if (!options.length) {
3206
+ return [];
3207
+ }
3208
+ const initialValues = rules.filter((rule) => rule.fixable && rule.status !== "pass" && rule.status !== "skip").map((rule) => rule.id);
3209
+ const selected = await multiselect({
3210
+ message: "Select parity rules to apply (space to toggle, enter to confirm):",
3211
+ options,
3212
+ initialValues
3213
+ });
3214
+ if (isCancel5(selected)) {
3215
+ return [];
3216
+ }
3217
+ return selected;
3218
+ }
3219
+ function readJson(path) {
3220
+ if (!existsSync9(path)) return void 0;
3221
+ try {
3222
+ const parsed = JSON.parse(readFileSync6(path, "utf8"));
3223
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
3224
+ } catch {
3225
+ return void 0;
3226
+ }
3227
+ }
3228
+ function findGitRoot(cwd) {
3229
+ const result = spawnSync6("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
3230
+ if (result.status !== 0) return void 0;
3231
+ return resolve3(result.stdout.trim());
3232
+ }
3233
+ function packageNameToProjectName(value) {
3234
+ if (!value) return void 0;
3235
+ const name = value.split("/").pop() ?? value;
3236
+ return name.replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()).trim();
3237
+ }
3238
+ function deriveProjectDefaults(targetDir) {
3239
+ const manifest = readJson(join11(targetDir, ".project.json"));
3240
+ const pkg = readJson(join11(targetDir, "package.json"));
3241
+ const name = String(manifest?.project_name ?? "").trim() || packageNameToProjectName(typeof pkg?.name === "string" ? pkg.name : void 0) || packageNameToProjectName(basename4(targetDir)) || "Project";
3242
+ const ticketProvider = manifest?.ticket_provider && typeof manifest.ticket_provider === "object" ? manifest.ticket_provider : {};
3243
+ return {
3244
+ name,
3245
+ description: String(manifest?.project_description ?? pkg?.description ?? ""),
3246
+ slug: typeof manifest?.project_slug === "string" ? manifest.project_slug : void 0,
3247
+ identifier: typeof ticketProvider.identifier === "string" ? ticketProvider.identifier : void 0
3248
+ };
3249
+ }
3250
+ function isInteractiveProjectInit(options) {
3251
+ return !options.json && !options.yes && options.tui !== false && Boolean(process.stdin.isTTY && process.stdout.isTTY);
3252
+ }
3253
+ async function promptTextValue(message, initialValue) {
3254
+ const value = await text3({
3255
+ message,
3256
+ initialValue,
3257
+ validate: (input) => input?.trim() ? void 0 : "Required"
3258
+ });
3259
+ if (isCancel5(value)) {
3260
+ cancel2("project init cancelled");
3261
+ process.exit(1);
3262
+ }
3263
+ return value.trim();
3264
+ }
3265
+ function projectInitActionLabel(kind) {
3266
+ switch (kind) {
3267
+ case "registry.upsert":
3268
+ return "Register/update project registry entry";
3269
+ case "copier.copy.commonproject":
3270
+ return "Render CommonProject scaffold";
3271
+ case "project.write-manifest":
3272
+ return "Write repo-local .project.json projection";
3273
+ case "plane.create-or-link":
3274
+ return "Create/link ticket provider project";
3275
+ case "hermes.provision-agent":
3276
+ return "Provision Hermes agent";
3277
+ default:
3278
+ return kind;
3279
+ }
3280
+ }
3281
+ function registryNeedsUpsert(plan) {
3282
+ const registry = loadProjectRegistry(plan.registryPath);
3283
+ const existing = registry.projects[plan.project.slug];
3284
+ if (!existing) return true;
3285
+ const { created_at: _existingCreated, updated_at: _existingUpdated, ...existingComparable } = existing;
3286
+ const { created_at: _projectCreated, updated_at: _projectUpdated, ...projectComparable } = plan.project;
3287
+ return JSON.stringify(existingComparable) !== JSON.stringify(projectComparable);
3288
+ }
3289
+ function actionNeedsRun(plan, kind, syncMode) {
3290
+ if (kind === "registry.upsert") return registryNeedsUpsert(plan);
3291
+ if (kind === "project.write-manifest") {
3292
+ const action = plan.actions.find((item) => item.kind === "project.write-manifest");
3293
+ if (!action || action.kind !== "project.write-manifest") return false;
3294
+ const next = `${JSON.stringify(action.manifest, null, 2)}
3295
+ `;
3296
+ return !existsSync9(action.path) || readFileSync6(action.path, "utf8") !== next;
3297
+ }
3298
+ if (kind === "copier.copy.commonproject") return true;
3299
+ if (kind === "plane.create-or-link") return plan.actions.some((action) => action.kind === kind && action.enabled);
3300
+ if (kind === "hermes.provision-agent") return plan.actions.some((action) => action.kind === kind && action.enabled);
3301
+ return true;
3302
+ }
3303
+ async function selectProjectInitOperations(input) {
3304
+ const planOperations = input.plan.actions.filter((action) => actionNeedsRun(input.plan, action.kind, input.syncMode)).map((action) => ({
3305
+ value: action.kind,
3306
+ label: projectInitActionLabel(action.kind),
3307
+ hint: action.kind === "registry.upsert" ? input.plan.registryPath : action.kind
3308
+ }));
3309
+ const parityOperations = input.auditRules.filter((rule) => rule.fixable && rule.status !== "pass" && rule.status !== "skip").map((rule) => ({
3310
+ value: `parity:${rule.id}`,
3311
+ label: `${rule.title}`,
3312
+ hint: `${rule.id}: ${rule.summary}`
3313
+ }));
3314
+ const operations = [...planOperations, ...parityOperations];
3315
+ const all = operations.map((operation) => operation.value);
3316
+ if (input.options.yes || input.options.apply && !isInteractiveProjectInit(input.options)) {
3317
+ return {
3318
+ selectedOperations: all,
3319
+ selectedParityRules: parityOperations.map((operation) => operation.value.replace(/^parity:/, ""))
3320
+ };
3321
+ }
3322
+ if (input.options.dryRun || !isInteractiveProjectInit(input.options)) {
3323
+ return { selectedOperations: [], selectedParityRules: [] };
3324
+ }
3325
+ if (!operations.length) return { selectedOperations: [], selectedParityRules: [] };
3326
+ const selected = await multiselect({
3327
+ message: "Select project init operations to run:",
3328
+ options: operations,
3329
+ initialValues: all
3330
+ });
3331
+ if (isCancel5(selected)) {
3332
+ cancel2("project init cancelled");
3333
+ process.exit(1);
3334
+ }
3335
+ return {
3336
+ selectedOperations: selected,
3337
+ selectedParityRules: selected.filter((value) => value.startsWith("parity:")).map((value) => value.replace(/^parity:/, ""))
3338
+ };
3339
+ }
3340
+ async function resolveProjectInitTarget(name, options) {
3341
+ const interactive = isInteractiveProjectInit(options);
3342
+ const cwd = process.cwd();
3343
+ const cwdGitRoot = findGitRoot(cwd);
3344
+ let targetDir = options.targetDir ? resolve3(options.targetDir) : void 0;
3345
+ if (!targetDir && cwdGitRoot) {
3346
+ targetDir = cwdGitRoot;
3347
+ }
3348
+ if (!targetDir && interactive) {
3349
+ const defaultName = name ?? basename4(cwd);
3350
+ const promptedName = name ?? await promptTextValue("Project name", packageNameToProjectName(defaultName));
3351
+ const defaultDir = join11(cwd, promptedName.replace(/[^A-Za-z0-9._-]/g, "") || promptedName.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
3352
+ targetDir = await promptTextValue("Project directory", defaultDir);
3353
+ name = promptedName;
3354
+ }
3355
+ if (!targetDir) {
3356
+ if (!name) throw new Error("Project name or --target-dir is required when project init is not run inside a git repo");
3357
+ targetDir = resolve3(process.cwd(), name.replace(/[^A-Za-z0-9._-]/g, "") || name.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
3358
+ }
3359
+ const targetExists = existsSync9(targetDir);
3360
+ if (targetExists && !statSync2(targetDir).isDirectory()) throw new Error(`Target path is not a directory: ${targetDir}`);
3361
+ const targetGitRoot = targetExists ? findGitRoot(targetDir) : void 0;
3362
+ const syncMode = Boolean(targetGitRoot && resolve3(targetGitRoot) === resolve3(targetDir));
3363
+ const defaults = targetExists ? deriveProjectDefaults(targetDir) : { name: packageNameToProjectName(basename4(targetDir)) ?? "Project", description: "" };
3364
+ if (!name && interactive && !syncMode) {
3365
+ name = await promptTextValue("Project name", defaults.name);
3366
+ }
3367
+ return {
3368
+ name: name ?? defaults.name,
3369
+ targetDir,
3370
+ description: options.description ?? defaults.description,
3371
+ syncMode,
3372
+ slug: options.slug ?? defaults.slug,
3373
+ identifier: options.identifier ?? defaults.identifier
3374
+ };
3375
+ }
2610
3376
  var program = new Command3();
2611
3377
  program.name("pjangler").description("Project subsystem bootstrapper CLI").version(PJANGLER_VERSION);
2612
3378
  program.command("init").argument("<subsystem>", "Subsystem to initialize").description("Initialize a project subsystem").option("--dry-run", "Preview changes without writing files").option("-f, --force", "Overwrite existing files").action(async (subsystem, options) => {
@@ -2640,6 +3406,144 @@ program.command("list").description("List available subsystems").action(() => {
2640
3406
  console.log(" pjangler init docker");
2641
3407
  console.log(" pjangler init node");
2642
3408
  });
3409
+ var projectCmd = program.command("project").description("Manage the pjangler project registry");
3410
+ projectCmd.command("init").argument("[name]", "Project display name").description("Plan or apply a registry-backed CommonProject initialization or legacy repo sync").option("--description <text>", "Project description").option("--target-dir <path>", "Target repo path").option("--source-skill <path>", "Source skill/template provenance path").option("--primary-language <language>", "Primary language for CommonProject rendering", "python").option("--provision-agent", "Plan local Hermes PM agent provisioning").option("--agent-role <role>", "Hermes agent role to plan when --provision-agent is set", "pm").option("--apply", "Write the registry and render the repo scaffold").option("--dry-run", "Preview changes without writing files (default)").option("--live", "Allow live/network/cloud provisioning actions").option("--slug <slug>", "Project registry slug override").option("--identifier <identifier>", "Ticket identifier override").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("-f, --force", "Allow replacing an existing registry entry and re-rendering files").option("-y, --yes", "Apply every proposed operation without prompting").option("--no-tui", "Disable interactive prompts").option("--json", "Output machine-parseable JSON").action(async (name, options) => {
3411
+ try {
3412
+ const target = await resolveProjectInitTarget(name, options);
3413
+ const interactive = isInteractiveProjectInit(options);
3414
+ const apply = Boolean(!options.dryRun && (options.yes || options.apply || interactive));
3415
+ const plan = planProjectInit({
3416
+ name: target.name,
3417
+ description: target.description,
3418
+ targetDir: target.targetDir,
3419
+ sourceSkill: options.sourceSkill,
3420
+ primaryLanguage: options.primaryLanguage,
3421
+ provisionAgent: options.provisionAgent ?? false,
3422
+ agentRole: options.agentRole,
3423
+ apply,
3424
+ live: options.live ?? false,
3425
+ projectSlug: target.slug,
3426
+ projectIdentifier: target.identifier,
3427
+ registryPath: options.registry,
3428
+ force: options.force ?? false,
3429
+ overwrite: options.force ?? false,
3430
+ cwd: process.cwd(),
3431
+ scaffold: !target.syncMode
3432
+ });
3433
+ const audit = target.syncMode ? runAudit(target.targetDir) : void 0;
3434
+ const selection = await selectProjectInitOperations({
3435
+ plan,
3436
+ auditRules: audit?.rules ?? [],
3437
+ syncMode: target.syncMode,
3438
+ options
3439
+ });
3440
+ const selectedPlanActionKinds = new Set(selection.selectedOperations.filter((value) => !value.startsWith("parity:")));
3441
+ const selectedPlan = {
3442
+ ...plan,
3443
+ apply,
3444
+ dryRun: !apply,
3445
+ actions: apply ? plan.actions.filter((action) => selectedPlanActionKinds.has(action.kind)) : plan.actions
3446
+ };
3447
+ if (!apply) {
3448
+ const payload = {
3449
+ ...plan,
3450
+ mode: target.syncMode ? "sync" : "create",
3451
+ audit,
3452
+ proposedOperations: [
3453
+ ...plan.actions.filter((action) => actionNeedsRun(plan, action.kind, target.syncMode)).map((action) => action.kind),
3454
+ ...(audit?.rules ?? []).filter((rule) => rule.fixable && rule.status !== "pass" && rule.status !== "skip").map((rule) => `parity:${rule.id}`)
3455
+ ]
3456
+ };
3457
+ if (options.json) console.log(JSON.stringify(payload, null, 2));
3458
+ else {
3459
+ console.log(formatProjectInitPlan(plan));
3460
+ if (payload.proposedOperations.length) {
3461
+ console.log("Proposed sync operations:");
3462
+ for (const operation of payload.proposedOperations) console.log(` - ${operation}`);
3463
+ } else {
3464
+ console.log("Project is already in parity.");
3465
+ }
3466
+ }
3467
+ return;
3468
+ }
3469
+ const initResult = selectedPlan.actions.length ? executeProjectInitPlan(selectedPlan) : { ok: true, plan: selectedPlan, logs: [], errors: [], changedFiles: [] };
3470
+ const migrationReport = selection.selectedParityRules.length ? runMigrationForRules(selection.selectedParityRules, target.targetDir, false) : void 0;
3471
+ const migrationErrors = migrationReport?.results.filter((result2) => result2.status === "blocked").map((result2) => `${result2.id}: ${result2.summary}`) ?? [];
3472
+ const changedFiles = Array.from(/* @__PURE__ */ new Set([
3473
+ ...initResult.changedFiles,
3474
+ ...migrationReport?.changedFiles ?? []
3475
+ ])).sort();
3476
+ const result = {
3477
+ ok: initResult.ok && (migrationReport?.ok ?? true),
3478
+ mode: target.syncMode ? "sync" : "create",
3479
+ plan: selectedPlan,
3480
+ audit,
3481
+ selectedOperations: selection.selectedOperations,
3482
+ selectedParityRules: selection.selectedParityRules,
3483
+ logs: initResult.logs,
3484
+ errors: [...initResult.errors, ...migrationErrors],
3485
+ changedFiles,
3486
+ migrationReport
3487
+ };
3488
+ if (options.json) {
3489
+ console.log(JSON.stringify(result, null, 2));
3490
+ } else {
3491
+ console.log(formatProjectInitPlan(selectedPlan));
3492
+ for (const line of result.logs) console.log(line);
3493
+ for (const line of result.errors) console.error(line);
3494
+ if (migrationReport) console.log(formatMigrationReport(migrationReport));
3495
+ if (result.ok && changedFiles.length) console.log(`Project synchronized: ${plan.project.slug}`);
3496
+ if (result.ok && changedFiles.length === 0) console.log(`Project already in parity: ${plan.project.slug}`);
3497
+ }
3498
+ process.exitCode = result.ok ? 0 : 1;
3499
+ } catch (err) {
3500
+ if (options.json) {
3501
+ console.log(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }, null, 2));
3502
+ } else {
3503
+ console.error("\u274C project init failed:", err instanceof Error ? err.message : err);
3504
+ }
3505
+ process.exit(1);
3506
+ }
3507
+ });
3508
+ projectCmd.command("list").description("List projects in the pjangler registry").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("--json", "Output machine-parseable JSON").action((options) => {
3509
+ try {
3510
+ const registry = loadProjectRegistry(options.registry ?? projectRegistryPath());
3511
+ if (options.json) console.log(JSON.stringify(registry, null, 2));
3512
+ else console.log(formatProjectList(registry));
3513
+ } catch (err) {
3514
+ console.error("\u274C project list failed:", err instanceof Error ? err.message : err);
3515
+ process.exit(1);
3516
+ }
3517
+ });
3518
+ projectCmd.command("show").argument("<slug>", "Project slug").description("Show one project from the pjangler registry").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("--json", "Output machine-parseable JSON").action((slug, options) => {
3519
+ try {
3520
+ const project = getProject(loadProjectRegistry(options.registry ?? projectRegistryPath()), slug);
3521
+ if (options.json) console.log(JSON.stringify(project, null, 2));
3522
+ else console.log(`${project.name} (${project.slug})
3523
+ ${project.repo_path}
3524
+ ${project.description}`);
3525
+ } catch (err) {
3526
+ console.error("\u274C project show failed:", err instanceof Error ? err.message : err);
3527
+ process.exit(1);
3528
+ }
3529
+ });
3530
+ projectCmd.command("doctor").argument("[slug]", "Optional project slug").description("Validate the project registry and local projections").option("--registry <path>", `Registry path override (default: ${projectRegistryPath()})`).option("--json", "Output machine-parseable JSON").action((slug, options) => {
3531
+ try {
3532
+ const report = doctorProjectRegistry(options.registry ?? projectRegistryPath(), slug);
3533
+ if (options.json) {
3534
+ console.log(JSON.stringify(report, null, 2));
3535
+ } else if (!report.issues.length) {
3536
+ console.log(`Project registry OK: ${report.registryPath}`);
3537
+ } else {
3538
+ console.log(`Project registry issues: ${report.registryPath}`);
3539
+ for (const issue of report.issues) console.log(` [${issue.level}] ${issue.slug ?? "registry"}: ${issue.message}`);
3540
+ }
3541
+ process.exit(report.ok ? 0 : 1);
3542
+ } catch (err) {
3543
+ console.error("\u274C project doctor failed:", err instanceof Error ? err.message : err);
3544
+ process.exit(1);
3545
+ }
3546
+ });
2643
3547
  var recipeCmd = program.command("recipe").description("Manage pjangler recipes");
2644
3548
  recipeCmd.command("list").description("List all available recipes").action(() => {
2645
3549
  console.log("\u{1F4E6} Available Recipes:");
@@ -2772,24 +3676,50 @@ program.command("audit").argument("[repo]", "Path to repo to audit (default: cwd
2772
3676
  process.exit(1);
2773
3677
  }
2774
3678
  });
2775
- program.command("migrate").argument("[rule-id]", "Rule ID to migrate (omit with --all to apply all)").argument("[repo]", "Path to repo (default: cwd)").description("Idempotent migration recipe for a parity rule (or --all)").option("--all", "Apply every migration recipe in order").option("--dry-run", "Preview changes without writing files").option("--json", "Output machine-parseable JSON").action((ruleId, repo, options) => {
3679
+ program.command("migrate").argument("[rule-id]", "Rule ID to migrate (omit to open interactive rule selector)").argument("[repo]", "Path to repo (default: cwd)").description("Idempotent migration recipe for a parity rule (or open the rule selector)").option("--all", "Apply every migration recipe in order").option("--dry-run", "Preview changes without writing files").option("--json", "Output machine-parseable JSON").action(async (ruleId, repo, options) => {
2776
3680
  try {
2777
3681
  const all = options.all ?? false;
2778
- if (!all && !ruleId) {
2779
- console.error("\u274C Provide a rule-id or use --all");
2780
- process.exit(1);
3682
+ const dryRun = options.dryRun ?? false;
3683
+ if (all) {
3684
+ let actualRepo = repo;
3685
+ if (ruleId && !actualRepo) {
3686
+ actualRepo = ruleId;
3687
+ }
3688
+ const report2 = runMigration(void 0, actualRepo, dryRun, true);
3689
+ printMigrationReport(report2, options.json);
3690
+ process.exit(report2.ok ? 0 : 1);
3691
+ }
3692
+ if (ruleId && repo) {
3693
+ if (!getParityRuleIds().includes(ruleId)) {
3694
+ console.error(`\u274C Unknown parity rule: ${ruleId}`);
3695
+ process.exit(1);
3696
+ }
3697
+ const report2 = runMigration(ruleId, repo, dryRun, false);
3698
+ printMigrationReport(report2, options.json);
3699
+ process.exit(report2.ok ? 0 : 1);
2781
3700
  }
2782
- let actualRuleId = all ? void 0 : ruleId;
2783
- let actualRepo = repo;
2784
- if (all && ruleId && !actualRepo) {
2785
- actualRepo = ruleId;
3701
+ if (ruleId && getParityRuleIds().includes(ruleId)) {
3702
+ const report2 = runMigration(ruleId, void 0, dryRun, false);
3703
+ printMigrationReport(report2, options.json);
3704
+ process.exit(report2.ok ? 0 : 1);
2786
3705
  }
2787
- const report = runMigration(actualRuleId, actualRepo, options.dryRun ?? false, all);
2788
3706
  if (options.json) {
2789
- console.log(JSON.stringify(report, null, 2));
2790
- } else {
2791
- console.log(formatMigrationReport(report));
3707
+ console.error("\u274C JSON output requires a rule-id or --all");
3708
+ process.exit(1);
3709
+ }
3710
+ if (!process.stdin.isTTY) {
3711
+ console.error("\u274C Provide a rule-id, use --all, or run in an interactive terminal");
3712
+ process.exit(1);
3713
+ }
3714
+ const targetRepo = ruleId ?? repo;
3715
+ const audit = runAudit(targetRepo);
3716
+ const ruleIds = await promptForRuleIds(audit.rules);
3717
+ if (!ruleIds.length) {
3718
+ console.log("No rules selected; nothing to migrate.");
3719
+ process.exit(0);
2792
3720
  }
3721
+ const report = runMigrationForRules(ruleIds, targetRepo, dryRun);
3722
+ printMigrationReport(report, false);
2793
3723
  process.exit(report.ok ? 0 : 1);
2794
3724
  } catch (err) {
2795
3725
  console.error("\u274C migrate failed:", err);