@cryptolibertus/pi-peer 0.3.5 → 0.3.7

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
@@ -17,8 +17,10 @@ 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
23
+ - `pi-peer-publish` skill for safe npm release checks, version bumping, tag push, publish, and verification
22
24
 
23
25
  ## Setup and health checks
24
26
 
@@ -30,7 +32,7 @@ pi install ./packages/pi-peer
30
32
  /peer cancel <message-id> "superseded"
31
33
  ```
32
34
 
33
- `/peer setup` is a guided alias for `/peer init`: it creates `.pi/peers.json` only when the file is missing, seeds protocol/capability/trust manifest metadata, and never overwrites an existing config. `/peer doctor` is read-only and checks enablement, local identity, advertised endpoint, protocol compatibility, discovered peers, warnings, and resumable disconnected tasks. `/peer reconnect` refreshes local discovery after starting or restarting another Pi session. Discovery is repo/project scoped: sessions under the same `.git` root see each other, while sessions in other repos are ignored. Outside git, the resolved cwd is used as the scope. `/peer resume` re-dispatches a disconnected message restored from `.pi/peer-messages.json`; `/peer cancel` records a local cancellation so stale work is no longer treated as active.
35
+ `/peer setup` is a guided alias for `/peer init`: it creates `.pi/peers.json` only when the file is missing, seeds protocol/capability/trust manifest metadata, and never overwrites an existing config. `/peer doctor` is read-only and checks enablement, local identity, advertised endpoint, protocol compatibility, discovered peers, warnings, and resumable disconnected tasks. `/peer reconnect` refreshes local discovery after starting or restarting another Pi session. Discovery is repo/project scoped: sessions under the same `.git` root see each other, while sessions in other repos are ignored. Outside git, the resolved cwd is used as the scope. `/peer resume` re-dispatches a disconnected message restored from `.pi/peer-messages.json`; `/peer cancel` records a local cancellation so stale work is no longer treated as active. List-style flags such as `--peer`, `--path`, and `--claim` accept comma-separated values or repeated flags.
34
36
 
35
37
  ## Flat goal board
36
38
 
@@ -56,7 +58,7 @@ Useful commands (long form and short aliases):
56
58
 
57
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.
58
60
 
59
- 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. `/peer goal fanout` turns a goal into role-specific peer lanes, while `peer_progress` reports checkpoints from an inbound long-running task. Scout suggestions are persona-aware: they surface a recommended lane (`research`, `review`, `coordination`, etc.), preferred roles, a safe default claim mode, and rationale. 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`.
60
62
 
61
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.
62
64
 
@@ -65,7 +67,7 @@ Normal goal closure requires at least one current passing vote, no current faile
65
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:
66
68
 
67
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.
68
- 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.
69
71
 
70
72
  Configuration can be placed in `.pi/peers.json` as `idleWatcher` or in `.pi/settings.json` under `peerMessaging.idleWatcher`:
71
73
 
@@ -93,7 +95,9 @@ npm run check
93
95
 
94
96
  ## Publish to npm
95
97
 
96
- Use this release workflow after landing package changes on `main`:
98
+ This package includes a `pi-peer-publish` Pi skill. Ask Pi to use it, or run `/skill:pi-peer-publish`, when you want an agent-guided release with safety checks, version bumping, tag push, publish, and npm verification.
99
+
100
+ Use this manual release workflow after landing package changes on `main`:
97
101
 
