@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.
- package/dist/index.js +1033 -103
- package/dist/mcp-server.js +669 -111
- package/package.json +3 -1
package/dist/mcp-server.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/mcp-server.ts
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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
|
|
1728
|
-
const
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
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
|
|
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 `${
|
|
1802
|
+
return `${text3.slice(0, versioningIndex).replace(/\s*$/, "\n\n")}${block}
|
|
1736
1803
|
|
|
1737
|
-
${
|
|
1804
|
+
${text3.slice(versioningIndex)}`;
|
|
1738
1805
|
}
|
|
1739
|
-
return `${
|
|
1806
|
+
return `${text3.replace(/\s*$/, "")}
|
|
1740
1807
|
|
|
1741
|
-
${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
2716
|
+
const selected = RULES.filter((rule) => ruleIds.includes(rule.id));
|
|
2551
2717
|
if (!selected.length) {
|
|
2552
|
-
throw new Error(`Unknown parity
|
|
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 =
|
|
2596
|
-
if (!
|
|
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 (!
|
|
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
|
|
2605
|
-
let dir =
|
|
2606
|
-
while (dir !==
|
|
2607
|
-
if (
|
|
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 =
|
|
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 =
|
|
3329
|
+
const pjanglerRoot = resolvePjanglerRoot3();
|
|
2821
3330
|
const projectSlug = input.projectSlug ?? slugify(input.projectName);
|
|
2822
|
-
const parentDir =
|
|
2823
|
-
if (!
|
|
2824
|
-
const targetDir =
|
|
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 (
|
|
2834
|
-
const
|
|
2835
|
-
|
|
2836
|
-
|
|
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
|
-
|
|
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({
|
|
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,
|
|
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 ??
|
|
3564
|
+
targetRepo: input.targetRepo ?? basename4(resolvedTarget),
|
|
3007
3565
|
role: input.role,
|
|
3008
3566
|
agentPurpose: input.agentPurpose,
|
|
3009
3567
|
soulTone: input.soulTone,
|