@delorenj/pjangler 1.3.0 → 1.3.7

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 (26) hide show
  1. package/README.md +72 -0
  2. package/dist/index.js +4510 -2485
  3. package/dist/mcp-server.js +4166 -1959
  4. package/dist/prompt.js +404 -0
  5. package/package.json +7 -5
  6. package/templates/commonproject/copier.yml +6 -1
  7. package/templates/commonproject/template/.mise/scripts/provision-packs.py +14 -50
  8. package/templates/hermes-agent/copier.yml +8 -11
  9. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +44 -0
  10. package/templates/hermes-agent/template/.scripts/01-config.sh +9 -0
  11. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +18 -28
  12. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +68 -4
  13. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -1
  14. package/templates/hermes-agent/template/.scripts/70-systemd.sh +73 -43
  15. package/templates/hermes-agent/template/.scripts/80-registry.sh +6 -0
  16. package/templates/hermes-agent/template/.scripts/_lib.sh +62 -6
  17. package/templates/hermes-agent/template/.scripts/checkpoint.sh +29 -1
  18. package/templates/hermes-agent/template/.scripts/heartbeat.sh +13 -1
  19. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +202 -0
  20. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +734 -0
  21. package/templates/hermes-agent/template/.scripts/lifecycle.sh +126 -0
  22. package/templates/hermes-agent/template/.scripts/providers/plane.sh +1 -1
  23. package/templates/hermes-agent/template/SOUL.md.jinja +44 -8
  24. package/templates/hermes-agent/template/hermes.jinja +20 -8
  25. package/templates/hermes-agent/template/momo.jinja +177 -0
  26. package/templates/hermes-agent/template/role.yaml.jinja +19 -19
