@delorenj/pjangler 1.5.10 → 1.5.12

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 +150 -27
  4. package/dist/mcp-server.js +134 -20
  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];
@@ -8884,6 +8973,15 @@ function createMiseChecks() {
8884
8973
  if (!text?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
8885
8974
  if (!existsSync3(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
8886
8975
  if (!existsSync3(manifestPath)) details.push(".mise/version-files.conf missing");
8976
+ const conf = safeReadText2(manifestPath);
8977
+ const script = safeReadText2(versioningPath);
8978
+ if (conf !== null && script !== null) {
8979
+ const unsupported = unsupportedVersionTypes(conf, script);
8980
+ if (unsupported.length) details.push(`.mise/scripts/versioning.sh cannot version type(s) ${unsupported.join(", ")} named in .mise/version-files.conf`);
8981
+ }
8982
+ if (conf !== null && isObsidianPluginRepo(ctx, ctx.repoRoot) && !/^obsidian\s+manifest\.json\s*$/m.test(conf)) {
8983
+ details.push(".mise/version-files.conf does not version manifest.json/versions.json for this obsidian-plugin project");
8984
+ }
8887
8985
  return {
8888
8986
  id: "mise.versioning",
8889
8987
  title: "managed mise versioning block",
@@ -9425,6 +9523,15 @@ function strayOpInjectLines(text) {
9425
9523
  }
9426
9524
  return [...new Set(lines)].sort((a, b) => a - b);
9427
9525
  }
9526
+ var COPIER_ANSWERS_HEADER = "# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY";
9527
+ function parseCopierAnswers(text) {
9528
+ try {
9529
+ const parsed = YAML2.parse(text);
9530
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { answers: parsed } : { problem: ".copier-answers.yml is not a YAML mapping" };
9531
+ } catch (error) {
9532
+ return { problem: `.copier-answers.yml is not valid YAML (${errorText(error).split("\n")[0]})` };
9533
+ }
9534
+ }
9428
9535
  function createProjectProvenanceChecks() {
9429
9536
  return [
9430
9537
  {
@@ -9438,16 +9545,14 @@ function createProjectProvenanceChecks() {
9438
9545
  if (!text) {
9439
9546
  details.push(".copier-answers.yml missing");
9440
9547
  } 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");
9548
+ if (!text.startsWith(COPIER_ANSWERS_HEADER)) details.push("missing Copier overwrite warning header");
9549
+ const parsed = parseCopierAnswers(text);
9550
+ if ("problem" in parsed) details.push(parsed.problem);
9551
+ else {
9552
+ const { answers } = parsed;
9553
+ if (answers._src_path === void 0) details.push("_src_path missing");
9554
+ if (project?.project_name && String(answers.project_name ?? "") !== String(project.project_name)) details.push("project_name drift between .copier-answers.yml and .project.json");
9555
+ 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
9556
  }
9452
9557
  }
9453
9558
  return {
@@ -9462,14 +9567,18 @@ function createProjectProvenanceChecks() {
9462
9567
  migrate: (ctx, finding2) => {
9463
9568
  const changedFiles = [];
9464
9569
  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
9570
  const path = join5(ctx.repoRoot, ".copier-answers.yml");
9472
- if (safeReadText2(path) !== text) {
9571
+ const current = safeReadText2(path);
9572
+ const existing = current ? YAML2.parseDocument(current) : void 0;
9573
+ const document = existing && !existing.errors.length && YAML2.isMap(existing.contents) ? existing : new YAML2.Document({});
9574
+ document.set("_src_path", join5(ctx.pjanglerRoot, "templates", "commonproject"));
9575
+ document.set("project_description", String(project.project_description));
9576
+ document.set("project_name", String(project.project_name));
9577
+ document.set("ticket_provider", String(project.ticket_provider?.type ?? "plane"));
9578
+ const body = String(document);
9579
+ const text = body.startsWith(COPIER_ANSWERS_HEADER) ? body : `${COPIER_ANSWERS_HEADER}
9580
+ ${body}`;
9581
+ if (current !== text) {
9473
9582
  changedFiles.push(path);
9474
9583
  if (!ctx.dryRun) writeText(path, text);
9475
9584
  }
@@ -14427,6 +14536,7 @@ var server = new McpServer({
14427
14536
  version: PJANGLER_VERSION
14428
14537
  });
14429
14538
  var TICKET_PROVIDER_SCHEMA = z.enum(["plane", "trello"]);
14539
+ 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
14540
  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
14541
  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
14542
  function safePathSegmentSchema(label) {
@@ -14748,6 +14858,7 @@ server.registerTool(
14748
14858
  planeProjectId: z.string().optional(),
14749
14859
  projectIdentifier: z.string().optional(),
14750
14860
  primaryLanguage: z.string().optional(),
14861
+ projectType: PROJECT_TYPE_SCHEMA.optional(),
14751
14862
  skipPlane: z.boolean().optional(),
14752
14863
  agentPurpose: z.string().optional(),
14753
14864
  local: z.boolean().optional(),
@@ -14792,7 +14903,8 @@ server.registerTool(
14792
14903
  targetDir,
14793
14904
  projectSlug,
14794
14905
  sourceSkill: input.sourceSkill,
14795
- primaryLanguage: input.primaryLanguage ?? "python",
14906
+ primaryLanguage: input.primaryLanguage,
14907
+ projectType: input.projectType,
14796
14908
  apply: !dryRun,
14797
14909
  live: input.live ?? false,
14798
14910
  provisionTicketBoard: externalEffects.ticketBoard,
@@ -14857,6 +14969,7 @@ server.registerTool(
14857
14969
  targetDir: z.string().optional(),
14858
14970
  sourceSkill: z.string().optional(),
14859
14971
  primaryLanguage: z.string().optional(),
14972
+ projectType: PROJECT_TYPE_SCHEMA.optional(),
14860
14973
  apply: z.boolean().optional(),
14861
14974
  live: z.boolean().optional(),
14862
14975
  provisionTicketBoard: z.boolean().optional().describe("Explicitly opt in to ticket-board provisioning; also requires live=true and skipPlane!=true."),
@@ -14880,6 +14993,7 @@ server.registerTool(
14880
14993
  targetDir: input.targetDir,
14881
14994
  sourceSkill: input.sourceSkill,
14882
14995
  primaryLanguage: input.primaryLanguage,
14996
+ projectType: input.projectType,
14883
14997
  apply: input.apply ?? false,
14884
14998
  live: input.live ?? false,
14885
14999
  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.10",
3
+ "version": "1.5.12",
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' || '' }}
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ #MISE description="Symlink this plugin into an Obsidian vault: plugin:link [vault] (default: $OBSIDIAN_VAULT)"
3
+ #MISE tools={node="24"}
4
+ # Obsidian loads a plugin from <vault>/.obsidian/plugins/<manifest id>/, reading
5
+ # main.js, manifest.json and styles.css. Linking the repo there means every
6
+ # `npm run dev` rebuild is live after a plugin reload (or instantly with the
7
+ # Hot-Reload community plugin). Replaces only a symlink it can recognise as a
8
+ # link; a real directory at the destination is left alone.
9
+ set -euo pipefail
10
+
11
+ root="${MISE_CONFIG_ROOT:-$(pwd)}"
12
+ vault="${1:-${OBSIDIAN_VAULT:-}}"
13
+ [ -n "$vault" ] || { echo "plugin:link: pass the vault path or set OBSIDIAN_VAULT" >&2; exit 2; }
14
+ [ -d "$vault/.obsidian" ] || { echo "plugin:link: $vault is not an Obsidian vault (no .obsidian/)" >&2; exit 2; }
15
+
16
+ id="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).id' "$root/manifest.json")"
17
+ [ -n "$id" ] || { echo "plugin:link: manifest.json has no id" >&2; exit 1; }
18
+
19
+ dest="$vault/.obsidian/plugins/$id"
20
+ if [ -e "$dest" ] && [ ! -L "$dest" ]; then
21
+ echo "plugin:link: $dest already exists and is not a symlink; remove it first" >&2
22
+ exit 1
23
+ fi
24
+ mkdir -p "$vault/.obsidian/plugins"
25
+ ln -sfn "$root" "$dest"
26
+ echo "linked $dest -> $root"
27
+ [ -f "$root/main.js" ] || echo "plugin:link: no main.js yet; run npm run dev (or npm run build)" >&2
@@ -5,3 +5,70 @@
5
5
  ## Overview
6
6
 
7
7
  __TODO__
8
+ {%- if is_obsidian_plugin %}
9
+
10
+ ## Obsidian community plugin
11
+
12
+ This project is an Obsidian community plugin, scaffolded from the canonical
13
+ [obsidian-sample-plugin](https://github.com/obsidianmd/obsidian-sample-plugin).
14
+ TypeScript in `src/` is bundled by esbuild into `main.js`, which Obsidian loads.
15
+
16
+ - Package manager: **npm** (`package.json` scripts, `package-lock.json`). Never bun or pnpm.
17
+ - Bundler: **esbuild** (`esbuild.config.mjs`). Types come from the `obsidian` package.
18
+ - Release artifacts: `main.js`, `manifest.json`, and `styles.css` (optional).
19
+
20
+ ### Commands
21
+
22
+ ```bash
23
+ npm install # dev dependencies, pinned by the committed package-lock.json
24
+ npm run dev # watch build -> main.js (inline sourcemaps)
25
+ npm run build # type-check, then production build
26
+ npm run lint # eslint with eslint-plugin-obsidianmd
27
+ mise run plugin:link # symlink this repo into $OBSIDIAN_VAULT/.obsidian/plugins/<id>
28
+ ```
29
+
30
+ Reload Obsidian (or use the Hot-Reload plugin) and enable the plugin under
31
+ **Settings → Community plugins**.
32
+
33
+ ### Layout
34
+
35
+ - `src/main.ts` stays small: plugin lifecycle (`onload`/`onunload`) and registration only.
36
+ - Feature logic goes in focused modules under `src/` (`settings.ts`, `commands/`, `ui/`, `utils/`).
37
+ - Never commit `node_modules/`, `main.js`, `*.map`, or the vault's `data.json`.
38
+ - Bundle every runtime dependency into `main.js`; keep dependencies small and browser-compatible.
39
+
40
+ ### Manifest rules (`manifest.json`)
41
+
42
+ - `id` is stable API: never change it after release. It must not contain "obsidian",
43
+ and for local development it must match the plugin folder name in the vault.
44
+ - `version` is SemVer `x.y.z`; keep `minAppVersion` accurate when you use newer APIs.
45
+ - `isDesktopOnly` must be `true` if you use Node or Electron APIs.
46
+ - Canonical checks: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml
47
+
48
+ ### Versioning and releases
49
+
50
+ - `npm version patch|minor|major` is the release step. It bumps `package.json`, and
51
+ `version-bump.mjs` syncs `manifest.json` and `versions.json` (plugin version → minimum
52
+ app version). It commits, then tags the bare version: no leading `v`, because `.npmrc`
53
+ sets `tag-version-prefix=""`.
54
+ - `mise run version:bump` keeps the same three files in step without committing or tagging.
55
+ - Push the tag. `.github/workflows/release.yml` drafts the GitHub release with `main.js`,
56
+ `manifest.json` and `styles.css` attached.
57
+ - The community catalog requires a public repo and a LICENSE; pick one before submitting.
58
+
59
+ ### Plugin conventions
60
+
61
+ - Add commands with `this.addCommand(...)` and stable IDs; never rename a released ID.
62
+ - Persist settings with `this.loadData()` / `this.saveData()`, with defaults in `settings.ts`.
63
+ - Register every DOM event, workspace event, and interval through `this.register*`,
64
+ so the plugin unloads without leaking listeners.
65
+ - Keep `onload` light: defer heavy work, debounce vault events, batch disk access.
66
+ - Stay inside the vault, make no network calls without a user-visible reason, and never
67
+ fetch or eval remote code. These are Obsidian's developer policies, and review enforces them.
68
+ - UI copy uses sentence case, short imperative labels, and **Settings → Community plugins**
69
+ style arrow navigation.
70
+
71
+ References: [API docs](https://docs.obsidian.md) ·
72
+ [Plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines) ·
73
+ [Developer policies](https://docs.obsidian.md/Developer+policies)
74
+ {%- endif %}