@delorenj/pjangler 1.4.2 → 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 (39) hide show
  1. package/dist/index.js +3336 -1359
  2. package/dist/index.js.map +7 -0
  3. package/dist/mcp-server.js +1520 -838
  4. package/dist/mcp-server.js.map +7 -0
  5. package/dist/prompt.js +2 -1
  6. package/dist/prompt.js.map +7 -0
  7. package/package.json +8 -4
  8. package/templates/hermes-agent/copier.yml +16 -3
  9. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
  10. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
  11. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
  12. package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
  13. package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
  14. package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
  15. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
  16. package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
  17. package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
  18. package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
  19. package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
  20. package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
  21. package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
  22. package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
  23. package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
  24. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
  25. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
  26. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +9 -0
  27. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
  28. package/templates/hermes-agent/template/.scripts/providers/linear.sh +24 -1
  29. package/templates/hermes-agent/template/.scripts/providers/plane.sh +54 -8
  30. package/templates/hermes-agent/template/.scripts/providers/trello.sh +25 -2
  31. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +5 -16
  32. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +2 -16
  33. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
  34. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
  35. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
  36. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
  37. package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
  38. package/templates/hermes-agent/template/hermes.jinja +1 -1
  39. 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": "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",
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.4.2",
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 && node scripts/export-project-notebook-skill.mjs",
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",
@@ -33,12 +33,15 @@
33
33
  "typecheck": "tsc --noEmit",
34
34
  "test:bmad-installer-contract": "node tests/bmad-installer-contract-regressions.mjs",
35
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": "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/pjan-84-global-scope-regressions.mjs && node tests/pjan-84-registry-flag-regressions.mjs && node tests/pjan-84-finding-scope-regressions.mjs && node tests/pjan-84-orphan-adoption-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 && npm run test:pjan-77",
36
+ "test": "node scripts/run-tests.mjs",
37
37
  "migrate:up": "node-pg-migrate --migrations-dir migrations up",
38
38
  "migrate:down": "node-pg-migrate --migrations-dir migrations down",
39
39
  "migrate:create": "node-pg-migrate --migrations-dir migrations create",
40
40
  "prepublishOnly": "npm run check:lock && npm run check:submodules -- --remote --recursive --archive --npm && npm run build && npm run check:tracked-secrets",
41
- "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"
42
45
  },