98
102
  ```bash
99
103
  # Keep local peer runtime state out of release commits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptolibertus/pi-peer",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
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",
@@ -13,6 +13,7 @@
13
13
  ],
14
14
  "files": [
15
15
  "extensions",
16
+ "skills",
16
17
  "src",
17
18
  "README.md",
18
19
  "LICENSE"
@@ -32,6 +33,9 @@
32
33
  "pi": {
33
34
  "extensions": [
34
35
  "extensions/pi-peer/index.ts"
36
+ ],
37
+ "skills": [
38
+ "skills"
35
39
  ]
36
40
  },
37
41
  "engines": {
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: pi-peer-publish
3
+ description: Publish @cryptolibertus/pi-peer to npm safely. Use when the user asks to publish, release, npm publish, bump the package version, or verify/package/push an npm release for this pi-peer package.
4
+ ---
5
+
6
+ # Pi peer publish
7
+
8
+ This skill publishes `@cryptolibertus/pi-peer` from the repository root.
9
+
10
+ ## Safety rules
11
+
12
+ - Never publish with uncommitted source changes unless the user explicitly asks to include them and they have been committed first.
13
+ - Never commit `.pi/`, local peer runtime state, npm debug logs, or generated tarballs.
14
+ - Stop before `npm publish` unless the user has explicitly asked to publish now in the current conversation. If they only asked to prepare a release, stop after the dry run and show the exact publish command.
15
+ - If `npm whoami` fails, stop and ask the user to authenticate with `npm login` or configure an automation token outside the chat.
16
+ - For scoped packages, always publish with `--access public` unless `package.json` says otherwise.
17
+
18
+ ## Workflow
19
+
20
+ 1. Inspect release state:
21
+ ```bash
22
+ git status --short --branch
23
+ git remote get-url origin
24
+ git branch --show-current
25
+ npm whoami
26
+ npm view @cryptolibertus/pi-peer version --json
27
+ node -p "require('./package.json').version"
28
+ ```
29
+
30
+ 2. Ensure local peer state will not be committed:
31
+ ```bash
32
+ grep -qxF '.pi/' .git/info/exclude 2>/dev/null || echo '.pi/' >> .git/info/exclude
33
+ ```
34
+
35
+ 3. Verify package quality and tarball contents:
36
+ ```bash
37
+ npm run check
38
+ npm pack --dry-run
39
+ ```
40
+
41
+ 4. Choose the version bump:
42
+ - Default to `patch` for small fixes and docs.
43
+ - Use `minor` only for new user-facing capabilities.
44
+ - Use `major` only for breaking changes, and ask the user first.
45
+
46
+ 5. Bump, commit, and tag using npm so `package.json`, `package-lock.json` if present, and the git tag stay consistent:
47
+ ```bash
48
+ npm version patch
49
+ ```
50
+
51
+ 6. Push the release commit and tag:
52
+ ```bash
53
+ git push origin HEAD --follow-tags
54
+ ```
55
+
56
+ 7. Publish:
57
+ ```bash
58
+ npm publish --access public
59
+ ```
60
+
61
+ 8. Verify the published package:
62
+ ```bash
63
+ npm view @cryptolibertus/pi-peer version
64
+ npm view @cryptolibertus/pi-peer dist-tags --json
65
+ ```
66
+
67
+ ## Failure handling
68
+
69
+ - If `npm version` fails because the working tree is dirty, inspect `git status --short`; commit intended changes or revert accidental/generated files before retrying.
70
+ - If the tag already exists locally or remotely, compare the package version with `npm view`. Do not force-push tags. Pick the next valid version instead.
71
+ - If `npm publish` says the version already exists, do not retry the same version. Verify with `npm view`, then bump to the next patch version if the user still wants to publish.
72
+ - If tests fail, stop and fix the failure before bumping or publishing.
73
+
74
+ ## Final response
75
+
76
+ Report:
77
+
78
+ - Package name and version published
79
+ - Commit hash and tag pushed
80
+ - Verification commands with exit status
81
+ - npm package URL
82
+ - Any skipped step or blocker
@@ -251,11 +251,13 @@ function parsePeerGoalCommand(parsed, flags, positionals) {
251
251
  }
252
252
 
253
253
  function stringFlag(value, fallback) {
254
+ if (Array.isArray(value)) return stringFlag(value.at(-1), fallback);
254
255
  if (typeof value === "string" && value.trim()) return value.trim();
255
256
  return fallback;
256
257
  }
257
258
 
258
259
  function positiveIntegerFlag(value) {
260
+ if (Array.isArray(value)) return positiveIntegerFlag(value.at(-1));
259
261
  if (value === undefined || value === true) return undefined;
260
262
  const number = Number(value);
261
263
  return Number.isInteger(number) && number > 0 ? number : undefined;
@@ -288,7 +290,9 @@ function capabilitiesFromFlags(flags = {}) {
288
290
  }
289
291
 
290
292
  function listFlag(value) {
291
- if (Array.isArray(value)) return [...new Set(value.map((item) => String(item).trim()).filter(Boolean))];
292
- if (typeof value !== "string" || !value.trim()) return [];
293
- return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
293
+ const values = Array.isArray(value) ? value : [value];
294
+ return [...new Set(values.flatMap((item) => {
295
+ if (typeof item !== "string") return [];
296
+ return item.split(",").map((part) => part.trim()).filter(Boolean);
297
+ }))];
294
298
  }
@@ -9,6 +9,15 @@ 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 DEFAULT_GOAL_CLAIM_STALE_MS = 45 * 60 * 1000;
12
+ const SCOUT_LANES = Object.freeze({
13
+ blocker: { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator", "reviewer"], claimMode: "read", suggestedIntent: "review", rationale: "Blocking objections need a coordination/review lane before more work starts." },
14
+ "failed-vote": { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator", "reviewer"], claimMode: "read", suggestedIntent: "review", rationale: "Failed votes need triage before new implementation work." },
15
+ "stale-claim": { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator"], claimMode: "read", suggestedIntent: "coordinate", rationale: "Stale claims need owner follow-up or release, not duplicate writes." },
16
+ "open-proposal": { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator", "reviewer"], claimMode: "read", suggestedIntent: "review", rationale: "Open proposals need triage into accept, defer, or resolve decisions." },
17
+ close: { recommendedLane: "coordination", preferredRoles: ["planner", "coordinator", "reviewer"], claimMode: "read", suggestedIntent: "coordinate", rationale: "Ready goals need final closure checks and a concise handoff." },
18
+ "next-step": { recommendedLane: "research", preferredRoles: ["researcher", "reviewer", "planner", "coordinator", "worker"], claimMode: "read", suggestedIntent: "review", rationale: "Empty goals benefit from a read-only lane before write claims." },
19
+ review: { recommendedLane: "review", preferredRoles: ["reviewer", "qa", "coordinator", "planner"], claimMode: "read", suggestedIntent: "review", rationale: "Goals without current votes need read-only validation before closure." },
20
+ });
12
21
  const GOAL_BOARD_LOCK_STALE_MS = 30_000;
13
22
  const GOAL_BOARD_LOCK_RETRY_MS = 10;
14
23
  const GOAL_BOARD_LOCK_TIMEOUT_MS = 5_000;
@@ -222,7 +231,8 @@ export function formatPeerGoalScout(board, options = {}) {
222
231
  const lines = ["# Peer Scout", "", "Proactive suggestions (read-only):"];
223
232
  for (const suggestion of suggestions.slice(0, limit)) {
224
233
  const pathText = suggestion.paths?.length ? ` · paths: ${suggestion.paths.join(", ")}` : "";
225
- lines.push(`- ${suggestion.priority} · ${suggestion.goalId} · ${suggestion.kind}: ${suggestion.summary}${pathText}`);
234
+ const laneText = suggestion.recommendedLane ? ` · lane: ${suggestion.recommendedLane}${suggestion.preferredRoles?.length ? ` for ${suggestion.preferredRoles.join("/")}` : ""}${suggestion.claimMode ? ` (${suggestion.claimMode})` : ""}` : "";
235
+ lines.push(`- ${suggestion.priority} · ${suggestion.goalId} · ${suggestion.kind}: ${suggestion.summary}${laneText}${pathText}`);
226
236
  }
227
237
  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
238
  return lines.join("\n");
@@ -238,7 +248,7 @@ export function derivePeerGoalScoutSuggestions(board, options = {}) {
238
248
  const suggestions = [];
239
249
  for (const goal of goals) {
240
250
  const state = deriveGoalState(goal);
241
- const push = (priority, kind, summary, extra = {}) => suggestions.push(stripEmpty({ goalId: goal.id, priority, kind, summary, ...extra }));
251
+ const push = (priority, kind, summary, extra = {}) => suggestions.push(enrichScoutSuggestion({ goalId: goal.id, priority, kind, summary, ...extra }));
242
252
  if (state.blockingObjections.length) {
243
253
  push("P0", "blocker", `Resolve ${state.blockingObjections.length} blocking objection${state.blockingObjections.length === 1 ? "" : "s"} before more work.`, { paths: uniqueEventPaths(state.blockingObjections) });
244
254
  continue;
@@ -483,6 +493,19 @@ function uniqueEventPaths(events) {
483
493
  return [...new Set(events.flatMap((event) => Array.isArray(event.paths) ? event.paths : []))];
484
494
  }
485
495
 
496
+ function enrichScoutSuggestion(suggestion = {}) {
497
+ const lane = SCOUT_LANES[suggestion.kind] || {};
498
+ return stripEmpty({
499
+ ...suggestion,
500
+ recommendedLane: suggestion.recommendedLane || lane.recommendedLane,
501
+ preferredRoles: normalizeList(suggestion.preferredRoles || lane.preferredRoles),
502
+ preferredCapabilities: normalizeList(suggestion.preferredCapabilities || lane.preferredCapabilities),
503
+ claimMode: cleanText(suggestion.claimMode || lane.claimMode),
504
+ suggestedIntent: cleanText(suggestion.suggestedIntent || lane.suggestedIntent),
505
+ rationale: cleanText(suggestion.rationale || lane.rationale),
506
+ });
507
+ }
508
+
486
509
  async function updatePeerGoalBoard(root, updater) {
487
510
  const path = goalBoardPath(root);
488
511
  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
- 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}${paths}\n\nInstructions:\n- First inspect current state with peer_get id '${activation.goalId}'.\n- If useful, take one small safe action: 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.`;
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" };
@@ -133,20 +133,44 @@ function capabilitySummary(capabilities = {}) {
133
133
  }
