@cryptolibertus/pi-peer 0.3.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.
@@ -0,0 +1,528 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { dirname, resolve as resolvePath } from "node:path";
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+
6
+ export const PEER_GOAL_BOARD_RELATIVE_PATH = ".pi/peer-goals.json";
7
+
8
+ const EVENT_TYPES = new Set(["finding", "task", "claim", "release", "heartbeat", "objection", "resolve", "vote", "handoff", "note"]);
9
+ const BLOCKING_SEVERITIES = new Set(["blocking", "blocker", "critical"]);
10
+ const VOTE_VERDICTS = new Set(["pass", "fail", "pass-with-risks"]);
11
+ const DEFAULT_GOAL_CLAIM_STALE_MS = 45 * 60 * 1000;
12
+ const GOAL_BOARD_LOCK_STALE_MS = 30_000;
13
+ const GOAL_BOARD_LOCK_RETRY_MS = 10;
14
+ const GOAL_BOARD_LOCK_TIMEOUT_MS = 5_000;
15
+
16
+ export async function loadPeerGoalBoard(root) {
17
+ const path = goalBoardPath(root);
18
+ try {
19
+ const parsed = JSON.parse(await readFile(path, "utf8"));
20
+ return normalizeBoard(parsed);
21
+ } catch (error) {
22
+ if (error?.code === "ENOENT") return normalizeBoard({});
23
+ throw error;
24
+ }
25
+ }
26
+
27
+ export async function savePeerGoalBoard(root, board) {
28
+ const path = goalBoardPath(root);
29
+ const normalized = normalizeBoard(board);
30
+ await mkdir(dirname(path), { recursive: true });
31
+ const tmp = `${path}.${process.pid}.${process.hrtime.bigint().toString(36)}.tmp`;
32
+ try {
33
+ await writeFile(tmp, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
34
+ await rename(tmp, path);
35
+ } catch (error) {
36
+ await unlink(tmp).catch(() => {});
37
+ throw error;
38
+ }
39
+ return normalized;
40
+ }
41
+
42
+ export async function createPeerGoal(root, input = {}) {
43
+ const objective = cleanText(input.objective);
44
+ if (!objective) throw new Error("peer goal create requires an objective");
45
+ return updatePeerGoalBoard(root, (board) => {
46
+ const now = nowIso();
47
+ const goal = {
48
+ id: input.id || newGoalId(),
49
+ objective,
50
+ constraints: normalizeList(input.constraints),
51
+ status: "open",
52
+ createdAt: now,
53
+ updatedAt: now,
54
+ createdBy: cleanText(input.peerId) || "unknown",
55
+ events: [],
56
+ };
57
+ if (board.goals[goal.id]) throw new Error(`peer goal ${goal.id} already exists`);
58
+ board.goals[goal.id] = goal;
59
+ board.currentGoalId = goal.id;
60
+ return deriveGoalState(goal, { now });
61
+ });
62
+ }
63
+
64
+ export async function appendPeerGoalEvent(root, goalId, eventInput = {}) {
65
+ return updatePeerGoalBoard(root, (board) => {
66
+ const goal = resolveGoal(board, goalId);
67
+ const event = normalizeEvent(eventInput);
68
+ if (event.type === "claim") validateClaim(goal, event);
69
+ if (event.type === "release") validateRelease(goal, event);
70
+ if (event.type === "heartbeat") validateHeartbeat(goal, event);
71
+ goal.events.push(event);
72
+ goal.updatedAt = event.at;
73
+ if (event.type === "handoff" && event.status === "done") goal.lastHandoffAt = event.at;
74
+ board.currentGoalId = goal.id;
75
+ return { goal: deriveGoalState(goal), event };
76
+ });
77
+ }
78
+
79
+ export async function closePeerGoal(root, goalId, input = {}) {
80
+ return updatePeerGoalBoard(root, (board) => {
81
+ const goal = resolveGoal(board, goalId);
82
+ const state = deriveGoalState(goal);
83
+ if (!input.force) validateGoalReadyToClose(state);
84
+ const now = nowIso();
85
+ goal.status = input.status || "closed";
86
+ goal.closedAt = now;
87
+ goal.updatedAt = now;
88
+ goal.closedBy = cleanText(input.peerId) || "unknown";
89
+ if (input.summary) {
90
+ goal.events.push(normalizeEvent({ type: "note", peerId: input.peerId, summary: input.summary, metadata: { close: true } }));
91
+ }
92
+ return deriveGoalState(goal, { now });
93
+ });
94
+ }
95
+
96
+ export async function beginPeerGoalTask(root, goalId, input = {}) {
97
+ const id = requiredGoalId(goalId || input.goalId);
98
+ const board = await loadPeerGoalBoard(root);
99
+ if (!board.goals[id]) throw new Error(`peer goal ${id} not found`);
100
+ const paths = normalizePaths(input.claimedPaths || input.paths);
101
+ if (!paths.length) return { goalId: id, goal: deriveGoalState(board.goals[id]) };
102
+
103
+ const result = await appendPeerGoalEvent(root, id, {
104
+ type: "claim",
105
+ peerId: cleanText(input.targetPeerId || input.peerId) || "unknown",
106
+ summary: taskSummary(input),
107
+ paths,
108
+ mode: cleanText(input.mode || input.claimMode || "write").toLowerCase(),
109
+ ttlMs: input.ttlMs,
110
+ staleAfterMs: input.staleAfterMs,
111
+ metadata: stripEmpty({ requesterPeerId: cleanText(input.requesterPeerId), targetPeerId: cleanText(input.targetPeerId) }),
112
+ });
113
+ return { goalId: id, goal: result.goal, claimEvent: result.event };
114
+ }
115
+
116
+ export async function recordPeerGoalTaskDispatch(root, goalId, input = {}) {
117
+ const id = requiredGoalId(goalId || input.goalId);
118
+ const result = await appendPeerGoalEvent(root, id, {
119
+ type: "task",
120
+ peerId: cleanText(input.requesterPeerId || input.peerId) || "unknown",
121
+ summary: taskSummary(input),
122
+ paths: input.claimedPaths || input.paths,
123
+ taskId: input.messageId,
124
+ status: cleanText(input.status || "running"),
125
+ metadata: stripEmpty({
126
+ messageId: cleanText(input.messageId),
127
+ conversationId: cleanText(input.conversationId),
128
+ targetPeerId: cleanText(input.targetPeerId),
129
+ claimEventId: cleanText(input.claimEventId),
130
+ }),
131
+ });
132
+ return { goalId: id, goal: result.goal, taskEvent: result.event };
133
+ }
134
+
135
+ export async function completePeerGoalTask(root, goalId, input = {}) {
136
+ const id = requiredGoalId(goalId || input.goalId);
137
+ const handoff = await appendPeerGoalEvent(root, id, {
138
+ type: "handoff",
139
+ peerId: cleanText(input.targetPeerId || input.peerId) || "unknown",
140
+ summary: cleanText(input.summary) || "Peer task completed",
141
+ paths: input.claimedPaths || input.paths,
142
+ taskId: input.messageId,
143
+ status: cleanText(input.status || "done"),
144
+ metadata: stripEmpty({
145
+ messageId: cleanText(input.messageId),
146
+ conversationId: cleanText(input.conversationId),
147
+ claimEventId: cleanText(input.claimEventId),
148
+ responseStatus: cleanText(input.responseStatus),
149
+ }),
150
+ });
151
+ if (!input.claimEventId) return { goalId: id, goal: handoff.goal, handoffEvent: handoff.event };
152
+ const release = await appendPeerGoalEvent(root, id, {
153
+ type: "release",
154
+ peerId: cleanText(input.targetPeerId || input.peerId) || "unknown",
155
+ resolves: input.claimEventId,
156
+ summary: cleanText(input.releaseSummary) || `Released claim ${input.claimEventId}`,
157
+ });
158
+ return { goalId: id, goal: release.goal, handoffEvent: handoff.event, releaseEvent: release.event };
159
+ }
160
+
161
+ export function deriveGoalState(goal, options = {}) {
162
+ const now = options.now || nowIso();
163
+ const events = Array.isArray(goal?.events) ? goal.events : [];
164
+ const resolvedIds = new Set(events.filter((event) => event.type === "resolve" && event.resolves).map((event) => event.resolves));
165
+ const releasedIds = new Set(events.filter((event) => event.type === "release" && event.resolves).map((event) => event.resolves));
166
+ const claims = events.filter((event) => event.type === "claim");
167
+ const claimSummaries = claims.map((event) => projectClaimSummary(event, events, now));
168
+ const activeClaims = claimSummaries.filter((event) => !releasedIds.has(event.id) && !event.expired && !event.stale);
169
+ const expiredClaims = claimSummaries.filter((event) => !releasedIds.has(event.id) && event.expired);
170
+ const staleClaims = claimSummaries.filter((event) => !releasedIds.has(event.id) && !event.expired && event.stale);
171
+ const releasedClaims = claimSummaries.filter((event) => releasedIds.has(event.id));
172
+ const blockingObjections = events
173
+ .filter((event) => event.type === "objection" && isBlockingSeverity(event.severity) && !resolvedIds.has(event.id))
174
+ .map(projectEventSummary);
175
+ const votes = events.filter((event) => event.type === "vote").map(projectEventSummary);
176
+ const currentVotes = currentPeerVotes(votes);
177
+ const failedVotes = currentVotes.filter((vote) => vote.verdict === "fail");
178
+ const passingVotes = currentVotes.filter((vote) => vote.verdict === "pass" || vote.verdict === "pass-with-risks");
179
+ const activeWriteClaims = activeClaims.filter((claim) => claim.mode === "write");
180
+ const tasks = events.filter((event) => event.type === "task").map(projectEventSummary);
181
+ return {
182
+ ...goal,
183
+ events,
184
+ activeClaims,
185
+ activeWriteClaims,
186
+ expiredClaims,
187
+ staleClaims,
188
+ releasedClaims,
189
+ blockingObjections,
190
+ votes,
191
+ currentVotes,
192
+ failedVotes,
193
+ passingVotes,
194
+ tasks,
195
+ readyToClose: goal?.status === "open" && blockingObjections.length === 0 && failedVotes.length === 0 && activeWriteClaims.length === 0 && passingVotes.length > 0,
196
+ };
197
+ }
198
+
199
+ export function formatPeerGoalList(board) {
200
+ const goals = Object.values(normalizeBoard(board).goals).sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
201
+ if (!goals.length) return "No peer goals yet. Start one with `/peer goal create \"<objective>\"`.";
202
+ return goals.map((goal) => {
203
+ const state = deriveGoalState(goal);
204
+ const bits = [goal.id, goal.status || "open", truncate(goal.objective, 80)];
205
+ if (state.activeClaims.length) bits.push(`${state.activeClaims.length} active claim${state.activeClaims.length === 1 ? "" : "s"}`);
206
+ if (state.staleClaims.length) bits.push(`${state.staleClaims.length} stale claim${state.staleClaims.length === 1 ? "" : "s"}`);
207
+ if (state.blockingObjections.length) bits.push(`${state.blockingObjections.length} blocker${state.blockingObjections.length === 1 ? "" : "s"}`);
208
+ return bits.join(" · ");
209
+ }).join("\n");
210
+ }
211
+
212
+ export function formatPeerGoal(goal) {
213
+ const state = goal && Array.isArray(goal.activeClaims) && Array.isArray(goal.expiredClaims) && Array.isArray(goal.staleClaims) ? goal : deriveGoalState(goal);
214
+ const lines = [
215
+ `# Peer Goal ${state.id}`,
216
+ `status: ${state.status || "open"}`,
217
+ `objective: ${state.objective}`,
218
+ ];
219
+ if (state.constraints?.length) lines.push(`constraints: ${state.constraints.join("; ")}`);
220
+ if (state.activeClaims.length) {
221
+ lines.push("", "Active claims:");
222
+ for (const claim of state.activeClaims) lines.push(`- ${claim.id} · ${claim.peerId} · ${claim.mode || "read"} · ${claim.summary}${claim.paths?.length ? ` · ${claim.paths.join(", ")}` : ""}`);
223
+ }
224
+ if (state.staleClaims.length) {
225
+ lines.push("", "Stale claims:");
226
+ for (const claim of state.staleClaims.slice(-8)) lines.push(`- ${claim.id} · ${claim.peerId} · ${claim.mode || "read"} · ${claim.summary}${claim.lastHeartbeatAt ? ` · last heartbeat ${claim.lastHeartbeatAt}` : ""}`);
227
+ }
228
+ if (state.expiredClaims.length) {
229
+ lines.push("", "Expired claims:");
230
+ for (const claim of state.expiredClaims.slice(-8)) lines.push(`- ${claim.id} · ${claim.peerId} · ${claim.mode || "read"} · ${claim.summary}`);
231
+ }
232
+ if (state.blockingObjections.length) {
233
+ lines.push("", "Blocking objections:");
234
+ for (const objection of state.blockingObjections) lines.push(`- ${objection.id} · ${objection.peerId} · ${objection.summary}`);
235
+ }
236
+ if (state.currentVotes.length) {
237
+ lines.push("", "Votes:");
238
+ for (const vote of state.currentVotes.slice(-8)) lines.push(`- ${vote.peerId}: ${vote.verdict}${vote.confidence !== undefined ? ` (${vote.confidence})` : ""}${vote.summary ? ` — ${vote.summary}` : ""}`);
239
+ }
240
+ const recent = state.events.slice(-10);
241
+ if (recent.length) {
242
+ lines.push("", "Recent events:");
243
+ for (const event of recent) lines.push(`- ${event.id} · ${event.type} · ${event.peerId} · ${truncate(event.summary || event.verdict || "", 120)}`);
244
+ }
245
+ lines.push("", state.status === "closed" ? "Ready to close: already closed" : state.readyToClose ? "Ready to close: yes" : "Ready to close: no");
246
+ return lines.join("\n");
247
+ }
248
+
249
+ function validateClaim(goal, event) {
250
+ if (!event.summary) throw new Error("peer goal claim requires a task summary");
251
+ const paths = normalizePaths(event.paths);
252
+ if (event.mode === "write" && paths.length === 0) throw new Error("write claims require --path <path[,path]>");
253
+ const state = deriveGoalState(goal);
254
+ if (event.mode === "write") {
255
+ const conflicts = state.activeClaims.filter((claim) => claim.mode === "write" && pathsOverlap(paths, claim.paths || []));
256
+ if (conflicts.length) throw new Error(`claim conflicts with active write claim ${conflicts.map((claim) => claim.id).join(", ")}`);
257
+ }
258
+ }
259
+
260
+ function validateRelease(goal, event) {
261
+ if (!event.resolves) throw new Error("peer goal release requires a claim event id");
262
+ const state = deriveGoalState(goal);
263
+ const claim = state.activeClaims.find((item) => item.id === event.resolves) || state.staleClaims.find((item) => item.id === event.resolves) || state.expiredClaims.find((item) => item.id === event.resolves);
264
+ if (!claim) throw new Error(`peer goal release target ${event.resolves} is not an active, stale, or expired claim`);
265
+ }
266
+
267
+ function validateHeartbeat(goal, event) {
268
+ if (!event.resolves) throw new Error("peer goal heartbeat requires a claim event id");
269
+ const state = deriveGoalState(goal);
270
+ const claim = state.activeClaims.find((item) => item.id === event.resolves) || state.staleClaims.find((item) => item.id === event.resolves) || state.expiredClaims.find((item) => item.id === event.resolves);
271
+ if (!claim) throw new Error(`peer goal heartbeat target ${event.resolves} is not an active, stale, or expired claim`);
272
+ }
273
+
274
+ function validateGoalReadyToClose(state) {
275
+ if (state.blockingObjections.length) {
276
+ throw new Error(`peer goal ${state.id} has unresolved blocking objections: ${state.blockingObjections.map((item) => item.id).join(", ")}`);
277
+ }
278
+ if (state.failedVotes.length) {
279
+ throw new Error(`peer goal ${state.id} has failed peer votes: ${state.failedVotes.map((item) => item.peerId || item.id).join(", ")}`);
280
+ }
281
+ if (state.activeWriteClaims.length) {
282
+ throw new Error(`peer goal ${state.id} has active write claims: ${state.activeWriteClaims.map((item) => item.id).join(", ")}`);
283
+ }
284
+ if (!state.readyToClose) throw new Error(`peer goal ${state.id} is not ready to close; record at least one passing vote or use --force`);
285
+ }
286
+
287
+ function normalizeEvent(input = {}) {
288
+ const type = cleanText(input.type || "note").toLowerCase();
289
+ if (!EVENT_TYPES.has(type)) throw new Error(`unknown peer goal event type '${type}'`);
290
+ const now = nowIso();
291
+ const ttlMs = positiveNumber(input.ttlMs);
292
+ const event = {
293
+ id: input.id || newEventId(type),
294
+ type,
295
+ at: now,
296
+ peerId: cleanText(input.peerId) || "unknown",
297
+ summary: cleanText(input.summary),
298
+ severity: cleanText(input.severity || (type === "objection" ? "blocking" : "info")).toLowerCase(),
299
+ paths: normalizePaths(input.paths),
300
+ taskId: cleanText(input.taskId),
301
+ mode: cleanText(input.mode || (type === "claim" ? "read" : "")).toLowerCase() || undefined,
302
+ resolves: cleanText(input.resolves),
303
+ verdict: cleanText(input.verdict)?.toLowerCase(),
304
+ confidence: confidenceValue(input.confidence),
305
+ status: cleanText(input.status),
306
+ staleAfterMs: positiveNumber(input.staleAfterMs),
307
+ metadata: plainObject(input.metadata) ? input.metadata : {},
308
+ };
309
+ if (ttlMs) {
310
+ event.ttlMs = ttlMs;
311
+ event.expiresAt = new Date(Date.now() + ttlMs).toISOString();
312
+ }
313
+ if (event.type === "vote" && !VOTE_VERDICTS.has(event.verdict)) {
314
+ throw new Error("peer goal vote verdict must be pass, fail, or pass-with-risks");
315
+ }
316
+ if (event.type === "release" && !event.resolves) throw new Error("peer goal release requires a claim event id");
317
+ if (event.type === "heartbeat" && !event.resolves) throw new Error("peer goal heartbeat requires a claim event id");
318
+ return stripEmpty(event);
319
+ }
320
+
321
+ function requiredGoalId(value) {
322
+ const id = cleanText(value);
323
+ if (!id) throw new Error("peer goal id is required");
324
+ return id;
325
+ }
326
+
327
+ function taskSummary(input = {}) {
328
+ return cleanText(input.summary) || cleanText(input.prompt) || `Peer task for ${cleanText(input.targetPeerId || input.peerId) || "unknown"}`;
329
+ }
330
+
331
+ function normalizeBoard(board = {}) {
332
+ const goals = plainObject(board.goals) ? board.goals : {};
333
+ const normalizedGoals = {};
334
+ for (const [id, goal] of Object.entries(goals)) {
335
+ if (!plainObject(goal)) continue;
336
+ normalizedGoals[id] = {
337
+ id: cleanText(goal.id) || id,
338
+ objective: cleanText(goal.objective),
339
+ constraints: normalizeList(goal.constraints),
340
+ status: cleanText(goal.status || "open"),
341
+ createdAt: cleanText(goal.createdAt),
342
+ updatedAt: cleanText(goal.updatedAt || goal.createdAt),
343
+ createdBy: cleanText(goal.createdBy),
344
+ closedAt: cleanText(goal.closedAt),
345
+ closedBy: cleanText(goal.closedBy),
346
+ events: Array.isArray(goal.events) ? goal.events.map((event) => stripEmpty({ ...event, paths: normalizePaths(event.paths) })) : [],
347
+ };
348
+ }
349
+ return { version: 1, currentGoalId: cleanText(board.currentGoalId), goals: normalizedGoals };
350
+ }
351
+
352
+ function resolveGoal(board, goalId) {
353
+ const id = cleanText(goalId) || board.currentGoalId;
354
+ if (!id) throw new Error("peer goal id is required");
355
+ const goal = board.goals[id];
356
+ if (!goal) throw new Error(`peer goal ${id} not found`);
357
+ return goal;
358
+ }
359
+
360
+ function projectEventSummary(event) {
361
+ return stripEmpty({
362
+ id: event.id,
363
+ type: event.type,
364
+ peerId: event.peerId,
365
+ summary: event.summary,
366
+ severity: event.severity,
367
+ paths: event.paths,
368
+ taskId: event.taskId,
369
+ mode: event.mode,
370
+ resolves: event.resolves,
371
+ verdict: event.verdict,
372
+ confidence: event.confidence,
373
+ status: event.status,
374
+ staleAfterMs: event.staleAfterMs,
375
+ at: event.at,
376
+ expiresAt: event.expiresAt,
377
+ });
378
+ }
379
+
380
+ function projectClaimSummary(claim, events, now) {
381
+ const heartbeat = latestEvent(events.filter((event) => event.type === "heartbeat" && event.resolves === claim.id));
382
+ const lastHeartbeatAt = heartbeat?.at;
383
+ const lastActiveAt = lastHeartbeatAt || claim.at;
384
+ const staleAfterMs = positiveNumber(heartbeat?.staleAfterMs) || positiveNumber(claim.staleAfterMs) || DEFAULT_GOAL_CLAIM_STALE_MS;
385
+ const staleAt = addMsIso(lastActiveAt, staleAfterMs);
386
+ const effectiveExpiresAt = heartbeat?.expiresAt || claim.expiresAt;
387
+ const expired = Boolean(effectiveExpiresAt && effectiveExpiresAt <= now);
388
+ const stale = !expired && Boolean(staleAt && staleAt <= now);
389
+ return stripEmpty({
390
+ ...projectEventSummary(claim),
391
+ staleAfterMs,
392
+ staleAt,
393
+ expiresAt: effectiveExpiresAt,
394
+ lastHeartbeatAt,
395
+ ...(expired ? { expired: true } : {}),
396
+ ...(stale ? { stale: true } : {}),
397
+ });
398
+ }
399
+
400
+ function latestEvent(events) {
401
+ return events.reduce((latest, event) => {
402
+ if (!latest) return event;
403
+ return String(event.at || "") > String(latest.at || "") ? event : latest;
404
+ }, undefined);
405
+ }
406
+
407
+ function currentPeerVotes(votes) {
408
+ const byPeer = new Map();
409
+ for (const vote of votes) byPeer.set(vote.peerId || vote.id, vote);
410
+ return [...byPeer.values()];
411
+ }
412
+
413
+ async function updatePeerGoalBoard(root, updater) {
414
+ const path = goalBoardPath(root);
415
+ await mkdir(dirname(path), { recursive: true });
416
+ return withGoalBoardLock(root, async () => {
417
+ const board = await loadPeerGoalBoard(root);
418
+ const result = await updater(board);
419
+ await savePeerGoalBoard(root, board);
420
+ return result;
421
+ });
422
+ }
423
+
424
+ async function withGoalBoardLock(root, fn) {
425
+ const lockPath = `${goalBoardPath(root)}.lock`;
426
+ const start = Date.now();
427
+ while (true) {
428
+ try {
429
+ await mkdir(lockPath);
430
+ await writeFile(`${lockPath}/owner`, `${process.pid}\n${new Date().toISOString()}\n`, "utf8").catch(() => {});
431
+ try {
432
+ return await fn();
433
+ } finally {
434
+ await rm(lockPath, { recursive: true, force: true }).catch(() => {});
435
+ }
436
+ } catch (error) {
437
+ if (error?.code !== "EEXIST") throw error;
438
+ if (await removeStaleGoalBoardLock(lockPath)) continue;
439
+ if (Date.now() - start >= GOAL_BOARD_LOCK_TIMEOUT_MS) throw new Error(`timed out waiting for peer goal board lock ${lockPath}`);
440
+ await sleep(GOAL_BOARD_LOCK_RETRY_MS);
441
+ }
442
+ }
443
+ }
444
+
445
+ async function removeStaleGoalBoardLock(lockPath) {
446
+ try {
447
+ const info = await stat(lockPath);
448
+ if (Date.now() - info.mtimeMs < GOAL_BOARD_LOCK_STALE_MS) return false;
449
+ await rm(lockPath, { recursive: true, force: true });
450
+ return true;
451
+ } catch (error) {
452
+ if (error?.code === "ENOENT") return true;
453
+ return false;
454
+ }
455
+ }
456
+
457
+ function goalBoardPath(root) {
458
+ if (!root) throw new Error("peer goal board requires root");
459
+ return resolvePath(root, PEER_GOAL_BOARD_RELATIVE_PATH);
460
+ }
461
+
462
+ function pathsOverlap(a, b) {
463
+ return a.some((left) => b.some((right) => left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)));
464
+ }
465
+
466
+ function normalizePaths(value) {
467
+ return [...new Set(normalizeList(value).map((item) => item.replace(/^\.\//, "").replace(/\/+/g, "/").replace(/\/$/, "")).filter(Boolean))];
468
+ }
469
+
470
+ function normalizeList(value) {
471
+ if (Array.isArray(value)) return value.map(cleanText).filter(Boolean);
472
+ if (typeof value === "string") return value.split(",").map(cleanText).filter(Boolean);
473
+ return [];
474
+ }
475
+
476
+ function isBlockingSeverity(value) {
477
+ return BLOCKING_SEVERITIES.has(String(value || "").toLowerCase());
478
+ }
479
+
480
+ function cleanText(value) {
481
+ return typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
482
+ }
483
+
484
+ function plainObject(value) {
485
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
486
+ }
487
+
488
+ function positiveNumber(value) {
489
+ const number = Number(value);
490
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : undefined;
491
+ }
492
+
493
+ function confidenceValue(value) {
494
+ if (value === undefined || value === null || value === "") return undefined;
495
+ const number = Number(value);
496
+ return Number.isFinite(number) ? Math.max(0, Math.min(1, number)) : undefined;
497
+ }
498
+
499
+ function addMsIso(value, ms) {
500
+ const timestamp = Date.parse(value || "");
501
+ return Number.isFinite(timestamp) ? new Date(timestamp + ms).toISOString() : undefined;
502
+ }
503
+
504
+ function stripEmpty(object) {
505
+ return Object.fromEntries(Object.entries(object).filter(([, value]) => {
506
+ if (value === undefined || value === null || value === "") return false;
507
+ if (Array.isArray(value) && value.length === 0) return false;
508
+ if (plainObject(value) && Object.keys(value).length === 0) return false;
509
+ return true;
510
+ }));
511
+ }
512
+
513
+ function truncate(value, max) {
514
+ const text = String(value || "");
515
+ return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`;
516
+ }
517
+
518
+ function nowIso() {
519
+ return new Date().toISOString();
520
+ }
521
+
522
+ function newGoalId() {
523
+ return `goal_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
524
+ }
525
+
526
+ function newEventId(type) {
527
+ return `evt_${type}_${Date.now().toString(36)}_${randomUUID().slice(0, 6)}`;
528
+ }
@@ -0,0 +1,45 @@
1
+ export const PEER_TOOL_NAMES = Object.freeze({
2
+ list: "peer_list",
3
+ send: "peer_send",
4
+ get: "peer_get",
5
+ await: "peer_await",
6
+ progress: "peer_progress",
7
+ });
8
+
9
+ const PEER_LIST_GUIDANCE = "Use peer_list to discover configured or discovered local peers before peer_send; do not invent peer ids, and avoid peers marked current/self unless intentionally testing self-targeting.";
10
+ const PEER_SEND_GUIDANCE = "Use peer_send to send a prompt-first message to a peer; for long-running tasks pass goalId plus claimedPaths to create a durable goal-board claim, and if await is false or waiting times out, save the returned messageId and conversationId.";
11
+ const PEER_AWAIT_GUIDANCE = "Use peer_await with messageId values from queued or timed-out peer_send calls to read final assistant replies.";
12
+ const PEER_GET_GUIDANCE = "Use peer_get to inspect a peer, message, conversation, runtime summary, active tasks via 'tasks', fan-out suggestions via 'fanout', or redacted audit state by id.";
13
+ const PEER_PROGRESS_GUIDANCE = "Use peer_progress from inside an inbound long-running peer task to send structured checkpoint updates before the final handoff.";
14
+ const PEER_FANOUT_GUIDANCE = "Fan-out gate: for multi-part, long-running, or implementation-plus-review work, call peer_list and delegate research/review/QA lanes with peer_send unless the user explicitly says to work solo; if you skip fan-out, state the reason in the final response.";
15
+
16
+ export const PEER_INBOUND_FINAL_RESPONSE_GUIDANCE = "For inbound peer asks, answer the inbound ask in your final assistant response; that final assistant response is returned to the requesting peer. For write-capable task intents, end with a concise handoff: status, files changed, verification commands with exit status, and blockers.";
17
+
18
+ export const PEER_COMMUNICATION_GUIDANCE = Object.freeze([
19
+ PEER_LIST_GUIDANCE,
20
+ PEER_SEND_GUIDANCE,
21
+ PEER_AWAIT_GUIDANCE,
22
+ PEER_GET_GUIDANCE,
23
+ PEER_PROGRESS_GUIDANCE,
24
+ PEER_FANOUT_GUIDANCE,
25
+ PEER_INBOUND_FINAL_RESPONSE_GUIDANCE,
26
+ ]);
27
+
28
+ export const PEER_TOOL_PROMPT_GUIDELINES = Object.freeze({
29
+ [PEER_TOOL_NAMES.list]: Object.freeze([PEER_LIST_GUIDANCE]),
30
+ [PEER_TOOL_NAMES.send]: Object.freeze([
31
+ PEER_LIST_GUIDANCE,
32
+ PEER_SEND_GUIDANCE,
33
+ "peer_send awaited results contain the target peer's final assistant response plus peerIdentity metadata when available; use peer_await later when peer_send returns queued or await_timeout.",
34
+ "For long-running task peers, inspect active work with peer_get id 'tasks' and require final handoff summaries.",
35
+ PEER_FANOUT_GUIDANCE,
36
+ "Do not send planner work to the current planner peer; self-targeting requires explicit allowSelf and is usually only for diagnostics.",
37
+ ]),
38
+ [PEER_TOOL_NAMES.await]: Object.freeze([PEER_AWAIT_GUIDANCE]),
39
+ [PEER_TOOL_NAMES.get]: Object.freeze([PEER_GET_GUIDANCE]),
40
+ [PEER_TOOL_NAMES.progress]: Object.freeze([PEER_PROGRESS_GUIDANCE, PEER_INBOUND_FINAL_RESPONSE_GUIDANCE]),
41
+ });
42
+
43
+ export function renderPeerCommunicationGuidance() {
44
+ return PEER_COMMUNICATION_GUIDANCE.map((line) => `- ${line}`).join("\n");
45
+ }