@cryptolibertus/pi-peer 0.3.6 → 0.4.0

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
@@ -17,6 +17,7 @@ pi install ./packages/pi-peer
17
17
  - Local peer discovery and transport using project `.pi/peers.json`
18
18
  - Repo-scoped discovery: only Pi sessions in the same git repo/project appear as local peers
19
19
  - Idle watcher daemon: idle peers nudge stuck inbound activations and proactively inspect open goal-board work
20
+ - Persona-aware scout routing: goal-board suggestions include recommended lanes, preferred roles, claim mode, and rationale so proactive peers can choose work that fits their role/persona
20
21
  - Protocol compatibility metadata (`protocolVersion`, min/max compatible versions), peer manifests, capabilities, and trust summaries in descriptors/status/list output
21
22
  - `PI_PEER_ID` runtime override for running multiple local Pi sessions
22
23
  - `pi-peer-publish` skill for safe npm release checks, version bumping, tag push, publish, and verification
@@ -40,14 +41,14 @@ pi install ./packages/pi-peer
40
41
  Useful commands (long form and short aliases):
41
42
 
42
43
  ```bash
43
- /peer goal create "Improve finalization safety" --constraint "one writer per path,no destructive commands"
44
+ /peer goal create "Improve finalization safety" --constraint "one writer per path,no duplicate work,no destructive commands"
44
45
  /peer goal fanout <goal-id> "Fix PR waiting path" --peer researcher,reviewer,worker --path extensions/symphony/index.ts,test/pr-watcher-runtime.test.mjs --send --no-await
45
46
  /peer send worker "Fix PR waiting path" --goal <goal-id> --claim extensions/symphony/index.ts,test/pr-watcher-runtime.test.mjs --no-await
46
47
  /peer progress "tests are running" --phase verification
47
48
  /peer goal finding <goal-id> "PR auto-close can close before merge" --path extensions/symphony/index.ts
48
49
  /peer scout <goal-id> --limit 5
49
50
  /peer goal propose <goal-id> "Add a read-only reviewer before closing" --path extensions/symphony/index.ts
50
- /peer goal claim <goal-id> "Fix PR waiting path" --mode write --path extensions/symphony/index.ts,test/pr-watcher-runtime.test.mjs
51
+ /peer goal claim <goal-id> "Fix PR waiting path" --mode write --path extensions/symphony/index.ts,test/pr-watcher-runtime.test.mjs --key implement:pr-waiting
51
52
  /peer goal heartbeat <goal-id> <claim-event-id> "still working after reconnect" --stale-after-ms 900000
52
53
  /peer goal release <goal-id> <claim-event-id> "worker lane complete"
53
54
  /peer goal object <goal-id> "Missing merged-PR verification"
@@ -57,7 +58,7 @@ Useful commands (long form and short aliases):
57
58
 
58
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.
59
60
 
60
- 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. `/peer goal fanout` turns a goal into role-specific peer lanes, while `peer_progress` reports checkpoints from an inbound long-running task. Active write claims conflict on overlapping paths; released, stale, or expired claims are kept visible but inactive. Claims become stale after 45 minutes without a heartbeat unless the claim or heartbeat sets `--stale-after-ms`.
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`, `coordination`, etc.), preferred roles, a safe default claim mode, rationale, and suppress suggestions already covered by active work keys. 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. Claims become stale after 45 minutes without a heartbeat unless the claim or heartbeat sets `--stale-after-ms`.
61
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
64
 
@@ -66,7 +67,7 @@ Normal goal closure requires at least one current passing vote, no current faile
66
67
  When peer messaging is enabled, the extension starts a lightweight in-process idle watcher on `session_start`. It only acts when Pi reports the agent is idle and there are no queued local follow-up messages. The watcher does two things:
67
68
 
68
69
  1. If an inbound peer task is active but appears not to have triggered a turn, it re-nudges the existing inbound prompt with `triggerTurn: true` using a cooldown.
69
- 2. If no peer task is active, it reads `.pi/peer-goals.json`, derives the same read-only scout suggestions as `/peer scout`, and injects a concise self-prompt so the idle peer can propose, review, claim, vote, or no-op safely.
70
+ 2. If no peer task is active, it reads `.pi/peer-goals.json`, derives the same read-only scout suggestions as `/peer scout`, and injects a concise self-prompt so the idle peer can propose, review, claim, vote, or no-op safely. When the local peer has a configured `role` or `persona`, the watcher prefers suggestions whose lane matches that profile and leaves mismatched work for better-fit peers.
70
71
 
71
72
  Configuration can be placed in `.pi/peers.json` as `idleWatcher` or in `.pi/settings.json` under `peerMessaging.idleWatcher`:
72
73
 
@@ -101,7 +101,10 @@ export default function piPeerExtension(pi: ExtensionAPI) {
101
101
  metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
102
102
  claimedPaths: Type.Optional(Type.Array(Type.String({ description: "Paths this peer task is expected to own while running" }))),
103
103
  goalId: Type.Optional(Type.String({ description: "Peer goal id to link this long-running task to" })),
104
- goalClaimMode: Type.Optional(Type.String({ description: "Goal-board claim mode for claimedPaths; defaults to write" })),
104
+ goalClaimMode: Type.Optional(Type.String({ description: "Goal-board claim mode; defaults to write when paths are supplied, otherwise read" })),
105
+ workKey: Type.Optional(Type.String({ description: "Semantic work fingerprint for idempotent peer dispatch" })),
106
+ workLane: Type.Optional(Type.String({ description: "Semantic work lane, e.g. research, review, coordination, or implementation" })),
107
+ duplicatePolicy: Type.Optional(Type.String({ description: "Duplicate work policy: reuse (default), error, or allow-parallel" })),
105
108
  goalStaleAfterMs: Type.Optional(Type.Number({ description: "Milliseconds before this goal claim is considered stale without heartbeat" })),
106
109
  }),
