@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.
- package/README.md +41 -520
- package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
- package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
- package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
- package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
- package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
- package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
- package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
- package/dist/index.js +5747 -13483
- package/dist/mcp-server.js +6853 -14059
- package/dist/notebook/journal-service-main.js +5682 -0
- package/dist/project-registry-service.js +748 -0
- package/dist/prompt.js +2 -1
- package/package.json +15 -10
- package/scripts/install-project-registry.mjs +34 -0
- package/scripts/migrate-project-registry.mjs +117 -0
- package/src/project/registryClient.ts +62 -0
- package/templates/commonproject/copier.yml +16 -19
- package/templates/commonproject/template/.agents/hooks/README.md +10 -4
- package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
- package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
- package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
- package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
- package/templates/commonproject/template/.agents/skills.json +0 -1
- package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
- package/templates/commonproject/template/.project.json.jinja +1 -1
- package/templates/commonproject/template/mise.toml.jinja +11 -21
- package/contracts/fleet-contract.yaml +0 -513
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
- package/templates/hermes-agent/copier.yml +0 -186
- package/templates/hermes-agent/template/.gitignore.jinja +0 -11
- package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
- package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
- package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
- package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
- package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
- package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
- package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
- package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
- package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
- package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
- package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
- package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
- package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
- package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
- package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
- package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
- package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
- package/templates/hermes-agent/template/hermes.jinja +0 -109
- package/templates/hermes-agent/template/momo.jinja +0 -177
- 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
|
-
|
|
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.
|
|
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": ">=
|
|
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:
|
|
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
|
|
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 —
|
|
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
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
#
|
|
122
|
-
#
|
|
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
|
-
#
|
|
137
|
-
#
|
|
138
|
-
- "
|
|
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
|
|
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;
|
|
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
|
|
42
|
-
|
|
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 <
|
|
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
|
-
|
|
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":
|
|
47
|
+
"owns_hooks_key": false,
|
|
48
48
|
"lifecycle_events": {
|
|
49
49
|
"user_prompt": "UserPromptSubmit",
|
|
50
50
|
"post_tool": "PostToolUse",
|
|
51
|
-
"session_end": "
|
|
51
|
+
"session_end": "SessionEnd"
|
|
52
52
|
},
|
|
53
|
-
"notes": "
|
|
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": "
|
|
60
|
+
"timeout_unit": "s",
|
|
61
61
|
"lifecycle_events": {
|
|
62
62
|
"user_prompt": "UserPromptSubmit",
|
|
63
63
|
"post_tool": "PostToolUse",
|
|
64
|
-
"session_end": "
|
|
64
|
+
"session_end": "SessionEnd"
|
|
65
65
|
},
|
|
66
|
-
"notes": "
|
|
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": "
|
|
78
|
+
"session_end": "on_session_finalize"
|
|
79
79
|
},
|
|
80
|
-
"notes": "
|
|
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": "
|
|
92
|
+
"session_end": "SessionEnd"
|
|
93
93
|
},
|
|
94
|
-
"notes": "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
|
|