@delorenj/pjangler 1.5.11 → 1.5.13

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 (25) hide show
  1. package/.mise/scripts/versioning.sh +26 -5
  2. package/README.md +25 -0
  3. package/dist/index.js +169 -36
  4. package/dist/mcp-server.js +153 -29
  5. package/dist/notebook/journal-service-main.js +4 -1
  6. package/dist/project-registry-service.js +1 -1
  7. package/package.json +1 -1
  8. package/templates/commonproject/copier.yml +102 -1
  9. package/templates/commonproject/template/.github/workflows/{% if is_obsidian_plugin %}lint.yml{% endif %} +29 -0
  10. package/templates/commonproject/template/.github/workflows/{% if is_obsidian_plugin %}release.yml{% endif %} +62 -0
  11. package/templates/commonproject/template/.mise/{% if is_obsidian_plugin %}tasks{% endif %}/plugin/link +27 -0
  12. package/templates/commonproject/template/AGENTS.md.jinja +67 -0
  13. package/templates/commonproject/template/{% if is_obsidian_plugin %}.editorconfig{% endif %} +15 -0
  14. package/templates/commonproject/template/{% if is_obsidian_plugin %}.npmrc{% endif %} +1 -0
  15. package/templates/commonproject/template/{% if is_obsidian_plugin %}esbuild.config.mjs{% endif %} +49 -0
  16. package/templates/commonproject/template/{% if is_obsidian_plugin %}eslint.config.mts{% endif %} +38 -0
  17. package/templates/commonproject/template/{% if is_obsidian_plugin %}manifest.json{% endif %}.jinja +9 -0
  18. package/templates/commonproject/template/{% if is_obsidian_plugin %}package-lock.json{% endif %}.jinja +5053 -0
  19. package/templates/commonproject/template/{% if is_obsidian_plugin %}package.json{% endif %}.jinja +28 -0
  20. package/templates/commonproject/template/{% if is_obsidian_plugin %}src{% endif %}/main.ts.jinja +105 -0
  21. package/templates/commonproject/template/{% if is_obsidian_plugin %}src{% endif %}/settings.ts.jinja +38 -0
  22. package/templates/commonproject/template/{% if is_obsidian_plugin %}styles.css{% endif %} +8 -0
  23. package/templates/commonproject/template/{% if is_obsidian_plugin %}tsconfig.json{% endif %} +19 -0
  24. package/templates/commonproject/template/{% if is_obsidian_plugin %}version-bump.mjs{% endif %} +17 -0
  25. package/templates/commonproject/template/{% if is_obsidian_plugin %}versions.json{% endif %} +3 -0
@@ -828,6 +828,38 @@ import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
828
828
  import { basename as basename6, delimiter as delimiter2, dirname as dirname10, isAbsolute as isAbsolute5, join as join11, relative as relative7, resolve as resolve9, sep as sep2, win32 } from "node:path";
829
829
  import { fileURLToPath as fileURLToPath2 } from "node:url";
830
830
  import YAML4 from "yaml";
831
+ function normalizeProjectType(value, label = "Project type") {
832
+ const text = typeof value === "string" ? value.trim().toLowerCase() : "";
833
+ if (PROJECT_TYPES.includes(text)) return text;
834
+ throw new Error(`${label} ${JSON.stringify(value)} is not one of: ${PROJECT_TYPES.join(", ")}`);
835
+ }
836
+ function looksLikeObsidianPlugin(dir) {
837
+ try {
838
+ const manifest = JSON.parse(readFileSync8(join11(dir, "manifest.json"), "utf8"));
839
+ return isRecord2(manifest) && typeof manifest.id === "string" && typeof manifest.minAppVersion === "string";
840
+ } catch {
841
+ return false;
842
+ }
843
+ }
844
+ function assertTemplateOffersProjectType(pjanglerRoot, projectType) {
845
+ if (projectType === "base") return;
846
+ const copierYml = join11(pjanglerRoot, "templates", "commonproject", "copier.yml");
847
+ let choices;
848
+ try {
849
+ const parsed = YAML4.parse(readFileSync8(copierYml, "utf8"));
850
+ choices = isRecord2(parsed) && isRecord2(parsed.project_type) ? parsed.project_type.choices : void 0;
851
+ } catch {
852
+ choices = void 0;
853
+ }
854
+ const offered = isRecord2(choices) ? Object.values(choices) : Array.isArray(choices) ? choices : [];
855
+ if (!offered.includes(projectType)) {
856
+ throw new Error(`The CommonProject template at ${dirname10(copierYml)} cannot render project type "${projectType}" (its copier.yml offers no such project_type); update pjangler or its templates/commonproject pin`);
857
+ }
858
+ }
859
+ function operatorDisplayName(cwd) {
860
+ const result2 = spawnSync4("git", ["config", "--get", "user.name"], { cwd: existsSync7(cwd) ? cwd : void 0, encoding: "utf8", timeout: 5e3 });
861
+ return result2.status === 0 ? result2.stdout.trim() : "";
862
+ }
831
863
  function providerAssignsIdentifiers(type) {
832
864
  return IDENTIFIER_ASSIGNING_PROVIDERS.includes((type ?? "").trim());
833
865
  }
