@delorenj/pjangler 1.4.5 → 1.5.2

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 (83) hide show
  1. package/README.md +41 -520
  2. package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
  3. package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
  4. package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
  5. package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
  6. package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
  7. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
  8. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
  9. package/dist/index.js +5747 -13483
  10. package/dist/mcp-server.js +6853 -14059
  11. package/dist/notebook/journal-service-main.js +5682 -0
  12. package/dist/project-registry-service.js +748 -0
  13. package/dist/prompt.js +2 -1
  14. package/package.json +15 -10
  15. package/scripts/install-project-registry.mjs +34 -0
  16. package/scripts/migrate-project-registry.mjs +117 -0
  17. package/src/project/registryClient.ts +62 -0
  18. package/templates/commonproject/copier.yml +16 -19
  19. package/templates/commonproject/template/.agents/hooks/README.md +10 -4
  20. package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
  21. package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
  22. package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
  23. package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
  24. package/templates/commonproject/template/.agents/skills.json +0 -1
  25. package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
  26. package/templates/commonproject/template/.project.json.jinja +1 -1
  27. package/templates/commonproject/template/mise.toml.jinja +11 -21
  28. package/contracts/fleet-contract.yaml +0 -513
  29. package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
  30. package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
  31. package/templates/hermes-agent/copier.yml +0 -186
  32. package/templates/hermes-agent/template/.gitignore.jinja +0 -11
  33. package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
  34. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
  35. package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
  36. package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
  37. package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
  38. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
  39. package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
  40. package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
  41. package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
  42. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
  43. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
  44. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
  45. package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
  46. package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
  47. package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
  48. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
  49. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
  50. package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
  51. package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
  52. package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
  53. package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
  54. package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
  55. package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
  56. package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
  57. package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
  58. package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
  59. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
  60. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
  61. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
  62. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
  63. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
  64. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
  65. package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
  66. package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
  67. package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
  68. package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
  69. package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
  70. package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
  71. package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
  72. package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
  73. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
  74. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
  75. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
  76. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
  77. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
  78. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
  79. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
  80. package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
  81. package/templates/hermes-agent/template/hermes.jinja +0 -109
  82. package/templates/hermes-agent/template/momo.jinja +0 -177
  83. package/templates/hermes-agent/template/role.yaml.jinja +0 -112
