@cryptolibertus/pi-peer 0.3.7 → 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 +3 -3
- package/extensions/pi-peer/index.ts +110 -13
- package/package.json +1 -1
- package/src/peers/command.mjs +15 -6
- package/src/peers/goal-board.mjs +128 -21
package/README.md
CHANGED
|
@@ -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`, `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`.
|
|
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,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]>] [--
|
|
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> [--
|
|
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." },
|
|
@@ -105,26 +106,61 @@ export async function closePeerGoal(root, goalId, input = {}) {
|
|
|
105
106
|
|
|
106
107
|
export async function beginPeerGoalTask(root, goalId, input = {}) {
|
|
107
108
|
const id = requiredGoalId(goalId || input.goalId);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
}
|
|
112
139
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 };
|
|
122
158
|
});
|
|
123
|
-
return { goalId: id, goal: result.goal, claimEvent: result.event };
|
|
124
159
|
}
|
|
125
160
|
|
|
126
161
|
export async function recordPeerGoalTaskDispatch(root, goalId, input = {}) {
|
|
127
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 });
|
|
128
164
|
const result = await appendPeerGoalEvent(root, id, {
|
|
129
165
|
type: "task",
|
|
130
166
|
peerId: cleanText(input.requesterPeerId || input.peerId) || "unknown",
|
|
@@ -132,11 +168,15 @@ export async function recordPeerGoalTaskDispatch(root, goalId, input = {}) {
|
|
|
132
168
|
paths: input.claimedPaths || input.paths,
|
|
133
169
|
taskId: input.messageId,
|
|
134
170
|
status: cleanText(input.status || "running"),
|
|
171
|
+
workKey,
|
|
172
|
+
lane: input.lane || input.workLane,
|
|
173
|
+
duplicatePolicy: input.duplicatePolicy,
|
|
135
174
|
metadata: stripEmpty({
|
|
136
175
|
messageId: cleanText(input.messageId),
|
|
137
176
|
conversationId: cleanText(input.conversationId),
|
|
138
177
|
targetPeerId: cleanText(input.targetPeerId),
|
|
139
178
|
claimEventId: cleanText(input.claimEventId),
|
|
179
|
+
workKey,
|
|
140
180
|
}),
|
|
141
181
|
});
|
|
142
182
|
return { goalId: id, goal: result.goal, taskEvent: result.event };
|
|
@@ -144,6 +184,7 @@ export async function recordPeerGoalTaskDispatch(root, goalId, input = {}) {
|
|
|
144
184
|
|
|
145
185
|
export async function completePeerGoalTask(root, goalId, input = {}) {
|
|
146
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 });
|
|
147
188
|
const handoff = await appendPeerGoalEvent(root, id, {
|
|
148
189
|
type: "handoff",
|
|
149
190
|
peerId: cleanText(input.targetPeerId || input.peerId) || "unknown",
|
|
@@ -151,11 +192,14 @@ export async function completePeerGoalTask(root, goalId, input = {}) {
|
|
|
151
192
|
paths: input.claimedPaths || input.paths,
|
|
152
193
|
taskId: input.messageId,
|
|
153
194
|
status: cleanText(input.status || "done"),
|
|
195
|
+
workKey,
|
|
196
|
+
lane: input.lane || input.workLane,
|
|
154
197
|
metadata: stripEmpty({
|
|
155
198
|
messageId: cleanText(input.messageId),
|
|
156
199
|
conversationId: cleanText(input.conversationId),
|
|
157
200
|
claimEventId: cleanText(input.claimEventId),
|
|
158
201
|
responseStatus: cleanText(input.responseStatus),
|
|
202
|
+
workKey,
|
|
159
203
|
}),
|
|
160
204
|
});
|
|
161
205
|
if (!input.claimEventId) return { goalId: id, goal: handoff.goal, handoffEvent: handoff.event };
|
|
@@ -168,6 +212,16 @@ export async function completePeerGoalTask(root, goalId, input = {}) {
|
|
|
168
212
|
return { goalId: id, goal: release.goal, handoffEvent: handoff.event, releaseEvent: release.event };
|
|
169
213
|
}
|
|
170
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
|
+
|
|
171
225
|
export function deriveGoalState(goal, options = {}) {
|
|
172
226
|
const now = options.now || nowIso();
|
|
173
227
|
const events = Array.isArray(goal?.events) ? goal.events : [];
|
|
@@ -248,7 +302,10 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
|
|
|
248
302
|
const suggestions = [];
|
|
249
303
|
for (const goal of goals) {
|
|
250
304
|
const state = deriveGoalState(goal);
|
|
251
|
-
const push = (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
|
+
};
|
|
252
309
|
if (state.blockingObjections.length) {
|
|
253
310
|
push("P0", "blocker", `Resolve ${state.blockingObjections.length} blocking objection${state.blockingObjections.length === 1 ? "" : "s"} before more work.`, { paths: uniqueEventPaths(state.blockingObjections) });
|
|
254
311
|
continue;
|
|
@@ -286,11 +343,11 @@ export function formatPeerGoal(goal) {
|
|
|
286
343
|
if (state.constraints?.length) lines.push(`constraints: ${state.constraints.join("; ")}`);
|
|
287
344
|
if (state.activeClaims.length) {
|
|
288
345
|
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(", ")}` : ""}`);
|
|
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)}` : ""}`);
|
|
290
347
|
}
|
|
291
348
|
if (state.staleClaims.length) {
|
|
292
349
|
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}` : ""}`);
|
|
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}` : ""}`);
|
|
294
351
|
}
|
|
295
352
|
if (state.expiredClaims.length) {
|
|
296
353
|
lines.push("", "Expired claims:");
|
|
@@ -322,6 +379,10 @@ function validateClaim(goal, event) {
|
|
|
322
379
|
const paths = normalizePaths(event.paths);
|
|
323
380
|
if (event.mode === "write" && paths.length === 0) throw new Error("write claims require --path <path[,path]>");
|
|
324
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
|
+
}
|
|
325
386
|
if (event.mode === "write") {
|
|
326
387
|
const conflicts = state.activeClaims.filter((claim) => claim.mode === "write" && pathsOverlap(paths, claim.paths || []));
|
|
327
388
|
if (conflicts.length) throw new Error(`claim conflicts with active write claim ${conflicts.map((claim) => claim.id).join(", ")}`);
|
|
@@ -344,6 +405,10 @@ function validateHeartbeat(goal, event) {
|
|
|
344
405
|
const state = deriveGoalState(goal);
|
|
345
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);
|
|
346
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
|
+
}
|
|
347
412
|
if (claim.mode === "write") {
|
|
348
413
|
const conflicts = state.activeClaims.filter((item) => item.id !== claim.id && item.mode === "write" && pathsOverlap(claim.paths || [], item.paths || []));
|
|
349
414
|
if (conflicts.length) throw new Error(`heartbeat conflicts with active write claim ${conflicts.map((item) => item.id).join(", ")}`);
|
|
@@ -378,6 +443,9 @@ function normalizeEvent(input = {}) {
|
|
|
378
443
|
paths: normalizePaths(input.paths),
|
|
379
444
|
taskId: cleanText(input.taskId),
|
|
380
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),
|
|
381
449
|
resolves: cleanText(input.resolves),
|
|
382
450
|
verdict: cleanText(input.verdict)?.toLowerCase(),
|
|
383
451
|
confidence: confidenceValue(input.confidence),
|
|
@@ -407,6 +475,14 @@ function taskSummary(input = {}) {
|
|
|
407
475
|
return cleanText(input.summary) || cleanText(input.prompt) || `Peer task for ${cleanText(input.targetPeerId || input.peerId) || "unknown"}`;
|
|
408
476
|
}
|
|
409
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
|
+
|
|
410
486
|
function normalizeBoard(board = {}) {
|
|
411
487
|
const goals = plainObject(board.goals) ? board.goals : {};
|
|
412
488
|
const normalizedGoals = {};
|
|
@@ -422,7 +498,7 @@ function normalizeBoard(board = {}) {
|
|
|
422
498
|
createdBy: cleanText(goal.createdBy),
|
|
423
499
|
closedAt: cleanText(goal.closedAt),
|
|
424
500
|
closedBy: cleanText(goal.closedBy),
|
|
425
|
-
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() })) : [],
|
|
426
502
|
};
|
|
427
503
|
}
|
|
428
504
|
return { version: 1, currentGoalId: cleanText(board.currentGoalId), goals: normalizedGoals };
|
|
@@ -446,6 +522,9 @@ function projectEventSummary(event) {
|
|
|
446
522
|
paths: event.paths,
|
|
447
523
|
taskId: event.taskId,
|
|
448
524
|
mode: event.mode,
|
|
525
|
+
lane: event.lane,
|
|
526
|
+
workKey: event.workKey,
|
|
527
|
+
duplicatePolicy: event.duplicatePolicy,
|
|
449
528
|
resolves: event.resolves,
|
|
450
529
|
verdict: event.verdict,
|
|
451
530
|
confidence: event.confidence,
|
|
@@ -493,17 +572,45 @@ function uniqueEventPaths(events) {
|
|
|
493
572
|
return [...new Set(events.flatMap((event) => Array.isArray(event.paths) ? event.paths : []))];
|
|
494
573
|
}
|
|
495
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
|
+
|
|
496
592
|
function enrichScoutSuggestion(suggestion = {}) {
|
|
497
593
|
const lane = SCOUT_LANES[suggestion.kind] || {};
|
|
498
|
-
|
|
594
|
+
const recommendedLane = suggestion.recommendedLane || lane.recommendedLane;
|
|
595
|
+
const claimMode = cleanText(suggestion.claimMode || lane.claimMode);
|
|
596
|
+
const enriched = stripEmpty({
|
|
499
597
|
...suggestion,
|
|
500
|
-
recommendedLane
|
|
598
|
+
recommendedLane,
|
|
501
599
|
preferredRoles: normalizeList(suggestion.preferredRoles || lane.preferredRoles),
|
|
502
600
|
preferredCapabilities: normalizeList(suggestion.preferredCapabilities || lane.preferredCapabilities),
|
|
503
|
-
claimMode
|
|
601
|
+
claimMode,
|
|
504
602
|
suggestedIntent: cleanText(suggestion.suggestedIntent || lane.suggestedIntent),
|
|
505
603
|
rationale: cleanText(suggestion.rationale || lane.rationale),
|
|
506
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);
|
|
507
614
|
}
|
|
508
615
|
|
|
509
616
|
async function updatePeerGoalBoard(root, updater) {
|