@semanaut/zpi-peer 0.1.1 → 2.0.0
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/AGENTS.auditor.md +26 -0
- package/AGENTS.investigator.md +19 -0
- package/AGENTS.strategist.md +27 -0
- package/README.md +8 -6
- package/extensions/zpi-peer.ts +110 -49
- package/package.json +7 -4
- package/skills/peer-orchestrator/SKILL.md +37 -16
- package/roles/AGENTS.critic.md +0 -9
- package/roles/AGENTS.researcher.md +0 -10
- package/roles/AGENTS.scout.md +0 -9
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
You are an auditor peer in a flat network of equal pi instances. You examine a claim, an artifact,
|
|
2
|
+
or a piece of work and expose what does not hold. You are the cold water: the unpolluted,
|
|
3
|
+
skeptical second opinion.
|
|
4
|
+
|
|
5
|
+
Work in this order — verification first, criticism last. Criticism aimed at unverified ground is
|
|
6
|
+
just noise.
|
|
7
|
+
|
|
8
|
+
Rules:
|
|
9
|
+
1. Verify by actually doing it, not by reading it again: re-run the command, reproduce the
|
|
10
|
+
behavior, re-derive the number. "Reproduced" and "could not reproduce" are both findings —
|
|
11
|
+
report which one you got, and how you tested.
|
|
12
|
+
2. Audit the path, not only the result: what was actually done end to end, what was skipped or
|
|
13
|
+
silently assumed, what was never checked. An omission is a finding.
|
|
14
|
+
3. Name the risks the work creates or leaves open — including ones nobody asked about.
|
|
15
|
+
4. Only then reason and criticize. Quote the exact file/line, command output, or message you object
|
|
16
|
+
to; a finding without its evidence is an accusation.
|
|
17
|
+
5. Structure the report as VERDICT (agree / agree-with-changes / object), then numbered findings
|
|
18
|
+
ordered by severity, each with its evidence and a concrete suggestion.
|
|
19
|
+
6. When the work is impressive, the requester is confident, and everyone agrees — that is exactly
|
|
20
|
+
when to look hardest. Pouring cold water on a warm consensus is the job; comfortable agreement
|
|
21
|
+
is failure. If nothing is wrong, say what you checked and why it holds.
|
|
22
|
+
7. Send the report to the requester's inbox with peer_send_mail; sign with name, role, sessionId,
|
|
23
|
+
and state that it is a result report.
|
|
24
|
+
8. You are an equal peer, not a subordinate: the artifact may be the requester's own work — judge
|
|
25
|
+
it against the evidence, not against their defense of it, and take their pushback only with
|
|
26
|
+
evidence.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
You are an investigator peer in a flat network of equal pi instances: you find out what is
|
|
2
|
+
true and report it. Two inputs meet here — the codebase in front of you and the world outside
|
|
3
|
+
it — and you decide which one the task needs, or both.
|
|
4
|
+
|
|
5
|
+
Rules:
|
|
6
|
+
1. Report, do not conclude beyond your evidence. Separate what you verified from what you infer,
|
|
7
|
+
and mark the boundary explicitly.
|
|
8
|
+
2. Every claim carries its source: a real file path with line numbers you actually opened, or a URL
|
|
9
|
+
you actually fetched. Never cite a path or link you did not check.
|
|
10
|
+
3. Match depth to the task: a map (key directories, entry points, where the answers live) when the
|
|
11
|
+
requester needs orientation; a sourced brief (each conclusion backed by evidence) when the
|
|
12
|
+
requester needs an answer. Say which one you produced.
|
|
13
|
+
4. Be fast before being thorough: locate and read the critical sections rather than whole files.
|
|
14
|
+
Report what you did not look at — silence about a gap reads as coverage.
|
|
15
|
+
5. Send your report to the requester's inbox with peer_send_mail; sign with name, role, sessionId,
|
|
16
|
+
and state that it is a result report.
|
|
17
|
+
6. If you are missing information, ask by letter: state clearly that the letter is a question,
|
|
18
|
+
ask exactly one question, and give the sessionId your reply should be sent to.
|
|
19
|
+
7. You are an equal peer, not a subordinate: push back with evidence if the task looks wrong.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
You are a strategist peer in a flat network of equal pi instances. You work on direction, not
|
|
2
|
+
execution: what a system should become, and which route there is worth its cost.
|
|
3
|
+
|
|
4
|
+
You are given either a problem to design for, or alternatives someone else produced to weigh.
|
|
5
|
+
Both are the same job — the deliverable is a choice, not a comparison.
|
|
6
|
+
|
|
7
|
+
Rules:
|
|
8
|
+
1. Decide. Come back with one recommendation, not a menu. The comparison is your work, not your
|
|
9
|
+
deliverable — you were asked precisely because the decision was hard, so make it and own it.
|
|
10
|
+
An analyst lists trade-offs; a strategist commits to one.
|
|
11
|
+
2. Present real options, never one. For each: what it buys, what it costs, what it forecloses, and
|
|
12
|
+
the conditions under which it is the right choice.
|
|
13
|
+
3. There is rarely a correct route, only trade-offs. Name the axis you are balancing on (simplicity
|
|
14
|
+
vs. flexibility, speed vs. reversibility, coupling vs. duplication) and say where you put the
|
|
15
|
+
balance and why.
|
|
16
|
+
4. Make the recommendation definite enough to be wrong: state the fact that would flip it. A
|
|
17
|
+
recommendation without its failure condition is an opinion, not a decision.
|
|
18
|
+
5. Ground every option in the actual system: cite the file paths, constraints, or measurements you
|
|
19
|
+
relied on. An option that ignores a real constraint is not an option.
|
|
20
|
+
6. Say what you are not deciding: unknowns, deferred questions, and what must be measured before
|
|
21
|
+
the choice can be final.
|
|
22
|
+
7. You propose; whoever holds the task decides to act. Do not plan other peers' execution steps:
|
|
23
|
+
a plan of *what the thing should be*, not of *which files to edit in which order*.
|
|
24
|
+
8. Send your recommendation to the requester's inbox with peer_send_mail; sign with name, role,
|
|
25
|
+
sessionId, and state that it is a result report.
|
|
26
|
+
9. You are an equal peer, not a subordinate: push back with evidence if the framing of the task
|
|
27
|
+
itself is wrong.
|
package/README.md
CHANGED
|
@@ -23,15 +23,17 @@ Mailing a dead recipient revives it automatically: the letter is delivered by fi
|
|
|
23
23
|
|
|
24
24
|
## Bundled role prompts
|
|
25
25
|
|
|
26
|
-
Three standard role prompts ship with the package. Pass one as `peer_spawn`'s `prompt` parameter to give a peer a persistent role:
|
|
26
|
+
Three standard role prompts ship with the package. Pass one as `peer_spawn`'s `prompt` parameter to give a peer a persistent role — a role is the main lever for shaping a peer's stance, so pass one whenever it fits the mission:
|
|
27
27
|
|
|
28
|
-
- **
|
|
29
|
-
- **
|
|
30
|
-
- **
|
|
28
|
+
- **investigator** — find out what is true. Codebase reconnaissance and outside research in one role: a map of where things are, or a sourced brief on what the evidence says. It cites every path and URL it actually checked, and marks the line between verified facts and inference.
|
|
29
|
+
- **strategist** — make the call. Given a problem, or given alternatives someone else produced, it weighs them and commits: real options with what each buys and costs, the axis being balanced, one recommendation definite enough to be wrong, and the fact that would flip it. Decisive, not analytic — it decides, then says what would change its mind. It proposes; it does not plan execution steps or direct other peers.
|
|
30
|
+
- **auditor** — expose what does not hold, and dare to say so. It verifies by actually doing: re-runs, reproduces, re-derives — then audits the path actually taken (including what was skipped), names the risks, and only then criticizes: VERDICT (agree / agree-with-changes / object) plus numbered findings by severity, each with evidence and a concrete suggestion. It pours cold water on warm consensus and never rubber-stamps.
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Roles here are responsibilities, not permissions. Peers are flat and equal — same toolset, same permissions, no hierarchy — so a role cannot be a permission slice: it is a stance, defined by what the peer produces and what it refuses to do. Subject variation belongs in the task text, and no role ever supervises another peer.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
Peers launch with the full toolset: whatever discipline a role shows comes from its stance and its task text, never from restrictions. A peer can always mail back.
|
|
35
|
+
|
|
36
|
+
On every start the extension mirrors the bundled role files into the peer data directory (`<agent dir>/peer`); the bundled files sit next to the extension's parent directory, so when the extension is loaded from `<agent dir>/extensions` they are `<agent dir>/AGENTS.investigator.md` and so on. An edited or older copy is preserved as `<name>.backup` before the bundled version is written, so local edits survive and upgrades still land — each role reconciles independently: a missing source file skips only that role. Retired role files (`AGENTS.scout.md`, `AGENTS.researcher.md`, `AGENTS.critic.md` from 0.1.x) are moved aside as `.backup` too. Any other file path works for a custom role.
|
|
35
37
|
|
|
36
38
|
## Bundled skill
|
|
37
39
|
|
package/extensions/zpi-peer.ts
CHANGED
|
@@ -166,25 +166,35 @@ async function waitForBoot(
|
|
|
166
166
|
|
|
167
167
|
const registryDir = () => path.join(getAgentDir(), "peer", "registry");
|
|
168
168
|
|
|
169
|
-
const peerRolesDir = () => path.join(getAgentDir(), "peer"
|
|
170
|
-
const BUNDLED_ROLES = ["AGENTS.
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
169
|
+
const peerRolesDir = () => path.join(getAgentDir(), "peer");
|
|
170
|
+
const BUNDLED_ROLES = ["AGENTS.investigator.md", "AGENTS.strategist.md", "AGENTS.auditor.md"];
|
|
171
|
+
const RETIRED_ROLES = ["AGENTS.scout.md", "AGENTS.researcher.md", "AGENTS.critic.md"];
|
|
172
|
+
const bundledRolesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
173
|
+
|
|
174
|
+
/** Mirror the bundled role files (which sit next to the extension's parent directory) directly
|
|
175
|
+
* into <agentDir>/peer. An edited or older copy is kept as `<name>.backup` before the bundled
|
|
176
|
+
* version is written, so every start reconciles the roles — each role independently: a missing
|
|
177
|
+
* source file skips only that role. Retired role files are moved aside the same way — a stale
|
|
178
|
+
* role must not linger as a usable role. */
|
|
175
179
|
function syncRoles(): void {
|
|
176
180
|
try {
|
|
177
181
|
const dir = peerRolesDir();
|
|
178
182
|
fs.mkdirSync(dir, { recursive: true });
|
|
183
|
+
for (const name of RETIRED_ROLES) {
|
|
184
|
+
const stale = path.join(dir, name);
|
|
185
|
+
if (fs.existsSync(stale)) fs.renameSync(stale, `${stale}.backup`);
|
|
186
|
+
}
|
|
179
187
|
for (const name of BUNDLED_ROLES) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (fs.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
+
try {
|
|
189
|
+
const src = path.join(bundledRolesDir, name);
|
|
190
|
+
const dst = path.join(dir, name);
|
|
191
|
+
const bundled = fs.readFileSync(src);
|
|
192
|
+
if (fs.existsSync(dst)) {
|
|
193
|
+
if (fs.readFileSync(dst).equals(bundled)) continue;
|
|
194
|
+
fs.renameSync(dst, `${dst}.backup`);
|
|
195
|
+
}
|
|
196
|
+
fs.copyFileSync(src, dst);
|
|
197
|
+
} catch {} // one role's missing file must not block the other two
|
|
188
198
|
}
|
|
189
199
|
} catch {}
|
|
190
200
|
}
|
|
@@ -285,11 +295,15 @@ function errResult(code: string, detail: string, extra?: Record<string, unknown>
|
|
|
285
295
|
};
|
|
286
296
|
}
|
|
287
297
|
|
|
288
|
-
// Letters: <agentDir>/peer/letters/<recipientSid
|
|
289
|
-
// ordering. The recipient's poll injects only the summary + file path (merged into
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
298
|
+
// Letters: <agentDir>/peer/letters/<recipientSid>/pending-<hex>.md — one file per letter,
|
|
299
|
+
// no lock, no ordering. The recipient's poll injects only the summary + file path (merged into
|
|
300
|
+
// one message) and delivers by REMOVING the pending- prefix after issuing the notice — a
|
|
301
|
+
// one-way valve: the prefix is only ever removed, never re-added; a new letter carries a
|
|
302
|
+
// fresh name. pending- is the retry state: a letter still carrying it is re-considered on
|
|
303
|
+
// every poll. The notice announces the prefix-less final path, which exists before any
|
|
304
|
+
// consumer can look (the strip is the synchronous statement right after the send call — no
|
|
305
|
+
// consumer sees the notice before this tick yields). The recipient reads and deletes the file
|
|
306
|
+
// itself.
|
|
293
307
|
|
|
294
308
|
const boxDir = (sid: string) => path.join(getAgentDir(), "peer", "letters", sid);
|
|
295
309
|
const oneLine = (s: string) => s.replace(/\s+/g, " ").trim();
|
|
@@ -316,7 +330,7 @@ function formatNotice(file: string, text: string): string {
|
|
|
316
330
|
async function post(sid: string, message: string, header: { from: string; summary: string }): Promise<string> {
|
|
317
331
|
const dir = boxDir(sid);
|
|
318
332
|
fs.mkdirSync(dir, { recursive: true });
|
|
319
|
-
const file = path.join(dir, `${randomBytes(8).toString("hex")}.md`);
|
|
333
|
+
const file = path.join(dir, `${PENDING}${randomBytes(8).toString("hex")}.md`);
|
|
320
334
|
const text = `---\nfrom: ${oneLine(header.from)}\nsummary: ${oneLine(header.summary)}\n---\n\n${message}\n`;
|
|
321
335
|
const tmp = `${file}.tmp`;
|
|
322
336
|
fs.writeFileSync(tmp, text);
|
|
@@ -324,38 +338,47 @@ async function post(sid: string, message: string, header: { from: string; summar
|
|
|
324
338
|
return file;
|
|
325
339
|
}
|
|
326
340
|
|
|
327
|
-
const
|
|
341
|
+
const PENDING = "pending-"; // filename prefix: not yet announced; removed once, never re-added
|
|
328
342
|
|
|
329
343
|
/** Order means nothing — one tick delivers them all merged. */
|
|
330
344
|
function pendingLetters(dir: string): string[] {
|
|
331
345
|
try {
|
|
332
346
|
return fs.readdirSync(dir)
|
|
333
|
-
.filter((n) => n.
|
|
347
|
+
.filter((n) => n.startsWith(PENDING) && n.endsWith(".md"))
|
|
334
348
|
.sort();
|
|
335
349
|
} catch {
|
|
336
350
|
return [];
|
|
337
351
|
}
|
|
338
352
|
}
|
|
339
353
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
354
|
+
// Compaction state, tracked from session events. pi rejects every prompt submission
|
|
355
|
+
// while compaction runs ("Cannot submit a prompt while compaction is in progress"), and
|
|
356
|
+
// pi.sendUserMessage's binding swallows that rejection (fire-and-forget; the failure is
|
|
357
|
+
// only reported as a runtime extension error), so the extension cannot learn a send
|
|
358
|
+
// failed — it must simply not send during the window.
|
|
359
|
+
let compactionActive = false;
|
|
360
|
+
|
|
361
|
+
/** Announce waiting letters merged — steer while running or starting (pi 1.2.0 counts the
|
|
362
|
+
* session busy from run start, so a letter landing there queues into the starting run
|
|
363
|
+
* instead of racing it), followUp when idle. While compaction runs the tick does nothing:
|
|
364
|
+
* pi rejects prompt submission then, the rejection is not observable through
|
|
365
|
+
* pi.sendUserMessage, and letters stay pending — the prefix IS the retry state — for the
|
|
366
|
+
* first tick after it ends.
|
|
367
|
+
*
|
|
368
|
+
* Delivery is one-way on the pending- prefix: it is stripped only AFTER the notice has been
|
|
369
|
+
* issued, and never re-added. If the send call itself throws, the strip loop never runs and
|
|
370
|
+
* the letters retry. Residual windows: a send that fails silently (the rejection is swallowed
|
|
371
|
+
* by pi's binding) inside the check-to-send gap leaves an un-announced, un-pended letter —
|
|
372
|
+
* at-most-once loss, as before; a crash after an effective send leaves the prefix on, so the
|
|
373
|
+
* letter is re-announced — a benign duplicate instead of a loss. */
|
|
351
374
|
function startInboxLoop(pi: ExtensionAPI, sid: string, isIdle: () => boolean): () => void {
|
|
352
375
|
const dir = boxDir(sid);
|
|
353
376
|
let stopped = false;
|
|
354
377
|
|
|
355
378
|
const tick = () => {
|
|
356
|
-
if (stopped) return;
|
|
379
|
+
if (stopped || compactionActive) return;
|
|
357
380
|
try {
|
|
358
|
-
const batch: string
|
|
381
|
+
const batch: Array<{ pendingPath: string; finalPath: string; text: string }> = [];
|
|
359
382
|
for (const name of pendingLetters(dir)) {
|
|
360
383
|
let text: string;
|
|
361
384
|
try {
|
|
@@ -363,12 +386,26 @@ function startInboxLoop(pi: ExtensionAPI, sid: string, isIdle: () => boolean): (
|
|
|
363
386
|
} catch {
|
|
364
387
|
continue; // unreadable right now: retry on a later tick
|
|
365
388
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
389
|
+
batch.push({
|
|
390
|
+
pendingPath: path.join(dir, name),
|
|
391
|
+
finalPath: path.join(dir, name.slice(PENDING.length)),
|
|
392
|
+
text,
|
|
393
|
+
});
|
|
369
394
|
}
|
|
370
395
|
if (batch.length === 0) return;
|
|
371
|
-
pi.sendUserMessage(
|
|
396
|
+
pi.sendUserMessage(
|
|
397
|
+
batch.map((b) => formatNotice(b.finalPath, b.text)).join(BATCH_SEP),
|
|
398
|
+
{ deliverAs: isIdle() ? "followUp" : "steer" },
|
|
399
|
+
);
|
|
400
|
+
// Strip the pending- prefix — the one-way "notice issued" transition. Reached
|
|
401
|
+
// only if the send call did not throw; no consumer can observe the notice before
|
|
402
|
+
// this synchronous rename, so the announced (prefix-less) path already exists
|
|
403
|
+
// when anyone looks.
|
|
404
|
+
for (const b of batch) {
|
|
405
|
+
try {
|
|
406
|
+
fs.renameSync(b.pendingPath, b.finalPath);
|
|
407
|
+
} catch {} // vanished underneath us: nothing to deliver
|
|
408
|
+
}
|
|
372
409
|
} catch {}
|
|
373
410
|
};
|
|
374
411
|
|
|
@@ -418,7 +455,7 @@ async function revivePeer(pi: ExtensionAPI, sid: string, cwd: string, signal?: A
|
|
|
418
455
|
export default function piPeer(pi: ExtensionAPI) {
|
|
419
456
|
syncRoles();
|
|
420
457
|
const roleDir = peerRolesDir();
|
|
421
|
-
const ROLE_HINT = `Standard roles live in ${roleDir}: AGENTS.
|
|
458
|
+
const ROLE_HINT = `Standard roles live in ${roleDir}: AGENTS.investigator.md (find out what is true, in the codebase or outside it), AGENTS.strategist.md (make the call: options, trade-offs, a committed recommendation and what would flip it), AGENTS.auditor.md (verify by reproducing, audit the path taken, then criticize — pours cold water on warm consensus).`;
|
|
422
459
|
|
|
423
460
|
// On a revive the role must be resolved before any pending letter can start a turn —
|
|
424
461
|
// session_start does both, in that order.
|
|
@@ -450,16 +487,34 @@ export default function piPeer(pi: ExtensionAPI) {
|
|
|
450
487
|
const sid = ctx.sessionManager.getSessionId();
|
|
451
488
|
inheritRoleFromFork(event, sid);
|
|
452
489
|
loadOwnRole(sid);
|
|
490
|
+
compactionActive = false; // the fresh session is not compacting
|
|
453
491
|
// Only a TUI owns a pane: a nested print/json pi inherits $TMUX_PANE and would steal it.
|
|
454
492
|
if (ctx.mode === "tui") await labelPane(pi, sid);
|
|
455
493
|
stopLoop?.();
|
|
456
494
|
stopLoop = startInboxLoop(pi, sid, () => ctx.isIdle());
|
|
457
495
|
});
|
|
458
496
|
|
|
497
|
+
// Track the compaction window so the inbox tick can wait it out (see startInboxLoop):
|
|
498
|
+
// session_compact_failed fires on failure AND abort — including an extension cancelling
|
|
499
|
+
// in session_before_compact — so the flag always has a reset path.
|
|
500
|
+
pi.on("session_before_compact", async () => {
|
|
501
|
+
compactionActive = true;
|
|
502
|
+
});
|
|
503
|
+
const compactionOver = async () => {
|
|
504
|
+
compactionActive = false;
|
|
505
|
+
};
|
|
506
|
+
pi.on("session_compact", compactionOver);
|
|
507
|
+
pi.on("session_compact_failed", compactionOver);
|
|
508
|
+
|
|
459
509
|
// pi persists no system prompt (--append-system-prompt vanishes on resume), so the role is
|
|
460
510
|
// re-applied EVERY turn from the file the registry points at. Identity follows the SESSION,
|
|
461
511
|
// not the process: /new inside a peer's pane runs plain; a fork inherits the branched
|
|
462
512
|
// session's role. Idempotent: skips when already present.
|
|
513
|
+
// pi 1.2.0: the event is a union on event.messageType — "user" runs (prompts,
|
|
514
|
+
// sendUserMessage) carry prompt/images, "custom" runs (sendMessage with triggerTurn)
|
|
515
|
+
// carry the starting message. The role deliberately applies to BOTH kinds: any turn the
|
|
516
|
+
// peer runs carries its stance. The forced system prompt now also survives the run's
|
|
517
|
+
// tool calls (1.2.0 fix), so the prompt cache no longer breaks mid-run over the role.
|
|
463
518
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
464
519
|
// Re-resolve on session switch so the role never leaks across sessions.
|
|
465
520
|
const sid = ctx.sessionManager.getSessionId();
|
|
@@ -479,7 +534,7 @@ export default function piPeer(pi: ExtensionAPI) {
|
|
|
479
534
|
name: "peer_spawn",
|
|
480
535
|
label: "Spawn peer",
|
|
481
536
|
description:
|
|
482
|
-
`Spawn an equal pi peer in a tmux pane with a pre-assigned sessionId. ${SKILL_GATE} It returns only once the peer is verifiably up — it blocks up to ~${SPAWN_BOOT_TIMEOUT_MS / 1000}s until the peer labels the pane it was given, and reports a failure instead of handing back a sid that never booted. The returned sid becomes disk-durable a few seconds after return, when the peer submits its first message (pi persists the session file then, not at boot); a peer killed before that leaves no session — its sid goes dark (mail
|
|
537
|
+
`Spawn an equal pi peer in a tmux pane with a pre-assigned sessionId. ${SKILL_GATE} It returns only once the peer is verifiably up — it blocks up to ~${SPAWN_BOOT_TIMEOUT_MS / 1000}s until the peer labels the pane it was given, and reports a failure instead of handing back a sid that never booted. The returned sid becomes disk-durable a few seconds after return, when the peer submits its first message (pi persists the session file then, not at boot); a peer killed before that leaves no session — its sid goes dark (mail to it is rejected as unknown_session, resume reports not found). The task is delivered at launch as the peer's first user message. Use peer_send_mail for follow-up letters. prompt: path to a role file that stays with the peer across every start — ${ROLE_HINT} Pass a role whenever one fits the mission — it is the main way to shape a peer's stance. The peer-orchestrator skill describes what each role does; any other file path works for a custom role. context: omit = empty | fork:<sessionId> = fork that session's full history, and the forked peer also inherits its role unless prompt overrides it | handoff:<text> = custom context text (prepended to the first message).`,
|
|
483
538
|
promptGuidelines: [
|
|
484
539
|
"Use peer_spawn only when the user asked for parallel/independent work or a task clearly warrants a separate context; every letter sent via peer_send_mail costs the recipient one LLM turn.",
|
|
485
540
|
],
|
|
@@ -489,7 +544,7 @@ export default function piPeer(pi: ExtensionAPI) {
|
|
|
489
544
|
prompt: Type.Optional(
|
|
490
545
|
Type.String({
|
|
491
546
|
description:
|
|
492
|
-
"Path to a role-prompt file (errors if it does not exist). The role stays with the peer across every start — spawn, mail revive, or a user-run /resume — and the file is re-read per turn, so editing it takes effect without respawning. Identity follows the session: /new inside a peer's pane starts a plain session, while a fork inherits the role of the session it branched from unless this overrides it. ${ROLE_HINT} Any other file path works for a custom role
|
|
547
|
+
"Path to a role-prompt file (errors if it does not exist). The role stays with the peer across every start — spawn, mail revive, or a user-run /resume — and the file is re-read per turn, so editing it takes effect without respawning. Identity follows the session: /new inside a peer's pane starts a plain session, while a fork inherits the role of the session it branched from unless this overrides it. ${ROLE_HINT} Omit only when no role fits — a plain peer has no role discipline. Any other file path works for a custom role.",
|
|
493
548
|
}),
|
|
494
549
|
),
|
|
495
550
|
context: Type.Optional(
|
|
@@ -653,14 +708,14 @@ export default function piPeer(pi: ExtensionAPI) {
|
|
|
653
708
|
name: "peer_send_mail",
|
|
654
709
|
label: "Send letter",
|
|
655
710
|
description:
|
|
656
|
-
`Deliver a letter to another session's mailbox (ANY pi session — peers and human-started). ${SKILL_GATE} The letter is stored as a .md file whose frontmatter header (from + summary) you write via the summary parameter. The recipient's poll injects ONLY the summary plus the file path — never the full text; the recipient reads the full letter from that path itself and deletes the file afterwards. Notices reach a running session within ~${POLL_MS / 1000}s — via steer while it is running, via followUp when it is idle — and multiple pending notices are merged into one message. A dead recipient is revived automatically, whether it was spawned as a peer or started by a human (this tool blocks up to ~${REVIVE_TIMEOUT_MS / 1000}s and reports whether the revive succeeded), and reads the notice at boot; if the revive fails the letter stays queued and is read when it next starts. The summary must be self-contained: state who you are (name/sessionId) and what the letter is about; the letter body should also say whether a reply is needed and where to send it.`,
|
|
711
|
+
`Deliver a letter to another session's mailbox (ANY pi session — peers and human-started). ${SKILL_GATE} The recipient must already exist on disk: an id with no session file is rejected with an error and nothing is written or sent (a just-spawned peer becomes addressable a few seconds after peer_spawn returns, once it persists its session file). The letter is stored as a .md file whose frontmatter header (from + summary) you write via the summary parameter. The recipient's poll injects ONLY the summary plus the file path — never the full text; the recipient reads the full letter from that path itself and deletes the file afterwards. Notices reach a running session within ~${POLL_MS / 1000}s — via steer while it is running, via followUp when it is idle — and multiple pending notices are merged into one message. A dead recipient is revived automatically, whether it was spawned as a peer or started by a human (this tool blocks up to ~${REVIVE_TIMEOUT_MS / 1000}s and reports whether the revive succeeded), and reads the notice at boot; if the revive fails the letter stays queued and is read when it next starts. The summary must be self-contained: state who you are (name/sessionId) and what the letter is about; the letter body should also say whether a reply is needed and where to send it.`,
|
|
657
712
|
promptGuidelines: [
|
|
658
713
|
"Batch peer_send_mail letters and keep them substantive: never send acknowledgements or small talk. Multiple pending notices are merged into one message, so one letter per topic is enough. Write a good summary — it is all the recipient sees before deciding to open the letter file.",
|
|
659
714
|
],
|
|
660
715
|
parameters: Type.Object({
|
|
661
716
|
to: Type.String({
|
|
662
717
|
description:
|
|
663
|
-
"Recipient sessionId: the exact lowercase 36-character UUID as peer_list prints it.
|
|
718
|
+
"Recipient sessionId: the exact lowercase 36-character UUID as peer_list prints it. The session must already exist on disk — an id with no session file is rejected and nothing is sent. Prefixes, a different case and any other variant are rejected — an id is never normalised into a match.",
|
|
664
719
|
}),
|
|
665
720
|
summary: Type.String({
|
|
666
721
|
description:
|
|
@@ -671,7 +726,9 @@ export default function piPeer(pi: ExtensionAPI) {
|
|
|
671
726
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
672
727
|
const tmuxProblem = await tmuxGuard(pi);
|
|
673
728
|
if (tmuxProblem) return errResult("no_tmux", tmuxProblem, { state: "no_tmux" });
|
|
674
|
-
//
|
|
729
|
+
// The letter is composed but not yet on disk: a recipient that exists nowhere on disk is a typo (or a
|
|
730
|
+
// peer that never booted), so refuse it before writing anything — an unreadable letter in a dead
|
|
731
|
+
// mailbox is invisible to both sides.
|
|
675
732
|
const sid = params.to.trim();
|
|
676
733
|
if (!UUID_RE.test(sid)) {
|
|
677
734
|
return errResult(
|
|
@@ -682,19 +739,23 @@ export default function piPeer(pi: ExtensionAPI) {
|
|
|
682
739
|
}
|
|
683
740
|
const records = await scanAllSessions(pi);
|
|
684
741
|
const target = findSessionById(records, sid);
|
|
742
|
+
if (!target) {
|
|
743
|
+
return errResult(
|
|
744
|
+
"unknown_session",
|
|
745
|
+
`no session with id ${sid} exists on disk; nothing was sent. Pass the exact sessionId of an existing session (peer_list shows them).`,
|
|
746
|
+
{ state: "unknown_session", sessionId: sid },
|
|
747
|
+
);
|
|
748
|
+
}
|
|
685
749
|
const self = ctx.sessionManager.getSessionId();
|
|
686
750
|
const selfName = findSessionById(records, self)?.name;
|
|
687
751
|
const from = selfName ? `${selfName} (sessionId: ${self})` : `sessionId: ${self}`;
|
|
688
752
|
const letterFile = await post(sid, params.message, { from, summary: params.summary });
|
|
689
|
-
const label = target
|
|
753
|
+
const label = target.name ?? sid;
|
|
690
754
|
const done = (state: string, text: string) => ({
|
|
691
755
|
content: [{ type: "text" as const, text: `${text} | letter file: ${letterFile}` }],
|
|
692
756
|
details: { recipient: label, sessionId: sid, state, letterFile },
|
|
693
757
|
});
|
|
694
758
|
|
|
695
|
-
if (!target) {
|
|
696
|
-
return done("queued_no_session", `sent → ${label} — no session on disk yet; the letter is queued and will be read when it starts`);
|
|
697
|
-
}
|
|
698
759
|
if (target.alive) {
|
|
699
760
|
return done("alive", `sent → ${label} — alive; it picks the letter up within ~${POLL_MS / 1000}s`);
|
|
700
761
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@semanaut/zpi-peer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"piVersion": "1.2.0",
|
|
5
|
+
"description": "pi extension: equal, durable pi peers managed through tmux panes plus a letter channel between any pi sessions. Tools: peer_spawn (launch a peer in a tmux pane), peer_list (list peers, alive or exited), peer_send_mail (deliver a letter, reviving dead recipients). Ships investigator / strategist / auditor role prompts; developed against the pi version given by the piVersion field",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"keywords": [
|
|
7
8
|
"pi-package"
|
|
@@ -9,8 +10,10 @@
|
|
|
9
10
|
"license": "MIT",
|
|
10
11
|
"files": [
|
|
11
12
|
"extensions",
|
|
12
|
-
"
|
|
13
|
-
"
|
|
13
|
+
"skills",
|
|
14
|
+
"AGENTS.investigator.md",
|
|
15
|
+
"AGENTS.strategist.md",
|
|
16
|
+
"AGENTS.auditor.md"
|
|
14
17
|
],
|
|
15
18
|
"pi": {
|
|
16
19
|
"extensions": [
|
|
@@ -67,23 +67,44 @@ session, never the process: `/new` inside a peer's pane starts a plain session,
|
|
|
67
67
|
inherits the role of the session it branched from (unless you pass `prompt`) — spawn a proper
|
|
68
68
|
peer when a new one is needed.
|
|
69
69
|
|
|
70
|
+
**Pass a role whenever one fits the mission.** It is the main lever you have for shaping a peer's
|
|
71
|
+
stance, and it costs one path string. Leave it empty only when no standard role fits and the task
|
|
72
|
+
text alone can carry the discipline you need — a plain peer has no role discipline at all.
|
|
73
|
+
|
|
74
|
+
## What a role is here
|
|
75
|
+
|
|
76
|
+
Peers are flat and equal: same toolset, same permissions, no hierarchy. A role therefore cannot be
|
|
77
|
+
a permission slice — it is a **responsibility and a stance**, expressed as what the peer produces
|
|
78
|
+
and what it refuses to do. Two consequences:
|
|
79
|
+
|
|
80
|
+
- A role must change the output contract or the discipline, not the subject matter. "Review the
|
|
81
|
+
frontend" is a task; "verify, audit, then criticize" is a role. Subject variation belongs in `task`.
|
|
82
|
+
- A role never implies rank. No role is an overseer of another, and a role file must not tell a
|
|
83
|
+
peer to direct, schedule, or supervise other peers. Every peer can spawn and mail every other.
|
|
84
|
+
|
|
85
|
+
Peers launch with the full toolset: whatever discipline a role shows comes from its stance and its
|
|
86
|
+
task text, never from restrictions. A peer can always mail you back.
|
|
87
|
+
|
|
70
88
|
## The standard roles
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
and
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
89
|
+
|
|
90
|
+
Pass the path of the role file you want to `peer_spawn`'s prompt parameter; the exact paths are
|
|
91
|
+
given in the peer_spawn tool description. For a custom role, pass the path of any other role file.
|
|
92
|
+
|
|
93
|
+
- **investigator** — find out what is true. Merges codebase reconnaissance with outside research:
|
|
94
|
+
a map of where things are, or a sourced brief on what the evidence says. It cites every path and
|
|
95
|
+
URL it actually checked, and marks the boundary between what it verified and what it infers. Use
|
|
96
|
+
it whenever you need facts you do not have.
|
|
97
|
+
- **strategist** — make the call. Given a problem, or given alternatives someone else produced, it
|
|
98
|
+
weighs them and commits: real options with what each buys and costs, the axis being balanced, one
|
|
99
|
+
recommendation definite enough to be wrong, and the fact that would flip it. Decisive, not
|
|
100
|
+
analytic — it decides, then says what would change its mind. It proposes; it does not plan
|
|
101
|
+
execution steps or direct other peers. Use it before committing to an approach.
|
|
102
|
+
- **auditor** — expose what does not hold, and dare to say so. It verifies by actually doing:
|
|
103
|
+
re-runs, reproduces, re-derives — then audits the path actually taken (including what was
|
|
104
|
+
skipped), names the risks, and only then criticizes: VERDICT (agree / agree-with-changes /
|
|
105
|
+
object) plus numbered findings by severity, each with evidence and a concrete suggestion. It
|
|
106
|
+
pours cold water on warm consensus and never rubber-stamps — give it the artifact, not your
|
|
107
|
+
defense of it.
|
|
87
108
|
|
|
88
109
|
## Conventions
|
|
89
110
|
- One direction per peer; a peer with two missions does neither well.
|
package/roles/AGENTS.critic.md
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
You are an independent critic peer in a flat network of equal pi instances.
|
|
2
|
-
|
|
3
|
-
Rules:
|
|
4
|
-
1. Read-only. Your value is an unpolluted second opinion, not execution.
|
|
5
|
-
2. Judge against evidence: quote the exact file/line or message you object to.
|
|
6
|
-
3. Structure verdicts as: VERDICT (agree / agree-with-changes / object), then numbered
|
|
7
|
-
findings ordered by severity, each with evidence and a concrete suggestion.
|
|
8
|
-
4. Send your verdict to the requester's inbox with peer_send_mail; sign with name, role, sessionId.
|
|
9
|
-
5. Do not rubber-stamp. If you find nothing wrong, say what you checked and why it holds.
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
You are a read-only research peer in a flat network of equal pi instances.
|
|
2
|
-
|
|
3
|
-
Rules:
|
|
4
|
-
1. Do not modify files or run commands with side effects.
|
|
5
|
-
2. Every conclusion must cite its source (file path or URL).
|
|
6
|
-
3. When done, send your findings brief to the requester's inbox with peer_send_mail:
|
|
7
|
-
sign the letter with your name, role and sessionId, and state that it is a result report.
|
|
8
|
-
4. If you are missing information, ask by letter: state clearly that the letter is a question,
|
|
9
|
-
ask exactly one question, and give the sessionId your reply should be sent to.
|
|
10
|
-
5. You are an equal peer, not a subordinate: push back with evidence if the task looks wrong.
|
package/roles/AGENTS.scout.md
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
You are a scout peer in a flat network of equal pi instances.
|
|
2
|
-
|
|
3
|
-
Rules:
|
|
4
|
-
1. Read-only and fast: locate, don't analyze in depth.
|
|
5
|
-
2. Produce a map: key directories, entry points, relevant files with one-line descriptions,
|
|
6
|
-
and where the answers to the task's questions live.
|
|
7
|
-
3. Every pointer must be a real path you verified exists.
|
|
8
|
-
4. Send the map to the requester's inbox with peer_send_mail; sign with name, role, sessionId.
|
|
9
|
-
5. If the task is ambiguous, make the most useful reasonable interpretation and state it.
|