package/dist/prompt.js CHANGED
@@ -347,7 +347,8 @@ function readPromptFacts(root, now) {
347
347
  let identifier;
348
348
  try {
349
349
  const manifest = JSON.parse(readFileSync2(join3(root, ".project.json"), "utf8"));
350
- if (typeof manifest.project_slug === "string" && manifest.project_slug) slug = manifest.project_slug;
350
+ const projectId = manifest.project_id ?? manifest.project_slug;
351
+ if (typeof projectId === "string" && projectId.trim()) slug = projectId.trim().toLowerCase();
351
352
  const provider = manifest.ticket_provider;
352
353
  if (provider && typeof provider.identifier === "string" && provider.identifier) identifier = provider.identifier;
353
354
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delorenj/pjangler",
3
- "version": "1.4.5",
3
+ "version": "1.5.2",
4
4
  "description": "Project subsystem bootstrapper CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -16,16 +16,17 @@
16
16
  "contracts",
17
17
  "templates/commonproject/copier.yml",
18
18
  "templates/commonproject/template",
19
- "templates/hermes-agent/copier.yml",
20
- "templates/hermes-agent/template",
21
19
  ".mise/scripts/versioning.sh",
22
- ".mise/scripts/link-agentfiles.sh"
20
+ ".mise/scripts/link-agentfiles.sh",
21
+ "scripts/install-project-registry.mjs",
22
+ "scripts/migrate-project-registry.mjs",
23
+ "src/project/registryClient.ts"
23
24
  ],
24
25
  "engines": {
25
- "node": ">=20"
26
+ "node": ">=24"
26
27
  },
27
28
  "scripts": {
28
- "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",
29
+ "build": "esbuild src/index.ts src/mcp-server.ts src/prompt.ts src/project-registry-service.ts src/notebook/journal-service-main.ts --bundle --packages=external --platform=node --format=esm --sourcemap --outbase=src --outdir=dist && node scripts/export-project-notebook-skill.mjs",
29
30
  "check:audit:prod": "npm audit --omit=dev",
30
31
  "check:lock": "node scripts/check-package-lock-parity.mjs",
31
32
  "check:submodules": "node scripts/check-submodule-contract.mjs",
@@ -40,10 +41,12 @@
40
41
  "migrate:down": "node-pg-migrate --migrations-dir migrations down",
41
42
  "migrate:create": "node-pg-migrate --migrations-dir migrations create",
42
43
  "prepublishOnly": "npm run check:lock && npm run check:submodules -- --remote --recursive --archive --npm && npm run build && npm run check:tracked-secrets",
43
- "test:hermes-profile-inheritance": "node tests/hermes-profile-inheritance-regressions.mjs",
44
- "test:coverage": "npm run build && NODE_OPTIONS=--max-old-space-size=12288 c8 --exclude='**/.pjan-*/**' --reporter=json-summary --reporter=text-summary --src=src --report-dir=coverage node scripts/run-tests.mjs",
44
+ "test:coverage": "npm run build && NODE_OPTIONS=--max-old-space-size=12288 c8 node scripts/run-tests.mjs",
45
45
  "coverage:check": "node scripts/coverage-ratchet.mjs",
46
- "coverage:apply": "node scripts/coverage-ratchet.mjs --apply"
46
+ "coverage:apply": "node scripts/coverage-ratchet.mjs --apply",
47
+ "registry:serve": "node dist/project-registry-service.js",
48
+ "journal:serve": "node dist/notebook/journal-service-main.js",
49
+ "registry:install": "node scripts/install-project-registry.mjs"
47
50
  },
48
51
  "keywords": [
49
52
  "cli",
@@ -56,11 +59,13 @@
56
59
  "license": "MIT",
57
60
  "dependencies": {
58
61
  "@clack/prompts": "^1.4.0",
62
+ "@delorenj/skillex": "0.1.2",
59
63
  "@modelcontextprotocol/sdk": "^1.29.0",
60
64
  "commander": "^14.0.2",
61
65
  "pg": "^8.16.0",
62
66
  "yaml": "^2.9.0",
63
- "zod": "^4.4.3"
67
+ "zod": "^4.4.3",
68
+ "smol-toml": "^1.8.0"
64
69
  },
65
70
  "devDependencies": {
66
71
  "@types/node": "^25.9.1",
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { homedir, userInfo } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+ import { execFileSync } from 'node:child_process';
8
+
9
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
+ const entrypoint = join(root, 'dist/project-registry-service.js');
11
+ if (!existsSync(entrypoint)) throw new Error('Build first: npm run build');
12
+ const directory = join(homedir(), '.config/systemd/user');
13
+ mkdirSync(directory, { recursive: true });
14
+ const unit = join(directory, 'pjangler-project-registry.service');
15
+ const quote = value => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')}"`;
16
+ writeFileSync(unit, `[Unit]\nDescription=PJangler manifest-owned project registry\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=${quote(process.execPath)} ${quote(entrypoint)}\nEnvironment=PGHOST=/var/run/postgresql\nEnvironment=PGDATABASE=33god\nEnvironment=PGUSER=${userInfo().username}\nEnvironment=PJ_REGISTRY_PORT=8764\nRestart=on-failure\nRestartSec=3\n\n[Install]\nWantedBy=default.target\n`);
17
+ const runtimeDir = process.env.XDG_RUNTIME_DIR || `/run/user/${userInfo().uid}`;
18
+ const systemdEnv = { ...process.env, ...(existsSync(runtimeDir) ? { XDG_RUNTIME_DIR: runtimeDir } : {}),
19
+ ...(!process.env.DBUS_SESSION_BUS_ADDRESS && existsSync(join(runtimeDir, 'bus')) ? { DBUS_SESSION_BUS_ADDRESS: `unix:path=${join(runtimeDir, 'bus')}` } : {}) };
20
+ execFileSync('systemd-analyze', ['--user', 'verify', unit], { stdio: 'inherit', env: systemdEnv });
21
+ execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit', env: systemdEnv });
22
+ execFileSync('systemctl', ['--user', 'enable', 'pjangler-project-registry.service'], { stdio: 'inherit', env: systemdEnv });
23
+ execFileSync('systemctl', ['--user', 'restart', 'pjangler-project-registry.service'], { stdio: 'inherit', env: systemdEnv });
24
+ let healthy = false;
25
+ for (let attempt = 0; attempt < 30; attempt++) {
26
+ try {
27
+ const response = await fetch('http://127.0.0.1:8764/health', { signal: AbortSignal.timeout(1000) });
28
+ const health = await response.json();
29
+ if (response.ok && health.ok && health.service === 'pjangler-project-registry') { healthy = true; break; }
30
+ } catch {}
31
+ await delay(200);
32
+ }
33
+ if (!healthy) throw new Error('Registry failed readiness check; inspect journalctl --user -u pjangler-project-registry.service');
34
+ console.log(`Installed and healthy: ${unit}`);
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ // One-way migration: manifests own values; YAML supplies only missing legacy
3
+ // metadata and discovery locations. No live ticket-provider writes.
4
+ import { readFileSync, writeFileSync, existsSync, renameSync, mkdirSync, lstatSync, openSync, closeSync, fsyncSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { resolve, join, dirname } from "node:path";
7
+ import { createHash } from "node:crypto";
8
+ import YAML from "yaml";
9
+ import { normalizeProjectId } from "../src/project/registryClient.ts";
10
+
11
+ const args = process.argv.slice(2);
12
+ const option = (name, fallback) => { const i = args.indexOf(name); return i < 0 ? fallback : args[i + 1]; };
13
+ const apply = args.includes("--apply");
14
+ const legacyPath = resolve(option("--legacy", join(homedir(), ".config/pjangler/projects.yaml")));
15
+ const endpoint = option("--url", process.env.PJ_REGISTRY_URL || "http://localhost:8764").replace(/\/$/, "");
16
+ const extraRepos = args.flatMap((arg, i) => arg === "--repo" ? [resolve(args[i + 1])] : []);
17
+ const hash = text => createHash("sha256").update(text).digest("hex");
18
+ const legacyText = existsSync(legacyPath) ? readFileSync(legacyPath, "utf8") : undefined;
19
+ const legacy = legacyText ? YAML.parse(legacyText) : { projects: {} };
20
+ const projects = new Map();
21
+ for (const [id, record] of Object.entries(legacy.projects || {})) {
22
+ if (!record || typeof record !== "object" || typeof record.repo_path !== "string" || !record.repo_path.trim()) throw new Error(`Legacy project ${id} has no repo_path`);
23
+ const repo = resolve(record.repo_path);
24
+ if (projects.has(repo)) throw new Error(`Duplicate legacy repo_path ${repo}: ${projects.get(repo).id} and ${id}`);
25
+ projects.set(repo, { id, record });
26
+ }
27
+ // Keep authoritative values recursively; only absent fields are inherited.
28
+ const mergeMissing = (current, fallback) => {
29
+ if (current === undefined) return fallback;
30
+ if (!current || typeof current !== "object" || Array.isArray(current) || !fallback || typeof fallback !== "object" || Array.isArray(fallback)) return current;
31
+ const result = { ...current };
32
+ for (const [key, value] of Object.entries(fallback)) result[key] = mergeMissing(result[key], value);
33
+ return result;
34
+ };
35
+ for (const path of extraRepos) if (!projects.has(path)) projects.set(path, { record: {} });
36
+ const planned = [];
37
+ const ids = new Map();
38
+
39
+ // Preflight every location before the first write.
40
+ for (const [repo, { id: legacyId, record }] of projects) {
41
+ const path = join(repo, ".project.json");
42
+ if (!existsSync(path)) throw new Error(`Missing authoritative manifest: ${path}`);
43
+ const fileStat = lstatSync(path);
44
+ if (!fileStat.isFile() || fileStat.isSymbolicLink()) throw new Error(`Expected regular manifest: ${path}`);
45
+ const original = readFileSync(path, "utf8");
46
+ const manifest = JSON.parse(original);
47
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error(`Expected a manifest object: ${path}`);
48
+ const id = normalizeProjectId(manifest.project_id ?? manifest.project_slug ?? legacyId);
49
+ if (manifest.project_id && manifest.project_slug && normalizeProjectId(manifest.project_slug) !== id) throw new Error(`Conflicting identities in ${path}`);
50
+ if (ids.has(id) && ids.get(id) !== path) throw new Error(`Duplicate project_id ${id}: ${path} and ${ids.get(id)}`);
51
+ ids.set(id, path);
52
+ const { name, description, slug: _slug, project_id: _id, repo_path: _repo, notebook, ...extensions } = record;
53
+ const fallback = { ...extensions, project_name: name, project_description: description,
54
+ ...(notebook ? { notebook: { binding: notebook } } : {}) };
55
+ // Empty legacy URL placeholders carry no metadata and canonical writers omit them.
56
+ if (fallback.ticket_provider?.board_url === "") {
57
+ fallback.ticket_provider = { ...fallback.ticket_provider };
58
+ delete fallback.ticket_provider.board_url;
59
+ }
60
+ const desired = mergeMissing(manifest, fallback);
61
+ // An explicit agents map is a complete declaration; do not revive removed roles.
62
+ if (manifest.agents !== undefined) desired.agents = manifest.agents;
63
+ desired.project_id = id;
64
+ delete desired.project_slug;
65
+ desired.repo_path = repo;
66
+ const text = `${JSON.stringify(desired, null, 2)}\n`;
67
+ planned.push({ project_id: id, manifest_path: path, changed: text !== original, originalHash: hash(original), mode: fileStat.mode & 0o777, text });
68
+ }
69
+ const request = async (path, body) => {
70
+ const response = await fetch(`${endpoint}${path}`, { method: "POST", headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
72
+ const result = await response.json();
73
+ if (!response.ok) throw new Error(result.error || `Registry HTTP ${response.status}`);
74
+ return result;
75
+ };
76
+ if (apply) {
77
+ const health = await fetch(`${endpoint}/health`, { signal: AbortSignal.timeout(5_000) });
78
+ if (!health.ok) throw new Error("Registry health check failed before migration");
79
+ const existingResponse = await fetch(`${endpoint}/v1/registry`, { signal: AbortSignal.timeout(15_000) });
80
+ if (!existingResponse.ok) throw new Error("Cannot inspect registry before migration");
81
+ const existing = await existingResponse.json();
82
+ for (const item of planned) {
83
+ const indexed = existing.__registry_status?.[item.project_id]?.manifest_path
84
+ ?? (existing.projects?.[item.project_id]?.repo_path ? join(existing.projects[item.project_id].repo_path, ".project.json") : undefined);
85
+ if (indexed && resolve(indexed) !== item.manifest_path) throw new Error(`Project ID collision ${item.project_id}: index=${indexed}, manifest=${item.manifest_path}`);
86
+ }
87
+ for (const item of planned) {
88
+ if (hash(readFileSync(item.manifest_path, "utf8")) !== item.originalHash) throw new Error(`Manifest changed during migration: ${item.manifest_path}`);
89
+ if (item.changed) {
90
+ const temporary = `${item.manifest_path}.${process.pid}.tmp`;
91
+ const fd = openSync(temporary, "wx", item.mode);
92
+ try { writeFileSync(fd, item.text); fsyncSync(fd); } finally { closeSync(fd); }
93
+ if (hash(readFileSync(item.manifest_path, "utf8")) !== item.originalHash) throw new Error(`Manifest changed before commit: ${item.manifest_path}`);
94
+ renameSync(temporary, item.manifest_path);
95
+ const directory = openSync(dirname(item.manifest_path), "r");
96
+ try { fsyncSync(directory); } finally { closeSync(directory); }
97
+ }
98
+ await request("/v1/index", { manifest_path: item.manifest_path });
99
+ }
100
+ if (legacy.notebook) {
101
+ // The running service may have been edited after the original migration.
102
+ // Re-read immediately before the settings write; a rerun only fills gaps.
103
+ const latestResponse = await fetch(`${endpoint}/v1/registry`, { signal: AbortSignal.timeout(15_000) });
104
+ if (!latestResponse.ok) throw new Error("Cannot inspect current settings before migration");
105
+ const latest = await latestResponse.json();
106
+ const notebook = mergeMissing(latest.notebook, legacy.notebook);
107
+ if (JSON.stringify(notebook) !== JSON.stringify(latest.notebook)) await request("/v1/settings", { notebook });
108
+ }
109
+ // An audit receipt contains locations/hashes only. YAML is no longer a
110
+ // production input; retain the migration source until the caller archives it.
111
+ const receiptPath = resolve(option("--receipt", join(homedir(), ".local/state/pjangler/registry-migration.json")));
112
+ mkdirSync(dirname(receiptPath), { recursive: true });
113
+ writeFileSync(receiptPath, JSON.stringify({ migrated_at: new Date().toISOString(), legacy_path: legacyPath,
114
+ legacy_hash: legacyText ? hash(legacyText) : null, endpoint,
115
+ projects: planned.map(({ text, originalHash, mode, ...item }) => ({ ...item, content_hash: hash(text) })) }, null, 2) + "\n");
116
+ }
117
+ console.log(JSON.stringify({ apply, endpoint, projects: planned.map(({ text, originalHash, mode, ...item }) => item) }, null, 2));
@@ -0,0 +1,62 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+ import { resolve } from "node:path";
4
+
5
+ export const DEFAULT_REGISTRY_URL = "http://localhost:8764";
6
+
7
+ export function isRegistryServiceLocation(location: string): boolean {
8
+ return /^https?:\/\//i.test(location);
9
+ }
10
+
11
+ /** An explicit file location is an import/test adapter; production uses the service. */
12
+ export function resolveRegistryLocation(location?: string, env: NodeJS.ProcessEnv = process.env): string {
13
+ const value = location || env.PJ_PROJECT_REGISTRY || env.PJ_REGISTRY_URL || DEFAULT_REGISTRY_URL;
14
+ if (isRegistryServiceLocation(value)) {
15
+ const url = new URL(value);
16
+ if (url.username || url.password || url.search || url.hash) throw new Error("Registry URL must not contain credentials, a query or a fragment");
17
+ return url.toString().replace(/\/$/, "");
18
+ }
19
+ return resolve(value.startsWith("~/") ? `${homedir()}/${value.slice(2)}` : value);
20
+ }
21
+
22
+ // Existing lifecycle/config APIs are synchronous. Isolate the network wait in
23
+ // a bounded Node child rather than making a file cache a second authority.
24
+ // Request bodies travel over stdin, never through shell interpolation/argv.
25
+ const REQUEST = `
26
+ let input='';
27
+ for await (const chunk of process.stdin) input+=chunk;
28
+ const {url,method,body,timeout}=JSON.parse(input);
29
+ try {
30
+ const response=await fetch(url,{method,headers:{'Content-Type':'application/json'},
31
+ ...(body===undefined?{}:{body:JSON.stringify(body)}),signal:AbortSignal.timeout(timeout)});
32
+ const text=await response.text();
33
+ let result;try{result=JSON.parse(text)}catch{throw new Error('Registry returned invalid JSON')}
34
+ if(!response.ok) throw new Error(result.error||result.message||('Registry HTTP '+response.status));
35
+ process.stdout.write(JSON.stringify(result));
36
+ } catch(error) {process.stderr.write(error.message);process.exitCode=1;}
37
+ `;
38
+
39
+ export function registryRequest<T>(location: string, method: string, path: string, body?: unknown): T {
40
+ const endpoint = resolveRegistryLocation(location);
41
+ if (!isRegistryServiceLocation(endpoint)) throw new Error("This operation requires the project registry service");
42
+ const timeout = 15_000;
43
+ const result = spawnSync(process.execPath, ["--input-type=module", "-e", REQUEST], {
44
+ input: JSON.stringify({ url: `${endpoint}${path}`, method, body, timeout }),
45
+ encoding: "utf8", timeout: timeout + 2_000, maxBuffer: 16 * 1024 * 1024,
46
+ });
47
+ if (result.status !== 0 || result.error) {
48
+ const detail = result.stderr?.trim() || result.error?.message || "request failed";
49
+ throw new Error(`Project registry request failed (${endpoint}): ${detail}. Check pjangler-project-registry.service; local pj info remains available.`);
50
+ }
51
+ try { return JSON.parse(result.stdout) as T; }
52
+ catch { throw new Error(`Project registry returned invalid JSON (${endpoint})`); }
53
+ }
54
+
55
+ export function normalizeProjectId(value: unknown): string {
56
+ if (typeof value !== "string") throw new Error("project_id is required");
57
+ const id = value.trim().toLowerCase();
58
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(id) || id.endsWith("-") || id.length > 128) {
59
+ throw new Error(`Invalid project_id: ${value}; use letters, digits and internal hyphens`);
60
+ }
61
+ return id;
62
+ }
@@ -79,7 +79,7 @@ primary_language:
79
79
  default: python
80
80
  when: false
81
81
 
82
- # Whether to scaffold the project-scoped agent-hooks + skill fan-out layer
82
+ # Whether to scaffold the project-scoped agent-hooks layer
83
83
  # (.agents/hooks + the mise enter/leave wiring that injects into each agent CLI's
84
84
  # per-user config). Defaults true so a bare `copier copy` still gets it. pjangler
85
85
  # passes `--data agent_hooks_layer=false` when the machine already runs a GLOBAL
@@ -91,7 +91,7 @@ agent_hooks_layer:
91
91
  when: false
92
92
 
93
93
  # When agent_hooks_layer is false, omit only the project-scoped hook tree. The
94
- # skills manifest stays — sync-skills.py writes project-local CLI dirs and is safe
94
+ # skills manifest stays — Skillex writes project-local CLI dirs and is safe
95
95
  # even on machines that already run a global ~/.agents/hooks install.
96
96
  # The `.copier-noop-never-matches` sentinel keeps every pattern non-empty when the
97
97
  # layer is enabled, which copier requires.
@@ -107,6 +107,11 @@ _exclude:
107
107
  - "{% if agent_hooks_layer %}.copier-noop-never-matches{% else %}.agents/hooks/**{% endif %}"
108
108
  - "{% if agent_hooks_layer %}.copier-noop-never-matches{% else %}.agents/local.example.json{% endif %}"
109
109
 
110
+ _skip_if_exists:
111
+ # Selection is project intent, not generated output. A refresh must preserve
112
+ # inheritance choices, exclusions, and active packs byte-for-byte.
113
+ - ".agents/skills.json"
114
+
110
115
  # --- Post-generation tasks ---
111
116
  #
112
117
  # NOTE: intentionally does NOT run `mise trust`. Trusting the repo fires the mise
@@ -114,27 +119,19 @@ _exclude:
114
119
  # into the caller's shared per-user CLI configs. Scaffolding must never mutate
115
120
  # global state as a side effect — the operator trusts/enters the repo themselves.
116
121
  _tasks:
117
- # PJAN-82: seed the base ignore only when there is nothing to lose. `cp` over
118
- # an existing .gitignore silently discarded whatever the repo already had,
119
- # every time the template was re-rendered into a live project.
120
- - "[ -s .gitignore ] || cp ~/.config/git/ignore .gitignore 2>/dev/null || [ -s .gitignore ] || echo '# Add ignores here' > .gitignore"
121
- # Ensure secrets + per-dev agent-hook/skill overrides are ignored (appended after the base ignore).
122
- # Each CLI root's hand-owned config is durable project state; the skill
123
- # projections inside it are not — bmad-method and skills:sync rewrite them
124
- # on every run, so they are re-ignored after the un-ignore lines (last
125
- # match wins). _bmad/_config/manifest.yaml pins the version that
126
- # reproduces them exactly.
127
- # PJAN-82: appended once (a re-render used to duplicate the whole block), and
128
- # the skill patterns carry no trailing slash so a SYMLINKED projection is
129
- # ignored too — .gemini/.copilot/.kimi-code get a symlink, not a directory.
130
- - "grep -q '^# Generated CLI configurations are durable project state' .gitignore 2>/dev/null || printf '\\n# Secrets + per-dev agent-hook/skill overrides (see .agents/local.example.json)\\n.env\\n.env.*\\n!.env.op\\n.agents/local.json\\n.lastagent\\n**/.claude/settings.local.json\\n\\n# Generated CLI configurations are durable project state...\\n!.claude/\\n!.claude/**\\n!.codex/\\n!.codex/**\\n!.gemini/\\n!.gemini/**\\n!.copilot/\\n!.copilot/**\\n!.opencode/\\n!.opencode/**\\n!.kimi-code/\\n!.kimi-code/**\\n# ...but their skill projections are regenerated, so they stay out of the tree.\\n# No trailing slash: some CLIs get a real directory here and some a symlink.\\n/.agents/skills\\n.claude/skills\\n.codex/skills\\n.gemini/skills\\n.copilot/skills\\n.opencode/skills\\n.kimi-code/skills\\n' >> .gitignore"
122
+ # A repository ignore file is a portable project contract, never a snapshot
123
+ # of one developer's core.excludesFile. Preserve existing content byte-for-
124
+ # byte and append only the small CommonProject-owned contract when absent.
125
+ # Machine-local backup/editor/CLI exclusions belong in the user's global
126
+ # ignore; `.agents/` is the sole canonical agent-config tree in Git.
127
+ - "touch .gitignore; if [ -s .gitignore ] && [ -n \"$(tail -c 1 .gitignore)\" ]; then printf '\\n' >> .gitignore; fi; grep -Fqx '# CommonProject repository contract' .gitignore 2>/dev/null || printf '%s\\n' '# CommonProject repository contract' >> .gitignore; for pattern in '.env' '.env.*' '!.env.op' '/.agents/local.json' '/.agents/skills'; do grep -Fqx -- \"$pattern\" .gitignore 2>/dev/null || printf '%s\\n' \"$pattern\" >> .gitignore; done"
131
128
  # PJAN-82: use the managed script, which refuses to replace a REAL CLAUDE.md.
132
129
  # `ln -sf` cannot distinguish a stale link it owns from a hand-written file it
133
130
  # must not touch, so a re-render into a live project destroyed the file and
134
131
  # reported success.
135
132
  - "./.mise/scripts/link-agentfiles.sh \"$(pwd)\""
136
- # Materialize the declared Skillex packs' manifest + symlink topology
137
- # without copying pack content or disturbing project-specific skills.
138
- - "python3 .mise/scripts/provision-packs.py --root \"$(pwd)\""
133
+ # One explicit project activation after rendering. Trust only this generated
134
+ # config for this process; do not persist trust or execute enter/watch hooks.
135
+ - "MISE_TRUSTED_CONFIG_PATHS=\"$(pwd)/mise.toml\" mise run skills:sync"
139
136
  # Stamp the repo's absolute path into .project.json (the SOT) post-render.
140
137
  - "python3 -c \"import json,os,pathlib; p=pathlib.Path('.project.json'); d=json.loads(p.read_text()); d['repo_path']=os.getcwd(); p.write_text(json.dumps(d,indent=2)+chr(10))\""
@@ -29,17 +29,17 @@ You normally run nothing — `mise` does it on directory enter/leave (see
29
29
  | **Claude Code** | committed `.claude/settings.json` (`hooks` key, `$CLAUDE_PROJECT_DIR`) | project, committed | nothing to do — every clone gets it |
30
30
  | **Codex** | per-user `~/.codex/hooks.json` (absolute-path entries) | per-user, injected | `mise enter` → inject, `mise leave` → remove |
31
31
  | **Kimi** | per-user `~/.kimi-code/config.toml` (sentinel `[[hooks]]` block) | per-user, injected | `mise enter` → inject, `mise leave` → remove |
32
- | **Hermes** | runtime `config.yaml` `hooks:` + `shell-hooks-allowlist.json`, via an **adapter** | per-deployment | `mise enter` merges (idempotent, backed up) |
32
+ | **Hermes** | runtime `config.yaml` `hooks:` + `shell-hooks-allowlist.json`, via an **adapter** | per-deployment | `mise enter` merges owned commands idempotently |
33
33
 
34
34
  **Kimi** is fully Claude-compatible (identical event names; payload `{prompt}` /
35
35
  `{tool_name, tool_input}` on stdin; `shell:true` commands; edit tools `Write`/`Edit`),
36
36
  so it **reuses the same guard-wrapped scripts** as Claude/Codex — just injected as a
37
37
  sentinel-bounded `[[hooks]]` block in the per-user `config.toml` (timeout in seconds;
38
- `KIMI_CODE_HOME` respected; backed up to `config.toml.caf-bak`; byte-exact reversible).
38
+ `KIMI_CODE_HOME` respected; only the owned sentinel block is replaced).
39
39
 
40
40
  Each injection is **surgical & reversible** — `sync.py` only touches entries
41
- whose command points at this repo's `.agents/hooks/`, backs up before its first
42
- write (`*.caf-bak`), and an uninstall restores the original data.
41
+ whose command points at this repo's `.agents/hooks/`; foreign settings are preserved.
42
+ No adjacent backups are written.
43
43
 
44
44
  ## The hooks
45
45
 
@@ -136,3 +136,9 @@ Without a key the hooks **no-op gracefully** — recall/retain just do nothing.
136
136
 
137
137
  `gitmark/` holds standalone git helpers — committed for convenience, **not** part
138
138
  of the agent-hook fan-out.
139
+
140
+ ## Central hook ownership
141
+
142
+ On a machine with an activated Bloodbank hook-hub, `~/.config/33god/hook-hub/ownership.json` identifies the CLI families and concerns that have completed native cutover. Fanout omits those concerns, removes its earlier injected copies, and preserves all foreign commands. Runtime guards also consult the hub ownership helper, so committed project fallback settings cannot double-fire after cutover. A paused central concern remains owned and is not restored by a later project sync.
143
+
144
+ Without that manifest this template continues to provide standalone project hooks. Codex timeouts are seconds. Claude, Codex, and Kimi use `SessionEnd` for session closure; Hermes uses `on_session_finalize`. Stop/`on_session_end` indicate turn completion.
@@ -4,7 +4,7 @@
4
4
  # Hermes fires shell hooks with the payload piped as JSON on stdin:
5
5
  # {"hook_event_name","tool_name","tool_input","session_id","cwd","extra"}
6
6
  # and runs the command shell=False (argv), so this is invoked as:
7
- # hindsight-hook.sh <on_session_end|post_tool_call>
7
+ # hindsight-hook.sh <on_session_finalize|post_tool_call>
8
8
  #
9
9
  # Why an adapter (not the Claude hindsight scripts verbatim):
10
10
  # - Hermes has NO user-prompt event, so recall has no attach point here.
@@ -16,6 +16,16 @@
16
16
  set -uo pipefail
17
17
 
18
18
  EVENT="${1:-}"
19
+ HUB_CONCERN=""
20
+ case "$EVENT" in
21
+ post_tool_call) HUB_CONCERN=hindsight-retain ;;
22
+ on_session_finalize) HUB_CONCERN=hindsight-session-end ;;
23
+ esac
24
+ if [[ -n "$HUB_CONCERN" && "${BB_HOOK_HUB:-}" != "off" && -f "$HOME/.agents/hooks/hub/ownership.py" ]] &&
25
+ python3 "$HOME/.agents/hooks/hub/ownership.py" "$HUB_CONCERN" --cli hermes; then
26
+ cat >/dev/null 2>&1 || true
27
+ exit 0
28
+ fi
19
29
  HERMES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # .agents/hooks/hermes
20
30
  REPO_ROOT="$(cd "${HERMES_DIR}/../../.." && pwd)" # the project repo (NOT the PM submodule)
21
31
 
@@ -50,7 +60,7 @@ case "$EVENT" in
50
60
  snippet=$(printf '%s' "$content" | head -3 | tr '\n' ' ' | cut -c1-200)
51
61
  retain_detached "code-edit" "Hermes ${tool} edited ${path:-<no-path>}: ${snippet}"
52
62
  ;;
53
- on_session_end)
63
+ on_session_finalize)
54
64
  hook_disabled "hindsight-session-end" && exit 0
55
65
  sid=$(jq -r '.session_id // "unknown"' <<<"$INPUT" 2>/dev/null || echo unknown)
56
66
  retain_detached "session-summary" "Hermes PM session ${sid} ended at $(date -u +%Y-%m-%dT%H:%M:%SZ)"
@@ -44,26 +44,26 @@
44
44
  "config_target": ".claude/settings.json",
45
45
  "base_dir": "$CLAUDE_PROJECT_DIR/.agents/hooks",
46
46
  "timeout_unit": "s",
47
- "owns_hooks_key": true,
47
+ "owns_hooks_key": false,
48
48
  "lifecycle_events": {
49
49
  "user_prompt": "UserPromptSubmit",
50
50
  "post_tool": "PostToolUse",
51
- "session_end": "Stop"
51
+ "session_end": "SessionEnd"
52
52
  },
53
- "notes": "Committed to the repo, so every dev gets these automatically with NO bootstrap. Claude expands $CLAUDE_PROJECT_DIR in hook commands at runtime. sync owns the entire `hooks` key of this file."
53
+ "notes": "Project hooks remain portable fallbacks. Fanout owns only its canonical command paths and preserves foreign groups. Activated Bloodbank hub concerns are omitted automatically."
54
54
  },
