@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.
- package/dist/index.js +3336 -1359
- package/dist/index.js.map +7 -0
- package/dist/mcp-server.js +1520 -838
- package/dist/mcp-server.js.map +7 -0
- package/dist/prompt.js +2 -1
- package/dist/prompt.js.map +7 -0
- package/package.json +8 -4
- package/templates/hermes-agent/copier.yml +16 -3
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
- package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
- package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
- package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
- package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
- package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
- package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +9 -0
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +24 -1
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +54 -8
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +25 -2
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +5 -16
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +2 -16
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
- package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
- package/templates/hermes-agent/template/hermes.jinja +1 -1
- 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.
|
|
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": "
|
|
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.
|
|
17
|
-
# 7. Optionally
|
|
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
|
-
|
|
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.
|
|
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
|
|
41
|
-
lowercase, past-tense action for events).
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
|
|
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
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
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
|
|
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", "
|
|
79
|
-
"
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|