@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
@@ -16,10 +16,12 @@
16
16
  # versioning.sh files List the manifest
17
17
  #
18
18
  # Manifest: .mise/version-files.conf — lines of "<type> <path>" (see file-types).
19
- # types: json toml cargo csproj gradle plain gittag
19
+ # types: json toml cargo csproj gradle plain gittag obsidian
20
20
  #
21
21
  # Storage format per type:
22
22
  # json/toml/cargo/csproj/gradle/plain -> bare "X.Y.Z"
23
+ # obsidian (manifest.json) -> bare "X.Y.Z", plus the sibling
24
+ # versions.json gains X.Y.Z -> minAppVersion
23
25
  # gittag -> tag "vX.Y.Z"
24
26
  # `current` always prints with the leading "v".
25
27
 
@@ -85,12 +87,21 @@ manifest_entries() {
85
87
  done <"$MANIFEST"
86
88
  }
87
89
 
90
+ # Rewrite a JSON file through jq, keeping its indentation: tab-indented files
91
+ # (Obsidian's manifest.json/versions.json/package.json) stay tab-indented.
92
+ jq_rewrite() { # $1=path, remaining args go to jq
93
+ local path="$1" tmp indent=(--indent 2); shift
94
+ grep -q $'^\t' "$path" && indent=(--tab)
95
+ tmp="$(mktemp)"
96
+ jq "${indent[@]}" "$@" "$path" >"$tmp" && mv "$tmp" "$path"
97
+ }
98
+
88
99
  # ---- per-type read -----------------------------------------------------------
89
100
 
90
101
  read_version() { # $1=type $2=path -> bare X.Y.Z on stdout, or nothing
91
102
  local type="$1" path="$2" raw=""
92
103
  case "$type" in
93
- json)
104
+ json|obsidian)
94
105
  [[ -f "$path" ]] || return 0
95
106
  raw="$(jq -r '.version // empty' "$path" 2>/dev/null || true)" ;;
96
107
  toml|cargo)
@@ -119,12 +130,22 @@ read_version() { # $1=type $2=path -> bare X.Y.Z on stdout, or nothing
119
130
  # ---- per-type write ----------------------------------------------------------
120
131
 
121
132
  write_version() { # $1=type $2=path $3=bare-new-version
122
- local type="$1" path="$2" new="$3" tmp
133
+ local type="$1" path="$2" new="$3"
123
134
  case "$type" in
124
135
  json)
125
136
  [[ -f "$path" ]] || return 0
126
- tmp="$(mktemp)"
127
- jq --indent 2 --arg v "$new" '.version = $v' "$path" >"$tmp" && mv "$tmp" "$path" ;;
137
+ jq_rewrite "$path" --arg v "$new" '.version = $v' ;;
138
+ obsidian)
139
+ # What the sample plugin's version-bump.mjs does: set the manifest
140
+ # version, then record new -> minAppVersion in versions.json (once).
141
+ [[ -f "$path" ]] || return 0
142
+ jq_rewrite "$path" --arg v "$new" '.version = $v'
143
+ local versions min
144
+ versions="$(dirname "$path")/versions.json"
145
+ min="$(jq -r '.minAppVersion // empty' "$path")"
146
+ if [[ -f "$versions" && -n "$min" ]]; then
147
+ jq_rewrite "$versions" --arg v "$new" --arg m "$min" 'if has($v) then . else .[$v] = $m end'
148
+ fi ;;
128
149
  toml|cargo)
129
150
  [[ -f "$path" ]] || return 0
130
151
  # Replace only the first top-level `version = "..."` line.
package/README.md CHANGED
@@ -76,6 +76,31 @@ instead of reading a clean report over nothing.
76
76
  live catalog. The rules are owned by the recipes in `src/recipes/`, not by a
77
77
  central table, so that catalog is the only authority on which ones exist.
78
78
 
