@fradser/pi-agent-teams 0.7.0 → 0.8.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.
@@ -5,12 +5,13 @@
5
5
  * engine described by the BDD contract.
6
6
  */
7
7
 
8
- import { exec } from "node:child_process";
9
8
  import { randomUUID } from "node:crypto";
10
9
  import * as fs from "node:fs";
11
10
  import * as path from "node:path";
12
11
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
13
- import { resolveAgent } from "./agents.ts";
12
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
13
+ import { modelLabel, runPiWorker } from "@fradser/pi-kit";
14
+ import { MODEL_INHERIT_ALIAS, discoverAgents, persistAgentDefinition, registerSessionAgent, resolveAgent, type AgentDefinition, type AgentDefinitionInput } from "./agents.ts";
14
15
  import {
15
16
  applyClaimIntent,
16
17
  claimableTasks,
@@ -22,6 +23,7 @@ import {
22
23
  getPeerInboxOffset,
23
24
  getState,
24
25
  getTeammate,
26
+ getTeamDefaultModel,
25
27
  idleTeammates,
26
28
  isPeerDelivered,
27
29
  isValidTeammateName,
@@ -74,7 +76,19 @@ import type { FollowUpReport } from "./follow-up-queue.ts";
74
76
  export const MAX_SESSION_WORKERS = 8;
75
77
  /** Harness coordination cadence: outbox drain every tick, notices paced. */
76
78
  const LIVE_POLL_MS = 500;
77
- export const NOTICE_PACE_MS = 2000;
79
+ /** Minimum gap between claimable-task notices per teammate. One-shot noticing
80
+ * makes repeats rare; this floor keeps any residual burst from stampeding
81
+ * every idle teammate at once, and each wake costs a full worker turn. */
82
+ const DEFAULT_NOTICE_PACE_MS = 5 * 60 * 1000;
83
+ export const NOTICE_PACE_MS = readDurationEnv("PI_TEAMMATE_NOTICE_PACE_MS", DEFAULT_NOTICE_PACE_MS);
84
+ /** Consecutive verify failures before the harness stops inviting resubmission and escalates to the leader instead. */
85
+ export const VERIFY_FAILURE_ESCALATE_AFTER = 2;
86
+ /** Claimable task ids remembered per teammate. markTasksNoticed prunes stale
87
+ * ids on every notice, so slots are occupied only by currently-claimable
88
+ * work; the cap is far above any realistic concurrent board size. */
89
+ const MAX_NOTICED_TASK_IDS = 256;
90
+ /** Claimable tasks listed in one wake-prompt board notice. */
91
+ const WAKE_NOTICE_TASK_LIMIT = 10;
78
92
  /** Silence is not a work deadline: active stream output keeps a teammate alive.
79
93
  * The watchdog may only notify; termination decisions belong to the leader model. */
80
94
  const DEFAULT_STALL_NOTICE_MS = 30 * 60 * 1000;
@@ -117,14 +131,23 @@ let generation = 0;
117
131
  let runtimeStateFile = "";
118
132
  let boardFile = "";
119
133
  let leaderCwd = "";
134
+ /** Live view of the leader session's current model, resolved at spawn time. */
135
+ let leaderModelRef: () => string | undefined = () => undefined;
120
136
  let sendUpdate: (report: FollowUpReport) => void = () => {};
121
137
  let notifyChange: () => void = () => {};
122
138
 
123
139
  const pendingShutdowns = new Set<string>();
124
- /** Task ids under verification, bound to the claiming teammate incarnation. */
125
- const verifyingTasks = new Map<string, { worker: string; spawnId: string }>();
140
+ /** Task ids under verification, bound to one exact submission via token so a
141
+ * release/re-claim or newer submission invalidates any older in-flight gate. */
142
+ const verifyingTasks = new Map<string, { worker: string; spawnId: string; token: string }>();
126
143
  /** Idle nudges already fired per teammate incarnation (one per transition). */
127
144
  const idleNudgesSent = new Set<string>();
145
+ /** One finish entry per spawn incarnation; repeated terminal reports stay ordinary report rows. */
146
+ const announcedFinishKeys = new Set<string>();
147
+ /** Incarnations whose terminal report reached the leader pipeline, queued or dispatched. */
148
+ const terminalReportKeys = new Set<string>();
149
+ /** Consecutive verify failures per taskId:spawnId holder incarnation. */
150
+ const verifyFailures = new Map<string, VerifyFailureRecord>();
128
151
  /** Self-finalize requests delivered per teammate incarnation before escalating to the leader. */
129
152
  const selfFinalizeAttempts = new Set<string>();
130
153
  const pendingDeliveries = new Map<string, InboxMessage[]>();
@@ -138,11 +161,12 @@ export interface MachineHooks {
138
161
  }
139
162
 
140
163
  export function initTeamMachine(
141
- ctx: Pick<ExtensionContext, "sessionManager" | "cwd">,
164
+ ctx: Pick<ExtensionContext, "sessionManager" | "cwd" | "model">,
142
165
  hooks: MachineHooks,
143
166
  ): void {
144
167
  generation++;
145
168
  leaderCwd = ctx.cwd || process.cwd();
169
+ leaderModelRef = () => (ctx.model ? modelLabel(ctx.model) : undefined);
146
170
  const sessionFile = ctx.sessionManager?.getSessionFile();
147
171
  runtimeStateFile = stateFilePath(sessionFile, leaderCwd);
148
172
  boardFile = boardFilePath(sessionFile, leaderCwd);
@@ -160,6 +184,8 @@ export function shutdownTeamMachine(): void {
160
184
  runtimeStateFile = "";
161
185
  boardFile = "";
162
186
  leaderCwd = "";
187
+ leaderModelRef = () => undefined;
188
+ setVerifyGateRunner(undefined);
163
189
  sendUpdate = () => {};
164
190
  notifyChange = () => {};
165
191
  pendingShutdowns.clear();
@@ -167,6 +193,39 @@ export function shutdownTeamMachine(): void {
167
193
  idleNudgesSent.clear();
168
194
  selfFinalizeAttempts.clear();
169
195
  pendingDeliveries.clear();
196
+ verifyFailures.clear();
197
+ announcedFinishKeys.clear();
198
+ terminalReportKeys.clear();
199
+ }
200
+
201
+ // ── Spawn model resolution ────────────────────────────────────
202
+
203
+ /** How a spawn's effective model was chosen. */
204
+ export type SpawnModelSource = "pin" | "inherit" | "team-default" | "none";
205
+
206
+ /**
207
+ * Resolve the effective spawn model. Precedence: explicit role pin beats the
208
+ * `inherit` alias (the leader session's current model), which beats the team
209
+ * default set from the console; with none of these Pi picks its own default.
210
+ * The value is resolved at spawn time so mid-session leader model switches
211
+ * apply to later spawns.
212
+ */
213
+ export function resolveSpawnModel(
214
+ pinned: string | undefined,
215
+ teamDefault: string | undefined,
216
+ leaderModel: string | undefined,
217
+ ): { model?: string; source: SpawnModelSource } {
218
+ const pin = pinned?.trim();
219
+ if (pin && pin.toLowerCase() !== MODEL_INHERIT_ALIAS) return { model: pin, source: "pin" };
220
+ if (pin && leaderModel) return { model: leaderModel, source: "inherit" };
221
+ const fallback = teamDefault?.trim();
222
+ if (fallback) return { model: fallback, source: "team-default" };
223
+ return { model: undefined, source: "none" };
224
+ }
225
+
226
+ /** The leader session's current model reference, when one is selected. */
227
+ export function currentLeaderModelRef(): string | undefined {
228
+ return leaderModelRef();
170
229
  }
171
230
 
172
231
  /** Terminate every resident teammate; returns unconfirmed-close diagnostics. */
@@ -247,12 +306,37 @@ export function spawnTeammate(input: {
247
306
  name: string;
248
307
  agent: string;
249
308
  prompt?: string;
309
+ definition?: Omit<AgentDefinitionInput, "name" | "tools"> & {
310
+ tools?: string[];
311
+ persist?: boolean;
312
+ persistScope?: "project" | "project-local";
313
+ };
250
314
  }): { ok: true; teammate: Teammate } | { ok: false; error: string } {
251
315
  const stateFile = requireStateFile();
252
316
  const invalid = validateSpawnInput(input);
253
317
  if (invalid) return { ok: false, error: invalid };
254
- const agent = resolveAgent(input.agent, leaderCwd);
255
- if (!agent) return { ok: false, error: `Agent "${input.agent}" not found in any scope.` };
318
+ const resolved = resolveAgent(input.agent, leaderCwd);
319
+ let agent: AgentDefinition | undefined = resolved;
320
+ if (input.definition && inlineDefinitionApplies(resolved)) {
321
+ try {
322
+ const generated = input.definition;
323
+ const roleInput: AgentDefinitionInput = {
324
+ name: input.agent,
325
+ description: generated.description,
326
+ tools: generated.tools ?? [],
327
+ model: generated.model,
328
+ verify: generated.verify,
329
+ worktree: generated.worktree,
330
+ prompt: generated.prompt,
331
+ };
332
+ agent = generated.persist
333
+ ? persistAgentDefinition(roleInput, generated.persistScope ?? "project-local", leaderCwd)
334
+ : registerSessionAgent(roleInput);
335
+ } catch (error) {
336
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
337
+ }
338
+ }
339
+ if (!agent) return { ok: false, error: unknownAgentError(input.agent, leaderCwd) };
256
340
 
257
341
  let isolation: Teammate["isolation"] = "none";
258
342
  let workerCwd = leaderCwd;
@@ -271,10 +355,13 @@ export function spawnTeammate(input: {
271
355
  return { ok: false, error: registered.error };
272
356
  }
273
357
 
358
+ const spawnModel = resolveSpawnModel(agent.model, getTeamDefaultModel(), leaderModelRef());
359
+ updateTeammate(input.name, { model: spawnModel.model });
360
+
274
361
  const started = spawnResident({
275
362
  workerName: input.name,
276
363
  description: buildKickoffPrompt(input.name, input.agent, agent.prompt, input.prompt, isolation),
277
- model: agent.model,
364
+ model: spawnModel.model,
278
365
  tools: agent.tools,
279
366
  cwd: workerCwd,
280
367
  env: teammateEnv(stateFile, input, spawnId, agent.verify),
@@ -294,6 +381,28 @@ export function spawnTeammate(input: {
294
381
  return { ok: true, teammate: getTeammate(input.name)! };
295
382
  }
296
383
 
384
+ /** True when a spawn's inline definition should create or replace the role:
385
+ * nothing resolved, or only a session-scoped generated role did. Filesystem
386
+ * scopes are user-owned and always win over inline input. */
387
+ export function inlineDefinitionApplies(resolved: AgentDefinition | undefined): boolean {
388
+ return !resolved || resolved.scope === "session";
389
+ }
390
+
391
+ /** Spawn failure for an unresolvable agent name. Definitions resolve live at
392
+ * spawn time, so the available-agents list in the cached session guidance can
393
+ * go stale (e.g. another session removed the file); name every checked scope
394
+ * and the recovery path. */
395
+ export function unknownAgentError(name: string, cwd: string): string {
396
+ const available = [...discoverAgents(cwd).keys()];
397
+ return [
398
+ `Agent "${name}" not found in any scope.`,
399
+ `Checked: ${cwd}/.pi/agents/<name>.local.md, ${cwd}/.pi/agents/<name>.md, ${path.join(getAgentDir(), "agents")}, and in-memory session roles.`,
400
+ 'The available-agents list in your guidance may be stale: definition files can change mid-session (for example removed by a parallel session).',
401
+ 'Recover by spawning with an inline definition derived from references/agent-roles.md, choose an existing role, or create the role first.',
402
+ `Available now: ${available.length > 0 ? available.join(", ") : "(none)"}.`,
403
+ ].join(' ');
404
+ }
405
+
297
406
  function validateSpawnInput(input: { name: string }): string | undefined {
298
407
  if (!isValidTeammateName(input.name)) {
299
408
  return `Invalid teammate name "${input.name}". Use letters, digits, dots, dashes, underscores.`;
@@ -418,6 +527,47 @@ function checkStalledTeammates(now = Date.now()): void {
418
527
  }
419
528
  }
420
529
 
530
+ interface VerifyFailureRecord {
531
+ count: number;
532
+ escalated: boolean;
533
+ }
534
+
535
+ /** Record one finish entry per spawn incarnation; later terminal reports from
536
+ * the same resident stay ordinary report rows. */
537
+ export function markTeammateFinished(
538
+ report: Pick<FollowUpReport, "teammate" | "agent" | "spawnId" | "finished">,
539
+ ): boolean {
540
+ if (!report.finished) return false;
541
+ const name = report.teammate ?? report.agent ?? "teammate";
542
+ const key = `${name}:${report.spawnId ?? "session"}`;
543
+ if (announcedFinishKeys.has(key)) return false;
544
+ announcedFinishKeys.add(key);
545
+ return true;
546
+ }
547
+
548
+ /** True when this teammate's current incarnation already announced its finish entry. */
549
+ export function hasAnnouncedFinish(name: string): boolean {
550
+ const spawnId = getTeammate(name)?.spawnId;
551
+ return announcedFinishKeys.has(`${name}:${spawnId ?? "session"}`);
552
+ }
553
+
554
+ /** Remember at send time that this incarnation produced a terminal report, so
555
+ * end-of-life suppression covers reports still queued for dispatch. */
556
+ export function recordTerminalReport(
557
+ report: Pick<FollowUpReport, "teammate" | "agent" | "spawnId" | "finished">,
558
+ ): void {
559
+ if (!report.finished) return;
560
+ const name = report.teammate ?? report.agent ?? "teammate";
561
+ terminalReportKeys.add(`${name}:${report.spawnId ?? "session"}`);
562
+ }
563
+
564
+ /** True when this teammate's current incarnation has a terminal report in the
565
+ * leader pipeline, whether or not its finish entry has been dispatched yet. */
566
+ export function hasTerminalReport(name: string): boolean {
567
+ const spawnId = getTeammate(name)?.spawnId;
568
+ return terminalReportKeys.has(`${name}:${spawnId ?? "session"}`);
569
+ }
570
+
421
571
  /** True when the teammate's last leader-bound report lacks a terminal status. */
422
572
  export function hasUnfinalizedReport(name: string): boolean {
423
573
  const mailbox = getState().leaderMailbox;
@@ -481,6 +631,11 @@ export async function shutdownTeammate(name: string): Promise<{ ok: true; body:
481
631
  // The child was already gone; synthesize the close bookkeeping.
482
632
  pendingShutdowns.delete(name);
483
633
  const released = releaseTasksOf(name, "Teammate was shut down.");
634
+ for (const task of released) {
635
+ verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
636
+ verifyingTasks.delete(task.id);
637
+ rearmTaskNotice(task.id);
638
+ }
484
639
  updateTeammate(name, { status: "stopped", activeTool: undefined });
485
640
  idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
486
641
  selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
@@ -509,6 +664,11 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
509
664
  drainTeammateOutboxes();
510
665
 
511
666
  const released = releaseTasksOf(name, requested ? "Teammate was shut down." : "Teammate stopped unexpectedly.");
667
+ for (const task of released) {
668
+ verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
669
+ verifyingTasks.delete(task.id);
670
+ rearmTaskNotice(task.id);
671
+ }
512
672
  pendingShutdowns.delete(name);
513
673
  // A stopped teammate must not keep the poll loop or its queue alive.
514
674
  pendingDeliveries.delete(name);
@@ -527,11 +687,14 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
527
687
  : { from: name, subject: "Teammate stopped unexpectedly", body: crashDiagnostic(name, result, released) });
528
688
 
529
689
  if (!requested) {
530
- sendUpdate({
690
+ const report = {
531
691
  teammate: name,
692
+ spawnId: teammate.spawnId,
532
693
  body: `@${name} stopped unexpectedly${released.length > 0 ? `; claimed task(s) ${released.map((t) => t.id).join(", ")} returned to the board` : ""}.`,
533
694
  finished: true,
534
- });
695
+ };
696
+ recordTerminalReport(report);
697
+ sendUpdate(report);
535
698
  }
536
699
  publishStateSnapshot();
537
700
  ensureLivePoll();
@@ -632,13 +795,15 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
632
795
  });
633
796
  if (record.status === "completed" || record.status === "failed") {
634
797
  // The assignment ends; the resident itself stays alive until sequence end.
635
- sendUpdate({
798
+ const report = {
636
799
  teammate: teammate.name,
637
800
  agent: teammate.agent,
638
801
  spawnId: teammate.spawnId,
639
802
  body: record.body,
640
803
  finished: true,
641
- });
804
+ };
805
+ recordTerminalReport(report);
806
+ sendUpdate(report);
642
807
  }
643
808
  return true;
644
809
  }
@@ -770,6 +935,9 @@ function applyClaimMarker(intent: import("./types").TaskIntent): void {
770
935
  if (!sender) return;
771
936
  const outcome = applyClaimIntent(intent);
772
937
  if (outcome.applied) {
938
+ verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
939
+ // A new holding must not inherit an in-flight gate from a previous one.
940
+ verifyingTasks.delete(intent.taskId);
773
941
  updateTeammate(intent.worker, { currentTaskId: intent.taskId });
774
942
  return;
775
943
  }
@@ -786,6 +954,9 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
786
954
  }
787
955
  if (intent.status === "failed") {
788
956
  releaseTask(intent.taskId, intent.result?.trim() || "Teammate reported failure.");
957
+ verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
958
+ verifyingTasks.delete(intent.taskId);
959
+ rearmTaskNotice(intent.taskId);
789
960
  freeTeammateFromTask(intent.worker, intent.taskId);
790
961
  notifyTaskOutcome(task.subject, `${intent.worker} reported failure`, intent.result ?? "");
791
962
  return;
@@ -793,6 +964,17 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
793
964
  beginVerifyOrComplete(intent, task.verify ?? resolveAgent(sender.agent, leaderCwd)?.verify);
794
965
  }
795
966
 
967
+ /** Fold one more verify failure into bookkeeping. The first failure invites a
968
+ * fix-and-resubmit cycle; from the second the harness escalates to the leader
969
+ * exactly once per holding and stays quiet after that. */
970
+ export function reactToVerifyFailure(
971
+ previous: VerifyFailureRecord | undefined,
972
+ ): VerifyFailureRecord & { escalateToLeader: boolean } {
973
+ const count = (previous?.count ?? 0) + 1;
974
+ const escalated = previous?.escalated === true || count >= VERIFY_FAILURE_ESCALATE_AFTER;
975
+ return { count, escalated, escalateToLeader: escalated && previous?.escalated !== true };
976
+ }
977
+
796
978
  function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: string | undefined): void {
797
979
  const task = getState().tasks[intent.taskId];
798
980
  if (!task) return;
@@ -800,30 +982,71 @@ function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: str
800
982
  finishCompletion(intent, task.subject);
801
983
  return;
802
984
  }
803
- // The gate is bound to this claim: a crash/release/re-claim between submit
804
- // and verify resolution must not let an old result complete a new holder.
805
- verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId });
806
- runVerifyCommand(verify)
807
- .then((outcome) => {
808
- verifyingTasks.delete(intent.taskId);
809
- const current = getState().tasks[intent.taskId];
810
- const stillHolds = current?.status === "claimed"
811
- && current.claimedBy === intent.worker
812
- && getTeammate(intent.worker)?.spawnId === intent.spawnId;
813
- if (!stillHolds) return;
814
- if (outcome.ok) finishCompletion(intent, task.subject);
815
- else {
816
- deliverFeedback(
817
- intent.worker,
818
- `Verify failed for ${intent.taskId}`,
819
- [`The completion gate for "${task.subject}" failed.`, outcome.detail ?? "(no verify output)", "Fix the issues and resubmit with task_submit."].filter(Boolean).join("\n"),
820
- );
821
- notifyTaskOutcome(task.subject, `verify gate failed for ${intent.taskId}`, outcome.detail ?? "(no verify output)");
985
+ // The gate is bound to this exact submission via a unique token: a
986
+ // release/re-claim or newer submission between submit and verify resolution
987
+ // must not let the stale result complete the new holding.
988
+ const token = randomUUID();
989
+ verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, token });
990
+ const input: VerifyReviewInput = {
991
+ verify,
992
+ taskSubject: task.subject,
993
+ workerResult: intent.result ?? "",
994
+ cwd: getTeammate(intent.worker)?.cwd || leaderCwd,
995
+ };
996
+ // A runner that throws synchronously or rejects is a failed gate, never a
997
+ // silent stuck claim: the holder gets feedback and escalation still applies.
998
+ Promise.resolve()
999
+ .then(() => verifyGateRunner(input))
1000
+ .then((outcome) => resolveGateOutcome(intent, task.subject, token, outcome))
1001
+ .catch((error) => resolveGateOutcome(intent, task.subject, token, {
1002
+ ok: false,
1003
+ detail: truncated(`(completion review crashed) ${error instanceof Error ? error.message : String(error)}`),
1004
+ }));
1005
+ }
1006
+
1007
+ /** Apply one gate outcome to its submission, guarded by the binding token. */
1008
+ function resolveGateOutcome(
1009
+ intent: import("./types").TaskIntent,
1010
+ subject: string,
1011
+ token: string,
1012
+ outcome: VerifyReviewOutcome,
1013
+ ): void {
1014
+ if (verifyingTasks.get(intent.taskId)?.token !== token) return;
1015
+ verifyingTasks.delete(intent.taskId);
1016
+ const current = getState().tasks[intent.taskId];
1017
+ const stillHolds = current?.status === "claimed"
1018
+ && current.claimedBy === intent.worker
1019
+ && getTeammate(intent.worker)?.spawnId === intent.spawnId;
1020
+ if (!stillHolds) return;
1021
+ if (outcome.ok) {
1022
+ finishCompletion(intent, subject);
1023
+ } else {
1024
+ const key = `${intent.taskId}:${intent.spawnId}`;
1025
+ const reaction = reactToVerifyFailure(verifyFailures.get(key));
1026
+ verifyFailures.set(key, reaction);
1027
+ const detail = outcome.detail ?? "(no review output)";
1028
+ if (reaction.count >= VERIFY_FAILURE_ESCALATE_AFTER) {
1029
+ // An unfixable gate parks the task with its holder instead of
1030
+ // looping: no further resubmit invitations, one leader escalation.
1031
+ if (reaction.escalateToLeader) {
1032
+ notifyTaskOutcome(subject, `verify failed ${reaction.count} times for ${intent.taskId}: manual attention needed`, detail);
822
1033
  }
823
- ensureLivePoll();
824
- notifyChange();
825
- })
826
- .catch(() => verifyingTasks.delete(intent.taskId));
1034
+ deliverFeedback(
1035
+ intent.worker,
1036
+ `Verify still failing for ${intent.taskId}`,
1037
+ [`The completion gate for "${subject}" failed again (${reaction.count} consecutive failures).`, detail, "The task stays claimed by you. Do not resubmit or reclaim it; the leader has been notified and will decide next steps."].join("\n"),
1038
+ );
1039
+ } else {
1040
+ deliverFeedback(
1041
+ intent.worker,
1042
+ `Verify failed for ${intent.taskId}`,
1043
+ [`The completion gate for "${subject}" failed.`, detail, "Fix the issues and resubmit with task_submit."].join("\n"),
1044
+ );
1045
+ notifyTaskOutcome(subject, `verify gate failed for ${intent.taskId}`, detail);
1046
+ }
1047
+ }
1048
+ ensureLivePoll();
1049
+ notifyChange();
827
1050
  }
828
1051
 
829
1052
  function finishCompletion(intent: import("./types").TaskIntent, subject: string): void {
@@ -831,21 +1054,92 @@ function finishCompletion(intent: import("./types").TaskIntent, subject: string)
831
1054
  if (!task) return;
832
1055
  const completed = completeTask(intent.taskId, intent.result);
833
1056
  if (!completed) return;
1057
+ verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
834
1058
  freeTeammateFromTask(intent.worker, intent.taskId);
835
1059
  notifyTaskOutcome(subject, `${intent.worker} completed`, intent.result ?? "");
836
1060
  }
837
1061
 
838
- function runVerifyCommand(command: string): Promise<{ ok: boolean; detail?: string }> {
839
- return new Promise((resolve) => {
840
- exec(command, { cwd: leaderCwd, maxBuffer: 1024 * 1024 }, (error, _stdout, stderr) => {
841
- const code = (error as { code?: number | string } | null)?.code;
842
- if (error) {
843
- resolve({ ok: false, detail: `verify exited with code ${code ?? "unknown"}: ${(stderr || error.message).slice(0, 4000)}` });
844
- return;
845
- }
846
- resolve({ ok: true });
847
- });
1062
+ // ── Verify gate: fresh one-shot reviewer with a VERDICT protocol ──
1063
+
1064
+ /** The reviewer's reply must end with exactly one of these verdict lines. */
1065
+ export const VERIFY_VERDICT_PASS = "VERDICT: PASS";
1066
+ export const VERIFY_VERDICT_FAIL = "VERDICT: FAIL";
1067
+
1068
+ export interface VerifyReviewInput {
1069
+ /** The acceptance-gate prompt (task-level or agent-role default). */
1070
+ verify: string;
1071
+ /** Board subject of the gated task. */
1072
+ taskSubject: string;
1073
+ /** The claimer's own result summary; evidence, never trusted alone. */
1074
+ workerResult: string;
1075
+ /** Working directory to review: the holder's worktree root when isolated,
1076
+ * else the leader cwd — the reviewer must inspect the claimed work's tree. */
1077
+ cwd: string;
1078
+ }
1079
+
1080
+ export type VerifyReviewOutcome = { ok: boolean; detail?: string };
1081
+
1082
+ /** Compose the reviewer prompt: fresh context, independent checks, explicit verdict line. */
1083
+ export function buildVerifyReviewPrompt(input: VerifyReviewInput): string {
1084
+ const result = input.workerResult.trim().slice(0, 4000);
1085
+ return [
1086
+ "You are a fresh completion-gate reviewer for one teammate task on a shared board.",
1087
+ `Task subject: ${input.taskSubject}`,
1088
+ result ? `The claimer's result summary:\n${result}` : "The claimer provided no result summary.",
1089
+ "Independently verify the work against the acceptance gate below using your tools; do not trust the summary alone. Do not modify any files.",
1090
+ "Acceptance gate:",
1091
+ input.verify.trim(),
1092
+ "",
1093
+ `End your final message with exactly one verdict line: "${VERIFY_VERDICT_PASS}" if the gate holds, or "${VERIFY_VERDICT_FAIL} - <reasons>" if it does not.`,
1094
+ ].join("\n");
1095
+ }
1096
+
1097
+ /** Parse the reviewer's reply into a gate outcome, scanning from the final
1098
+ * line upward. PASS must be an exact verdict line ("VERDICT: PASS", no
1099
+ * trailing content) so a contradictory suffix cannot sneak through; FAIL
1100
+ * carries its reasons. A missing or malformed verdict fails the gate so an
1101
+ * ambiguous review never completes a task. */
1102
+ export function parseVerifyVerdict(text: string): VerifyReviewOutcome {
1103
+ const lines = text.trim().split("\n");
1104
+ for (let index = lines.length - 1; index >= 0; index--) {
1105
+ const candidate = lines[index].trim();
1106
+ if (/^verdict:\s*pass$/i.test(candidate)) return { ok: true };
1107
+ const failMatch = /^verdict:\s*fail\b[\s:-]*(.*)$/i.exec(candidate);
1108
+ if (failMatch) {
1109
+ return { ok: false, detail: truncated(failMatch[1].trim()) || "(no reasons given)" };
1110
+ }
1111
+ }
1112
+ return { ok: false, detail: `(no ${VERIFY_VERDICT_PASS}/FAIL verdict line in the review)\n${truncated(text.trim())}` };
1113
+ }
1114
+
1115
+ function truncated(text: string, cap = 4000): string {
1116
+ const suffix = "\n…[truncated]";
1117
+ return text.length <= cap ? text : text.slice(0, Math.max(0, cap - suffix.length)) + suffix;
1118
+ }
1119
+
1120
+ /** Run the gate as a one-shot Pi worker: a brand-new context that inspects
1121
+ * the working tree itself before answering. Uses the team's model resolution
1122
+ * chain (team default, else Pi default) rather than any role pin. */
1123
+ export async function runVerifyReview(input: VerifyReviewInput): Promise<VerifyReviewOutcome> {
1124
+ const outcome = await runPiWorker({
1125
+ prompt: buildVerifyReviewPrompt(input),
1126
+ cwd: input.cwd || leaderCwd,
1127
+ model: resolveSpawnModel(undefined, getTeamDefaultModel(), leaderModelRef()).model,
848
1128
  });
1129
+ // A reviewer that did not exit cleanly produced no trustworthy verdict,
1130
+ // even if partial output happens to contain a PASS line.
1131
+ if (outcome.exitCode !== 0) {
1132
+ return { ok: false, detail: truncated(`reviewer exited with code ${outcome.exitCode}: ${(outcome.stderr || outcome.text || "no output").trim()}`) };
1133
+ }
1134
+ return parseVerifyVerdict(outcome.text);
1135
+ }
1136
+
1137
+ /** Active gate runner; tests inject a stub through setVerifyGateRunner. */
1138
+ let verifyGateRunner: (input: VerifyReviewInput) => Promise<VerifyReviewOutcome> = runVerifyReview;
1139
+
1140
+ /** Replace the completion-gate runner (test seam); undefined restores the real reviewer. */
1141
+ export function setVerifyGateRunner(runner: ((input: VerifyReviewInput) => Promise<VerifyReviewOutcome>) | undefined): void {
1142
+ verifyGateRunner = runner ?? runVerifyReview;
849
1143
  }
850
1144
 
851
1145
  function freeTeammateFromTask(workerName: string, taskId: string): void {
@@ -868,20 +1162,56 @@ function findLivingTeammate(intent: import("./types").TaskIntent): Teammate | un
868
1162
 
869
1163
  // ── Wake-ups ──────────────────────────────────────────────────────
870
1164
 
1165
+ /** Claimable tasks the teammate has not been notified about yet. */
1166
+ export function freshClaimableTasks<T extends { id: string }>(
1167
+ noticedIds: readonly string[] | undefined,
1168
+ tasks: readonly T[],
1169
+ ): T[] {
1170
+ const noticed = new Set(noticedIds ?? []);
1171
+ return tasks.filter((task) => !noticed.has(task.id));
1172
+ }
1173
+
1174
+ /** Retain only noticed ids that are still claimable, so a long board's history
1175
+ * can never evict a live one and resurrect an old wake-up. */
1176
+ export function retainLiveNoticedIds(noticedIds: readonly string[], claimableIds: ReadonlySet<string>): string[] {
1177
+ return noticedIds.filter((id) => claimableIds.has(id));
1178
+ }
1179
+
1180
+ function markTasksNoticed(name: string, taskIds: string[]): void {
1181
+ if (taskIds.length === 0) return;
1182
+ const teammate = getTeammate(name);
1183
+ if (!teammate) return;
1184
+ const live = retainLiveNoticedIds(teammate.noticedTaskIds ?? [], new Set(claimableTasks().map((task) => task.id)));
1185
+ const merged = [...live, ...taskIds];
1186
+ while (merged.length > MAX_NOTICED_TASK_IDS) merged.shift();
1187
+ updateTeammate(name, { noticedTaskIds: merged });
1188
+ }
1189
+
1190
+ /** A released task becomes noticeable again for every living teammate. */
1191
+ function rearmTaskNotice(taskId: string): void {
1192
+ for (const teammate of livingTeammates()) {
1193
+ if (teammate.noticedTaskIds?.includes(taskId)) {
1194
+ updateTeammate(teammate.name, { noticedTaskIds: teammate.noticedTaskIds.filter((id) => id !== taskId) });
1195
+ }
1196
+ }
1197
+ }
1198
+
871
1199
  /**
872
1200
  * Compose one wake-up prompt per idle teammate from queued inbox deliveries
873
- * plus a paced claimable-task notice. Teammates with nothing to do are never
874
- * woken.
1201
+ * plus a board notice covering only claimable work that teammate has never
1202
+ * been shown. Teammates with nothing new are never woken.
875
1203
  */
876
1204
  export function wakeIdleTeammates(): void {
877
- const claimable = claimableTasks();
878
1205
  for (const teammate of idleTeammates()) {
879
1206
  const deliveries = pendingDeliveries.get(teammate.name) ?? [];
880
- const dueNotice = claimable.length > 0 && noticeDue(teammate);
1207
+ const fresh = freshClaimableTasks(teammate.noticedTaskIds, claimableTasks());
1208
+ const dueNotice = fresh.length > 0 && noticeDue(teammate);
881
1209
  if (deliveries.length === 0 && !dueNotice) continue;
882
- const prompt = buildWakePrompt(deliveries, claimable, dueNotice);
1210
+ const noticed = fresh.slice(0, WAKE_NOTICE_TASK_LIMIT);
1211
+ const prompt = buildWakePrompt(deliveries, noticed, dueNotice);
883
1212
  if (!deliverPrompt(teammate.name, prompt)) continue;
884
1213
  pendingDeliveries.delete(teammate.name);
1214
+ if (dueNotice) markTasksNoticed(teammate.name, noticed.map((task) => task.id));
885
1215
  // A delivered prompt is fresh activity: restart the silence clock so a
886
1216
  // long-idle teammate is never insta-flagged as stalled on wake.
887
1217
  updateTeammate(teammate.name, {
package/src/tools.ts CHANGED
@@ -3,6 +3,8 @@ import { formatAgentMessagePrefix, formatAgentTaskName, formatExpandHint, format
3
3
  import {
4
4
  createBoardTask,
5
5
  formatSilenceDuration,
6
+ hasAnnouncedFinish,
7
+ hasTerminalReport,
6
8
  publishStateSnapshot,
7
9
  sendLeaderMessage,
8
10
  shutdownTeammate,
@@ -26,7 +28,7 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
26
28
  name: "teammate_spawn",
27
29
  promptSnippet: "Spawn a named resident teammate",
28
30
  label: "Spawn Teammate",
29
- description: "Spawn one named resident teammate. Model and worktree behavior come from its declarative agent definition; the kickoff prompt is optional.",
31
+ description: "Spawn one named resident teammate. Generated role definitions stay in memory by default; persist one only when the user explicitly asks to keep it for future sessions.",
30
32
  parameters: TeammateSpawnParams,
31
33
  // Canonical lifecycle rows (same as packages/monitor): empty renderCall,
32
34
  // ONE startup row owned by renderResult.
@@ -72,6 +74,9 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
72
74
  return new Text(theme.fg("error", text.split("\n")[0] || "Failed to shut down teammate."), 0, 0);
73
75
  }
74
76
  const name = String((context.args as { name?: string }).name ?? "");
77
+ // The finish entry already announced this end of life (or its terminal
78
+ // report is queued to); a second event row is noise.
79
+ if (hasAnnouncedFinish(name) || hasTerminalReport(name)) return { render: () => [], invalidate: () => {} };
75
80
  const title = theme.fg("toolTitle", theme.bold(formatToolEventLabel("event", `@${name} shut down`, "agent")));
76
81
  const render = (width: number) => {
77
82
  if (width <= 0) return [];
@@ -143,7 +148,7 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
143
148
  name: "task_create",
144
149
  promptSnippet: "Create a shared board task",
145
150
  label: "Create Task",
146
- description: "Create one shared board task. Resident teammates self-claim it when dependencies are met; an optional verify command gates completion.",
151
+ description: "Create one shared board task. Resident teammates self-claim it when dependencies are met; an optional verify prompt gates completion through a fresh reviewer.",
147
152
  parameters: TaskCreateParams,
148
153
  // Canonical lifecycle rows (same as packages/monitor): empty call slot,
149
154
  // ONE created row owned by renderResult.