@cryptolibertus/pi-peer 0.5.0 → 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 CHANGED
@@ -60,7 +60,7 @@ Short aliases keep common board updates terse: `/peer goals`/`/peer ls`, `/peer
60
60
 
61
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, and no active write claims. Open proposals are intentionally non-blocking: they let peers show initiative without freezing closure. Stale write claims no longer block closure or new overlapping claims; use `/peer goal heartbeat` to revive work after a reconnect and `--force` only when intentionally overriding the readiness gate. 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptolibertus/pi-peer",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Pi package for local Pi-to-Pi peer messaging, slash commands, tools, and runtime transport.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
 
@@ -326,7 +326,20 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
326
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) });
327
327
  }
328
328
  if (state.openProposals.length) {
329
- 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))) {
330
+ const lane = normalizeLaneName(proposal.lane);
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))) {
330
343
  const lane = normalizeLaneName(proposal.lane);
331
344
  const summary = `Self-select proposed ${lane} lane: ${proposal.summary}`;
332
345
  push("P1", "open-proposal", summary, {
@@ -336,13 +349,16 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
336
349
  claimMode: "read",
337
350
  suggestedIntent: suggestedIntentForLane(lane),
338
351
  rationale: "A peer proposed a lane; matching idle peers can claim or review it without planner assignment.",
339
- workKey: proposal.workKey || derivePeerGoalWorkKey({ goalId: goal.id, lane, objective: proposal.summary, mode: "read", paths: proposal.paths }),
352
+ workKey: proposalLaneWorkKey(goal.id, lane, proposal),
340
353
  relatedEventId: proposal.id,
341
354
  });
342
355
  }
343
- push("P1", "open-proposal", `Triage ${state.openProposals.length} open proposal${state.openProposals.length === 1 ? "" : "s"}; claim one or resolve it if obsolete.`, { paths: uniqueEventPaths(state.openProposals) });
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
+ });
344
360
  }
345
- if (state.readyToClose) {
361
+ if (state.readyToClose && !state.openProposals.length) {
346
362
  push("P1", "close", "Goal satisfies closure gates; close it or record a final note.");
347
363
  continue;
348
364
  }
@@ -356,7 +372,7 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
356
372
  rationale: "Multiple lane-specific suggestions let idle peers self-select complementary work and suppress duplicates by work key.",
357
373
  });
358
374
  }
359
- } else if (!state.currentVotes.length && !state.activeWriteClaims.length) {
375
+ } else if (!state.currentVotes.length && !state.activeWriteClaims.length && !state.openProposals.length) {
360
376
  push("P2", "review", "No current peer vote; ask a peer for read-only review or record a pass/fail vote.");
361
377
  }
362
378
  }
@@ -698,6 +714,41 @@ function hasActiveClaimForScoutSuggestion(state, suggestion) {
698
714
  return state.activeClaims.some((claim) => claim.workKey === suggestion.workKey);
699
715
  }
700
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
+
701
752
  async function updatePeerGoalBoard(root, updater) {
702
753
  const path = goalBoardPath(root);
703
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);