@cryptolibertus/pi-peer 0.3.2 → 0.3.3

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
@@ -12,7 +12,7 @@ pi install ./packages/pi-peer
12
12
 
13
13
  ## What it adds
14
14
 
15
- - `/peer help|setup|doctor|status|list|init|reconnect|resume|cancel|send|get|await|goal`
15
+ - `/peer help|setup|doctor|status|list|init|reconnect|resume|cancel|send|get|await|goal|scout`
16
16
  - `peer_list`, `peer_send`, `peer_get`, `peer_await`, and `peer_progress` tools
17
17
  - Local peer discovery and transport using project `.pi/peers.json`
18
18
  - Protocol compatibility metadata (`protocolVersion`, min/max compatible versions), peer manifests, capabilities, and trust summaries in descriptors/status/list output
@@ -32,7 +32,7 @@ pi install ./packages/pi-peer
32
32
 
33
33
  ## Flat goal board
34
34
 
35
- `/peer goal` provides a local blackboard for flat peer collaboration. Peers can create a shared goal, post findings/tasks/handoffs, claim read or write leases, object, resolve objections, and vote without a planner assigning every step.
35
+ `/peer goal` provides a local blackboard for flat peer collaboration. Peers can create a shared goal, post findings/tasks/proposals/handoffs, claim read or write leases, object, resolve objections, scout for proactive next steps, and vote without a planner assigning every step.
36
36
 
37
37
  Useful commands (long form and short aliases):
38
38
 
@@ -42,6 +42,8 @@ Useful commands (long form and short aliases):
42
42
  /peer send worker "Fix PR waiting path" --goal <goal-id> --claim extensions/symphony/index.ts,test/pr-watcher-runtime.test.mjs --no-await
43
43
  /peer progress "tests are running" --phase verification
44
44
  /peer goal finding <goal-id> "PR auto-close can close before merge" --path extensions/symphony/index.ts
45
+ /peer scout <goal-id> --limit 5
46
+ /peer goal propose <goal-id> "Add a read-only reviewer before closing" --path extensions/symphony/index.ts
45
47
  /peer goal claim <goal-id> "Fix PR waiting path" --mode write --path extensions/symphony/index.ts,test/pr-watcher-runtime.test.mjs
46
48
  /peer goal heartbeat <goal-id> <claim-event-id> "still working after reconnect" --stale-after-ms 900000
47
49
  /peer goal release <goal-id> <claim-event-id> "worker lane complete"
@@ -50,11 +52,11 @@ Useful commands (long form and short aliases):
50
52
  /peer get <goal-id>
51
53
  ```
52
54
 
53
- Short aliases keep common board updates terse: `/peer goals`/`/peer ls`, `/peer current`, `/peer fanout`, `/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.
55
+ 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.
54
56
 