package/dist/prompt.js ADDED
@@ -0,0 +1,404 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/prompt.ts
4
+ import { readFileSync as readFileSync2, realpathSync } from "node:fs";
5
+ import { basename, join as join3 } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ // src/describe/activity.ts
9
+ import { spawn, spawnSync } from "node:child_process";
10
+ import { statSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ var ACTIVE_WINDOW_SECONDS = 24 * 60 * 60;
13
+ var MAX_DIRTY_STATS = 500;
14
+ var GIT_TIMEOUT_MS = 5e3;
15
+ var GIT_MAX_BUFFER = 16 * 1024 * 1024;
16
+ function git(repo, args) {
17
+ const result = spawnSync("git", ["-C", repo, ...args], {
18
+ encoding: "utf8",
19
+ timeout: GIT_TIMEOUT_MS,
20
+ maxBuffer: GIT_MAX_BUFFER
21
+ });
22
+ if (result.status !== 0 || typeof result.stdout !== "string") return void 0;
23
+ return result.stdout;
24
+ }
25
+ function trimmed(raw) {
26
+ if (raw === void 0) return void 0;
27
+ const value = raw.trim();
28
+ return value === "" ? void 0 : value;
29
+ }
30
+ function gitLine(repo, args) {
31
+ return trimmed(git(repo, args));
32
+ }
33
+ function isGitRepo(repo) {
34
+ return gitLine(repo, ["rev-parse", "--is-inside-work-tree"]) === "true";
35
+ }
36
+ var MINUTE = 60;
37
+ var HOUR = 60 * MINUTE;
38
+ var DAY = 24 * HOUR;
39
+ var WEEK = 7 * DAY;
40
+ var MONTH = 30 * DAY;
41
+ var YEAR = 365 * DAY;
42
+ function plural(count, unit) {
43
+ return `${count} ${unit}${count === 1 ? "" : "s"} ago`;
44
+ }
45
+ function formatRelativeAge(deltaSeconds) {
46
+ const delta = Math.max(0, Math.floor(deltaSeconds));
47
+ if (delta < MINUTE) return "just now";
48
+ if (delta < HOUR) return plural(Math.floor(delta / MINUTE), "minute");
49
+ if (delta < DAY) return plural(Math.floor(delta / HOUR), "hour");
50
+ if (delta < WEEK) return plural(Math.floor(delta / DAY), "day");
51
+ if (delta < MONTH) return plural(Math.floor(delta / WEEK), "week");
52
+ if (delta < YEAR) return plural(Math.floor(delta / MONTH), "month");
53
+ return plural(Math.floor(delta / YEAR), "year");
54
+ }
55
+ function formatCompactAge(deltaSeconds) {
56
+ const delta = Math.max(0, Math.floor(deltaSeconds));
57
+ if (delta < MINUTE) return "now";
58
+ if (delta < HOUR) return `${Math.floor(delta / MINUTE)}m`;
59
+ if (delta < DAY) return `${Math.floor(delta / HOUR)}h`;
60
+ if (delta < WEEK) return `${Math.floor(delta / DAY)}d`;
61
+ if (delta < MONTH) return `${Math.floor(delta / WEEK)}w`;
62
+ if (delta < YEAR) return `${Math.floor(delta / MONTH)}mo`;
63
+ return `${Math.floor(delta / YEAR)}y`;
64
+ }
65
+ var REF_ARGS = [
66
+ "for-each-ref",
67
+ "--sort=-committerdate",
68
+ "--format=%(committerdate:unix)%09%(refname:short)",
69
+ "refs/heads",
70
+ "refs/remotes",
71
+ "refs/tags"
72
+ ];
73
+ var WORKTREE_ARGS = ["worktree", "list", "--porcelain"];
74
+ var STATUS_ARGS = ["status", "--porcelain", "-z", "--ignore-submodules=dirty"];
75
+ function parseRefs(raw) {
76
+ if (raw === void 0) return { count: 0 };
77
+ const lines = raw.split("\n").filter((line) => line.trim() !== "");
78
+ if (!lines.length) return { count: 0 };
79
+ const [stamp, name] = lines[0].split(" ");
80
+ const unix = Number(stamp);
81
+ if (!Number.isFinite(unix) || unix <= 0) return { count: lines.length };
82
+ return { source: { kind: "ref", label: name ?? "(unnamed ref)", unix }, count: lines.length };
83
+ }
84
+ function parseWorktrees(raw) {
85
+ if (raw === void 0) return [];
86
+ const entries = [];
87
+ let current = { detached: false };
88
+ const flush = () => {
89
+ if (current.path && current.sha) entries.push({ path: current.path, sha: current.sha, detached: current.detached });
90
+ current = { detached: false };
91
+ };
92
+ for (const line of raw.split("\n")) {
93
+ if (line.startsWith("worktree ")) {
94
+ flush();
95
+ current.path = line.slice("worktree ".length);
96
+ } else if (line.startsWith("HEAD ")) {
97
+ current.sha = line.slice("HEAD ".length).trim();
98
+ } else if (line === "detached") {
99
+ current.detached = true;
100
+ }
101
+ }
102
+ flush();
103
+ return entries;
104
+ }
105
+ function parseWorktreeStamps(raw, entries) {
106
+ if (raw === void 0) return void 0;
107
+ let best;
108
+ for (const line of raw.split("\n")) {
109
+ const [stamp, sha] = line.trim().split(" ");
110
+ const unix = Number(stamp);
111
+ if (!Number.isFinite(unix) || unix <= 0 || !sha) continue;
112
+ if (best && unix <= best.unix) continue;
113
+ const owner = entries.find((entry) => entry.sha === sha);
114
+ const name = owner ? basenameOf(owner.path) : sha.slice(0, 7);
115
+ best = { kind: "worktree", label: owner?.detached ? `${name} (detached)` : name, unix };
116
+ }
117
+ return best;
118
+ }
119
+ function parseStatusPaths(raw) {
120
+ if (raw === void 0) return [];
121
+ const parts = raw.split("\0").filter((part) => part !== "");
122
+ const paths = [];
123
+ for (let index = 0; index < parts.length; index++) {
124
+ const entry = parts[index];
125
+ if (entry.length < 4 || entry[2] !== " ") continue;
126
+ paths.push(entry.slice(3));
127
+ if (entry[0] === "R" || entry[0] === "C") index += 1;
128
+ }
129
+ return paths;
130
+ }
131
+ function basenameOf(path) {
132
+ const parts = path.split("/").filter(Boolean);
133
+ return parts[parts.length - 1] ?? path;
134
+ }
135
+ function uncommittedSource(repo, paths) {
136
+ if (!paths.length) return void 0;
137
+ let newest = 0;
138
+ for (const path of paths.slice(0, MAX_DIRTY_STATS)) {
139
+ try {
140
+ const mtime = Math.floor(statSync(join(repo, path)).mtimeMs / 1e3);
141
+ if (mtime > newest) newest = mtime;
142
+ } catch {
143
+ }
144
+ }
145
+ if (newest <= 0) return void 0;
146
+ const label = paths.length === 1 ? "1 uncommitted file" : `${paths.length} uncommitted files`;
147
+ return { kind: "uncommitted", label, unix: newest };
148
+ }
149
+ var NO_ACTIVITY = {
150
+ updated: null,
151
+ updatedUnix: null,
152
+ relative: "never",
153
+ compact: "\u2014",
154
+ active: false,
155
+ source: null,
156
+ scanned: { refs: 0, worktrees: 0, dirtyFiles: 0 }
157
+ };
158
+ function emptyActivity() {
159
+ return { ...NO_ACTIVITY, scanned: { refs: 0, worktrees: 0, dirtyFiles: 0 } };
160
+ }
161
+ function assembleActivity(candidates, scanned, now) {
162
+ let winner = null;
163
+ for (const candidate of candidates) {
164
+ if (!candidate) continue;
165
+ if (!winner || candidate.unix >= winner.unix) winner = candidate;
166
+ }
167
+ if (!winner) return { ...NO_ACTIVITY, scanned };
168
+ const nowUnix = Math.floor((now?.getTime() ?? Date.now()) / 1e3);
169
+ const delta = nowUnix - winner.unix;
170
+ return {
171
+ updated: new Date(winner.unix * 1e3).toISOString(),
172
+ updatedUnix: winner.unix,
173
+ relative: formatRelativeAge(delta),
174
+ compact: formatCompactAge(delta),
175
+ active: delta < ACTIVE_WINDOW_SECONDS,
176
+ source: winner,
177
+ scanned
178
+ };
179
+ }
180
+ function computeRepoActivity(repo, options = {}) {
181
+ if (!isGitRepo(repo)) return emptyActivity();
182
+ const refs = parseRefs(git(repo, REF_ARGS));
183
+ const worktrees = parseWorktrees(git(repo, WORKTREE_ARGS));
184
+ const shas = [...new Set(worktrees.map((entry) => entry.sha))];
185
+ const worktreeSource = shas.length ? parseWorktreeStamps(git(repo, ["show", "-s", "--format=%ct %H", ...shas]), worktrees) : void 0;
186
+ const paths = parseStatusPaths(git(repo, STATUS_ARGS));
187
+ return assembleActivity(
188
+ [refs.source, worktreeSource, uncommittedSource(repo, paths)],
189
+ { refs: refs.count, worktrees: worktrees.length, dirtyFiles: paths.length },
190
+ options.now
191
+ );
192
+ }
193
+
194
+ // src/project/boardUrl.ts
195
+ import { existsSync, readFileSync, statSync as statSync2 } from "node:fs";
196
+ import { homedir } from "node:os";
197
+ import { dirname, isAbsolute, join as join2, resolve } from "node:path";
198
+ var DEFAULT_PLANE_BASE = "https://plane.delo.sh";
199
+ var DEFAULT_PLANE_WORKSPACE = "33god";
200
+ function resolveTemplateConfigPath(env = process.env, home = homedir()) {
201
+ const fromEnv = env.HERMES_TEMPLATE_CONFIG;
202
+ if (fromEnv && fromEnv.trim()) return fromEnv.trim();
203
+ const xdg = env.XDG_CONFIG_HOME?.trim();
204
+ const base = xdg && xdg.length ? xdg : join2(home, ".config");
205
+ return join2(base, "hermes-agent-template", "config.toml");
206
+ }
207
+ function readTomlScalar(text, section, key) {
208
+ let inSection = false;
209
+ for (const raw of text.split("\n")) {
210
+ const line = raw.trim();
211
+ if (!line || line.startsWith("#")) continue;
212
+ if (line.startsWith("[")) {
213
+ inSection = line === `[${section}]`;
214
+ continue;
215
+ }
216
+ if (!inSection) continue;
217
+ const eq = line.indexOf("=");
218
+ if (eq === -1) continue;
219
+ if (line.slice(0, eq).trim() !== key) continue;
220
+ const value = line.slice(eq + 1).trim();
221
+ const quoted = /^"([^"]*)"|^'([^']*)'/.exec(value);
222
+ if (quoted) return quoted[1] ?? quoted[2];
223
+ const bare = (value.split("#")[0] ?? "").trim();
224
+ return bare || void 0;
225
+ }
226
+ return void 0;
227
+ }
228
+ function readTemplateConfig(env, home) {
229
+ try {
230
+ const path = resolveTemplateConfigPath(env, home);
231
+ return existsSync(path) ? readFileSync(path, "utf8") : void 0;
232
+ } catch {
233
+ return void 0;
234
+ }
235
+ }
236
+ function planeBase(env = process.env, home = homedir()) {
237
+ const fromEnv = env.PLANE_BASE?.trim();
238
+ if (fromEnv) return fromEnv.replace(/\/+$/, "");
239
+ const config = readTemplateConfig(env, home);
240
+ const fromConfig = config ? readTomlScalar(config, "plane", "base")?.trim() : void 0;
241
+ if (fromConfig) return fromConfig.replace(/\/+$/, "");
242
+ return DEFAULT_PLANE_BASE;
243
+ }
244
+ function planeWorkspace(provider, env, home) {
245
+ const fromManifest = provider.workspace?.trim();
246
+ if (fromManifest) return fromManifest;
247
+ const config = readTemplateConfig(env, home);
248
+ const fromConfig = config ? readTomlScalar(config, "plane", "workspace")?.trim() : void 0;
249
+ return fromConfig || DEFAULT_PLANE_WORKSPACE;
250
+ }
251
+ function escapeRegExp(value) {
252
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
253
+ }
254
+ function extractTicketRef(branch, identifier) {
255
+ if (!branch || !identifier) return void 0;
256
+ const ident = identifier.trim();
257
+ if (!ident) return void 0;
258
+ const match = new RegExp(`\\b${escapeRegExp(ident)}-(\\d+)\\b`, "i").exec(branch);
259
+ return match ? `${ident.toUpperCase()}-${match[1]}` : void 0;
260
+ }
261
+ function normalizeTicketRef(input, identifier) {
262
+ const value = input?.trim();
263
+ if (!value) return void 0;
264
+ if (/^\d+$/.test(value)) {
265
+ const ident = identifier?.trim();
266
+ return ident ? `${ident.toUpperCase()}-${value}` : void 0;
267
+ }
268
+ const qualified = /^([A-Za-z][A-Za-z0-9]*)-(\d+)$/.exec(value);
269
+ if (!qualified) return void 0;
270
+ return `${qualified[1].toUpperCase()}-${qualified[2]}`;
271
+ }
272
+ function resolveTicketRef(provider, options) {
273
+ return normalizeTicketRef(options.ref, provider.identifier) ?? extractTicketRef(options.branch, provider.identifier);
274
+ }
275
+ function boardUrl(provider, options = {}) {
276
+ if (!provider) return void 0;
277
+ const env = options.env ?? process.env;
278
+ const home = options.home ?? homedir();
279
+ const type = (provider.type || "plane").trim().toLowerCase();
280
+ const boardId = provider.board_id?.trim();
281
+ if (!boardId) return void 0;
282
+ if (type === "trello") {
283
+ return `https://trello.com/b/${boardId}`;
284
+ }
285
+ if (type !== "plane") return void 0;
286
+ const workspace = planeWorkspace(provider, env, home);
287
+ if (!workspace) return void 0;
288
+ const base = planeBase(env, home);
289
+ const ref = resolveTicketRef(provider, options);
290
+ return ref ? `${base}/${workspace}/browse/${ref}` : `${base}/${workspace}/projects/${boardId}/issues`;
291
+ }
292
+ function findProjectRoot(from) {
293
+ let dir = resolve(from);
294
+ for (; ; ) {
295
+ if (existsSync(join2(dir, ".project.json"))) return dir;
296
+ const parent = dirname(dir);
297
+ if (parent === dir) return void 0;
298
+ dir = parent;
299
+ }
300
+ }
301
+ function readTicketProvider(root) {
302
+ try {
303
+ const manifest = JSON.parse(readFileSync(join2(root, ".project.json"), "utf8"));
304
+ const provider = manifest.ticket_provider;
305
+ if (!provider || typeof provider !== "object") return void 0;
306
+ return provider;
307
+ } catch {
308
+ return void 0;
309
+ }
310
+ }
311
+ function currentBranch(from) {
312
+ try {
313
+ let dir = resolve(from);
314
+ for (; ; ) {
315
+ const dotgit = join2(dir, ".git");
316
+ if (existsSync(dotgit)) {
317
+ let gitDir = dotgit;
318
+ if (statSync2(dotgit).isFile()) {
319
+ const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotgit, "utf8"));
320
+ if (!pointer) return void 0;
321
+ const target = pointer[1].trim();
322
+ gitDir = isAbsolute(target) ? target : resolve(dir, target);
323
+ }
324
+ const head = readFileSync(join2(gitDir, "HEAD"), "utf8").trim();
325
+ const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
326
+ return ref ? ref[1].trim() : void 0;
327
+ }
328
+ const parent = dirname(dir);
329
+ if (parent === dir) return void 0;
330
+ dir = parent;
331
+ }
332
+ } catch {
333
+ return void 0;
334
+ }
335
+ }
336
+ function resolveBoardUrl(cwd, ref, env = process.env) {
337
+ const root = findProjectRoot(cwd);
338
+ if (!root) return void 0;
339
+ const provider = readTicketProvider(root);
340
+ if (!provider) return void 0;
341
+ return boardUrl(provider, { ref, branch: currentBranch(root), env });
342
+ }
343
+
344
+ // src/prompt.ts
345
+ function readPromptFacts(root, now) {
346
+ let slug = basename(root);
347
+ let identifier;
348
+ try {
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;
351
+ const provider = manifest.ticket_provider;
352
+ if (provider && typeof provider.identifier === "string" && provider.identifier) identifier = provider.identifier;
353
+ } catch {
354
+ }
355
+ const activity = computeRepoActivity(root, { now });
356
+ return {
357
+ root,
358
+ slug,
359
+ identifier,
360
+ age: activity.updatedUnix ? activity.compact : void 0,
361
+ active: activity.active
362
+ };
363
+ }
364
+ function formatPromptLine(facts) {
365
+ const parts = [facts.slug];
366
+ if (facts.identifier) parts.push(`(${facts.identifier})`);
367
+ const head = parts.join(" ");
368
+ return facts.age ? `${head} \xB7 ${facts.age}` : head;
369
+ }
370
+ function promptLine(cwd, now) {
371
+ const root = findProjectRoot(cwd);
372
+ if (!root) return void 0;
373
+ return formatPromptLine(readPromptFacts(root, now));
374
+ }
375
+ function main() {
376
+ try {
377
+ const args = process.argv.slice(2);
378
+ if (args[0] === "--url") {
379
+ const url = resolveBoardUrl(process.cwd(), args[1]);
380
+ if (url) process.stdout.write(`${url}
381
+ `);
382
+ else process.exitCode = 1;
383
+ return;
384
+ }
385
+ const line = promptLine(process.cwd());
386
+ if (line) process.stdout.write(line);
387
+ } catch {
388
+ }
389
+ }
390
+ function isMainModule() {
391
+ if (!process.argv[1]) return false;
392
+ try {
393
+ return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
394
+ } catch {
395
+ return false;
396
+ }
397
+ }
398
+ if (isMainModule()) main();
399
+ export {
400
+ findProjectRoot,
401
+ formatPromptLine,
402
+ promptLine,
403
+ readPromptFacts
404
+ };
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@delorenj/pjangler",
3
- "version": "1.3.0",
3
+ "version": "1.3.7",
4
4
  "description": "Project subsystem bootstrapper CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
