@cryptolibertus/pi-peer 0.5.1 → 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 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 `peer_progress` reports checkpoints from an inbound long-running task. Scout suggestions are persona-aware: they surface a recommended lane (`research`, `review`, `implementation`, `coordination`, etc.), preferred roles, a safe default claim mode, a stable work key, rationale, and suppress suggestions already covered by active work keys. `/peer scout` prints the exact work key and a copyable `claim:` command for each read-only suggestion, so idle peers can claim the same semantic lane the scout is using instead of inventing generic seed keys. 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. 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`.
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, no active write claims, 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 write 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.
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 summary = `${parsed.objective} [fanout:${targetPeerId}]`;
535
- const task = await appendPeerGoalEvent(root, parsed.goalId, {
536
- type: "task",
537
- peerId,
538
- summary,
539
- paths: parsed.paths,
540
- status: parsed.send ? "dispatching" : "planned",
541
- metadata: { targetPeerId, fanout: true, claimMode: mode, workLane: lane },
542
- });
543
- planned.push({ peerId: targetPeerId, taskEventId: task.event.id, mode, lane });
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
- 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(() => {});
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptolibertus/pi-peer",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Pi package for local Pi-to-Pi peer messaging, slash commands, tools, and runtime transport.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- readyToClose: goal?.status === "open" && blockingObjections.length === 0 && failedVotes.length === 0 && activeWriteClaims.length === 0 && openProposals.length === 0 && passingVotes.length > 0,
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");
@@ -353,16 +358,18 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
353
358
  relatedEventId: proposal.id,
354
359
  });
355
360
  }
356
- push("P1", "open-proposal", formatOpenProposalTriageSummary(state, goal.id), {
357
- paths: uniqueEventPaths(state.openProposals),
358
- workKey: derivePeerGoalWorkKey({ goalId: goal.id, lane: "coordination", objective: "triage open proposals", mode: "read" }),
359
- });
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
+ }
360
367
  }
361
368
  if (state.readyToClose && !state.openProposals.length) {
362
369
  push("P1", "close", "Goal satisfies closure gates; close it or record a final note.");
363
370
  continue;
364
371
  }
365
- if (!state.activeClaims.length && !state.tasks.length && !state.openProposals.length) {
372
+ if (!state.activeClaims.length && !state.staleClaims.length && !state.tasks.length && !state.openProposals.length) {
366
373
  for (const lane of STARTUP_SCOUT_LANES) {
367
374
  push("P2", "next-step", lane.summary, {
368
375
  recommendedLane: lane.lane,
@@ -372,7 +379,7 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
372
379
  rationale: "Multiple lane-specific suggestions let idle peers self-select complementary work and suppress duplicates by work key.",
373
380
  });
374
381
  }
375
- } else if (!state.currentVotes.length && !state.activeWriteClaims.length && !state.openProposals.length) {
382
+ } else if (!state.currentVotes.length && !state.activeWriteClaims.length && !state.staleClaims.length && !state.openProposals.length) {
376
383
  push("P2", "review", "No current peer vote; ask a peer for read-only review or record a pass/fail vote.");
377
384
  }
378
385
  }
@@ -468,10 +475,17 @@ function validateGoalReadyToClose(state) {
468
475
  if (state.failedVotes.length) {
469
476
  throw new Error(`peer goal ${state.id} has failed peer votes: ${state.failedVotes.map((item) => item.peerId || item.id).join(", ")}`);
470
477
  }
471
- if (state.activeWriteClaims.length) {
472
- throw new Error(`peer goal ${state.id} has active write claims: ${state.activeWriteClaims.map((item) => item.id).join(", ")}`);
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(", ")}`);
473
483
  }