55
57
  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`.
56
58
 
57
- Normal goal closure requires at least one current passing vote, no current failed votes, no unresolved blocking objections, and no active write claims. 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.
59
+ 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.
58
60
 
59
61
  ## Package checks
60
62
 
@@ -6,7 +6,7 @@ import { installPeerRuntimeLifecycle } from "../../src/peers/extension-lifecycle
6
6
  import { initPeerConfig } from "../../src/peers/config.mjs";
7
7
  import { formatPeerCommandError, formatPeerHelp, formatPeerInitResult, parsePeerCommand } from "../../src/peers/command.mjs";
8
8
  import { createPeerRuntime, getPeerRuntimeValue } from "../../src/peers/runtime.mjs";
9
- import { appendPeerGoalEvent, beginPeerGoalTask, closePeerGoal, completePeerGoalTask, createPeerGoal, formatPeerGoal, formatPeerGoalList, loadPeerGoalBoard, recordPeerGoalTaskDispatch } from "../../src/peers/goal-board.mjs";
9
+ import { appendPeerGoalEvent, beginPeerGoalTask, closePeerGoal, completePeerGoalTask, createPeerGoal, formatPeerGoal, formatPeerGoalList, formatPeerGoalScout, loadPeerGoalBoard, recordPeerGoalTaskDispatch } from "../../src/peers/goal-board.mjs";
10
10
  import { collectPeerRuntimeStatus, derivePeerDoctorReport, formatPeerDoctorText, formatPeerStatusLines, formatPeerStatusText } from "../../src/peers/status.mjs";
11
11
  import {
12
12
  peerAwaitToolResult,
@@ -51,7 +51,7 @@ export default function piPeerExtension(pi: ExtensionAPI) {
51
51
 
52
52
  pi.registerCommand("peer", {
53
53
  description: "Pi-to-Pi peers: setup, doctor, status, list, send, get, await, progress, goal",
54
- getArgumentCompletions: (prefix: string) => ["help", "status", "list", "init", "setup", "doctor", "reconnect", "resume", "cancel", "send", "get", "await", "progress", "goal", "goals", "ls", "current", "fanout", "claim", "take", "done", "complete", "block", "objection", "unblock", "pass", "fail"]
54
+ getArgumentCompletions: (prefix: string) => ["help", "status", "list", "init", "setup", "doctor", "reconnect", "resume", "cancel", "send", "get", "await", "progress", "goal", "goals", "ls", "current", "scout", "fanout", "proposal", "propose", "claim", "take", "done", "complete", "block", "objection", "unblock", "pass", "fail"]
55
55
  .filter((value) => value.startsWith(prefix))
56
56
  .map((value) => ({ value, label: value })),
57
57
  handler: async (rawArgs, ctx) => {
@@ -409,8 +409,9 @@ async function handlePeerGoalCommand(parsed: any, ctx: any, runtime: any) {
409
409
  if (!goal) throw new Error(goalId ? `peer goal ${goalId} not found` : "no current peer goal");
410
410
  return formatPeerGoal(goal);
411
411
  }
412
+ if (parsed.goalAction === "scout") return formatPeerGoalScout(await loadPeerGoalBoard(root), { goalId: parsed.goalId, limit: parsed.limit, includeClosed: parsed.includeClosed });
412
413
  if (parsed.goalAction === "fanout") return handlePeerGoalFanout(parsed, ctx, runtime, peerId);
413
- if (["task", "finding", "handoff", "note"].includes(parsed.goalAction)) {
414
+ if (["task", "finding", "proposal", "propose", "handoff", "note"].includes(parsed.goalAction)) {
414
415
  const result = await appendPeerGoalEvent(root, parsed.goalId, {
415
416
  type: parsed.eventType,
416
417
  peerId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptolibertus/pi-peer",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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",
@@ -18,7 +18,7 @@
18
18
  "LICENSE"
19
19
  ],
20
20
  "scripts": {
21
- "test": "node --test ../../test/peer-*.test.mjs",
21
+ "test": "node --test test/peer-*.test.mjs",
22
22
  "check": "npm test && npm run check:pack",
23
23
  "check:pack": "npm pack --dry-run",
24
24
  "smoke:pack": "npm run check:pack",
@@ -7,6 +7,9 @@ const PEER_GOAL_ALIASES = Object.freeze({
7
7
  ls: ["list"],
8
8
  current: ["show"],
9
9
  fanout: ["fanout"],
10
+ scout: ["scout"],
11
+ proposal: ["proposal"],
12
+ propose: ["propose"],
10
13
  claim: ["claim"],
11
14
  take: ["claim"],
12
15
  heartbeat: ["heartbeat"],
@@ -136,11 +139,12 @@ export function formatPeerHelp() {
136
139
  "- `/peer cancel <message-id> [reason]` — mark a queued/running/disconnected peer message cancelled",
137
140
  "- `/peer send <peer> <prompt> [--no-await] [--intent ask] [--goal <goal-id>] [--claim <path[,path]>] [--timeout-ms <ms>] [--allow-self]` — send a prompt-first peer message",
138
141
  "- `/peer progress <summary> [--status running] [--phase <name>]` — send a structured checkpoint from an inbound long-running peer task",
139
- "- `/peer goals|ls`, `/peer current [goal-id]`, `/peer fanout`, `/peer take|claim`, `/peer complete|done`, `/peer objection|block`, `/peer unblock`, `/peer ping`, `/peer drop`, `/peer pass|fail` — short goal-board aliases",
142
+ "- `/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",
140
143
  "- `/peer goal create <objective> [--constraint <a,b>]` — start a flat shared goal board",
141
- "- `/peer goal list|show [goal-id]` — inspect peer goals, active claims, blockers, and votes",
144
+ "- `/peer goal list|show [goal-id]` — inspect peer goals, active claims, blockers, proposals, and votes",
142
145
  "- `/peer goal fanout <goal-id> <objective> --peer <id[,id]> [--path <a,b>] [--send] [--no-await]` — plan or dispatch role-specific peer lanes",