8
  "pjangler": "dist/index.js",
9
9
  "pj": "dist/index.js",
10
- "pjangler-mcp": "dist/mcp-server.js"
10
+ "pjangler-mcp": "dist/mcp-server.js",
11
+ "pjangler-prompt": "dist/prompt.js"
11
12
  },
12
13
  "files": [
13
14
  "dist",
@@ -22,7 +23,7 @@
22
23
  "node": ">=20"
23
24
  },
24
25
  "scripts": {
25
- "build": "esbuild src/index.ts src/mcp-server.ts --bundle --packages=external --platform=node --format=esm --outdir=dist",
26
+ "build": "esbuild src/index.ts src/mcp-server.ts src/prompt.ts --bundle --packages=external --platform=node --format=esm --outdir=dist",
26
27
  "check:audit:prod": "npm audit --omit=dev",
27
28
  "check:lock": "node scripts/check-package-lock-parity.mjs",
28
29
  "check:submodules": "node scripts/check-submodule-contract.mjs",
@@ -31,11 +32,12 @@
31
32
  "mcp": "node dist/mcp-server.js",
32
33
  "typecheck": "tsc --noEmit",
33
34
  "test:bmad-installer-contract": "node tests/bmad-installer-contract-regressions.mjs",
34
- "test": "npm run check:lock && npm run check:submodules && npm run check:tracked-secrets && node tests/portable-test-paths-regressions.mjs && node tests/release-regressions.mjs && node tests/submodule-contract-regressions.mjs && node tests/secret-publication-gate-regressions.mjs && node tests/bmad-version-surface-regressions.mjs && node tests/bmad-transaction-regressions.mjs && node tests/parity-migrate-regressions.mjs && node tests/pjan-57-lifecycle-recipes-regressions.mjs && node tests/pjan-57-dogfood-regressions.mjs && node tests/generated-project-lifecycle-regressions.mjs && node tests/pack-flatten-regressions.mjs && node tests/pack-flatten-cross-engine-regressions.mjs && node tests/registry-cache-parity-regressions.mjs && node tests/registry-root-ladder-regressions.mjs && node tests/pjan-23-regressions.mjs && node tests/pjan-24-regressions.mjs && node tests/pjan-28-regressions.mjs && node tests/pjan-30-regressions.mjs && node tests/pjan-31a-regressions.mjs && node tests/pjan-36-regressions.mjs && node tests/pjan-43-regressions.mjs && node tests/pjan-48-regressions.mjs && node tests/pjan-49-regressions.mjs && node tests/pjan-50-regressions.mjs && node tests/skillex-init-regressions.mjs && node tests/fleet-shared-bloodbank-regressions.mjs && node tests/mcp-catalog-regressions.mjs && node tests/mcp-server-regressions.mjs && node tests/project-registry-regressions.mjs && node tests/pg-registry-regressions.mjs && node tests/momo-lifecycle-plane-regressions.mjs",
35
+ "test": "npm run check:lock && node tests/package-lock-parity-regressions.mjs && npm run check:submodules && npm run check:tracked-secrets && node tests/portable-test-paths-regressions.mjs && node tests/release-regressions.mjs && node tests/submodule-contract-regressions.mjs && node tests/secret-publication-gate-regressions.mjs && node tests/bmad-version-surface-regressions.mjs && node tests/bmad-transaction-regressions.mjs && node tests/parity-migrate-regressions.mjs && node tests/hermes-profile-inheritance-regressions.mjs && node tests/pjan-57-lifecycle-recipes-regressions.mjs && node tests/pjan-57-dogfood-regressions.mjs && node tests/generated-project-lifecycle-regressions.mjs && node tests/pack-flatten-regressions.mjs && node tests/pack-flatten-cross-engine-regressions.mjs && node tests/registry-cache-parity-regressions.mjs && node tests/registry-root-ladder-regressions.mjs && node tests/pjan-23-regressions.mjs && node tests/pjan-24-regressions.mjs && node tests/pjan-28-regressions.mjs && node tests/pjan-30-regressions.mjs && node tests/pjan-31a-regressions.mjs && node tests/pjan-36-regressions.mjs && node tests/pjan-43-regressions.mjs && node tests/pjan-48-regressions.mjs && node tests/pjan-49-regressions.mjs && node tests/pjan-50-regressions.mjs && node tests/pjan-65-regressions.mjs && node tests/pjan-67-lifecycle-preflight-regressions.mjs && node tests/pjan-67-regressions.mjs && node tests/pjan-67-trusted-lifecycle-regressions.mjs && node tests/pjan-71-regressions.mjs && node tests/pjan-72-regressions.mjs && node tests/pjan-75-regressions.mjs && node tests/pjan-76-regressions.mjs && node tests/skillex-init-regressions.mjs && node tests/fleet-shared-bloodbank-regressions.mjs && node tests/mcp-catalog-regressions.mjs && node tests/mcp-server-regressions.mjs && node tests/project-registry-regressions.mjs && node tests/pg-registry-regressions.mjs && node tests/momo-lifecycle-plane-regressions.mjs",
35
36
  "migrate:up": "node-pg-migrate --migrations-dir migrations up",
36
37
  "migrate:down": "node-pg-migrate --migrations-dir migrations down",
37
38
  "migrate:create": "node-pg-migrate --migrations-dir migrations create",
38
- "prepublishOnly": "npm run check:lock && npm run check:submodules -- --remote --recursive --archive --npm && npm run build && npm run check:tracked-secrets"
39
+ "prepublishOnly": "npm run check:lock && npm run check:submodules -- --remote --recursive --archive --npm && npm run build && npm run check:tracked-secrets",
40
+ "test:hermes-profile-inheritance": "node tests/hermes-profile-inheritance-regressions.mjs"
39
41
  },