@@ -1431,6 +1463,26 @@ function planProjectInit(input) {
1431
1463
  const overwrite = input.overwrite ?? input.force ?? false;
1432
1464
  const agents = createSafeRecord(Object.entries(existing?.agents ?? {}));
1433
1465
  const scaffold = input.scaffold ?? true;
1466
+ const recordedTypeValue = existing?.template?.commonproject?.project_type;
1467
+ const recordedType = recordedTypeValue === void 0 ? void 0 : normalizeProjectType(recordedTypeValue, `Recorded project type of ${slug}`);
1468
+ const requestedType = input.projectType === void 0 ? void 0 : normalizeProjectType(input.projectType);
1469
+ const freshRender = scaffold && localManifest === void 0 && !existsSync7(join11(targetDir, ".copier-answers.yml"));
1470
+ if (requestedType && !freshRender && requestedType !== (recordedType ?? "base")) {
1471
+ const adoptingPlugin = !scaffold && recordedType === void 0 && requestedType === "obsidian-plugin" && looksLikeObsidianPlugin(targetDir);
1472
+ if (!adoptingPlugin) {
1473
+ throw new Error(scaffold || recordedType !== void 0 ? `Project type "${requestedType}" conflicts with ${slug}'s recorded type "${recordedType ?? "base"}"; a project's type is fixed when it is created` : `${targetDir} is adopted as-is, so "${requestedType}" would render nothing, and the repo is not one already (no Obsidian manifest.json with id and minAppVersion)`);
1474
+ }
1475
+ }
1476
+ const projectType = requestedType ?? recordedType ?? "base";
1477
+ const typeLanguages = PROJECT_TYPE_LANGUAGES[projectType];
1478
+ if (input.primaryLanguage !== void 0 && typeLanguages && !typeLanguages.includes(input.primaryLanguage)) {
1479
+ throw new Error(`A ${projectType} project is written in ${typeLanguages.join(" or ")}, not ${input.primaryLanguage}`);
1480
+ }
1481
+ const recordedLanguageValue = (localManifest ? localManifest.template : indexed?.template)?.commonproject?.primary_language;
1482
+ const recordedLanguage = typeof recordedLanguageValue === "string" && recordedLanguageValue ? recordedLanguageValue : void 0;
1483
+ const newlyTyped = requestedType !== void 0 && requestedType !== recordedType;
1484
+ const recordedLanguageFits = !typeLanguages || recordedLanguage !== void 0 && typeLanguages.includes(recordedLanguage);
1485
+ const primaryLanguage = input.primaryLanguage ?? (newlyTyped && !recordedLanguageFits ? typeLanguages?.[0] : void 0) ?? recordedLanguage ?? typeLanguages?.[0] ?? "python";
1434
1486
  const candidateProject = {
1435
1487
  ...existing ?? {},
1436
1488
  name: input.name,
@@ -1452,7 +1504,10 @@ function planProjectInit(input) {
1452
1504
  commonproject: {
1453
1505
  ...existing?.template?.commonproject,
1454
1506
  enabled: true,
1455
- primary_language: input.primaryLanguage ?? existing?.template?.commonproject?.primary_language ?? "python"
1507
+ primary_language: primaryLanguage,
1508
+ // Written only for a non-base type (or one already recorded), so a
1509
+ // base record never gains a key and stays equivalent to its last run.
1510
+ ...projectType !== "base" || recordedType !== void 0 ? { project_type: projectType } : {}
1456
1511
  }
1457
1512
  },
1458
1513
  ticket_provider: { ...existing?.ticket_provider, ...buildTicketProviderBlock({
@@ -1488,6 +1543,7 @@ function planProjectInit(input) {
1488
1543
  { kind: "registry.upsert", registryPath, slug, project }
1489
1544
  ];
1490
1545
  if (scaffold) {
1546
+ assertTemplateOffersProjectType(pjanglerRoot, projectType);
1491
1547
  actions.push(buildCommonProjectCopierAction({
1492
1548
  pjanglerRoot,
1493
1549
  targetDir,
@@ -1501,6 +1557,8 @@ function planProjectInit(input) {
1501
1557
  boardId: project.ticket_provider.board_id ?? "",
1502
1558
  projectIdentifier: identifier,
1503
1559
  primaryLanguage: project.template.commonproject.primary_language,
1560
+ projectType,
1561
+ ...projectType === "obsidian-plugin" ? { pluginAuthor: operatorDisplayName(input.cwd ?? process.cwd()) } : {},
1504
1562
  agentHooksLayer: resolveAgentHooksLayer2(input.agentHooksLayer),
1505
1563
  overwrite
1506
1564
  }));
@@ -1639,6 +1697,13 @@ async function executeProjectInitPlan(plan, options = {}) {
1639
1697
  errors.push(`copier exited with status ${result2.status ?? "unknown"}`);
1640
1698
  break;
1641
1699
  }
1700
+ const projectType = action.data.project_type ?? "base";
1701
+ const missing = (PROJECT_TYPE_SENTINELS[projectType] ?? []).filter((rel) => !existsSync7(join11(action.targetDir, rel)));
1702
+ if (missing.length) {
1703
+ errors.push(`copier rendered no ${projectType} project: ${missing.join(", ")} missing from ${action.targetDir}`);
1704
+ break;
1705
+ }
1706
+ if (projectType !== "base") logs.push(`commonproject: rendered as ${projectType}`);
1642
1707
  } else if (action.kind === "project.write-manifest") {
1643
1708
  if (action.expectedContent !== void 0 && (!existsSync7(action.path) || readFileSync8(action.path, "utf8") !== action.expectedContent)) {
1644
1709
  errors.push(`Manifest changed after planning: ${action.path}; re-plan before applying`);
@@ -1771,6 +1836,8 @@ function buildCommonProjectCopierAction(input) {
1771
1836
  board_id: input.boardId ?? input.planeProjectId ?? "",
1772
1837
  project_identifier: input.projectIdentifier,
1773
1838
  primary_language: input.primaryLanguage,
1839
+ project_type: input.projectType ?? "base",
1840
+ ...input.pluginAuthor !== void 0 ? { plugin_author: input.pluginAuthor } : {},
1774
1841
  agent_hooks_layer: input.agentHooksLayer ?? true ? "true" : "false"
1775
1842
  };
1776
1843
  const command = ["copier", "copy", "--trust", "--vcs-ref=HEAD", templateDir, input.targetDir, "--defaults"];
@@ -1871,7 +1938,7 @@ function expandHome(path) {
1871
1938
  function isRecord2(value) {
1872
1939
  return typeof value === "object" && value !== null && !Array.isArray(value);
1873
1940
  }
1874
- var PROJECT_SOURCE_SKILL_ROOTS_ENV, TICKET_PROVIDER_ADAPTERS_ENV, PROJECT_REGISTRY_SCHEMA_VERSION, DEFAULT_NEW_PROJECT_STATUS, BOARD_URL_DEPRECATION_WARNING, PROJECT_IDENTIFIER_SOURCES, IDENTIFIER_ASSIGNING_PROVIDERS, TICKET_PROVIDER_STATES, IDENTIFIER_REPAIR_COMMAND, DEFAULT_SOURCE_SKILL_ROOTS, PROJECT_REGISTRY_OWNED_KEYS, PROJECT_NOTEBOOK_OWNED_KEYS, TICKET_PROVIDER_OWNED_KEYS, GLOBAL_NOTEBOOK_OWNED_KEYS, GLOBAL_NOTEBOOK_AUTH_OWNED_KEYS, GLOBAL_NOTEBOOK_DEFAULTS_OWNED_KEYS, GLOBAL_NOTEBOOK_LIMITS_OWNED_KEYS, GLOBAL_NOTEBOOK_SUMMARIZER_OWNED_KEYS, SAFE_PATH_SEGMENT;
1941
+ var PROJECT_SOURCE_SKILL_ROOTS_ENV, TICKET_PROVIDER_ADAPTERS_ENV, PROJECT_REGISTRY_SCHEMA_VERSION, DEFAULT_NEW_PROJECT_STATUS, PROJECT_TYPES, PROJECT_TYPE_LANGUAGES, PROJECT_TYPE_SENTINELS, BOARD_URL_DEPRECATION_WARNING, PROJECT_IDENTIFIER_SOURCES, IDENTIFIER_ASSIGNING_PROVIDERS, TICKET_PROVIDER_STATES, IDENTIFIER_REPAIR_COMMAND, DEFAULT_SOURCE_SKILL_ROOTS, PROJECT_REGISTRY_OWNED_KEYS, PROJECT_NOTEBOOK_OWNED_KEYS, TICKET_PROVIDER_OWNED_KEYS, GLOBAL_NOTEBOOK_OWNED_KEYS, GLOBAL_NOTEBOOK_AUTH_OWNED_KEYS, GLOBAL_NOTEBOOK_DEFAULTS_OWNED_KEYS, GLOBAL_NOTEBOOK_LIMITS_OWNED_KEYS, GLOBAL_NOTEBOOK_SUMMARIZER_OWNED_KEYS, SAFE_PATH_SEGMENT;
1875
1942
  var init_project = __esm({
1876
1943
  "src/project/index.ts"() {
1877
1944
  "use strict";
@@ -1887,6 +1954,13 @@ var init_project = __esm({
1887
1954
  TICKET_PROVIDER_ADAPTERS_ENV = "PJ_TICKET_PROVIDER_ADAPTERS";
1888
1955
  PROJECT_REGISTRY_SCHEMA_VERSION = 1;
1889
1956
  DEFAULT_NEW_PROJECT_STATUS = "active";
1957
+ PROJECT_TYPES = ["base", "obsidian-plugin"];
1958
+ PROJECT_TYPE_LANGUAGES = {
1959
+ "obsidian-plugin": ["typescript", "javascript"]
1960
+ };
1961
+ PROJECT_TYPE_SENTINELS = {
1962
+ "obsidian-plugin": ["manifest.json", join11("src", "main.ts")]
1963
+ };
1890
1964
  BOARD_URL_DEPRECATION_WARNING = "boardUrl is deprecated and ignored; board URLs are derived at runtime and are never persisted.";
1891
1965
  PROJECT_IDENTIFIER_SOURCES = ["provider", "proposed"];
1892
1966
  IDENTIFIER_ASSIGNING_PROVIDERS = ["plane", "linear"];
@@ -7678,9 +7752,24 @@ function lstatIfPresent(path) {
7678
7752
  }
7679
7753
  }
7680
7754
  function templateVersionFilesConf(ctx, repoRoot) {
7755
+ if (isObsidianPluginRepo(ctx, repoRoot)) return OBSIDIAN_VERSION_FILES_CONF;
7681
7756
  const packageJson = join5(repoRoot, "package.json");
7682
7757
  return existsSync3(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";
7683
7758
  }
7759
+ var OBSIDIAN_VERSION_FILES_CONF = "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag obsidian\n# Obsidian plugin: bump with `mise run version:bump`, release with `npm version` (bare X.Y.Z tag).\njson package.json\nobsidian manifest.json\n";
7760
+ function recordedProjectType(ctx) {
7761
+ return record(record(readProjectJson(ctx)?.template).commonproject).project_type;
7762
+ }
7763
+ function isObsidianPluginRepo(ctx, repoRoot) {
7764
+ const recorded = recordedProjectType(ctx);
7765
+ if (recorded !== void 0) return recorded === "obsidian-plugin";
7766
+ const manifest = record(tryParseJson(safeReadText2(join5(repoRoot, "manifest.json"))));
7767
+ return typeof manifest.id === "string" && typeof manifest.minAppVersion === "string";
7768
+ }
7769
+ function unsupportedVersionTypes(conf, script) {
7770
+ const types = conf.split("\n").map((line) => line.trim().split(/\s+/)[0] ?? "").filter((type) => type && !type.startsWith("#"));
7771
+ return [...new Set(types)].filter((type) => !new RegExp(`(^|[\\s|])${type.replace(/[^a-z0-9-]/gi, "")}(\\|[a-z-]+)*\\)`, "m").test(script));
7772
+ }
7684
7773
  var BASE_MISE_PATH_ENTRIES = [".mise/scripts", "agents/hermes/pm"];
7685
7774
  function requiredMisePathEntries(_ctx) {
7686
7775
  return [...BASE_MISE_PATH_ENTRIES];
@@ -8338,8 +8427,9 @@ function projectJsonFinding(ctx) {
8338
8427
  };
8339
8428
  }
8340
8429
  var BMAD_NPM_PACKAGE = "bmad-method";
8341
- var BMAD_INSTALLER_VERSION = "6.12.0";
8430
+ var BMAD_INSTALLER_VERSION = "6.12.1";
8342
8431
  var BMAD_TARGET_CHANNEL = "next";
8432
+ var BMAD_STABLE_CHANNEL = "latest";
8343
8433
  var BMAD_DIST_TAGS_TTL_MS = 60 * 60 * 1e3;
8344
8434
  var DEFAULT_BMAD_MODULES = ["bmm", "bmb", "cis"];
8345
8435
  var BMAD_INSTALL_TOOLS = SUPPORTED_BMAD_TOOLS;
@@ -8533,10 +8623,17 @@ function runBmadInstall(repoRoot, modules = selectedBmadModules(repoRoot), versi
8533
8623
  const invocation = bmadInstallerInvocation(version);
8534
8624
  const result2 = spawnSync3(invocation.command, [...invocation.prefixArgs, ...bmadInstallerArgs(repoRoot, modules)], { encoding: "utf8" });
8535
8625
  if (result2.status !== 0) {
8536
- return { ok: false, error: result2.stderr || result2.error?.message || "Unknown error" };
8626
+ const stdoutTail = result2.stdout?.trim().split("\n").slice(-12).join("\n");
8627
+ return { ok: false, error: result2.stderr?.trim() || stdoutTail || result2.error?.message || `installer exited ${result2.status}` };
8537
8628
  }
8538
8629
  return { ok: true };
8539
8630
  }
8631
+ function bmadChannelTarget(distTags) {
8632
+ const next = distTags?.[BMAD_TARGET_CHANNEL];
8633
+ const stable = distTags?.[BMAD_STABLE_CHANNEL];
8634
+ if (next && stable && compareBmadVersions(stable, next) > 0) return { version: stable, channel: BMAD_STABLE_CHANNEL };
8635
+ return next ? { version: next, channel: BMAD_TARGET_CHANNEL } : void 0;
8636
+ }
8540
8637
  function readInstalledBmadVersion(repoRoot) {
8541
8638
  const raw = safeReadText2(join5(repoRoot, "_bmad", "_config", "manifest.yaml"));
8542
8639
  if (!raw) return void 0;
@@ -8884,6 +8981,15 @@ function createMiseChecks() {
8884
8981
  if (!text?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
8885
8982
  if (!existsSync3(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
8886
8983
  if (!existsSync3(manifestPath)) details.push(".mise/version-files.conf missing");
8984
+ const conf = safeReadText2(manifestPath);
8985
+ const script = safeReadText2(versioningPath);
8986
+ if (conf !== null && script !== null) {
8987
+ const unsupported = unsupportedVersionTypes(conf, script);
8988
+ if (unsupported.length) details.push(`.mise/scripts/versioning.sh cannot version type(s) ${unsupported.join(", ")} named in .mise/version-files.conf`);
8989
+ }
8990
+ if (conf !== null && isObsidianPluginRepo(ctx, ctx.repoRoot) && !/^obsidian\s+manifest\.json\s*$/m.test(conf)) {
8991
+ details.push(".mise/version-files.conf does not version manifest.json/versions.json for this obsidian-plugin project");
8992
+ }
8887
8993
  return {
8888
8994
  id: "mise.versioning",
8889
8995
  title: "managed mise versioning block",
@@ -9425,6 +9531,15 @@ function strayOpInjectLines(text) {
9425
9531
  }
9426
9532
  return [...new Set(lines)].sort((a, b) => a - b);
9427
9533
  }
9534
+ var COPIER_ANSWERS_HEADER = "# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY";
9535
+ function parseCopierAnswers(text) {
9536
+ try {
9537
+ const parsed = YAML2.parse(text);
9538
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { answers: parsed } : { problem: ".copier-answers.yml is not a YAML mapping" };
9539
+ } catch (error) {
9540
+ return { problem: `.copier-answers.yml is not valid YAML (${errorText(error).split("\n")[0]})` };
9541
+ }
9542
+ }
9428
9543
  function createProjectProvenanceChecks() {
9429
9544
  return [
9430
9545
  {
@@ -9438,16 +9553,14 @@ function createProjectProvenanceChecks() {
9438
9553
  if (!text) {
9439
9554
  details.push(".copier-answers.yml missing");
9440
9555
  } else {
9441
- if (!text.startsWith("# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY")) details.push("missing Copier overwrite warning header");
9442
- if (!text.includes("_src_path:")) details.push("_src_path missing");
9443
- if (project?.project_name) {
9444
- const nameMatch = text.match(/project_name:\s*(.+)/);
9445
- if (!nameMatch || nameMatch[1]?.trim() !== String(project.project_name)) details.push("project_name drift between .copier-answers.yml and .project.json");
9446
- }
9447
- if (project?.project_description) {
9448
- const descMatch = text.match(/project_description:\s*([\s\S]*?)(?=\n\w|$)/);
9449
- const yamlDesc = descMatch?.[1]?.replace(/\n\s+/g, " ").trim() ?? "";
9450
- if (yamlDesc !== String(project.project_description)) details.push("project_description drift between .copier-answers.yml and .project.json");
9556
+ if (!text.startsWith(COPIER_ANSWERS_HEADER)) details.push("missing Copier overwrite warning header");
9557
+ const parsed = parseCopierAnswers(text);
9558
+ if ("problem" in parsed) details.push(parsed.problem);
9559
+ else {
9560
+ const { answers } = parsed;
9561
+ if (answers._src_path === void 0) details.push("_src_path missing");
9562
+ if (project?.project_name && String(answers.project_name ?? "") !== String(project.project_name)) details.push("project_name drift between .copier-answers.yml and .project.json");
9563
+ if (project?.project_description && String(answers.project_description ?? "") !== String(project.project_description)) details.push("project_description drift between .copier-answers.yml and .project.json");
9451
9564
  }
9452
9565
  }
9453
9566
  return {
@@ -9462,14 +9575,18 @@ function createProjectProvenanceChecks() {
9462
9575
  migrate: (ctx, finding2) => {
9463
9576
  const changedFiles = [];
9464
9577
  const project = canonicalProjectJson(ctx);
9465
- const text = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
9466
- _src_path: ${join5(ctx.pjanglerRoot, "templates", "commonproject")}
9467
- project_description: ${String(project.project_description)}
9468
- project_name: ${String(project.project_name)}
9469
- ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
9470
- `;
9471
9578
  const path = join5(ctx.repoRoot, ".copier-answers.yml");
9472
- if (safeReadText2(path) !== text) {
9579
+ const current = safeReadText2(path);
9580
+ const existing = current ? YAML2.parseDocument(current) : void 0;
9581
+ const document = existing && !existing.errors.length && YAML2.isMap(existing.contents) ? existing : new YAML2.Document({});
9582
+ document.set("_src_path", join5(ctx.pjanglerRoot, "templates", "commonproject"));
9583
+ document.set("project_description", String(project.project_description));
9584
+ document.set("project_name", String(project.project_name));
9585
+ document.set("ticket_provider", String(project.ticket_provider?.type ?? "plane"));
9586
+ const body = String(document);
9587
+ const text = body.startsWith(COPIER_ANSWERS_HEADER) ? body : `${COPIER_ANSWERS_HEADER}
9588
+ ${body}`;
9589
+ if (current !== text) {
9473
9590
  changedFiles.push(path);
9474
9591
  if (!ctx.dryRun) writeText(path, text);
9475
9592
  }
@@ -9701,7 +9818,9 @@ function createBmadChecks() {
9701
9818
  }
9702
9819
  const pinned = ctx.bmadVersionPin?.trim();
9703
9820
  const resolved = pinned ? void 0 : resolveBmadDistTags(ctx.homeDir);
9704
- const available = pinned ?? resolved?.distTags?.[BMAD_TARGET_CHANNEL];
9821
+ const channelTarget = pinned ? void 0 : bmadChannelTarget(resolved?.distTags);
9822
+ const available = pinned ?? channelTarget?.version;
9823
+ const channel = channelTarget?.channel ?? BMAD_TARGET_CHANNEL;
9705
9824
  if (!available) {
9706
9825
  return {
9707
9826
  id: "bmad.version",
@@ -9712,7 +9831,7 @@ function createBmadChecks() {
9712
9831
  fixable: false
9713
9832
  };
9714
9833
  }
9715
- const targetLabel = pinned ? `pinned ${available}` : `${BMAD_TARGET_CHANNEL} ${available}`;
9834
+ const targetLabel = pinned ? `pinned ${available}` : `${channel} ${available}`;
9716
9835
  const staleNote = resolved?.stale ? ` ${glyph.dot} cached` : "";
9717
9836
  const comparison = compareBmadVersions(installed, available);
9718
9837
  if (pinned ? comparison === 0 : comparison >= 0) {
@@ -9733,7 +9852,7 @@ function createBmadChecks() {
9733
9852
  title: "BMAD version currency",
9734
9853
  status: "skip",
9735
9854
  summary: `BMAD ${installed} installed; stable currency floor unknown (${BMAD_NPM_PACKAGE} latest unresolved)`,
9736
- details: [`installed: ${installed}`, `available: ${available} (${BMAD_NPM_PACKAGE}@${BMAD_TARGET_CHANNEL})`],
9855
+ details: [`installed: ${installed}`, `available: ${available} (${BMAD_NPM_PACKAGE}@${channel})`],
9737
9856
  fixable: false
9738
9857
  };
9739
9858
  }
@@ -9746,7 +9865,7 @@ function createBmadChecks() {
9746
9865
  summary: `BMAD ${installed} is at or ahead of stable ${stable ?? "unknown"}; ${targetLabel} available${staleNote}`,
9747
9866
  details: [
9748
9867
  `installed: ${installed}`,
9749
- `available: ${available} (${BMAD_NPM_PACKAGE}@${BMAD_TARGET_CHANNEL})`,
9868
+ `available: ${available} (${BMAD_NPM_PACKAGE}@${channel})`,
9750
9869
  stable ? `stable latest: ${stable}` : "",
9751
9870
  "run `pj migrate bmad.version` to take the prerelease"
9752
9871
  ].filter(Boolean),
@@ -9760,7 +9879,7 @@ function createBmadChecks() {
9760
9879
  summary: pinnedMismatch ? `BMAD ${installed} does not match ${targetLabel}` : `BMAD ${installed} is behind stable ${stable} \u2014 upgrade available`,
9761
9880
  details: [
9762
9881
  `installed: ${installed}`,
9763
- pinned ? `required transaction pin: ${available}` : `available: ${available} (${BMAD_NPM_PACKAGE}@${BMAD_TARGET_CHANNEL})`,
9882
+ pinned ? `required transaction pin: ${available}` : `available: ${available} (${BMAD_NPM_PACKAGE}@${channel})`,
9764
9883
  !pinned && stable ? `stable latest: ${stable}` : "",
9765
9884
  "run `pj migrate bmad.version` to upgrade"
9766
9885
  ].filter(Boolean),
@@ -9773,13 +9892,13 @@ function createBmadChecks() {
9773
9892
  }
9774
9893
  {
9775
9894
  const current = readInstalledBmadVersion(ctx.repoRoot);
9776
- const target = ctx.bmadVersionPin?.trim() ?? resolveBmadDistTags(ctx.homeDir)?.distTags?.[BMAD_TARGET_CHANNEL];
9895
+ const target = ctx.bmadVersionPin?.trim() ?? bmadChannelTarget(resolveBmadDistTags(ctx.homeDir)?.distTags)?.version;
9777
9896
  if (!current || !target || compareBmadVersions(current, target) === 0) {
9778
9897
  return { id: finding2.id, title: finding2.title, status: "noop", summary: "BMAD already current", changedFiles: [], details: [] };
9779
9898
  }
9780
9899
  }
9781
9900
  const installed = readInstalledBmadVersion(ctx.repoRoot);
9782
- const available = ctx.bmadVersionPin?.trim() ?? resolveBmadDistTags(ctx.homeDir)?.distTags?.[BMAD_TARGET_CHANNEL];
9901
+ const available = ctx.bmadVersionPin?.trim() ?? bmadChannelTarget(resolveBmadDistTags(ctx.homeDir)?.distTags)?.version;
9783
9902
  const manifestPath = join5(ctx.repoRoot, "_bmad", "_config", "manifest.yaml");
9784
9903
  const manifestSelection = manifestBmadModules(ctx.repoRoot);
9785
9904
  if (manifestSelection.status === "invalid") {
@@ -14427,6 +14546,7 @@ var server = new McpServer({
14427
14546
  version: PJANGLER_VERSION
14428
14547
  });
14429
14548
  var TICKET_PROVIDER_SCHEMA = z.enum(["plane", "trello"]);
14549
+ var PROJECT_TYPE_SCHEMA = z.enum(PROJECT_TYPES).describe("What CommonProject renders: base (default) or obsidian-plugin. Fixed at creation; omit it to keep the recorded type.");
14430
14550
  var BOARD_URL_COMPAT_SCHEMA = z.string().optional().describe("Deprecated compatibility input. Ignored; board URLs are derived at runtime and are never persisted.").meta({ deprecated: true });
14431
14551
  var RUNTIME_REPO_COMPAT_SCHEMA = z.boolean().optional().describe("Deprecated no-op. Hermes always converges ignored role-local runtime state and never provisions a per-agent GitHub repository.").meta({ deprecated: true });
14432
14552
  function safePathSegmentSchema(label) {
@@ -14748,6 +14868,7 @@ server.registerTool(
14748
14868
  planeProjectId: z.string().optional(),
14749
14869
  projectIdentifier: z.string().optional(),
14750
14870
  primaryLanguage: z.string().optional(),
14871
+ projectType: PROJECT_TYPE_SCHEMA.optional(),
14751
14872
  skipPlane: z.boolean().optional(),
14752
14873
  agentPurpose: z.string().optional(),
14753
14874
  local: z.boolean().optional(),
@@ -14792,7 +14913,8 @@ server.registerTool(
14792
14913
  targetDir,
14793
14914
  projectSlug,
14794
14915
  sourceSkill: input.sourceSkill,
14795
- primaryLanguage: input.primaryLanguage ?? "python",
14916
+ primaryLanguage: input.primaryLanguage,
14917
+ projectType: input.projectType,
14796
14918
  apply: !dryRun,
14797
14919
  live: input.live ?? false,
14798
14920
  provisionTicketBoard: externalEffects.ticketBoard,
@@ -14857,6 +14979,7 @@ server.registerTool(
14857
14979
  targetDir: z.string().optional(),
14858
14980
  sourceSkill: z.string().optional(),
14859
14981
  primaryLanguage: z.string().optional(),
14982
+ projectType: PROJECT_TYPE_SCHEMA.optional(),
14860
14983
  apply: z.boolean().optional(),
14861
14984
  live: z.boolean().optional(),
14862
14985
  provisionTicketBoard: z.boolean().optional().describe("Explicitly opt in to ticket-board provisioning; also requires live=true and skipPlane!=true."),
@@ -14880,6 +15003,7 @@ server.registerTool(
14880
15003
  targetDir: input.targetDir,
14881
15004
  sourceSkill: input.sourceSkill,
14882
15005
  primaryLanguage: input.primaryLanguage,
15006
+ projectType: input.projectType,
14883
15007
  apply: input.apply ?? false,
14884
15008
  live: input.live ?? false,
14885
15009
  provisionTicketBoard: externalEffects.ticketBoard,
@@ -660,7 +660,7 @@ function validateProjectRecord(project, key) {
660
660
  function isRecord2(value) {
661
661
  return typeof value === "object" && value !== null && !Array.isArray(value);
662
662
  }
663
- var PROJECT_REGISTRY_SCHEMA_VERSION, PROJECT_IDENTIFIER_SOURCES, IDENTIFIER_ASSIGNING_PROVIDERS, TICKET_PROVIDER_STATES, IDENTIFIER_REPAIR_COMMAND, DEFAULT_SOURCE_SKILL_ROOTS, PROJECT_REGISTRY_OWNED_KEYS, PROJECT_NOTEBOOK_OWNED_KEYS, TICKET_PROVIDER_OWNED_KEYS, GLOBAL_NOTEBOOK_OWNED_KEYS, GLOBAL_NOTEBOOK_AUTH_OWNED_KEYS, GLOBAL_NOTEBOOK_DEFAULTS_OWNED_KEYS, GLOBAL_NOTEBOOK_LIMITS_OWNED_KEYS, GLOBAL_NOTEBOOK_SUMMARIZER_OWNED_KEYS, SAFE_PATH_SEGMENT;
663
+ var PROJECT_REGISTRY_SCHEMA_VERSION, PROJECT_TYPE_SENTINELS, PROJECT_IDENTIFIER_SOURCES, IDENTIFIER_ASSIGNING_PROVIDERS, TICKET_PROVIDER_STATES, IDENTIFIER_REPAIR_COMMAND, DEFAULT_SOURCE_SKILL_ROOTS, PROJECT_REGISTRY_OWNED_KEYS, PROJECT_NOTEBOOK_OWNED_KEYS, TICKET_PROVIDER_OWNED_KEYS, GLOBAL_NOTEBOOK_OWNED_KEYS, GLOBAL_NOTEBOOK_AUTH_OWNED_KEYS, GLOBAL_NOTEBOOK_DEFAULTS_OWNED_KEYS, GLOBAL_NOTEBOOK_LIMITS_OWNED_KEYS, GLOBAL_NOTEBOOK_SUMMARIZER_OWNED_KEYS, SAFE_PATH_SEGMENT;
664
664
  var init_project = __esm({
665
665
  "src/project/index.ts"() {
666
666
  "use strict";
@@ -673,6 +673,9 @@ var init_project = __esm({
673
673
  init_boardQuery();
674
674
  init_registryClient();
675
675
  PROJECT_REGISTRY_SCHEMA_VERSION = 1;
676
+ PROJECT_TYPE_SENTINELS = {
677
+ "obsidian-plugin": ["manifest.json", join2("src", "main.ts")]
678
+ };
676
679
  PROJECT_IDENTIFIER_SOURCES = ["provider", "proposed"];
677
680
  IDENTIFIER_ASSIGNING_PROVIDERS = ["plane", "linear"];
678
681
  TICKET_PROVIDER_STATES = ["planned", "linked", "skipped"];
@@ -234,7 +234,7 @@ function validateManifestFields(manifest, path) {
234
234
  if (!object(manifest.template.commonproject)) fail("template.commonproject", "an object");
235
235
  const template = manifest.template.commonproject;
236
236
  if (template.enabled !== void 0 && typeof template.enabled !== "boolean") fail("template.commonproject.enabled", "boolean");
237
- strings(template, ["primary_language"], "template.commonproject.");
237
+ strings(template, ["primary_language", "project_type"], "template.commonproject.");
238
238
  }
239
239
  for (const field of ["binding", "policy"]) if (manifest.notebook?.[field] !== void 0 && !object(manifest.notebook[field])) fail(`notebook.${field}`, "an object");
240
240
  if (manifest.notebook?.binding) strings(manifest.notebook.binding, ["provider", "state", "notebook_name", "blocked_reason", "notebook_id", "overview_note_id", "created_at", "updated_at"], "notebook.binding.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delorenj/pjangler",
3
- "version": "1.5.11",
3
+ "version": "1.5.13",
4
4
  "description": "Project subsystem bootstrapper CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -43,6 +43,30 @@ ticket_provider:
43
43
  Plane: plane
44
44
  Trello: trello
45
45
 
46
+ # What this project is. `base` is the generic skeleton below and nothing else.
47
+ # `obsidian-plugin` (`pj init --obsidian-plugin`) adds an Obsidian community
48
+ # plugin on top of it, vendored from the canonical starter
49
+ # obsidianmd/obsidian-sample-plugin @ 07ceb81d (2026-08-02): npm + esbuild +
50
+ # TypeScript, manifest.json/versions.json, eslint-plugin-obsidianmd, and its
51
+ # build/release workflows moved to the self-hosted runners.
52
+ #
53
+ # Every type-only path under template/ carries `{% if is_obsidian_plugin %}`
54
+ # in its NAME (directories included), so it does not exist in a base render.
55
+ # Never name one a bare `.gitignore`/`.npmrc`: npm pack drops those from the
56
+ # published pjangler tarball.
57
+ #
58
+ # A real question on purpose, not `when: false`. Copier records only asked
59
+ # questions in .copier-answers.yml; a hidden type would be forgotten, and a
60
+ # re-copy would silently re-render the shared files as `base`. `--defaults`
61
+ # never prompts for it, and pjangler always passes it with --data.
62
+ project_type:
63
+ type: str
64
+ help: What kind of project is this?
65
+ default: base
66
+ choices:
67
+ CommonProject base: base
68
+ Obsidian community plugin: obsidian-plugin
69
+
46
70
  # --- Derived (never asked) ---
47
71
 
48
72
  project_slug:
@@ -50,6 +74,70 @@ project_slug:
50
74
  default: "{{ project_name | lower | replace(' ', '-') | replace('_', '-') }}"
51
75
  when: false
52
76
 
77
+ is_obsidian_plugin:
78
+ type: bool
79
+ default: "{{ project_type == 'obsidian-plugin' }}"
80
+ when: false
81
+
82
+ # Obsidian manifest identity (obsidian-plugin only). The catalog rejects a
83
+ # plugin id or name containing "obsidian" and frowns on a trailing "plugin",
84
+ # and the id must equal the plugin's folder name in a vault, so both are the
85
+ # project's own identity with those affixes dropped:
86
+ # "obsidian-tag-wrangler" -> id "tag-wrangler".
87
+ plugin_id:
88
+ type: str
89
+ default: >-
90
+ {%- set id = project_slug | lower | regex_replace('[^a-z0-9-]+', '-') | replace('obsidian', '') | regex_replace('-+', '-') -%}
91
+ {%- set id = id.strip('-') -%}
92
+ {%- if id.endswith('-plugin') %}{% set id = id[:-7] %}{% endif -%}
93
+ {{ id or 'plugin' }}
94
+ when: false
95
+
96
+ # A slug-style project name ("obsidian-tag-wrangler") reads as words in the
97
+ # Obsidian UI: separators become spaces and an all-lowercase name is
98
+ # title-cased ("Tag Wrangler"); a name with its own casing keeps it.
99
+ plugin_name:
100
+ type: str
101
+ default: >-
102
+ {%- set name = project_name | regex_replace('(?i)obsidian', ' ') | regex_replace('[-_:.]+', ' ') | regex_replace('\\s+', ' ') | trim -%}
103
+ {%- set name = name | regex_replace('(?i)\\s*\\bplugin$', '') | trim -%}
104
+ {%- if name == name | lower %}{% set name = name | title %}{% endif -%}
105
+ {{ name or project_name }}
106
+ when: false
107
+
108
+ # TypeScript identifier stem for src/: "tag-wrangler" -> TagWranglerPlugin,
109
+ # TagWranglerSettings, TagWranglerSettingTab, TagWranglerModal. The starter's
110
+ # own names (MyPlugin, SampleModal, ...) fail eslint-plugin-obsidianmd's
111
+ # sample-names rule, and a "Plugin" stem would declare PluginSettingTab over
112
+ # Obsidian's own, so those stems get a "Project" suffix.
113
+ plugin_class:
114
+ type: str
115
+ default: >-
116
+ {%- set ns = namespace(name='') -%}
117
+ {%- for word in plugin_id.replace('_', '-').replace('.', '-').split('-') -%}
118
+ {%- set ns.name = ns.name ~ (word | capitalize) -%}
119
+ {%- endfor -%}
120
+ {%- if not ns.name or ns.name[0] in '0123456789' %}{% set ns.name = 'Plugin' ~ ns.name %}{% endif -%}
121
+ {%- if ns.name in ['My', 'Sample', 'Plugin'] %}{% set ns.name = ns.name ~ 'Project' %}{% endif -%}
122
+ {{ ns.name }}
123
+ when: false
124
+
125
+ # The catalog wants a sentence: it must end in punctuation.
126
+ plugin_description:
127
+ type: str
128
+ default: >-
129
+ {%- set text = project_description | trim -%}
130
+ {%- if text and text[-1] not in '.?!)' %}{% set text = text ~ '.' %}{% endif -%}
131
+ {{ text or 'Describe what this plugin does.' }}
132
+ when: false
133
+
134
+ # pjangler passes the operator's git user.name; a bare copier render leaves it
135
+ # for the author to fill in.
136
+ plugin_author:
137
+ type: str
138
+ default: ""
139
+ when: false
140
+
53
141
  # Board binding — supplied by init-project.sh's --data-file after the board is
54
142
  # created. Defaults keep a bare `copier copy` rendering valid (empty binding).
55
143
  plane_workspace:
@@ -111,6 +199,19 @@ _skip_if_exists:
111
199
  # Selection is project intent, not generated output. A refresh must preserve
112
200
  # inheritance choices, exclusions, and active packs byte-for-byte.
113
201
  - ".agents/skills.json"
202
+ # obsidian-plugin seed files: rendered once at creation, owned by the plugin
203
+ # after that. A forced re-render must not reset a bumped manifest version or
204
+ # replace real plugin code with the sample. (Paths a base render never
205
+ # produces, so this is inert for base projects.)
206
+ - "package.json"
207
+ - "package-lock.json"
208
+ - "manifest.json"
209
+ - "versions.json"
210
+ - "styles.css"
211
+ - "tsconfig.json"
212
+ - "esbuild.config.mjs"
213
+ - "eslint.config.mts"
214
+ - "src/**"
114
215
 
115
216
  # --- Post-generation tasks ---
116
217
  #
@@ -124,7 +225,7 @@ _tasks:
124
225
  # byte and append only the small CommonProject-owned contract when absent.
125
226
  # Machine-local backup/editor/CLI exclusions belong in the user's global
126
227
  # ignore; `.agents/` is the sole canonical agent-config tree in Git.
127
- - "touch .gitignore; if [ -s .gitignore ] && [ -n \"$(tail -c 1 .gitignore)\" ]; then printf '\\n' >> .gitignore; fi; grep -Fqx '# CommonProject repository contract' .gitignore 2>/dev/null || printf '%s\\n' '# CommonProject repository contract' >> .gitignore; for pattern in '.env' '.env.*' '!.env.op' '/.agents/local.json' '/.agents/skills'; do grep -Fqx -- \"$pattern\" .gitignore 2>/dev/null || printf '%s\\n' \"$pattern\" >> .gitignore; done"
228
+ - "touch .gitignore; if [ -s .gitignore ] && [ -n \"$(tail -c 1 .gitignore)\" ]; then printf '\\n' >> .gitignore; fi; grep -Fqx '# CommonProject repository contract' .gitignore 2>/dev/null || printf '%s\\n' '# CommonProject repository contract' >> .gitignore; for pattern in '.env' '.env.*' '!.env.op' '/.agents/local.json' '/.agents/skills'{% if is_obsidian_plugin %} 'node_modules/' '/main.js' '*.map' '/data.json'{% endif %}; do grep -Fqx -- \"$pattern\" .gitignore 2>/dev/null || printf '%s\\n' \"$pattern\" >> .gitignore; done"
128
229
  # PJAN-82: use the managed script, which refuses to replace a REAL CLAUDE.md.
129
230
  # `ln -sf` cannot distinguish a stale link it owns from a hand-written file it
130
231
  # must not touch, so a re-render into a live project destroyed the file and
@@ -0,0 +1,29 @@
1
+ # Build + lint the Obsidian plugin on every push and pull request.
2
+ #
3
+ # Adapted from obsidianmd/obsidian-sample-plugin's lint.yml. The one deliberate
4
+ # change is the runner: self-hosted on big-chungus, never a GitHub-hosted label.
5
+ # Hosted minutes are billed on private repos and the budget is $0. A
6
+ # personal-account repo needs its own repo-level runner (delonet-conventions
7
+ # skill, references/ci-runners.md).
8
+ name: Plugin build
9
+
10
+ on:
11
+ push:
12
+ branches: ['**']
13
+ pull_request:
14
+ branches: ['**']
15
+
16
+ jobs:
17
+ build:
18
+ runs-on: [self-hosted, Linux, delonet]
19
+
20
+ steps:
21
+ - uses: actions/checkout@v6
22
+ - name: Use Node.js 24
23
+ uses: actions/setup-node@v6
24
+ with:
25
+ node-version: 24
26
+ cache: 'npm'
27
+ - run: npm ci
28
+ - run: npm run build --if-present
29
+ - run: npm run lint
@@ -0,0 +1,62 @@
1
+ # Draft a GitHub release for every pushed tag.
2
+ #
3
+ # Adapted from obsidianmd/obsidian-sample-plugin's release.yml. Changes:
4
+ # - self-hosted runner (see lint.yml for why);
5
+ # - build provenance is attested only on public repos. GitHub refuses
6
+ # artifact attestations on user-owned private repos, and an Obsidian plugin
7
+ # only has to be public once it is submitted to the community catalog.
8
+ #
9
+ # Tag with the bare manifest version (`npm version patch` does this — .npmrc
10
+ # sets tag-version-prefix=""), never a leading `v`: Obsidian matches the release
11
+ # tag against manifest.json's version.
12
+ name: Release Obsidian plugin
13
+
14
+ on:
15
+ push:
16
+ tags:
17
+ - '*'
18
+
19
+ jobs:
20
+ build:
21
+ runs-on: [self-hosted, Linux, delonet]
22
+ permissions:
23
+ contents: write
24
+ id-token: write
25
+ attestations: write
26
+ steps:
27
+ - uses: actions/checkout@v6
28
+
29
+ - name: Use Node.js
30
+ uses: actions/setup-node@v6
31
+ with:
32
+ node-version: 24
33
+ cache: 'npm'
34
+
35
+ - name: Build plugin
36
+ run: |
37
+ npm ci
38
+ npm run build
39
+
40
+ - name: Check for optional styles
41
+ id: styles
42
+ run: |
43
+ [ -f styles.css ] && echo "exists=true" >> "$GITHUB_OUTPUT" || echo "exists=false" >> "$GITHUB_OUTPUT"
44
+
45
+ - name: Attest build provenance
46
+ if: ${{ !github.event.repository.private }}
47
+ uses: actions/attest@v4
48
+ with:
49
+ subject-path: |
50
+ main.js
51
+ ${{ steps.styles.outputs.exists == 'true' && 'styles.css' || '' }}
52
+
53
+ - name: Create release
54
+ env:
55
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
56
+ run: |
57
+ tag="${GITHUB_REF#refs/tags/}"
58
+
59
+ gh release create "$tag" \
60
+ --title="$tag" \
61
+ --draft \
62
+ main.js manifest.json ${{ steps.styles.outputs.exists == 'true' && 'styles.css' || '' }}