@delorenj/pjangler 1.3.7 → 1.4.3

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 (60) hide show
  1. package/.mise/scripts/link-agentfiles.sh +38 -5
  2. package/README.md +20 -0
  3. package/dist/assets/project-notebook-skill/SHA256SUMS +10 -0
  4. package/dist/assets/project-notebook-skill/SKILL.md +64 -0
  5. package/dist/assets/project-notebook-skill/agents/openai.yaml +6 -0
  6. package/dist/assets/project-notebook-skill/export-manifest.json +56 -0
  7. package/dist/assets/project-notebook-skill/hooks/claude.settings.json +26 -0
  8. package/dist/assets/project-notebook-skill/hooks/hooks.master.json +26 -0
  9. package/dist/assets/project-notebook-skill/hooks/session-end.sh +228 -0
  10. package/dist/assets/project-notebook-skill/hooks/session-start.sh +228 -0
  11. package/dist/assets/project-notebook-skill/references/configuration.md +93 -0
  12. package/dist/assets/project-notebook-skill/references/recovery.md +54 -0
  13. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +865 -0
  14. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +848 -0
  15. package/dist/index.js +16982 -8532
  16. package/dist/index.js.map +7 -0
  17. package/dist/mcp-server.js +14107 -8089
  18. package/dist/mcp-server.js.map +7 -0
  19. package/dist/prompt.js +2 -1
  20. package/dist/prompt.js.map +7 -0
  21. package/package.json +9 -4
  22. package/templates/commonproject/copier.yml +14 -5
  23. package/templates/commonproject/template/.mise/scripts/link-agentfiles.sh +38 -5
  24. package/templates/commonproject/template/.mise/scripts/provision-packs.py +60 -2
  25. package/templates/commonproject/template/.mise/scripts/sync-skills.py +479 -24
  26. package/templates/commonproject/template/mise.toml.jinja +12 -6
  27. package/templates/hermes-agent/copier.yml +16 -3
  28. package/templates/hermes-agent/template/.gitignore.jinja +1 -0
  29. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
  30. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
  31. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
  32. package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
  33. package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
  34. package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
  35. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
  36. package/templates/hermes-agent/template/.scripts/70-systemd.sh +140 -22
  37. package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
  38. package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
  39. package/templates/hermes-agent/template/.scripts/_lib.sh +827 -10
  40. package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
  41. package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
  42. package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
  43. package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
  44. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +29 -2
  45. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
  46. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
  47. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +9 -0
  48. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
  49. package/templates/hermes-agent/template/.scripts/providers/linear.sh +24 -1
  50. package/templates/hermes-agent/template/.scripts/providers/plane.sh +72 -10
  51. package/templates/hermes-agent/template/.scripts/providers/trello.sh +25 -2
  52. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +5 -16
  53. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +2 -16
  54. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
  55. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
  56. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
  57. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
  58. package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
  59. package/templates/hermes-agent/template/hermes.jinja +1 -1
  60. package/templates/hermes-agent/template/role.yaml.jinja +11 -4
package/dist/prompt.js CHANGED
@@ -241,7 +241,7 @@ function planeBase(env = process.env, home = homedir()) {
241
241
  if (fromConfig) return fromConfig.replace(/\/+$/, "");
242
242
  return DEFAULT_PLANE_BASE;
243
243
  }