107
110
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -109,16 +112,24 @@ export default function piPeerExtension(pi: ExtensionAPI) {
109
112
  attachPeerUi(runtime, () => activeContext, (current: any) => refreshPeerUi(current, runtime));
110
113
  ensureEnabled(runtime);
111
114
  await runtime.refreshLocalPeers();
112
- const metadata = mergePeerMetadata(params.metadata, params.claimedPaths, params.goalId);
115
+ const metadata = mergePeerMetadata(params.metadata, params.claimedPaths, params.goalId, { workKey: params.workKey, workLane: params.workLane, duplicatePolicy: params.duplicatePolicy });
113
116
  const goalLink = await beginPeerSendGoalLink(ctx?.cwd, runtime, {
114
117
  goalId: params.goalId,
115
118
  targetPeerId: params.peer,
116
119
  prompt: params.prompt,
117
120
  claimedPaths: metadata.claimedPaths,
118
121
  claimMode: params.goalClaimMode,
122
+ workKey: metadata.workKey,
123
+ workLane: metadata.workLane,
124
+ duplicatePolicy: metadata.duplicatePolicy,
119
125
  staleAfterMs: params.goalStaleAfterMs,
120
126
  });
127
+ if (goalLink?.duplicate) {
128
+ await refreshPeerUi(ctx, runtime);
129
+ return duplicatePeerSendToolResult(goalLink);
130
+ }
121
131
  if (goalLink?.claimEvent?.id) metadata.goalClaimId = goalLink.claimEvent.id;
132
+ if (goalLink?.workKey) metadata.workKey = goalLink.workKey;
122
133
  let handle: any;
123
134
  try {
124
135
  handle = await runtime.comms.sendMessage(params.peer, {
@@ -323,9 +334,17 @@ async function handlePeerCommand(pi: ExtensionAPI, rawArgs: string, ctx: any, re
323
334
  prompt: parsed.prompt,
324
335
  claimedPaths: parsed.claimedPaths,
325
336
  claimMode: parsed.goalClaimMode,
337
+ workKey: parsed.workKey,
338
+ workLane: parsed.workLane,
339
+ duplicatePolicy: parsed.duplicatePolicy,
326
340
  staleAfterMs: parsed.goalStaleAfterMs,
327
341
  });
342
+ if (goalLink?.duplicate) {
343
+ await refresh();
344
+ return sendPeerMessage(pi, formatDuplicatePeerSend(goalLink));
345
+ }
328
346
  if (goalLink?.claimEvent?.id) metadata.goalClaimId = goalLink.claimEvent.id;
347
+ if (goalLink?.workKey) metadata.workKey = goalLink.workKey;
329
348
  let handle: any;
330
349
  try {
331
350
  handle = await runtime.comms.sendMessage(parsed.peerId, { prompt: withPeerGoalInstructions(parsed.prompt, goalLink), intent: parsed.intent, metadata }, { maxHopCount: parsed.maxHopCount, allowSelf: parsed.allowSelf });
@@ -425,6 +444,9 @@ async function handlePeerGoalCommand(parsed: any, ctx: any, runtime: any) {
425
444
  paths: parsed.paths,
426
445
  taskId: parsed.taskId,
427
446
  status: parsed.status,
447
+ workKey: parsed.workKey,
448
+ lane: parsed.workLane,
449
+ duplicatePolicy: parsed.duplicatePolicy,
428
450
  });
429
451
  return `Posted ${result.event.type} ${result.event.id} to ${result.goal.id}.\n\n${formatPeerGoal(result.goal)}`;
430
452
  }
@@ -435,6 +457,9 @@ async function handlePeerGoalCommand(parsed: any, ctx: any, runtime: any) {
435
457
  summary: parsed.summary,
436
458
  paths: parsed.paths,
437
459
  mode: parsed.mode,
460
+ lane: parsed.workLane,
461
+ workKey: parsed.workKey,
462
+ duplicatePolicy: parsed.duplicatePolicy,
438
463
  ttlMs: parsed.ttlMs,
439
464
  staleAfterMs: parsed.staleAfterMs,
440
465
  });
@@ -505,6 +530,7 @@ async function handlePeerGoalFanout(parsed: any, ctx: any, runtime: any, peerId:
505
530
  const planned = [];
506
531
  for (const targetPeerId of parsed.peers) {
507
532
  const mode = inferFanoutClaimMode(targetPeerId);
533
+ const lane = inferFanoutWorkLane(targetPeerId, mode);
508
534
  const summary = `${parsed.objective} [fanout:${targetPeerId}]`;
509
535
  const task = await appendPeerGoalEvent(root, parsed.goalId, {
510
536
  type: "task",
@@ -512,9 +538,9 @@ async function handlePeerGoalFanout(parsed: any, ctx: any, runtime: any, peerId:
512
538
  summary,
513
539
  paths: parsed.paths,
514
540
  status: parsed.send ? "dispatching" : "planned",
515
- metadata: { targetPeerId, fanout: true, claimMode: mode },
541
+ metadata: { targetPeerId, fanout: true, claimMode: mode, workLane: lane },
516
542
  });
517
- planned.push({ peerId: targetPeerId, taskEventId: task.event.id, mode });
543
+ planned.push({ peerId: targetPeerId, taskEventId: task.event.id, mode, lane });
518
544
  }
519
545
  if (parsed.send) {
520
546
  await Promise.all(planned.map(async (item: any) => {
@@ -526,12 +552,31 @@ async function handlePeerGoalFanout(parsed: any, ctx: any, runtime: any, peerId:
526
552
  prompt: parsed.objective,
527
553
  claimedPaths: parsed.paths,
528
554
  claimMode: item.mode,
555
+ workLane: item.lane,
556
+ duplicatePolicy: "reuse",
529
557
  staleAfterMs: parsed.staleAfterMs,
530
558
  });
531
- const metadata = mergePeerMetadata({ fanout: true }, parsed.paths, parsed.goalId);
559
+ if (goalLink?.duplicate) {
560
+ item.duplicate = true;
561
+ item.messageId = goalLink.existingTask?.taskId || goalLink.existingTask?.metadata?.messageId;
562
+ item.conversationId = goalLink.existingTask?.metadata?.conversationId;
563
+ await appendPeerGoalEvent(root, parsed.goalId, {
564
+ type: "handoff",
565
+ peerId: item.peerId,
566
+ summary: `Fan-out duplicate reused existing work key ${goalLink.workKey || "unknown"}`,
567
+ paths: parsed.paths,
568
+ taskId: item.taskEventId,
569
+ status: "done",
570
+ workKey: goalLink.workKey,
571
+ lane: item.lane,
572
+ metadata: { fanout: true, duplicate: true, targetPeerId: item.peerId, existingTaskId: item.messageId },
573
+ }).catch(() => {});
574
+ return;
575
+ }
576
+ const metadata = mergePeerMetadata({ fanout: true }, parsed.paths, parsed.goalId, { workKey: goalLink?.workKey, workLane: item.lane, duplicatePolicy: "reuse" });
532
577
  if (goalLink?.claimEvent?.id) metadata.goalClaimId = goalLink.claimEvent.id;
533
578
  const handle = await runtime.comms.sendMessage(item.peerId, {
534
- prompt: withPeerGoalInstructions(buildFanoutPrompt(parsed.objective, item.peerId, item.mode), goalLink),
579
+ prompt: withPeerGoalInstructions(buildFanoutPrompt(parsed.objective, item.peerId, item.mode, item.lane), goalLink),
535
580
  intent: item.mode === "write" ? "task" : "review",
536
581
  metadata,
537
582
  });
@@ -569,7 +614,7 @@ async function handlePeerGoalFanout(parsed: any, ctx: any, runtime: any, peerId:
569
614
  }
570
615
  const lines = [`Fan-out ${parsed.send ? "dispatched" : "planned"} for ${parsed.goalId}: ${parsed.objective}`];
571
616
  for (const item of planned) {
572
- lines.push(`- ${item.peerId} · ${item.mode}${item.messageId ? ` · ${item.messageId}` : ""}${item.error ? ` · ${item.error.code}` : ""}`);
617
+ lines.push(`- ${item.peerId} · ${item.lane}/${item.mode}${item.duplicate ? " · duplicate reused" : ""}${item.messageId ? ` · ${item.messageId}` : ""}${item.error ? ` · ${item.error.code}` : ""}`);
573
618
  }
574
619
  lines.push("", "Final-answer checklist: include Fan-out used: yes, peer ids, message ids, blockers, and verification.");
575
620
  return lines.join("\n");
@@ -630,24 +675,56 @@ function sendPeerMessage(pi: ExtensionAPI, content: string) {
630
675
  pi.sendMessage({ customType: MESSAGE_TYPE, content, display: true });
631
676
  }
632
677
 
633
- function mergePeerMetadata(metadata: any, claimedPaths: unknown, goalId?: unknown) {
634
- const base = metadata && typeof metadata === "object" && !Array.isArray(metadata) ? { ...metadata } : {};
678
+ function mergePeerMetadata(metadata: any, claimedPaths: unknown, goalId?: unknown, work: any = {}) {
679
+ const base: any = metadata && typeof metadata === "object" && !Array.isArray(metadata) ? { ...metadata } : {};
635
680
  if (Array.isArray(claimedPaths)) {
636
681
  const paths = [...new Set(claimedPaths.filter((item): item is string => typeof item === "string" && item.trim()).map((item) => item.trim()))];
637
682
  if (paths.length) base.claimedPaths = paths;
638
683
  }
639
684
  if (typeof goalId === "string" && goalId.trim()) base.goalId = goalId.trim();
685
+ for (const [key, value] of Object.entries(work || {})) {
686
+ if (typeof value === "string" && value.trim()) base[key] = value.trim();
687
+ }
640
688
  return base;
641
689
  }
642
690
 
691
+ function duplicatePeerSendToolResult(goalLink: any) {
692
+ return {
693
+ content: [{ type: "text", text: formatDuplicatePeerSend(goalLink) }],
694
+ details: { ok: true, kind: "peer_send_duplicate", duplicate: true, ...duplicatePeerSendDetails(goalLink) },
695
+ };
696
+ }
697
+
698
+ function formatDuplicatePeerSend(goalLink: any) {
699
+ const details = duplicatePeerSendDetails(goalLink);
700
+ const task = details.messageId ? ` Existing message: ${details.messageId}${details.conversationId ? ` in ${details.conversationId}` : ""}.` : "";
701
+ return `Duplicate peer work reused for ${details.goalId}: active claim ${details.claimId || "unknown"} already owns work key ${details.workKey || "unknown"}.${task}`;
702
+ }
703
+
704
+ function duplicatePeerSendDetails(goalLink: any) {
705
+ return {
706
+ goalId: goalLink?.goalId,
707
+ workKey: goalLink?.workKey,
708
+ claimId: goalLink?.existingClaim?.id,
709
+ claimPeerId: goalLink?.existingClaim?.peerId,
710
+ messageId: goalLink?.existingTask?.taskId || goalLink?.existingTask?.metadata?.messageId,
711
+ conversationId: goalLink?.existingTask?.metadata?.conversationId,
712
+ };
713
+ }
714
+
643
715
  async function beginPeerSendGoalLink(root: string | undefined, runtime: any, options: any) {
644
716
  if (!options?.goalId) return undefined;
717
+ const paths = Array.isArray(options.claimedPaths) ? options.claimedPaths : [];
718
+ const mode = options.claimMode || (paths.length ? "write" : "read");
645
719
  return beginPeerGoalTask(root || process.cwd(), options.goalId, {
646
720
  requesterPeerId: runtime?.localPeerId || runtime?.summary?.localPeerId || "unknown",
647
721
  targetPeerId: options.targetPeerId,
648
722
  prompt: options.prompt,
649
- claimedPaths: options.claimedPaths,
650
- mode: options.claimMode || "write",
723
+ claimedPaths: paths,
724
+ mode,
725
+ lane: options.workLane || mode,
726
+ workKey: options.workKey,
727
+ duplicatePolicy: options.duplicatePolicy || "reuse",
651
728
  staleAfterMs: options.staleAfterMs,
652
729
  });
653
730
  }
@@ -662,6 +739,10 @@ async function recordPeerSendGoalDispatch(root: string | undefined, runtime: any
662
739
  messageId: handle.messageId,
663
740
  conversationId: handle.conversationId,
664
741
  claimEventId: goalLink.claimEvent?.id,
742
+ workKey: goalLink.workKey,
743
+ mode: goalLink.claimEvent?.mode,
744
+ lane: goalLink.claimEvent?.lane,
745
+ duplicatePolicy: goalLink.duplicatePolicy,
665
746
  });
666
747
  }
667
748
 
@@ -676,6 +757,9 @@ async function recordPeerSendGoalFailure(root: string | undefined, goalLink: any
676
757
  responseStatus: "DISPATCH_ERROR",
677
758
  summary: `DISPATCH_ERROR: ${options.error?.message || String(options.error || "peer send failed")}`,
678
759
  releaseSummary: "Peer message dispatch failed before delivery",
760
+ workKey: goalLink.workKey,
761
+ mode: goalLink.claimEvent?.mode,
762
+ lane: goalLink.claimEvent?.lane,
679
763
  }).catch(() => {});
680
764
  }
681
765
 
@@ -695,6 +779,9 @@ function trackPeerSendGoalCompletion(root: string | undefined, goalLink: any, ha
695
779
  responseStatus: response?.status,
696
780
  summary: summarizePeerGoalResponse(response),
697
781
  releaseSummary: `Peer message ${handle.messageId} completed with ${response?.status || "unknown"}`,
782
+ workKey: goalLink.workKey,
783
+ mode: goalLink.claimEvent?.mode,
784
+ lane: goalLink.claimEvent?.lane,
698
785
  });
699
786
  const missing = missingHandoffFields(response);
700
787
  if (missing.length) {
@@ -739,7 +826,9 @@ function withPeerGoalInstructions(prompt: string, goalLink: any) {
739
826
  const lines = [
740
827
  `Peer goal context:`,
741
828
  `- goalId: ${goalLink.goalId}`,
829
+ ...(goalLink.workKey ? [`- workKey: ${goalLink.workKey}`] : []),
742
830
  ...(goalLink.claimEvent?.id ? [`- claimEventId: ${goalLink.claimEvent.id}`, `- If this takes a while, send heartbeats with /peer goal heartbeat ${goalLink.goalId} ${goalLink.claimEvent.id} "still working".`] : []),
831
+ `- Before starting, inspect the goal board and stop if another active claim already owns the same work key.`,
743
832
  `- End with a concise handoff: status, files changed, verification, blockers.`,
744
833
  ``,
745
834
  `Original prompt:`,
@@ -753,8 +842,16 @@ function inferFanoutClaimMode(peerId: string) {
753
842
  return id.includes("worker") || id.includes("implement") ? "write" : "read";
754
843
  }
755
844
 
756
- function buildFanoutPrompt(objective: string, peerId: string, mode: string) {
757
- const role = mode === "write" ? "implementation lane" : "read-only research/review lane";
845
+ function inferFanoutWorkLane(peerId: string, mode: string) {
846
+ const id = String(peerId || "").toLowerCase();
847
+ if (id.includes("research") || id.includes("scout")) return "research";
848
+ if (id.includes("review") || id.includes("qa")) return "review";
849
+ if (id.includes("coordinator") || id.includes("planner")) return "coordination";
850
+ return mode === "write" ? "implementation" : "review";
851
+ }
852
+
853
+ function buildFanoutPrompt(objective: string, peerId: string, mode: string, lane: string) {
854
+ const role = mode === "write" ? `${lane} implementation lane` : `read-only ${lane} lane`;
758
855
  return `${objective}\n\nFan-out role for ${peerId}: ${role}. Stay within that lane. Report progress with peer_progress when work is long-running, and end with the required final handoff.`;
759
856
  }
760
857
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptolibertus/pi-peer",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
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",
@@ -65,10 +65,13 @@ export function parsePeerCommand(rawArgs = "") {
65
65
  maxHopCount: positiveIntegerFlag(flags.maxHopCount),
66
66
  allowSelf: flagEnabled(flags.allowSelf),
67
67
  goalId,
68
- goalClaimMode: stringFlag(flags.claimMode, "write"),
68
+ goalClaimMode: stringFlag(flags.claimMode, undefined),
69
69
  goalStaleAfterMs: positiveIntegerFlag(flags.staleAfterMs),
70
+ workKey: stringFlag(flags.workKey || flags.key, undefined),
71
+ workLane: stringFlag(flags.workLane || flags.lane, undefined),
72
+ duplicatePolicy: stringFlag(flags.duplicatePolicy, undefined),
70
73
  claimedPaths,
71
- metadata: metadataFromFlags(flags, { goalId, claimedPaths }),
74
+ metadata: metadataFromFlags(flags, { goalId, claimedPaths, workKey: stringFlag(flags.workKey || flags.key, undefined), workLane: stringFlag(flags.workLane || flags.lane, undefined), duplicatePolicy: stringFlag(flags.duplicatePolicy, undefined) }),
72
75
  };
73
76
  }
74
77
  if (subcommand === "goal") {
@@ -137,7 +140,7 @@ export function formatPeerHelp() {
137
140
  "- `/peer reconnect` — refresh local discovery and show current status",
138
141
  "- `/peer resume <message-id>` — resume a disconnected restored peer message after reconnect",
139
142
  "- `/peer cancel <message-id> [reason]` — mark a queued/running/disconnected peer message cancelled",
140
- "- `/peer send <peer> <prompt> [--no-await] [--intent ask] [--goal <goal-id>] [--claim <path[,path]>] [--timeout-ms <ms>] [--allow-self]` — send a prompt-first peer message",
143
+ "- `/peer send <peer> <prompt> [--no-await] [--intent ask] [--goal <goal-id>] [--claim <path[,path]>] [--key <work-key>] [--duplicate-policy reuse|error|allow-parallel]` — send a prompt-first peer message",
141
144
  "- `/peer progress <summary> [--status running] [--phase <name>]` — send a structured checkpoint from an inbound long-running peer task",
142
145
  "- `/peer goals|ls`, `/peer current [goal-id]`, `/peer scout [goal-id]`, `/peer fanout`, `/peer propose`, `/peer take|claim`, `/peer complete|done`, `/peer objection|block`, `/peer unblock`, `/peer ping`, `/peer drop`, `/peer pass|fail` — short goal-board aliases",
143
146
  "- `/peer goal create <objective> [--constraint <a,b>]` — start a flat shared goal board",
@@ -145,7 +148,7 @@ export function formatPeerHelp() {
145
148
  "- `/peer goal fanout <goal-id> <objective> --peer <id[,id]> [--path <a,b>] [--send] [--no-await]` — plan or dispatch role-specific peer lanes",
146
149
  "- `/peer goal scout [goal-id] [--limit <n>] [--include-closed]` — read-only proactive suggestions for what peers could do next",
147
150
  "- `/peer goal task|finding|proposal|handoff|note <goal-id> <summary> [--path <a,b>] [--status done]` — post goal-board events",
148
- "- `/peer goal claim <goal-id> <task> --mode write --path <a,b> [--ttl-ms <ms>] [--stale-after-ms <ms>]` — lease work without hierarchy",
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",
149
152
  "- `/peer goal heartbeat <goal-id> <claim-event-id> [summary] [--ttl-ms <ms>] [--stale-after-ms <ms>]` — refresh a live or stale claim",
150
153
  "- `/peer goal release <goal-id> <claim-event-id> [summary]` — release a claimed lane",
151
154
  "- `/peer goal object <goal-id> <reason> [--path <a,b>]`, `/peer goal resolve <goal-id> <event-id> <summary>`, `/peer goal vote <goal-id> <pass|fail|pass-with-risks> [summary]`",
@@ -198,14 +201,14 @@ function parsePeerGoalCommand(parsed, flags, positionals) {
198
201
  const goalId = rest[0];
199
202
  const summary = rest.slice(1).join(" ").trim();
200
203
  if (!goalId || !summary) return { ...withAction, error: `/peer goal ${action} requires <goal-id> <summary>` };
201
- return { ...withAction, goalId, eventType: action === "propose" ? "proposal" : action, summary, paths: listFlag(flags.path || flags.paths), severity: stringFlag(flags.severity, undefined), taskId: stringFlag(flags.taskId, undefined), status: stringFlag(flags.status, undefined) };
204
+ return { ...withAction, goalId, eventType: action === "propose" ? "proposal" : action, summary, paths: listFlag(flags.path || flags.paths), severity: stringFlag(flags.severity, undefined), taskId: stringFlag(flags.taskId, undefined), status: stringFlag(flags.status, undefined), workKey: stringFlag(flags.workKey || flags.key, undefined), workLane: stringFlag(flags.workLane || flags.lane, undefined), duplicatePolicy: stringFlag(flags.duplicatePolicy, undefined) };
202
205
  }
203
206
  if (action === "claim") {
204
207
  if (flagEnabled(flags.write) && flags.mode === undefined) flags.mode = "write";
205
208
  const goalId = rest[0];
206
209
  const summary = rest.slice(1).join(" ").trim();
207
210
  if (!goalId || !summary) return { ...withAction, error: "/peer goal claim requires <goal-id> <task>" };
208
- return { ...withAction, goalId, summary, paths: listFlag(flags.path || flags.paths), mode: stringFlag(flags.mode, "read"), ttlMs: positiveIntegerFlag(flags.ttlMs), staleAfterMs: positiveIntegerFlag(flags.staleAfterMs) };
211
+ return { ...withAction, goalId, summary, paths: listFlag(flags.path || flags.paths), mode: stringFlag(flags.mode, "read"), workKey: stringFlag(flags.workKey || flags.key, undefined), workLane: stringFlag(flags.workLane || flags.lane, undefined), duplicatePolicy: stringFlag(flags.duplicatePolicy, undefined), ttlMs: positiveIntegerFlag(flags.ttlMs), staleAfterMs: positiveIntegerFlag(flags.staleAfterMs) };
209
212
  }
210
213
  if (action === "heartbeat") {
211
214
  const goalId = rest[0];
@@ -266,9 +269,15 @@ function positiveIntegerFlag(value) {
266
269
  function metadataFromFlags(flags = {}, options = {}) {
267
270
  const claimedPaths = options.claimedPaths || claimedPathsFlag(flags.claim || flags.claimedPath || flags.claimedPaths);
268
271
  const goalId = options.goalId || stringFlag(flags.goal || flags.goalId, undefined);
272
+ const workKey = options.workKey || stringFlag(flags.workKey || flags.key, undefined);
273
+ const workLane = options.workLane || stringFlag(flags.workLane || flags.lane, undefined);
274
+ const duplicatePolicy = options.duplicatePolicy || stringFlag(flags.duplicatePolicy, undefined);
269
275
  return {
270
276
  ...(claimedPaths.length ? { claimedPaths } : {}),
271
277
  ...(goalId ? { goalId } : {}),
278
+ ...(workKey ? { workKey } : {}),
279
+ ...(workLane ? { workLane } : {}),
280
+ ...(duplicatePolicy ? { duplicatePolicy } : {}),
272
281
  };
273
282
  }
274
283
 
@@ -8,7 +8,17 @@ export const PEER_GOAL_BOARD_RELATIVE_PATH = ".pi/peer-goals.json";
8
8
  const EVENT_TYPES = new Set(["finding", "task", "proposal", "claim", "release", "heartbeat", "objection", "resolve", "vote", "handoff", "note"]);
9
9
  const BLOCKING_SEVERITIES = new Set(["blocking", "blocker", "critical"]);
10
10
  const VOTE_VERDICTS = new Set(["pass", "fail", "pass-with-risks"]);
11
+ const DUPLICATE_POLICIES = new Set(["error", "reuse", "allow-parallel"]);
11
12
  const DEFAULT_GOAL_CLAIM_STALE_MS = 45 * 60 * 1000;
13
+ const SCOUT_LANES = Object.freeze({
14
+ blocker: { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator", "reviewer"], claimMode: "read", suggestedIntent: "review", rationale: "Blocking objections need a coordination/review lane before more work starts." },
15
+ "failed-vote": { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator", "reviewer"], claimMode: "read", suggestedIntent: "review", rationale: "Failed votes need triage before new implementation work." },
16
+ "stale-claim": { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator"], claimMode: "read", suggestedIntent: "coordinate", rationale: "Stale claims need owner follow-up or release, not duplicate writes." },
17
+ "open-proposal": { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator", "reviewer"], claimMode: "read", suggestedIntent: "review", rationale: "Open proposals need triage into accept, defer, or resolve decisions." },
18
+ close: { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator", "reviewer"], claimMode: "read", suggestedIntent: "coordinate", rationale: "Ready goals need final closure checks and a concise handoff." },
19
+ "next-step": { recommendedLane: "research", preferredRoles: ["researcher", "reviewer", "planner", "coordinator", "worker"], claimMode: "read", suggestedIntent: "review", rationale: "Empty goals benefit from a read-only lane before write claims." },
20
+ review: { recommendedLane: "review", preferredRoles: ["reviewer", "qa", "coordinator", "planner"], claimMode: "read", suggestedIntent: "review", rationale: "Goals without current votes need read-only validation before closure." },
21
+ });
12
22
  const GOAL_BOARD_LOCK_STALE_MS = 30_000;
13
23
  const GOAL_BOARD_LOCK_RETRY_MS = 10;
14
24
  const GOAL_BOARD_LOCK_TIMEOUT_MS = 5_000;
@@ -96,26 +106,61 @@ export async function closePeerGoal(root, goalId, input = {}) {
96
106
 
97
107
  export async function beginPeerGoalTask(root, goalId, input = {}) {
98
108
  const id = requiredGoalId(goalId || input.goalId);
99
- const board = await loadPeerGoalBoard(root);
100
- if (!board.goals[id]) throw new Error(`peer goal ${id} not found`);
101
- const paths = normalizePaths(input.claimedPaths || input.paths);
102
- if (!paths.length) return { goalId: id, goal: deriveGoalState(board.goals[id]) };
109
+ return updatePeerGoalBoard(root, (board) => {
110
+ const goal = resolveGoal(board, id);
111
+ const paths = normalizePaths(input.claimedPaths || input.paths);
112
+ const mode = cleanText(input.mode || input.claimMode || (paths.length ? "write" : "read")).toLowerCase();
113
+ const lane = cleanText(input.lane || input.workLane || input.intent || mode).toLowerCase();
114
+ const workKey = normalizeWorkKey(input.workKey) || derivePeerGoalWorkKey({
115
+ goalId: id,
116
+ lane,
117
+ objective: input.objective || input.summary || input.prompt,
118
+ mode,
119
+ paths,
120
+ });
121
+ if (!paths.length && !workKey) return { goalId: id, goal: deriveGoalState(goal) };
122
+
123
+ const duplicatePolicy = normalizeDuplicatePolicy(input.duplicatePolicy) || "reuse";
124
+ if (workKey && duplicatePolicy === "reuse") {
125
+ const state = deriveGoalState(goal);
126
+ const existingClaim = state.activeClaims.find((claim) => claim.workKey === workKey);
127
+ if (existingClaim) {
128
+ return {
129
+ goalId: id,
130
+ goal: state,
131
+ duplicate: true,
132
+ duplicatePolicy,
133
+ workKey,
134
+ existingClaim,
135
+ existingTask: latestTaskForWorkKey(state.tasks, workKey),
136
+ };
137
+ }
138
+ }
103
139
 
104
- const result = await appendPeerGoalEvent(root, id, {
105
- type: "claim",
106
- peerId: cleanText(input.targetPeerId || input.peerId) || "unknown",
107
- summary: taskSummary(input),
108
- paths,
109
- mode: cleanText(input.mode || input.claimMode || "write").toLowerCase(),
110
- ttlMs: input.ttlMs,
111
- staleAfterMs: input.staleAfterMs,
112
- metadata: stripEmpty({ requesterPeerId: cleanText(input.requesterPeerId), targetPeerId: cleanText(input.targetPeerId) }),
140
+ const event = normalizeEvent({
141
+ type: "claim",
142
+ peerId: cleanText(input.targetPeerId || input.peerId) || "unknown",
143
+ summary: taskSummary(input),
144
+ paths,
145
+ mode,
146
+ lane,
147
+ workKey,
148
+ duplicatePolicy,
149
+ ttlMs: input.ttlMs,
150
+ staleAfterMs: input.staleAfterMs,
151
+ metadata: stripEmpty({ requesterPeerId: cleanText(input.requesterPeerId), targetPeerId: cleanText(input.targetPeerId), workKey, lane, duplicatePolicy }),
152
+ });
153
+ validateClaim(goal, event);
154
+ goal.events.push(event);
155
+ goal.updatedAt = event.at;
156
+ board.currentGoalId = goal.id;
157
+ return { goalId: id, goal: deriveGoalState(goal), claimEvent: event, workKey, duplicatePolicy };
113
158
  });
114
- return { goalId: id, goal: result.goal, claimEvent: result.event };
115
159
  }
116
160
 
117
161
  export async function recordPeerGoalTaskDispatch(root, goalId, input = {}) {
118
162
  const id = requiredGoalId(goalId || input.goalId);
163
+ const workKey = normalizeWorkKey(input.workKey) || derivePeerGoalWorkKey({ goalId: id, lane: input.lane || input.workLane || input.intent || input.mode, objective: input.objective || input.summary || input.prompt, mode: input.mode || input.claimMode, paths: input.claimedPaths || input.paths });
119
164
  const result = await appendPeerGoalEvent(root, id, {
120
165
  type: "task",
121
166
  peerId: cleanText(input.requesterPeerId || input.peerId) || "unknown",
@@ -123,11 +168,15 @@ export async function recordPeerGoalTaskDispatch(root, goalId, input = {}) {
123
168
  paths: input.claimedPaths || input.paths,
124
169
  taskId: input.messageId,
125
170
  status: cleanText(input.status || "running"),
171
+ workKey,
172
+ lane: input.lane || input.workLane,
173
+ duplicatePolicy: input.duplicatePolicy,
126
174
  metadata: stripEmpty({
127
175
  messageId: cleanText(input.messageId),
128
176
  conversationId: cleanText(input.conversationId),
129
177
  targetPeerId: cleanText(input.targetPeerId),
130
178
  claimEventId: cleanText(input.claimEventId),
179
+ workKey,
131
180
  }),
132
181
  });
133
182
  return { goalId: id, goal: result.goal, taskEvent: result.event };
@@ -135,6 +184,7 @@ export async function recordPeerGoalTaskDispatch(root, goalId, input = {}) {
135
184
 
136
185
  export async function completePeerGoalTask(root, goalId, input = {}) {
137
186
  const id = requiredGoalId(goalId || input.goalId);
187
+ const workKey = normalizeWorkKey(input.workKey) || derivePeerGoalWorkKey({ goalId: id, lane: input.lane || input.workLane || input.intent || input.mode, objective: input.objective || input.summary || input.prompt, mode: input.mode || input.claimMode, paths: input.claimedPaths || input.paths });
138
188
  const handoff = await appendPeerGoalEvent(root, id, {
139
189
  type: "handoff",
140
190
  peerId: cleanText(input.targetPeerId || input.peerId) || "unknown",
@@ -142,11 +192,14 @@ export async function completePeerGoalTask(root, goalId, input = {}) {
142
192
  paths: input.claimedPaths || input.paths,
143
193
  taskId: input.messageId,
144
194
  status: cleanText(input.status || "done"),
195
+ workKey,
196
+ lane: input.lane || input.workLane,
145
197
  metadata: stripEmpty({
146
198
  messageId: cleanText(input.messageId),
147
199
  conversationId: cleanText(input.conversationId),
148
200
  claimEventId: cleanText(input.claimEventId),
149
201
  responseStatus: cleanText(input.responseStatus),
202
+ workKey,
150
203
  }),
151
204
  });
152
205
  if (!input.claimEventId) return { goalId: id, goal: handoff.goal, handoffEvent: handoff.event };
@@ -159,6 +212,16 @@ export async function completePeerGoalTask(root, goalId, input = {}) {
159
212
  return { goalId: id, goal: release.goal, handoffEvent: handoff.event, releaseEvent: release.event };
160
213
  }
161
214
 
215
+ export function derivePeerGoalWorkKey(input = {}) {
216
+ const goalId = normalizeWorkKeyPart(input.goalId);
217
+ const lane = normalizeWorkKeyPart(input.lane || input.workLane || input.intent || "work");
218
+ const objective = normalizeWorkKeyPart(input.objective || input.summary || input.prompt || "work");
219
+ const mode = normalizeWorkKeyPart(input.mode || input.claimMode || "read");
220
+ const paths = normalizePaths(input.paths || input.claimedPaths).map(normalizeWorkKeyPart).sort();
221
+ const parts = [goalId, lane, objective, mode, paths.join(",")].filter((part) => part !== undefined && part !== "");
222
+ return parts.length ? parts.join("|") : undefined;
223
+ }
224
+
162
225
  export function deriveGoalState(goal, options = {}) {
163
226
  const now = options.now || nowIso();
164
227
  const events = Array.isArray(goal?.events) ? goal.events : [];
@@ -222,7 +285,8 @@ export function formatPeerGoalScout(board, options = {}) {
222
285
  const lines = ["# Peer Scout", "", "Proactive suggestions (read-only):"];
223
286
  for (const suggestion of suggestions.slice(0, limit)) {
224
287
  const pathText = suggestion.paths?.length ? ` · paths: ${suggestion.paths.join(", ")}` : "";
225
- lines.push(`- ${suggestion.priority} · ${suggestion.goalId} · ${suggestion.kind}: ${suggestion.summary}${pathText}`);
288
+ const laneText = suggestion.recommendedLane ? ` · lane: ${suggestion.recommendedLane}${suggestion.preferredRoles?.length ? ` for ${suggestion.preferredRoles.join("/")}` : ""}${suggestion.claimMode ? ` (${suggestion.claimMode})` : ""}` : "";
289
+ lines.push(`- ${suggestion.priority} · ${suggestion.goalId} · ${suggestion.kind}: ${suggestion.summary}${laneText}${pathText}`);
226
290
  }
227
291
  lines.push("", "Next: post one with `/peer goal propose <goal-id> <summary>` or claim safe work with `/peer goal claim <goal-id> <task> --mode read|write --path <path>`. Scout does not mutate the board.");
228
292
  return lines.join("\n");
@@ -238,7 +302,10 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
238
302
  const suggestions = [];
239
303
  for (const goal of goals) {
240
304
  const state = deriveGoalState(goal);
241
- const push = (priority, kind, summary, extra = {}) => suggestions.push(stripEmpty({ goalId: goal.id, priority, kind, summary, ...extra }));
305
+ const push = (priority, kind, summary, extra = {}) => {
306
+ const suggestion = enrichScoutSuggestion({ goalId: goal.id, priority, kind, summary, ...extra });
307
+ if (!hasActiveClaimForScoutSuggestion(state, suggestion)) suggestions.push(suggestion);
308
+ };
242
309
  if (state.blockingObjections.length) {
243
310
  push("P0", "blocker", `Resolve ${state.blockingObjections.length} blocking objection${state.blockingObjections.length === 1 ? "" : "s"} before more work.`, { paths: uniqueEventPaths(state.blockingObjections) });
244
311
  continue;
@@ -276,11 +343,11 @@ export function formatPeerGoal(goal) {
276
343
  if (state.constraints?.length) lines.push(`constraints: ${state.constraints.join("; ")}`);
277
344
  if (state.activeClaims.length) {
278
345
  lines.push("", "Active claims:");
279
- for (const claim of state.activeClaims) lines.push(`- ${claim.id} · ${claim.peerId} · ${claim.mode || "read"} · ${claim.summary}${claim.paths?.length ? ` · ${claim.paths.join(", ")}` : ""}`);
346
+ for (const claim of state.activeClaims) lines.push(`- ${claim.id} · ${claim.peerId} · ${claim.mode || "read"} · ${claim.summary}${claim.paths?.length ? ` · ${claim.paths.join(", ")}` : ""}${claim.workKey ? ` · key ${truncate(claim.workKey, 80)}` : ""}`);
280
347
  }
281
348
  if (state.staleClaims.length) {
282
349
  lines.push("", "Stale claims:");
283
- 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}` : ""}`);
350
+ for (const claim of state.staleClaims.slice(-8)) lines.push(`- ${claim.id} · ${claim.peerId} · ${claim.mode || "read"} · ${claim.summary}${claim.workKey ? ` · key ${truncate(claim.workKey, 80)}` : ""}${claim.lastHeartbeatAt ? ` · last heartbeat ${claim.lastHeartbeatAt}` : ""}`);
284
351
  }
285
352
  if (state.expiredClaims.length) {
286
353
  lines.push("", "Expired claims:");
@@ -312,6 +379,10 @@ function validateClaim(goal, event) {
312
379
  const paths = normalizePaths(event.paths);
313
380
  if (event.mode === "write" && paths.length === 0) throw new Error("write claims require --path <path[,path]>");
314
381
  const state = deriveGoalState(goal);
382
+ if (event.workKey && event.duplicatePolicy !== "allow-parallel") {
383
+ const duplicates = state.activeClaims.filter((claim) => claim.workKey === event.workKey);
384
+ if (duplicates.length) throw new Error(`claim duplicates active work key ${event.workKey} already held by ${duplicates.map((claim) => claim.id).join(", ")}`);
385
+ }
315
386
  if (event.mode === "write") {
316
387
  const conflicts = state.activeClaims.filter((claim) => claim.mode === "write" && pathsOverlap(paths, claim.paths || []));
317
388
  if (conflicts.length) throw new Error(`claim conflicts with active write claim ${conflicts.map((claim) => claim.id).join(", ")}`);
@@ -334,6 +405,10 @@ function validateHeartbeat(goal, event) {
334
405
  const state = deriveGoalState(goal);
335
406
  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);
336
407
  if (!claim) throw new Error(`peer goal heartbeat target ${event.resolves} is not an active, stale, or expired claim`);
408
+ if (claim.workKey && claim.duplicatePolicy !== "allow-parallel") {
409
+ const duplicates = state.activeClaims.filter((item) => item.id !== claim.id && item.workKey === claim.workKey);
410
+ if (duplicates.length) throw new Error(`heartbeat conflicts with active work key ${claim.workKey} already held by ${duplicates.map((item) => item.id).join(", ")}`);
411
+ }
337
412
  if (claim.mode === "write") {
338
413
  const conflicts = state.activeClaims.filter((item) => item.id !== claim.id && item.mode === "write" && pathsOverlap(claim.paths || [], item.paths || []));
339
414
  if (conflicts.length) throw new Error(`heartbeat conflicts with active write claim ${conflicts.map((item) => item.id).join(", ")}`);
@@ -368,6 +443,9 @@ function normalizeEvent(input = {}) {
368
443
  paths: normalizePaths(input.paths),
369
444
  taskId: cleanText(input.taskId),
370
445
  mode: cleanText(input.mode || (type === "claim" ? "read" : "")).toLowerCase() || undefined,
446
+ lane: cleanText(input.lane || input.workLane)?.toLowerCase(),
447
+ workKey: normalizeWorkKey(input.workKey),
448
+ duplicatePolicy: normalizeDuplicatePolicy(input.duplicatePolicy),
371
449
  resolves: cleanText(input.resolves),
372
450
  verdict: cleanText(input.verdict)?.toLowerCase(),
373
451
  confidence: confidenceValue(input.confidence),
@@ -397,6 +475,14 @@ function taskSummary(input = {}) {
397
475
  return cleanText(input.summary) || cleanText(input.prompt) || `Peer task for ${cleanText(input.targetPeerId || input.peerId) || "unknown"}`;
398
476
  }
399
477
 
478
+ function latestTaskForWorkKey(tasks = [], workKey) {
479
+ if (!workKey) return undefined;
480
+ const activeStatuses = new Set(["queued", "dispatching", "running", "pending"]);
481
+ return [...tasks]
482
+ .reverse()
483
+ .find((task) => task.workKey === workKey && (!task.status || activeStatuses.has(String(task.status).toLowerCase())));
484
+ }
485
+
400
486
  function normalizeBoard(board = {}) {
401
487
  const goals = plainObject(board.goals) ? board.goals : {};
402
488
  const normalizedGoals = {};
@@ -412,7 +498,7 @@ function normalizeBoard(board = {}) {
412
498
  createdBy: cleanText(goal.createdBy),
413
499
  closedAt: cleanText(goal.closedAt),
414
500
  closedBy: cleanText(goal.closedBy),
415
- events: Array.isArray(goal.events) ? goal.events.map((event) => stripEmpty({ ...event, paths: normalizePaths(event.paths) })) : [],
501
+ events: Array.isArray(goal.events) ? goal.events.map((event) => stripEmpty({ ...event, paths: normalizePaths(event.paths), workKey: normalizeWorkKey(event.workKey), duplicatePolicy: normalizeDuplicatePolicy(event.duplicatePolicy), lane: cleanText(event.lane || event.workLane)?.toLowerCase() })) : [],
416
502
  };
417
503
  }
418
504
  return { version: 1, currentGoalId: cleanText(board.currentGoalId), goals: normalizedGoals };
@@ -436,6 +522,9 @@ function projectEventSummary(event) {
436
522
  paths: event.paths,
437
523
  taskId: event.taskId,
438
524
  mode: event.mode,
525
+ lane: event.lane,
526
+ workKey: event.workKey,
527
+ duplicatePolicy: event.duplicatePolicy,
439
528
  resolves: event.resolves,
440
529
  verdict: event.verdict,
441
530
  confidence: event.confidence,
@@ -483,6 +572,47 @@ function uniqueEventPaths(events) {
483
572
  return [...new Set(events.flatMap((event) => Array.isArray(event.paths) ? event.paths : []))];
484
573
  }
485
574
 
575
+ function normalizeWorkKey(value) {
576
+ const text = cleanText(value);
577
+ if (!text) return undefined;
578
+ return text.toLowerCase().replace(/\s+/g, " ").trim();
579
+ }
580
+
581
+ function normalizeWorkKeyPart(value) {
582
+ const text = cleanText(value);
583
+ if (!text) return undefined;
584
+ return text.toLowerCase().replace(/\s+/g, " ").replace(/[|]+/g, "/").trim();
585
+ }
586
+
587
+ function normalizeDuplicatePolicy(value) {
588
+ const policy = cleanText(value)?.toLowerCase();
589
+ return DUPLICATE_POLICIES.has(policy) ? policy : undefined;
590
+ }
591
+
592
+ function enrichScoutSuggestion(suggestion = {}) {
593
+ const lane = SCOUT_LANES[suggestion.kind] || {};
594
+ const recommendedLane = suggestion.recommendedLane || lane.recommendedLane;
595
+ const claimMode = cleanText(suggestion.claimMode || lane.claimMode);
596
+ const enriched = stripEmpty({
597
+ ...suggestion,
598
+ recommendedLane,
599
+ preferredRoles: normalizeList(suggestion.preferredRoles || lane.preferredRoles),
600
+ preferredCapabilities: normalizeList(suggestion.preferredCapabilities || lane.preferredCapabilities),
601
+ claimMode,
602
+ suggestedIntent: cleanText(suggestion.suggestedIntent || lane.suggestedIntent),
603
+ rationale: cleanText(suggestion.rationale || lane.rationale),
604
+ });
605
+ return stripEmpty({
606
+ ...enriched,
607
+ workKey: suggestion.workKey || derivePeerGoalWorkKey({ goalId: enriched.goalId, lane: recommendedLane, objective: enriched.summary, mode: claimMode, paths: enriched.paths }),
608
+ });
609
+ }
610
+
611
+ function hasActiveClaimForScoutSuggestion(state, suggestion) {
612
+ if (!suggestion?.workKey) return false;
613
+ return state.activeClaims.some((claim) => claim.workKey === suggestion.workKey);
614
+ }
615
+
486
616
  async function updatePeerGoalBoard(root, updater) {
487
617
  const path = goalBoardPath(root);
488
618
  await mkdir(dirname(path), { recursive: true });
@@ -62,6 +62,9 @@ export function createPeerIdleWatcher(options = {}) {
62
62
  const board = await loadBoard(runtime.cwd || ctx?.cwd || process.cwd());
63
63
  const activation = derivePeerIdleActivation(board, {
64
64
  localPeerId: runtime.localPeerId,
65
+ localRole: runtime.config?.localPeerProfile?.role || runtime.localEndpoint?.role,
66
+ localPersona: runtime.config?.localPeerProfile?.persona || runtime.localEndpoint?.persona,
67
+ localCapabilities: runtime.localEndpoint?.capabilities || runtime.config?.manifest?.capabilities,
65
68
  config,
66
69
  state,
67
70
  nowMs: now(),
@@ -116,8 +119,8 @@ export function derivePeerIdleActivation(board, options = {}) {
116
119
  const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
117
120
  for (const suggestion of suggestions) {
118
121
  if (!allowedKinds.has(suggestion.kind)) continue;
119
- const activation = normalizeActivation(suggestion, options.localPeerId);
120
- if (!activation) continue;
122
+ const activation = normalizeActivation(suggestion, options.localPeerId, options);
123
+ if (!activation || !activationFitsPeer(activation, options)) continue;
121
124
  if (isActivationCoolingDown(options.state, activation, config, nowMs)) continue;
122
125
  return activation;
123
126
  }
@@ -135,11 +138,14 @@ export function markPeerIdleActivation(state, activation, nowMs = Date.now()) {
135
138
  export function buildPeerIdleActivationPrompt(activation, options = {}) {
136
139
  const peerId = options.localPeerId || "this-peer";
137
140
  const paths = activation.paths?.length ? `\nPaths: ${activation.paths.join(", ")}` : "";
138
- return `[Pi peer idle watcher]\nYou are local peer '${peerId}' and Pi is idle. A proactive goal-board scout suggestion is available.\n\nGoal: ${activation.goalId}\nSuggestion: ${activation.kind} (${activation.priority}) ${activation.summary}${paths}\n\nInstructions:\n- First inspect current state with peer_get id '${activation.goalId}'.\n- If useful, take one small safe action: post a proposal/finding/vote, claim a read-only review lane, or claim write work only when you intend to edit and can name the paths.\n- Do not duplicate active claims or proposals. If the board is no longer actionable, say so briefly and stop.\n- For write work, respect goal-board claims and end with the required peer handoff sections.\n- Keep the response concise.`;
141
+ const lane = activation.recommendedLane ? `\nRecommended lane: ${activation.recommendedLane}${activation.claimMode ? ` (${activation.claimMode})` : ""}${activation.preferredRoles?.length ? ` · preferred roles: ${activation.preferredRoles.join(", ")}` : ""}` : "";
142
+ const rationale = activation.rationale ? `\nRationale: ${activation.rationale}` : "";
143
+ const fit = activation.personaFit?.matched?.length ? `\nPersona fit: matched ${activation.personaFit.matched.join(", ")}` : "";
144
+ return `[Pi peer idle watcher]\nYou are local peer '${peerId}' and Pi is idle. A proactive goal-board scout suggestion is available.\n\nGoal: ${activation.goalId}\nSuggestion: ${activation.kind} (${activation.priority}) — ${activation.summary}${lane}${rationale}${fit}${paths}\n\nInstructions:\n- First inspect current state with peer_get id '${activation.goalId}'.\n- If useful, take one small safe action that fits the recommended lane: post a proposal/finding/vote, claim a read-only review lane, or claim write work only when you intend to edit and can name the paths.\n- Do not duplicate active claims or proposals. If the board is no longer actionable, say so briefly and stop.\n- For write work, respect goal-board claims and end with the required peer handoff sections.\n- Keep the response concise.`;
139
145
  }
140
146
 
141
147
  export function peerIdleActivationKey(activation = {}) {
142
- return [activation.goalId, activation.kind, activation.summary, ...(activation.paths || [])].join("|");
148
+ return [activation.goalId, activation.kind, activation.recommendedLane, activation.summary, ...(activation.paths || [])].join("|");
143
149
  }
144
150
 
145
151
  function isActivationCoolingDown(state, activation, config, nowMs) {
@@ -147,18 +153,76 @@ function isActivationCoolingDown(state, activation, config, nowMs) {
147
153
  return Number.isFinite(last) && nowMs - last < config.cooldownMs;
148
154
  }
149
155
 
150
- function normalizeActivation(suggestion = {}, localPeerId) {
156
+ function normalizeActivation(suggestion = {}, localPeerId, options = {}) {
151
157
  if (!suggestion.goalId || !suggestion.kind || !suggestion.summary) return undefined;
158
+ const personaFit = peerPersonaFit(suggestion, options);
152
159
  return {
153
160
  goalId: suggestion.goalId,
154
161
  priority: suggestion.priority || "P2",
155
162
  kind: suggestion.kind,
156
163
  summary: suggestion.summary,
164
+ recommendedLane: cleanString(suggestion.recommendedLane),
165
+ preferredRoles: normalizeStringList(suggestion.preferredRoles),
166
+ preferredCapabilities: normalizeStringList(suggestion.preferredCapabilities),
167
+ claimMode: cleanString(suggestion.claimMode),
168
+ suggestedIntent: cleanString(suggestion.suggestedIntent),
169
+ rationale: cleanString(suggestion.rationale),
170
+ personaFit,
157
171
  paths: Array.isArray(suggestion.paths) ? suggestion.paths.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim()) : [],
158
172
  peerId: localPeerId,
159
173
  };
160
174
  }
161
175
 
176
+ function activationFitsPeer(activation = {}, options = {}) {
177
+ const preferred = activation.preferredRoles || [];
178
+ if (activation.priority === "P0" || !preferred.length) return true;
179
+ const fit = activation.personaFit || peerPersonaFit(activation, options);
180
+ if (!fit.hasProfile) return true;
181
+ return fit.matched.length > 0;
182
+ }
183
+
184
+ function peerPersonaFit(suggestion = {}, options = {}) {
185
+ const preferredRoles = normalizeStringList(suggestion.preferredRoles);
186
+ const localTerms = peerProfileTerms(options);
187
+ if (!preferredRoles.length) return { hasProfile: localTerms.length > 0, matched: [] };
188
+ const matched = preferredRoles.filter((role) => localTerms.includes(normalizeRoleToken(role)));
189
+ return { hasProfile: localTerms.some(isKnownRoleTerm), matched };
190
+ }
191
+
192
+ function peerProfileTerms(options = {}) {
193
+ const terms = [options.localRole, options.localPersona, options.localPeerId]
194
+ .flatMap((value) => String(value || "").toLowerCase().split(/[^a-z0-9]+/g))
195
+ .map(normalizeRoleToken)
196
+ .filter(Boolean);
197
+ const capabilities = options.localCapabilities && typeof options.localCapabilities === "object" ? options.localCapabilities : {};
198
+ if (Array.isArray(capabilities.roles)) terms.push(...capabilities.roles.map(normalizeRoleToken));
199
+ return [...new Set(terms)];
200
+ }
201
+
202
+ function isKnownRoleTerm(value) {
203
+ return ["planner", "coordinator", "reviewer", "qa", "worker", "researcher"].includes(value);
204
+ }
205
+
206
+ function normalizeRoleToken(value) {
207
+ const token = String(value || "").trim().toLowerCase();
208
+ if (!token) return "";
209
+ if (/^(reviewer|review|qa|quality)\d*$/.test(token)) return token === "qa" ? "qa" : "reviewer";
210
+ if (/^(worker|implement|implementation|code|coder|engineer|developer|task)\d*$/.test(token)) return "worker";
211
+ if (/^(researcher|research|scout)\d*$/.test(token)) return "researcher";
212
+ if (/^(planner|plan|coordinate|coordinator|orchestrator)\d*$/.test(token)) return token.startsWith("planner") ? "planner" : "coordinator";
213
+ return token;
214
+ }
215
+
216
+ function normalizeStringList(value) {
217
+ if (Array.isArray(value)) return [...new Set(value.map(cleanString).filter(Boolean))];
218
+ if (typeof value === "string") return [...new Set(value.split(",").map(cleanString).filter(Boolean))];
219
+ return [];
220
+ }
221
+
222
+ function cleanString(value) {
223
+ return typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
224
+ }
225
+
162
226
  function isContextIdle(ctx) {
163
227
  if (!ctx) return { ok: false, reason: "no active session context" };
164
228
  if (typeof ctx.isIdle === "function" && !ctx.isIdle()) return { ok: false, reason: "agent busy" };
@@ -133,20 +133,44 @@ function capabilitySummary(capabilities = {}) {
133
133
  }
134
134
 
135
135
  export function deriveFanoutSuggestion(peers = [], pendingMessages = []) {
136
- const availablePeers = peers
136
+ const availablePeerDetails = peers
137
137
  .filter((peer) => !peer.current && !peer.self && peer.trust !== "disabled")
138
- .map((peer) => peer.peerId)
139
- .filter(Boolean);
138
+ .map((peer) => ({
139
+ peerId: peer.peerId,
140
+ role: safeStatusText(peer.role),
141
+ persona: safeStatusText(peer.persona),
142
+ recommendedLane: recommendLaneForPeer(peer),
143
+ }))
144
+ .filter((peer) => peer.peerId);
145
+ const availablePeers = availablePeerDetails.map((peer) => peer.peerId);
146
+ const lanes = availablePeerDetails.reduce((groups, peer) => {
147
+ const lane = peer.recommendedLane || "general";
148
+ if (!groups[lane]) groups[lane] = [];
149
+ groups[lane].push(peer.peerId);
150
+ return groups;
151
+ }, {});
140
152
  const activePeerTasks = pendingMessages.filter((message) => ["queued", "running"].includes(message.status));
141
153
  const recommended = availablePeers.length > 0 && activePeerTasks.length === 0;
154
+ const laneText = Object.entries(lanes).slice(0, 4).map(([lane, ids]) => `${lane}:${ids.slice(0, 3).join("/")}`).join(", ");
142
155
  return {
143
156
  recommended,
144
157
  availablePeers,
158
+ availablePeerDetails,
159
+ lanes,
145
160
  activePeerTaskCount: activePeerTasks.length,
146
- warning: recommended ? `fan-out available for multi-lane work: ${availablePeers.slice(0, 4).join(", ")} — use /peer goal fanout or peer_send` : undefined,
161
+ warning: recommended ? `fan-out available for multi-lane work: ${availablePeers.slice(0, 4).join(", ")}${laneText ? ` · lanes ${laneText}` : ""} — use /peer goal fanout or peer_send` : undefined,
147
162
  };
148
163
  }
149
164
 
165
+ function recommendLaneForPeer(peer = {}) {
166
+ const text = [peer.role, peer.persona, peer.peerId].filter(Boolean).join(" ").toLowerCase();
167
+ if (/(^|[^a-z0-9])(review|reviewer|qa|quality)\d*($|[^a-z0-9])/.test(text)) return "review";
168
+ if (/(^|[^a-z0-9])(research|researcher|scout)\d*($|[^a-z0-9])/.test(text)) return "research";
169
+ if (/(^|[^a-z0-9])(plan|planner|coord|coordinator|orchestrator)\d*($|[^a-z0-9])/.test(text)) return "coordination";
170
+ if (/(^|[^a-z0-9])(worker|implement|implementation|engineer|developer|code|coder|task)\d*($|[^a-z0-9])/.test(text)) return "implementation";
171
+ return "general";
172
+ }
173
+
150
174
  function line(kind, color, text) {
151
175
  return { kind, color, text };
152
176
  }