@tmustier/pi-agent-teams 0.4.0-beta.3 → 0.5.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +72 -9
  3. package/WORKFLOW.md +110 -0
  4. package/docs/claude-parity.md +18 -13
  5. package/docs/hook-contract.md +183 -0
  6. package/docs/smoke-test-plan.md +26 -7
  7. package/extensions/teams/activity-tracker.ts +296 -8
  8. package/extensions/teams/cleanup.ts +216 -3
  9. package/extensions/teams/hooks.ts +57 -5
  10. package/extensions/teams/leader-attach-commands.ts +8 -4
  11. package/extensions/teams/leader-inbox.ts +162 -4
  12. package/extensions/teams/leader-info-commands.ts +105 -3
  13. package/extensions/teams/leader-lifecycle-commands.ts +205 -3
  14. package/extensions/teams/leader-messaging-commands.ts +19 -7
  15. package/extensions/teams/leader-spawn-command.ts +5 -1
  16. package/extensions/teams/leader-team-command.ts +51 -2
  17. package/extensions/teams/leader-teams-tool.ts +387 -11
  18. package/extensions/teams/leader.ts +126 -52
  19. package/extensions/teams/mailbox.ts +6 -1
  20. package/extensions/teams/model-policy.ts +117 -0
  21. package/extensions/teams/spawn-types.ts +4 -0
  22. package/extensions/teams/teammate-rpc.ts +14 -0
  23. package/extensions/teams/teams-panel.ts +117 -19
  24. package/extensions/teams/teams-ui-shared.ts +205 -2
  25. package/extensions/teams/teams-widget.ts +67 -14
  26. package/extensions/teams/worker.ts +18 -6
  27. package/extensions/teams/worktree.ts +143 -0
  28. package/package.json +4 -2
  29. package/scripts/integration-cleanup-test.mts +419 -0
  30. package/scripts/integration-hooks-remediation-test.mts +382 -0
  31. package/scripts/integration-spawn-overrides-test.mts +10 -0
  32. package/scripts/smoke-test.mts +701 -3
  33. package/skills/agent-teams/SKILL.md +28 -7
@@ -31,9 +31,12 @@ import {
31
31
  } from "../extensions/teams/task-store.js";
32
32
  import { ensureTeamConfig, loadTeamConfig, upsertMember, setMemberStatus, updateTeamHooksPolicy } from "../extensions/teams/team-config.js";
33
33
  import { sanitizeName } from "../extensions/teams/names.js";
34
- import { isDeprecatedTeammateModelId } from "../extensions/teams/model-policy.js";
34
+ import { formatProviderModel, isDeprecatedTeammateModelId, resolveTeammateModelSelection } from "../extensions/teams/model-policy.js";
35
+ import { getMemberModel, getMemberThinking, shortModelLabel } from "../extensions/teams/teams-ui-shared.js";
35
36
  import { getTeamsNamingRules, getTeamsStrings } from "../extensions/teams/teams-style.js";