244
- function planeWorkspace(provider, env, home) {
244
+ function planeWorkspace(provider, env = process.env, home = homedir()) {
245
245
  const fromManifest = provider.workspace?.trim();
246
246
  if (fromManifest) return fromManifest;
247
247
  const config = readTemplateConfig(env, home);
@@ -402,3 +402,4 @@ export {
402
402
  promptLine,
403
403
  readPromptFacts
404
404
  };
405
+ //# sourceMappingURL=prompt.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/prompt.ts", "../src/describe/activity.ts", "../src/project/boardUrl.ts"],
4
+ "sourcesContent": ["#!/usr/bin/env node\n// `pjangler-prompt` \u2014 the shell-prompt surface.\n//\n// This is a SEPARATE entry point from the main CLI on purpose. `dist/index.js`\n// pulls in the whole parity rule set (a ~280KB module) and takes ~52ms just to\n// boot, which is far too much to pay on every shell prompt. This bundle\n// imports only the activity probe and node builtins, so it lands near node's\n// own startup floor.\n//\n// Contract with starship: print ONE line and exit 0 when the cwd is inside a\n// pjangler project, print NOTHING otherwise. Starship renders a custom\n// module's format wrapper even when the command fails, so \"no output\" \u2014 not a\n// non-zero exit \u2014 is what makes the extra prompt line disappear cleanly.\n//\n// `--url [ref]` is the second contract: print the board (or work-item) URL for\n// this project and nothing else, exiting non-zero when there is nothing to\n// point at so a shell widget can tell \"no project here\" from \"here it is\".\n// It lives on THIS entry point rather than the main CLI for one reason \u2014 the\n// prompt and the shortcut must never disagree about which board you are on,\n// and the cheapest way to guarantee that is to make them the same program\n// reading the same facts.\n//\n// Never throws. A broken manifest degrades to the directory name rather than\n// spilling a stack trace into someone's prompt.\n\nimport { readFileSync, realpathSync } from \"node:fs\";\nimport { basename, join } from \"node:path\";\nimport { pathToFileURL } from \"node:url\";\nimport { computeRepoActivity } from \"./describe/activity\";\nimport { findProjectRoot, resolveBoardUrl } from \"./project/boardUrl\";\n\nexport interface PromptFacts {\n root: string;\n slug: string;\n identifier?: string;\n age?: string;\n active: boolean;\n}\n\n/**\n * Nearest ancestor holding a `.project.json`, starting at `from`.\n *\n * Walking up matters: most of the time you are in `src/` or `docs/`, not the\n * repo root. Node resolves symlinks in `process.cwd()`, so unlike the shell's\n * `$PWD` this needs no physical-path fallback.\n *\n * Re-exported rather than reimplemented: `--url` resolution needs the same\n * walk, and two copies of \"where is the project root\" is exactly the kind of\n * drift that makes the prompt and the shortcut disagree.\n */\nexport { findProjectRoot };\n\nexport function readPromptFacts(root: string, now?: Date): PromptFacts {\n let slug = basename(root);\n let identifier: string | undefined;\n try {\n const manifest = JSON.parse(readFileSync(join(root, \".project.json\"), \"utf8\")) as Record<string, unknown>;\n if (typeof manifest.project_slug === \"string\" && manifest.project_slug) slug = manifest.project_slug;\n const provider = manifest.ticket_provider as Record<string, unknown> | undefined;\n if (provider && typeof provider.identifier === \"string\" && provider.identifier) identifier = provider.identifier;\n } catch {\n // A malformed manifest still identifies a project; the directory name is a\n // truthful fallback and keeps the prompt line from vanishing confusingly.\n }\n\n const activity = computeRepoActivity(root, { now });\n return {\n root,\n slug,\n identifier,\n age: activity.updatedUnix ? activity.compact : undefined,\n active: activity.active,\n };\n}\n\n/** `pjangler (PJAN) \u00B7 3m` \u2014 deliberately terse; a prompt is not a report. */\nexport function formatPromptLine(facts: PromptFacts): string {\n const parts = [facts.slug];\n if (facts.identifier) parts.push(`(${facts.identifier})`);\n const head = parts.join(\" \");\n return facts.age ? `${head} \u00B7 ${facts.age}` : head;\n}\n\n/** Returns the line to print, or undefined when there is nothing to say. */\nexport function promptLine(cwd: string, now?: Date): string | undefined {\n const root = findProjectRoot(cwd);\n if (!root) return undefined;\n return formatPromptLine(readPromptFacts(root, now));\n}\n\nfunction main(): void {\n try {\n const args = process.argv.slice(2);\n if (args[0] === \"--url\") {\n // Trailing newline here, unlike the prompt line: this output is consumed\n // by `$(...)` and read by humans, not spliced into a prompt string.\n const url = resolveBoardUrl(process.cwd(), args[1]);\n if (url) process.stdout.write(`${url}\\n`);\n else process.exitCode = 1;\n return;\n }\n const line = promptLine(process.cwd());\n if (line) process.stdout.write(line);\n } catch {\n // A prompt must never be the thing that breaks a shell.\n }\n}\n\n/**\n * Run only when executed directly, so the pieces above stay importable.\n * `realpathSync` matters: npm installs bins as symlinks, so argv[1] is\n * `.bin/pjangler-prompt` while import.meta.url is the real `dist/prompt.js`.\n */\nfunction isMainModule(): boolean {\n if (!process.argv[1]) return false;\n try {\n return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;\n } catch {\n return false;\n }\n}\n\nif (isMainModule()) main();\n", "// Repo activity \u2014 \"when was work last done here?\"\n//\n// This replaces the `status` field, which claimed `planned` for 22 of the 27\n// registered projects \u2014 pjangler itself among them, on a day it had commits.\n// A repo in the registry is by definition past planning, so the field never\n// varied and never informed.\n//\n// Activity is temporal instead of categorical, and COMPUTED instead of stored.\n// A stored status goes stale the moment someone commits, which is exactly how\n// the old one ended up wrong everywhere; deriving it from git at read time\n// means it cannot drift, and no cron has to walk every project to keep it\n// fresh. (The registry's own `updated_at` is not a substitute: it records the\n// last registry write, not the last piece of work.)\n//\n// \"Work\" deliberately spans more than the checked-out branch:\n//\n// refs every local branch, remote-tracking branch, and tag\n// worktrees every linked worktree's HEAD, which catches detached HEADs\n// whose commits are on no ref at all\n// uncommitted changes in the working tree that are not committed yet\n//\n// That breadth is not theoretical. Across the live registry, the newest work\n// in several projects (deckard, intelliforia, slowburns) lives in a worktree,\n// so a naive `git log -1` on the checked-out branch reports them as stale.\n//\n// Honesty note: remote-tracking refs reflect the last fetch, not the live\n// remote. Asking the network would mean a round trip per project, which is\n// unacceptable on a shell prompt. The winning source is always reported, so a\n// reader can see whether the answer came from local or remote state.\n//\n// Structure: the parsing is pure and the IO is a thin driver, in two flavours\n// (sync for one repo, async for scanning the whole registry concurrently).\n// The tricky part is the parsing, and keeping it pure means it is unit\n// testable without constructing a repo for every edge case.\n\nimport { spawn, spawnSync } from \"node:child_process\";\nimport { statSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n/** A repo counts as active when the newest work is within this window. */\nexport const ACTIVE_WINDOW_SECONDS = 24 * 60 * 60;\n\n/** Stop stat-ing dirty files past this many; the newest mtime converges fast. */\nconst MAX_DIRTY_STATS = 500;\n\nexport type ActivityKind = \"ref\" | \"worktree\" | \"uncommitted\";\n\nexport interface ActivitySource {\n kind: ActivityKind;\n /** Human label for the winning source: \"main\", \"origin/feat/x\", \"3 files\". */\n label: string;\n unix: number;\n}\n\nexport interface RepoActivity {\n /** ISO-8601 of the newest work, or null when the repo has no history. */\n updated: string | null;\n updatedUnix: number | null;\n /** \"3 hours ago\" \u2014 or \"never\" when nothing has happened yet. */\n relative: string;\n /** Compact form for space-constrained surfaces like a shell prompt: \"3h\". */\n compact: string;\n /** Newest work is within ACTIVE_WINDOW_SECONDS. */\n active: boolean;\n source: ActivitySource | null;\n scanned: { refs: number; worktrees: number; dirtyFiles: number };\n}\n\nexport interface ActivityOptions {\n /** Injected clock. Tests pass a fixed instant; production passes nothing. */\n now?: Date;\n}\n\n// ---------------------------------------------------------------------------\n// git plumbing\n// ---------------------------------------------------------------------------\n\nconst GIT_TIMEOUT_MS = 5_000;\nconst GIT_MAX_BUFFER = 16 * 1024 * 1024;\n\n/** Run a git subcommand in `repo`. Returns raw stdout, or undefined on failure. */\nexport function git(repo: string, args: string[]): string | undefined {\n const result = spawnSync(\"git\", [\"-C\", repo, ...args], {\n encoding: \"utf8\",\n timeout: GIT_TIMEOUT_MS,\n maxBuffer: GIT_MAX_BUFFER,\n });\n if (result.status !== 0 || typeof result.stdout !== \"string\") return undefined;\n return result.stdout;\n}\n\n/** Async twin of `git`, so a registry scan can run repos concurrently. */\nexport function gitAsync(repo: string, args: string[]): Promise<string | undefined> {\n return new Promise((resolve) => {\n const child = spawn(\"git\", [\"-C\", repo, ...args], { stdio: [\"ignore\", \"pipe\", \"ignore\"] });\n let out = \"\";\n let size = 0;\n let settled = false;\n const finish = (value: string | undefined) => {\n if (settled) return;\n settled = true;\n resolve(value);\n };\n const timer = setTimeout(() => {\n child.kill(\"SIGKILL\");\n finish(undefined);\n }, GIT_TIMEOUT_MS);\n timer.unref?.();\n\n child.stdout.setEncoding(\"utf8\");\n child.stdout.on(\"data\", (chunk: string) => {\n size += chunk.length;\n if (size > GIT_MAX_BUFFER) {\n child.kill(\"SIGKILL\");\n finish(undefined);\n return;\n }\n out += chunk;\n });\n child.on(\"error\", () => {\n clearTimeout(timer);\n finish(undefined);\n });\n child.on(\"close\", (code) => {\n clearTimeout(timer);\n finish(code === 0 ? out : undefined);\n });\n });\n}\n\nfunction trimmed(raw: string | undefined): string | undefined {\n if (raw === undefined) return undefined;\n const value = raw.trim();\n return value === \"\" ? undefined : value;\n}\n\n/** Run a git subcommand and trim; undefined for failure or empty output. */\nexport function gitLine(repo: string, args: string[]): string | undefined {\n return trimmed(git(repo, args));\n}\n\nexport function isGitRepo(repo: string): boolean {\n return gitLine(repo, [\"rev-parse\", \"--is-inside-work-tree\"]) === \"true\";\n}\n\n// ---------------------------------------------------------------------------\n// Relative time\n// ---------------------------------------------------------------------------\n\nconst MINUTE = 60;\nconst HOUR = 60 * MINUTE;\nconst DAY = 24 * HOUR;\nconst WEEK = 7 * DAY;\nconst MONTH = 30 * DAY;\nconst YEAR = 365 * DAY;\n\nfunction plural(count: number, unit: string): string {\n return `${count} ${unit}${count === 1 ? \"\" : \"s\"} ago`;\n}\n\n/**\n * Human relative age. Deterministic and clock-injected so it is testable \u2014 no\n * \"about a minute\" fuzz, because a tool reporting staleness should be precise\n * about which bucket it chose.\n */\nexport function formatRelativeAge(deltaSeconds: number): string {\n const delta = Math.max(0, Math.floor(deltaSeconds));\n if (delta < MINUTE) return \"just now\";\n if (delta < HOUR) return plural(Math.floor(delta / MINUTE), \"minute\");\n if (delta < DAY) return plural(Math.floor(delta / HOUR), \"hour\");\n if (delta < WEEK) return plural(Math.floor(delta / DAY), \"day\");\n if (delta < MONTH) return plural(Math.floor(delta / WEEK), \"week\");\n if (delta < YEAR) return plural(Math.floor(delta / MONTH), \"month\");\n return plural(Math.floor(delta / YEAR), \"year\");\n}\n\n/** Same ladder, one or two characters wide, for prompts: \"now\", \"5m\", \"3d\". */\nexport function formatCompactAge(deltaSeconds: number): string {\n const delta = Math.max(0, Math.floor(deltaSeconds));\n if (delta < MINUTE) return \"now\";\n if (delta < HOUR) return `${Math.floor(delta / MINUTE)}m`;\n if (delta < DAY) return `${Math.floor(delta / HOUR)}h`;\n if (delta < WEEK) return `${Math.floor(delta / DAY)}d`;\n if (delta < MONTH) return `${Math.floor(delta / WEEK)}w`;\n if (delta < YEAR) return `${Math.floor(delta / MONTH)}mo`;\n return `${Math.floor(delta / YEAR)}y`;\n}\n\n// ---------------------------------------------------------------------------\n// Commands (pure) \u2014 what to ask git\n// ---------------------------------------------------------------------------\n\nexport const REF_ARGS = [\n \"for-each-ref\",\n \"--sort=-committerdate\",\n \"--format=%(committerdate:unix)%09%(refname:short)\",\n \"refs/heads\",\n \"refs/remotes\",\n \"refs/tags\",\n];\n\nexport const WORKTREE_ARGS = [\"worktree\", \"list\", \"--porcelain\"];\n\n/**\n * `--porcelain -z` is deliberate: the non-`-z` form C-quotes paths containing\n * spaces or unicode, and un-quoting that correctly is a parser nobody should\n * write. NUL separation sidesteps it.\n *\n * `--ignore-submodules=dirty` is also deliberate, on both cost and correctness\n * grounds. Cost: without it `git status` recurses into every submodule working\n * tree \u2014 measured at 100ms on the 33GOD superproject versus under a\n * millisecond with it, which alone would rule out the shell-prompt path.\n * Correctness: a submodule is its own repo with its own registry entry and its\n * own activity, so edits inside it are that project's work, not the\n * superproject's. Committed submodule POINTER moves still count, because those\n * genuinely are changes to this repo.\n */\nexport const STATUS_ARGS = [\"status\", \"--porcelain\", \"-z\", \"--ignore-submodules=dirty\"];\n\n// ---------------------------------------------------------------------------\n// Parsers (pure) \u2014 what git said\n// ---------------------------------------------------------------------------\n\nexport interface WorktreeEntry {\n path: string;\n sha: string;\n detached: boolean;\n}\n\n/** Newest ref plus the total ref count, from one sorted ref walk. */\nexport function parseRefs(raw: string | undefined): { source?: ActivitySource; count: number } {\n if (raw === undefined) return { count: 0 };\n const lines = raw.split(\"\\n\").filter((line) => line.trim() !== \"\");\n if (!lines.length) return { count: 0 };\n\n const [stamp, name] = lines[0]!.split(\"\\t\");\n const unix = Number(stamp);\n if (!Number.isFinite(unix) || unix <= 0) return { count: lines.length };\n return { source: { kind: \"ref\", label: name ?? \"(unnamed ref)\", unix }, count: lines.length };\n}\n\nexport function parseWorktrees(raw: string | undefined): WorktreeEntry[] {\n if (raw === undefined) return [];\n const entries: WorktreeEntry[] = [];\n let current: { path?: string; sha?: string; detached: boolean } = { detached: false };\n const flush = () => {\n if (current.path && current.sha) entries.push({ path: current.path, sha: current.sha, detached: current.detached });\n current = { detached: false };\n };\n for (const line of raw.split(\"\\n\")) {\n if (line.startsWith(\"worktree \")) {\n flush();\n current.path = line.slice(\"worktree \".length);\n } else if (line.startsWith(\"HEAD \")) {\n current.sha = line.slice(\"HEAD \".length).trim();\n } else if (line === \"detached\") {\n current.detached = true;\n }\n }\n flush();\n return entries;\n}\n\n/** Pick the newest worktree HEAD out of a `git show -s --format=%ct %H` batch. */\nexport function parseWorktreeStamps(raw: string | undefined, entries: readonly WorktreeEntry[]): ActivitySource | undefined {\n if (raw === undefined) return undefined;\n let best: ActivitySource | undefined;\n for (const line of raw.split(\"\\n\")) {\n const [stamp, sha] = line.trim().split(\" \");\n const unix = Number(stamp);\n if (!Number.isFinite(unix) || unix <= 0 || !sha) continue;\n if (best && unix <= best.unix) continue;\n const owner = entries.find((entry) => entry.sha === sha);\n const name = owner ? basenameOf(owner.path) : sha.slice(0, 7);\n best = { kind: \"worktree\", label: owner?.detached ? `${name} (detached)` : name, unix };\n }\n return best;\n}\n\n/**\n * Paths from `git status --porcelain -z`. Every entry is \"XY <path>\"; renames\n * and copies are followed by a bare origin path with no status prefix, which\n * is consumed rather than treated as a second change.\n */\nexport function parseStatusPaths(raw: string | undefined): string[] {\n if (raw === undefined) return [];\n const parts = raw.split(\"\\0\").filter((part) => part !== \"\");\n const paths: string[] = [];\n for (let index = 0; index < parts.length; index++) {\n const entry = parts[index]!;\n if (entry.length < 4 || entry[2] !== \" \") continue;\n paths.push(entry.slice(3));\n if (entry[0] === \"R\" || entry[0] === \"C\") index += 1;\n }\n return paths;\n}\n\nfunction basenameOf(path: string): string {\n const parts = path.split(\"/\").filter(Boolean);\n return parts[parts.length - 1] ?? path;\n}\n\n/**\n * Newest mtime among uncommitted paths. This is what makes \"active\" true while\n * you are mid-edit and have not committed yet \u2014 the case a commit-only signal\n * misses entirely.\n */\nexport function uncommittedSource(repo: string, paths: readonly string[]): ActivitySource | undefined {\n if (!paths.length) return undefined;\n let newest = 0;\n for (const path of paths.slice(0, MAX_DIRTY_STATS)) {\n try {\n const mtime = Math.floor(statSync(join(repo, path)).mtimeMs / 1000);\n if (mtime > newest) newest = mtime;\n } catch {\n // Deleted (or unreadable) paths have no mtime to contribute.\n }\n }\n if (newest <= 0) return undefined;\n const label = paths.length === 1 ? \"1 uncommitted file\" : `${paths.length} uncommitted files`;\n return { kind: \"uncommitted\", label, unix: newest };\n}\n\n// ---------------------------------------------------------------------------\n// Assembly (pure)\n// ---------------------------------------------------------------------------\n\nconst NO_ACTIVITY: RepoActivity = {\n updated: null,\n updatedUnix: null,\n relative: \"never\",\n compact: \"\u2014\",\n active: false,\n source: null,\n scanned: { refs: 0, worktrees: 0, dirtyFiles: 0 },\n};\n\n/** Empty activity, for a path that is not a git repo at all. */\nexport function emptyActivity(): RepoActivity {\n return { ...NO_ACTIVITY, scanned: { refs: 0, worktrees: 0, dirtyFiles: 0 } };\n}\n\n/**\n * Ties break toward immediacy (uncommitted > worktree > ref): when a commit\n * and a working-tree edit share a second, the edit is the later event.\n */\nexport function assembleActivity(\n candidates: readonly (ActivitySource | undefined)[],\n scanned: RepoActivity[\"scanned\"],\n now?: Date,\n): RepoActivity {\n let winner: ActivitySource | null = null;\n for (const candidate of candidates) {\n if (!candidate) continue;\n if (!winner || candidate.unix >= winner.unix) winner = candidate;\n }\n if (!winner) return { ...NO_ACTIVITY, scanned };\n\n const nowUnix = Math.floor((now?.getTime() ?? Date.now()) / 1000);\n const delta = nowUnix - winner.unix;\n return {\n updated: new Date(winner.unix * 1000).toISOString(),\n updatedUnix: winner.unix,\n relative: formatRelativeAge(delta),\n compact: formatCompactAge(delta),\n active: delta < ACTIVE_WINDOW_SECONDS,\n source: winner,\n scanned,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Drivers\n// ---------------------------------------------------------------------------\n\n/**\n * Newest work across every branch, worktree, and uncommitted change.\n *\n * There is deliberately no \"fast, less accurate\" variant. The three probes\n * together measure ~8ms of git, and the ~5ms a ref-only shortcut would save\n * comes at the cost of missing detached worktrees entirely \u2014 a repo whose work\n * happens in a detached worktree would report months of staleness. That is the\n * exact coverage this function exists to provide, so it is not optional.\n */\nexport function computeRepoActivity(repo: string, options: ActivityOptions = {}): RepoActivity {\n if (!isGitRepo(repo)) return emptyActivity();\n\n const refs = parseRefs(git(repo, REF_ARGS));\n const worktrees = parseWorktrees(git(repo, WORKTREE_ARGS));\n const shas = [...new Set(worktrees.map((entry) => entry.sha))];\n const worktreeSource = shas.length\n ? parseWorktreeStamps(git(repo, [\"show\", \"-s\", \"--format=%ct %H\", ...shas]), worktrees)\n : undefined;\n const paths = parseStatusPaths(git(repo, STATUS_ARGS));\n\n return assembleActivity(\n [refs.source, worktreeSource, uncommittedSource(repo, paths)],\n { refs: refs.count, worktrees: worktrees.length, dirtyFiles: paths.length },\n options.now,\n );\n}\n\n/** Async twin of `computeRepoActivity`, for concurrent registry scans. */\nexport async function computeRepoActivityAsync(repo: string, options: ActivityOptions = {}): Promise<RepoActivity> {\n if (trimmed(await gitAsync(repo, [\"rev-parse\", \"--is-inside-work-tree\"])) !== \"true\") return emptyActivity();\n\n const [refRaw, worktreeRaw, statusRaw] = await Promise.all([\n gitAsync(repo, REF_ARGS),\n gitAsync(repo, WORKTREE_ARGS),\n gitAsync(repo, STATUS_ARGS),\n ]);\n\n const refs = parseRefs(refRaw);\n const worktrees = parseWorktrees(worktreeRaw);\n const shas = [...new Set(worktrees.map((entry) => entry.sha))];\n const worktreeSource = shas.length\n ? parseWorktreeStamps(await gitAsync(repo, [\"show\", \"-s\", \"--format=%ct %H\", ...shas]), worktrees)\n : undefined;\n const paths = parseStatusPaths(statusRaw);\n\n return assembleActivity(\n [refs.source, worktreeSource, uncommittedSource(repo, paths)],\n { refs: refs.count, worktrees: worktrees.length, dirtyFiles: paths.length },\n options.now,\n );\n}\n\n/**\n * Activity for many repos at once, keyed by the path passed in.\n *\n * Scanning the live registry serially costs ~450ms across 27 projects, which\n * is a visible stall on `project list`; a bounded pool brings that down to\n * roughly the cost of the slowest repo.\n */\nexport async function computeRepoActivityBatch(\n repos: readonly string[],\n options: ActivityOptions & { concurrency?: number } = {},\n): Promise<Map<string, RepoActivity>> {\n const results = new Map<string, RepoActivity>();\n const unique = [...new Set(repos)];\n const limit = Math.max(1, options.concurrency ?? 8);\n let cursor = 0;\n\n const worker = async (): Promise<void> => {\n while (cursor < unique.length) {\n const repo = unique[cursor++]!;\n try {\n results.set(repo, await computeRepoActivityAsync(repo, options));\n } catch {\n results.set(repo, emptyActivity());\n }\n }\n };\n\n await Promise.all(Array.from({ length: Math.min(limit, unique.length) }, worker));\n return results;\n}\n", "// Ticket-provider URL derivation \u2014 the one place a board or work-item URL is\n// constructed.\n//\n// `src/project/index.ts` has long declared the rule (\"board URLs are derived\n// from provider + workspace + board_id at runtime; the manifest stores only\n// stable identity\") without anywhere actually performing it, so every caller\n// that wanted a URL either hand-assembled one or persisted a stale\n// `board_url`. This module is that missing function.\n//\n// Deliberately dependency-free \u2014 node builtins only, no imports from\n// `./index`. `src/prompt.ts` is a separate size-critical bundle that runs on\n// every shell prompt, and pulling the registry (or the parity rule set behind\n// it) into that bundle would cost more than the whole feature is worth.\n\nimport { existsSync, readFileSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, isAbsolute, join, resolve } from \"node:path\";\n\nexport const DEFAULT_PLANE_BASE = \"https://plane.delo.sh\";\nexport const DEFAULT_PLANE_WORKSPACE = \"33god\";\n\n/** The `.project.json` `ticket_provider` block, as far as URL building cares. */\nexport interface TicketProviderFacts {\n type?: string;\n workspace?: string;\n identifier?: string;\n board_id?: string;\n}\n\nexport interface BoardUrlOptions {\n /** Explicit work item: `71`, `PJAN-71`, or `pjan-71`. Wins over `branch`. */\n ref?: string;\n /** Branch name to mine for a ticket reference when `ref` is absent. */\n branch?: string;\n env?: NodeJS.ProcessEnv;\n home?: string;\n}\n\n/**\n * Where the Hermes template keeps `[plane] base`.\n *\n * NOTE: twin of `resolveTemplateConfigPath` in\n * `src/commands/hermes/EnsureTemplateConfig.ts`. That module cannot be\n * imported here without dragging the command layer into the prompt bundle.\n * `tests/pjan-72-regressions.mjs` carries a drift tripwire on the pair.\n */\nexport function resolveTemplateConfigPath(env: NodeJS.ProcessEnv = process.env, home = homedir()): string {\n const fromEnv = env.HERMES_TEMPLATE_CONFIG;\n if (fromEnv && fromEnv.trim()) return fromEnv.trim();\n const xdg = env.XDG_CONFIG_HOME?.trim();\n const base = xdg && xdg.length ? xdg : join(home, \".config\");\n return join(base, \"hermes-agent-template\", \"config.toml\");\n}\n\n/**\n * Read `key` from `[section]` of a small TOML file.\n *\n * Scoped to exactly what this module needs \u2014 two string scalars out of a\n * generated config. Not a TOML parser, and deliberately not pretending to be\n * one: anything it cannot confidently read comes back undefined and the caller\n * falls through to a default.\n */\nexport function readTomlScalar(text: string, section: string, key: string): string | undefined {\n let inSection = false;\n for (const raw of text.split(\"\\n\")) {\n const line = raw.trim();\n if (!line || line.startsWith(\"#\")) continue;\n if (line.startsWith(\"[\")) {\n inSection = line === `[${section}]`;\n continue;\n }\n if (!inSection) continue;\n const eq = line.indexOf(\"=\");\n if (eq === -1) continue;\n if (line.slice(0, eq).trim() !== key) continue;\n const value = line.slice(eq + 1).trim();\n const quoted = /^\"([^\"]*)\"|^'([^']*)'/.exec(value);\n if (quoted) return quoted[1] ?? quoted[2];\n const bare = (value.split(\"#\")[0] ?? \"\").trim();\n return bare || undefined;\n }\n return undefined;\n}\n\nfunction readTemplateConfig(env: NodeJS.ProcessEnv, home: string): string | undefined {\n try {\n const path = resolveTemplateConfigPath(env, home);\n return existsSync(path) ? readFileSync(path, \"utf8\") : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Plane instance base URL.\n *\n * Precedence mirrors the shell adapter at\n * `agents/hermes/pm/.scripts/providers/plane.sh:22` \u2014 env override first, then\n * the generated template config, then the fleet default \u2014 so the CLI and the\n * PM agent can never disagree about which Plane they are talking to.\n */\nexport function planeBase(env: NodeJS.ProcessEnv = process.env, home = homedir()): string {\n const fromEnv = env.PLANE_BASE?.trim();\n if (fromEnv) return fromEnv.replace(/\\/+$/, \"\");\n const config = readTemplateConfig(env, home);\n const fromConfig = config ? readTomlScalar(config, \"plane\", \"base\")?.trim() : undefined;\n if (fromConfig) return fromConfig.replace(/\\/+$/, \"\");\n return DEFAULT_PLANE_BASE;\n}\n\n/**\n * Workspace slug for a board binding: manifest, then generated template\n * config, then the fleet default.\n *\n * Exported for `boardQuery.ts`, which needs the identical chain to build API\n * URLs. A second copy of this fallback order is exactly how the CLI and the PM\n * agent would end up querying two different workspaces.\n */\nexport function planeWorkspace(provider: TicketProviderFacts, env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string | undefined {\n const fromManifest = provider.workspace?.trim();\n if (fromManifest) return fromManifest;\n const config = readTemplateConfig(env, home);\n const fromConfig = config ? readTomlScalar(config, \"plane\", \"workspace\")?.trim() : undefined;\n return fromConfig || DEFAULT_PLANE_WORKSPACE;\n}\n\nfunction escapeRegExp(value: string): string {\n return value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\n/**\n * Pull a ticket reference out of a branch name.\n *\n * `fix/PJAN-67-mcp-fail-closed` \u2192 `PJAN-67`. CLAUDE.md already requires branch\n * names to carry a ticket reference, so this is the common path rather than a\n * clever edge case.\n *\n * The `\\b` before the identifier is what keeps `XPJAN-3` from reading as\n * `PJAN-3`, and requiring a literal `-` after it keeps `PJANX-3` out too.\n */\nexport function extractTicketRef(branch: string | undefined, identifier: string | undefined): string | undefined {\n if (!branch || !identifier) return undefined;\n const ident = identifier.trim();\n if (!ident) return undefined;\n const match = new RegExp(`\\\\b${escapeRegExp(ident)}-(\\\\d+)\\\\b`, \"i\").exec(branch);\n return match ? `${ident.toUpperCase()}-${match[1]}` : undefined;\n}\n\n/**\n * Normalize user input into a work-item reference.\n *\n * A bare number is completed with this project's identifier. A fully-qualified\n * reference is accepted as-is even when its prefix belongs to another\n * project \u2014 Plane's browse route is workspace-scoped, not project-scoped, so\n * `board DECK-21` resolves correctly from inside the pjangler repo.\n */\nexport function normalizeTicketRef(input: string | undefined, identifier: string | undefined): string | undefined {\n const value = input?.trim();\n if (!value) return undefined;\n if (/^\\d+$/.test(value)) {\n const ident = identifier?.trim();\n return ident ? `${ident.toUpperCase()}-${value}` : undefined;\n }\n const qualified = /^([A-Za-z][A-Za-z0-9]*)-(\\d+)$/.exec(value);\n if (!qualified) return undefined;\n return `${qualified[1]!.toUpperCase()}-${qualified[2]!}`;\n}\n\n/** Explicit ref beats branch inference beats nothing. */\nexport function resolveTicketRef(provider: TicketProviderFacts, options: BoardUrlOptions): string | undefined {\n return (\n normalizeTicketRef(options.ref, provider.identifier) ??\n extractTicketRef(options.branch, provider.identifier)\n );\n}\n\n/**\n * Board URL, or the URL of one work item on it.\n *\n * Route shapes were read off the live instance's router manifest rather than\n * its documentation:\n * `:workspaceSlug/browse/:workItem`\n * `:workspaceSlug/projects/:projectId/issues`\n *\n * Returns undefined rather than guessing when there is no board to point at.\n */\nexport function boardUrl(provider: TicketProviderFacts | undefined, options: BoardUrlOptions = {}): string | undefined {\n if (!provider) return undefined;\n const env = options.env ?? process.env;\n const home = options.home ?? homedir();\n const type = (provider.type || \"plane\").trim().toLowerCase();\n const boardId = provider.board_id?.trim();\n if (!boardId) return undefined;\n\n if (type === \"trello\") {\n // Trello addresses cards by an opaque short id, not by `<IDENT>-<n>`, so a\n // ticket reference cannot be resolved to a card without an API round trip.\n // Opening the board is the honest answer; silently ignoring the ref would\n // not be.\n return `https://trello.com/b/${boardId}`;\n }\n if (type !== \"plane\") return undefined;\n\n const workspace = planeWorkspace(provider, env, home);\n if (!workspace) return undefined;\n const base = planeBase(env, home);\n const ref = resolveTicketRef(provider, options);\n return ref\n ? `${base}/${workspace}/browse/${ref}`\n : `${base}/${workspace}/projects/${boardId}/issues`;\n}\n\n/** Nearest ancestor holding a `.project.json`, starting at `from`. */\nexport function findProjectRoot(from: string): string | undefined {\n let dir = resolve(from);\n for (;;) {\n if (existsSync(join(dir, \".project.json\"))) return dir;\n const parent = dirname(dir);\n if (parent === dir) return undefined;\n dir = parent;\n }\n}\n\n/** The `ticket_provider` block, or undefined when absent or unreadable. */\nexport function readTicketProvider(root: string): TicketProviderFacts | undefined {\n try {\n const manifest = JSON.parse(readFileSync(join(root, \".project.json\"), \"utf8\")) as Record<string, unknown>;\n const provider = manifest.ticket_provider;\n if (!provider || typeof provider !== \"object\") return undefined;\n return provider as TicketProviderFacts;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Current branch, read straight out of `.git/HEAD`.\n *\n * No subprocess: this runs on the shell-prompt path, where spawning git would\n * cost more than everything else here combined. Handles the `.git`-as-a-file\n * form used by worktrees and submodules. A detached HEAD has no branch, so\n * there is no reference to mine and undefined is the truthful answer.\n */\nexport function currentBranch(from: string): string | undefined {\n try {\n let dir = resolve(from);\n for (;;) {\n const dotgit = join(dir, \".git\");\n if (existsSync(dotgit)) {\n let gitDir = dotgit;\n if (statSync(dotgit).isFile()) {\n const pointer = /^gitdir:\\s*(.+)$/m.exec(readFileSync(dotgit, \"utf8\"));\n if (!pointer) return undefined;\n const target = pointer[1]!.trim();\n gitDir = isAbsolute(target) ? target : resolve(dir, target);\n }\n const head = readFileSync(join(gitDir, \"HEAD\"), \"utf8\").trim();\n const ref = /^ref:\\s*refs\\/heads\\/(.+)$/.exec(head);\n return ref ? ref[1]!.trim() : undefined;\n }\n const parent = dirname(dir);\n if (parent === dir) return undefined;\n dir = parent;\n }\n } catch {\n return undefined;\n }\n}\n\n/**\n * Full resolution from a working directory: the URL this project's prompt\n * segment points at.\n */\nexport function resolveBoardUrl(cwd: string, ref?: string, env: NodeJS.ProcessEnv = process.env): string | undefined {\n const root = findProjectRoot(cwd);\n if (!root) return undefined;\n const provider = readTicketProvider(root);\n if (!provider) return undefined;\n return boardUrl(provider, { ref, branch: currentBranch(root), env });\n}\n"],
5
+ "mappings": ";;;AAyBA,SAAS,gBAAAA,eAAc,oBAAoB;AAC3C,SAAS,UAAU,QAAAC,aAAY;AAC/B,SAAS,qBAAqB;;;ACQ9B,SAAS,OAAO,iBAAiB;AACjC,SAAS,gBAAgB;AACzB,SAAS,YAAY;AAGd,IAAM,wBAAwB,KAAK,KAAK;AAG/C,IAAM,kBAAkB;AAkCxB,IAAM,iBAAiB;AACvB,IAAM,iBAAiB,KAAK,OAAO;AAG5B,SAAS,IAAI,MAAc,MAAoC;AACpE,QAAM,SAAS,UAAU,OAAO,CAAC,MAAM,MAAM,GAAG,IAAI,GAAG;AAAA,IACrD,UAAU;AAAA,IACV,SAAS;AAAA,IACT,WAAW;AAAA,EACb,CAAC;AACD,MAAI,OAAO,WAAW,KAAK,OAAO,OAAO,WAAW,SAAU,QAAO;AACrE,SAAO,OAAO;AAChB;AAyCA,SAAS,QAAQ,KAA6C;AAC5D,MAAI,QAAQ,OAAW,QAAO;AAC9B,QAAM,QAAQ,IAAI,KAAK;AACvB,SAAO,UAAU,KAAK,SAAY;AACpC;AAGO,SAAS,QAAQ,MAAc,MAAoC;AACxE,SAAO,QAAQ,IAAI,MAAM,IAAI,CAAC;AAChC;AAEO,SAAS,UAAU,MAAuB;AAC/C,SAAO,QAAQ,MAAM,CAAC,aAAa,uBAAuB,CAAC,MAAM;AACnE;AAMA,IAAM,SAAS;AACf,IAAM,OAAO,KAAK;AAClB,IAAM,MAAM,KAAK;AACjB,IAAM,OAAO,IAAI;AACjB,IAAM,QAAQ,KAAK;AACnB,IAAM,OAAO,MAAM;AAEnB,SAAS,OAAO,OAAe,MAAsB;AACnD,SAAO,GAAG,KAAK,IAAI,IAAI,GAAG,UAAU,IAAI,KAAK,GAAG;AAClD;AAOO,SAAS,kBAAkB,cAA8B;AAC9D,QAAM,QAAQ,KAAK,IAAI,GAAG,KAAK,MAAM,YAAY,CAAC;AAClD,MAAI,QAAQ,OAAQ,QAAO;AAC3B,MAAI,QAAQ,KAAM,QAAO,OAAO,KAAK,MAAM,QAAQ,MAAM,GAAG,QAAQ;AACpE,MAAI,QAAQ,IAAK,QAAO,OAAO,KAAK,MAAM,QAAQ,IAAI,GAAG,MAAM;AAC/D,MAAI,QAAQ,KAAM,QAAO,OAAO,KAAK,MAAM,QAAQ,GAAG,GAAG,KAAK;AAC9D,MAAI,QAAQ,MAAO,QAAO,OAAO,KAAK,MAAM,QAAQ,IAAI,GAAG,MAAM;AACjE,MAAI,QAAQ,KAAM,QAAO,OAAO,KAAK,MAAM,QAAQ,KAAK,GAAG,OAAO;AAClE,SAAO,OAAO,KAAK,MAAM,QAAQ,IAAI,GAAG,MAAM;AAChD;AAGO,SAAS,iBAAiB,cAA8B;AAC7D,QAAM,QAAQ,KAAK,IAAI,GAAG,KAAK,MAAM,YAAY,CAAC;AAClD,MAAI,QAAQ,OAAQ,QAAO;AAC3B,MAAI,QAAQ,KAAM,QAAO,GAAG,KAAK,MAAM,QAAQ,MAAM,CAAC;AACtD,MAAI,QAAQ,IAAK,QAAO,GAAG,KAAK,MAAM,QAAQ,IAAI,CAAC;AACnD,MAAI,QAAQ,KAAM,QAAO,GAAG,KAAK,MAAM,QAAQ,GAAG,CAAC;AACnD,MAAI,QAAQ,MAAO,QAAO,GAAG,KAAK,MAAM,QAAQ,IAAI,CAAC;AACrD,MAAI,QAAQ,KAAM,QAAO,GAAG,KAAK,MAAM,QAAQ,KAAK,CAAC;AACrD,SAAO,GAAG,KAAK,MAAM,QAAQ,IAAI,CAAC;AACpC;AAMO,IAAM,WAAW;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEO,IAAM,gBAAgB,CAAC,YAAY,QAAQ,aAAa;AAgBxD,IAAM,cAAc,CAAC,UAAU,eAAe,MAAM,2BAA2B;AAa/E,SAAS,UAAU,KAAqE;AAC7F,MAAI,QAAQ,OAAW,QAAO,EAAE,OAAO,EAAE;AACzC,QAAM,QAAQ,IAAI,MAAM,IAAI,EAAE,OAAO,CAAC,SAAS,KAAK,KAAK,MAAM,EAAE;AACjE,MAAI,CAAC,MAAM,OAAQ,QAAO,EAAE,OAAO,EAAE;AAErC,QAAM,CAAC,OAAO,IAAI,IAAI,MAAM,CAAC,EAAG,MAAM,GAAI;AAC1C,QAAM,OAAO,OAAO,KAAK;AACzB,MAAI,CAAC,OAAO,SAAS,IAAI,KAAK,QAAQ,EAAG,QAAO,EAAE,OAAO,MAAM,OAAO;AACtE,SAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,OAAO,QAAQ,iBAAiB,KAAK,GAAG,OAAO,MAAM,OAAO;AAC9F;AAEO,SAAS,eAAe,KAA0C;AACvE,MAAI,QAAQ,OAAW,QAAO,CAAC;AAC/B,QAAM,UAA2B,CAAC;AAClC,MAAI,UAA8D,EAAE,UAAU,MAAM;AACpF,QAAM,QAAQ,MAAM;AAClB,QAAI,QAAQ,QAAQ,QAAQ,IAAK,SAAQ,KAAK,EAAE,MAAM,QAAQ,MAAM,KAAK,QAAQ,KAAK,UAAU,QAAQ,SAAS,CAAC;AAClH,cAAU,EAAE,UAAU,MAAM;AAAA,EAC9B;AACA,aAAW,QAAQ,IAAI,MAAM,IAAI,GAAG;AAClC,QAAI,KAAK,WAAW,WAAW,GAAG;AAChC,YAAM;AACN,cAAQ,OAAO,KAAK,MAAM,YAAY,MAAM;AAAA,IAC9C,WAAW,KAAK,WAAW,OAAO,GAAG;AACnC,cAAQ,MAAM,KAAK,MAAM,QAAQ,MAAM,EAAE,KAAK;AAAA,IAChD,WAAW,SAAS,YAAY;AAC9B,cAAQ,WAAW;AAAA,IACrB;AAAA,EACF;AACA,QAAM;AACN,SAAO;AACT;AAGO,SAAS,oBAAoB,KAAyB,SAA+D;AAC1H,MAAI,QAAQ,OAAW,QAAO;AAC9B,MAAI;AACJ,aAAW,QAAQ,IAAI,MAAM,IAAI,GAAG;AAClC,UAAM,CAAC,OAAO,GAAG,IAAI,KAAK,KAAK,EAAE,MAAM,GAAG;AAC1C,UAAM,OAAO,OAAO,KAAK;AACzB,QAAI,CAAC,OAAO,SAAS,IAAI,KAAK,QAAQ,KAAK,CAAC,IAAK;AACjD,QAAI,QAAQ,QAAQ,KAAK,KAAM;AAC/B,UAAM,QAAQ,QAAQ,KAAK,CAAC,UAAU,MAAM,QAAQ,GAAG;AACvD,UAAM,OAAO,QAAQ,WAAW,MAAM,IAAI,IAAI,IAAI,MAAM,GAAG,CAAC;AAC5D,WAAO,EAAE,MAAM,YAAY,OAAO,OAAO,WAAW,GAAG,IAAI,gBAAgB,MAAM,KAAK;AAAA,EACxF;AACA,SAAO;AACT;AAOO,SAAS,iBAAiB,KAAmC;AAClE,MAAI,QAAQ,OAAW,QAAO,CAAC;AAC/B,QAAM,QAAQ,IAAI,MAAM,IAAI,EAAE,OAAO,CAAC,SAAS,SAAS,EAAE;AAC1D,QAAM,QAAkB,CAAC;AACzB,WAAS,QAAQ,GAAG,QAAQ,MAAM,QAAQ,SAAS;AACjD,UAAM,QAAQ,MAAM,KAAK;AACzB,QAAI,MAAM,SAAS,KAAK,MAAM,CAAC,MAAM,IAAK;AAC1C,UAAM,KAAK,MAAM,MAAM,CAAC,CAAC;AACzB,QAAI,MAAM,CAAC,MAAM,OAAO,MAAM,CAAC,MAAM,IAAK,UAAS;AAAA,EACrD;AACA,SAAO;AACT;AAEA,SAAS,WAAW,MAAsB;AACxC,QAAM,QAAQ,KAAK,MAAM,GAAG,EAAE,OAAO,OAAO;AAC5C,SAAO,MAAM,MAAM,SAAS,CAAC,KAAK;AACpC;AAOO,SAAS,kBAAkB,MAAc,OAAsD;AACpG,MAAI,CAAC,MAAM,OAAQ,QAAO;AAC1B,MAAI,SAAS;AACb,aAAW,QAAQ,MAAM,MAAM,GAAG,eAAe,GAAG;AAClD,QAAI;AACF,YAAM,QAAQ,KAAK,MAAM,SAAS,KAAK,MAAM,IAAI,CAAC,EAAE,UAAU,GAAI;AAClE,UAAI,QAAQ,OAAQ,UAAS;AAAA,IAC/B,QAAQ;AAAA,IAER;AAAA,EACF;AACA,MAAI,UAAU,EAAG,QAAO;AACxB,QAAM,QAAQ,MAAM,WAAW,IAAI,uBAAuB,GAAG,MAAM,MAAM;AACzE,SAAO,EAAE,MAAM,eAAe,OAAO,MAAM,OAAO;AACpD;AAMA,IAAM,cAA4B;AAAA,EAChC,SAAS;AAAA,EACT,aAAa;AAAA,EACb,UAAU;AAAA,EACV,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,SAAS,EAAE,MAAM,GAAG,WAAW,GAAG,YAAY,EAAE;AAClD;AAGO,SAAS,gBAA8B;AAC5C,SAAO,EAAE,GAAG,aAAa,SAAS,EAAE,MAAM,GAAG,WAAW,GAAG,YAAY,EAAE,EAAE;AAC7E;AAMO,SAAS,iBACd,YACA,SACA,KACc;AACd,MAAI,SAAgC;AACpC,aAAW,aAAa,YAAY;AAClC,QAAI,CAAC,UAAW;AAChB,QAAI,CAAC,UAAU,UAAU,QAAQ,OAAO,KAAM,UAAS;AAAA,EACzD;AACA,MAAI,CAAC,OAAQ,QAAO,EAAE,GAAG,aAAa,QAAQ;AAE9C,QAAM,UAAU,KAAK,OAAO,KAAK,QAAQ,KAAK,KAAK,IAAI,KAAK,GAAI;AAChE,QAAM,QAAQ,UAAU,OAAO;AAC/B,SAAO;AAAA,IACL,SAAS,IAAI,KAAK,OAAO,OAAO,GAAI,EAAE,YAAY;AAAA,IAClD,aAAa,OAAO;AAAA,IACpB,UAAU,kBAAkB,KAAK;AAAA,IACjC,SAAS,iBAAiB,KAAK;AAAA,IAC/B,QAAQ,QAAQ;AAAA,IAChB,QAAQ;AAAA,IACR;AAAA,EACF;AACF;AAeO,SAAS,oBAAoB,MAAc,UAA2B,CAAC,GAAiB;AAC7F,MAAI,CAAC,UAAU,IAAI,EAAG,QAAO,cAAc;AAE3C,QAAM,OAAO,UAAU,IAAI,MAAM,QAAQ,CAAC;AAC1C,QAAM,YAAY,eAAe,IAAI,MAAM,aAAa,CAAC;AACzD,QAAM,OAAO,CAAC,GAAG,IAAI,IAAI,UAAU,IAAI,CAAC,UAAU,MAAM,GAAG,CAAC,CAAC;AAC7D,QAAM,iBAAiB,KAAK,SACxB,oBAAoB,IAAI,MAAM,CAAC,QAAQ,MAAM,mBAAmB,GAAG,IAAI,CAAC,GAAG,SAAS,IACpF;AACJ,QAAM,QAAQ,iBAAiB,IAAI,MAAM,WAAW,CAAC;AAErD,SAAO;AAAA,IACL,CAAC,KAAK,QAAQ,gBAAgB,kBAAkB,MAAM,KAAK,CAAC;AAAA,IAC5D,EAAE,MAAM,KAAK,OAAO,WAAW,UAAU,QAAQ,YAAY,MAAM,OAAO;AAAA,IAC1E,QAAQ;AAAA,EACV;AACF;;;AClYA,SAAS,YAAY,cAAc,YAAAC,iBAAgB;AACnD,SAAS,eAAe;AACxB,SAAS,SAAS,YAAY,QAAAC,OAAM,eAAe;AAE5C,IAAM,qBAAqB;AAC3B,IAAM,0BAA0B;AA2BhC,SAAS,0BAA0B,MAAyB,QAAQ,KAAK,OAAO,QAAQ,GAAW;AACxG,QAAM,UAAU,IAAI;AACpB,MAAI,WAAW,QAAQ,KAAK,EAAG,QAAO,QAAQ,KAAK;AACnD,QAAM,MAAM,IAAI,iBAAiB,KAAK;AACtC,QAAM,OAAO,OAAO,IAAI,SAAS,MAAMA,MAAK,MAAM,SAAS;AAC3D,SAAOA,MAAK,MAAM,yBAAyB,aAAa;AAC1D;AAUO,SAAS,eAAe,MAAc,SAAiB,KAAiC;AAC7F,MAAI,YAAY;AAChB,aAAW,OAAO,KAAK,MAAM,IAAI,GAAG;AAClC,UAAM,OAAO,IAAI,KAAK;AACtB,QAAI,CAAC,QAAQ,KAAK,WAAW,GAAG,EAAG;AACnC,QAAI,KAAK,WAAW,GAAG,GAAG;AACxB,kBAAY,SAAS,IAAI,OAAO;AAChC;AAAA,IACF;AACA,QAAI,CAAC,UAAW;AAChB,UAAM,KAAK,KAAK,QAAQ,GAAG;AAC3B,QAAI,OAAO,GAAI;AACf,QAAI,KAAK,MAAM,GAAG,EAAE,EAAE,KAAK,MAAM,IAAK;AACtC,UAAM,QAAQ,KAAK,MAAM,KAAK,CAAC,EAAE,KAAK;AACtC,UAAM,SAAS,wBAAwB,KAAK,KAAK;AACjD,QAAI,OAAQ,QAAO,OAAO,CAAC,KAAK,OAAO,CAAC;AACxC,UAAM,QAAQ,MAAM,MAAM,GAAG,EAAE,CAAC,KAAK,IAAI,KAAK;AAC9C,WAAO,QAAQ;AAAA,EACjB;AACA,SAAO;AACT;AAEA,SAAS,mBAAmB,KAAwB,MAAkC;AACpF,MAAI;AACF,UAAM,OAAO,0BAA0B,KAAK,IAAI;AAChD,WAAO,WAAW,IAAI,IAAI,aAAa,MAAM,MAAM,IAAI;AAAA,EACzD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAUO,SAAS,UAAU,MAAyB,QAAQ,KAAK,OAAO,QAAQ,GAAW;AACxF,QAAM,UAAU,IAAI,YAAY,KAAK;AACrC,MAAI,QAAS,QAAO,QAAQ,QAAQ,QAAQ,EAAE;AAC9C,QAAM,SAAS,mBAAmB,KAAK,IAAI;AAC3C,QAAM,aAAa,SAAS,eAAe,QAAQ,SAAS,MAAM,GAAG,KAAK,IAAI;AAC9E,MAAI,WAAY,QAAO,WAAW,QAAQ,QAAQ,EAAE;AACpD,SAAO;AACT;AAUO,SAAS,eAAe,UAA+B,MAAyB,QAAQ,KAAK,OAAe,QAAQ,GAAuB;AAChJ,QAAM,eAAe,SAAS,WAAW,KAAK;AAC9C,MAAI,aAAc,QAAO;AACzB,QAAM,SAAS,mBAAmB,KAAK,IAAI;AAC3C,QAAM,aAAa,SAAS,eAAe,QAAQ,SAAS,WAAW,GAAG,KAAK,IAAI;AACnF,SAAO,cAAc;AACvB;AAEA,SAAS,aAAa,OAAuB;AAC3C,SAAO,MAAM,QAAQ,uBAAuB,MAAM;AACpD;AAYO,SAAS,iBAAiB,QAA4B,YAAoD;AAC/G,MAAI,CAAC,UAAU,CAAC,WAAY,QAAO;AACnC,QAAM,QAAQ,WAAW,KAAK;AAC9B,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,QAAQ,IAAI,OAAO,MAAM,aAAa,KAAK,CAAC,cAAc,GAAG,EAAE,KAAK,MAAM;AAChF,SAAO,QAAQ,GAAG,MAAM,YAAY,CAAC,IAAI,MAAM,CAAC,CAAC,KAAK;AACxD;AAUO,SAAS,mBAAmB,OAA2B,YAAoD;AAChH,QAAM,QAAQ,OAAO,KAAK;AAC1B,MAAI,CAAC,MAAO,QAAO;AACnB,MAAI,QAAQ,KAAK,KAAK,GAAG;AACvB,UAAM,QAAQ,YAAY,KAAK;AAC/B,WAAO,QAAQ,GAAG,MAAM,YAAY,CAAC,IAAI,KAAK,KAAK;AAAA,EACrD;AACA,QAAM,YAAY,iCAAiC,KAAK,KAAK;AAC7D,MAAI,CAAC,UAAW,QAAO;AACvB,SAAO,GAAG,UAAU,CAAC,EAAG,YAAY,CAAC,IAAI,UAAU,CAAC,CAAE;AACxD;AAGO,SAAS,iBAAiB,UAA+B,SAA8C;AAC5G,SACE,mBAAmB,QAAQ,KAAK,SAAS,UAAU,KACnD,iBAAiB,QAAQ,QAAQ,SAAS,UAAU;AAExD;AAYO,SAAS,SAAS,UAA2C,UAA2B,CAAC,GAAuB;AACrH,MAAI,CAAC,SAAU,QAAO;AACtB,QAAM,MAAM,QAAQ,OAAO,QAAQ;AACnC,QAAM,OAAO,QAAQ,QAAQ,QAAQ;AACrC,QAAM,QAAQ,SAAS,QAAQ,SAAS,KAAK,EAAE,YAAY;AAC3D,QAAM,UAAU,SAAS,UAAU,KAAK;AACxC,MAAI,CAAC,QAAS,QAAO;AAErB,MAAI,SAAS,UAAU;AAKrB,WAAO,wBAAwB,OAAO;AAAA,EACxC;AACA,MAAI,SAAS,QAAS,QAAO;AAE7B,QAAM,YAAY,eAAe,UAAU,KAAK,IAAI;AACpD,MAAI,CAAC,UAAW,QAAO;AACvB,QAAM,OAAO,UAAU,KAAK,IAAI;AAChC,QAAM,MAAM,iBAAiB,UAAU,OAAO;AAC9C,SAAO,MACH,GAAG,IAAI,IAAI,SAAS,WAAW,GAAG,KAClC,GAAG,IAAI,IAAI,SAAS,aAAa,OAAO;AAC9C;AAGO,SAAS,gBAAgB,MAAkC;AAChE,MAAI,MAAM,QAAQ,IAAI;AACtB,aAAS;AACP,QAAI,WAAWA,MAAK,KAAK,eAAe,CAAC,EAAG,QAAO;AACnD,UAAM,SAAS,QAAQ,GAAG;AAC1B,QAAI,WAAW,IAAK,QAAO;AAC3B,UAAM;AAAA,EACR;AACF;AAGO,SAAS,mBAAmB,MAA+C;AAChF,MAAI;AACF,UAAM,WAAW,KAAK,MAAM,aAAaA,MAAK,MAAM,eAAe,GAAG,MAAM,CAAC;AAC7E,UAAM,WAAW,SAAS;AAC1B,QAAI,CAAC,YAAY,OAAO,aAAa,SAAU,QAAO;AACtD,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAUO,SAAS,cAAc,MAAkC;AAC9D,MAAI;AACF,QAAI,MAAM,QAAQ,IAAI;AACtB,eAAS;AACP,YAAM,SAASA,MAAK,KAAK,MAAM;AAC/B,UAAI,WAAW,MAAM,GAAG;AACtB,YAAI,SAAS;AACb,YAAID,UAAS,MAAM,EAAE,OAAO,GAAG;AAC7B,gBAAM,UAAU,oBAAoB,KAAK,aAAa,QAAQ,MAAM,CAAC;AACrE,cAAI,CAAC,QAAS,QAAO;AACrB,gBAAM,SAAS,QAAQ,CAAC,EAAG,KAAK;AAChC,mBAAS,WAAW,MAAM,IAAI,SAAS,QAAQ,KAAK,MAAM;AAAA,QAC5D;AACA,cAAM,OAAO,aAAaC,MAAK,QAAQ,MAAM,GAAG,MAAM,EAAE,KAAK;AAC7D,cAAM,MAAM,6BAA6B,KAAK,IAAI;AAClD,eAAO,MAAM,IAAI,CAAC,EAAG,KAAK,IAAI;AAAA,MAChC;AACA,YAAM,SAAS,QAAQ,GAAG;AAC1B,UAAI,WAAW,IAAK,QAAO;AAC3B,YAAM;AAAA,IACR;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAMO,SAAS,gBAAgB,KAAa,KAAc,MAAyB,QAAQ,KAAyB;AACnH,QAAM,OAAO,gBAAgB,GAAG;AAChC,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,WAAW,mBAAmB,IAAI;AACxC,MAAI,CAAC,SAAU,QAAO;AACtB,SAAO,SAAS,UAAU,EAAE,KAAK,QAAQ,cAAc,IAAI,GAAG,IAAI,CAAC;AACrE;;;AFnOO,SAAS,gBAAgB,MAAc,KAAyB;AACrE,MAAI,OAAO,SAAS,IAAI;AACxB,MAAI;AACJ,MAAI;AACF,UAAM,WAAW,KAAK,MAAMC,cAAaC,MAAK,MAAM,eAAe,GAAG,MAAM,CAAC;AAC7E,QAAI,OAAO,SAAS,iBAAiB,YAAY,SAAS,aAAc,QAAO,SAAS;AACxF,UAAM,WAAW,SAAS;AAC1B,QAAI,YAAY,OAAO,SAAS,eAAe,YAAY,SAAS,WAAY,cAAa,SAAS;AAAA,EACxG,QAAQ;AAAA,EAGR;AAEA,QAAM,WAAW,oBAAoB,MAAM,EAAE,IAAI,CAAC;AAClD,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA,KAAK,SAAS,cAAc,SAAS,UAAU;AAAA,IAC/C,QAAQ,SAAS;AAAA,EACnB;AACF;AAGO,SAAS,iBAAiB,OAA4B;AAC3D,QAAM,QAAQ,CAAC,MAAM,IAAI;AACzB,MAAI,MAAM,WAAY,OAAM,KAAK,IAAI,MAAM,UAAU,GAAG;AACxD,QAAM,OAAO,MAAM,KAAK,GAAG;AAC3B,SAAO,MAAM,MAAM,GAAG,IAAI,SAAM,MAAM,GAAG,KAAK;AAChD;AAGO,SAAS,WAAW,KAAa,KAAgC;AACtE,QAAM,OAAO,gBAAgB,GAAG;AAChC,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,iBAAiB,gBAAgB,MAAM,GAAG,CAAC;AACpD;AAEA,SAAS,OAAa;AACpB,MAAI;AACF,UAAM,OAAO,QAAQ,KAAK,MAAM,CAAC;AACjC,QAAI,KAAK,CAAC,MAAM,SAAS;AAGvB,YAAM,MAAM,gBAAgB,QAAQ,IAAI,GAAG,KAAK,CAAC,CAAC;AAClD,UAAI,IAAK,SAAQ,OAAO,MAAM,GAAG,GAAG;AAAA,CAAI;AAAA,UACnC,SAAQ,WAAW;AACxB;AAAA,IACF;AACA,UAAM,OAAO,WAAW,QAAQ,IAAI,CAAC;AACrC,QAAI,KAAM,SAAQ,OAAO,MAAM,IAAI;AAAA,EACrC,QAAQ;AAAA,EAER;AACF;AAOA,SAAS,eAAwB;AAC/B,MAAI,CAAC,QAAQ,KAAK,CAAC,EAAG,QAAO;AAC7B,MAAI;AACF,WAAO,YAAY,QAAQ,cAAc,aAAa,QAAQ,KAAK,CAAC,CAAC,CAAC,EAAE;AAAA,EAC1E,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,IAAI,aAAa,EAAG,MAAK;",
6
+ "names": ["readFileSync", "join", "statSync", "join", "readFileSync", "join"]
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delorenj/pjangler",
3
- "version": "1.3.7",
3
+ "version": "1.4.3",
4
4
  "description": "Project subsystem bootstrapper CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -23,7 +23,7 @@
23
23
  "node": ">=20"
24
24
  },
25
25
  "scripts": {
26
- "build": "esbuild src/index.ts src/mcp-server.ts src/prompt.ts --bundle --packages=external --platform=node --format=esm --outdir=dist",
26
+ "build": "esbuild src/index.ts src/mcp-server.ts src/prompt.ts --bundle --packages=external --platform=node --format=esm --sourcemap --outdir=dist && node scripts/export-project-notebook-skill.mjs",
27
27
  "check:audit:prod": "npm audit --omit=dev",
28
28
  "check:lock": "node scripts/check-package-lock-parity.mjs",
29
29
  "check:submodules": "node scripts/check-submodule-contract.mjs",
@@ -32,12 +32,16 @@
32
32
  "mcp": "node dist/mcp-server.js",
33
33
  "typecheck": "tsc --noEmit",
34
34
  "test:bmad-installer-contract": "node tests/bmad-installer-contract-regressions.mjs",
35
- "test": "npm run check:lock && node tests/package-lock-parity-regressions.mjs && npm run check:submodules && npm run check:tracked-secrets && node tests/portable-test-paths-regressions.mjs && node tests/release-regressions.mjs && node tests/submodule-contract-regressions.mjs && node tests/secret-publication-gate-regressions.mjs && node tests/bmad-version-surface-regressions.mjs && node tests/bmad-transaction-regressions.mjs && node tests/parity-migrate-regressions.mjs && node tests/hermes-profile-inheritance-regressions.mjs && node tests/pjan-57-lifecycle-recipes-regressions.mjs && node tests/pjan-57-dogfood-regressions.mjs && node tests/generated-project-lifecycle-regressions.mjs && node tests/pack-flatten-regressions.mjs && node tests/pack-flatten-cross-engine-regressions.mjs && node tests/registry-cache-parity-regressions.mjs && node tests/registry-root-ladder-regressions.mjs && node tests/pjan-23-regressions.mjs && node tests/pjan-24-regressions.mjs && node tests/pjan-28-regressions.mjs && node tests/pjan-30-regressions.mjs && node tests/pjan-31a-regressions.mjs && node tests/pjan-36-regressions.mjs && node tests/pjan-43-regressions.mjs && node tests/pjan-48-regressions.mjs && node tests/pjan-49-regressions.mjs && node tests/pjan-50-regressions.mjs && node tests/pjan-65-regressions.mjs && node tests/pjan-67-lifecycle-preflight-regressions.mjs && node tests/pjan-67-regressions.mjs && node tests/pjan-67-trusted-lifecycle-regressions.mjs && node tests/pjan-71-regressions.mjs && node tests/pjan-72-regressions.mjs && node tests/pjan-75-regressions.mjs && node tests/pjan-76-regressions.mjs && node tests/skillex-init-regressions.mjs && node tests/fleet-shared-bloodbank-regressions.mjs && node tests/mcp-catalog-regressions.mjs && node tests/mcp-server-regressions.mjs && node tests/project-registry-regressions.mjs && node tests/pg-registry-regressions.mjs && node tests/momo-lifecycle-plane-regressions.mjs",
35
+ "test:pjan-77": "node tests/pjan-77-notebook-domain-regressions.mjs && node tests/pjan-77-notebook-adapter-contract.mjs && node tests/pjan-77-notebook-lifecycle-regressions.mjs && node tests/pjan-77-notebook-cli-contract.mjs && node tests/pjan-77-notebook-hooks-capture.mjs && node tests/pjan-77-notebook-security-isolation.mjs && node tests/pjan-77-notebook-release-gates.mjs",
36
+ "test": "node scripts/run-tests.mjs",
36
37
  "migrate:up": "node-pg-migrate --migrations-dir migrations up",
37
38
  "migrate:down": "node-pg-migrate --migrations-dir migrations down",
38
39
  "migrate:create": "node-pg-migrate --migrations-dir migrations create",
39
40
  "prepublishOnly": "npm run check:lock && npm run check:submodules -- --remote --recursive --archive --npm && npm run build && npm run check:tracked-secrets",
40
- "test:hermes-profile-inheritance": "node tests/hermes-profile-inheritance-regressions.mjs"
41
+ "test:hermes-profile-inheritance": "node tests/hermes-profile-inheritance-regressions.mjs",
42
+ "test:coverage": "npm run build && c8 --reporter=json-summary --reporter=text-summary --src=src --report-dir=coverage node scripts/run-tests.mjs",
43
+ "coverage:check": "node scripts/coverage-ratchet.mjs",
44
+ "coverage:apply": "node scripts/coverage-ratchet.mjs --apply"
41
45
  },
42
46
  "keywords": [
43
47
  "cli",
@@ -59,6 +63,7 @@
59
63
  "devDependencies": {
60
64
  "@types/node": "^25.9.1",
61
65
  "@types/pg": "^8.15.0",
66
+ "c8": "^10.1.3",
62
67
  "esbuild": "^0.25.0",
63
68
  "node-pg-migrate": "^8.0.0",
64
69
  "typescript": "^5"
@@ -114,18 +114,27 @@ _exclude:
114
114
  # into the caller's shared per-user CLI configs. Scaffolding must never mutate
115
115
  # global state as a side effect — the operator trusts/enters the repo themselves.
116
116
  _tasks:
117
- - "cp ~/.config/git/ignore .gitignore 2>/dev/null || echo '# Add ignores here' > .gitignore"
117
+ # PJAN-82: seed the base ignore only when there is nothing to lose. `cp` over
118
+ # an existing .gitignore silently discarded whatever the repo already had,
119
+ # every time the template was re-rendered into a live project.
120
+ - "[ -s .gitignore ] || cp ~/.config/git/ignore .gitignore 2>/dev/null || [ -s .gitignore ] || echo '# Add ignores here' > .gitignore"
118
121
  # Ensure secrets + per-dev agent-hook/skill overrides are ignored (appended after the base ignore).
119
122
  # Each CLI root's hand-owned config is durable project state; the skill
120
123
  # projections inside it are not — bmad-method and skills:sync rewrite them
121
124
  # on every run, so they are re-ignored after the un-ignore lines (last
122
125
  # match wins). _bmad/_config/manifest.yaml pins the version that
123
126
  # reproduces them exactly.
124
- - "printf '\\n# Secrets + per-dev agent-hook/skill overrides (see .agents/local.example.json)\\n.env\\n.env.*\\n!.env.op\\n.agents/local.json\\n.lastagent\\n**/.claude/settings.local.json\\n\\n# Generated CLI configurations are durable project state...\\n!.claude/\\n!.claude/**\\n!.codex/\\n!.codex/**\\n!.gemini/\\n!.gemini/**\\n!.copilot/\\n!.copilot/**\\n!.opencode/\\n!.opencode/**\\n!.kimi-code/\\n!.kimi-code/**\\n# ...but their skill projections are regenerated, so they stay out of the tree.\\n/.agents/skills/\\n.claude/skills/\\n.codex/skills/\\n.gemini/skills/\\n.copilot/skills/\\n.opencode/skills/\\n.kimi-code/skills/\\n' >> .gitignore"
125
- - "ln -sf AGENTS.md CLAUDE.md"
126
- - "ln -sf AGENTS.md GEMINI.md"
127
+ # PJAN-82: appended once (a re-render used to duplicate the whole block), and
128
+ # the skill patterns carry no trailing slash so a SYMLINKED projection is
129
+ # ignored too .gemini/.copilot/.kimi-code get a symlink, not a directory.
130
+ - "grep -q '^# Generated CLI configurations are durable project state' .gitignore 2>/dev/null || printf '\\n# Secrets + per-dev agent-hook/skill overrides (see .agents/local.example.json)\\n.env\\n.env.*\\n!.env.op\\n.agents/local.json\\n.lastagent\\n**/.claude/settings.local.json\\n\\n# Generated CLI configurations are durable project state...\\n!.claude/\\n!.claude/**\\n!.codex/\\n!.codex/**\\n!.gemini/\\n!.gemini/**\\n!.copilot/\\n!.copilot/**\\n!.opencode/\\n!.opencode/**\\n!.kimi-code/\\n!.kimi-code/**\\n# ...but their skill projections are regenerated, so they stay out of the tree.\\n# No trailing slash: some CLIs get a real directory here and some a symlink.\\n/.agents/skills\\n.claude/skills\\n.codex/skills\\n.gemini/skills\\n.copilot/skills\\n.opencode/skills\\n.kimi-code/skills\\n' >> .gitignore"
131
+ # PJAN-82: use the managed script, which refuses to replace a REAL CLAUDE.md.
132
+ # `ln -sf` cannot distinguish a stale link it owns from a hand-written file it
133
+ # must not touch, so a re-render into a live project destroyed the file and
134
+ # reported success.
135
+ - "./.mise/scripts/link-agentfiles.sh \"$(pwd)\""
127
136
  # Materialize the declared Skillex packs' manifest + symlink topology
128
137
  # without copying pack content or disturbing project-specific skills.
129
- - "python3 .mise/scripts/provision-packs.py"
138
+ - "python3 .mise/scripts/provision-packs.py --root \"$(pwd)\""
130
139
  # Stamp the repo's absolute path into .project.json (the SOT) post-render.
131
140
  - "python3 -c \"import json,os,pathlib; p=pathlib.Path('.project.json'); d=json.loads(p.read_text()); d['repo_path']=os.getcwd(); p.write_text(json.dumps(d,indent=2)+chr(10))\""
@@ -1,9 +1,42 @@
1
1
  #!/bin/bash
2
+ set -eu
2
3
 
3
- if [ -f AGENTS.md ]; then
4
- ln -sf AGENTS.md CLAUDE.md
5
- ln -sf AGENTS.md GEMINI.md
6
- echo "✅ AI agent symlinks verified"
4
+ # Symlink the per-CLI agent instruction files at AGENTS.md.
5
+ #
6
+ # A mise ENTER hook runs with cwd set to the directory the user cd'd into, NOT
7
+ # to config_root measured on mise 2026.8.10, and true even for a parent config
8
+ # when the entered directory is a nested child. `mise run <task>` does run at
9
+ # config_root, which is why the old cwd-relative version looked correct for
10
+ # years: only the enter-hook path was wrong. So take the root explicitly, and
11
+ # fall back to this script's own location — never to cwd.
12
+ root="${1:-${MISE_CONFIG_ROOT:-}}"
13
+ own_root=$(CDPATH= cd "$(dirname "$0")/../.." && pwd -P)
14
+ if [ -z "$root" ]; then
15
+ root="$own_root"
7
16
  else
8
- echo "No AGENTS.md file found. Can't symlink."
17
+ root=$(CDPATH= cd "$root" && pwd -P)
9
18
  fi
19
+ if [ "$root" != "$own_root" ]; then
20
+ echo "link-agentfiles: refusing to act on $root; this script belongs to $own_root" >&2
21
+ echo "link-agentfiles: a nested repo must ship its own .mise/scripts copy" >&2
22
+ exit 1
23
+ fi
24
+
25
+ CDPATH= cd "$root"
26
+ if [ ! -f AGENTS.md ]; then
27
+ echo "No AGENTS.md in $root. Nothing to symlink."
28
+ exit 0
29
+ fi
30
+
31
+ # `ln -sf` cannot tell a stale link it owns from a real file it must not touch:
32
+ # -f means "unlink whatever is there". A hand-written CLAUDE.md was silently
33
+ # replaced by a symlink, and the script printed a green checkmark while doing it.
34
+ for name in CLAUDE.md GEMINI.md; do
35
+ if [ -e "$name" ] && [ ! -L "$name" ]; then
36
+ echo "link-agentfiles: refusing to replace the real file $root/$name with a symlink" >&2
37
+ echo "link-agentfiles: move its content into AGENTS.md, then delete $name" >&2
38
+ exit 1
39
+ fi
40
+ ln -sfn AGENTS.md "$name"
41
+ done
42
+ echo "✅ AGENTS.md links verified in $root"
@@ -29,6 +29,7 @@ from __future__ import annotations
29
29
 
30
30
  import importlib.util
31
31
  import json
32
+ import argparse
32
33
  import os
33
34
  import re
34
35
  import shutil
@@ -286,13 +287,46 @@ def atomic_write(path: Path, content: bytes, mode: int) -> None:
286
287
  # --------------------------------------------------------------------------- #
287
288
 
288
289
 
290
+ def own_root() -> Path:
291
+ """The repo this script file belongs to: <root>/.mise/scripts/<this>."""
292
+ return Path(__file__).resolve().parents[2]
293
+
294
+
295
+ def resolved_project_root(requested: str | None) -> Path:
296
+ """Resolve the repo to provision, refusing to infer it from cwd.
297
+
298
+ A mise ENTER hook runs with cwd set to the directory the user cd'd into, not
299
+ config_root -- including a PARENT config's hook. That is how 33GOD's copy of
300
+ this script came to force-rewrite `pjangler/.agents/skills.json` and plant 75
301
+ dangling pack links in `pjangler/.agents/skills`. `mise run <task>` does run
302
+ at config_root, so the cwd assumption was only ever wrong on the hook path.
303
+ """
304
+ mine = own_root()
305
+ if requested is None:
306
+ requested = os.environ.get("MISE_CONFIG_ROOT") or None
307
+ if requested is None:
308
+ raise SystemExit(
309
+ "provision-packs: --root (or $MISE_CONFIG_ROOT) is required; "
310
+ "refusing to infer the subject repo from cwd"
311
+ )
312
+ resolved = Path(requested).resolve(strict=True)
313
+ if resolved != mine:
314
+ raise SystemExit(
315
+ f"provision-packs: refusing to act on {resolved}; this script "
316
+ f"belongs to {mine}. A nested repo must ship its own "
317
+ ".mise/scripts copy."
318
+ )
319
+ return resolved
320
+
321
+
289
322
  def provision(
290
323
  *,
324
+ root: str | None = None,
291
325
  after_preflight: Callable[[], None] | None = None,
292
326
  create_link: Callable[[Path, Path, int], None] | None = None,
293
327
  after_apply: Callable[[Path, Path], None] | None = None,
294
328
  ) -> int:
295
- project_root = Path.cwd().resolve(strict=True)
329
+ project_root = resolved_project_root(root)
296
330
  agents_path = project_root / ".agents"
297
331
  skills_path = agents_path / "skills"
298
332
  agents_existed = agents_path.exists() or agents_path.is_symlink()
@@ -387,10 +421,24 @@ def provision(
387
421
  )
388
422
  except OSError:
389
423
  link_targets_pack = False
424
+ # PJAN-82: a DANGLING symlink here is stale by definition.
425
+ #
426
+ # An entry stopped being recognizable as pack-managed the moment its pack
427
+ # declaration went away: `ownership_roots` no longer contains the pack, so
428
+ # `link_targets_pack` is false, and the normalized manifest no longer names
429
+ # it either — so the checks below skipped it and it stayed forever. That is
430
+ # how momo, bloodbank and candystore each ended up with 76 links into
431
+ # `~/.agents/.cache/registries/.../packs/bmad/6.10.1-next.31/`, a cache
432
+ # directory that no longer exists. A link that resolves to nothing cannot be
433
+ # a hand-authored skill and cannot be serving anyone, so it is reclaimable
434
+ # regardless of where it points. A link that still RESOLVES is left alone
435
+ # unless it is provably pack-managed.
436
+ dangling_link = link_target is not None and not entry.exists()
390
437
  if (
391
438
  entry.name not in expected
392
439
  and entry.name not in managed_manifest_names
393
440
  and not link_targets_pack
441
+ and not dangling_link
394
442
  ):
395
443
  continue
396
444
  target = expected.get(entry.name)
@@ -514,8 +562,18 @@ def provision(
514
562
 
515
563
 
516
564
  def main() -> None:
565
+ parser = argparse.ArgumentParser(description="Provision declared Skillex packs.")
566
+ parser.add_argument(
567
+ "--root",
568
+ default=None,
569
+ help=(
570
+ "Project root to provision; defaults to $MISE_CONFIG_ROOT. Never "
571
+ "cwd -- see resolved_project_root()."
572
+ ),
573
+ )
574
+ args = parser.parse_args()
517
575
  try:
518
- changed = provision()
576
+ changed = provision(root=args.root)
519
577
  except (FileNotFoundError, OSError, ValueError, RuntimeError, engine.PackUnavailable) as error:
520
578
  raise SystemExit(
521
579
  f"Skillex pack provisioning failed: {error}; "