@cryptolibertus/pi-peer 0.5.0 → 0.5.2
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 +2 -2
- package/extensions/pi-peer/index.ts +30 -23
- package/package.json +1 -1
- package/src/peers/goal-board.mjs +93 -12
- package/src/peers/idle-watcher.mjs +26 -3
package/README.md
CHANGED
|
@@ -58,9 +58,9 @@ 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. 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 `
|
|
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; planned fanouts create `planned` task rows, while `--send` records only the dispatched peer task so boards do not accumulate orphan `dispatching` placeholders. Scout suggestions are persona-aware: they surface a recommended lane (`research`, `review`, `implementation`, `coordination`, etc.), preferred roles, a safe default claim mode, a stable work key, rationale, and suppress suggestions already covered by active work keys. `/peer scout` prints the exact work key and a copyable `claim:` command for each read-only suggestion, and fulfilled proposal lanes also print a direct `resolve:` command. Empty goals emit multiple lane-specific read-only suggestions so idle peers can self-select research, review/QA, or implementation-planning work instead of waiting for a planner to assign lanes; goals with stale-only claims prompt stale-claim cleanup instead of generic new startup lanes. Lane-tagged proposals (for example `/peer goal propose <goal> "Check package contents" --lane review --key review:package-contents`) become matching scout suggestions that reuse the proposal work key, letting the next suitable peer claim or review that proposed lane and suppress duplicate suggestions while the lane is active. Active write claims conflict on overlapping paths; active semantic claims conflict on matching work keys; released, stale, or expired claims are kept visible but inactive. Completed goal-linked tasks are shown with their handoff status instead of remaining visually stuck as `running`. Claims become stale after 45 minutes without a heartbeat unless the claim or heartbeat sets `--stale-after-ms`.
|
|
62
62
|
|
|
63
|
-
Normal goal closure requires at least one current passing vote, no current failed votes, no unresolved blocking objections,
|
|
63
|
+
Normal goal closure requires at least one current passing vote, no current failed votes, no unresolved blocking objections, no active claims or running tasks, and no unresolved open proposals. Proposals let peers show initiative, then must be resolved or explicitly deferred before normal closure so the final handoff reflects human intent; use `--force` only when intentionally overriding that readiness gate. Stale claims no longer block closure or new overlapping claims; use `/peer goal heartbeat` to revive work after a reconnect. Goal-linked tasks validate final handoff headings (`Status`, `Files changed`, `Verification`, `Blockers/risks`, `Safe for review`); missing sections create a blocking objection while still releasing the write claim. For multi-part work, use the fan-out gate: list peers, create/reuse a goal, delegate research/review/worker lanes, and include `Fan-out used: yes/no` plus peer handles in the final answer.
|
|
64
64
|
|
|
65
65
|
## Idle watcher
|
|
66
66
|
|
|
@@ -531,16 +531,20 @@ async function handlePeerGoalFanout(parsed: any, ctx: any, runtime: any, peerId:
|
|
|
531
531
|
for (const targetPeerId of parsed.peers) {
|
|
532
532
|
const mode = inferFanoutClaimMode(targetPeerId);
|
|
533
533
|
const lane = inferFanoutWorkLane(targetPeerId, mode);
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
534
|
+
const item: any = { peerId: targetPeerId, mode, lane };
|
|
535
|
+
if (!parsed.send) {
|
|
536
|
+
const summary = `${parsed.objective} [fanout:${targetPeerId}]`;
|
|
537
|
+
const task = await appendPeerGoalEvent(root, parsed.goalId, {
|
|
538
|
+
type: "task",
|
|
539
|
+
peerId,
|
|
540
|
+
summary,
|
|
541
|
+
paths: parsed.paths,
|
|
542
|
+
status: "planned",
|
|
543
|
+
metadata: { targetPeerId, fanout: true, claimMode: mode, workLane: lane },
|
|
544
|
+
});
|
|
545
|
+
item.taskEventId = task.event.id;
|
|
546
|
+
}
|
|
547
|
+
planned.push(item);
|
|
544
548
|
}
|
|
545
549
|
if (parsed.send) {
|
|
546
550
|
await Promise.all(planned.map(async (item: any) => {
|
|
@@ -560,17 +564,19 @@ async function handlePeerGoalFanout(parsed: any, ctx: any, runtime: any, peerId:
|
|
|
560
564
|
item.duplicate = true;
|
|
561
565
|
item.messageId = goalLink.existingTask?.taskId || goalLink.existingTask?.metadata?.messageId;
|
|
562
566
|
item.conversationId = goalLink.existingTask?.metadata?.conversationId;
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
567
|
+
if (item.taskEventId) {
|
|
568
|
+
await appendPeerGoalEvent(root, parsed.goalId, {
|
|
569
|
+
type: "handoff",
|
|
570
|
+
peerId: item.peerId,
|
|
571
|
+
summary: `Fan-out duplicate reused existing work key ${goalLink.workKey || "unknown"}`,
|
|
572
|
+
paths: parsed.paths,
|
|
573
|
+
taskId: item.taskEventId,
|
|
574
|
+
status: "done",
|
|
575
|
+
workKey: goalLink.workKey,
|
|
576
|
+
lane: item.lane,
|
|
577
|
+
metadata: { fanout: true, duplicate: true, targetPeerId: item.peerId, existingTaskId: item.messageId },
|
|
578
|
+
}).catch(() => {});
|
|
579
|
+
}
|
|
574
580
|
return;
|
|
575
581
|
}
|
|
576
582
|
const metadata = mergePeerMetadata({ fanout: true }, parsed.paths, parsed.goalId, { workKey: goalLink?.workKey, workLane: item.lane, duplicatePolicy: "reuse" });
|
|
@@ -590,12 +596,13 @@ async function handlePeerGoalFanout(parsed: any, ctx: any, runtime: any, peerId:
|
|
|
590
596
|
await recordPeerSendGoalFailure(root, goalLink, { targetPeerId: item.peerId, prompt: parsed.objective, claimedPaths: parsed.paths, error });
|
|
591
597
|
} else {
|
|
592
598
|
await appendPeerGoalEvent(root, parsed.goalId, {
|
|
593
|
-
type: "handoff",
|
|
594
|
-
peerId: item.peerId,
|
|
599
|
+
type: item.taskEventId ? "handoff" : "task",
|
|
600
|
+
peerId: item.taskEventId ? item.peerId : peerId,
|
|
595
601
|
summary: `Fan-out dispatch failed before claim: ${error?.message || String(error)}`,
|
|
596
602
|
paths: parsed.paths,
|
|
597
603
|
taskId: item.taskEventId,
|
|
598
604
|
status: "blocked",
|
|
605
|
+
lane: item.lane,
|
|
599
606
|
metadata: { fanout: true, targetPeerId: item.peerId },
|
|
600
607
|
}).catch(() => {});
|
|
601
608
|
}
|
package/package.json
CHANGED
package/src/peers/goal-board.mjs
CHANGED
|
@@ -9,6 +9,7 @@ const EVENT_TYPES = new Set(["finding", "task", "proposal", "claim", "release",
|
|
|
9
9
|
const BLOCKING_SEVERITIES = new Set(["blocking", "blocker", "critical"]);
|
|
10
10
|
const VOTE_VERDICTS = new Set(["pass", "fail", "pass-with-risks"]);
|
|
11
11
|
const DUPLICATE_POLICIES = new Set(["error", "reuse", "allow-parallel"]);
|
|
12
|
+
const ACTIVE_TASK_STATUSES = new Set(["queued", "dispatching", "planned", "running", "pending", "blocked"]);
|
|
12
13
|
const DEFAULT_GOAL_CLAIM_STALE_MS = 45 * 60 * 1000;
|
|
13
14
|
const SCOUT_LANES = Object.freeze({
|
|
14
15
|
blocker: { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator", "reviewer"], claimMode: "read", suggestedIntent: "review", rationale: "Blocking objections need a coordination/review lane before more work starts." },
|
|
@@ -249,6 +250,7 @@ export function deriveGoalState(goal, options = {}) {
|
|
|
249
250
|
const passingVotes = currentVotes.filter((vote) => vote.verdict === "pass" || vote.verdict === "pass-with-risks");
|
|
250
251
|
const activeWriteClaims = activeClaims.filter((claim) => claim.mode === "write");
|
|
251
252
|
const tasks = events.filter((event) => event.type === "task").map((event) => projectTaskSummary(event, events));
|
|
253
|
+
const activeTasks = tasks.filter(isActiveTaskSummary);
|
|
252
254
|
return {
|
|
253
255
|
...goal,
|
|
254
256
|
events,
|
|
@@ -265,7 +267,8 @@ export function deriveGoalState(goal, options = {}) {
|
|
|
265
267
|
failedVotes,
|
|
266
268
|
passingVotes,
|
|
267
269
|
tasks,
|
|
268
|
-
|
|
270
|
+
activeTasks,
|
|
271
|
+
readyToClose: goal?.status === "open" && blockingObjections.length === 0 && failedVotes.length === 0 && activeClaims.length === 0 && activeTasks.length === 0 && openProposals.length === 0 && passingVotes.length > 0,
|
|
269
272
|
};
|
|
270
273
|
}
|
|
271
274
|
|
|
@@ -295,6 +298,8 @@ export function formatPeerGoalScout(board, options = {}) {
|
|
|
295
298
|
lines.push(`- ${suggestion.priority} · ${suggestion.goalId} · ${suggestion.kind}: ${suggestion.summary}${laneText}${pathText}${keyText}`);
|
|
296
299
|
const claim = formatScoutClaimCommand(suggestion);
|
|
297
300
|
if (claim) lines.push(` claim: ${claim}`);
|
|
301
|
+
const resolve = formatScoutResolveCommand(suggestion);
|
|
302
|
+
if (resolve) lines.push(` resolve: ${resolve}`);
|
|
298
303
|
}
|
|
299
304
|
lines.push("", "Next: post one with `/peer goal propose <goal-id> <summary>` or claim the exact suggested lane with the printed `claim:` command/work key. Scout does not mutate the board.");
|
|
300
305
|
return lines.join("\n");
|
|
@@ -326,7 +331,20 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
|
|
|
326
331
|
push("P1", "stale-claim", `Ask owners to heartbeat or release ${state.staleClaims.length} stale claim${state.staleClaims.length === 1 ? "" : "s"}.`, { paths: uniqueEventPaths(state.staleClaims) });
|
|
327
332
|
}
|
|
328
333
|
if (state.openProposals.length) {
|
|
329
|
-
for (const proposal of state.openProposals.filter((item) => item.lane)) {
|
|
334
|
+
for (const proposal of state.openProposals.filter((item) => item.lane && proposalLaneWorkCompleted(state, goal.id, item))) {
|
|
335
|
+
const lane = normalizeLaneName(proposal.lane);
|
|
336
|
+
push("P1", "open-proposal", `Resolve fulfilled ${lane} proposal or record why it remains open: ${proposal.summary}`, {
|
|
337
|
+
paths: proposal.paths,
|
|
338
|
+
recommendedLane: "coordination",
|
|
339
|
+
preferredRoles: preferredRolesForLane("coordination"),
|
|
340
|
+
claimMode: "read",
|
|
341
|
+
suggestedIntent: "coordinate",
|
|
342
|
+
rationale: "A peer posted completion evidence and released the lane; flat hierarchy works best when any suitable peer resolves or explicitly defers fulfilled proposals.",
|
|
343
|
+
workKey: derivePeerGoalWorkKey({ goalId: goal.id, lane: "coordination", objective: `resolve fulfilled proposal ${proposal.id}`, mode: "read", paths: proposal.paths }),
|
|
344
|
+
relatedEventId: proposal.id,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
for (const proposal of state.openProposals.filter((item) => item.lane && !proposalLaneWorkCompleted(state, goal.id, item))) {
|
|
330
348
|
const lane = normalizeLaneName(proposal.lane);
|
|
331
349
|
const summary = `Self-select proposed ${lane} lane: ${proposal.summary}`;
|
|
332
350
|
push("P1", "open-proposal", summary, {
|
|
@@ -336,17 +354,22 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
|
|
|
336
354
|
claimMode: "read",
|
|
337
355
|
suggestedIntent: suggestedIntentForLane(lane),
|
|
338
356
|
rationale: "A peer proposed a lane; matching idle peers can claim or review it without planner assignment.",
|
|
339
|
-
workKey:
|
|
357
|
+
workKey: proposalLaneWorkKey(goal.id, lane, proposal),
|
|
340
358
|
relatedEventId: proposal.id,
|
|
341
359
|
});
|
|
342
360
|
}
|
|
343
|
-
|
|
361
|
+
if (shouldSuggestOpenProposalTriage(state, goal.id)) {
|
|
362
|
+
push("P1", "open-proposal", formatOpenProposalTriageSummary(state, goal.id), {
|
|
363
|
+
paths: uniqueEventPaths(state.openProposals),
|
|
364
|
+
workKey: derivePeerGoalWorkKey({ goalId: goal.id, lane: "coordination", objective: "triage open proposals", mode: "read" }),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
344
367
|
}
|
|
345
|
-
if (state.readyToClose) {
|
|
368
|
+
if (state.readyToClose && !state.openProposals.length) {
|
|
346
369
|
push("P1", "close", "Goal satisfies closure gates; close it or record a final note.");
|
|
347
370
|
continue;
|
|
348
371
|
}
|
|
349
|
-
if (!state.activeClaims.length && !state.tasks.length && !state.openProposals.length) {
|
|
372
|
+
if (!state.activeClaims.length && !state.staleClaims.length && !state.tasks.length && !state.openProposals.length) {
|
|
350
373
|
for (const lane of STARTUP_SCOUT_LANES) {
|
|
351
374
|
push("P2", "next-step", lane.summary, {
|
|
352
375
|
recommendedLane: lane.lane,
|
|
@@ -356,7 +379,7 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
|
|
|
356
379
|
rationale: "Multiple lane-specific suggestions let idle peers self-select complementary work and suppress duplicates by work key.",
|
|
357
380
|
});
|
|
358
381
|
}
|
|
359
|
-
} else if (!state.currentVotes.length && !state.activeWriteClaims.length) {
|
|
382
|
+
} else if (!state.currentVotes.length && !state.activeWriteClaims.length && !state.staleClaims.length && !state.openProposals.length) {
|
|
360
383
|
push("P2", "review", "No current peer vote; ask a peer for read-only review or record a pass/fail vote.");
|
|
361
384
|
}
|
|
362
385
|
}
|
|
@@ -452,10 +475,17 @@ function validateGoalReadyToClose(state) {
|
|
|
452
475
|
if (state.failedVotes.length) {
|
|
453
476
|
throw new Error(`peer goal ${state.id} has failed peer votes: ${state.failedVotes.map((item) => item.peerId || item.id).join(", ")}`);
|
|
454
477
|
}
|
|
455
|
-
if (state.
|
|
456
|
-
throw new Error(`peer goal ${state.id} has active
|
|
478
|
+
if (state.activeClaims.length) {
|
|
479
|
+
throw new Error(`peer goal ${state.id} has active claims: ${state.activeClaims.map((item) => item.id).join(", ")}`);
|
|
480
|
+
}
|
|
481
|
+
if (state.activeTasks?.length) {
|
|
482
|
+
throw new Error(`peer goal ${state.id} has active tasks: ${state.activeTasks.map((item) => item.taskId || item.id).join(", ")}`);
|
|
457
483
|
}
|
|
458
|
-
if (
|
|
484
|
+
if (state.openProposals.length) {
|
|
485
|
+
throw new Error(`peer goal ${state.id} has unresolved open proposals: ${state.openProposals.map((item) => item.id).join(", ")}`);
|
|
486
|
+
}
|
|
487
|
+
if (!state.passingVotes.length) throw new Error(`peer goal ${state.id} is not ready to close; record at least one passing vote or use --force`);
|
|
488
|
+
if (!state.readyToClose) throw new Error(`peer goal ${state.id} is not ready to close; use --force to override readiness gates`);
|
|
459
489
|
}
|
|
460
490
|
|
|
461
491
|
function normalizeEvent(input = {}) {
|
|
@@ -507,10 +537,9 @@ function taskSummary(input = {}) {
|
|
|
507
537
|
|
|
508
538
|
function latestTaskForWorkKey(tasks = [], workKey) {
|
|
509
539
|
if (!workKey) return undefined;
|
|
510
|
-
const activeStatuses = new Set(["queued", "dispatching", "running", "pending"]);
|
|
511
540
|
return [...tasks]
|
|
512
541
|
.reverse()
|
|
513
|
-
.find((task) => task.workKey === workKey && (
|
|
542
|
+
.find((task) => task.workKey === workKey && isActiveTaskSummary(task));
|
|
514
543
|
}
|
|
515
544
|
|
|
516
545
|
function normalizeBoard(board = {}) {
|
|
@@ -577,6 +606,10 @@ function projectTaskSummary(task, events) {
|
|
|
577
606
|
});
|
|
578
607
|
}
|
|
579
608
|
|
|
609
|
+
function isActiveTaskSummary(task = {}) {
|
|
610
|
+
return !task.status || ACTIVE_TASK_STATUSES.has(String(task.status).toLowerCase());
|
|
611
|
+
}
|
|
612
|
+
|
|
580
613
|
function taskMatchesHandoff(task, event, events) {
|
|
581
614
|
if (event.type !== "handoff" || event.at < task.at) return false;
|
|
582
615
|
if (task.taskId && event.taskId === task.taskId) return true;
|
|
@@ -666,6 +699,11 @@ function formatScoutClaimCommand(suggestion = {}) {
|
|
|
666
699
|
return `/peer goal claim ${shellQuote(suggestion.goalId)} ${shellQuote(suggestion.summary)} --mode read${lane} --key ${shellQuote(suggestion.workKey)}${paths}`;
|
|
667
700
|
}
|
|
668
701
|
|
|
702
|
+
function formatScoutResolveCommand(suggestion = {}) {
|
|
703
|
+
if (suggestion.kind !== "open-proposal" || !suggestion.relatedEventId || !String(suggestion.summary || "").startsWith("Resolve fulfilled")) return "";
|
|
704
|
+
return `/peer goal resolve ${shellQuote(suggestion.goalId)} ${shellQuote(suggestion.relatedEventId)} ${shellQuote("fulfilled lane complete")}`;
|
|
705
|
+
}
|
|
706
|
+
|
|
669
707
|
function shellQuote(value) {
|
|
670
708
|
const text = String(value || "");
|
|
671
709
|
if (/^[A-Za-z0-9_./:-]+$/.test(text)) return text;
|
|
@@ -698,6 +736,49 @@ function hasActiveClaimForScoutSuggestion(state, suggestion) {
|
|
|
698
736
|
return state.activeClaims.some((claim) => claim.workKey === suggestion.workKey);
|
|
699
737
|
}
|
|
700
738
|
|
|
739
|
+
function proposalLaneWorkCompleted(state, goalId, proposal) {
|
|
740
|
+
const lane = normalizeLaneName(proposal?.lane);
|
|
741
|
+
const workKey = proposalLaneWorkKey(goalId, lane, proposal);
|
|
742
|
+
if (!workKey) return false;
|
|
743
|
+
const proposalAt = String(proposal.at || "");
|
|
744
|
+
const releasedClaim = state.releasedClaims.some((claim) => claim.workKey === workKey && String(claim.at || "") >= proposalAt);
|
|
745
|
+
if (!releasedClaim) return false;
|
|
746
|
+
return state.events.some((event) => ["finding", "handoff", "note"].includes(event.type) && event.workKey === workKey && String(event.at || "") >= proposalAt);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function shouldSuggestOpenProposalTriage(state, goalId) {
|
|
750
|
+
const counts = openProposalActionabilityCounts(state, goalId);
|
|
751
|
+
return counts.unclaimed > 0 || counts.fulfilled > 0;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function formatOpenProposalTriageSummary(state, goalId) {
|
|
755
|
+
const { total, unclaimed: actionable, owned, fulfilled } = openProposalActionabilityCounts(state, goalId);
|
|
756
|
+
const detail = [];
|
|
757
|
+
if (actionable !== total) detail.push(`${actionable} unclaimed actionable`);
|
|
758
|
+
if (owned) detail.push(`${owned} active-owned`);
|
|
759
|
+
if (fulfilled) detail.push(`${fulfilled} fulfilled awaiting resolve/defer`);
|
|
760
|
+
const suffix = detail.length ? ` (${detail.join("; ")})` : "";
|
|
761
|
+
return `Triage ${total} open proposal${total === 1 ? "" : "s"}${suffix}; claim one, resolve fulfilled work, or defer obsolete/ambiguous items.`;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function openProposalActionabilityCounts(state, goalId) {
|
|
765
|
+
const counts = { total: state.openProposals.length, unclaimed: 0, owned: 0, fulfilled: 0 };
|
|
766
|
+
for (const proposal of state.openProposals) counts[proposalLaneActionability(state, goalId, proposal)] += 1;
|
|
767
|
+
return counts;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function proposalLaneActionability(state, goalId, proposal) {
|
|
771
|
+
const lane = normalizeLaneName(proposal?.lane);
|
|
772
|
+
const workKey = proposalLaneWorkKey(goalId, lane, proposal);
|
|
773
|
+
if (proposalLaneWorkCompleted(state, goalId, proposal)) return "fulfilled";
|
|
774
|
+
if (workKey && state.activeClaims.some((claim) => claim.workKey === workKey)) return "owned";
|
|
775
|
+
return "unclaimed";
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function proposalLaneWorkKey(goalId, lane, proposal = {}) {
|
|
779
|
+
return proposal.workKey || derivePeerGoalWorkKey({ goalId, lane, objective: proposal.summary, mode: "read", paths: proposal.paths });
|
|
780
|
+
}
|
|
781
|
+
|
|
701
782
|
async function updatePeerGoalBoard(root, updater) {
|
|
702
783
|
const path = goalBoardPath(root);
|
|
703
784
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { derivePeerGoalScoutSuggestions, loadPeerGoalBoard } from "./goal-board.mjs";
|
|
1
|
+
import { deriveGoalState, derivePeerGoalScoutSuggestions, loadPeerGoalBoard } from "./goal-board.mjs";
|
|
2
2
|
|
|
3
3
|
export const DEFAULT_PEER_IDLE_WATCHER_INTERVAL_MS = 15_000;
|
|
4
4
|
export const DEFAULT_PEER_IDLE_WATCHER_COOLDOWN_MS = 5 * 60 * 1000;
|
|
@@ -35,6 +35,7 @@ export function createPeerIdleWatcher(options = {}) {
|
|
|
35
35
|
timer: undefined,
|
|
36
36
|
activationCount: 0,
|
|
37
37
|
lastActivationAtByKey: new Map(),
|
|
38
|
+
lastActivationByGoal: new Map(),
|
|
38
39
|
checking: false,
|
|
39
40
|
};
|
|
40
41
|
|
|
@@ -119,6 +120,7 @@ export function derivePeerIdleActivation(board, options = {}) {
|
|
|
119
120
|
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
|
120
121
|
for (const suggestion of suggestions) {
|
|
121
122
|
if (!allowedKinds.has(suggestion.kind)) continue;
|
|
123
|
+
if (localPeerHasActiveGoalWork(board, suggestion.goalId, options.localPeerId)) continue;
|
|
122
124
|
const activation = normalizeActivation(suggestion, options.localPeerId, options);
|
|
123
125
|
if (!activation || !activationFitsPeer(activation, options)) continue;
|
|
124
126
|
if (isActivationCoolingDown(options.state, activation, config, nowMs)) continue;
|
|
@@ -131,7 +133,9 @@ export function markPeerIdleActivation(state, activation, nowMs = Date.now()) {
|
|
|
131
133
|
if (!state || !activation) return false;
|
|
132
134
|
state.activationCount = (state.activationCount || 0) + 1;
|
|
133
135
|
if (!state.lastActivationAtByKey) state.lastActivationAtByKey = new Map();
|
|
136
|
+
if (!state.lastActivationByGoal) state.lastActivationByGoal = new Map();
|
|
134
137
|
state.lastActivationAtByKey.set(peerIdleActivationKey(activation), nowMs);
|
|
138
|
+
state.lastActivationByGoal.set(activation.goalId, { at: nowMs, priority: activation.priority || "P2" });
|
|
135
139
|
return true;
|
|
136
140
|
}
|
|
137
141
|
|
|
@@ -164,8 +168,18 @@ export function peerIdleActivationKey(activation = {}) {
|
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
function isActivationCoolingDown(state, activation, config, nowMs) {
|
|
167
|
-
const
|
|
168
|
-
|
|
171
|
+
const exactLast = state?.lastActivationAtByKey?.get?.(peerIdleActivationKey(activation));
|
|
172
|
+
if (Number.isFinite(exactLast) && nowMs - exactLast < config.cooldownMs) return true;
|
|
173
|
+
const goalLast = state?.lastActivationByGoal?.get?.(activation.goalId);
|
|
174
|
+
if (!goalLast || !Number.isFinite(goalLast.at) || nowMs - goalLast.at >= config.cooldownMs) return false;
|
|
175
|
+
return priorityRank(activation.priority) >= priorityRank(goalLast.priority);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function priorityRank(priority) {
|
|
179
|
+
const normalized = String(priority || "P2").toUpperCase();
|
|
180
|
+
if (normalized === "P0") return 0;
|
|
181
|
+
if (normalized === "P1") return 1;
|
|
182
|
+
return 2;
|
|
169
183
|
}
|
|
170
184
|
|
|
171
185
|
function normalizeActivation(suggestion = {}, localPeerId, options = {}) {
|
|
@@ -197,6 +211,15 @@ function activationFitsPeer(activation = {}, options = {}) {
|
|
|
197
211
|
return fit.matched.length > 0;
|
|
198
212
|
}
|
|
199
213
|
|
|
214
|
+
function localPeerHasActiveGoalWork(board = {}, goalId, localPeerId) {
|
|
215
|
+
const peerId = cleanString(localPeerId);
|
|
216
|
+
if (!peerId || !goalId) return false;
|
|
217
|
+
const goal = board?.goals?.[goalId];
|
|
218
|
+
if (!goal) return false;
|
|
219
|
+
const state = deriveGoalState(goal);
|
|
220
|
+
return state.activeClaims.some((claim) => claim.peerId === peerId);
|
|
221
|
+
}
|
|
222
|
+
|
|
200
223
|
function peerPersonaFit(suggestion = {}, options = {}) {
|
|
201
224
|
const preferredRoles = normalizeStringList(suggestion.preferredRoles);
|
|
202
225
|
const localTerms = peerProfileTerms(options);
|