36
37
  import {
38
+ HOOK_CONTRACT_VERSION,
39
+ buildHookContextPayload,
37
40
  getTeamsHookFailureAction,
38
41
  getTeamsHookFollowupOwnerPolicy,
39
42
  getTeamsHookMaxReopensPerTask,
@@ -42,6 +45,7 @@ import {
42
45
  shouldCreateHookFollowupTask,
43
46
  shouldReopenTaskOnHookFailure,
44
47
  } from "../extensions/teams/hooks.js";
48
+ import { TranscriptTracker, type TranscriptEntry } from "../extensions/teams/activity-tracker.js";
45
49
  import { listDiscoveredTeams } from "../extensions/teams/team-discovery.js";
46
50
  import {
47
51
  acquireTeamAttachClaim,
@@ -50,6 +54,7 @@ import {
50
54
  releaseTeamAttachClaim,
51
55
  } from "../extensions/teams/team-attach-claim.js";
52
56
  import { getTeamHelpText } from "../extensions/teams/leader-team-command.js";
57
+ import { isTeamDone, formatElapsed, lastMessageSummary } from "../extensions/teams/teams-ui-shared.js";
53
58
  import {
54
59
  TEAM_MAILBOX_NS,
55
60
  isIdleNotification,
@@ -64,6 +69,8 @@ import {
64
69
  isPlanApprovedMessage,
65
70
  isPlanRejectedMessage,
66
71
  } from "../extensions/teams/protocol.js";
72
+ import { pollLeaderInbox } from "../extensions/teams/leader-inbox.js";
73
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
67
74
 
68
75
  // ── helpers ──────────────────────────────────────────────────────────
69
76
  let passed = 0;
@@ -117,6 +124,81 @@ assert(!isDeprecatedTeammateModelId("claude-sonnet-4-5"), "does not block sonnet
117
124
  assert(!isDeprecatedTeammateModelId("claude-sonnet-4.5"), "does not block sonnet-4.5");
118
125
  assert(!isDeprecatedTeammateModelId("gpt-5.1-codex-mini"), "keeps current models allowed");
119
126
 
127
+ const modelResolvedExplicit = resolveTeammateModelSelection({
128
+ modelOverride: "openai-codex/gpt-5.1-codex-mini",
129
+ leaderProvider: "anthropic",
130
+ leaderModelId: "claude-sonnet-4-5",
131
+ });
132
+ assert(modelResolvedExplicit.ok, "resolveTeammateModelSelection accepts provider/model override");
133
+ if (modelResolvedExplicit.ok) {
134
+ assertEq(modelResolvedExplicit.value.source, "override", "explicit override source");
135
+ assertEq(formatProviderModel(modelResolvedExplicit.value.provider, modelResolvedExplicit.value.modelId), "openai-codex/gpt-5.1-codex-mini", "explicit override keeps provider/model");
136
+ }
137
+
138
+ const modelResolvedModelOnly = resolveTeammateModelSelection({
139
+ modelOverride: "gpt-5.1-codex-mini",
140
+ leaderProvider: "openai-codex",
141
+ leaderModelId: "gpt-5.1-codex-mini",
142
+ });
143
+ assert(modelResolvedModelOnly.ok, "resolveTeammateModelSelection accepts model-only override");
144
+ if (modelResolvedModelOnly.ok) {
145
+ assertEq(formatProviderModel(modelResolvedModelOnly.value.provider, modelResolvedModelOnly.value.modelId), "openai-codex/gpt-5.1-codex-mini", "model-only override inherits leader provider");
146
+ }
147
+
148
+ const modelResolvedInvalid = resolveTeammateModelSelection({ modelOverride: "openai-codex/" });
149
+ assert(!modelResolvedInvalid.ok, "resolveTeammateModelSelection rejects invalid provider/model override");
150
+ if (!modelResolvedInvalid.ok) {
151
+ assertEq(modelResolvedInvalid.reason, "invalid_override", "invalid override reason");
152
+ }
153
+
154
+ const modelResolvedDeprecated = resolveTeammateModelSelection({ modelOverride: "claude-sonnet-4" });
155
+ assert(!modelResolvedDeprecated.ok, "resolveTeammateModelSelection rejects deprecated override");
156
+ if (!modelResolvedDeprecated.ok) {
157
+ assertEq(modelResolvedDeprecated.reason, "deprecated_override", "deprecated override reason");
158
+ }
159
+
160
+ const modelResolvedDeprecatedLeader = resolveTeammateModelSelection({
161
+ leaderProvider: "anthropic",
162
+ leaderModelId: "claude-sonnet-4-20250514",
163
+ });
164
+ assert(modelResolvedDeprecatedLeader.ok, "resolveTeammateModelSelection handles deprecated leader model fallback");
165
+ if (modelResolvedDeprecatedLeader.ok) {
166
+ assertEq(modelResolvedDeprecatedLeader.value.source, "default", "deprecated leader model is not inherited");
167
+ assertEq(formatProviderModel(modelResolvedDeprecatedLeader.value.provider, modelResolvedDeprecatedLeader.value.modelId), null, "deprecated leader fallback has no explicit model");
168
+ }
169
+
170
+ // ── 1c. UI shared helpers ────────────────────────────────────────────
171
+ console.log("\n1c. teams-ui-shared helpers");
172
+ {
173
+ // shortModelLabel
174
+ assertEq(shortModelLabel("anthropic/claude-sonnet-4-5-20250514"), "claude-sonnet-4-5", "shortModelLabel strips provider and date");
175
+ assertEq(shortModelLabel("openai-codex/gpt-5.1-codex-mini"), "gpt-5.1-codex-mini", "shortModelLabel strips provider, no date");
176
+ assertEq(shortModelLabel("claude-sonnet-4-5-20250514"), "claude-sonnet-4-5", "shortModelLabel strips date without provider");
177
+ assertEq(shortModelLabel("claude-opus-4-20250514-v2"), "claude-opus-4", "shortModelLabel strips date with variant suffix");
178
+ assertEq(shortModelLabel("gpt-5.1-codex-mini"), "gpt-5.1-codex-mini", "shortModelLabel keeps clean model id");
179
+
180
+ // getMemberModel
181
+ assertEq(getMemberModel(undefined), null, "getMemberModel returns null for undefined member");
182
+ assertEq(
183
+ getMemberModel({ name: "a", role: "worker", addedAt: "", status: "online" }),
184
+ null,
185
+ "getMemberModel returns null when no meta",
186
+ );
187
+ assertEq(
188
+ getMemberModel({ name: "a", role: "worker", addedAt: "", status: "online", meta: { model: "anthropic/claude-sonnet-4-5" } }),
189
+ "anthropic/claude-sonnet-4-5",
190
+ "getMemberModel extracts model from meta",
191
+ );
192
+
193
+ // getMemberThinking
194
+ assertEq(getMemberThinking(undefined), null, "getMemberThinking returns null for undefined member");
195
+ assertEq(
196
+ getMemberThinking({ name: "a", role: "worker", addedAt: "", status: "online", meta: { thinkingLevel: "high" } }),
197
+ "high",
198
+ "getMemberThinking extracts thinking from meta",
199
+ );
200
+ }
201
+
120
202
  // ── 2. fs-lock ───────────────────────────────────────────────────────
121
203
  console.log("\n2. fs-lock.withLock");
122
204
  {
@@ -205,6 +287,31 @@ console.log("\n3. mailbox");
205
287
  });
206
288
  const msgs3 = await popUnreadMessages(teamDir, TEAM_MAILBOX_NS, "agent1");
207
289
  assertEq(msgs3.length, 2, "pop returns 2 new unread messages");
290
+
291
+ // urgent messages
292
+ await writeToMailbox(teamDir, TEAM_MAILBOX_NS, "agent1", {
293
+ from: "peer",
294
+ text: "urgent interrupt",
295
+ timestamp: "2025-01-01T00:03:00Z",
296
+ urgent: true,
297
+ });
298
+ const msgs4 = await popUnreadMessages(teamDir, TEAM_MAILBOX_NS, "agent1");
299
+ assertEq(msgs4.length, 1, "pop returns 1 urgent message");
300
+ {
301
+ const m = msgs4.at(0);
302
+ assertEq(m?.urgent, true, "urgent flag preserved");
303
+ assertEq(m?.text, "urgent interrupt", "urgent message text correct");
304
+ }
305
+
306
+ // non-urgent messages default to no urgent field
307
+ await writeToMailbox(teamDir, TEAM_MAILBOX_NS, "agent1", {
308
+ from: "peer",
309
+ text: "normal msg",
310
+ timestamp: "2025-01-01T00:04:00Z",
311
+ });
312
+ const msgs5 = await popUnreadMessages(teamDir, TEAM_MAILBOX_NS, "agent1");
313
+ assertEq(msgs5.length, 1, "pop returns 1 normal message");
314
+ assertEq(msgs5.at(0)?.urgent, undefined, "non-urgent message has no urgent flag");
208
315
  }
209
316
 
210
317
  // ── 4. task-store ────────────────────────────────────────────────────
@@ -557,6 +664,7 @@ console.log("\n9. teams-hooks (quality gates)");
557
664
 
558
665
  assert(res.ran === true, "runs on_task_completed hook");
559
666
  assert(res.exitCode === 0, "hook exit code is 0");
667
+ assertEq(res.contractVersion, HOOK_CONTRACT_VERSION, "result includes contract version");
560
668
  assert(fs.existsSync(outFile), "hook wrote output file");
561
669
  const hookOutRaw = fs.readFileSync(outFile, "utf8").trim();
562
670
  const hookOut = JSON.parse(hookOutRaw) as {
@@ -601,6 +709,67 @@ console.log("\n9. teams-hooks (quality gates)");
601
709
  assertEq(getTeamsHookMaxReopensPerTask({}), 3, "hook max reopens default is 3");
602
710
  assertEq(getTeamsHookMaxReopensPerTask({ PI_TEAMS_HOOKS_MAX_REOPENS_PER_TASK: "0" }), 0, "hook max reopens supports zero");
603
711
 
712
+ // contract version constant
713
+ assert(typeof HOOK_CONTRACT_VERSION === "number", "HOOK_CONTRACT_VERSION is a number");
714
+ assert(HOOK_CONTRACT_VERSION >= 1, "HOOK_CONTRACT_VERSION >= 1");
715
+ assert(Number.isInteger(HOOK_CONTRACT_VERSION), "HOOK_CONTRACT_VERSION is an integer");
716
+
717
+ // buildHookContextPayload produces correct shape
718
+ const payload = buildHookContextPayload({
719
+ event: "task_completed",
720
+ teamId: "test-team",
721
+ teamDir: "/tmp/test",
722
+ taskListId: "test-tl",
723
+ style: "normal",
724
+ memberName: "agent1",
725
+ timestamp: "2025-01-01T00:00:00Z",
726
+ completedTask: {
727
+ id: "42",
728
+ subject: "Test subject",
729
+ description: "Test desc",
730
+ owner: "agent1",
731
+ status: "completed",
732
+ blocks: ["43"],
733
+ blockedBy: [],
734
+ metadata: { result: "ok" },
735
+ createdAt: "2025-01-01T00:00:00Z",
736
+ updatedAt: "2025-01-01T00:00:00Z",
737
+ },
738
+ });
739
+ assertEq(payload.version, HOOK_CONTRACT_VERSION, "payload version matches constant");
740
+ assertEq(payload.event, "task_completed", "payload event correct");
741
+ assertEq(payload.team.id, "test-team", "payload team.id correct");
742
+ assertEq(payload.member, "agent1", "payload member correct");
743
+ assertEq(payload.task?.id, "42", "payload task.id correct");
744
+ assertEq(payload.task?.owner, "agent1", "payload task.owner correct");
745
+ assertEq(payload.task?.blocks.at(0), "43", "payload task.blocks preserved");
746
+
747
+ // null task for idle events
748
+ const idlePayload = buildHookContextPayload({
749
+ event: "idle",
750
+ teamId: "test-team",
751
+ teamDir: "/tmp/test",
752
+ taskListId: "test-tl",
753
+ style: "normal",
754
+ });
755
+ assertEq(idlePayload.task, null, "idle payload has null task");
756
+ assertEq(idlePayload.member, null, "idle payload has null member when not provided");
757
+
758
+ // disabled hooks still return contract version
759
+ const disabledRes = await runTeamsHook({
760
+ invocation: {
761
+ event: "idle",
762
+ teamId: "noop",
763
+ teamDir: "/tmp/noop",
764
+ taskListId: "noop",
765
+ style: "normal",
766
+ },
767
+ cwd: tmpRoot,
768
+ env: { PI_TEAMS_HOOKS_ENABLED: "0" },
769
+ });
770
+ assertEq(disabledRes.ran, false, "disabled hooks do not run");
771
+ assertEq(disabledRes.contractVersion, HOOK_CONTRACT_VERSION, "disabled hooks still report contract version");
772
+
604
773
  // restore env
605
774
  if (prevRoot === undefined) delete process.env.PI_TEAMS_ROOT_DIR;
606
775
  else process.env.PI_TEAMS_ROOT_DIR = prevRoot;
@@ -672,20 +841,352 @@ console.log("\n10. team discovery + attach claims");
672
841
  }
673
842
  }
674
843
 
675
- // ── 11. docs/help drift guard ────────────────────────────────────────
676
- console.log("\n11. docs/help drift guard");
844
+ // ── 11. /team done (end-of-run cleanup) ──────────────────────────────
845
+ console.log("\n11. /team done (end-of-run)");
846
+ {
847
+ const doneDir = path.join(tmpRoot, "done-test");
848
+ const doneTeamId = "done-team";
849
+ const doneTeamDir = path.join(doneDir, doneTeamId);
850
+ const doneTlId = doneTeamId;
851
+
852
+ await ensureTeamConfig(doneTeamDir, { teamId: doneTeamId, taskListId: doneTlId, leadName: "team-lead", style: "normal" });
853
+ await upsertMember(doneTeamDir, { name: "alice", role: "worker", status: "online" });
854
+ await upsertMember(doneTeamDir, { name: "bob", role: "worker", status: "online" });
855
+
856
+ // Create tasks and mark all completed
857
+ const t1 = await createTask(doneTeamDir, doneTlId, { subject: "Task 1", description: "", owner: "alice" });
858
+ const t2 = await createTask(doneTeamDir, doneTlId, { subject: "Task 2", description: "", owner: "bob" });
859
+ await completeTask(doneTeamDir, doneTlId, t1.id, "alice");
860
+ await completeTask(doneTeamDir, doneTlId, t2.id, "bob");
861
+
862
+ const doneTasks = await listTasks(doneTeamDir, doneTlId);
863
+ const allCompleted = doneTasks.every((t) => t.status === "completed");
864
+ assert(allCompleted, "/team done precondition: all tasks completed");
865
+ assertEq(doneTasks.length, 2, "/team done: 2 tasks exist");
866
+
867
+ // Mark workers offline (simulating what /team done does)
868
+ await setMemberStatus(doneTeamDir, "alice", "offline", { meta: { stoppedReason: "team-done" } });
869
+ await setMemberStatus(doneTeamDir, "bob", "offline", { meta: { stoppedReason: "team-done" } });
870
+
871
+ const cfgAfter = await loadTeamConfig(doneTeamDir);
872
+ const onlineAfter = (cfgAfter?.members ?? []).filter((m) => m.role === "worker" && m.status === "online");
873
+ assertEq(onlineAfter.length, 0, "/team done: all workers offline after done");
874
+
875
+ const offlineAlice = cfgAfter?.members.find((m) => m.name === "alice");
876
+ assert(offlineAlice?.meta?.["stoppedReason"] === "team-done", "/team done: alice has stoppedReason=team-done");
877
+
878
+ // Test force-done with in-progress tasks (simulates --force path)
879
+ const forceDir = path.join(tmpRoot, "force-done-test");
880
+ const forceTeamId = "force-done-team";
881
+ const forceTeamDir = path.join(forceDir, forceTeamId);
882
+ const forceTlId = forceTeamId;
883
+
884
+ await ensureTeamConfig(forceTeamDir, { teamId: forceTeamId, taskListId: forceTlId, leadName: "team-lead", style: "normal" });
885
+ await upsertMember(forceTeamDir, { name: "carol", role: "worker", status: "online" });
886
+
887
+ const ft1 = await createTask(forceTeamDir, forceTlId, { subject: "Ongoing work", description: "", owner: "carol" });
888
+ await startAssignedTask(forceTeamDir, forceTlId, ft1.id, "carol");
889
+
890
+ const forceTasks = await listTasks(forceTeamDir, forceTlId);
891
+ assertEq(forceTasks[0]?.status, "in_progress", "/team done --force precondition: task in_progress");
892
+
893
+ // Simulate force-done: unassign in-progress tasks
894
+ await unassignTasksForAgent(forceTeamDir, forceTlId, "carol", "team done");
895
+ await setMemberStatus(forceTeamDir, "carol", "offline", { meta: { stoppedReason: "team-done" } });
896
+
897
+ const forceTasksAfter = await listTasks(forceTeamDir, forceTlId);
898
+ assertEq(forceTasksAfter[0]?.status, "pending", "/team done --force: in-progress task reset to pending");
899
+ assertEq(forceTasksAfter[0]?.owner, undefined, "/team done --force: in-progress task unassigned");
900
+
901
+ const forceCfgAfter = await loadTeamConfig(forceTeamDir);
902
+ const forceCarol = forceCfgAfter?.members.find((m) => m.name === "carol");
903
+ assertEq(forceCarol?.status, "offline", "/team done --force: worker offline");
904
+
905
+ // Test idempotency: calling done again on already-done team is safe
906
+ await setMemberStatus(doneTeamDir, "alice", "offline", { meta: { stoppedReason: "team-done" } });
907
+ await setMemberStatus(doneTeamDir, "bob", "offline", { meta: { stoppedReason: "team-done" } });
908
+ const idempotentCfg = await loadTeamConfig(doneTeamDir);
909
+ const idempotentOnline = (idempotentCfg?.members ?? []).filter((m) => m.role === "worker" && m.status === "online");
910
+ assertEq(idempotentOnline.length, 0, "/team done idempotent: still 0 online after second done");
911
+ const idempotentTasks = await listTasks(doneTeamDir, doneTlId);
912
+ const idempotentAllDone = idempotentTasks.every((t) => t.status === "completed");
913
+ assert(idempotentAllDone, "/team done idempotent: tasks still completed after second done");
914
+ }
915
+
916
+ // ── 12. isTeamDone (pure function unit tests) ───────────────────────
917
+ console.log("\n12. isTeamDone");
918
+ {
919
+ // Minimal mock: isTeamDone only reads .status from TeammateRpc values
920
+ type MockRpc = { status: string };
921
+ const mockMap = (entries: Array<[string, MockRpc]>) =>
922
+ new Map(entries) as unknown as ReadonlyMap<string, import("../extensions/teams/teammate-rpc.js").TeammateRpc>;
923
+
924
+ const completedTask = (id: string): import("../extensions/teams/task-store.js").TeamTask => ({
925
+ id, subject: "x", description: "", status: "completed",
926
+ blocks: [], blockedBy: [], metadata: {}, createdAt: "", updatedAt: "",
927
+ });
928
+ const pendingTask = (id: string): import("../extensions/teams/task-store.js").TeamTask => ({
929
+ id, subject: "x", description: "", status: "pending",
930
+ blocks: [], blockedBy: [], metadata: {}, createdAt: "", updatedAt: "",
931
+ });
932
+ const inProgressTask = (id: string): import("../extensions/teams/task-store.js").TeamTask => ({
933
+ id, subject: "x", description: "", status: "in_progress",
934
+ blocks: [], blockedBy: [], metadata: {}, createdAt: "", updatedAt: "",
935
+ });
936
+
937
+ // No tasks → not done
938
+ assert(!isTeamDone([], mockMap([])), "isTeamDone: empty tasks = false");
939
+
940
+ // All completed, no teammates → done
941
+ assert(isTeamDone([completedTask("1"), completedTask("2")], mockMap([])), "isTeamDone: all completed, no teammates = true");
942
+
943
+ // All completed, all idle → done
944
+ assert(
945
+ isTeamDone([completedTask("1")], mockMap([["alice", { status: "idle" }]])),
946
+ "isTeamDone: all completed + idle teammate = true",
947
+ );
948
+
949
+ // All completed, one streaming → not done
950
+ assert(
951
+ !isTeamDone([completedTask("1")], mockMap([["alice", { status: "streaming" }]])),
952
+ "isTeamDone: all completed + streaming teammate = false",
953
+ );
954
+
955
+ // All completed, one starting → not done
956
+ assert(
957
+ !isTeamDone([completedTask("1")], mockMap([["alice", { status: "starting" }]])),
958
+ "isTeamDone: all completed + starting teammate = false",
959
+ );
960
+
961
+ // All completed, stopped teammate → done
962
+ assert(
963
+ isTeamDone([completedTask("1")], mockMap([["alice", { status: "stopped" }]])),
964
+ "isTeamDone: all completed + stopped teammate = true",
965
+ );
966
+
967
+ // Pending task → not done
968
+ assert(
969
+ !isTeamDone([completedTask("1"), pendingTask("2")], mockMap([])),
970
+ "isTeamDone: one pending = false",
971
+ );
972
+
973
+ // In-progress task → not done
974
+ assert(
975
+ !isTeamDone([completedTask("1"), inProgressTask("2")], mockMap([])),
976
+ "isTeamDone: one in-progress = false",
977
+ );
978
+
979
+ // Mixed: all completed, mixed teammate states (idle + stopped) → done
980
+ assert(
981
+ isTeamDone(
982
+ [completedTask("1"), completedTask("2")],
983
+ mockMap([["alice", { status: "idle" }], ["bob", { status: "stopped" }]]),
984
+ ),
985
+ "isTeamDone: all completed + idle+stopped = true",
986
+ );
987
+ }
988
+
989
+ // ── 13. formatElapsed + lastMessageSummary ───────────────────────────
990
+ console.log("\n13. formatElapsed + lastMessageSummary");
991
+ {
992
+ assert(formatElapsed(500) === "0s", "formatElapsed <1s rounds to 0s");
993
+ assert(formatElapsed(1000) === "1s", "formatElapsed 1s");
994
+ assert(formatElapsed(45000) === "45s", "formatElapsed 45s");
995
+ assert(formatElapsed(90000) === "1m30s", "formatElapsed 1m30s");
996
+ assert(formatElapsed(120000) === "2m", "formatElapsed 2m exact");
997
+ assert(formatElapsed(3600000) === "1h", "formatElapsed 1h exact");
998
+ assert(formatElapsed(3660000) === "1h1m", "formatElapsed 1h1m");
999
+
1000
+ // lastMessageSummary with undefined rpc
1001
+ assert(lastMessageSummary(undefined) === "", "lastMessageSummary(undefined) is empty");
1002
+
1003
+ // lastMessageSummary with mock rpc-like object (only lastAssistantText matters)
1004
+ const mockRpc = { lastAssistantText: "Hello world, this is a test" } as unknown as import("../extensions/teams/teammate-rpc.js").TeammateRpc;
1005
+ const summary = lastMessageSummary(mockRpc, 20);
1006
+ assert(summary.length <= 20, "lastMessageSummary respects maxLen");
1007
+ assert(summary.endsWith("…"), "lastMessageSummary truncates with ellipsis");
1008
+
1009
+ const shortRpc = { lastAssistantText: "Short" } as unknown as import("../extensions/teams/teammate-rpc.js").TeammateRpc;
1010
+ assert(lastMessageSummary(shortRpc, 20) === "Short", "lastMessageSummary keeps short text intact");
1011
+ }
1012
+
1013
+ // ── 14. leader-inbox LLM message injection ───────────────────────────
1014
+ console.log("\n14. leader-inbox LLM message injection");
1015
+ {
1016
+ const inboxTeamDir = path.join(tmpRoot, "inbox-test");
1017
+ const inboxTaskListId = "inbox-tl";
1018
+ const leadName = "team-lead";
1019
+ const style = "default";
1020
+
1021
+ // Set up team config
1022
+ await ensureTeamConfig(inboxTeamDir, { teamId: "inbox-team", taskListId: inboxTaskListId, leadName, style });
1023
+
1024
+ // Create and complete a task with a result
1025
+ const t1 = await createTask(inboxTeamDir, inboxTaskListId, { subject: "Fix tests", description: "", owner: "alice" });
1026
+ await completeTask(inboxTeamDir, inboxTaskListId, t1.id, "alice", "All 12 tests passing");
1027
+
1028
+ // Write an idle notification with completedTaskId
1029
+ const ts = new Date().toISOString();
1030
+ await writeToMailbox(inboxTeamDir, TEAM_MAILBOX_NS, leadName, {
1031
+ from: "alice",
1032
+ text: JSON.stringify({
1033
+ type: "idle_notification",
1034
+ from: "alice",
1035
+ timestamp: ts,
1036
+ completedTaskId: t1.id,
1037
+ completedStatus: "completed",
1038
+ }),
1039
+ timestamp: ts,
1040
+ });
1041
+
1042
+ // Track messages sent to leader LLM
1043
+ const llmMessages: Array<{ content: string; options?: { deliverAs?: string } }> = [];
1044
+
1045
+ // Minimal ExtensionContext stub
1046
+ const stubCtx = {
1047
+ cwd: inboxTeamDir,
1048
+ ui: { notify: () => {} },
1049
+ sessionManager: { getSessionId: () => "inbox-team" },
1050
+ } as unknown as ExtensionContext;
1051
+
1052
+ await pollLeaderInbox({
1053
+ ctx: stubCtx,
1054
+ teamId: "inbox-team",
1055
+ teamDir: inboxTeamDir,
1056
+ taskListId: inboxTaskListId,
1057
+ leadName,
1058
+ style,
1059
+ pendingPlanApprovals: new Map(),
1060
+ sendLeaderLlmMessage: (content, options) => {
1061
+ llmMessages.push({ content, options });
1062
+ },
1063
+ });
1064
+
1065
+ assert(llmMessages.length === 1, "one LLM message sent on task completion");
1066
+ const msg0 = llmMessages[0];
1067
+ if (msg0) {
1068
+ assert(msg0.content.includes("[Team]"), "LLM message has [Team] prefix");
1069
+ assert(msg0.content.includes("alice"), "LLM message includes worker name");
1070
+ assert(msg0.content.includes(t1.id), "LLM message includes task id");
1071
+ assert(msg0.content.includes("Fix tests"), "LLM message includes task subject");
1072
+ assert(msg0.content.includes("All 12 tests passing"), "LLM message includes result");
1073
+ assert(msg0.content.includes("All 1 task(s) are now completed"), "LLM message includes allDone summary");
1074
+ assertEq(msg0.options?.deliverAs, "followUp", "LLM message uses followUp delivery");
1075
+ }
1076
+
1077
+ // Test failure path with abort metadata
1078
+ const t2 = await createTask(inboxTeamDir, inboxTaskListId, { subject: "Deploy staging", description: "", owner: "bob" });
1079
+ await updateTask(inboxTeamDir, inboxTaskListId, t2.id, (cur) => ({
1080
+ ...cur,
1081
+ status: "pending",
1082
+ metadata: {
1083
+ ...(cur.metadata ?? {}),
1084
+ abortReason: "timeout after 60s",
1085
+ partialResult: "Deployed but health check failed",
1086
+ },
1087
+ }));
1088
+
1089
+ const ts2 = new Date().toISOString();
1090
+ await writeToMailbox(inboxTeamDir, TEAM_MAILBOX_NS, leadName, {
1091
+ from: "bob",
1092
+ text: JSON.stringify({
1093
+ type: "idle_notification",
1094
+ from: "bob",
1095
+ timestamp: ts2,
1096
+ completedTaskId: t2.id,
1097
+ completedStatus: "failed",
1098
+ }),
1099
+ timestamp: ts2,
1100
+ });
1101
+
1102
+ llmMessages.length = 0;
1103
+ await pollLeaderInbox({
1104
+ ctx: stubCtx,
1105
+ teamId: "inbox-team",
1106
+ teamDir: inboxTeamDir,
1107
+ taskListId: inboxTaskListId,
1108
+ leadName,
1109
+ style,
1110
+ pendingPlanApprovals: new Map(),
1111
+ sendLeaderLlmMessage: (content, options) => {
1112
+ llmMessages.push({ content, options });
1113
+ },
1114
+ });
1115
+
1116
+ assert(llmMessages.length === 1, "one LLM message sent on task failure");
1117
+ const failMsg = llmMessages[0];
1118
+ if (failMsg) {
1119
+ assert(failMsg.content.includes("failed"), "failure LLM message includes failed");
1120
+ assert(failMsg.content.includes("timeout after 60s"), "failure LLM message includes abortReason");
1121
+ assert(failMsg.content.includes("health check failed"), "failure LLM message includes partialResult");
1122
+ }
1123
+
1124
+ // Test hook-aware allDone qualifier
1125
+ const t3 = await createTask(inboxTeamDir, inboxTaskListId, { subject: "Final task", description: "", owner: "carol" });
1126
+ await completeTask(inboxTeamDir, inboxTaskListId, t3.id, "carol", "Done");
1127
+ // Also complete t2 so all tasks are done
1128
+ await updateTask(inboxTeamDir, inboxTaskListId, t2.id, (cur) => ({
1129
+ ...cur,
1130
+ status: "completed",
1131
+ owner: "bob",
1132
+ }));
1133
+
1134
+ const ts3 = new Date().toISOString();
1135
+ await writeToMailbox(inboxTeamDir, TEAM_MAILBOX_NS, leadName, {
1136
+ from: "carol",
1137
+ text: JSON.stringify({
1138
+ type: "idle_notification",
1139
+ from: "carol",
1140
+ timestamp: ts3,
1141
+ completedTaskId: t3.id,
1142
+ completedStatus: "completed",
1143
+ }),
1144
+ timestamp: ts3,
1145
+ });
1146
+
1147
+ llmMessages.length = 0;
1148
+ await pollLeaderInbox({
1149
+ ctx: stubCtx,
1150
+ teamId: "inbox-team",
1151
+ teamDir: inboxTeamDir,
1152
+ taskListId: inboxTaskListId,
1153
+ leadName,
1154
+ style,
1155
+ pendingPlanApprovals: new Map(),
1156
+ enqueueHook: () => {}, // hooks present → should qualify allDone
1157
+ sendLeaderLlmMessage: (content, options) => {
1158
+ llmMessages.push({ content, options });
1159
+ },
1160
+ });
1161
+
1162
+ assert(llmMessages.length === 1, "one LLM message sent with hooks active");
1163
+ const hookMsg = llmMessages[0];
1164
+ if (hookMsg) {
1165
+ assert(hookMsg.content.includes("quality gates are still running"), "allDone qualified when hooks active");
1166
+ assert(!hookMsg.content.includes("Review results and determine next steps"), "no premature wrap-up prompt when hooks active");
1167
+ }
1168
+ }
1169
+
1170
+ // ── 15. docs/help drift guard ────────────────────────────────────────
1171
+ console.log("\n15. docs/help drift guard");
677
1172
  {
678
1173
  const help = getTeamHelpText();
1174
+ assert(help.includes("/team done"), "help mentions /team done");
679
1175
  assert(help.includes("/team style list"), "help mentions /team style list");
680
1176
  assert(help.includes("/team style init"), "help mentions /team style init");
681
1177
  assert(help.includes("/team attach <teamId> [--claim]"), "help mentions /team attach claim mode");
682
1178
  assert(help.includes("/team detach"), "help mentions /team detach");
1179
+ assert(help.includes("[--urgent]"), "help mentions --urgent flag");
1180
+ assert(help.includes("/team gc"), "help mentions /team gc");
1181
+ assert(help.includes("/team status"), "help mentions /team status");
683
1182
 
684
1183
  const readmePath = path.join(process.cwd(), "README.md");
685
1184
  if (!fs.existsSync(readmePath)) {
686
1185
  console.log(" (skipped) README.md not found");
687
1186
  } else {
688
1187
  const readme = fs.readFileSync(readmePath, "utf8");
1188
+ assert(readme.includes("/team done"), "README mentions /team done");
1189
+ assert(readme.includes("\"action\": \"team_done\""), "README mentions teams tool team_done action");
689
1190
  assert(readme.includes("/team style list"), "README mentions /team style list");
690
1191
  assert(readme.includes("/team attach <teamId> [--claim]"), "README mentions /team attach claim mode");
691
1192
  assert(readme.includes("/team detach"), "README mentions /team detach");
@@ -696,6 +1197,8 @@ console.log("\n11. docs/help drift guard");
696
1197
  assert(readme.includes("\"action\": \"plan_approve\""), "README mentions teams tool plan_approve action");
697
1198
  assert(readme.includes("\"action\": \"hooks_policy_get\""), "README mentions teams tool hooks_policy_get action");
698
1199
  assert(readme.includes("\"action\": \"hooks_policy_set\""), "README mentions teams tool hooks_policy_set action");
1200
+ assert(readme.includes("\"action\": \"model_policy_get\""), "README mentions teams tool model_policy_get action");
1201
+ assert(readme.includes("\"action\": \"model_policy_check\""), "README mentions teams tool model_policy_check action");
699
1202
  assert(readme.includes("PI_TEAMS_HOOKS_FAILURE_ACTION"), "README mentions hook failure action policy");
700
1203
  assert(readme.includes("PI_TEAMS_HOOKS_MAX_REOPENS_PER_TASK"), "README mentions hook reopen cap policy");
701
1204
  assert(readme.includes("PI_TEAMS_HOOK_CONTEXT_JSON"), "README mentions hook context json contract");
@@ -704,7 +1207,202 @@ console.log("\n11. docs/help drift guard");
704
1207
  assert(readme.includes("`t` or `shift+t`"), "README mentions panel task toggle key");
705
1208
  assert(readme.includes("task view: `c` complete"), "README mentions panel task mutations");
706
1209
  assert(readme.includes("`r` reassign"), "README mentions panel task reassignment");
1210
+ assert(readme.includes("tool args inline"), "README mentions transcript tool content display");
707
1211
  assert(readme.includes("_styles"), "README mentions _styles directory");
1212
+ assert(readme.includes("[--urgent]"), "README mentions --urgent flag");
1213
+ assert(readme.includes("\"urgent\": true"), "README mentions urgent tool param example");
1214
+ assert(readme.includes("/team gc"), "README mentions /team gc command");
1215
+ assert(readme.includes("/team cleanup"), "README mentions /team cleanup command");
1216
+ assert(readme.includes("docs/hook-contract.md"), "README references hook contract doc");
1217
+ assert(readme.includes("member_status"), "README mentions teams tool member_status action");
1218
+ assert(readme.includes("/team status"), "README mentions /team status command");
1219
+ assert(readme.includes("PI_TEAMS_STALL_THRESHOLD_MS"), "README mentions stall threshold env var");
1220
+ assert(readme.includes("Stall detection"), "README mentions stall detection feature");
1221
+ assert(readme.includes("Time in state"), "README mentions time-in-state feature");
1222
+ }
1223
+
1224
+ const skillPath = path.join(process.cwd(), "skills/agent-teams/SKILL.md");
1225
+ if (!fs.existsSync(skillPath)) {
1226
+ console.log(" (skipped) SKILL.md not found");
1227
+ } else {
1228
+ const skill = fs.readFileSync(skillPath, "utf8");
1229
+ assert(skill.includes("team_done"), "SKILL.md mentions team_done action");
1230
+ assert(skill.includes("/team done"), "SKILL.md mentions /team done command");
1231
+ assert(skill.includes("urgent"), "SKILL.md mentions urgent flag");
1232
+ assert(skill.includes("model_policy_get"), "SKILL.md mentions model_policy_get action");
1233
+ assert(skill.includes("hooks_policy_get"), "SKILL.md mentions hooks_policy_get action");
1234
+ }
1235
+ }
1236
+
1237
+ // ── 12. transcript tracker (tool content summarization) ─────────────
1238
+ {
1239
+ console.log(`\n12. transcript tracker (tool content summarization)`);
1240
+
1241
+ const tracker = new TranscriptTracker();
1242
+
1243
+ // Helper to get last entry with proper narrowing
1244
+ function lastEntry(name: string): TranscriptEntry {
1245
+ const entries = tracker.get(name).getEntries();
1246
+ const last = entries[entries.length - 1];
1247
+ if (!last) throw new Error("no transcript entries");
1248
+ return last;
1249
+ }
1250
+
1251
+ // Simulate tool_execution_start with read tool
1252
+ tracker.handleEvent("alice", {
1253
+ type: "tool_execution_start",
1254
+ toolCallId: "tc1",
1255
+ toolName: "Read",
1256
+ args: { path: "/src/index.ts" },
1257
+ });
1258
+ {
1259
+ const e = lastEntry("alice");
1260
+ assert(e.kind === "tool_start", "read tool_start recorded");
1261
+ if (e.kind === "tool_start") {
1262
+ assert(e.summary === "/src/index.ts", "read summary is file path");
1263
+ }
1264
+ }
1265
+
1266
+ // Simulate tool_execution_end with content array (ToolResultMessage shape)
1267
+ tracker.handleEvent("alice", {
1268
+ type: "tool_execution_end",
1269
+ toolCallId: "tc1",
1270
+ toolName: "Read",
1271
+ result: { content: [{ type: "text", text: "file contents here\nline two" }] },
1272
+ isError: false,
1273
+ });
1274
+ {
1275
+ const e = lastEntry("alice");
1276
+ assert(e.kind === "tool_end", "read tool_end recorded");
1277
+ if (e.kind === "tool_end") {
1278
+ assert(e.summary === "file contents here line two", "read result summarized from content array");
1279
+ assert(!e.isError, "read result not error");
1280
+ }
1281
+ }
1282
+
1283
+ // Simulate bash tool with command
1284
+ tracker.handleEvent("alice", {
1285
+ type: "tool_execution_start",
1286
+ toolCallId: "tc2",
1287
+ toolName: "Bash",
1288
+ args: { command: "npm run check" },
1289
+ });
1290
+ {
1291
+ const e = lastEntry("alice");
1292
+ assert(e.kind === "tool_start", "bash tool_start recorded");
1293
+ if (e.kind === "tool_start") {
1294
+ assert(e.summary === "npm run check", "bash summary normalizes whitespace");
1295
+ }
1296
+ }
1297
+
1298
+ // Simulate bash error result
1299
+ tracker.handleEvent("alice", {
1300
+ type: "tool_execution_end",
1301
+ toolCallId: "tc2",
1302
+ toolName: "Bash",
1303
+ result: { content: [{ type: "text", text: "Command failed with exit code 1" }] },
1304
+ isError: true,
1305
+ });
1306
+ {
1307
+ const e = lastEntry("alice");
1308
+ assert(e.kind === "tool_end", "bash error tool_end recorded");
1309
+ if (e.kind === "tool_end") {
1310
+ assert(e.isError, "bash error flagged");
1311
+ assert(e.summary === "Command failed with exit code 1", "bash error summary from content");
1312
+ }
1313
+ }
1314
+
1315
+ // Simulate edit tool
1316
+ tracker.handleEvent("alice", {
1317
+ type: "tool_execution_start",
1318
+ toolCallId: "tc3",
1319
+ toolName: "Edit",
1320
+ args: { path: "/src/utils.ts", oldText: "foo", newText: "bar" },
1321
+ });
1322
+ {
1323
+ const e = lastEntry("alice");
1324
+ assert(e.kind === "tool_start", "edit tool_start recorded");
1325
+ if (e.kind === "tool_start") {
1326
+ assert(e.summary === "/src/utils.ts", "edit summary is file path");
1327
+ }
1328
+ }
1329
+
1330
+ // Simulate grep tool
1331
+ tracker.handleEvent("alice", {
1332
+ type: "tool_execution_start",
1333
+ toolCallId: "tc4",
1334
+ toolName: "Grep",
1335
+ args: { pattern: "TODO", path: "/src" },
1336
+ });
1337
+ {
1338
+ const e = lastEntry("alice");
1339
+ assert(e.kind === "tool_start", "grep tool_start recorded");
1340
+ if (e.kind === "tool_start") {
1341
+ assert(e.summary === "TODO in /src", "grep summary includes pattern and path");
1342
+ }
1343
+ }
1344
+
1345
+ // Simulate team_message tool — should show recipient + message, not just recipient
1346
+ tracker.handleEvent("alice", {
1347
+ type: "tool_execution_start",
1348
+ toolCallId: "tc4b",
1349
+ toolName: "team_message",
1350
+ args: { recipient: "bob", message: "please rebase onto main", urgent: false },
1351
+ });
1352
+ {
1353
+ const e = lastEntry("alice");
1354
+ assert(e.kind === "tool_start", "team_message tool_start recorded");
1355
+ if (e.kind === "tool_start") {
1356
+ assert(e.summary === "→ bob: please rebase onto main", "team_message summary includes recipient and message");
1357
+ }
1358
+ }
1359
+
1360
+ // Simulate unknown tool — fallback to first string arg
1361
+ tracker.handleEvent("alice", {
1362
+ type: "tool_execution_start",
1363
+ toolCallId: "tc5",
1364
+ toolName: "CustomTool",
1365
+ args: { target: "my-resource" },
1366
+ });
1367
+ {
1368
+ const e = lastEntry("alice");
1369
+ assert(e.kind === "tool_start", "unknown tool_start recorded");
1370
+ if (e.kind === "tool_start") {
1371
+ assert(e.summary === "my-resource", "unknown tool fallback to first string arg");
1372
+ }
1373
+ }
1374
+
1375
+ // Simulate empty result
1376
+ tracker.handleEvent("alice", {
1377
+ type: "tool_execution_end",
1378
+ toolCallId: "tc5",
1379
+ toolName: "CustomTool",
1380
+ result: { content: [{ type: "text", text: "" }] },
1381
+ isError: false,
1382
+ });
1383
+ {
1384
+ const e = lastEntry("alice");
1385
+ assert(e.kind === "tool_end", "empty result tool_end recorded");
1386
+ if (e.kind === "tool_end") {
1387
+ assert(e.summary === "(empty)", "empty result shows (empty)");
1388
+ }
1389
+ }
1390
+
1391
+ // Simulate long summary truncation
1392
+ const longPath = "/very/long/" + "a".repeat(200) + ".ts";
1393
+ tracker.handleEvent("alice", {
1394
+ type: "tool_execution_start",
1395
+ toolCallId: "tc6",
1396
+ toolName: "Read",
1397
+ args: { path: longPath },
1398
+ });
1399
+ {
1400
+ const e = lastEntry("alice");
1401
+ assert(e.kind === "tool_start", "long path tool_start recorded");
1402
+ if (e.kind === "tool_start") {
1403
+ assert(e.summary !== null && e.summary.length <= 120, "long summary truncated to 120 chars");
1404
+ assert(e.summary !== null && e.summary.endsWith("…"), "truncated summary ends with ellipsis");
1405
+ }
708
1406
  }
709
1407
  }
710
1408