134
134
 
135
135
  export function deriveFanoutSuggestion(peers = [], pendingMessages = []) {
136
- const availablePeers = peers
136
+ const availablePeerDetails = peers
137
137
  .filter((peer) => !peer.current && !peer.self && peer.trust !== "disabled")
138
- .map((peer) => peer.peerId)
139
- .filter(Boolean);
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
  }
package/src/utils.mjs CHANGED
@@ -58,21 +58,22 @@ export function parseFlags(args) {
58
58
  const [rawKey, rawValue] = arg.slice(2).split(/=(.*)/s, 2);
59
59
  const key = rawKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
60
60
  if (rawValue !== undefined) {
61
- flags[key] = rawValue;
61
+ appendFlagValue(flags, key, rawValue);
62
62
  continue;
63
63
  }
64
64
  const next = args[i + 1];
65
65
  if (next !== undefined && !next.startsWith("--")) {
66
- flags[key] = next;
66
+ appendFlagValue(flags, key, next);
67
67
  i += 1;
68
68
  } else {
69
- flags[key] = true;
69
+ appendFlagValue(flags, key, true);
70
70
  }
71
71
  }
72
72
  return { flags, positionals };
73
73
  }
74
74
 
75
75
  export function flagEnabled(value) {
76
+ if (Array.isArray(value)) return flagEnabled(value.at(-1));
76
77
  if (value === true) return true;
77
78
  if (typeof value === "number") return Number.isFinite(value) && value !== 0;
78
79
  if (typeof value === "string") {
@@ -81,3 +82,11 @@ export function flagEnabled(value) {
81
82
  }
82
83
  return false;
83
84
  }
85
+
86
+ function appendFlagValue(flags, key, value) {
87
+ if (Object.prototype.hasOwnProperty.call(flags, key)) {
88
+ flags[key] = Array.isArray(flags[key]) ? [...flags[key], value] : [flags[key], value];
89
+ } else {
90
+ flags[key] = value;
91
+ }
92
+ }