474
- if (!state.readyToClose) throw new Error(`peer goal ${state.id} is not ready to close; record at least one passing vote or use --force`);
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`);
475
489
  }
476
490
 
477
491
  function normalizeEvent(input = {}) {
@@ -523,10 +537,9 @@ function taskSummary(input = {}) {
523
537
 
524
538
  function latestTaskForWorkKey(tasks = [], workKey) {
525
539
  if (!workKey) return undefined;
526
- const activeStatuses = new Set(["queued", "dispatching", "running", "pending"]);
527
540
  return [...tasks]
528
541
  .reverse()
529
- .find((task) => task.workKey === workKey && (!task.status || activeStatuses.has(String(task.status).toLowerCase())));
542
+ .find((task) => task.workKey === workKey && isActiveTaskSummary(task));
530
543
  }
531
544
 
532
545
  function normalizeBoard(board = {}) {
@@ -593,6 +606,10 @@ function projectTaskSummary(task, events) {
593
606
  });
594
607
  }
595
608
 
609
+ function isActiveTaskSummary(task = {}) {
610
+ return !task.status || ACTIVE_TASK_STATUSES.has(String(task.status).toLowerCase());
611
+ }
612
+
596
613
  function taskMatchesHandoff(task, event, events) {
597
614
  if (event.type !== "handoff" || event.at < task.at) return false;
598
615
  if (task.taskId && event.taskId === task.taskId) return true;
@@ -682,6 +699,11 @@ function formatScoutClaimCommand(suggestion = {}) {
682
699
  return `/peer goal claim ${shellQuote(suggestion.goalId)} ${shellQuote(suggestion.summary)} --mode read${lane} --key ${shellQuote(suggestion.workKey)}${paths}`;
683
700
  }
684
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
+
685
707
  function shellQuote(value) {
686
708
  const text = String(value || "");
687
709
  if (/^[A-Za-z0-9_./:-]+$/.test(text)) return text;
@@ -724,11 +746,13 @@ function proposalLaneWorkCompleted(state, goalId, proposal) {
724
746
  return state.events.some((event) => ["finding", "handoff", "note"].includes(event.type) && event.workKey === workKey && String(event.at || "") >= proposalAt);
725
747
  }
726
748
 
749
+ function shouldSuggestOpenProposalTriage(state, goalId) {
750
+ const counts = openProposalActionabilityCounts(state, goalId);
751
+ return counts.unclaimed > 0 || counts.fulfilled > 0;
752
+ }
753
+
727
754
  function formatOpenProposalTriageSummary(state, goalId) {
728
- const total = state.openProposals.length;
729
- const actionable = state.openProposals.filter((proposal) => proposalLaneActionability(state, goalId, proposal) === "unclaimed").length;
730
- const owned = state.openProposals.filter((proposal) => proposalLaneActionability(state, goalId, proposal) === "owned").length;
731
- const fulfilled = state.openProposals.filter((proposal) => proposalLaneActionability(state, goalId, proposal) === "fulfilled").length;
755
+ const { total, unclaimed: actionable, owned, fulfilled } = openProposalActionabilityCounts(state, goalId);
732
756
  const detail = [];
733
757
  if (actionable !== total) detail.push(`${actionable} unclaimed actionable`);
734
758
  if (owned) detail.push(`${owned} active-owned`);
@@ -737,11 +761,17 @@ function formatOpenProposalTriageSummary(state, goalId) {
737
761
  return `Triage ${total} open proposal${total === 1 ? "" : "s"}${suffix}; claim one, resolve fulfilled work, or defer obsolete/ambiguous items.`;
738
762
  }
739
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
+
740
770
  function proposalLaneActionability(state, goalId, proposal) {
741
771
  const lane = normalizeLaneName(proposal?.lane);
742
772
  const workKey = proposalLaneWorkKey(goalId, lane, proposal);
743
- if (workKey && state.activeClaims.some((claim) => claim.workKey === workKey)) return "owned";
744
773
  if (proposalLaneWorkCompleted(state, goalId, proposal)) return "fulfilled";
774
+ if (workKey && state.activeClaims.some((claim) => claim.workKey === workKey)) return "owned";
745
775
  return "unclaimed";
746
776
  }
747
777
 
@@ -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
 
@@ -132,7 +133,9 @@ export function markPeerIdleActivation(state, activation, nowMs = Date.now()) {
132
133
  if (!state || !activation) return false;
133
134
  state.activationCount = (state.activationCount || 0) + 1;
134
135
  if (!state.lastActivationAtByKey) state.lastActivationAtByKey = new Map();
136
+ if (!state.lastActivationByGoal) state.lastActivationByGoal = new Map();
135
137
  state.lastActivationAtByKey.set(peerIdleActivationKey(activation), nowMs);
138
+ state.lastActivationByGoal.set(activation.goalId, { at: nowMs, priority: activation.priority || "P2" });
136
139
  return true;
137
140
  }
138
141
 
@@ -165,8 +168,18 @@ export function peerIdleActivationKey(activation = {}) {
165
168
  }
166
169
 
167
170
  function isActivationCoolingDown(state, activation, config, nowMs) {
168
- const last = state?.lastActivationAtByKey?.get?.(peerIdleActivationKey(activation));
169
- return Number.isFinite(last) && nowMs - last < config.cooldownMs;
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;
170
183
  }
171
184
 
172
185
  function normalizeActivation(suggestion = {}, localPeerId, options = {}) {