@cryptolibertus/pi-peer 0.3.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- 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 +149 -19
- package/src/peers/idle-watcher.mjs +69 -5
- package/src/peers/status.mjs +28 -4
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ pi install ./packages/pi-peer
|
|
|
17
17
|
- Local peer discovery and transport using project `.pi/peers.json`
|
|
18
18
|
- Repo-scoped discovery: only Pi sessions in the same git repo/project appear as local peers
|
|
19
19
|
- Idle watcher daemon: idle peers nudge stuck inbound activations and proactively inspect open goal-board work
|
|
20
|
+
- Persona-aware scout routing: goal-board suggestions include recommended lanes, preferred roles, claim mode, and rationale so proactive peers can choose work that fits their role/persona
|
|
20
21
|
- Protocol compatibility metadata (`protocolVersion`, min/max compatible versions), peer manifests, capabilities, and trust summaries in descriptors/status/list output
|
|
21
22
|
- `PI_PEER_ID` runtime override for running multiple local Pi sessions
|
|
22
23
|
- `pi-peer-publish` skill for safe npm release checks, version bumping, tag push, publish, and verification
|
|
@@ -40,14 +41,14 @@ pi install ./packages/pi-peer
|
|
|
40
41
|
Useful commands (long form and short aliases):
|
|
41
42
|
|
|
42
43
|
```bash
|
|
43
|
-
/peer goal create "Improve finalization safety" --constraint "one writer per path,no destructive commands"
|
|
44
|
+
/peer goal create "Improve finalization safety" --constraint "one writer per path,no duplicate work,no destructive commands"
|
|
44
45
|
/peer goal fanout <goal-id> "Fix PR waiting path" --peer researcher,reviewer,worker --path extensions/symphony/index.ts,test/pr-watcher-runtime.test.mjs --send --no-await
|
|
45
46
|
/peer send worker "Fix PR waiting path" --goal <goal-id> --claim extensions/symphony/index.ts,test/pr-watcher-runtime.test.mjs --no-await
|
|
46
47
|
/peer progress "tests are running" --phase verification
|
|
47
48
|
/peer goal finding <goal-id> "PR auto-close can close before merge" --path extensions/symphony/index.ts
|
|
48
49
|
/peer scout <goal-id> --limit 5
|
|
49
50
|
/peer goal propose <goal-id> "Add a read-only reviewer before closing" --path extensions/symphony/index.ts
|
|
50
|
-
/peer goal claim <goal-id> "Fix PR waiting path" --mode write --path extensions/symphony/index.ts,test/pr-watcher-runtime.test.mjs
|
|
51
|
+
/peer goal claim <goal-id> "Fix PR waiting path" --mode write --path extensions/symphony/index.ts,test/pr-watcher-runtime.test.mjs --key implement:pr-waiting
|
|
51
52
|
/peer goal heartbeat <goal-id> <claim-event-id> "still working after reconnect" --stale-after-ms 900000
|
|
52
53
|
/peer goal release <goal-id> <claim-event-id> "worker lane complete"
|
|
53
54
|
/peer goal object <goal-id> "Missing merged-PR verification"
|
|
@@ -57,7 +58,7 @@ Useful commands (long form and short aliases):
|
|
|
57
58
|
|
|
58
59
|
Short aliases keep common board updates terse: `/peer goals`/`/peer ls`, `/peer current`, `/peer scout`, `/peer fanout`, `/peer proposal`/`/peer propose`, `/peer take`/`/peer claim`, `/peer complete`/`/peer done`, `/peer objection`/`/peer block`, `/peer unblock`, `/peer note`, `/peer finding`, `/peer ping`/`/peer heartbeat`, `/peer drop`/`/peer release`, `/peer pass`, `/peer fail`, `/peer vote`, and `/peer close` map to the corresponding `/peer goal ...` actions.
|
|
59
60
|
|
|
60
|
-
The board is stored locally at `.pi/peer-goals.json`; outbound message snapshots are stored in `.pi/peer-messages.json` so restarted planners can still inspect disconnected historical tasks. Mutating goal-board operations take a short local lock before load/modify/save so concurrent peer appends do not drop events. `/peer send --goal <goal-id> --claim <path[,path]>` and the `peer_send` tool's `goalId`/`claimedPaths` parameters link long-running peer tasks to the board: Symphony records a task, claims overlapping write paths before dispatch, injects goal/heartbeat instructions into the peer prompt, keeps the claim alive with local heartbeats, and releases the claim after the peer returns a final response. `/peer goal fanout` turns a goal into role-specific peer lanes, while `peer_progress` reports checkpoints from an inbound long-running task. Active write claims conflict on overlapping paths; released, stale, or expired claims are kept visible but inactive. Claims become stale after 45 minutes without a heartbeat unless the claim or heartbeat sets `--stale-after-ms`.
|
|
61
|
+
The board is stored locally at `.pi/peer-goals.json`; outbound message snapshots are stored in `.pi/peer-messages.json` so restarted planners can still inspect disconnected historical tasks. Mutating goal-board operations take a short local lock before load/modify/save so concurrent peer appends do not drop events. `/peer send --goal <goal-id> --claim <path[,path]>` and the `peer_send` tool's `goalId`/`claimedPaths` parameters link long-running peer tasks to the board: Symphony records a task, claims overlapping write paths before dispatch, injects goal/heartbeat instructions into the peer prompt, keeps the claim alive with local heartbeats, and releases the claim after the peer returns a final response. Each goal-linked dispatch also gets a semantic work key (`goalId | lane | objective | mode | paths`) so duplicate read/review/research lanes are leased just like write paths; pass `--key <work-key>` / `workKey` for an explicit fingerprint and `--duplicate-policy allow-parallel` only when independent second opinions are intentional. The default dispatch policy is `reuse`, so a matching active work key returns the existing claim/task handle instead of starting another peer. `/peer goal fanout` turns a goal into role-specific peer lanes, while `peer_progress` reports checkpoints from an inbound long-running task. Scout suggestions are persona-aware: they surface a recommended lane (`research`, `review`, `coordination`, etc.), preferred roles, a safe default claim mode, rationale, and suppress suggestions already covered by active work keys. Active write claims conflict on overlapping paths; active semantic claims conflict on matching work keys; released, stale, or expired claims are kept visible but inactive. Claims become stale after 45 minutes without a heartbeat unless the claim or heartbeat sets `--stale-after-ms`.
|
|
61
62
|
|
|
62
63
|
Normal goal closure requires at least one current passing vote, no current failed votes, no unresolved blocking objections, and no active write claims. Open proposals are intentionally non-blocking: they let peers show initiative without freezing closure. Stale write claims no longer block closure or new overlapping claims; use `/peer goal heartbeat` to revive work after a reconnect and `--force` only when intentionally overriding the readiness gate. Goal-linked tasks validate final handoff headings (`Status`, `Files changed`, `Verification`, `Blockers/risks`, `Safe for review`); missing sections create a blocking objection while still releasing the write claim. For multi-part work, use the fan-out gate: list peers, create/reuse a goal, delegate research/review/worker lanes, and include `Fan-out used: yes/no` plus peer handles in the final answer.
|
|
63
64
|
|
|
@@ -66,7 +67,7 @@ Normal goal closure requires at least one current passing vote, no current faile
|
|
|
66
67
|
When peer messaging is enabled, the extension starts a lightweight in-process idle watcher on `session_start`. It only acts when Pi reports the agent is idle and there are no queued local follow-up messages. The watcher does two things:
|
|
67
68
|
|
|
68
69
|
1. If an inbound peer task is active but appears not to have triggered a turn, it re-nudges the existing inbound prompt with `triggerTurn: true` using a cooldown.
|
|
69
|
-
2. If no peer task is active, it reads `.pi/peer-goals.json`, derives the same read-only scout suggestions as `/peer scout`, and injects a concise self-prompt so the idle peer can propose, review, claim, vote, or no-op safely.
|
|
70
|
+
2. If no peer task is active, it reads `.pi/peer-goals.json`, derives the same read-only scout suggestions as `/peer scout`, and injects a concise self-prompt so the idle peer can propose, review, claim, vote, or no-op safely. When the local peer has a configured `role` or `persona`, the watcher prefers suggestions whose lane matches that profile and leaves mismatched work for better-fit peers.
|
|
70
71
|
|
|
71
72
|
Configuration can be placed in `.pi/peers.json` as `idleWatcher` or in `.pi/settings.json` under `peerMessaging.idleWatcher`:
|
|
72
73
|
|
|
@@ -101,7 +101,10 @@ export default function piPeerExtension(pi: ExtensionAPI) {
|
|
|
101
101
|
metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
102
102
|
claimedPaths: Type.Optional(Type.Array(Type.String({ description: "Paths this peer task is expected to own while running" }))),
|
|
103
103
|
goalId: Type.Optional(Type.String({ description: "Peer goal id to link this long-running task to" })),
|
|
104
|
-
goalClaimMode: Type.Optional(Type.String({ description: "Goal-board claim mode
|
|
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,7 +8,17 @@ export const PEER_GOAL_BOARD_RELATIVE_PATH = ".pi/peer-goals.json";
|
|
|
8
8
|
const EVENT_TYPES = new Set(["finding", "task", "proposal", "claim", "release", "heartbeat", "objection", "resolve", "vote", "handoff", "note"]);
|
|
9
9
|
const BLOCKING_SEVERITIES = new Set(["blocking", "blocker", "critical"]);
|
|
10
10
|
const VOTE_VERDICTS = new Set(["pass", "fail", "pass-with-risks"]);
|
|
11
|
+
const DUPLICATE_POLICIES = new Set(["error", "reuse", "allow-parallel"]);
|
|
11
12
|
const DEFAULT_GOAL_CLAIM_STALE_MS = 45 * 60 * 1000;
|
|
13
|
+
const SCOUT_LANES = Object.freeze({
|
|
14
|
+
blocker: { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator", "reviewer"], claimMode: "read", suggestedIntent: "review", rationale: "Blocking objections need a coordination/review lane before more work starts." },
|
|
15
|
+
"failed-vote": { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator", "reviewer"], claimMode: "read", suggestedIntent: "review", rationale: "Failed votes need triage before new implementation work." },
|
|
16
|
+
"stale-claim": { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator"], claimMode: "read", suggestedIntent: "coordinate", rationale: "Stale claims need owner follow-up or release, not duplicate writes." },
|
|
17
|
+
"open-proposal": { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator", "reviewer"], claimMode: "read", suggestedIntent: "review", rationale: "Open proposals need triage into accept, defer, or resolve decisions." },
|
|
18
|
+
close: { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator", "reviewer"], claimMode: "read", suggestedIntent: "coordinate", rationale: "Ready goals need final closure checks and a concise handoff." },
|
|
19
|
+
"next-step": { recommendedLane: "research", preferredRoles: ["researcher", "reviewer", "planner", "coordinator", "worker"], claimMode: "read", suggestedIntent: "review", rationale: "Empty goals benefit from a read-only lane before write claims." },
|
|
20
|
+
review: { recommendedLane: "review", preferredRoles: ["reviewer", "qa", "coordinator", "planner"], claimMode: "read", suggestedIntent: "review", rationale: "Goals without current votes need read-only validation before closure." },
|
|
21
|
+
});
|
|
12
22
|
const GOAL_BOARD_LOCK_STALE_MS = 30_000;
|
|
13
23
|
const GOAL_BOARD_LOCK_RETRY_MS = 10;
|
|
14
24
|
const GOAL_BOARD_LOCK_TIMEOUT_MS = 5_000;
|
|
@@ -96,26 +106,61 @@ export async function closePeerGoal(root, goalId, input = {}) {
|
|
|
96
106
|
|
|
97
107
|
export async function beginPeerGoalTask(root, goalId, input = {}) {
|
|
98
108
|
const id = requiredGoalId(goalId || input.goalId);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
109
|
+
return updatePeerGoalBoard(root, (board) => {
|
|
110
|
+
const goal = resolveGoal(board, id);
|
|
111
|
+
const paths = normalizePaths(input.claimedPaths || input.paths);
|
|
112
|
+
const mode = cleanText(input.mode || input.claimMode || (paths.length ? "write" : "read")).toLowerCase();
|
|
113
|
+
const lane = cleanText(input.lane || input.workLane || input.intent || mode).toLowerCase();
|
|
114
|
+
const workKey = normalizeWorkKey(input.workKey) || derivePeerGoalWorkKey({
|
|
115
|
+
goalId: id,
|
|
116
|
+
lane,
|
|
117
|
+
objective: input.objective || input.summary || input.prompt,
|
|
118
|
+
mode,
|
|
119
|
+
paths,
|
|
120
|
+
});
|
|
121
|
+
if (!paths.length && !workKey) return { goalId: id, goal: deriveGoalState(goal) };
|
|
122
|
+
|
|
123
|
+
const duplicatePolicy = normalizeDuplicatePolicy(input.duplicatePolicy) || "reuse";
|
|
124
|
+
if (workKey && duplicatePolicy === "reuse") {
|
|
125
|
+
const state = deriveGoalState(goal);
|
|
126
|
+
const existingClaim = state.activeClaims.find((claim) => claim.workKey === workKey);
|
|
127
|
+
if (existingClaim) {
|
|
128
|
+
return {
|
|
129
|
+
goalId: id,
|
|
130
|
+
goal: state,
|
|
131
|
+
duplicate: true,
|
|
132
|
+
duplicatePolicy,
|
|
133
|
+
workKey,
|
|
134
|
+
existingClaim,
|
|
135
|
+
existingTask: latestTaskForWorkKey(state.tasks, workKey),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
103
139
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
140
|
+
const event = normalizeEvent({
|
|
141
|
+
type: "claim",
|
|
142
|
+
peerId: cleanText(input.targetPeerId || input.peerId) || "unknown",
|
|
143
|
+
summary: taskSummary(input),
|
|
144
|
+
paths,
|
|
145
|
+
mode,
|
|
146
|
+
lane,
|
|
147
|
+
workKey,
|
|
148
|
+
duplicatePolicy,
|
|
149
|
+
ttlMs: input.ttlMs,
|
|
150
|
+
staleAfterMs: input.staleAfterMs,
|
|
151
|
+
metadata: stripEmpty({ requesterPeerId: cleanText(input.requesterPeerId), targetPeerId: cleanText(input.targetPeerId), workKey, lane, duplicatePolicy }),
|
|
152
|
+
});
|
|
153
|
+
validateClaim(goal, event);
|
|
154
|
+
goal.events.push(event);
|
|
155
|
+
goal.updatedAt = event.at;
|
|
156
|
+
board.currentGoalId = goal.id;
|
|
157
|
+
return { goalId: id, goal: deriveGoalState(goal), claimEvent: event, workKey, duplicatePolicy };
|
|
113
158
|
});
|
|
114
|
-
return { goalId: id, goal: result.goal, claimEvent: result.event };
|
|
115
159
|
}
|
|
116
160
|
|
|
117
161
|
export async function recordPeerGoalTaskDispatch(root, goalId, input = {}) {
|
|
118
162
|
const id = requiredGoalId(goalId || input.goalId);
|
|
163
|
+
const workKey = normalizeWorkKey(input.workKey) || derivePeerGoalWorkKey({ goalId: id, lane: input.lane || input.workLane || input.intent || input.mode, objective: input.objective || input.summary || input.prompt, mode: input.mode || input.claimMode, paths: input.claimedPaths || input.paths });
|
|
119
164
|
const result = await appendPeerGoalEvent(root, id, {
|
|
120
165
|
type: "task",
|
|
121
166
|
peerId: cleanText(input.requesterPeerId || input.peerId) || "unknown",
|
|
@@ -123,11 +168,15 @@ export async function recordPeerGoalTaskDispatch(root, goalId, input = {}) {
|
|
|
123
168
|
paths: input.claimedPaths || input.paths,
|
|
124
169
|
taskId: input.messageId,
|
|
125
170
|
status: cleanText(input.status || "running"),
|
|
171
|
+
workKey,
|
|
172
|
+
lane: input.lane || input.workLane,
|
|
173
|
+
duplicatePolicy: input.duplicatePolicy,
|
|
126
174
|
metadata: stripEmpty({
|
|
127
175
|
messageId: cleanText(input.messageId),
|
|
128
176
|
conversationId: cleanText(input.conversationId),
|
|
129
177
|
targetPeerId: cleanText(input.targetPeerId),
|
|
130
178
|
claimEventId: cleanText(input.claimEventId),
|
|
179
|
+
workKey,
|
|
131
180
|
}),
|
|
132
181
|
});
|
|
133
182
|
return { goalId: id, goal: result.goal, taskEvent: result.event };
|
|
@@ -135,6 +184,7 @@ export async function recordPeerGoalTaskDispatch(root, goalId, input = {}) {
|
|
|
135
184
|
|
|
136
185
|
export async function completePeerGoalTask(root, goalId, input = {}) {
|
|
137
186
|
const id = requiredGoalId(goalId || input.goalId);
|
|
187
|
+
const workKey = normalizeWorkKey(input.workKey) || derivePeerGoalWorkKey({ goalId: id, lane: input.lane || input.workLane || input.intent || input.mode, objective: input.objective || input.summary || input.prompt, mode: input.mode || input.claimMode, paths: input.claimedPaths || input.paths });
|
|
138
188
|
const handoff = await appendPeerGoalEvent(root, id, {
|
|
139
189
|
type: "handoff",
|
|
140
190
|
peerId: cleanText(input.targetPeerId || input.peerId) || "unknown",
|
|
@@ -142,11 +192,14 @@ export async function completePeerGoalTask(root, goalId, input = {}) {
|
|
|
142
192
|
paths: input.claimedPaths || input.paths,
|
|
143
193
|
taskId: input.messageId,
|
|
144
194
|
status: cleanText(input.status || "done"),
|
|
195
|
+
workKey,
|
|
196
|
+
lane: input.lane || input.workLane,
|
|
145
197
|
metadata: stripEmpty({
|
|
146
198
|
messageId: cleanText(input.messageId),
|
|
147
199
|
conversationId: cleanText(input.conversationId),
|
|
148
200
|
claimEventId: cleanText(input.claimEventId),
|
|
149
201
|
responseStatus: cleanText(input.responseStatus),
|
|
202
|
+
workKey,
|
|
150
203
|
}),
|
|
151
204
|
});
|
|
152
205
|
if (!input.claimEventId) return { goalId: id, goal: handoff.goal, handoffEvent: handoff.event };
|
|
@@ -159,6 +212,16 @@ export async function completePeerGoalTask(root, goalId, input = {}) {
|
|
|
159
212
|
return { goalId: id, goal: release.goal, handoffEvent: handoff.event, releaseEvent: release.event };
|
|
160
213
|
}
|
|
161
214
|
|
|
215
|
+
export function derivePeerGoalWorkKey(input = {}) {
|
|
216
|
+
const goalId = normalizeWorkKeyPart(input.goalId);
|
|
217
|
+
const lane = normalizeWorkKeyPart(input.lane || input.workLane || input.intent || "work");
|
|
218
|
+
const objective = normalizeWorkKeyPart(input.objective || input.summary || input.prompt || "work");
|
|
219
|
+
const mode = normalizeWorkKeyPart(input.mode || input.claimMode || "read");
|
|
220
|
+
const paths = normalizePaths(input.paths || input.claimedPaths).map(normalizeWorkKeyPart).sort();
|
|
221
|
+
const parts = [goalId, lane, objective, mode, paths.join(",")].filter((part) => part !== undefined && part !== "");
|
|
222
|
+
return parts.length ? parts.join("|") : undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
162
225
|
export function deriveGoalState(goal, options = {}) {
|
|
163
226
|
const now = options.now || nowIso();
|
|
164
227
|
const events = Array.isArray(goal?.events) ? goal.events : [];
|
|
@@ -222,7 +285,8 @@ export function formatPeerGoalScout(board, options = {}) {
|
|
|
222
285
|
const lines = ["# Peer Scout", "", "Proactive suggestions (read-only):"];
|
|
223
286
|
for (const suggestion of suggestions.slice(0, limit)) {
|
|
224
287
|
const pathText = suggestion.paths?.length ? ` · paths: ${suggestion.paths.join(", ")}` : "";
|
|
225
|
-
|
|
288
|
+
const laneText = suggestion.recommendedLane ? ` · lane: ${suggestion.recommendedLane}${suggestion.preferredRoles?.length ? ` for ${suggestion.preferredRoles.join("/")}` : ""}${suggestion.claimMode ? ` (${suggestion.claimMode})` : ""}` : "";
|
|
289
|
+
lines.push(`- ${suggestion.priority} · ${suggestion.goalId} · ${suggestion.kind}: ${suggestion.summary}${laneText}${pathText}`);
|
|
226
290
|
}
|
|
227
291
|
lines.push("", "Next: post one with `/peer goal propose <goal-id> <summary>` or claim safe work with `/peer goal claim <goal-id> <task> --mode read|write --path <path>`. Scout does not mutate the board.");
|
|
228
292
|
return lines.join("\n");
|
|
@@ -238,7 +302,10 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
|
|
|
238
302
|
const suggestions = [];
|
|
239
303
|
for (const goal of goals) {
|
|
240
304
|
const state = deriveGoalState(goal);
|
|
241
|
-
const push = (priority, kind, summary, extra = {}) =>
|
|
305
|
+
const push = (priority, kind, summary, extra = {}) => {
|
|
306
|
+
const suggestion = enrichScoutSuggestion({ goalId: goal.id, priority, kind, summary, ...extra });
|
|
307
|
+
if (!hasActiveClaimForScoutSuggestion(state, suggestion)) suggestions.push(suggestion);
|
|
308
|
+
};
|
|
242
309
|
if (state.blockingObjections.length) {
|
|
243
310
|
push("P0", "blocker", `Resolve ${state.blockingObjections.length} blocking objection${state.blockingObjections.length === 1 ? "" : "s"} before more work.`, { paths: uniqueEventPaths(state.blockingObjections) });
|
|
244
311
|
continue;
|
|
@@ -276,11 +343,11 @@ export function formatPeerGoal(goal) {
|
|
|
276
343
|
if (state.constraints?.length) lines.push(`constraints: ${state.constraints.join("; ")}`);
|
|
277
344
|
if (state.activeClaims.length) {
|
|
278
345
|
lines.push("", "Active claims:");
|
|
279
|
-
for (const claim of state.activeClaims) lines.push(`- ${claim.id} · ${claim.peerId} · ${claim.mode || "read"} · ${claim.summary}${claim.paths?.length ? ` · ${claim.paths.join(", ")}` : ""}`);
|
|
346
|
+
for (const claim of state.activeClaims) lines.push(`- ${claim.id} · ${claim.peerId} · ${claim.mode || "read"} · ${claim.summary}${claim.paths?.length ? ` · ${claim.paths.join(", ")}` : ""}${claim.workKey ? ` · key ${truncate(claim.workKey, 80)}` : ""}`);
|
|
280
347
|
}
|
|
281
348
|
if (state.staleClaims.length) {
|
|
282
349
|
lines.push("", "Stale claims:");
|
|
283
|
-
for (const claim of state.staleClaims.slice(-8)) lines.push(`- ${claim.id} · ${claim.peerId} · ${claim.mode || "read"} · ${claim.summary}${claim.lastHeartbeatAt ? ` · last heartbeat ${claim.lastHeartbeatAt}` : ""}`);
|
|
350
|
+
for (const claim of state.staleClaims.slice(-8)) lines.push(`- ${claim.id} · ${claim.peerId} · ${claim.mode || "read"} · ${claim.summary}${claim.workKey ? ` · key ${truncate(claim.workKey, 80)}` : ""}${claim.lastHeartbeatAt ? ` · last heartbeat ${claim.lastHeartbeatAt}` : ""}`);
|
|
284
351
|
}
|
|
285
352
|
if (state.expiredClaims.length) {
|
|
286
353
|
lines.push("", "Expired claims:");
|
|
@@ -312,6 +379,10 @@ function validateClaim(goal, event) {
|
|
|
312
379
|
const paths = normalizePaths(event.paths);
|
|
313
380
|
if (event.mode === "write" && paths.length === 0) throw new Error("write claims require --path <path[,path]>");
|
|
314
381
|
const state = deriveGoalState(goal);
|
|
382
|
+
if (event.workKey && event.duplicatePolicy !== "allow-parallel") {
|
|
383
|
+
const duplicates = state.activeClaims.filter((claim) => claim.workKey === event.workKey);
|
|
384
|
+
if (duplicates.length) throw new Error(`claim duplicates active work key ${event.workKey} already held by ${duplicates.map((claim) => claim.id).join(", ")}`);
|
|
385
|
+
}
|
|
315
386
|
if (event.mode === "write") {
|
|
316
387
|
const conflicts = state.activeClaims.filter((claim) => claim.mode === "write" && pathsOverlap(paths, claim.paths || []));
|
|
317
388
|
if (conflicts.length) throw new Error(`claim conflicts with active write claim ${conflicts.map((claim) => claim.id).join(", ")}`);
|
|
@@ -334,6 +405,10 @@ function validateHeartbeat(goal, event) {
|
|
|
334
405
|
const state = deriveGoalState(goal);
|
|
335
406
|
const claim = state.activeClaims.find((item) => item.id === event.resolves) || state.staleClaims.find((item) => item.id === event.resolves) || state.expiredClaims.find((item) => item.id === event.resolves);
|
|
336
407
|
if (!claim) throw new Error(`peer goal heartbeat target ${event.resolves} is not an active, stale, or expired claim`);
|
|
408
|
+
if (claim.workKey && claim.duplicatePolicy !== "allow-parallel") {
|
|
409
|
+
const duplicates = state.activeClaims.filter((item) => item.id !== claim.id && item.workKey === claim.workKey);
|
|
410
|
+
if (duplicates.length) throw new Error(`heartbeat conflicts with active work key ${claim.workKey} already held by ${duplicates.map((item) => item.id).join(", ")}`);
|
|
411
|
+
}
|
|
337
412
|
if (claim.mode === "write") {
|
|
338
413
|
const conflicts = state.activeClaims.filter((item) => item.id !== claim.id && item.mode === "write" && pathsOverlap(claim.paths || [], item.paths || []));
|
|
339
414
|
if (conflicts.length) throw new Error(`heartbeat conflicts with active write claim ${conflicts.map((item) => item.id).join(", ")}`);
|
|
@@ -368,6 +443,9 @@ function normalizeEvent(input = {}) {
|
|
|
368
443
|
paths: normalizePaths(input.paths),
|
|
369
444
|
taskId: cleanText(input.taskId),
|
|
370
445
|
mode: cleanText(input.mode || (type === "claim" ? "read" : "")).toLowerCase() || undefined,
|
|
446
|
+
lane: cleanText(input.lane || input.workLane)?.toLowerCase(),
|
|
447
|
+
workKey: normalizeWorkKey(input.workKey),
|
|
448
|
+
duplicatePolicy: normalizeDuplicatePolicy(input.duplicatePolicy),
|
|
371
449
|
resolves: cleanText(input.resolves),
|
|
372
450
|
verdict: cleanText(input.verdict)?.toLowerCase(),
|
|
373
451
|
confidence: confidenceValue(input.confidence),
|
|
@@ -397,6 +475,14 @@ function taskSummary(input = {}) {
|
|
|
397
475
|
return cleanText(input.summary) || cleanText(input.prompt) || `Peer task for ${cleanText(input.targetPeerId || input.peerId) || "unknown"}`;
|
|
398
476
|
}
|
|
399
477
|
|
|
478
|
+
function latestTaskForWorkKey(tasks = [], workKey) {
|
|
479
|
+
if (!workKey) return undefined;
|
|
480
|
+
const activeStatuses = new Set(["queued", "dispatching", "running", "pending"]);
|
|
481
|
+
return [...tasks]
|
|
482
|
+
.reverse()
|
|
483
|
+
.find((task) => task.workKey === workKey && (!task.status || activeStatuses.has(String(task.status).toLowerCase())));
|
|
484
|
+
}
|
|
485
|
+
|
|
400
486
|
function normalizeBoard(board = {}) {
|
|
401
487
|
const goals = plainObject(board.goals) ? board.goals : {};
|
|
402
488
|
const normalizedGoals = {};
|
|
@@ -412,7 +498,7 @@ function normalizeBoard(board = {}) {
|
|
|
412
498
|
createdBy: cleanText(goal.createdBy),
|
|
413
499
|
closedAt: cleanText(goal.closedAt),
|
|
414
500
|
closedBy: cleanText(goal.closedBy),
|
|
415
|
-
events: Array.isArray(goal.events) ? goal.events.map((event) => stripEmpty({ ...event, paths: normalizePaths(event.paths) })) : [],
|
|
501
|
+
events: Array.isArray(goal.events) ? goal.events.map((event) => stripEmpty({ ...event, paths: normalizePaths(event.paths), workKey: normalizeWorkKey(event.workKey), duplicatePolicy: normalizeDuplicatePolicy(event.duplicatePolicy), lane: cleanText(event.lane || event.workLane)?.toLowerCase() })) : [],
|
|
416
502
|
};
|
|
417
503
|
}
|
|
418
504
|
return { version: 1, currentGoalId: cleanText(board.currentGoalId), goals: normalizedGoals };
|
|
@@ -436,6 +522,9 @@ function projectEventSummary(event) {
|
|
|
436
522
|
paths: event.paths,
|
|
437
523
|
taskId: event.taskId,
|
|
438
524
|
mode: event.mode,
|
|
525
|
+
lane: event.lane,
|
|
526
|
+
workKey: event.workKey,
|
|
527
|
+
duplicatePolicy: event.duplicatePolicy,
|
|
439
528
|
resolves: event.resolves,
|
|
440
529
|
verdict: event.verdict,
|
|
441
530
|
confidence: event.confidence,
|
|
@@ -483,6 +572,47 @@ function uniqueEventPaths(events) {
|
|
|
483
572
|
return [...new Set(events.flatMap((event) => Array.isArray(event.paths) ? event.paths : []))];
|
|
484
573
|
}
|
|
485
574
|
|
|
575
|
+
function normalizeWorkKey(value) {
|
|
576
|
+
const text = cleanText(value);
|
|
577
|
+
if (!text) return undefined;
|
|
578
|
+
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function normalizeWorkKeyPart(value) {
|
|
582
|
+
const text = cleanText(value);
|
|
583
|
+
if (!text) return undefined;
|
|
584
|
+
return text.toLowerCase().replace(/\s+/g, " ").replace(/[|]+/g, "/").trim();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function normalizeDuplicatePolicy(value) {
|
|
588
|
+
const policy = cleanText(value)?.toLowerCase();
|
|
589
|
+
return DUPLICATE_POLICIES.has(policy) ? policy : undefined;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function enrichScoutSuggestion(suggestion = {}) {
|
|
593
|
+
const lane = SCOUT_LANES[suggestion.kind] || {};
|
|
594
|
+
const recommendedLane = suggestion.recommendedLane || lane.recommendedLane;
|
|
595
|
+
const claimMode = cleanText(suggestion.claimMode || lane.claimMode);
|
|
596
|
+
const enriched = stripEmpty({
|
|
597
|
+
...suggestion,
|
|
598
|
+
recommendedLane,
|
|
599
|
+
preferredRoles: normalizeList(suggestion.preferredRoles || lane.preferredRoles),
|
|
600
|
+
preferredCapabilities: normalizeList(suggestion.preferredCapabilities || lane.preferredCapabilities),
|
|
601
|
+
claimMode,
|
|
602
|
+
suggestedIntent: cleanText(suggestion.suggestedIntent || lane.suggestedIntent),
|
|
603
|
+
rationale: cleanText(suggestion.rationale || lane.rationale),
|
|
604
|
+
});
|
|
605
|
+
return stripEmpty({
|
|
606
|
+
...enriched,
|
|
607
|
+
workKey: suggestion.workKey || derivePeerGoalWorkKey({ goalId: enriched.goalId, lane: recommendedLane, objective: enriched.summary, mode: claimMode, paths: enriched.paths }),
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function hasActiveClaimForScoutSuggestion(state, suggestion) {
|
|
612
|
+
if (!suggestion?.workKey) return false;
|
|
613
|
+
return state.activeClaims.some((claim) => claim.workKey === suggestion.workKey);
|
|
614
|
+
}
|
|
615
|
+
|
|
486
616
|
async function updatePeerGoalBoard(root, updater) {
|
|
487
617
|
const path = goalBoardPath(root);
|
|
488
618
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -62,6 +62,9 @@ export function createPeerIdleWatcher(options = {}) {
|
|
|
62
62
|
const board = await loadBoard(runtime.cwd || ctx?.cwd || process.cwd());
|
|
63
63
|
const activation = derivePeerIdleActivation(board, {
|
|
64
64
|
localPeerId: runtime.localPeerId,
|
|
65
|
+
localRole: runtime.config?.localPeerProfile?.role || runtime.localEndpoint?.role,
|
|
66
|
+
localPersona: runtime.config?.localPeerProfile?.persona || runtime.localEndpoint?.persona,
|
|
67
|
+
localCapabilities: runtime.localEndpoint?.capabilities || runtime.config?.manifest?.capabilities,
|
|
65
68
|
config,
|
|
66
69
|
state,
|
|
67
70
|
nowMs: now(),
|
|
@@ -116,8 +119,8 @@ export function derivePeerIdleActivation(board, options = {}) {
|
|
|
116
119
|
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
|
117
120
|
for (const suggestion of suggestions) {
|
|
118
121
|
if (!allowedKinds.has(suggestion.kind)) continue;
|
|
119
|
-
const activation = normalizeActivation(suggestion, options.localPeerId);
|
|
120
|
-
if (!activation) continue;
|
|
122
|
+
const activation = normalizeActivation(suggestion, options.localPeerId, options);
|
|
123
|
+
if (!activation || !activationFitsPeer(activation, options)) continue;
|
|
121
124
|
if (isActivationCoolingDown(options.state, activation, config, nowMs)) continue;
|
|
122
125
|
return activation;
|
|
123
126
|
}
|
|
@@ -135,11 +138,14 @@ export function markPeerIdleActivation(state, activation, nowMs = Date.now()) {
|
|
|
135
138
|
export function buildPeerIdleActivationPrompt(activation, options = {}) {
|
|
136
139
|
const peerId = options.localPeerId || "this-peer";
|
|
137
140
|
const paths = activation.paths?.length ? `\nPaths: ${activation.paths.join(", ")}` : "";
|
|
138
|
-
|
|
141
|
+
const lane = activation.recommendedLane ? `\nRecommended lane: ${activation.recommendedLane}${activation.claimMode ? ` (${activation.claimMode})` : ""}${activation.preferredRoles?.length ? ` · preferred roles: ${activation.preferredRoles.join(", ")}` : ""}` : "";
|
|
142
|
+
const rationale = activation.rationale ? `\nRationale: ${activation.rationale}` : "";
|
|
143
|
+
const fit = activation.personaFit?.matched?.length ? `\nPersona fit: matched ${activation.personaFit.matched.join(", ")}` : "";
|
|
144
|
+
return `[Pi peer idle watcher]\nYou are local peer '${peerId}' and Pi is idle. A proactive goal-board scout suggestion is available.\n\nGoal: ${activation.goalId}\nSuggestion: ${activation.kind} (${activation.priority}) — ${activation.summary}${lane}${rationale}${fit}${paths}\n\nInstructions:\n- First inspect current state with peer_get id '${activation.goalId}'.\n- If useful, take one small safe action that fits the recommended lane: post a proposal/finding/vote, claim a read-only review lane, or claim write work only when you intend to edit and can name the paths.\n- Do not duplicate active claims or proposals. If the board is no longer actionable, say so briefly and stop.\n- For write work, respect goal-board claims and end with the required peer handoff sections.\n- Keep the response concise.`;
|
|
139
145
|
}
|
|
140
146
|
|
|
141
147
|
export function peerIdleActivationKey(activation = {}) {
|
|
142
|
-
return [activation.goalId, activation.kind, activation.summary, ...(activation.paths || [])].join("|");
|
|
148
|
+
return [activation.goalId, activation.kind, activation.recommendedLane, activation.summary, ...(activation.paths || [])].join("|");
|
|
143
149
|
}
|
|
144
150
|
|
|
145
151
|
function isActivationCoolingDown(state, activation, config, nowMs) {
|
|
@@ -147,18 +153,76 @@ function isActivationCoolingDown(state, activation, config, nowMs) {
|
|
|
147
153
|
return Number.isFinite(last) && nowMs - last < config.cooldownMs;
|
|
148
154
|
}
|
|
149
155
|
|
|
150
|
-
function normalizeActivation(suggestion = {}, localPeerId) {
|
|
156
|
+
function normalizeActivation(suggestion = {}, localPeerId, options = {}) {
|
|
151
157
|
if (!suggestion.goalId || !suggestion.kind || !suggestion.summary) return undefined;
|
|
158
|
+
const personaFit = peerPersonaFit(suggestion, options);
|
|
152
159
|
return {
|
|
153
160
|
goalId: suggestion.goalId,
|
|
154
161
|
priority: suggestion.priority || "P2",
|
|
155
162
|
kind: suggestion.kind,
|
|
156
163
|
summary: suggestion.summary,
|
|
164
|
+
recommendedLane: cleanString(suggestion.recommendedLane),
|
|
165
|
+
preferredRoles: normalizeStringList(suggestion.preferredRoles),
|
|
166
|
+
preferredCapabilities: normalizeStringList(suggestion.preferredCapabilities),
|
|
167
|
+
claimMode: cleanString(suggestion.claimMode),
|
|
168
|
+
suggestedIntent: cleanString(suggestion.suggestedIntent),
|
|
169
|
+
rationale: cleanString(suggestion.rationale),
|
|
170
|
+
personaFit,
|
|
157
171
|
paths: Array.isArray(suggestion.paths) ? suggestion.paths.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim()) : [],
|
|
158
172
|
peerId: localPeerId,
|
|
159
173
|
};
|
|
160
174
|
}
|
|
161
175
|
|
|
176
|
+
function activationFitsPeer(activation = {}, options = {}) {
|
|
177
|
+
const preferred = activation.preferredRoles || [];
|
|
178
|
+
if (activation.priority === "P0" || !preferred.length) return true;
|
|
179
|
+
const fit = activation.personaFit || peerPersonaFit(activation, options);
|
|
180
|
+
if (!fit.hasProfile) return true;
|
|
181
|
+
return fit.matched.length > 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function peerPersonaFit(suggestion = {}, options = {}) {
|
|
185
|
+
const preferredRoles = normalizeStringList(suggestion.preferredRoles);
|
|
186
|
+
const localTerms = peerProfileTerms(options);
|
|
187
|
+
if (!preferredRoles.length) return { hasProfile: localTerms.length > 0, matched: [] };
|
|
188
|
+
const matched = preferredRoles.filter((role) => localTerms.includes(normalizeRoleToken(role)));
|
|
189
|
+
return { hasProfile: localTerms.some(isKnownRoleTerm), matched };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function peerProfileTerms(options = {}) {
|
|
193
|
+
const terms = [options.localRole, options.localPersona, options.localPeerId]
|
|
194
|
+
.flatMap((value) => String(value || "").toLowerCase().split(/[^a-z0-9]+/g))
|
|
195
|
+
.map(normalizeRoleToken)
|
|
196
|
+
.filter(Boolean);
|
|
197
|
+
const capabilities = options.localCapabilities && typeof options.localCapabilities === "object" ? options.localCapabilities : {};
|
|
198
|
+
if (Array.isArray(capabilities.roles)) terms.push(...capabilities.roles.map(normalizeRoleToken));
|
|
199
|
+
return [...new Set(terms)];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isKnownRoleTerm(value) {
|
|
203
|
+
return ["planner", "coordinator", "reviewer", "qa", "worker", "researcher"].includes(value);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function normalizeRoleToken(value) {
|
|
207
|
+
const token = String(value || "").trim().toLowerCase();
|
|
208
|
+
if (!token) return "";
|
|
209
|
+
if (/^(reviewer|review|qa|quality)\d*$/.test(token)) return token === "qa" ? "qa" : "reviewer";
|
|
210
|
+
if (/^(worker|implement|implementation|code|coder|engineer|developer|task)\d*$/.test(token)) return "worker";
|
|
211
|
+
if (/^(researcher|research|scout)\d*$/.test(token)) return "researcher";
|
|
212
|
+
if (/^(planner|plan|coordinate|coordinator|orchestrator)\d*$/.test(token)) return token.startsWith("planner") ? "planner" : "coordinator";
|
|
213
|
+
return token;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function normalizeStringList(value) {
|
|
217
|
+
if (Array.isArray(value)) return [...new Set(value.map(cleanString).filter(Boolean))];
|
|
218
|
+
if (typeof value === "string") return [...new Set(value.split(",").map(cleanString).filter(Boolean))];
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function cleanString(value) {
|
|
223
|
+
return typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
|
|
224
|
+
}
|
|
225
|
+
|
|
162
226
|
function isContextIdle(ctx) {
|
|
163
227
|
if (!ctx) return { ok: false, reason: "no active session context" };
|
|
164
228
|
if (typeof ctx.isIdle === "function" && !ctx.isIdle()) return { ok: false, reason: "agent busy" };
|
package/src/peers/status.mjs
CHANGED
|
@@ -133,20 +133,44 @@ function capabilitySummary(capabilities = {}) {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
export function deriveFanoutSuggestion(peers = [], pendingMessages = []) {
|
|
136
|
-
const
|
|
136
|
+
const availablePeerDetails = peers
|
|
137
137
|
.filter((peer) => !peer.current && !peer.self && peer.trust !== "disabled")
|
|
138
|
-
.map((peer) =>
|
|
139
|
-
|
|
138
|
+
.map((peer) => ({
|
|
139
|
+
peerId: peer.peerId,
|
|
140
|
+
role: safeStatusText(peer.role),
|
|
141
|
+
persona: safeStatusText(peer.persona),
|
|
142
|
+
recommendedLane: recommendLaneForPeer(peer),
|
|
143
|
+
}))
|
|
144
|
+
.filter((peer) => peer.peerId);
|
|
145
|
+
const availablePeers = availablePeerDetails.map((peer) => peer.peerId);
|
|
146
|
+
const lanes = availablePeerDetails.reduce((groups, peer) => {
|
|
147
|
+
const lane = peer.recommendedLane || "general";
|
|
148
|
+
if (!groups[lane]) groups[lane] = [];
|
|
149
|
+
groups[lane].push(peer.peerId);
|
|
150
|
+
return groups;
|
|
151
|
+
}, {});
|
|
140
152
|
const activePeerTasks = pendingMessages.filter((message) => ["queued", "running"].includes(message.status));
|
|
141
153
|
const recommended = availablePeers.length > 0 && activePeerTasks.length === 0;
|
|
154
|
+
const laneText = Object.entries(lanes).slice(0, 4).map(([lane, ids]) => `${lane}:${ids.slice(0, 3).join("/")}`).join(", ");
|
|
142
155
|
return {
|
|
143
156
|
recommended,
|
|
144
157
|
availablePeers,
|
|
158
|
+
availablePeerDetails,
|
|
159
|
+
lanes,
|
|
145
160
|
activePeerTaskCount: activePeerTasks.length,
|
|
146
|
-
warning: recommended ? `fan-out available for multi-lane work: ${availablePeers.slice(0, 4).join(", ")} — use /peer goal fanout or peer_send` : undefined,
|
|
161
|
+
warning: recommended ? `fan-out available for multi-lane work: ${availablePeers.slice(0, 4).join(", ")}${laneText ? ` · lanes ${laneText}` : ""} — use /peer goal fanout or peer_send` : undefined,
|
|
147
162
|
};
|
|
148
163
|
}
|
|
149
164
|
|
|
165
|
+
function recommendLaneForPeer(peer = {}) {
|
|
166
|
+
const text = [peer.role, peer.persona, peer.peerId].filter(Boolean).join(" ").toLowerCase();
|
|
167
|
+
if (/(^|[^a-z0-9])(review|reviewer|qa|quality)\d*($|[^a-z0-9])/.test(text)) return "review";
|
|
168
|
+
if (/(^|[^a-z0-9])(research|researcher|scout)\d*($|[^a-z0-9])/.test(text)) return "research";
|
|
169
|
+
if (/(^|[^a-z0-9])(plan|planner|coord|coordinator|orchestrator)\d*($|[^a-z0-9])/.test(text)) return "coordination";
|
|
170
|
+
if (/(^|[^a-z0-9])(worker|implement|implementation|engineer|developer|code|coder|task)\d*($|[^a-z0-9])/.test(text)) return "implementation";
|
|
171
|
+
return "general";
|
|
172
|
+
}
|
|
173
|
+
|
|
150
174
|
function line(kind, color, text) {
|
|
151
175
|
return { kind, color, text };
|
|
152
176
|
}
|