43
46
  "keywords": [
44
47
  "cli",
@@ -60,6 +63,7 @@
60
63
  "devDependencies": {
61
64
  "@types/node": "^25.9.1",
62
65
  "@types/pg": "^8.15.0",
66
+ "c8": "^10.1.3",
63
67
  "esbuild": "^0.25.0",
64
68
  "node-pg-migrate": "^8.0.0",
65
69
  "typescript": "^5"
@@ -13,8 +13,8 @@
13
13
  # 2. Creates ignored local state at agents/hermes/<role>/runtime/ and delegates
14
14
  # named-profile wiring to `pj migrate hermes.runtime-singleton`
15
15
  # 4. Creates a Plane project in 33god workspace
16
- # 6. Prompts for a BotFather Telegram token, stores in ignored runtime state
17
- # 7. Optionally wires a dedicated Slack app+bot pair into runtime/.env
16
+ # 6. Verifies a BotFather token, stores it in 1Password, and maps an op:// reference
17
+ # 7. Optionally does the same for a dedicated Slack app+bot pair
18
18
  # 8. Records fleet-shared Bloodbank routing identity (no profile consumer)
19
19
  # 9. Installs a profile gateway + heartbeat timer (reconcile + checkpoint)
20
20
  # 10. Appends to ~/.hermes/agents-registry.yaml
@@ -34,6 +34,10 @@ _envops:
34
34
  variable_end_string: "}}"
35
35
  comment_start_string: "{#"
36
36
  comment_end_string: "#}"
37
+ _exclude:
38
+ - "**/__pycache__"
39
+ - "**/*.pyc"
40
+ - "**/*.pyo"
37
41
 
38
42
  # ─── Questions (only what we can't derive) ───────────────────────────────
39
43
 
@@ -111,6 +115,11 @@ soul_tone:
111
115
  - formal
112
116
  - terse
113
117
 
118
+ reconcile_enabled:
119
+ type: bool
120
+ help: "Run autonomous PM ticket-board reconciliation from the heartbeat timer"
121
+ default: true
122
+
114
123
  # ─── Derived (never asked) ───────────────────────────────────────────────
115
124
 
116
125
  agent_id:
@@ -169,5 +178,9 @@ _tasks:
169
178
  - "./.scripts/99-summary.sh"
170
179
 
171
180
  _skip_if_exists:
172
- - hermes
181
+ # role.yaml contains verified channel identities and explicit operator
182
+ # choices (including reconcile_enabled=false). A forced Copier refresh must
183
+ # preserve those durable values; the idempotent tasks below reconcile any
184
+ # newly-required fields and lifecycle assets around them.
185
+ - role.yaml
173
186
  - runtime
@@ -8,7 +8,7 @@ short and high-signal — anything that should ALWAYS be in context.
8
8
  You are **{{display_name}}**, the `{{role}}` agent for the `{{repo}}` repo.
9
9
  Your scope is the repo at the project root. You communicate via Telegram
10
10
  (`@{{repo}}_{{role}}_bot`) and canonical Bloodbank repo events on
11
- `bloodbank.evt.v1.repo.>`, filtered by `data.repo = {{repo}}`.
11
+ `bloodbank.evt.repo.>`, filtered by `data.repo = {{repo}}`.
12
12
 
13
13
  ## Operator
14
14
 
@@ -37,9 +37,12 @@ preambles.
37
37
 
38
38
  Always emit envelope-shaped events with `actor.agent_id = {{agent_id}}` and
39
39
  `producer = hermes-agent:{{agent_id}}` for every consequential action.
40
- Event type format: `bloodbank.v1.<domain>.<entity>.<action>` (5 tokens,
41
- lowercase, past-tense action for events). Naming spec lives at
42
- `~/code/33GOD/bloodbank/docs/event-naming.md`.
40
+ Event type format: `bloodbank.<domain>.<entity>.<action>` (4 tokens,
41
+ lowercase, past-tense action for events). The NATS subject inserts a kind
42
+ marker as segment 2 — `bloodbank.<evt|cmd|rpy>.<domain>.<entity>.<action>`
43
+ (5 tokens). There is no version token in either shape; a breaking payload
44
+ change earns a new action or entity, never a `v<n>` segment. Naming spec
45
+ lives at `~/code/33GOD/bloodbank/docs/event-naming.md`.
43
46
 
44
47
  ## Recent context
45
48
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # Create the initial named Hermes profile and sanitize cloned chat credentials.
2
+ # Create or reconcile the initial named Hermes profile without cloning secrets.
3
3
  # The directory remains REAL. Step 20 delegates final shared-vs-owned link
4
4
  # topology to `pj migrate hermes.runtime-singleton`; no template step may
5
5
  # replace the profile itself with a symlink.
@@ -13,104 +13,104 @@ fi
13
13
  source "$(dirname "$0")/_lib.sh"
14
14
  load_role_env
15
15
 
16
- already_done 10-hermes-profile && { log "[10] profile already created — skipping"; exit 0; }
16
+ PROFILE_HOME="$HOME/.hermes/profiles/$PROFILE_NAME"
17
+
18
+ # Older deployments made the named profile itself a symlink into project
19
+ # state. Following that link here would make the cleanup below delete files
20
+ # from the link target before the singleton-runtime migration can preserve
21
+ # them. Refuse the legacy topology before any profile mutation.
22
+ if [[ -L "$PROFILE_HOME" ]]; then
23
+ die "legacy named profile symlink detected at $PROFILE_HOME; refusing mutation. Run: pj migrate hermes.runtime-singleton '$(project_repo_path 2>/dev/null || printf '%s' "$ROLE_DIR")'"
24
+ fi
25
+ PROFILE_DELTA_SEEDER="$ROLE_DIR/.scripts/lib/profile-config-seed.py"
26
+ [[ -f "$PROFILE_DELTA_SEEDER" && ! -L "$PROFILE_DELTA_SEEDER" ]] \
27
+ || die "trusted profile config seed helper is unavailable: $PROFILE_DELTA_SEEDER"
28
+
29
+ already_done 10-hermes-profile \
30
+ && log "[10] profile marker found — revalidating required profile contract"
31
+
32
+ # Skillex projects the canonical global catalog into ~/.agents/skills. These
33
+ # six skills are the immutable deployed PM contract, not optional suggestions.
34
+ # Validate the complete set before any profile mutation so a partial/missing
35
+ # projection cannot warn and then be falsely reported as provisioned.
36
+ CANONICAL_SKILLS_DIR="${CANONICAL_SKILLS_DIR:-$(config_get fleet.canonical_skills_dir "$HOME/.agents/skills")}"
37
+ CORE_RUNTIME_SKILLS=(
38
+ 33god-projects
39
+ delonet-conventions
40
+ delonet-dotenv
41
+ hermes-pm-template-maintenance
42
+ hindsight
43
+ subagent-driven-development
44
+ )
45
+ OPTIONAL_RUNTIME_SKILLS_TEXT="${SYMLINKED_RUNTIME_SKILLS:-$(config_get fleet.symlinked_runtime_skills '')}"
46
+ read -r -a OPTIONAL_RUNTIME_SKILLS <<< "$OPTIONAL_RUNTIME_SKILLS_TEXT"
47
+ SYMLINKED_RUNTIME_SKILLS=("${CORE_RUNTIME_SKILLS[@]}")
48
+ for skill_name in "${OPTIONAL_RUNTIME_SKILLS[@]}"; do
49
+ [[ -n "$skill_name" ]] || continue
50
+ skill_present=0
51
+ for required_name in "${SYMLINKED_RUNTIME_SKILLS[@]}"; do
52
+ [[ "$required_name" == "$skill_name" ]] && { skill_present=1; break; }
53
+ done
54
+ [[ $skill_present -eq 1 ]] || SYMLINKED_RUNTIME_SKILLS+=("$skill_name")
55
+ done
56
+ missing_runtime_skills=()
57
+ for skill_name in "${SYMLINKED_RUNTIME_SKILLS[@]}"; do
58
+ [[ -f "$CANONICAL_SKILLS_DIR/$skill_name/SKILL.md" ]] \
59
+ || missing_runtime_skills+=("$skill_name")
60
+ done
61
+ if [[ ${#missing_runtime_skills[@]} -gt 0 ]]; then
62
+ clear_done 10-hermes-profile
63
+ die "required Skillex projection missing SKILL.md for: ${missing_runtime_skills[*]} (run the global Skillex sync, then rerun)"
64
+ fi
65
+ log "[10] required Skillex skills validated: ${SYMLINKED_RUNTIME_SKILLS[*]}"
17
66
 
18
67
  log "[10] creating hermes profile: $PROFILE_NAME"
19
- PROFILE_HOME="$HOME/.hermes/profiles/$PROFILE_NAME"
20
68
 
21
69
  if [[ -d "$PROFILE_HOME" ]]; then
22
70
  log " profile dir already exists; reusing"
23
71
  else
24
- # --clone (NOT --clone-all): copies config.yaml, .env, SOUL.md only.
25
- # --clone-all has a recursion bug it copies the entire ~/.hermes tree,
26
- # including profiles/ itself, producing nested profiles/<name>/profiles/<name>/...
27
- # We do explicit skill + plugin + hooks copies below to avoid that.
28
- "$HERMES_BIN" profile create "$PROFILE_NAME" --clone --no-alias
72
+ # `--clone` copies the default profile's .env before a provisioner can
73
+ # inspect it, transiently materializing every credential in the new profile.
74
+ # Start clean; required skills and the project SOUL are installed below.
75
+ "$HERMES_BIN" profile create "$PROFILE_NAME" --no-alias
29
76
  fi
30
77
 
31
- # Manually copy the inheritable bits that --clone doesn't get.
32
- # These are content-only dirs; safe to mirror without recursion risk.
33
- log " mirroring skills, plugins, hooks from default profile"
34
- for sub in skills plugins hooks cron skins; do
35
- src="$HOME/.hermes/$sub"
36
- dst="$PROFILE_HOME/$sub"
37
- if [[ -d "$src" && "$src" != "$PROFILE_HOME"* ]]; then
38
- mkdir -p "$dst"
39
- # cp -R, dereferencing symlinks; -u to preserve newer if dst exists
40
- cp -RLu "$src/." "$dst/" 2>/dev/null || cp -RL "$src/." "$dst/" 2>/dev/null || true
41
- fi
42
- done
43
-
44
78
  # Strip any inherited gateway/runtime state so this profile boots clean.
45
79
  rm -f "$PROFILE_HOME/gateway.pid" "$PROFILE_HOME/gateway_state.json" \
46
80
  "$PROFILE_HOME/processes.json" "$PROFILE_HOME/state.db" 2>/dev/null || true
47
81
  # Belt-and-suspenders: if a profiles/ dir somehow exists, remove it
48
82
  [[ -d "$PROFILE_HOME/profiles" ]] && rm -rf "$PROFILE_HOME/profiles"
49
83
 
50
- # Strip inherited messaging-platform credentials from the cloned .env.
51
- # `profile create --clone` copies the DEFAULT profile's .env verbatim — and the
52
- # default profile is an operator's own agent (e.g. Condaleeza on Slack). Without
53
- # this, every sub-agent inherits the parent's bot token and would (a) hijack the
54
- # parent's Slack/Telegram socket if it ever connects, and (b) crash-loop: the
55
- # gateway treats an inherited-but-unusable platform as "configured", fails to
56
- # connect it, and exits non-fatal only when ZERO platforms are configured. A
57
- # sub-agent must establish its OWN identity via the Wire steps (30-telegram etc),
58
- # never borrow the parent's.
84
+ # A new profile gets an empty Hermes-created .env. Existing profiles are never
85
+ # migrated opportunistically: if a legacy deployment still has raw channel
86
+ # credentials, fail closed and leave the approval-gated migration to the fleet
87
+ # operator. Only key names are reported; values are never printed.
59
88
  PROFILE_ENV="$PROFILE_HOME/.env"
60
89
  if [[ -f "$PROFILE_ENV" ]]; then
61
- # Some Hermes versions implement `profile create --clone` by symlinking the
62
- # cloned .env back to the default profile. Never sanitize through that
63
- # symlink: doing so would delete the operator's fleet-wide provider secrets.
64
- # Break only the .env link into a private, mode-0600 copy first.
65
- if [[ -L "$PROFILE_ENV" ]]; then
66
- log " detaching cloned .env symlink before profile-local sanitization"
67
- PROFILE_ENV_COPY="$PROFILE_HOME/.env.profile-local.$$"
68
- cp -L "$PROFILE_ENV" "$PROFILE_ENV_COPY"
69
- chmod 600 "$PROFILE_ENV_COPY"
70
- mv -fT "$PROFILE_ENV_COPY" "$PROFILE_ENV"
71
- fi
72
- log " stripping inherited platform credentials from profile .env"
73
90
  python3 - "$PROFILE_ENV" <<'PYEOF'
74
- import re, sys, pathlib
91
+ import sys, pathlib
75
92
  p = pathlib.Path(sys.argv[1])
76
- # Identity-bearing platform creds that must be per-agent, not inherited.
77
93
  keys = (
78
- "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS",
79
- "SLACK_SIGNING_SECRET", "SLACK_HOME_CHANNEL",
80
- "TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS",
81
- "DISCORD_BOT_TOKEN", "DISCORD_ALLOWED_USERS", "DISCORD_HOME_CHANNEL",
82
- "DISCORD_HOME_CHANNEL_NAME",
94
+ "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_SIGNING_SECRET",
95
+ "TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN",
83
96
  )
84
- # Only touch *uncommented* assignments (leave the template's `# KEY=` examples).
85
- pat = re.compile(r"^\s*(?:%s)=" % "|".join(keys))
86
- lines = p.read_text().splitlines(keepends=True)
87
- kept = [ln for ln in lines if not pat.match(ln)]
88
- if len(kept) != len(lines):
89
- p.write_text("".join(kept))
97
+ found = []
98
+ for raw in p.read_text(encoding="utf-8").splitlines():
99
+ line = raw.strip()
100
+ if not line or line.startswith("#"):
101
+ continue
102
+ if line.startswith("export "):
103
+ line = line[7:].lstrip()
104
+ name, sep, value = line.partition("=")
105
+ if sep and name.strip() in keys and value.strip():
106
+ found.append(name.strip())
107
+ if found:
108
+ raise SystemExit(
109
+ "raw channel credential assignments require the approval-gated "
110
+ "1Password migration: " + ", ".join(sorted(set(found)))
111
+ )
90
112
  PYEOF
91
- chmod 600 "$PROFILE_ENV"
92
- fi
93
-
94
- if [[ "$ROLE" == "pm" ]]; then
95
- VOX_URL_VALUE="${VOX_URL:-$(config_get fleet.vox_url 'https://vox.delo.sh')}"
96
- if [[ -n "$VOX_URL_VALUE" ]]; then
97
- log " ensuring VOX_URL is present for PM voice mode"
98
- python3 - "$PROFILE_ENV" "$VOX_URL_VALUE" <<'PYEOF'
99
- import pathlib, sys
100
-
101
- path = pathlib.Path(sys.argv[1])
102
- vox_url = sys.argv[2]
103
- lines = path.read_text().splitlines() if path.exists() else []
104
- for idx, line in enumerate(lines):
105
- if line.startswith("VOX_URL="):
106
- lines[idx] = f'VOX_URL="{vox_url}"'
107
- break
108
- else:
109
- lines.append(f'VOX_URL="{vox_url}"')
110
- path.write_text("\n".join(lines) + "\n")
111
- PYEOF
112
- chmod 600 "$PROFILE_ENV"
113
- fi
113
+ [[ -L "$PROFILE_ENV" ]] || chmod 600 "$PROFILE_ENV"
114
114
  fi
115
115
 
116
116
  # Never persist a project-specific terminal.cwd through the named profile.
@@ -127,15 +127,13 @@ fi
127
127
  # Seed an EMPTY delta: a new agent should be identical to the fleet base, and
128
128
  # every line here is an override someone must justify later.
129
129
  PROFILE_DELTA="$PROFILE_HOME/config.delta.yaml"
130
- if [[ ! -f "$PROFILE_DELTA" ]]; then
130
+ profile_delta_seed_result="$(
131
+ python3 -I "$PROFILE_DELTA_SEEDER" --profile "$PROFILE_HOME"
132
+ )" || die "config.delta.yaml seed reconciliation failed"
133
+ if [[ "$profile_delta_seed_result" == "seeded" ]]; then
131
134
  log " seeding empty config.delta.yaml (override-only SSOT)"
132
- cat > "$PROFILE_DELTA" <<'DELTA_EOF'
133
- # Override-only delta for this Hermes profile.
134
- # Merged over ~/.hermes/config.yaml to produce config.yaml (which is GENERATED).
135
- # Empty == identical to the fleet base. Add ONLY what must differ.
136
- {}
137
- DELTA_EOF
138
- chmod 600 "$PROFILE_DELTA"
135
+ elif [[ "$profile_delta_seed_result" != "exists" ]]; then
136
+ die "profile config seed helper returned an invalid result"
139
137
  fi
140
138
 
141
139
  # Pin the identity-memory bank explicitly rather than relying on the fleet
@@ -166,7 +164,6 @@ else
166
164
  fi
167
165
 
168
166
  # Canonical shared-skill source of truth + local PM fallback sync.
169
- CANONICAL_SKILLS_DIR="${CANONICAL_SKILLS_DIR:-$(config_get fleet.canonical_skills_dir '/home/delorenj/.agents/skills')}"
170
167
  CANONICAL_PM_SKILL_SRC="$CANONICAL_SKILLS_DIR/subagent-driven-development"
171
168
  LOCAL_PM_SKILL_DST="$PROFILE_HOME/skills/software-development/subagent-driven-development"
172
169
 
@@ -186,17 +183,14 @@ if [[ -d "$CANONICAL_SKILLS_DIR" ]]; then
186
183
 
187
184
  # Ensure key PM/local-ops skills are symlinked into runtime/profile skills root.
188
185
  # This preserves canonical ownership and keeps updates instant across agents.
189
- read -r -a SYMLINKED_RUNTIME_SKILLS <<< "${SYMLINKED_RUNTIME_SKILLS:-$(config_get fleet.symlinked_runtime_skills 'delonet-conventions delonet-dotenv hermes-pm-template-maintenance hindsight subagent-driven-development')}"
190
186
  mkdir -p "$PROFILE_HOME/skills"
191
187
 
192
188
  for skill_name in "${SYMLINKED_RUNTIME_SKILLS[@]}"; do
193
189
  src="$CANONICAL_SKILLS_DIR/$skill_name"
194
190
  dst="$PROFILE_HOME/skills/$skill_name"
195
191
 
196
- if [[ ! -f "$src/SKILL.md" ]]; then
197
- warn " skipping runtime skill symlink (missing SKILL.md): $src"
198
- continue
199
- fi
192
+ [[ -f "$src/SKILL.md" ]] \
193
+ || die "required Skillex skill disappeared during provisioning: $src/SKILL.md"
200
194
 
201
195
  if [[ -L "$dst" && "$(readlink "$dst")" == "$src" ]]; then
202
196
  log " runtime skill symlink already set: $dst -> $src"
@@ -208,7 +202,7 @@ if [[ -d "$CANONICAL_SKILLS_DIR" ]]; then
208
202
  log " symlinked runtime skill: $dst -> $src"
209
203
  done
210
204
  else
211
- warn " canonical skills dir missing: $CANONICAL_SKILLS_DIR"
205
+ die "canonical Skillex projection directory disappeared during provisioning: $CANONICAL_SKILLS_DIR"
212
206
  fi
213
207
 
214
208
  if [[ -f "$CANONICAL_PM_SKILL_SRC/SKILL.md" ]]; then
@@ -216,7 +210,7 @@ if [[ -f "$CANONICAL_PM_SKILL_SRC/SKILL.md" ]]; then
216
210
  mkdir -p "$LOCAL_PM_SKILL_DST"
217
211
  cp -f "$CANONICAL_PM_SKILL_SRC/SKILL.md" "$LOCAL_PM_SKILL_DST/SKILL.md"
218
212
  else
219
- warn " canonical PM skill missing: $CANONICAL_PM_SKILL_SRC/SKILL.md"
213
+ die "required canonical PM skill disappeared during provisioning: $CANONICAL_PM_SKILL_SRC/SKILL.md"
220
214
  fi
221
215
 
222
216
  # Install the project's SOUL.md into the profile so the agent loads it.
@@ -70,7 +70,25 @@ PYEOF
70
70
  # Never let a literal secret reach the runtime. The scaffold is rendered from
71
71
  # templates, so a leaked credential shows up here before anything is copied.
72
72
  python3 "$(dirname "$0")/secret-scan.py" "$TMP"
73
- cp -an "$TMP/." "$RUNTIME_LOCAL/"
73
+ python3 - "$TMP" "$RUNTIME_LOCAL" <<'PYEOF'
74
+ import os, pathlib, shutil, sys
75
+
76
+ source = pathlib.Path(sys.argv[1])
77
+ target = pathlib.Path(sys.argv[2])
78
+ for current in sorted(source.rglob("*")):
79
+ relative = current.relative_to(source)
80
+ destination = target / relative
81
+ if current.is_dir():
82
+ destination.mkdir(parents=True, exist_ok=True)
83
+ continue
84
+ if os.path.lexists(destination):
85
+ continue
86
+ destination.parent.mkdir(parents=True, exist_ok=True)
87
+ if current.is_symlink():
88
+ destination.symlink_to(os.readlink(current))
89
+ else:
90
+ shutil.copy2(current, destination, follow_symlinks=False)
91
+ PYEOF
74
92
 
75
93
  # Seed mutable identity/config only when no local value exists.
76
94
  if [[ "$ROLE" == "reporter" ]]; then
@@ -148,29 +166,34 @@ log " singleton profile verified by pj migrate hermes.runtime-singleton: $PRO
148
166
  # config.yaml is fleet-shared in the singleton topology. The manual and
149
167
  # service launchers pass TERMINAL_CWD process-locally for this role instead.
150
168
 
151
- profile_config_set() {
152
- local key="$1"
153
- [[ -x "$HERMES_BIN" ]] \
154
- || die "Hermes CLI is not executable; cannot configure named profile: $HERMES_BIN"
155
- if ! env HERMES_HOME="$PROFILE_HOME" "$HERMES_BIN" config set "$@" >/dev/null; then
156
- die "required Hermes config write failed for named profile $PROFILE_NAME: $key"
157
- fi
158
- }
159
-
160
169
  if [[ "$ROLE" == "pm" ]]; then
161
- VOXXY_PLUGIN_DIR="${VOXXY_PLUGIN_DIR:-$(config_get fleet.voxxy_plugin_dir "$HOME/code/voxxy/plugins/tts/voxxy")}"
162
- if [[ -d "$VOXXY_PLUGIN_DIR" ]]; then
163
- mkdir -p "$RUNTIME_LOCAL/plugins/tts"
164
- ln -sfn "$VOXXY_PLUGIN_DIR" "$RUNTIME_LOCAL/plugins/tts/voxxy"
165
- log " linked Voxxy plugin into runtime"
170
+ VOX_PLUGIN_NAME="${VOX_PLUGIN_NAME:-$(config_get fleet.vox_plugin_name 'vox')}"
171
+ VOX_VOICE="${VOX_VOICE:-$(config_get fleet.vox_voice 'carlin')}"
172
+ VOX_PLUGIN_DIR="${VOX_PLUGIN_DIR:-$(config_get fleet.vox_plugin_dir "$HOME/code/voxxy/plugins/tts/$VOX_PLUGIN_NAME")}"
173
+ if [[ -d "$VOX_PLUGIN_DIR" ]]; then
174
+ mkdir -p "$PROFILE_HOME/plugins/tts"
175
+ ln -sfn "$VOX_PLUGIN_DIR" "$PROFILE_HOME/plugins/tts/$VOX_PLUGIN_NAME"
176
+ profile_voice_contract_set "$PROFILE_HOME" "$VOX_PLUGIN_NAME" "$VOX_VOICE" \
177
+ || die "could not write the canonical PM voice contract to config.delta.yaml"
178
+ python3 - "$PROFILE_HOME/config.yaml" "$VOX_PLUGIN_NAME" "$VOX_VOICE" <<'PYEOF' \
179
+ || die "PM voice config is not the canonical vox/carlin contract"
180
+ import pathlib, sys
181
+ try:
182
+ import yaml
183
+ except ImportError:
184
+ raise SystemExit("PyYAML is required to validate PM voice config")
185
+ config = yaml.safe_load(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) or {}
186
+ plugin, voice = sys.argv[2:4]
187
+ enabled = ((config.get("plugins") or {}).get("enabled") or [])
188
+ tts = config.get("tts") or {}
189
+ actual_voice = ((tts.get(plugin) or {}).get("voice") or tts.get("voice") or "")
190
+ if f"tts/{plugin}" not in enabled or tts.get("provider") != plugin or actual_voice != voice:
191
+ raise SystemExit(1)
192
+ PYEOF
193
+ log " PM voice verified: provider=$VOX_PLUGIN_NAME voice=$VOX_VOICE"
166
194
  else
167
- warn " Voxxy plugin dir missing: $VOXXY_PLUGIN_DIR"
195
+ log " optional Vox plugin not installed; PM voice activation deferred"
168
196
  fi
169
-
170
- profile_config_set plugins.enabled.0 tts/voxxy
171
- profile_config_set tts.provider voxxy
172
- profile_config_set tts.voice rick
173
- log " set PM named-profile TTS provider -> voxxy"
174
197
  fi
175
198
 
176
199
  mark_done 20-runtime-repo