40
42
  "keywords": [
41
43
  "cli",
@@ -116,7 +116,12 @@ _exclude:
116
116
  _tasks:
117
117
  - "cp ~/.config/git/ignore .gitignore 2>/dev/null || echo '# Add ignores here' > .gitignore"
118
118
  # Ensure secrets + per-dev agent-hook/skill overrides are ignored (appended after the base ignore).
119
- - "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# PJAN-57: all six 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' >> .gitignore"
119
+ # Each CLI root's hand-owned config is durable project state; the skill
120
+ # projections inside it are not — bmad-method and skills:sync rewrite them
121
+ # on every run, so they are re-ignored after the un-ignore lines (last
122
+ # match wins). _bmad/_config/manifest.yaml pins the version that
123
+ # reproduces them exactly.
124
+ - "printf '\\n# Secrets + per-dev agent-hook/skill overrides (see .agents/local.example.json)\\n.env\\n.env.*\\n!.env.op\\n.agents/local.json\\n.lastagent\\n**/.claude/settings.local.json\\n\\n# Generated CLI configurations are durable project state...\\n!.claude/\\n!.claude/**\\n!.codex/\\n!.codex/**\\n!.gemini/\\n!.gemini/**\\n!.copilot/\\n!.copilot/**\\n!.opencode/\\n!.opencode/**\\n!.kimi-code/\\n!.kimi-code/**\\n# ...but their skill projections are regenerated, so they stay out of the tree.\\n/.agents/skills/\\n.claude/skills/\\n.codex/skills/\\n.gemini/skills/\\n.copilot/skills/\\n.opencode/skills/\\n.kimi-code/skills/\\n' >> .gitignore"
120
125
  - "ln -sf AGENTS.md CLAUDE.md"
121
126
  - "ln -sf AGENTS.md GEMINI.md"
122
127
  # Materialize the declared Skillex packs' manifest + symlink topology
@@ -19,9 +19,10 @@ What this script owns is the transactional projection into the project:
19
19
  one unsafe or tampered pack produces ZERO mutation
20
20
  * `.agents/skills.json` is rewritten atomically, preserving its mode
21
21
 
22
- Backwards compatibility: a project that declares no `bmad` pack still gets the
23
- pinned BMAD pack expanded into `skills[]`, exactly as before. Declaring
24
- `{"name": "bmad", ...}` in `packs[]` takes over and the pin is not consulted.
22
+ Only packs a project DECLARES in `packs[]` are provisioned. Nothing is pinned
23
+ implicitly -- in particular BMAD is not a pack: `bmad-method install` writes
24
+ bmad-* skills into `.agents/skills` itself, versioned per project by
25
+ `_bmad/_config/manifest.yaml`.
25
26
  """
26
27
 
27
28
  from __future__ import annotations
@@ -40,11 +41,12 @@ from typing import Callable
40
41
  SKILLS_SCHEMA = "https://raw.githubusercontent.com/delorenj/skillex/main/skills.schema.json"
41
42
  SKILLS_REGISTRY = "https://github.com/delorenj/skillex.git"
42
43
 
43
- # The BMAD pack pinned when a project declares none. Sealed from this side so
44
- # the pinned release is verified byte-for-byte even though its `pack.toml`
45
- # predates `[policy] sealed`.
46
- BMAD_PACK_NAME = "bmad"
47
- BMAD_PACK_VERSION = "6.10.1-next.31"
44
+ # BMAD is NOT a pack. `bmad-method install` writes bmad-* skills into
45
+ # .agents/skills itself, per project, versioned by _bmad/_config/manifest.yaml.
46
+ # This script used to pin a frozen `packs/bmad/<version>` in the registry and
47
+ # project a second copy of the same skills; when the registry dropped that pack
48
+ # the pin took every `pjangler project create` down with it on any machine
49
+ # without a warm cache. Only packs a project DECLARES are provisioned now.
48
50
 
49
51
 
50
52
  def load_engine():
@@ -68,38 +70,13 @@ engine = load_engine()
68
70
 
69
71
 
70
72
  def pack_root_override(name: str) -> Path | None:
71
- """Developer/test pin for one pack root, e.g. `PJ_PACK_ROOT_HERMES_BASE`.
72
-
73
- `PJ_BMAD_PACK_ROOT` predates the generic form and stays a first-class alias
74
- for the `bmad` pack.
75
- """
73
+ """Developer/test pin for one pack root, e.g. `PJ_PACK_ROOT_HERMES_BASE`."""
76
74
  generic = os.environ.get(f"PJ_PACK_ROOT_{re.sub(r'[^A-Z0-9]', '_', name.upper())}", "").strip()
77
75
  if generic:
78
76
  return Path(generic).expanduser().absolute()
79
- if name == BMAD_PACK_NAME:
80
- legacy = os.environ.get("PJ_BMAD_PACK_ROOT", "").strip()
81
- if legacy:
82
- return Path(legacy).expanduser().absolute()
83
77
  return None
84
78
 
85
79
 
86
- def implicit_bmad_entry() -> dict:
87
- """The implicit BMAD pin, expressed as an ordinary `packs[]` entry.
88
-
89
- It deliberately carries NO `source`: like every declared pack it must walk
90
- the one resolution ladder in `resolve_pack_root()` -- env override first,
91
- then the contract-ordered registry checkouts. Pinning a hardcoded root here
92
- would give the same pack name two resolutions in one process, so adding
93
- `packs:[{"name":"bmad",...}]` to a manifest would silently MOVE the pack.
94
- """
95
- return {
96
- "name": BMAD_PACK_NAME,
97
- "version": BMAD_PACK_VERSION,
98
- "sealed": True,
99
- "optional": False,
100
- }
101
-
102
-
103
80
  def apply_root_override(entry: dict) -> dict:
104
81
  """Pin a declared pack at an overridden root without changing its identity."""
105
82
  if entry.get("source") or entry.get("registry_path"):
@@ -150,24 +127,11 @@ def resolve_declared_packs(manifest: dict, base_dir: Path):
150
127
  # Later packs override earlier ones (contract section 5).
151
128
  declared_members[name] = path
152
129
 
130
+ # Nothing is pinned implicitly any more. The empty pair is kept so every
131
+ # caller keeps one shape and the manifest writer still evicts leftovers
132
+ # from when something WAS pinned here.
153
133
  implicit_members: dict[str, Path] = {}
154
134
  implicit_packs: list[dict] = []
155
- if not any(entry["name"] == BMAD_PACK_NAME for entry in entries):
156
- # Same call shape as a declared pack above, so the pin cannot resolve
157
- # anywhere a declared `bmad` entry would not.
158
- pinned = engine.normalize_pack_entry(apply_root_override(implicit_bmad_entry()))
159
- pinned["sealed"] = True
160
- for name, path in engine.resolve_pack(
161
- pinned,
162
- cache_dir,
163
- base_dir,
164
- default_registry,
165
- registry_roots,
166
- managed_roots,
167
- on_resolved=implicit_packs.append,
168
- ):
169
- implicit_members[name] = path
170
-
171
135
  return declared_members, implicit_members, declared_packs, implicit_packs
172
136
 
173
137
 
@@ -46,17 +46,14 @@ target_repo:
46
46
 
47
47
  role:
48
48
  type: str
49
- help: "Role for this agent"
49
+ help: "Role for this agent (safe single path segment; arbitrary roles are supported)"
50
50
  default: pm
51
- choices:
52
- "Project Manager (pm)": pm
53
- "Company Director (director)": director
54
- "Developer (dev)": dev
55
- "Reviewer (review)": review
56
- "Company Reporter — daily cross-project rollup (reporter)": reporter
57
- "Ops (ops)": ops
58
- "QA (qa)": qa
59
- validator: "{% if not role %}Required{% endif %}"
51
+ validator: >-
52
+ {% set alnum = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' -%}
53
+ {% set safe_chars = alnum ~ '._-' -%}
54
+ {% if not role or role != role|trim or role in ['.', '..'] or role[0] not in alnum or role[-1] not in alnum or role|reject('in', safe_chars)|list|length -%}
55
+ Role must be a non-empty safe single path segment using letters, numbers, dots, underscores, or hyphens
56
+ {%- endif %}
60
57
 
61
58
  ticket_provider:
62
59
  type: str
@@ -157,7 +154,7 @@ plane_workspace:
157
154
  # All scripts are idempotent — safe to re-run if a step fails.
158
155
 
159
156
  _tasks:
160
- - "chmod +x .scripts/*.sh .scripts/sentinel/bin/*.sh hermes 2>/dev/null || true"
157
+ - "chmod +x .scripts/*.sh .scripts/sentinel/bin/*.sh hermes momo 2>/dev/null || true"
161
158
  - "./.scripts/00-banner.sh"
162
159
  - "./.scripts/01-config.sh"
163
160
  - "./.scripts/05-fleet-env.sh"
@@ -0,0 +1,44 @@
1
+ # Per-agent runtime — what NOT to commit to the checkpoint repo.
2
+ # Sensitive credentials never go in git.
3
+ .env
4
+ .env.*
5
+ !.env.example
6
+ auth.json
7
+ auth.lock
8
+ secrets.*
9
+ *.pem
10
+ *.key
11
+ gateway.pid
12
+ gateway_state.json
13
+ processes.json
14
+ interrupt_debug.log
15
+
16
+ # Caches (large, regenerable)
17
+ audio_cache/
18
+ image_cache/
19
+ models_dev_cache.json
20
+ context_length_cache.yaml
21
+ honcho.json
22
+
23
+ # Sandboxes (ephemeral execution environments)
24
+ sandboxes/
25
+
26
+ # Heartbeat / sentinel ephemeral state — local to this runtime, never checkpointed.
27
+ # (Keeps the fused checkpoint a true no-op on a clean tree.)
28
+ continuous-ticket-sentinel-state.json
29
+ continuous-ticket-sentinel.lock
30
+ continuous-ticket-sentinel.lock.d/
31
+ .last-checkpoint
32
+
33
+ # Runtime logs (we want the LFS-tracked summary.log if we add one, not the noise)
34
+ logs/*.log
35
+ logs/*.systemd.log
36
+
37
+ # Python cruft
38
+ __pycache__/
39
+ *.pyc
40
+ .scripts/.done-*
41
+ .scripts/.provision.log
42
+
43
+ # Allow these subdirs to exist
44
+ !logs/.gitkeep
@@ -1,6 +1,15 @@
1
1
  #!/usr/bin/env bash
2
2
  # Ensure the distributable config exists before any other step reads it.
3
3
  # Seeds ~/.config/hermes-agent-template/config.toml from the shipped example.
4
+
5
+ # MCP render transactions establish repo-local lifecycle eligibility before
6
+ # touching any host-global state. This guard must precede _lib.sh because that
7
+ # library creates the role log and may read fleet configuration.
8
+ if [[ "${SKIP_HOST_STATE:-0}" == "1" ]]; then
9
+ printf '%s\n' '[01] host config — DEFERRED (SKIP_HOST_STATE=1)' >&2
10
+ exit 0
11
+ fi
12
+
4
13
  # shellcheck source=_lib.sh
5
14
  source "$(dirname "$0")/_lib.sh"
6
15