@cryptolibertus/pi-peer 0.4.1 → 0.5.1
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 +2 -2
- package/package.json +1 -1
- package/src/peers/command.mjs +1 -1
- package/src/peers/goal-board.mjs +101 -10
- package/src/peers/idle-watcher.mjs +11 -1
package/README.md
CHANGED
|
@@ -58,9 +58,9 @@ Useful commands (long form and short aliases):
|
|
|
58
58
|
|
|
59
59
|
Short aliases keep common board updates terse: `/peer goals`/`/peer ls`, `/peer current`, `/peer scout`, `/peer fanout`, `/peer proposal`/`/peer propose`, `/peer take`/`/peer claim`, `/peer complete`/`/peer done`, `/peer objection`/`/peer block`, `/peer unblock`, `/peer note`, `/peer finding`, `/peer ping`/`/peer heartbeat`, `/peer drop`/`/peer release`, `/peer pass`, `/peer fail`, `/peer vote`, and `/peer close` map to the corresponding `/peer goal ...` actions.
|
|
60
60
|
|
|
61
|
-
The board is stored locally at `.pi/peer-goals.json`; outbound message snapshots are stored in `.pi/peer-messages.json` so restarted planners can still inspect disconnected historical tasks. Mutating goal-board operations take a short local lock before load/modify/save so concurrent peer appends do not drop events. `/peer send --goal <goal-id> --claim <path[,path]>` and the `peer_send` tool's `goalId`/`claimedPaths` parameters link long-running peer tasks to the board: Symphony records a task, claims overlapping write paths before dispatch, injects goal/heartbeat instructions into the peer prompt, keeps the claim alive with local heartbeats, and releases the claim after the peer returns a final response. Each goal-linked dispatch also gets a semantic work key (`goalId | lane | objective | mode | paths`) so duplicate read/review/research lanes are leased just like write paths; pass `--key <work-key>` / `workKey` for an explicit fingerprint and `--duplicate-policy allow-parallel` only when independent second opinions are intentional. The default dispatch policy is `reuse`, so a matching active work key returns the existing claim/task handle instead of starting another peer. `/peer goal fanout` turns a goal into role-specific peer lanes, while `peer_progress` reports checkpoints from an inbound long-running task. Scout suggestions are persona-aware: they surface a recommended lane (`research`, `review`, `implementation`, `coordination`, etc.), preferred roles, a safe default claim mode, a stable work key, rationale, and suppress suggestions already covered by active work keys. Empty goals
|
|
61
|
+
The board is stored locally at `.pi/peer-goals.json`; outbound message snapshots are stored in `.pi/peer-messages.json` so restarted planners can still inspect disconnected historical tasks. Mutating goal-board operations take a short local lock before load/modify/save so concurrent peer appends do not drop events. `/peer send --goal <goal-id> --claim <path[,path]>` and the `peer_send` tool's `goalId`/`claimedPaths` parameters link long-running peer tasks to the board: Symphony records a task, claims overlapping write paths before dispatch, injects goal/heartbeat instructions into the peer prompt, keeps the claim alive with local heartbeats, and releases the claim after the peer returns a final response. Each goal-linked dispatch also gets a semantic work key (`goalId | lane | objective | mode | paths`) so duplicate read/review/research lanes are leased just like write paths; pass `--key <work-key>` / `workKey` for an explicit fingerprint and `--duplicate-policy allow-parallel` only when independent second opinions are intentional. The default dispatch policy is `reuse`, so a matching active work key returns the existing claim/task handle instead of starting another peer. `/peer goal fanout` turns a goal into role-specific peer lanes, while `peer_progress` reports checkpoints from an inbound long-running task. Scout suggestions are persona-aware: they surface a recommended lane (`research`, `review`, `implementation`, `coordination`, etc.), preferred roles, a safe default claim mode, a stable work key, rationale, and suppress suggestions already covered by active work keys. `/peer scout` prints the exact work key and a copyable `claim:` command for each read-only suggestion, so idle peers can claim the same semantic lane the scout is using instead of inventing generic seed keys. Empty goals emit multiple lane-specific read-only suggestions so idle peers can self-select research, review/QA, or implementation-planning work instead of waiting for a planner to assign lanes. Lane-tagged proposals (for example `/peer goal propose <goal> "Check package contents" --lane review --key review:package-contents`) become matching scout suggestions that reuse the proposal work key, letting the next suitable peer claim or review that proposed lane and suppress duplicate suggestions while the lane is active. Active write claims conflict on overlapping paths; active semantic claims conflict on matching work keys; released, stale, or expired claims are kept visible but inactive. Completed goal-linked tasks are shown with their handoff status instead of remaining visually stuck as `running`. Claims become stale after 45 minutes without a heartbeat unless the claim or heartbeat sets `--stale-after-ms`.
|
|
62
62
|
|
|
63
|
-
Normal goal closure requires at least one current passing vote, no current failed votes, no unresolved blocking objections,
|
|
63
|
+
Normal goal closure requires at least one current passing vote, no current failed votes, no unresolved blocking objections, no active write claims, and no unresolved open proposals. Proposals let peers show initiative, then must be resolved or explicitly deferred before normal closure so the final handoff reflects human intent; use `--force` only when intentionally overriding that readiness gate. Stale write claims no longer block closure or new overlapping claims; use `/peer goal heartbeat` to revive work after a reconnect. Goal-linked tasks validate final handoff headings (`Status`, `Files changed`, `Verification`, `Blockers/risks`, `Safe for review`); missing sections create a blocking objection while still releasing the write claim. For multi-part work, use the fan-out gate: list peers, create/reuse a goal, delegate research/review/worker lanes, and include `Fan-out used: yes/no` plus peer handles in the final answer.
|
|
64
64
|
|
|
65
65
|
## Idle watcher
|
|
66
66
|
|
package/package.json
CHANGED
package/src/peers/command.mjs
CHANGED
|
@@ -146,7 +146,7 @@ export function formatPeerHelp() {
|
|
|
146
146
|
"- `/peer goal create <objective> [--constraint <a,b>]` — start a flat shared goal board",
|
|
147
147
|
"- `/peer goal list|show [goal-id]` — inspect peer goals, active claims, blockers, proposals, and votes",
|
|
148
148
|
"- `/peer goal fanout <goal-id> <objective> --peer <id[,id]> [--path <a,b>] [--send] [--no-await]` — plan or dispatch role-specific peer lanes",
|
|
149
|
-
"- `/peer goal scout [goal-id] [--limit <n>] [--include-closed]` — read-only proactive suggestions for what peers could do next",
|
|
149
|
+
"- `/peer goal scout [goal-id] [--limit <n>] [--include-closed]` — read-only proactive suggestions with exact work keys and copyable claim commands for what peers could do next",
|
|
150
150
|
"- `/peer goal task|finding|proposal|handoff|note <goal-id> <summary> [--path <a,b>] [--lane research|review|implementation] [--status done]` — post goal-board events; lane-tagged proposals become scout suggestions peers can self-select",
|
|
151
151
|
"- `/peer goal claim <goal-id> <task> --mode read|write --path <a,b> [--key <work-key>] [--duplicate-policy error|allow-parallel] [--ttl-ms <ms>]` — lease work without hierarchy",
|
|
152
152
|
"- `/peer goal heartbeat <goal-id> <claim-event-id> [summary] [--ttl-ms <ms>] [--stale-after-ms <ms>]` — refresh a live or stale claim",
|
package/src/peers/goal-board.mjs
CHANGED
|
@@ -248,7 +248,7 @@ export function deriveGoalState(goal, options = {}) {
|
|
|
248
248
|
const failedVotes = currentVotes.filter((vote) => vote.verdict === "fail");
|
|
249
249
|
const passingVotes = currentVotes.filter((vote) => vote.verdict === "pass" || vote.verdict === "pass-with-risks");
|
|
250
250
|
const activeWriteClaims = activeClaims.filter((claim) => claim.mode === "write");
|
|
251
|
-
const tasks = events.filter((event) => event.type === "task").map(
|
|
251
|
+
const tasks = events.filter((event) => event.type === "task").map((event) => projectTaskSummary(event, events));
|
|
252
252
|
return {
|
|
253
253
|
...goal,
|
|
254
254
|
events,
|
|
@@ -265,7 +265,7 @@ export function deriveGoalState(goal, options = {}) {
|
|
|
265
265
|
failedVotes,
|
|
266
266
|
passingVotes,
|
|
267
267
|
tasks,
|
|
268
|
-
readyToClose: goal?.status === "open" && blockingObjections.length === 0 && failedVotes.length === 0 && activeWriteClaims.length === 0 && passingVotes.length > 0,
|
|
268
|
+
readyToClose: goal?.status === "open" && blockingObjections.length === 0 && failedVotes.length === 0 && activeWriteClaims.length === 0 && openProposals.length === 0 && passingVotes.length > 0,
|
|
269
269
|
};
|
|
270
270
|
}
|
|
271
271
|
|
|
@@ -291,9 +291,12 @@ export function formatPeerGoalScout(board, options = {}) {
|
|
|
291
291
|
for (const suggestion of suggestions.slice(0, limit)) {
|
|
292
292
|
const pathText = suggestion.paths?.length ? ` · paths: ${suggestion.paths.join(", ")}` : "";
|
|
293
293
|
const laneText = suggestion.recommendedLane ? ` · lane: ${suggestion.recommendedLane}${suggestion.preferredRoles?.length ? ` for ${suggestion.preferredRoles.join("/")}` : ""}${suggestion.claimMode ? ` (${suggestion.claimMode})` : ""}` : "";
|
|
294
|
-
|
|
294
|
+
const keyText = suggestion.workKey ? ` · key: ${suggestion.workKey}` : "";
|
|
295
|
+
lines.push(`- ${suggestion.priority} · ${suggestion.goalId} · ${suggestion.kind}: ${suggestion.summary}${laneText}${pathText}${keyText}`);
|
|
296
|
+
const claim = formatScoutClaimCommand(suggestion);
|
|
297
|
+
if (claim) lines.push(` claim: ${claim}`);
|
|
295
298
|
}
|
|
296
|
-
lines.push("", "Next: post one with `/peer goal propose <goal-id> <summary>` or claim
|
|
299
|
+
lines.push("", "Next: post one with `/peer goal propose <goal-id> <summary>` or claim the exact suggested lane with the printed `claim:` command/work key. Scout does not mutate the board.");
|
|
297
300
|
return lines.join("\n");
|
|
298
301
|
}
|
|
299
302
|
|
|
@@ -323,20 +326,39 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
|
|
|
323
326
|
push("P1", "stale-claim", `Ask owners to heartbeat or release ${state.staleClaims.length} stale claim${state.staleClaims.length === 1 ? "" : "s"}.`, { paths: uniqueEventPaths(state.staleClaims) });
|
|
324
327
|
}
|
|
325
328
|
if (state.openProposals.length) {
|
|
326
|
-
for (const proposal of state.openProposals.filter((item) => item.lane)) {
|
|
329
|
+
for (const proposal of state.openProposals.filter((item) => item.lane && proposalLaneWorkCompleted(state, goal.id, item))) {
|
|
327
330
|
const lane = normalizeLaneName(proposal.lane);
|
|
328
|
-
push("P1", "open-proposal", `
|
|
331
|
+
push("P1", "open-proposal", `Resolve fulfilled ${lane} proposal or record why it remains open: ${proposal.summary}`, {
|
|
332
|
+
paths: proposal.paths,
|
|
333
|
+
recommendedLane: "coordination",
|
|
334
|
+
preferredRoles: preferredRolesForLane("coordination"),
|
|
335
|
+
claimMode: "read",
|
|
336
|
+
suggestedIntent: "coordinate",
|
|
337
|
+
rationale: "A peer posted completion evidence and released the lane; flat hierarchy works best when any suitable peer resolves or explicitly defers fulfilled proposals.",
|
|
338
|
+
workKey: derivePeerGoalWorkKey({ goalId: goal.id, lane: "coordination", objective: `resolve fulfilled proposal ${proposal.id}`, mode: "read", paths: proposal.paths }),
|
|
339
|
+
relatedEventId: proposal.id,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
for (const proposal of state.openProposals.filter((item) => item.lane && !proposalLaneWorkCompleted(state, goal.id, item))) {
|
|
343
|
+
const lane = normalizeLaneName(proposal.lane);
|
|
344
|
+
const summary = `Self-select proposed ${lane} lane: ${proposal.summary}`;
|
|
345
|
+
push("P1", "open-proposal", summary, {
|
|
329
346
|
paths: proposal.paths,
|
|
330
347
|
recommendedLane: lane,
|
|
331
348
|
preferredRoles: preferredRolesForLane(lane),
|
|
332
349
|
claimMode: "read",
|
|
333
350
|
suggestedIntent: suggestedIntentForLane(lane),
|
|
334
351
|
rationale: "A peer proposed a lane; matching idle peers can claim or review it without planner assignment.",
|
|
352
|
+
workKey: proposalLaneWorkKey(goal.id, lane, proposal),
|
|
353
|
+
relatedEventId: proposal.id,
|
|
335
354
|
});
|
|
336
355
|
}
|
|
337
|
-
push("P1", "open-proposal",
|
|
356
|
+
push("P1", "open-proposal", formatOpenProposalTriageSummary(state, goal.id), {
|
|
357
|
+
paths: uniqueEventPaths(state.openProposals),
|
|
358
|
+
workKey: derivePeerGoalWorkKey({ goalId: goal.id, lane: "coordination", objective: "triage open proposals", mode: "read" }),
|
|
359
|
+
});
|
|
338
360
|
}
|
|
339
|
-
if (state.readyToClose) {
|
|
361
|
+
if (state.readyToClose && !state.openProposals.length) {
|
|
340
362
|
push("P1", "close", "Goal satisfies closure gates; close it or record a final note.");
|
|
341
363
|
continue;
|
|
342
364
|
}
|
|
@@ -350,7 +372,7 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
|
|
|
350
372
|
rationale: "Multiple lane-specific suggestions let idle peers self-select complementary work and suppress duplicates by work key.",
|
|
351
373
|
});
|
|
352
374
|
}
|
|
353
|
-
} else if (!state.currentVotes.length && !state.activeWriteClaims.length) {
|
|
375
|
+
} else if (!state.currentVotes.length && !state.activeWriteClaims.length && !state.openProposals.length) {
|
|
354
376
|
push("P2", "review", "No current peer vote; ask a peer for read-only review or record a pass/fail vote.");
|
|
355
377
|
}
|
|
356
378
|
}
|
|
@@ -559,6 +581,26 @@ function projectEventSummary(event) {
|
|
|
559
581
|
});
|
|
560
582
|
}
|
|
561
583
|
|
|
584
|
+
function projectTaskSummary(task, events) {
|
|
585
|
+
const handoff = latestEvent(events.filter((event) => taskMatchesHandoff(task, event, events)));
|
|
586
|
+
const projected = projectEventSummary(task);
|
|
587
|
+
if (!handoff) return projected;
|
|
588
|
+
return stripEmpty({
|
|
589
|
+
...projected,
|
|
590
|
+
status: handoff.status || "done",
|
|
591
|
+
completedAt: handoff.at,
|
|
592
|
+
handoffEventId: handoff.id,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function taskMatchesHandoff(task, event, events) {
|
|
597
|
+
if (event.type !== "handoff" || event.at < task.at) return false;
|
|
598
|
+
if (task.taskId && event.taskId === task.taskId) return true;
|
|
599
|
+
if (event.taskId === task.id) return true;
|
|
600
|
+
if (task.taskId || event.taskId || !task.workKey || event.workKey !== task.workKey) return false;
|
|
601
|
+
return events.filter((item) => item.type === "task" && item.workKey === task.workKey).length === 1;
|
|
602
|
+
}
|
|
603
|
+
|
|
562
604
|
function projectClaimSummary(claim, events, now) {
|
|
563
605
|
const heartbeat = latestEvent(events.filter((event) => event.type === "heartbeat" && event.resolves === claim.id));
|
|
564
606
|
const lastHeartbeatAt = heartbeat?.at;
|
|
@@ -625,13 +667,27 @@ function enrichScoutSuggestion(suggestion = {}) {
|
|
|
625
667
|
claimMode,
|
|
626
668
|
suggestedIntent: cleanText(suggestion.suggestedIntent || lane.suggestedIntent),
|
|
627
669
|
rationale: cleanText(suggestion.rationale || lane.rationale),
|
|
670
|
+
relatedEventId: cleanText(suggestion.relatedEventId),
|
|
628
671
|
});
|
|
629
672
|
return stripEmpty({
|
|
630
673
|
...enriched,
|
|
631
|
-
workKey: suggestion.workKey || derivePeerGoalWorkKey({ goalId: enriched.goalId, lane: recommendedLane, objective: enriched.summary, mode: claimMode, paths: enriched.paths }),
|
|
674
|
+
workKey: normalizeWorkKey(suggestion.workKey) || derivePeerGoalWorkKey({ goalId: enriched.goalId, lane: recommendedLane, objective: enriched.summary, mode: claimMode, paths: enriched.paths }),
|
|
632
675
|
});
|
|
633
676
|
}
|
|
634
677
|
|
|
678
|
+
function formatScoutClaimCommand(suggestion = {}) {
|
|
679
|
+
if (suggestion.claimMode !== "read" || !suggestion.workKey || !suggestion.goalId || !suggestion.summary) return "";
|
|
680
|
+
const lane = suggestion.recommendedLane ? ` --lane ${shellQuote(suggestion.recommendedLane)}` : "";
|
|
681
|
+
const paths = suggestion.paths?.length ? suggestion.paths.map((path) => ` --path ${shellQuote(path)}`).join("") : "";
|
|
682
|
+
return `/peer goal claim ${shellQuote(suggestion.goalId)} ${shellQuote(suggestion.summary)} --mode read${lane} --key ${shellQuote(suggestion.workKey)}${paths}`;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function shellQuote(value) {
|
|
686
|
+
const text = String(value || "");
|
|
687
|
+
if (/^[A-Za-z0-9_./:-]+$/.test(text)) return text;
|
|
688
|
+
return `'${text.replace(/'/g, `'"'"'`)}'`;
|
|
689
|
+
}
|
|
690
|
+
|
|
635
691
|
function normalizeLaneName(value) {
|
|
636
692
|
const lane = cleanText(value).toLowerCase();
|
|
637
693
|
if (["qa", "quality", "test", "testing"].includes(lane)) return "review";
|
|
@@ -658,6 +714,41 @@ function hasActiveClaimForScoutSuggestion(state, suggestion) {
|
|
|
658
714
|
return state.activeClaims.some((claim) => claim.workKey === suggestion.workKey);
|
|
659
715
|
}
|
|
660
716
|
|
|
717
|
+
function proposalLaneWorkCompleted(state, goalId, proposal) {
|
|
718
|
+
const lane = normalizeLaneName(proposal?.lane);
|
|
719
|
+
const workKey = proposalLaneWorkKey(goalId, lane, proposal);
|
|
720
|
+
if (!workKey) return false;
|
|
721
|
+
const proposalAt = String(proposal.at || "");
|
|
722
|
+
const releasedClaim = state.releasedClaims.some((claim) => claim.workKey === workKey && String(claim.at || "") >= proposalAt);
|
|
723
|
+
if (!releasedClaim) return false;
|
|
724
|
+
return state.events.some((event) => ["finding", "handoff", "note"].includes(event.type) && event.workKey === workKey && String(event.at || "") >= proposalAt);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function formatOpenProposalTriageSummary(state, goalId) {
|
|
728
|
+
const total = state.openProposals.length;
|
|
729
|
+
const actionable = state.openProposals.filter((proposal) => proposalLaneActionability(state, goalId, proposal) === "unclaimed").length;
|
|
730
|
+
const owned = state.openProposals.filter((proposal) => proposalLaneActionability(state, goalId, proposal) === "owned").length;
|
|
731
|
+
const fulfilled = state.openProposals.filter((proposal) => proposalLaneActionability(state, goalId, proposal) === "fulfilled").length;
|
|
732
|
+
const detail = [];
|
|
733
|
+
if (actionable !== total) detail.push(`${actionable} unclaimed actionable`);
|
|
734
|
+
if (owned) detail.push(`${owned} active-owned`);
|
|
735
|
+
if (fulfilled) detail.push(`${fulfilled} fulfilled awaiting resolve/defer`);
|
|
736
|
+
const suffix = detail.length ? ` (${detail.join("; ")})` : "";
|
|
737
|
+
return `Triage ${total} open proposal${total === 1 ? "" : "s"}${suffix}; claim one, resolve fulfilled work, or defer obsolete/ambiguous items.`;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function proposalLaneActionability(state, goalId, proposal) {
|
|
741
|
+
const lane = normalizeLaneName(proposal?.lane);
|
|
742
|
+
const workKey = proposalLaneWorkKey(goalId, lane, proposal);
|
|
743
|
+
if (workKey && state.activeClaims.some((claim) => claim.workKey === workKey)) return "owned";
|
|
744
|
+
if (proposalLaneWorkCompleted(state, goalId, proposal)) return "fulfilled";
|
|
745
|
+
return "unclaimed";
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function proposalLaneWorkKey(goalId, lane, proposal = {}) {
|
|
749
|
+
return proposal.workKey || derivePeerGoalWorkKey({ goalId, lane, objective: proposal.summary, mode: "read", paths: proposal.paths });
|
|
750
|
+
}
|
|
751
|
+
|
|
661
752
|
async function updatePeerGoalBoard(root, updater) {
|
|
662
753
|
const path = goalBoardPath(root);
|
|
663
754
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { derivePeerGoalScoutSuggestions, loadPeerGoalBoard } from "./goal-board.mjs";
|
|
1
|
+
import { deriveGoalState, derivePeerGoalScoutSuggestions, loadPeerGoalBoard } from "./goal-board.mjs";
|
|
2
2
|
|
|
3
3
|
export const DEFAULT_PEER_IDLE_WATCHER_INTERVAL_MS = 15_000;
|
|
4
4
|
export const DEFAULT_PEER_IDLE_WATCHER_COOLDOWN_MS = 5 * 60 * 1000;
|
|
@@ -119,6 +119,7 @@ export function derivePeerIdleActivation(board, options = {}) {
|
|
|
119
119
|
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
|
120
120
|
for (const suggestion of suggestions) {
|
|
121
121
|
if (!allowedKinds.has(suggestion.kind)) continue;
|
|
122
|
+
if (localPeerHasActiveGoalWork(board, suggestion.goalId, options.localPeerId)) continue;
|
|
122
123
|
const activation = normalizeActivation(suggestion, options.localPeerId, options);
|
|
123
124
|
if (!activation || !activationFitsPeer(activation, options)) continue;
|
|
124
125
|
if (isActivationCoolingDown(options.state, activation, config, nowMs)) continue;
|
|
@@ -197,6 +198,15 @@ function activationFitsPeer(activation = {}, options = {}) {
|
|
|
197
198
|
return fit.matched.length > 0;
|
|
198
199
|
}
|
|
199
200
|
|
|
201
|
+
function localPeerHasActiveGoalWork(board = {}, goalId, localPeerId) {
|
|
202
|
+
const peerId = cleanString(localPeerId);
|
|
203
|
+
if (!peerId || !goalId) return false;
|
|
204
|
+
const goal = board?.goals?.[goalId];
|
|
205
|
+
if (!goal) return false;
|
|
206
|
+
const state = deriveGoalState(goal);
|
|
207
|
+
return state.activeClaims.some((claim) => claim.peerId === peerId);
|
|
208
|
+
}
|
|
209
|
+
|
|
200
210
|
function peerPersonaFit(suggestion = {}, options = {}) {
|
|
201
211
|
const preferredRoles = normalizeStringList(suggestion.preferredRoles);
|
|
202
212
|
const localTerms = peerProfileTerms(options);
|