143
- "- `/peer goal task|finding|handoff|note <goal-id> <summary> [--path <a,b>] [--status done]` — post goal-board events",
146
+ "- `/peer goal scout [goal-id] [--limit <n>] [--include-closed]` — read-only proactive suggestions for what peers could do next",
147
+ "- `/peer goal task|finding|proposal|handoff|note <goal-id> <summary> [--path <a,b>] [--status done]` — post goal-board events",
144
148
  "- `/peer goal claim <goal-id> <task> --mode write --path <a,b> [--ttl-ms <ms>] [--stale-after-ms <ms>]` — lease work without hierarchy",
145
149
  "- `/peer goal heartbeat <goal-id> <claim-event-id> [summary] [--ttl-ms <ms>] [--stale-after-ms <ms>]` — refresh a live or stale claim",
146
150
  "- `/peer goal release <goal-id> <claim-event-id> [summary]` — release a claimed lane",
@@ -171,6 +175,7 @@ function parsePeerGoalCommand(parsed, flags, positionals) {
171
175
  return { ...withAction, objective, constraints: listFlag(flags.constraint || flags.constraints) };
172
176
  }
173
177
  if (action === "show") return { ...withAction, goalId: rest[0] };
178
+ if (action === "scout") return { ...withAction, goalId: rest[0], limit: positiveIntegerFlag(flags.limit), includeClosed: flagEnabled(flags.includeClosed) };
174
179
  if (action === "fanout") {
175
180
  const goalId = rest[0];
176
181
  const objective = rest.slice(1).join(" ").trim();
@@ -189,11 +194,11 @@ function parsePeerGoalCommand(parsed, flags, positionals) {
189
194
  staleAfterMs: positiveIntegerFlag(flags.staleAfterMs),
190
195
  };
191
196
  }
192
- if (["task", "finding", "handoff", "note"].includes(action)) {
197
+ if (["task", "finding", "proposal", "propose", "handoff", "note"].includes(action)) {
193
198
  const goalId = rest[0];
194
199
  const summary = rest.slice(1).join(" ").trim();
195
200
  if (!goalId || !summary) return { ...withAction, error: `/peer goal ${action} requires <goal-id> <summary>` };
196
- return { ...withAction, goalId, eventType: action === "task" ? "task" : action, summary, paths: listFlag(flags.path || flags.paths), severity: stringFlag(flags.severity, undefined), taskId: stringFlag(flags.taskId, undefined), status: stringFlag(flags.status, undefined) };
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) };
197
202
  }