55
55
  "codex": {
56
56
  "enabled": true,
57
57
  "dialect": "codex_hooks",
58
58
  "config_target": "~/.codex/hooks.json",
59
59
  "base_dir": "{repo}/.agents/hooks",
60
- "timeout_unit": "ms",
60
+ "timeout_unit": "s",
61
61
  "lifecycle_events": {
62
62
  "user_prompt": "UserPromptSubmit",
63
63
  "post_tool": "PostToolUse",
64
- "session_end": "Stop"
64
+ "session_end": "SessionEnd"
65
65
  },
66
- "notes": "Codex has no project-scoped hook file, so sync INJECTS absolute-path entries into the per-user ~/.codex/hooks.json on `mise enter` and REMOVES them on `mise leave`. Idempotent; backs up to ~/.codex/hooks.json.caf-bak before first write. Set CAF_HOOKS_SKIP_CODEX=1 to opt out (e.g. if you already run these hooks globally)."
66
+ "notes": "Native timeouts are seconds. Fanout replaces only this project hook path and suppresses concerns already owned by the activated hub. SessionEnd is true closure; Stop completes a turn."
67
67
  },
68
68
  "hermes": {
69
69
  "enabled": true,
@@ -75,9 +75,9 @@
75
75
  "timeout_s": 5,
76
76
  "lifecycle_events": {
77
77
  "post_tool": "post_tool_call",
78
- "session_end": "on_session_end"
78
+ "session_end": "on_session_finalize"
79
79
  },
80
- "notes": "Hermes runs hooks shell=False (argv) with the payload as JSON on stdin and requires commands in shell-hooks-allowlist.json. It has NO user-prompt event, so hindsight-recall is not wired. Instead of the Claude-shaped scripts, Hermes uses the ADAPTER (hermes/hindsight-hook.sh) which translates the Hermes payload and pins the project bank (repo basename) (resolve_bank from the runtime cwd would resolve to the PM submodule). sync merges into the per-deployment runtime config.yaml + allowlist (idempotent, backed up, skipped if the runtime is absent). Disable per-dev via local.json disabled_agents:[\"hermes\"]."
80
+ "notes": "Standalone project fallback adapter. on_session_finalize is true session closure; on_session_end is a turn. The active central hub owns fleet hooks and suppresses this fallback."
81
81
  },