79
+ ## Obsidian plugins
80
+
81
+ `pjangler init <name> --obsidian-plugin` does everything a normal init does
82
+ (registry, `.project.json`, board, BMAD, skills, hooks, mise, the init commit).
83
+ The difference is the CommonProject render: it is the variant built on the
84
+ canonical [obsidian-sample-plugin](https://github.com/obsidianmd/obsidian-sample-plugin).
85
+ That means `manifest.json`, `versions.json`, npm + esbuild + TypeScript in `src/`,
86
+ `eslint-plugin-obsidianmd`, and build/release workflows on the self-hosted runners.
87
+
88
+ ```bash
89
+ pjangler init tag-wrangler --obsidian-plugin --description "Wrangle tags across a vault"
90
+ cd tag-wrangler && npm install && npm run dev
91
+ mise run plugin:link ~/Vaults/Main # symlink into <vault>/.obsidian/plugins/<id>
92
+ ```
93
+
94
+ - The type is recorded as `template.commonproject.project_type` and in
95
+ `.copier-answers.yml`. It is fixed at creation, and re-running `init` keeps it.
96
+ - On an existing repo, the flag only records the type, and only when the repo
97
+ already has an Obsidian `manifest.json`.
98
+ - `mise run version:bump` keeps `package.json`, `manifest.json` and `versions.json`
99
+ in step. It never tags. Release with `npm version patch|minor|major`, which
100
+ commits and then tags a bare `X.Y.Z` (Obsidian matches that), and push the tag.
101
+ - MCP takes the same choice as `projectType: "obsidian-plugin"` on
102
+ `pjangler_project_init` and `pjangler_bootstrap_33god_project`.
103
+
79
104
  ## Agents
80
105
 
81
106
  pjangler does not hire, provision, or inspect agents. That is Flume's surface —
package/dist/index.js CHANGED
@@ -968,6 +968,38 @@ import { homedir as homedir5, tmpdir } from "node:os";
968
968
  import { basename as basename7, delimiter as delimiter2, dirname as dirname11, isAbsolute as isAbsolute5, join as join12, relative as relative7, resolve as resolve10, sep as sep2, win32 } from "node:path";
969
969
  import { fileURLToPath as fileURLToPath2 } from "node:url";
970
970
  import YAML4 from "yaml";
971
+ function normalizeProjectType(value, label = "Project type") {
972
+ const text2 = typeof value === "string" ? value.trim().toLowerCase() : "";
973
+ if (PROJECT_TYPES.includes(text2)) return text2;
974
+ throw new Error(`${label} ${JSON.stringify(value)} is not one of: ${PROJECT_TYPES.join(", ")}`);
975
+ }
976
+ function looksLikeObsidianPlugin(dir) {
977
+ try {
978
+ const manifest = JSON.parse(readFileSync10(join12(dir, "manifest.json"), "utf8"));
979
+ return isRecord3(manifest) && typeof manifest.id === "string" && typeof manifest.minAppVersion === "string";
980
+ } catch {
981
+ return false;
982
+ }
983
+ }
984
+ function assertTemplateOffersProjectType(pjanglerRoot, projectType) {
985
+ if (projectType === "base") return;
986
+ const copierYml = join12(pjanglerRoot, "templates", "commonproject", "copier.yml");
987
+ let choices;
988
+ try {
989
+ const parsed = YAML4.parse(readFileSync10(copierYml, "utf8"));
990
+ choices = isRecord3(parsed) && isRecord3(parsed.project_type) ? parsed.project_type.choices : void 0;
991
+ } catch {
992
+ choices = void 0;
993
+ }
994
+ const offered = isRecord3(choices) ? Object.values(choices) : Array.isArray(choices) ? choices : [];
995
+ if (!offered.includes(projectType)) {
996
+ throw new Error(`The CommonProject template at ${dirname11(copierYml)} cannot render project type "${projectType}" (its copier.yml offers no such project_type); update pjangler or its templates/commonproject pin`);
997
+ }
998
+ }
999
+ function operatorDisplayName(cwd) {
1000
+ const result2 = spawnSync5("git", ["config", "--get", "user.name"], { cwd: existsSync7(cwd) ? cwd : void 0, encoding: "utf8", timeout: 5e3 });
1001
+ return result2.status === 0 ? result2.stdout.trim() : "";
1002
+ }
971
1003
  function providerAssignsIdentifiers(type) {
972
1004
  return IDENTIFIER_ASSIGNING_PROVIDERS.includes((type ?? "").trim());
973
1005
  }
@@ -1560,6 +1592,26 @@ function planProjectInit(input) {
1560
1592
  const overwrite = input.overwrite ?? input.force ?? false;
1561
1593
  const agents = createSafeRecord(Object.entries(existing?.agents ?? {}));
1562
1594
  const scaffold = input.scaffold ?? true;
1595
+ const recordedTypeValue = existing?.template?.commonproject?.project_type;
1596
+ const recordedType = recordedTypeValue === void 0 ? void 0 : normalizeProjectType(recordedTypeValue, `Recorded project type of ${slug}`);
1597
+ const requestedType = input.projectType === void 0 ? void 0 : normalizeProjectType(input.projectType);
1598
+ const freshRender = scaffold && localManifest === void 0 && !existsSync7(join12(targetDir, ".copier-answers.yml"));
1599
+ if (requestedType && !freshRender && requestedType !== (recordedType ?? "base")) {
1600
+ const adoptingPlugin = !scaffold && recordedType === void 0 && requestedType === "obsidian-plugin" && looksLikeObsidianPlugin(targetDir);
1601
+ if (!adoptingPlugin) {
1602
+ 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)`);
1603
+ }
1604
+ }
1605
+ const projectType = requestedType ?? recordedType ?? "base";
1606
+ const typeLanguages = PROJECT_TYPE_LANGUAGES[projectType];
1607
+ if (input.primaryLanguage !== void 0 && typeLanguages && !typeLanguages.includes(input.primaryLanguage)) {
1608
+ throw new Error(`A ${projectType} project is written in ${typeLanguages.join(" or ")}, not ${input.primaryLanguage}`);
1609
+ }
1610
+ const recordedLanguageValue = (localManifest ? localManifest.template : indexed?.template)?.commonproject?.primary_language;
1611
+ const recordedLanguage = typeof recordedLanguageValue === "string" && recordedLanguageValue ? recordedLanguageValue : void 0;
1612
+ const newlyTyped = requestedType !== void 0 && requestedType !== recordedType;
1613
+ const recordedLanguageFits = !typeLanguages || recordedLanguage !== void 0 && typeLanguages.includes(recordedLanguage);
1614
+ const primaryLanguage = input.primaryLanguage ?? (newlyTyped && !recordedLanguageFits ? typeLanguages?.[0] : void 0) ?? recordedLanguage ?? typeLanguages?.[0] ?? "python";
1563
1615
  const candidateProject = {
1564
1616
  ...existing ?? {},
1565
1617
  name: input.name,
@@ -1581,7 +1633,10 @@ function planProjectInit(input) {
1581
1633
  commonproject: {
1582
1634
  ...existing?.template?.commonproject,
1583
1635
  enabled: true,
1584
- primary_language: input.primaryLanguage ?? existing?.template?.commonproject?.primary_language ?? "python"
1636
+ primary_language: primaryLanguage,
1637
+ // Written only for a non-base type (or one already recorded), so a
1638
+ // base record never gains a key and stays equivalent to its last run.
1639
+ ...projectType !== "base" || recordedType !== void 0 ? { project_type: projectType } : {}
1585
1640
  }
1586
1641
  },
1587
1642
  ticket_provider: { ...existing?.ticket_provider, ...buildTicketProviderBlock({
@@ -1617,6 +1672,7 @@ function planProjectInit(input) {
1617
1672
  { kind: "registry.upsert", registryPath, slug, project }
1618
1673
  ];
1619
1674
  if (scaffold) {
1675
+ assertTemplateOffersProjectType(pjanglerRoot, projectType);
1620
1676
  actions.push(buildCommonProjectCopierAction({
1621
1677
  pjanglerRoot,
1622
1678
  targetDir,
@@ -1630,6 +1686,8 @@ function planProjectInit(input) {
1630
1686
  boardId: project.ticket_provider.board_id ?? "",
1631
1687
  projectIdentifier: identifier,
1632
1688
  primaryLanguage: project.template.commonproject.primary_language,
1689
+ projectType,
1690
+ ...projectType === "obsidian-plugin" ? { pluginAuthor: operatorDisplayName(input.cwd ?? process.cwd()) } : {},
1633
1691
  agentHooksLayer: resolveAgentHooksLayer2(input.agentHooksLayer),
1634
1692
  overwrite
1635
1693
  }));
@@ -1768,6 +1826,13 @@ async function executeProjectInitPlan(plan, options = {}) {
1768
1826
  errors.push(`copier exited with status ${result2.status ?? "unknown"}`);
1769
1827
  break;
1770
1828
  }
1829
+ const projectType = action.data.project_type ?? "base";
1830
+ const missing = (PROJECT_TYPE_SENTINELS[projectType] ?? []).filter((rel) => !existsSync7(join12(action.targetDir, rel)));
1831
+ if (missing.length) {
1832
+ errors.push(`copier rendered no ${projectType} project: ${missing.join(", ")} missing from ${action.targetDir}`);
1833
+ break;
1834
+ }
1835
+ if (projectType !== "base") logs.push(`commonproject: rendered as ${projectType}`);
1771
1836
  } else if (action.kind === "project.write-manifest") {
1772
1837
  if (action.expectedContent !== void 0 && (!existsSync7(action.path) || readFileSync10(action.path, "utf8") !== action.expectedContent)) {
1773
1838
  errors.push(`Manifest changed after planning: ${action.path}; re-plan before applying`);
@@ -1887,13 +1952,18 @@ function formatProjectInitPlan(plan) {
1887
1952
  lines.push(` ${cyan(bold(glyph.chevron))} ${title}${plan.dryRun ? ` ${dim(glyph.dot)} ${yellow("dry run")}` : ""}`);
1888
1953
  lines.push(` ${dim("registry".padEnd(8))} ${dim(plan.registryPath)}`);
1889
1954
  lines.push(` ${dim("target".padEnd(8))} ${dim(plan.project.repo_path)}`);
1955
+ const projectType = plan.project.template?.commonproject?.project_type;
1956
+ if (projectType && projectType !== "base") lines.push(` ${dim("type".padEnd(8))} ${cyan(projectType)}`);
1890
1957
  for (const warning of plan.warnings ?? []) lines.push(` ${yellow(glyph.warn)} ${warning}`);
1891
1958
  lines.push("");
1892
1959
  lines.push(` ${bold("Actions")} ${dim(`(${plan.actions.length})`)}`);
1893
1960
  if (!plan.actions.length) lines.push(` ${dim("(nothing to do)")}`);
1894
1961
  for (const action of plan.actions) {
1895
1962
  lines.push(` ${cyan(glyph.bullet)} ${action.kind}`);
1896
- if (action.kind === "copier.copy.commonproject") lines.push(` ${dim(`target: ${action.targetDir}`)}`);
1963
+ if (action.kind === "copier.copy.commonproject") {
1964
+ lines.push(` ${dim(`target: ${action.targetDir}`)}`);
1965
+ if (action.data.project_type && action.data.project_type !== "base") lines.push(` ${dim(`type: ${action.data.project_type}`)}`);
1966
+ }
1897
1967
  if (action.kind === "project.write-manifest") lines.push(` ${dim(`path: ${action.path}`)}`);
1898
1968
  if (action.kind === "ticket-provider.create-or-link") {
1899
1969
  const target = [action.provider, action.workspace, action.identifier].filter(Boolean).join("/");
@@ -2014,6 +2084,8 @@ function buildCommonProjectCopierAction(input) {
2014
2084
  board_id: input.boardId ?? input.planeProjectId ?? "",
2015
2085
  project_identifier: input.projectIdentifier,
2016
2086
  primary_language: input.primaryLanguage,
2087
+ project_type: input.projectType ?? "base",
2088
+ ...input.pluginAuthor !== void 0 ? { plugin_author: input.pluginAuthor } : {},
2017
2089
  agent_hooks_layer: input.agentHooksLayer ?? true ? "true" : "false"
2018
2090
  };
2019
2091
  const command = ["copier", "copy", "--trust", "--vcs-ref=HEAD", templateDir, input.targetDir, "--defaults"];
@@ -2114,7 +2186,7 @@ function expandHome(path) {
2114
2186
  function isRecord3(value) {
2115
2187
  return typeof value === "object" && value !== null && !Array.isArray(value);
2116
2188
  }
2117
- 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;
2189
+ 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;
2118
2190
  var init_project = __esm({
2119
2191
  "src/project/index.ts"() {
2120
2192
  "use strict";
@@ -2130,6 +2202,13 @@ var init_project = __esm({
2130
2202
  TICKET_PROVIDER_ADAPTERS_ENV = "PJ_TICKET_PROVIDER_ADAPTERS";
2131
2203
  PROJECT_REGISTRY_SCHEMA_VERSION = 1;
2132
2204
  DEFAULT_NEW_PROJECT_STATUS = "active";
2205
+ PROJECT_TYPES = ["base", "obsidian-plugin"];
2206
+ PROJECT_TYPE_LANGUAGES = {
2207
+ "obsidian-plugin": ["typescript", "javascript"]
2208
+ };
2209
+ PROJECT_TYPE_SENTINELS = {
2210
+ "obsidian-plugin": ["manifest.json", join12("src", "main.ts")]
2211
+ };
2133
2212
  BOARD_URL_DEPRECATION_WARNING = "boardUrl is deprecated and ignored; board URLs are derived at runtime and are never persisted.";
2134
2213
  PROJECT_IDENTIFIER_SOURCES = ["provider", "proposed"];
2135
2214
  IDENTIFIER_ASSIGNING_PROVIDERS = ["plane", "linear"];
@@ -8510,9 +8589,24 @@ function lstatIfPresent(path) {
8510
8589
  }
8511
8590
  }
8512
8591
  function templateVersionFilesConf(ctx, repoRoot) {
8592
+ if (isObsidianPluginRepo(ctx, repoRoot)) return OBSIDIAN_VERSION_FILES_CONF;
8513
8593
  const packageJson = join5(repoRoot, "package.json");
8514
8594
  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";
8515
8595
  }
8596
+ 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";
8597
+ function recordedProjectType(ctx) {
8598
+ return record(record(readProjectJson(ctx)?.template).commonproject).project_type;
8599
+ }
8600
+ function isObsidianPluginRepo(ctx, repoRoot) {
8601
+ const recorded = recordedProjectType(ctx);
8602
+ if (recorded !== void 0) return recorded === "obsidian-plugin";
8603
+ const manifest = record(tryParseJson(safeReadText2(join5(repoRoot, "manifest.json"))));
8604
+ return typeof manifest.id === "string" && typeof manifest.minAppVersion === "string";
8605
+ }
8606
+ function unsupportedVersionTypes(conf, script) {
8607
+ const types = conf.split("\n").map((line) => line.trim().split(/\s+/)[0] ?? "").filter((type) => type && !type.startsWith("#"));
8608
+ return [...new Set(types)].filter((type) => !new RegExp(`(^|[\\s|])${type.replace(/[^a-z0-9-]/gi, "")}(\\|[a-z-]+)*\\)`, "m").test(script));
8609
+ }
8516
8610
  var BASE_MISE_PATH_ENTRIES = [".mise/scripts", "agents/hermes/pm"];
8517
8611
  function requiredMisePathEntries(_ctx) {
8518
8612
  return [...BASE_MISE_PATH_ENTRIES];
@@ -10068,6 +10162,15 @@ function createMiseChecks() {
10068
10162
  if (!text2?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
10069
10163
  if (!existsSync3(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
10070
10164
  if (!existsSync3(manifestPath)) details.push(".mise/version-files.conf missing");
10165
+ const conf = safeReadText2(manifestPath);
10166
+ const script = safeReadText2(versioningPath);
10167
+ if (conf !== null && script !== null) {
10168
+ const unsupported = unsupportedVersionTypes(conf, script);
10169
+ if (unsupported.length) details.push(`.mise/scripts/versioning.sh cannot version type(s) ${unsupported.join(", ")} named in .mise/version-files.conf`);
10170
+ }
10171
+ if (conf !== null && isObsidianPluginRepo(ctx, ctx.repoRoot) && !/^obsidian\s+manifest\.json\s*$/m.test(conf)) {
10172
+ details.push(".mise/version-files.conf does not version manifest.json/versions.json for this obsidian-plugin project");
10173
+ }
10071
10174
  return {
10072
10175
  id: "mise.versioning",
10073
10176
  title: "managed mise versioning block",
@@ -10609,6 +10712,15 @@ function strayOpInjectLines(text2) {
10609
10712
  }
10610
10713
  return [...new Set(lines)].sort((a, b) => a - b);
10611
10714
  }
10715
+ var COPIER_ANSWERS_HEADER = "# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY";
10716
+ function parseCopierAnswers(text2) {
10717
+ try {
10718
+ const parsed = YAML2.parse(text2);
10719
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { answers: parsed } : { problem: ".copier-answers.yml is not a YAML mapping" };
10720
+ } catch (error) {
10721
+ return { problem: `.copier-answers.yml is not valid YAML (${errorText(error).split("\n")[0]})` };
10722
+ }
10723
+ }
10612
10724
  function createProjectProvenanceChecks() {
10613
10725
  return [
10614
10726
  {
@@ -10622,16 +10734,14 @@ function createProjectProvenanceChecks() {
10622
10734
  if (!text2) {
10623
10735
  details.push(".copier-answers.yml missing");
10624
10736
  } else {
10625
- if (!text2.startsWith("# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY")) details.push("missing Copier overwrite warning header");
10626
- if (!text2.includes("_src_path:")) details.push("_src_path missing");
10627
- if (project?.project_name) {
10628
- const nameMatch = text2.match(/project_name:\s*(.+)/);
10629
- if (!nameMatch || nameMatch[1]?.trim() !== String(project.project_name)) details.push("project_name drift between .copier-answers.yml and .project.json");
10630
- }
10631
- if (project?.project_description) {
10632
- const descMatch = text2.match(/project_description:\s*([\s\S]*?)(?=\n\w|$)/);
10633
- const yamlDesc = descMatch?.[1]?.replace(/\n\s+/g, " ").trim() ?? "";
10634
- if (yamlDesc !== String(project.project_description)) details.push("project_description drift between .copier-answers.yml and .project.json");
10737
+ if (!text2.startsWith(COPIER_ANSWERS_HEADER)) details.push("missing Copier overwrite warning header");
10738
+ const parsed = parseCopierAnswers(text2);
10739
+ if ("problem" in parsed) details.push(parsed.problem);
10740
+ else {
10741
+ const { answers } = parsed;
10742
+ if (answers._src_path === void 0) details.push("_src_path missing");
10743
+ if (project?.project_name && String(answers.project_name ?? "") !== String(project.project_name)) details.push("project_name drift between .copier-answers.yml and .project.json");
10744
+ if (project?.project_description && String(answers.project_description ?? "") !== String(project.project_description)) details.push("project_description drift between .copier-answers.yml and .project.json");
10635
10745
  }
10636
10746
  }
10637
10747
  return {
@@ -10646,14 +10756,18 @@ function createProjectProvenanceChecks() {
10646
10756
  migrate: (ctx, finding2) => {
10647
10757
  const changedFiles = [];
10648
10758
  const project = canonicalProjectJson(ctx);
10649
- const text2 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
10650
- _src_path: ${join5(ctx.pjanglerRoot, "templates", "commonproject")}
10651
- project_description: ${String(project.project_description)}
10652
- project_name: ${String(project.project_name)}
10653
- ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
10654
- `;
10655
10759
  const path = join5(ctx.repoRoot, ".copier-answers.yml");
10656
- if (safeReadText2(path) !== text2) {
10760
+ const current = safeReadText2(path);
10761
+ const existing = current ? YAML2.parseDocument(current) : void 0;
10762
+ const document = existing && !existing.errors.length && YAML2.isMap(existing.contents) ? existing : new YAML2.Document({});
10763
+ document.set("_src_path", join5(ctx.pjanglerRoot, "templates", "commonproject"));
10764
+ document.set("project_description", String(project.project_description));
10765
+ document.set("project_name", String(project.project_name));
10766
+ document.set("ticket_provider", String(project.ticket_provider?.type ?? "plane"));
10767
+ const body = String(document);
10768
+ const text2 = body.startsWith(COPIER_ANSWERS_HEADER) ? body : `${COPIER_ANSWERS_HEADER}
10769
+ ${body}`;
10770
+ if (current !== text2) {
10657
10771
  changedFiles.push(path);
10658
10772
  if (!ctx.dryRun) writeText(path, text2);
10659
10773
  }
@@ -17586,18 +17700,18 @@ function reportBoardDelivery(board, slug, skipRequested) {
17586
17700
  log("");
17587
17701
  return skipRequested;
17588
17702
  }
17589
- function projectInitActionLabel(kind) {
17590
- switch (kind) {
17703
+ function projectInitActionLabel(action) {
17704
+ switch (action.kind) {
17591
17705
  case "registry.upsert":
17592
17706
  return "Refresh the project registry index";
17593
17707
  case "copier.copy.commonproject":
17594
- return "Render CommonProject scaffold";
17708
+ return action.data.project_type && action.data.project_type !== "base" ? `Render CommonProject scaffold (${action.data.project_type})` : "Render CommonProject scaffold";
17595
17709
  case "project.write-manifest":
17596
17710
  return "Write authoritative repo-local .project.json";
17597
17711
  case "ticket-provider.create-or-link":
17598
17712
  return "Create/link ticket provider project";
17599
17713
  default:
17600
- return kind;
17714
+ return action.kind;
17601
17715
  }
17602
17716
  }
17603
17717
  function registryNeedsUpsert(plan) {
@@ -17622,7 +17736,7 @@ function actionNeedsRun(plan, kind, syncMode) {
17622
17736
  async function selectProjectInitOperations(input) {
17623
17737
  const planOperations = input.plan.actions.filter((action) => actionNeedsRun(input.plan, action.kind, input.syncMode)).map((action) => ({
17624
17738
  value: action.kind,
17625
- label: projectInitActionLabel(action.kind),
17739
+ label: projectInitActionLabel(action),
17626
17740
  hint: action.kind === "registry.upsert" ? input.plan.registryPath : action.kind
17627
17741
  }));
17628
17742
  const parityOperations = input.auditRules.filter((rule) => rule.fixable && rule.status !== "pass" && rule.status !== "skip").map((rule) => ({
@@ -17742,7 +17856,7 @@ program.configureOutput({
17742
17856
  });
17743
17857
  registerNotebookCli(program);
17744
17858
  program.name("pjangler").description("Project subsystem bootstrapper CLI").version(PJANGLER_VERSION);
17745
- program.command("init").argument("[name]", "Name of a NEW project; creates ./<name>. Omit to adopt the repo you are standing in.").description("Initialize the project manifest, scaffold and registry index").option("--description <text>", "Project description").option("--target-dir <path>", "Adopt an existing repo at this path (the only way to init a directory you are not standing in)").option("--source-skill <path>", "Source skill/template provenance path").option("--primary-language <language>", "Primary language for CommonProject rendering", "python").option("--apply", "Write the project manifest, render the scaffold and refresh its index").option("--dry-run", "Preview changes without writing files (default)").option("--live", "Allow host-level external effects (systemd, notebook reconcile). The ticket board is created by default and does not need this.").option("--id <project-id>", "Canonical project ID (case-insensitive)").addOption(new Option("--slug <slug>", "Legacy alias for --id").hideHelp()).option("--identifier <identifier>", "Provider ticket prefix (board metadata)").option("--ticket-provider <type>", "Ticket provider: plane | trello (default: existing binding or plane)").option("--board-id <id>", "Board id (Plane project UUID or Trello board id)").option("--board-url <url>", "Deprecated no-op; board URLs are derived from provider + workspace + board-id").option("--skip-board", "Do not create or link a ticket board (the record stays unlinked, and init says so)").option("--workspace <name>", "Ticket workspace/org (Plane workspace; blank for Trello)").option("--registry <location>", `Registry service URL or fixture path (default: ${projectRegistryPath()})`).option("-f, --force", "Allow replacing an existing registry entry, re-rendering files, and renaming an already-registered project").option("-y, --yes", "Apply every proposed operation without prompting").option("--no-tui", "Disable interactive prompts").option("--json", "Output machine-parseable JSON").action(async (name, options) => {
17859
+ program.command("init").argument("[name]", "Name of a NEW project; creates ./<name>. Omit to adopt the repo you are standing in.").description("Initialize the project manifest, scaffold and registry index").option("--description <text>", "Project description").option("--target-dir <path>", "Adopt an existing repo at this path (the only way to init a directory you are not standing in)").option("--source-skill <path>", "Source skill/template provenance path").option("--primary-language <language>", "Primary language recorded for the project (default: the recorded one, else the project type's, else python)").option("--obsidian-plugin", "Render the CommonProject variant built on the canonical Obsidian sample plugin (new projects; on an existing repo, records one that already is a plugin)").option("--apply", "Write the project manifest, render the scaffold and refresh its index").option("--dry-run", "Preview changes without writing files (default)").option("--live", "Allow host-level external effects (systemd, notebook reconcile). The ticket board is created by default and does not need this.").option("--id <project-id>", "Canonical project ID (case-insensitive)").addOption(new Option("--slug <slug>", "Legacy alias for --id").hideHelp()).option("--identifier <identifier>", "Provider ticket prefix (board metadata)").option("--ticket-provider <type>", "Ticket provider: plane | trello (default: existing binding or plane)").option("--board-id <id>", "Board id (Plane project UUID or Trello board id)").option("--board-url <url>", "Deprecated no-op; board URLs are derived from provider + workspace + board-id").option("--skip-board", "Do not create or link a ticket board (the record stays unlinked, and init says so)").option("--workspace <name>", "Ticket workspace/org (Plane workspace; blank for Trello)").option("--registry <location>", `Registry service URL or fixture path (default: ${projectRegistryPath()})`).option("-f, --force", "Allow replacing an existing registry entry, re-rendering files, and renaming an already-registered project").option("-y, --yes", "Apply every proposed operation without prompting").option("--no-tui", "Disable interactive prompts").option("--json", "Output machine-parseable JSON").action(async (name, options) => {
17746
17860
  if (name && getRecipeNames().includes(name)) {
17747
17861
  if (!options.json) {
17748
17862
  console.error(`${yellow(glyph.warn)} ${dim(`"pjangler init ${name}" is deprecated \u2014 use "pjangler add ${name}". Forwarding\u2026`)}`);
@@ -17865,7 +17979,7 @@ boardCmd.command("modules").description("List this project's modules").option("-
17865
17979
  }
17866
17980
  });
17867
17981
  var projectCmd = program.command("project", { hidden: true }).description("Legacy aliases for project commands");
17868
- projectCmd.command("init").argument("[name]", "Project display name").description("Plan or apply a registry-backed CommonProject initialization or legacy repo sync").option("--description <text>", "Project description").option("--target-dir <path>", "Adopt an existing repo at this path (the only way to init a directory you are not standing in)").option("--source-skill <path>", "Source skill/template provenance path").option("--primary-language <language>", "Primary language for CommonProject rendering", "python").option("--apply", "Write the project manifest, render the scaffold and refresh its index").option("--dry-run", "Preview changes without writing files (default)").option("--live", "Allow host-level external effects (systemd, notebook reconcile). The ticket board is created by default and does not need this.").option("--id <project-id>", "Canonical project ID (case-insensitive)").addOption(new Option("--slug <slug>", "Legacy alias for --id").hideHelp()).option("--identifier <identifier>", "Provider ticket prefix (board metadata)").option("--ticket-provider <type>", "Ticket provider: plane | trello (default: existing binding or plane)").option("--board-id <id>", "Board id (Plane project UUID or Trello board id)").option("--board-url <url>", "Deprecated no-op; board URLs are derived from provider + workspace + board-id").option("--skip-board", "Do not create or link a ticket board (the record stays unlinked, and init says so)").option("--workspace <name>", "Ticket workspace/org (Plane workspace; blank for Trello)").option("--registry <location>", `Registry service URL or fixture path (default: ${projectRegistryPath()})`).option("-f, --force", "Allow replacing an existing registry entry, re-rendering files, and renaming an already-registered project").option("-y, --yes", "Apply every proposed operation without prompting").option("--no-tui", "Disable interactive prompts").option("--json", "Output machine-parseable JSON").action((name, options) => {
17982
+ projectCmd.command("init").argument("[name]", "Project display name").description("Plan or apply a registry-backed CommonProject initialization or legacy repo sync").option("--description <text>", "Project description").option("--target-dir <path>", "Adopt an existing repo at this path (the only way to init a directory you are not standing in)").option("--source-skill <path>", "Source skill/template provenance path").option("--primary-language <language>", "Primary language recorded for the project (default: the recorded one, else the project type's, else python)").option("--obsidian-plugin", "Render the CommonProject variant built on the canonical Obsidian sample plugin (new projects; on an existing repo, records one that already is a plugin)").option("--apply", "Write the project manifest, render the scaffold and refresh its index").option("--dry-run", "Preview changes without writing files (default)").option("--live", "Allow host-level external effects (systemd, notebook reconcile). The ticket board is created by default and does not need this.").option("--id <project-id>", "Canonical project ID (case-insensitive)").addOption(new Option("--slug <slug>", "Legacy alias for --id").hideHelp()).option("--identifier <identifier>", "Provider ticket prefix (board metadata)").option("--ticket-provider <type>", "Ticket provider: plane | trello (default: existing binding or plane)").option("--board-id <id>", "Board id (Plane project UUID or Trello board id)").option("--board-url <url>", "Deprecated no-op; board URLs are derived from provider + workspace + board-id").option("--skip-board", "Do not create or link a ticket board (the record stays unlinked, and init says so)").option("--workspace <name>", "Ticket workspace/org (Plane workspace; blank for Trello)").option("--registry <location>", `Registry service URL or fixture path (default: ${projectRegistryPath()})`).option("-f, --force", "Allow replacing an existing registry entry, re-rendering files, and renaming an already-registered project").option("-y, --yes", "Apply every proposed operation without prompting").option("--no-tui", "Disable interactive prompts").option("--json", "Output machine-parseable JSON").action((name, options) => {
17869
17983
  if (!options.json) console.error(`${yellow(glyph.warn)} ${dim('"pjangler project init" is deprecated \u2014 use "pjangler init".')}`);
17870
17984
  return runProjectInit(name, options);
17871
17985
  });
@@ -17880,6 +17994,7 @@ async function runProjectInit(name, options) {
17880
17994
  targetDir: target.targetDir,
17881
17995
  sourceSkill: options.sourceSkill,
17882
17996
  primaryLanguage: options.primaryLanguage,
17997
+ projectType: options.obsidianPlugin ? "obsidian-plugin" : void 0,
17883
17998
  apply,
17884
17999
  live: options.live ?? false,
17885
18000
  projectSlug: target.slug,
@@ -17968,6 +18083,14 @@ async function runProjectInit(name, options) {
17968
18083
  if (result2.ok && result2.changedFiles.length) console.log(` ${green(glyph.pass)} ${bold("Project synchronized")} ${dim(glyph.dot)} ${cyan(plan.project.slug)}`);
17969
18084
  if (result2.ok && result2.changedFiles.length === 0) console.log(` ${green(glyph.pass)} ${dim("Already in parity")} ${dim(glyph.dot)} ${cyan(plan.project.slug)}`);
17970
18085
  reportBoardDelivery(board, plan.project.slug, skipRequested);
18086
+ const rendered = selectedPlan.actions.find((action) => action.kind === "copier.copy.commonproject");
18087
+ if (result2.ok && rendered?.kind === "copier.copy.commonproject" && rendered.data.project_type === "obsidian-plugin") {
18088
+ console.log(` ${bold("Next")} ${dim(`cd ${target.targetDir}`)}`);
18089
+ console.log(` ${cyan("npm install")} ${dim("dev dependencies, pinned by the committed package-lock.json")}`);
18090
+ console.log(` ${cyan("npm run dev")} ${dim("watch build -> main.js")}`);
18091
+ console.log(` ${cyan("mise run plugin:link <vault>")} ${dim("load it in an Obsidian vault")}`);
18092
+ console.log("");
18093
+ }
17971
18094
  }
17972
18095
  process.exitCode = result2.ok && boardDelivered ? 0 : 1;
17973
18096
  } catch (err) {