198
203
  if (action === "claim") {
199
204
  if (flagEnabled(flags.write) && flags.mode === undefined) flags.mode = "write";
@@ -5,7 +5,7 @@ import { setTimeout as sleep } from "node:timers/promises";
5
5
 
6
6
  export const PEER_GOAL_BOARD_RELATIVE_PATH = ".pi/peer-goals.json";
7
7
 
8
- const EVENT_TYPES = new Set(["finding", "task", "claim", "release", "heartbeat", "objection", "resolve", "vote", "handoff", "note"]);
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
11
  const DEFAULT_GOAL_CLAIM_STALE_MS = 45 * 60 * 1000;
@@ -66,6 +66,7 @@ export async function appendPeerGoalEvent(root, goalId, eventInput = {}) {
66
66
  const goal = resolveGoal(board, goalId);
67
67
  const event = normalizeEvent(eventInput);
68
68
  if (event.type === "claim") validateClaim(goal, event);
69
+ if (event.type === "proposal") validateProposal(event);
69
70
  if (event.type === "release") validateRelease(goal, event);
70
71
  if (event.type === "heartbeat") validateHeartbeat(goal, event);
71
72
  goal.events.push(event);
@@ -172,6 +173,8 @@ export function deriveGoalState(goal, options = {}) {
172
173
  const blockingObjections = events
173
174
  .filter((event) => event.type === "objection" && isBlockingSeverity(event.severity) && !resolvedIds.has(event.id))
174
175
  .map(projectEventSummary);
176
+ const proposals = events.filter((event) => event.type === "proposal").map(projectEventSummary);
177
+ const openProposals = proposals.filter((event) => !resolvedIds.has(event.id));
175
178
  const votes = events.filter((event) => event.type === "vote").map(projectEventSummary);
176
179
  const currentVotes = currentPeerVotes(votes);
177
180
  const failedVotes = currentVotes.filter((vote) => vote.verdict === "fail");
@@ -187,6 +190,8 @@ export function deriveGoalState(goal, options = {}) {
187
190
  staleClaims,
188
191
  releasedClaims,
189
192
  blockingObjections,
193
+ proposals,
194
+ openProposals,
190
195
  votes,
191
196
  currentVotes,
192
197
  failedVotes,
@@ -205,10 +210,62 @@ export function formatPeerGoalList(board) {
205
210
  if (state.activeClaims.length) bits.push(`${state.activeClaims.length} active claim${state.activeClaims.length === 1 ? "" : "s"}`);
206
211
  if (state.staleClaims.length) bits.push(`${state.staleClaims.length} stale claim${state.staleClaims.length === 1 ? "" : "s"}`);
207
212
  if (state.blockingObjections.length) bits.push(`${state.blockingObjections.length} blocker${state.blockingObjections.length === 1 ? "" : "s"}`);
213
+ if (state.openProposals.length) bits.push(`${state.openProposals.length} proposal${state.openProposals.length === 1 ? "" : "s"}`);
208
214
  return bits.join(" · ");
209
215
  }).join("\n");
210
216
  }
211
217
 
218
+ export function formatPeerGoalScout(board, options = {}) {
219
+ const suggestions = derivePeerGoalScoutSuggestions(board, options);
220
+ if (!suggestions.length) return "No proactive scout suggestions. Open goals look idle-safe or there are no matching goals.";
221
+ const limit = positiveNumber(options.limit) || suggestions.length;
222
+ const lines = ["# Peer Scout", "", "Proactive suggestions (read-only):"];
223
+ for (const suggestion of suggestions.slice(0, limit)) {
224
+ const pathText = suggestion.paths?.length ? ` · paths: ${suggestion.paths.join(", ")}` : "";
225
+ lines.push(`- ${suggestion.priority} · ${suggestion.goalId} · ${suggestion.kind}: ${suggestion.summary}${pathText}`);
226
+ }
227
+ 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
+ return lines.join("\n");
229
+ }
230
+
231
+ export function derivePeerGoalScoutSuggestions(board, options = {}) {
232
+ const normalized = normalizeBoard(board);
233
+ const includeClosed = options.includeClosed === true;
234
+ const requestedGoalId = cleanText(options.goalId);
235
+ const goals = Object.values(normalized.goals)
236
+ .filter((goal) => (!requestedGoalId || goal.id === requestedGoalId) && (includeClosed || goal.status !== "closed"))
237
+ .sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
238
+ const suggestions = [];
239
+ for (const goal of goals) {
240
+ const state = deriveGoalState(goal);
241
+ const push = (priority, kind, summary, extra = {}) => suggestions.push(stripEmpty({ goalId: goal.id, priority, kind, summary, ...extra }));
242
+ if (state.blockingObjections.length) {
243
+ push("P0", "blocker", `Resolve ${state.blockingObjections.length} blocking objection${state.blockingObjections.length === 1 ? "" : "s"} before more work.`, { paths: uniqueEventPaths(state.blockingObjections) });
244
+ continue;
245
+ }
246
+ if (state.failedVotes.length) {
247
+ push("P0", "failed-vote", `Address failed vote from ${state.failedVotes.map((vote) => vote.peerId || vote.id).join(", ")}.`);
248
+ continue;
249
+ }
250
+ if (state.staleClaims.length) {
251
+ push("P1", "stale-claim", `Ask owners to heartbeat or release ${state.staleClaims.length} stale claim${state.staleClaims.length === 1 ? "" : "s"}.`, { paths: uniqueEventPaths(state.staleClaims) });
252
+ }
253
+ if (state.openProposals.length) {
254
+ push("P1", "open-proposal", `Triage ${state.openProposals.length} open proposal${state.openProposals.length === 1 ? "" : "s"}; claim one or resolve it if obsolete.`, { paths: uniqueEventPaths(state.openProposals) });
255
+ }
256
+ if (state.readyToClose) {
257
+ push("P1", "close", "Goal satisfies closure gates; close it or record a final note.");
258
+ continue;
259
+ }
260
+ if (!state.activeClaims.length && !state.tasks.length && !state.openProposals.length) {
261
+ push("P2", "next-step", "No active work yet; propose a research, review, or implementation lane.");
262
+ } else if (!state.currentVotes.length && !state.activeWriteClaims.length) {
263
+ push("P2", "review", "No current peer vote; ask a peer for read-only review or record a pass/fail vote.");
264
+ }
265
+ }
266
+ return suggestions;
267
+ }
268
+
212
269
  export function formatPeerGoal(goal) {
213
270
  const state = goal && Array.isArray(goal.activeClaims) && Array.isArray(goal.expiredClaims) && Array.isArray(goal.staleClaims) ? goal : deriveGoalState(goal);
214
271
  const lines = [
@@ -233,6 +290,10 @@ export function formatPeerGoal(goal) {
233
290
  lines.push("", "Blocking objections:");
234
291
  for (const objection of state.blockingObjections) lines.push(`- ${objection.id} · ${objection.peerId} · ${objection.summary}`);
235
292
  }
293
+ if (state.openProposals.length) {
294
+ lines.push("", "Open proposals:");
295
+ for (const proposal of state.openProposals.slice(-8)) lines.push(`- ${proposal.id} · ${proposal.peerId} · ${proposal.summary}${proposal.paths?.length ? ` · ${proposal.paths.join(", ")}` : ""}`);
296
+ }
236
297
  if (state.currentVotes.length) {
237
298
  lines.push("", "Votes:");
238
299
  for (const vote of state.currentVotes.slice(-8)) lines.push(`- ${vote.peerId}: ${vote.verdict}${vote.confidence !== undefined ? ` (${vote.confidence})` : ""}${vote.summary ? ` — ${vote.summary}` : ""}`);
@@ -257,6 +318,10 @@ function validateClaim(goal, event) {
257
318
  }
258
319
  }
259
320
 
321
+ function validateProposal(event) {
322
+ if (!event.summary) throw new Error("peer goal proposal requires a summary");
323
+ }
324
+
260
325
  function validateRelease(goal, event) {
261
326
  if (!event.resolves) throw new Error("peer goal release requires a claim event id");
262
327
  const state = deriveGoalState(goal);
@@ -269,6 +334,10 @@ function validateHeartbeat(goal, event) {
269
334
  const state = deriveGoalState(goal);
270
335
  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);
271
336
  if (!claim) throw new Error(`peer goal heartbeat target ${event.resolves} is not an active, stale, or expired claim`);
337
+ if (claim.mode === "write") {
338
+ const conflicts = state.activeClaims.filter((item) => item.id !== claim.id && item.mode === "write" && pathsOverlap(claim.paths || [], item.paths || []));
339
+ if (conflicts.length) throw new Error(`heartbeat conflicts with active write claim ${conflicts.map((item) => item.id).join(", ")}`);
340
+ }
272
341
  }
273
342
 
274
343
  function validateGoalReadyToClose(state) {
@@ -410,6 +479,10 @@ function currentPeerVotes(votes) {
410
479
  return [...byPeer.values()];
411
480
  }
412
481
 
482
+ function uniqueEventPaths(events) {
483
+ return [...new Set(events.flatMap((event) => Array.isArray(event.paths) ? event.paths : []))];
484
+ }
485
+
413
486
  async function updatePeerGoalBoard(root, updater) {
414
487
  const path = goalBoardPath(root);
415
488
  await mkdir(dirname(path), { recursive: true });
@@ -460,11 +533,18 @@ function goalBoardPath(root) {
460
533
  }
461
534
 
462
535
  function pathsOverlap(a, b) {
463
- return a.some((left) => b.some((right) => left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)));
536
+ return a.some((left) => b.some((right) => left === "." || right === "." || left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)));
464
537
  }
465
538
 
466
539
  function normalizePaths(value) {
467
- return [...new Set(normalizeList(value).map((item) => item.replace(/^\.\//, "").replace(/\/+/g, "/").replace(/\/$/, "")).filter(Boolean))];
540
+ return [...new Set(normalizeList(value).map(normalizePath).filter(Boolean))];
541
+ }
542
+
543
+ function normalizePath(value) {
544
+ let path = cleanText(value).replace(/\/+/g, "/");
545
+ if (path === "" || path === "." || path === "/") return ".";
546
+ path = path.replace(/^\.\//, "").replace(/\/$/, "");
547
+ return path === "" || path === "." || path === "/" ? "." : path;
468
548
  }
469
549
 
470
550
  function normalizeList(value) {