82
82
  "kimi": {
83
83
  "enabled": true,
@@ -89,9 +89,9 @@
89
89
  "lifecycle_events": {
90
90
  "user_prompt": "UserPromptSubmit",
91
91
  "post_tool": "PostToolUse",
92
- "session_end": "Stop"
92
+ "session_end": "SessionEnd"
93
93
  },
94
- "notes": "Kimi Code CLI hooks live in the per-user ~/.kimi-code/config.toml (KIMI_CODE_HOME overrides) as a `hooks` array of {event,command,matcher?,timeout}. Kimi is Claude-compatible: identical event names, payload {hook_event_name,session_id,cwd,prompt} / {tool_name,tool_input} on stdin, shell:true commands, timeout in SECONDS, edit tools named Write/Edit. So it reuses the SAME scripts (guard-wrapped) as claude/codex. No project-local config.toml exists, so sync INJECTS a sentinel-bounded [[hooks]] block on enter and removes it on leave (idempotent text-merge, backs up to config.toml.caf-bak). Disable per-dev via local.json disabled_agents:[\"kimi\"]."
94
+ "notes": "Kimi uses ~/.kimi-code/config.toml (or KIMI_CODE_HOME). Native timeouts are seconds. Sentinel-owned fallback hooks are suppressed automatically when the hub owns each concern."
95
95
  }
96
96
  }
97
97
  }
@@ -17,6 +17,19 @@ set -uo pipefail
17
17
  HOOK_ID="${1:-}"
18
18
  shift || true
19
19
 
20
+ # A generated project can keep its portable fallback settings in Git while
21
+ # this machine routes the concern centrally. Paused hub concerns remain owned.
22
+ HUB_CONCERN="$HOOK_ID"
23
+ case "$HUB_CONCERN" in
24
+ skill-check-reminder) HUB_CONCERN=skill-reminder ;;
25
+ merge-forward-session-rebalance) HUB_CONCERN=merge-forward ;;
26
+ esac
27
+ if [[ "${BB_HOOK_HUB:-}" != "off" && -f "$HOME/.agents/hooks/hub/ownership.py" ]] &&
28
+ python3 "$HOME/.agents/hooks/hub/ownership.py" "$HUB_CONCERN"; then
29
+ cat >/dev/null 2>&1 || true
30
+ exit 0
31
+ fi
32
+
20
33
  # shellcheck source=local-config.sh
21
34
  source "$(dirname "${BASH_SOURCE[0]}")/local-config.sh"
22
35