@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.
- package/README.md +72 -0
- package/dist/index.js +4510 -2485
- package/dist/mcp-server.js +4166 -1959
- package/dist/prompt.js +404 -0
- package/package.json +7 -5
- package/templates/commonproject/copier.yml +6 -1
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +14 -50
- package/templates/hermes-agent/copier.yml +8 -11
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +44 -0
- package/templates/hermes-agent/template/.scripts/01-config.sh +9 -0
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +18 -28
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +68 -4
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -1
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +73 -43
- package/templates/hermes-agent/template/.scripts/80-registry.sh +6 -0
- package/templates/hermes-agent/template/.scripts/_lib.sh +62 -6
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +29 -1
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +13 -1
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +202 -0
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +734 -0
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +126 -0
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +1 -1
- package/templates/hermes-agent/template/SOUL.md.jinja +44 -8
- package/templates/hermes-agent/template/hermes.jinja +20 -8
- package/templates/hermes-agent/template/momo.jinja +177 -0
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|