@cryptolibertus/pi-peer 0.3.7 → 0.4.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 +4 -4
- package/extensions/pi-peer/index.ts +110 -13
- package/package.json +1 -1
- package/src/peers/command.mjs +16 -7
- package/src/peers/goal-board.mjs +175 -23
- package/src/peers/guidance.mjs +1 -1
- package/src/peers/idle-watcher.mjs +18 -2
- package/src/peers/runtime.mjs +1 -1
package/README.md
CHANGED
|
@@ -17,7 +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
|
|
20
|
+
- Persona-aware scout routing: goal-board suggestions include recommended lanes, preferred roles, claim mode, work keys, and rationale so proactive peers can self-select complementary work that fits their role/persona
|
|
21
21
|
- Protocol compatibility metadata (`protocolVersion`, min/max compatible versions), peer manifests, capabilities, and trust summaries in descriptors/status/list output
|
|
22
22
|
- `PI_PEER_ID` runtime override for running multiple local Pi sessions
|
|
23
23
|
- `pi-peer-publish` skill for safe npm release checks, version bumping, tag push, publish, and verification
|
|
@@ -41,14 +41,14 @@ pi install ./packages/pi-peer
|
|
|
41
41
|
Useful commands (long form and short aliases):
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
|
-
/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"
|
|
45
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
|
|
46
46
|
/peer send worker "Fix PR waiting path" --goal <goal-id> --claim extensions/symphony/index.ts,test/pr-watcher-runtime.test.mjs --no-await
|
|
47
47
|
/peer progress "tests are running" --phase verification
|
|
48
48
|
/peer goal finding <goal-id> "PR auto-close can close before merge" --path extensions/symphony/index.ts
|
|
49
49
|
/peer scout <goal-id> --limit 5
|
|
50
50
|
/peer goal propose <goal-id> "Add a read-only reviewer before closing" --path extensions/symphony/index.ts
|
|
51
|
-
/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
|
|
52
52
|
/peer goal heartbeat <goal-id> <claim-event-id> "still working after reconnect" --stale-after-ms 900000
|
|
53
53
|
/peer goal release <goal-id> <claim-event-id> "worker lane complete"
|
|
54
54
|
/peer goal object <goal-id> "Missing merged-PR verification"
|
|
@@ -58,7 +58,7 @@ 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. `/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, and
|
|
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 now 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`) also become matching scout suggestions, letting the next suitable peer claim or review that proposed lane. 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`.
|
|
62
62
|
|
|
63
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.
|
|
64
64
|
|
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
650
|
-
mode
|
|
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
|
|
757
|
-
const
|
|
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
package/src/peers/command.mjs
CHANGED
|
@@ -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,
|
|
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,15 +140,15 @@ 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]>] [--
|
|
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",
|
|
144
147
|
"- `/peer goal list|show [goal-id]` — inspect peer goals, active claims, blockers, proposals, and votes",
|
|
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
|
-
"- `/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> [--
|
|
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
|
+
"- `/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
|
|
package/src/peers/goal-board.mjs
CHANGED
|
@@ -8,6 +8,7 @@ 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;
|
|
12
13
|
const SCOUT_LANES = Object.freeze({
|
|
13
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,9 +16,14 @@ const SCOUT_LANES = Object.freeze({
|
|
|
15
16
|
"stale-claim": { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator"], claimMode: "read", suggestedIntent: "coordinate", rationale: "Stale claims need owner follow-up or release, not duplicate writes." },
|
|
16
17
|
"open-proposal": { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator", "reviewer"], claimMode: "read", suggestedIntent: "review", rationale: "Open proposals need triage into accept, defer, or resolve decisions." },
|
|
17
18
|
close: { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator", "reviewer"], claimMode: "read", suggestedIntent: "coordinate", rationale: "Ready goals need final closure checks and a concise handoff." },
|
|
18
|
-
"next-step": { recommendedLane: "research", preferredRoles: ["researcher", "reviewer", "planner", "coordinator"
|
|
19
|
+
"next-step": { recommendedLane: "research", preferredRoles: ["researcher", "reviewer", "planner", "coordinator"], claimMode: "read", suggestedIntent: "review", rationale: "Empty goals benefit from peers self-selecting read-only lanes before write claims." },
|
|
19
20
|
review: { recommendedLane: "review", preferredRoles: ["reviewer", "qa", "coordinator", "planner"], claimMode: "read", suggestedIntent: "review", rationale: "Goals without current votes need read-only validation before closure." },
|
|
20
21
|
});
|
|
22
|
+
const STARTUP_SCOUT_LANES = Object.freeze([
|
|
23
|
+
{ lane: "research", preferredRoles: ["researcher", "planner", "coordinator"], summary: "No active work yet; self-select a research lane to map risks, options, and next moves." },
|
|
24
|
+
{ lane: "review", preferredRoles: ["reviewer", "qa", "planner", "coordinator"], summary: "No active work yet; self-select a read-only review/QA lane to validate the plan and risks." },
|
|
25
|
+
{ lane: "implementation", preferredRoles: ["worker"], summary: "No active work yet; self-select an implementation-planning lane, then claim write paths only after naming them." },
|
|
26
|
+
]);
|
|
21
27
|
const GOAL_BOARD_LOCK_STALE_MS = 30_000;
|
|
22
28
|
const GOAL_BOARD_LOCK_RETRY_MS = 10;
|
|
23
29
|
const GOAL_BOARD_LOCK_TIMEOUT_MS = 5_000;
|
|
@@ -105,26 +111,61 @@ export async function closePeerGoal(root, goalId, input = {}) {
|
|
|
105
111
|
|
|
106
112
|
export async function beginPeerGoalTask(root, goalId, input = {}) {
|
|
107
113
|
const id = requiredGoalId(goalId || input.goalId);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
return updatePeerGoalBoard(root, (board) => {
|
|
115
|
+
const goal = resolveGoal(board, id);
|
|
116
|
+
const paths = normalizePaths(input.claimedPaths || input.paths);
|
|
117
|
+
const mode = cleanText(input.mode || input.claimMode || (paths.length ? "write" : "read")).toLowerCase();
|
|
118
|
+
const lane = cleanText(input.lane || input.workLane || input.intent || mode).toLowerCase();
|
|
119
|
+
const workKey = normalizeWorkKey(input.workKey) || derivePeerGoalWorkKey({
|
|
120
|
+
goalId: id,
|
|
121
|
+
lane,
|
|
122
|
+
objective: input.objective || input.summary || input.prompt,
|
|
123
|
+
mode,
|
|
124
|
+
paths,
|
|
125
|
+
});
|
|
126
|
+
if (!paths.length && !workKey) return { goalId: id, goal: deriveGoalState(goal) };
|
|
127
|
+
|
|
128
|
+
const duplicatePolicy = normalizeDuplicatePolicy(input.duplicatePolicy) || "reuse";
|
|
129
|
+
if (workKey && duplicatePolicy === "reuse") {
|
|
130
|
+
const state = deriveGoalState(goal);
|
|
131
|
+
const existingClaim = state.activeClaims.find((claim) => claim.workKey === workKey);
|
|
132
|
+
if (existingClaim) {
|
|
133
|
+
return {
|
|
134
|
+
goalId: id,
|
|
135
|
+
goal: state,
|
|
136
|
+
duplicate: true,
|
|
137
|
+
duplicatePolicy,
|
|
138
|
+
workKey,
|
|
139
|
+
existingClaim,
|
|
140
|
+
existingTask: latestTaskForWorkKey(state.tasks, workKey),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
112
144
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
145
|
+
const event = normalizeEvent({
|
|
146
|
+
type: "claim",
|
|
147
|
+
peerId: cleanText(input.targetPeerId || input.peerId) || "unknown",
|
|
148
|
+
summary: taskSummary(input),
|
|
149
|
+
paths,
|
|
150
|
+
mode,
|
|
151
|
+
lane,
|
|
152
|
+
workKey,
|
|
153
|
+
duplicatePolicy,
|
|
154
|
+
ttlMs: input.ttlMs,
|
|
155
|
+
staleAfterMs: input.staleAfterMs,
|
|
156
|
+
metadata: stripEmpty({ requesterPeerId: cleanText(input.requesterPeerId), targetPeerId: cleanText(input.targetPeerId), workKey, lane, duplicatePolicy }),
|
|
157
|
+
});
|
|
158
|
+
validateClaim(goal, event);
|
|
159
|
+
goal.events.push(event);
|
|
160
|
+
goal.updatedAt = event.at;
|
|
161
|
+
board.currentGoalId = goal.id;
|
|
162
|
+
return { goalId: id, goal: deriveGoalState(goal), claimEvent: event, workKey, duplicatePolicy };
|
|
122
163
|
});
|
|
123
|
-
return { goalId: id, goal: result.goal, claimEvent: result.event };
|
|
124
164
|
}
|
|
125
165
|
|
|
126
166
|
export async function recordPeerGoalTaskDispatch(root, goalId, input = {}) {
|
|
127
167
|
const id = requiredGoalId(goalId || input.goalId);
|
|
168
|
+
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 });
|
|
128
169
|
const result = await appendPeerGoalEvent(root, id, {
|
|
129
170
|
type: "task",
|
|
130
171
|
peerId: cleanText(input.requesterPeerId || input.peerId) || "unknown",
|
|
@@ -132,11 +173,15 @@ export async function recordPeerGoalTaskDispatch(root, goalId, input = {}) {
|
|
|
132
173
|
paths: input.claimedPaths || input.paths,
|
|
133
174
|
taskId: input.messageId,
|
|
134
175
|
status: cleanText(input.status || "running"),
|
|
176
|
+
workKey,
|
|
177
|
+
lane: input.lane || input.workLane,
|
|
178
|
+
duplicatePolicy: input.duplicatePolicy,
|
|
135
179
|
metadata: stripEmpty({
|
|
136
180
|
messageId: cleanText(input.messageId),
|
|
137
181
|
conversationId: cleanText(input.conversationId),
|
|
138
182
|
targetPeerId: cleanText(input.targetPeerId),
|
|
139
183
|
claimEventId: cleanText(input.claimEventId),
|
|
184
|
+
workKey,
|
|
140
185
|
}),
|
|
141
186
|
});
|
|
142
187
|
return { goalId: id, goal: result.goal, taskEvent: result.event };
|
|
@@ -144,6 +189,7 @@ export async function recordPeerGoalTaskDispatch(root, goalId, input = {}) {
|
|
|
144
189
|
|
|
145
190
|
export async function completePeerGoalTask(root, goalId, input = {}) {
|
|
146
191
|
const id = requiredGoalId(goalId || input.goalId);
|
|
192
|
+
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 });
|
|
147
193
|
const handoff = await appendPeerGoalEvent(root, id, {
|
|
148
194
|
type: "handoff",
|
|
149
195
|
peerId: cleanText(input.targetPeerId || input.peerId) || "unknown",
|
|
@@ -151,11 +197,14 @@ export async function completePeerGoalTask(root, goalId, input = {}) {
|
|
|
151
197
|
paths: input.claimedPaths || input.paths,
|
|
152
198
|
taskId: input.messageId,
|
|
153
199
|
status: cleanText(input.status || "done"),
|
|
200
|
+
workKey,
|
|
201
|
+
lane: input.lane || input.workLane,
|
|
154
202
|
metadata: stripEmpty({
|
|
155
203
|
messageId: cleanText(input.messageId),
|
|
156
204
|
conversationId: cleanText(input.conversationId),
|
|
157
205
|
claimEventId: cleanText(input.claimEventId),
|
|
158
206
|
responseStatus: cleanText(input.responseStatus),
|
|
207
|
+
workKey,
|
|
159
208
|
}),
|
|
160
209
|
});
|
|
161
210
|
if (!input.claimEventId) return { goalId: id, goal: handoff.goal, handoffEvent: handoff.event };
|
|
@@ -168,6 +217,16 @@ export async function completePeerGoalTask(root, goalId, input = {}) {
|
|
|
168
217
|
return { goalId: id, goal: release.goal, handoffEvent: handoff.event, releaseEvent: release.event };
|
|
169
218
|
}
|
|
170
219
|
|
|
220
|
+
export function derivePeerGoalWorkKey(input = {}) {
|
|
221
|
+
const goalId = normalizeWorkKeyPart(input.goalId);
|
|
222
|
+
const lane = normalizeWorkKeyPart(input.lane || input.workLane || input.intent || "work");
|
|
223
|
+
const objective = normalizeWorkKeyPart(input.objective || input.summary || input.prompt || "work");
|
|
224
|
+
const mode = normalizeWorkKeyPart(input.mode || input.claimMode || "read");
|
|
225
|
+
const paths = normalizePaths(input.paths || input.claimedPaths).map(normalizeWorkKeyPart).sort();
|
|
226
|
+
const parts = [goalId, lane, objective, mode, paths.join(",")].filter((part) => part !== undefined && part !== "");
|
|
227
|
+
return parts.length ? parts.join("|") : undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
171
230
|
export function deriveGoalState(goal, options = {}) {
|
|
172
231
|
const now = options.now || nowIso();
|
|
173
232
|
const events = Array.isArray(goal?.events) ? goal.events : [];
|
|
@@ -248,7 +307,10 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
|
|
|
248
307
|
const suggestions = [];
|
|
249
308
|
for (const goal of goals) {
|
|
250
309
|
const state = deriveGoalState(goal);
|
|
251
|
-
const push = (priority, kind, summary, extra = {}) =>
|
|
310
|
+
const push = (priority, kind, summary, extra = {}) => {
|
|
311
|
+
const suggestion = enrichScoutSuggestion({ goalId: goal.id, priority, kind, summary, ...extra });
|
|
312
|
+
if (!hasActiveClaimForScoutSuggestion(state, suggestion)) suggestions.push(suggestion);
|
|
313
|
+
};
|
|
252
314
|
if (state.blockingObjections.length) {
|
|
253
315
|
push("P0", "blocker", `Resolve ${state.blockingObjections.length} blocking objection${state.blockingObjections.length === 1 ? "" : "s"} before more work.`, { paths: uniqueEventPaths(state.blockingObjections) });
|
|
254
316
|
continue;
|
|
@@ -261,6 +323,17 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
|
|
|
261
323
|
push("P1", "stale-claim", `Ask owners to heartbeat or release ${state.staleClaims.length} stale claim${state.staleClaims.length === 1 ? "" : "s"}.`, { paths: uniqueEventPaths(state.staleClaims) });
|
|
262
324
|
}
|
|
263
325
|
if (state.openProposals.length) {
|
|
326
|
+
for (const proposal of state.openProposals.filter((item) => item.lane)) {
|
|
327
|
+
const lane = normalizeLaneName(proposal.lane);
|
|
328
|
+
push("P1", "open-proposal", `Self-select proposed ${lane} lane: ${proposal.summary}`, {
|
|
329
|
+
paths: proposal.paths,
|
|
330
|
+
recommendedLane: lane,
|
|
331
|
+
preferredRoles: preferredRolesForLane(lane),
|
|
332
|
+
claimMode: "read",
|
|
333
|
+
suggestedIntent: suggestedIntentForLane(lane),
|
|
334
|
+
rationale: "A peer proposed a lane; matching idle peers can claim or review it without planner assignment.",
|
|
335
|
+
});
|
|
336
|
+
}
|
|
264
337
|
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) });
|
|
265
338
|
}
|
|
266
339
|
if (state.readyToClose) {
|
|
@@ -268,7 +341,15 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
|
|
|
268
341
|
continue;
|
|
269
342
|
}
|
|
270
343
|
if (!state.activeClaims.length && !state.tasks.length && !state.openProposals.length) {
|
|
271
|
-
|
|
344
|
+
for (const lane of STARTUP_SCOUT_LANES) {
|
|
345
|
+
push("P2", "next-step", lane.summary, {
|
|
346
|
+
recommendedLane: lane.lane,
|
|
347
|
+
preferredRoles: lane.preferredRoles,
|
|
348
|
+
claimMode: "read",
|
|
349
|
+
suggestedIntent: suggestedIntentForLane(lane.lane),
|
|
350
|
+
rationale: "Multiple lane-specific suggestions let idle peers self-select complementary work and suppress duplicates by work key.",
|
|
351
|
+
});
|
|
352
|
+
}
|
|
272
353
|
} else if (!state.currentVotes.length && !state.activeWriteClaims.length) {
|
|
273
354
|
push("P2", "review", "No current peer vote; ask a peer for read-only review or record a pass/fail vote.");
|
|
274
355
|
}
|
|
@@ -286,11 +367,11 @@ export function formatPeerGoal(goal) {
|
|
|
286
367
|
if (state.constraints?.length) lines.push(`constraints: ${state.constraints.join("; ")}`);
|
|
287
368
|
if (state.activeClaims.length) {
|
|
288
369
|
lines.push("", "Active claims:");
|
|
289
|
-
for (const claim of state.activeClaims) lines.push(`- ${claim.id} · ${claim.peerId} · ${claim.mode || "read"} · ${claim.summary}${claim.paths?.length ? ` · ${claim.paths.join(", ")}` : ""}`);
|
|
370
|
+
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)}` : ""}`);
|
|
290
371
|
}
|
|
291
372
|
if (state.staleClaims.length) {
|
|
292
373
|
lines.push("", "Stale claims:");
|
|
293
|
-
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}` : ""}`);
|
|
374
|
+
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}` : ""}`);
|
|
294
375
|
}
|
|
295
376
|
if (state.expiredClaims.length) {
|
|
296
377
|
lines.push("", "Expired claims:");
|
|
@@ -322,6 +403,10 @@ function validateClaim(goal, event) {
|
|
|
322
403
|
const paths = normalizePaths(event.paths);
|
|
323
404
|
if (event.mode === "write" && paths.length === 0) throw new Error("write claims require --path <path[,path]>");
|
|
324
405
|
const state = deriveGoalState(goal);
|
|
406
|
+
if (event.workKey && event.duplicatePolicy !== "allow-parallel") {
|
|
407
|
+
const duplicates = state.activeClaims.filter((claim) => claim.workKey === event.workKey);
|
|
408
|
+
if (duplicates.length) throw new Error(`claim duplicates active work key ${event.workKey} already held by ${duplicates.map((claim) => claim.id).join(", ")}`);
|
|
409
|
+
}
|
|
325
410
|
if (event.mode === "write") {
|
|
326
411
|
const conflicts = state.activeClaims.filter((claim) => claim.mode === "write" && pathsOverlap(paths, claim.paths || []));
|
|
327
412
|
if (conflicts.length) throw new Error(`claim conflicts with active write claim ${conflicts.map((claim) => claim.id).join(", ")}`);
|
|
@@ -344,6 +429,10 @@ function validateHeartbeat(goal, event) {
|
|
|
344
429
|
const state = deriveGoalState(goal);
|
|
345
430
|
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);
|
|
346
431
|
if (!claim) throw new Error(`peer goal heartbeat target ${event.resolves} is not an active, stale, or expired claim`);
|
|
432
|
+
if (claim.workKey && claim.duplicatePolicy !== "allow-parallel") {
|
|
433
|
+
const duplicates = state.activeClaims.filter((item) => item.id !== claim.id && item.workKey === claim.workKey);
|
|
434
|
+
if (duplicates.length) throw new Error(`heartbeat conflicts with active work key ${claim.workKey} already held by ${duplicates.map((item) => item.id).join(", ")}`);
|
|
435
|
+
}
|
|
347
436
|
if (claim.mode === "write") {
|
|
348
437
|
const conflicts = state.activeClaims.filter((item) => item.id !== claim.id && item.mode === "write" && pathsOverlap(claim.paths || [], item.paths || []));
|
|
349
438
|
if (conflicts.length) throw new Error(`heartbeat conflicts with active write claim ${conflicts.map((item) => item.id).join(", ")}`);
|
|
@@ -378,6 +467,9 @@ function normalizeEvent(input = {}) {
|
|
|
378
467
|
paths: normalizePaths(input.paths),
|
|
379
468
|
taskId: cleanText(input.taskId),
|
|
380
469
|
mode: cleanText(input.mode || (type === "claim" ? "read" : "")).toLowerCase() || undefined,
|
|
470
|
+
lane: cleanText(input.lane || input.workLane)?.toLowerCase(),
|
|
471
|
+
workKey: normalizeWorkKey(input.workKey),
|
|
472
|
+
duplicatePolicy: normalizeDuplicatePolicy(input.duplicatePolicy),
|
|
381
473
|
resolves: cleanText(input.resolves),
|
|
382
474
|
verdict: cleanText(input.verdict)?.toLowerCase(),
|
|
383
475
|
confidence: confidenceValue(input.confidence),
|
|
@@ -407,6 +499,14 @@ function taskSummary(input = {}) {
|
|
|
407
499
|
return cleanText(input.summary) || cleanText(input.prompt) || `Peer task for ${cleanText(input.targetPeerId || input.peerId) || "unknown"}`;
|
|
408
500
|
}
|
|
409
501
|
|
|
502
|
+
function latestTaskForWorkKey(tasks = [], workKey) {
|
|
503
|
+
if (!workKey) return undefined;
|
|
504
|
+
const activeStatuses = new Set(["queued", "dispatching", "running", "pending"]);
|
|
505
|
+
return [...tasks]
|
|
506
|
+
.reverse()
|
|
507
|
+
.find((task) => task.workKey === workKey && (!task.status || activeStatuses.has(String(task.status).toLowerCase())));
|
|
508
|
+
}
|
|
509
|
+
|
|
410
510
|
function normalizeBoard(board = {}) {
|
|
411
511
|
const goals = plainObject(board.goals) ? board.goals : {};
|
|
412
512
|
const normalizedGoals = {};
|
|
@@ -422,7 +522,7 @@ function normalizeBoard(board = {}) {
|
|
|
422
522
|
createdBy: cleanText(goal.createdBy),
|
|
423
523
|
closedAt: cleanText(goal.closedAt),
|
|
424
524
|
closedBy: cleanText(goal.closedBy),
|
|
425
|
-
events: Array.isArray(goal.events) ? goal.events.map((event) => stripEmpty({ ...event, paths: normalizePaths(event.paths) })) : [],
|
|
525
|
+
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() })) : [],
|
|
426
526
|
};
|
|
427
527
|
}
|
|
428
528
|
return { version: 1, currentGoalId: cleanText(board.currentGoalId), goals: normalizedGoals };
|
|
@@ -446,6 +546,9 @@ function projectEventSummary(event) {
|
|
|
446
546
|
paths: event.paths,
|
|
447
547
|
taskId: event.taskId,
|
|
448
548
|
mode: event.mode,
|
|
549
|
+
lane: event.lane,
|
|
550
|
+
workKey: event.workKey,
|
|
551
|
+
duplicatePolicy: event.duplicatePolicy,
|
|
449
552
|
resolves: event.resolves,
|
|
450
553
|
verdict: event.verdict,
|
|
451
554
|
confidence: event.confidence,
|
|
@@ -493,17 +596,66 @@ function uniqueEventPaths(events) {
|
|
|
493
596
|
return [...new Set(events.flatMap((event) => Array.isArray(event.paths) ? event.paths : []))];
|
|
494
597
|
}
|
|
495
598
|
|
|
599
|
+
function normalizeWorkKey(value) {
|
|
600
|
+
const text = cleanText(value);
|
|
601
|
+
if (!text) return undefined;
|
|
602
|
+
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function normalizeWorkKeyPart(value) {
|
|
606
|
+
const text = cleanText(value);
|
|
607
|
+
if (!text) return undefined;
|
|
608
|
+
return text.toLowerCase().replace(/\s+/g, " ").replace(/[|]+/g, "/").trim();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function normalizeDuplicatePolicy(value) {
|
|
612
|
+
const policy = cleanText(value)?.toLowerCase();
|
|
613
|
+
return DUPLICATE_POLICIES.has(policy) ? policy : undefined;
|
|
614
|
+
}
|
|
615
|
+
|
|
496
616
|
function enrichScoutSuggestion(suggestion = {}) {
|
|
497
617
|
const lane = SCOUT_LANES[suggestion.kind] || {};
|
|
498
|
-
|
|
618
|
+
const recommendedLane = normalizeLaneName(suggestion.recommendedLane || lane.recommendedLane);
|
|
619
|
+
const claimMode = cleanText(suggestion.claimMode || lane.claimMode);
|
|
620
|
+
const enriched = stripEmpty({
|
|
499
621
|
...suggestion,
|
|
500
|
-
recommendedLane
|
|
622
|
+
recommendedLane,
|
|
501
623
|
preferredRoles: normalizeList(suggestion.preferredRoles || lane.preferredRoles),
|
|
502
624
|
preferredCapabilities: normalizeList(suggestion.preferredCapabilities || lane.preferredCapabilities),
|
|
503
|
-
claimMode
|
|
625
|
+
claimMode,
|
|
504
626
|
suggestedIntent: cleanText(suggestion.suggestedIntent || lane.suggestedIntent),
|
|
505
627
|
rationale: cleanText(suggestion.rationale || lane.rationale),
|
|
506
628
|
});
|
|
629
|
+
return stripEmpty({
|
|
630
|
+
...enriched,
|
|
631
|
+
workKey: suggestion.workKey || derivePeerGoalWorkKey({ goalId: enriched.goalId, lane: recommendedLane, objective: enriched.summary, mode: claimMode, paths: enriched.paths }),
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function normalizeLaneName(value) {
|
|
636
|
+
const lane = cleanText(value).toLowerCase();
|
|
637
|
+
if (["qa", "quality", "test", "testing"].includes(lane)) return "review";
|
|
638
|
+
if (["implement", "implementation", "developer", "engineer", "worker", "code", "coding"].includes(lane)) return "implementation";
|
|
639
|
+
if (["coordinate", "coordinator", "planning", "planner", "orchestration"].includes(lane)) return "coordination";
|
|
640
|
+
if (["researcher", "scout", "investigation"].includes(lane)) return "research";
|
|
641
|
+
return lane || "review";
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function preferredRolesForLane(lane) {
|
|
645
|
+
const normalized = normalizeLaneName(lane);
|
|
646
|
+
if (normalized === "implementation") return ["worker"];
|
|
647
|
+
if (normalized === "research") return ["researcher", "planner", "coordinator"];
|
|
648
|
+
if (normalized === "coordination") return ["planner", "coordinator", "reviewer"];
|
|
649
|
+
return ["reviewer", "qa", "planner", "coordinator"];
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function suggestedIntentForLane(lane) {
|
|
653
|
+
return normalizeLaneName(lane) === "implementation" ? "task" : "review";
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function hasActiveClaimForScoutSuggestion(state, suggestion) {
|
|
657
|
+
if (!suggestion?.workKey) return false;
|
|
658
|
+
return state.activeClaims.some((claim) => claim.workKey === suggestion.workKey);
|
|
507
659
|
}
|
|
508
660
|
|
|
509
661
|
async function updatePeerGoalBoard(root, updater) {
|
package/src/peers/guidance.mjs
CHANGED
|
@@ -11,7 +11,7 @@ const PEER_SEND_GUIDANCE = "Use peer_send to send a prompt-first message to a pe
|
|
|
11
11
|
const PEER_AWAIT_GUIDANCE = "Use peer_await with messageId values from queued or timed-out peer_send calls to read final assistant replies.";
|
|
12
12
|
const PEER_GET_GUIDANCE = "Use peer_get to inspect a peer, message, conversation, runtime summary, active tasks via 'tasks', fan-out suggestions via 'fanout', or redacted audit state by id.";
|
|
13
13
|
const PEER_PROGRESS_GUIDANCE = "Use peer_progress from inside an inbound long-running peer task to send structured checkpoint updates before the final handoff.";
|
|
14
|
-
const PEER_FANOUT_GUIDANCE = "Fan-out gate: for multi-part, long-running, or implementation-plus-review work, call peer_list and
|
|
14
|
+
const PEER_FANOUT_GUIDANCE = "Fan-out gate: for multi-part, long-running, or implementation-plus-review work, call peer_list and use a goal board plus peer_send for research/review/QA lanes unless the user explicitly says to work solo. For emergent self-organization tests, create/reuse a peer goal and let peers inspect scout suggestions or claim lane-specific work keys instead of over-assigning every lane; if you skip fan-out, state the reason in the final response.";
|
|
15
15
|
|
|
16
16
|
export const PEER_INBOUND_FINAL_RESPONSE_GUIDANCE = "For inbound peer asks, answer the inbound ask in your final assistant response; that final assistant response is returned to the requesting peer. For write-capable task intents, end with a concise handoff: status, files changed, verification commands with exit status, and blockers.";
|
|
17
17
|
|
|
@@ -139,13 +139,28 @@ export function buildPeerIdleActivationPrompt(activation, options = {}) {
|
|
|
139
139
|
const peerId = options.localPeerId || "this-peer";
|
|
140
140
|
const paths = activation.paths?.length ? `\nPaths: ${activation.paths.join(", ")}` : "";
|
|
141
141
|
const lane = activation.recommendedLane ? `\nRecommended lane: ${activation.recommendedLane}${activation.claimMode ? ` (${activation.claimMode})` : ""}${activation.preferredRoles?.length ? ` · preferred roles: ${activation.preferredRoles.join(", ")}` : ""}` : "";
|
|
142
|
+
const workKey = activation.workKey ? `\nWork key: ${activation.workKey}` : "";
|
|
143
|
+
const suggestedClaim = buildSuggestedReadClaim(activation);
|
|
142
144
|
const rationale = activation.rationale ? `\nRationale: ${activation.rationale}` : "";
|
|
143
145
|
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,
|
|
146
|
+
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}${workKey}${rationale}${fit}${paths}${suggestedClaim}\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: claim a read-only lane with the work key above, post a proposal/finding/vote, or claim write work only when you intend to edit and can name the paths.\n- If the suggested claim fails as duplicate, inspect the board and stop with a brief handoff instead of starting parallel work.\n- Do not duplicate active claims, work keys, 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.`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function buildSuggestedReadClaim(activation = {}) {
|
|
150
|
+
if (activation.claimMode !== "read" || !activation.workKey) return "";
|
|
151
|
+
const lane = activation.recommendedLane ? ` --lane ${shellQuote(activation.recommendedLane)}` : "";
|
|
152
|
+
const paths = activation.paths?.length ? activation.paths.map((path) => ` --path ${shellQuote(path)}`).join("") : "";
|
|
153
|
+
return `\nSuggested first action: /peer goal claim ${shellQuote(activation.goalId)} ${shellQuote(activation.summary)} --mode read${lane} --key ${shellQuote(activation.workKey)}${paths}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function shellQuote(value) {
|
|
157
|
+
const text = String(value || "");
|
|
158
|
+
if (/^[A-Za-z0-9_./:-]+$/.test(text)) return text;
|
|
159
|
+
return `'${text.replace(/'/g, `'"'"'`)}'`;
|
|
145
160
|
}
|
|
146
161
|
|
|
147
162
|
export function peerIdleActivationKey(activation = {}) {
|
|
148
|
-
return [activation.goalId, activation.kind, activation.recommendedLane, activation.summary, ...(activation.paths || [])].join("|");
|
|
163
|
+
return [activation.goalId, activation.kind, activation.recommendedLane, activation.workKey, activation.summary, ...(activation.paths || [])].join("|");
|
|
149
164
|
}
|
|
150
165
|
|
|
151
166
|
function isActivationCoolingDown(state, activation, config, nowMs) {
|
|
@@ -167,6 +182,7 @@ function normalizeActivation(suggestion = {}, localPeerId, options = {}) {
|
|
|
167
182
|
claimMode: cleanString(suggestion.claimMode),
|
|
168
183
|
suggestedIntent: cleanString(suggestion.suggestedIntent),
|
|
169
184
|
rationale: cleanString(suggestion.rationale),
|
|
185
|
+
workKey: cleanString(suggestion.workKey),
|
|
170
186
|
personaFit,
|
|
171
187
|
paths: Array.isArray(suggestion.paths) ? suggestion.paths.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim()) : [],
|
|
172
188
|
peerId: localPeerId,
|
package/src/peers/runtime.mjs
CHANGED
|
@@ -195,7 +195,7 @@ export async function getPeerRuntimeValue(runtime, id) {
|
|
|
195
195
|
const peers = await runtime.comms.listPeers();
|
|
196
196
|
const messages = await runtime.comms.listMessages();
|
|
197
197
|
const suggestion = deriveFanoutSuggestion(peers, messages);
|
|
198
|
-
return { type: "fanout", value: { ...suggestion, checklist: ["Run peer_list before multi-lane work", "Create or reuse /peer goal", "Use /peer goal fanout or peer_send goalId+claimedPaths", "Final response must include Fan-out used: yes/no and peer handles"] } };
|
|
198
|
+
return { type: "fanout", value: { ...suggestion, checklist: ["Run peer_list before multi-lane work", "Create or reuse /peer goal", "For emergent self-organization, ask peers to inspect /peer scout or claim lane-specific work keys before assigning every lane", "Use /peer goal fanout or peer_send goalId+claimedPaths when direct dispatch is needed", "Final response must include Fan-out used: yes/no and peer handles"] } };
|
|
199
199
|
}
|
|
200
200
|
if (id === "audit") return { type: "audit", value: await runtime.comms.getAuditEntries() };
|
|
201
201
|
|