@cryptolibertus/pi-peer 0.3.6 → 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 +3 -2
- package/package.json +1 -1
- package/src/peers/goal-board.mjs +25 -2
- package/src/peers/idle-watcher.mjs +69 -5
- package/src/peers/status.mjs +28 -4
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ pi install ./packages/pi-peer
|
|
|
17
17
|
- Local peer discovery and transport using project `.pi/peers.json`
|
|
18
18
|
- Repo-scoped discovery: only Pi sessions in the same git repo/project appear as local peers
|
|
19
19
|
- Idle watcher daemon: idle peers nudge stuck inbound activations and proactively inspect open goal-board work
|
|
20
|
+
- Persona-aware scout routing: goal-board suggestions include recommended lanes, preferred roles, claim mode, and rationale so proactive peers can choose work that fits their role/persona
|
|
20
21
|
- Protocol compatibility metadata (`protocolVersion`, min/max compatible versions), peer manifests, capabilities, and trust summaries in descriptors/status/list output
|
|
21
22
|
- `PI_PEER_ID` runtime override for running multiple local Pi sessions
|
|
22
23
|
- `pi-peer-publish` skill for safe npm release checks, version bumping, tag push, publish, and verification
|
|
@@ -57,7 +58,7 @@ Useful commands (long form and short aliases):
|
|
|
57
58
|
|
|
58
59
|
Short aliases keep common board updates terse: `/peer goals`/`/peer ls`, `/peer current`, `/peer scout`, `/peer fanout`, `/peer proposal`/`/peer propose`, `/peer take`/`/peer claim`, `/peer complete`/`/peer done`, `/peer objection`/`/peer block`, `/peer unblock`, `/peer note`, `/peer finding`, `/peer ping`/`/peer heartbeat`, `/peer drop`/`/peer release`, `/peer pass`, `/peer fail`, `/peer vote`, and `/peer close` map to the corresponding `/peer goal ...` actions.
|
|
59
60
|
|
|
60
|
-
The board is stored locally at `.pi/peer-goals.json`; outbound message snapshots are stored in `.pi/peer-messages.json` so restarted planners can still inspect disconnected historical tasks. Mutating goal-board operations take a short local lock before load/modify/save so concurrent peer appends do not drop events. `/peer send --goal <goal-id> --claim <path[,path]>` and the `peer_send` tool's `goalId`/`claimedPaths` parameters link long-running peer tasks to the board: Symphony records a task, claims overlapping write paths before dispatch, injects goal/heartbeat instructions into the peer prompt, keeps the claim alive with local heartbeats, and releases the claim after the peer returns a final response. `/peer goal fanout` turns a goal into role-specific peer lanes, while `peer_progress` reports checkpoints from an inbound long-running task. Active write claims conflict on overlapping paths; released, stale, or expired claims are kept visible but inactive. Claims become stale after 45 minutes without a heartbeat unless the claim or heartbeat sets `--stale-after-ms`.
|
|
61
|
+
The board is stored locally at `.pi/peer-goals.json`; outbound message snapshots are stored in `.pi/peer-messages.json` so restarted planners can still inspect disconnected historical tasks. Mutating goal-board operations take a short local lock before load/modify/save so concurrent peer appends do not drop events. `/peer send --goal <goal-id> --claim <path[,path]>` and the `peer_send` tool's `goalId`/`claimedPaths` parameters link long-running peer tasks to the board: Symphony records a task, claims overlapping write paths before dispatch, injects goal/heartbeat instructions into the peer prompt, keeps the claim alive with local heartbeats, and releases the claim after the peer returns a final response. `/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`.
|
|
61
62
|
|
|
62
63
|
Normal goal closure requires at least one current passing vote, no current failed votes, no unresolved blocking objections, and no active write claims. Open proposals are intentionally non-blocking: they let peers show initiative without freezing closure. Stale write claims no longer block closure or new overlapping claims; use `/peer goal heartbeat` to revive work after a reconnect and `--force` only when intentionally overriding the readiness gate. Goal-linked tasks validate final handoff headings (`Status`, `Files changed`, `Verification`, `Blockers/risks`, `Safe for review`); missing sections create a blocking objection while still releasing the write claim. For multi-part work, use the fan-out gate: list peers, create/reuse a goal, delegate research/review/worker lanes, and include `Fan-out used: yes/no` plus peer handles in the final answer.
|
|
63
64
|
|
|
@@ -66,7 +67,7 @@ Normal goal closure requires at least one current passing vote, no current faile
|
|
|
66
67
|
When peer messaging is enabled, the extension starts a lightweight in-process idle watcher on `session_start`. It only acts when Pi reports the agent is idle and there are no queued local follow-up messages. The watcher does two things:
|
|
67
68
|
|
|
68
69
|
1. If an inbound peer task is active but appears not to have triggered a turn, it re-nudges the existing inbound prompt with `triggerTurn: true` using a cooldown.
|
|
69
|
-
2. If no peer task is active, it reads `.pi/peer-goals.json`, derives the same read-only scout suggestions as `/peer scout`, and injects a concise self-prompt so the idle peer can propose, review, claim, vote, or no-op safely.
|
|
70
|
+
2. If no peer task is active, it reads `.pi/peer-goals.json`, derives the same read-only scout suggestions as `/peer scout`, and injects a concise self-prompt so the idle peer can propose, review, claim, vote, or no-op safely. When the local peer has a configured `role` or `persona`, the watcher prefers suggestions whose lane matches that profile and leaves mismatched work for better-fit peers.
|
|
70
71
|
|
|
71
72
|
Configuration can be placed in `.pi/peers.json` as `idleWatcher` or in `.pi/settings.json` under `peerMessaging.idleWatcher`:
|
|
72
73
|
|
package/package.json
CHANGED
package/src/peers/goal-board.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
141
|
+
const lane = activation.recommendedLane ? `\nRecommended lane: ${activation.recommendedLane}${activation.claimMode ? ` (${activation.claimMode})` : ""}${activation.preferredRoles?.length ? ` · preferred roles: ${activation.preferredRoles.join(", ")}` : ""}` : "";
|
|
142
|
+
const rationale = activation.rationale ? `\nRationale: ${activation.rationale}` : "";
|
|
143
|
+
const fit = activation.personaFit?.matched?.length ? `\nPersona fit: matched ${activation.personaFit.matched.join(", ")}` : "";
|
|
144
|
+
return `[Pi peer idle watcher]\nYou are local peer '${peerId}' and Pi is idle. A proactive goal-board scout suggestion is available.\n\nGoal: ${activation.goalId}\nSuggestion: ${activation.kind} (${activation.priority}) — ${activation.summary}${lane}${rationale}${fit}${paths}\n\nInstructions:\n- First inspect current state with peer_get id '${activation.goalId}'.\n- If useful, take one small safe action that fits the recommended lane: post a proposal/finding/vote, claim a read-only review lane, or claim write work only when you intend to edit and can name the paths.\n- Do not duplicate active claims or proposals. If the board is no longer actionable, say so briefly and stop.\n- For write work, respect goal-board claims and end with the required peer handoff sections.\n- Keep the response concise.`;
|
|
139
145
|
}
|
|
140
146
|
|
|
141
147
|
export function peerIdleActivationKey(activation = {}) {
|
|
142
|
-
return [activation.goalId, activation.kind, activation.summary, ...(activation.paths || [])].join("|");
|
|
148
|
+
return [activation.goalId, activation.kind, activation.recommendedLane, activation.summary, ...(activation.paths || [])].join("|");
|
|
143
149
|
}
|
|
144
150
|
|
|
145
151
|
function isActivationCoolingDown(state, activation, config, nowMs) {
|
|
@@ -147,18 +153,76 @@ function isActivationCoolingDown(state, activation, config, nowMs) {
|
|
|
147
153
|
return Number.isFinite(last) && nowMs - last < config.cooldownMs;
|
|
148
154
|
}
|
|
149
155
|
|
|
150
|
-
function normalizeActivation(suggestion = {}, localPeerId) {
|
|
156
|
+
function normalizeActivation(suggestion = {}, localPeerId, options = {}) {
|
|
151
157
|
if (!suggestion.goalId || !suggestion.kind || !suggestion.summary) return undefined;
|
|
158
|
+
const personaFit = peerPersonaFit(suggestion, options);
|
|
152
159
|
return {
|
|
153
160
|
goalId: suggestion.goalId,
|
|
154
161
|
priority: suggestion.priority || "P2",
|
|
155
162
|
kind: suggestion.kind,
|
|
156
163
|
summary: suggestion.summary,
|
|
164
|
+
recommendedLane: cleanString(suggestion.recommendedLane),
|
|
165
|
+
preferredRoles: normalizeStringList(suggestion.preferredRoles),
|
|
166
|
+
preferredCapabilities: normalizeStringList(suggestion.preferredCapabilities),
|
|
167
|
+
claimMode: cleanString(suggestion.claimMode),
|
|
168
|
+
suggestedIntent: cleanString(suggestion.suggestedIntent),
|
|
169
|
+
rationale: cleanString(suggestion.rationale),
|
|
170
|
+
personaFit,
|
|
157
171
|
paths: Array.isArray(suggestion.paths) ? suggestion.paths.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim()) : [],
|
|
158
172
|
peerId: localPeerId,
|
|
159
173
|
};
|
|
160
174
|
}
|
|
161
175
|
|
|
176
|
+
function activationFitsPeer(activation = {}, options = {}) {
|
|
177
|
+
const preferred = activation.preferredRoles || [];
|
|
178
|
+
if (activation.priority === "P0" || !preferred.length) return true;
|
|
179
|
+
const fit = activation.personaFit || peerPersonaFit(activation, options);
|
|
180
|
+
if (!fit.hasProfile) return true;
|
|
181
|
+
return fit.matched.length > 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function peerPersonaFit(suggestion = {}, options = {}) {
|
|
185
|
+
const preferredRoles = normalizeStringList(suggestion.preferredRoles);
|
|
186
|
+
const localTerms = peerProfileTerms(options);
|
|
187
|
+
if (!preferredRoles.length) return { hasProfile: localTerms.length > 0, matched: [] };
|
|
188
|
+
const matched = preferredRoles.filter((role) => localTerms.includes(normalizeRoleToken(role)));
|
|
189
|
+
return { hasProfile: localTerms.some(isKnownRoleTerm), matched };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function peerProfileTerms(options = {}) {
|
|
193
|
+
const terms = [options.localRole, options.localPersona, options.localPeerId]
|
|
194
|
+
.flatMap((value) => String(value || "").toLowerCase().split(/[^a-z0-9]+/g))
|
|
195
|
+
.map(normalizeRoleToken)
|
|
196
|
+
.filter(Boolean);
|
|
197
|
+
const capabilities = options.localCapabilities && typeof options.localCapabilities === "object" ? options.localCapabilities : {};
|
|
198
|
+
if (Array.isArray(capabilities.roles)) terms.push(...capabilities.roles.map(normalizeRoleToken));
|
|
199
|
+
return [...new Set(terms)];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isKnownRoleTerm(value) {
|
|
203
|
+
return ["planner", "coordinator", "reviewer", "qa", "worker", "researcher"].includes(value);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function normalizeRoleToken(value) {
|
|
207
|
+
const token = String(value || "").trim().toLowerCase();
|
|
208
|
+
if (!token) return "";
|
|
209
|
+
if (/^(reviewer|review|qa|quality)\d*$/.test(token)) return token === "qa" ? "qa" : "reviewer";
|
|
210
|
+
if (/^(worker|implement|implementation|code|coder|engineer|developer|task)\d*$/.test(token)) return "worker";
|
|
211
|
+
if (/^(researcher|research|scout)\d*$/.test(token)) return "researcher";
|
|
212
|
+
if (/^(planner|plan|coordinate|coordinator|orchestrator)\d*$/.test(token)) return token.startsWith("planner") ? "planner" : "coordinator";
|
|
213
|
+
return token;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function normalizeStringList(value) {
|
|
217
|
+
if (Array.isArray(value)) return [...new Set(value.map(cleanString).filter(Boolean))];
|
|
218
|
+
if (typeof value === "string") return [...new Set(value.split(",").map(cleanString).filter(Boolean))];
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function cleanString(value) {
|
|
223
|
+
return typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
|
|
224
|
+
}
|
|
225
|
+
|
|
162
226
|
function isContextIdle(ctx) {
|
|
163
227
|
if (!ctx) return { ok: false, reason: "no active session context" };
|
|
164
228
|
if (typeof ctx.isIdle === "function" && !ctx.isIdle()) return { ok: false, reason: "agent busy" };
|
package/src/peers/status.mjs
CHANGED
|
@@ -133,20 +133,44 @@ function capabilitySummary(capabilities = {}) {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
export function deriveFanoutSuggestion(peers = [], pendingMessages = []) {
|
|
136
|
-
const
|
|
136
|
+
const availablePeerDetails = peers
|
|
137
137
|
.filter((peer) => !peer.current && !peer.self && peer.trust !== "disabled")
|
|
138
|
-
.map((peer) =>
|
|
139
|
-
|
|
138
|
+
.map((peer) => ({
|
|
139
|
+
peerId: peer.peerId,
|
|
140
|
+
role: safeStatusText(peer.role),
|
|
141
|
+
persona: safeStatusText(peer.persona),
|
|
142
|
+
recommendedLane: recommendLaneForPeer(peer),
|
|
143
|
+
}))
|
|
144
|
+
.filter((peer) => peer.peerId);
|
|
145
|
+
const availablePeers = availablePeerDetails.map((peer) => peer.peerId);
|
|
146
|
+
const lanes = availablePeerDetails.reduce((groups, peer) => {
|
|
147
|
+
const lane = peer.recommendedLane || "general";
|
|
148
|
+
if (!groups[lane]) groups[lane] = [];
|
|
149
|
+
groups[lane].push(peer.peerId);
|
|
150
|
+
return groups;
|
|
151
|
+
}, {});
|
|
140
152
|
const activePeerTasks = pendingMessages.filter((message) => ["queued", "running"].includes(message.status));
|
|
141
153
|
const recommended = availablePeers.length > 0 && activePeerTasks.length === 0;
|
|
154
|
+
const laneText = Object.entries(lanes).slice(0, 4).map(([lane, ids]) => `${lane}:${ids.slice(0, 3).join("/")}`).join(", ");
|
|
142
155
|
return {
|
|
143
156
|
recommended,
|
|
144
157
|
availablePeers,
|
|
158
|
+
availablePeerDetails,
|
|
159
|
+
lanes,
|
|
145
160
|
activePeerTaskCount: activePeerTasks.length,
|
|
146
|
-
warning: recommended ? `fan-out available for multi-lane work: ${availablePeers.slice(0, 4).join(", ")} — use /peer goal fanout or peer_send` : undefined,
|
|
161
|
+
warning: recommended ? `fan-out available for multi-lane work: ${availablePeers.slice(0, 4).join(", ")}${laneText ? ` · lanes ${laneText}` : ""} — use /peer goal fanout or peer_send` : undefined,
|
|
147
162
|
};
|
|
148
163
|
}
|
|
149
164
|
|
|
165
|
+
function recommendLaneForPeer(peer = {}) {
|
|
166
|
+
const text = [peer.role, peer.persona, peer.peerId].filter(Boolean).join(" ").toLowerCase();
|
|
167
|
+
if (/(^|[^a-z0-9])(review|reviewer|qa|quality)\d*($|[^a-z0-9])/.test(text)) return "review";
|
|
168
|
+
if (/(^|[^a-z0-9])(research|researcher|scout)\d*($|[^a-z0-9])/.test(text)) return "research";
|
|
169
|
+
if (/(^|[^a-z0-9])(plan|planner|coord|coordinator|orchestrator)\d*($|[^a-z0-9])/.test(text)) return "coordination";
|
|
170
|
+
if (/(^|[^a-z0-9])(worker|implement|implementation|engineer|developer|code|coder|task)\d*($|[^a-z0-9])/.test(text)) return "implementation";
|
|
171
|
+
return "general";
|
|
172
|
+
}
|
|
173
|
+
|
|
150
174
|
function line(kind, color, text) {
|
|
151
175
|
return { kind, color, text };
|
|
152
176
|
}
|