@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
@@ -1,14 +1,148 @@
1
1
  import type { AgentEvent } from "@mariozechner/pi-agent-core";
2
2
 
3
+ // ── Helpers ──
4
+
5
+ function isRecord(v: unknown): v is Record<string, unknown> {
6
+ return typeof v === "object" && v !== null;
7
+ }
8
+
3
9
  // ── Transcript types ──
4
10
 
5
11
  export type TranscriptEntry =
6
12
  | { kind: "text"; text: string; timestamp: number }
7
- | { kind: "tool_start"; toolName: string; timestamp: number }
8
- | { kind: "tool_end"; toolName: string; durationMs: number; timestamp: number }
13
+ | { kind: "tool_start"; toolName: string; content: string | null; summary: string | null; timestamp: number }
14
+ | { kind: "tool_end"; toolName: string; content: string | null; summary: string | null; isError: boolean; durationMs: number; timestamp: number }
9
15
  | { kind: "turn_end"; turnNumber: number; tokens: number; timestamp: number };
10
16
 
11
17
  const MAX_TRANSCRIPT = 200;
18
+ const MAX_SUMMARY_LENGTH = 120;
19
+
20
+ // ── Tool content summarization ──
21
+
22
+ function truncateSummary(text: string): string {
23
+ if (text.length <= MAX_SUMMARY_LENGTH) return text;
24
+ return `${text.slice(0, MAX_SUMMARY_LENGTH - 1)}…`;
25
+ }
26
+
27
+ function summarizeToolArgs(toolName: string, args: unknown): string {
28
+ if (!isRecord(args)) return "";
29
+ const key = toolName.toLowerCase();
30
+
31
+ if (key === "read" || key === "write") {
32
+ const path = typeof args.path === "string" ? args.path : null;
33
+ if (!path) return "";
34
+ return truncateSummary(path);
35
+ }
36
+
37
+ if (key === "edit") {
38
+ const path = typeof args.path === "string" ? args.path : null;
39
+ if (!path) return "";
40
+ return truncateSummary(path);
41
+ }
42
+
43
+ if (key === "bash") {
44
+ const cmd = typeof args.command === "string" ? args.command : null;
45
+ if (!cmd) return "";
46
+ return truncateSummary(cmd.replace(/\s+/g, " ").trim());
47
+ }
48
+
49
+ if (key === "grep" || key === "glob") {
50
+ const pattern = typeof args.pattern === "string" ? args.pattern : null;
51
+ const path = typeof args.path === "string" ? args.path : null;
52
+ const parts: string[] = [];
53
+ if (pattern) parts.push(pattern);
54
+ if (path) parts.push(`in ${path}`);
55
+ return truncateSummary(parts.join(" "));
56
+ }
57
+
58
+ if (key === "webfetch" || key === "websearch") {
59
+ const url = typeof args.url === "string" ? args.url : null;
60
+ const query = typeof args.query === "string" ? args.query : null;
61
+ return truncateSummary(url ?? query ?? "");
62
+ }
63
+
64
+ if (key === "team_message") {
65
+ const recipient = typeof args.recipient === "string" ? args.recipient : null;
66
+ const message = typeof args.message === "string" ? args.message : null;
67
+ if (recipient && message) return truncateSummary(`→ ${recipient}: ${message.replace(/\s+/g, " ").trim()}`);
68
+ if (message) return truncateSummary(message.replace(/\s+/g, " ").trim());
69
+ return recipient ? truncateSummary(`→ ${recipient}`) : "";
70
+ }
71
+
72
+ if (key === "task" || key === "teams") {
73
+ const action = typeof args.action === "string" ? args.action : null;
74
+ return action ? truncateSummary(action) : "";
75
+ }
76
+
77
+ // Fallback: try to find a meaningful first-string arg
78
+ for (const v of Object.values(args)) {
79
+ if (typeof v === "string" && v.length > 0) return truncateSummary(v);
80
+ }
81
+ return "";
82
+ }
83
+
84
+ /**
85
+ * Extract the first text content from a ToolResultMessage-shaped result.
86
+ * The agent-loop emits `{ content: [{type: "text", text: "..."}], ... }`.
87
+ */
88
+ function extractContentText(result: Record<string, unknown>): string | null {
89
+ const content = result.content;
90
+ if (!Array.isArray(content)) return null;
91
+ for (const item of content) {
92
+ if (isRecord(item) && item.type === "text" && typeof item.text === "string") {
93
+ return item.text;
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
99
+ function summarizeToolResult(toolName: string, result: unknown, isError: boolean): string {
100
+ if (isError) {
101
+ if (typeof result === "string") return truncateSummary(result.replace(/\s+/g, " ").trim());
102
+ if (isRecord(result)) {
103
+ // Try content array first (ToolResultMessage shape)
104
+ const contentText = extractContentText(result);
105
+ if (contentText !== null) {
106
+ const trimmed = contentText.replace(/\s+/g, " ").trim();
107
+ return trimmed.length > 0 ? truncateSummary(trimmed) : "error";
108
+ }
109
+ const msg = typeof result.message === "string"
110
+ ? result.message
111
+ : typeof result.error === "string"
112
+ ? result.error
113
+ : null;
114
+ if (msg) return truncateSummary(msg.replace(/\s+/g, " ").trim());
115
+ }
116
+ return "error";
117
+ }
118
+
119
+ if (typeof result === "string") {
120
+ const compact = result.replace(/\s+/g, " ").trim();
121
+ if (compact.length === 0) return "(empty)";
122
+ return truncateSummary(compact);
123
+ }
124
+
125
+ if (isRecord(result)) {
126
+ // Try content array first (ToolResultMessage shape from agent-loop)
127
+ const contentText = extractContentText(result);
128
+ if (contentText !== null) {
129
+ const compact = contentText.replace(/\s+/g, " ").trim();
130
+ if (compact.length === 0) return "(empty)";
131
+ return truncateSummary(compact);
132
+ }
133
+ // Fallback: check for common structured result shapes
134
+ const status = typeof result.status === "string" ? result.status : null;
135
+ if (status) return truncateSummary(status);
136
+ const output = typeof result.output === "string" ? result.output : null;
137
+ if (output) return truncateSummary(output.replace(/\s+/g, " ").trim());
138
+ }
139
+
140
+ if (Array.isArray(result)) {
141
+ return `${String(result.length)} items`;
142
+ }
143
+
144
+ return "";
145
+ }
12
146
 
13
147
  export class TranscriptLog {
14
148
  private entries: TranscriptEntry[] = [];
@@ -33,6 +167,160 @@ export class TranscriptLog {
33
167
  }
34
168
  }
35
169
 
170
+ // ── Tool content extraction ──
171
+ // Extracts a compact, human-readable summary from tool args/results.
172
+ // Budget: ≤120 chars to fit one terminal line minus timestamp prefix.
173
+
174
+ const MAX_CONTENT_LEN = 120;
175
+
176
+ function truncateContent(s: string, maxLen: number = MAX_CONTENT_LEN): string {
177
+ const oneLine = s.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
178
+ if (oneLine.length <= maxLen) return oneLine;
179
+ return oneLine.slice(0, maxLen - 1) + "\u2026";
180
+ }
181
+
182
+ /**
183
+ * Extract a display-friendly summary from tool start args.
184
+ *
185
+ * Known tool shapes:
186
+ * read → { path }
187
+ * edit → { path }
188
+ * write → { path }
189
+ * bash → { command }
190
+ * grep → { pattern, path? }
191
+ * glob → { pattern }
192
+ *
193
+ * Falls back to the first short string value for unknown tools.
194
+ */
195
+ function extractStartContent(toolName: string, args: unknown): string | null {
196
+ if (!isRecord(args)) return null;
197
+ const key = toolName.toLowerCase();
198
+
199
+ if (key === "read" || key === "edit" || key === "write") {
200
+ const p = args.path;
201
+ return typeof p === "string" ? truncateContent(p) : null;
202
+ }
203
+ if (key === "bash") {
204
+ const cmd = args.command;
205
+ return typeof cmd === "string" ? truncateContent(cmd) : null;
206
+ }
207
+ if (key === "grep") {
208
+ const pattern = args.pattern;
209
+ const path = args.path;
210
+ if (typeof pattern !== "string") return null;
211
+ const suffix = typeof path === "string" ? ` in ${path}` : "";
212
+ return truncateContent(`/${pattern}/${suffix}`);
213
+ }
214
+ if (key === "glob" || key === "find") {
215
+ const pattern = args.pattern;
216
+ return typeof pattern === "string" ? truncateContent(pattern) : null;
217
+ }
218
+
219
+ // Unknown tool: show the first short string arg value (if any).
220
+ for (const v of Object.values(args)) {
221
+ if (typeof v === "string" && v.length > 0 && v.length <= MAX_CONTENT_LEN) {
222
+ return truncateContent(v);
223
+ }
224
+ }
225
+ return null;
226
+ }
227
+
228
+ /**
229
+ * Extract a cleaner summary for display (may differ from content for some tools).
230
+ *
231
+ * grep: strips regex slashes → "TODO in /src" instead of "/TODO/ in /src"
232
+ * team_message: "→ bob: please rebase onto main" instead of just "bob"
233
+ * bash: normalizes whitespace
234
+ * Others: same as extractStartContent
235
+ */
236
+ function extractStartSummary(toolName: string, args: unknown): string | null {
237
+ if (!isRecord(args)) return null;
238
+ const key = toolName.toLowerCase();
239
+
240
+ if (key === "grep") {
241
+ const pattern = args.pattern;
242
+ const path = args.path;
243
+ if (typeof pattern !== "string") return null;
244
+ const suffix = typeof path === "string" ? ` in ${path}` : "";
245
+ return truncateContent(`${pattern}${suffix}`);
246
+ }
247
+ if (key === "team_message" || key === "message_dm" || key === "message_broadcast") {
248
+ const recipient = args.recipient ?? args.to ?? args.name;
249
+ const message = args.message;
250
+ if (typeof recipient === "string" && typeof message === "string") {
251
+ return truncateContent(`→ ${recipient}: ${message}`);
252
+ }
253
+ }
254
+ if (key === "bash") {
255
+ const cmd = args.command;
256
+ if (typeof cmd === "string") {
257
+ return truncateContent(cmd.replace(/\s+/g, " ").trim());
258
+ }
259
+ }
260
+ // Fall through to default extraction
261
+ return extractStartContent(toolName, args);
262
+ }
263
+
264
+ /**
265
+ * Extract a display-friendly summary from tool end result.
266
+ *
267
+ * For errors: first line of error text.
268
+ * For success: null (the tool_end line already shows tool name + duration;
269
+ * adding full output would be noisy).
270
+ */
271
+ function extractEndContent(isError: boolean, result: unknown): string | null {
272
+ if (!isError) return null;
273
+ if (typeof result === "string") return truncateContent(result);
274
+ if (isRecord(result)) {
275
+ const msg = result.error ?? result.message ?? result.stderr ?? result.output;
276
+ if (typeof msg === "string") return truncateContent(msg);
277
+ }
278
+ return null;
279
+ }
280
+
281
+ /**
282
+ * Extract a display-friendly summary from tool end result (for both success and error).
283
+ *
284
+ * Unlike extractEndContent (which only extracts for errors), this extracts
285
+ * a compact summary from any result for display in the transcript.
286
+ */
287
+ function extractEndSummary(isError: boolean, result: unknown): string | null {
288
+ // For errors, try existing logic first, then fall through to content array extraction
289
+ if (isError) {
290
+ const fromContent = extractEndContent(isError, result);
291
+ if (fromContent !== null) return fromContent;
292
+ // Fall through to handle { content: [{ type: "text", text: "..." }] } shape
293
+ }
294
+
295
+ // For success: try to extract text from common result shapes
296
+ if (typeof result === "string") {
297
+ return result.length === 0 ? "(empty)" : truncateContent(result.replace(/\n/g, " "));
298
+ }
299
+ if (isRecord(result)) {
300
+ // Handle { content: [{ type: "text", text: "..." }] } shape (common in pi tool results)
301
+ const content = result.content;
302
+ if (Array.isArray(content)) {
303
+ const texts: string[] = [];
304
+ for (const item of content) {
305
+ if (isRecord(item) && typeof item.text === "string") {
306
+ texts.push(item.text);
307
+ }
308
+ }
309
+ if (texts.length > 0) {
310
+ const joined = texts.join(" ").replace(/\n/g, " ");
311
+ return joined.length === 0 ? "(empty)" : truncateContent(joined);
312
+ }
313
+ }
314
+ // Handle { output: "..." } or { message: "..." }
315
+ const msg = result.output ?? result.message ?? result.text;
316
+ if (typeof msg === "string") {
317
+ return msg.length === 0 ? "(empty)" : truncateContent(msg.replace(/\n/g, " "));
318
+ }
319
+ }
320
+ // No extractable content
321
+ return null;
322
+ }
323
+
36
324
  export class TranscriptTracker {
37
325
  private logs = new Map<string, TranscriptLog>();
38
326
  private toolStarts = new Map<string, Map<string, number>>(); // name -> toolCallId -> startTimestamp
@@ -61,7 +349,9 @@ export class TranscriptTracker {
61
349
  const starts = this.toolStarts.get(name) ?? new Map<string, number>();
62
350
  starts.set(ev.toolCallId, now);
63
351
  this.toolStarts.set(name, starts);
64
- log.push({ kind: "tool_start", toolName: ev.toolName, timestamp: now });
352
+ const content = extractStartContent(ev.toolName, ev.args);
353
+ const summary = extractStartSummary(ev.toolName, ev.args);
354
+ log.push({ kind: "tool_start", toolName: ev.toolName, content, summary, timestamp: now });
65
355
  return;
66
356
  }
67
357
 
@@ -70,7 +360,9 @@ export class TranscriptTracker {
70
360
  const startTs = starts?.get(ev.toolCallId);
71
361
  const durationMs = startTs === undefined ? 0 : now - startTs;
72
362
  starts?.delete(ev.toolCallId);
73
- log.push({ kind: "tool_end", toolName: ev.toolName, durationMs, timestamp: now });
363
+ const content = extractEndContent(ev.isError, ev.result);
364
+ const summary = extractEndSummary(ev.isError, ev.result);
365
+ log.push({ kind: "tool_end", toolName: ev.toolName, content, summary, isError: ev.isError, durationMs, timestamp: now });
74
366
  return;
75
367
  }
76
368
 
@@ -148,10 +440,6 @@ export class TranscriptTracker {
148
440
 
149
441
  type TrackedEventType = "tool_execution_start" | "tool_execution_end" | "agent_end" | "message_end";
150
442
 
151
- function isRecord(v: unknown): v is Record<string, unknown> {
152
- return typeof v === "object" && v !== null;
153
- }
154
-
155
443
  export interface TeammateActivity {
156
444
  toolUseCount: number;
157
445
  currentToolName: string | null;
@@ -1,5 +1,12 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { cleanupWorktrees, type WorktreeCleanupResult } from "./worktree.js";
4
+
5
+ export type CleanupResult = {
6
+ teamDir: string;
7
+ worktreeResult: WorktreeCleanupResult;
8
+ warnings: string[];
9
+ };
3
10
 
4
11
  export function assertTeamDirWithinTeamsRoot(teamsRootDir: string, teamDir: string): {
5
12
  teamsRootAbs: string;
@@ -21,11 +28,217 @@ export function assertTeamDirWithinTeamsRoot(teamsRootDir: string, teamDir: stri
21
28
  }
22
29
 
23
30
  /**
24
- * Recursively delete the given teamDir, but only if it's safely inside teamsRootDir.
31
+ * Recursively delete the given teamDir, including proper git worktree + branch removal.
32
+ *
33
+ * Steps:
34
+ * 1. Remove git worktrees and branches via `cleanupWorktrees`
35
+ * 2. Delete the team directory recursively
25
36
  *
26
- * Uses fs.rm({ recursive: true, force: true }) so it's idempotent.
37
+ * Idempotent safe to call multiple times.
27
38
  */
28
- export async function cleanupTeamDir(teamsRootDir: string, teamDir: string): Promise<void> {
39
+ export async function cleanupTeamDir(
40
+ teamsRootDir: string,
41
+ teamDir: string,
42
+ opts?: { teamId?: string; repoCwd?: string },
43
+ ): Promise<CleanupResult> {
29
44
  const { teamDirAbs } = assertTeamDirWithinTeamsRoot(teamsRootDir, teamDir);
45
+ const warnings: string[] = [];
46
+
47
+ // Infer teamId from directory name if not provided.
48
+ const teamId = opts?.teamId ?? path.basename(teamDirAbs);
49
+
50
+ // 1. Clean up git worktrees and branches before deleting the directory.
51
+ let worktreeResult: WorktreeCleanupResult = { removedWorktrees: [], removedBranches: [], warnings: [] };
52
+ try {
53
+ worktreeResult = await cleanupWorktrees({ teamDir: teamDirAbs, teamId, repoCwd: opts?.repoCwd });
54
+ warnings.push(...worktreeResult.warnings);
55
+ } catch (err: unknown) {
56
+ const msg = err instanceof Error ? err.message : String(err);
57
+ warnings.push(`Worktree cleanup failed (non-fatal): ${msg}`);
58
+ }
59
+
60
+ // 2. Delete the team directory.
30
61
  await fs.promises.rm(teamDirAbs, { recursive: true, force: true });
62
+
63
+ return { teamDir: teamDirAbs, worktreeResult, warnings };
64
+ }
65
+
66
+ /**
67
+ * Garbage-collect stale team directories that have no active workers and are older
68
+ * than the given threshold.
69
+ *
70
+ * A team directory is considered stale when:
71
+ * - Its config.json `createdAt` (or directory mtime) is older than `maxAgeMs`
72
+ * - It has no in_progress tasks
73
+ * - It has no online members
74
+ *
75
+ * Returns a summary of what was cleaned up.
76
+ */
77
+ export async function gcStaleTeamDirs(opts: {
78
+ teamsRootDir: string;
79
+ maxAgeMs: number;
80
+ repoCwd?: string;
81
+ dryRun?: boolean;
82
+ }): Promise<{
83
+ scanned: number;
84
+ removed: string[];
85
+ skipped: Array<{ teamId: string; reason: string }>;
86
+ warnings: string[];
87
+ }> {
88
+ const { teamsRootDir, maxAgeMs, repoCwd, dryRun } = opts;
89
+ const teamsRootAbs = path.resolve(teamsRootDir);
90
+ const removed: string[] = [];
91
+ const skipped: Array<{ teamId: string; reason: string }> = [];
92
+ const warnings: string[] = [];
93
+
94
+ let entries: string[];
95
+ try {
96
+ entries = await fs.promises.readdir(teamsRootAbs);
97
+ } catch {
98
+ return { scanned: 0, removed, skipped, warnings: ["teams root directory not found"] };
99
+ }
100
+
101
+ // Filter out non-team entries (like _styles, _hooks).
102
+ const teamEntries = entries.filter((e) => !e.startsWith("_"));
103
+ const now = Date.now();
104
+
105
+ for (const teamId of teamEntries) {
106
+ const teamDir = path.join(teamsRootAbs, teamId);
107
+ let stat: fs.Stats;
108
+ try {
109
+ stat = await fs.promises.stat(teamDir);
110
+ } catch {
111
+ continue;
112
+ }
113
+ if (!stat.isDirectory()) continue;
114
+
115
+ // Check age: prefer config.json createdAt, fall back to directory mtime.
116
+ let ageMs: number;
117
+ try {
118
+ const configPath = path.join(teamDir, "config.json");
119
+ const configRaw = await fs.promises.readFile(configPath, "utf8");
120
+ const config: unknown = JSON.parse(configRaw);
121
+ const createdAt = typeof config === "object" && config !== null && "createdAt" in config
122
+ ? (config as Record<string, unknown>).createdAt
123
+ : undefined;
124
+ if (typeof createdAt === "string") {
125
+ const ts = Date.parse(createdAt);
126
+ ageMs = Number.isFinite(ts) ? now - ts : now - stat.mtimeMs;
127
+ } else {
128
+ ageMs = now - stat.mtimeMs;
129
+ }
130
+ } catch {
131
+ ageMs = now - stat.mtimeMs;
132
+ }
133
+
134
+ if (ageMs < maxAgeMs) {
135
+ skipped.push({ teamId, reason: "too recent" });
136
+ continue;
137
+ }
138
+
139
+ // Check for active work: in_progress tasks, online workers, or live attach claims.
140
+ let hasActiveWork = false;
141
+ try {
142
+ const configPath = path.join(teamDir, "config.json");
143
+ const configRaw = await fs.promises.readFile(configPath, "utf8");
144
+ const config: unknown = JSON.parse(configRaw);
145
+ if (typeof config === "object" && config !== null) {
146
+ const members = (config as Record<string, unknown>).members;
147
+ if (Array.isArray(members)) {
148
+ for (const m of members) {
149
+ if (typeof m !== "object" || m === null) continue;
150
+ const rec = m as Record<string, unknown>;
151
+ // Ignore the lead — it stays "online" forever and is not a signal of activity.
152
+ if (rec.role === "lead") continue;
153
+ if (rec.status === "online") {
154
+ hasActiveWork = true;
155
+ break;
156
+ }
157
+ }
158
+ }
159
+ }
160
+ } catch {
161
+ // No config — probably safe to remove.
162
+ }
163
+
164
+ // Check for a live attach claim (another session is using this team).
165
+ if (!hasActiveWork) {
166
+ try {
167
+ const claimPath = path.join(teamDir, ".attach-claim.json");
168
+ const claimRaw = await fs.promises.readFile(claimPath, "utf8");
169
+ const claim: unknown = JSON.parse(claimRaw);
170
+ if (typeof claim === "object" && claim !== null) {
171
+ const heartbeatAt = (claim as Record<string, unknown>).heartbeatAt;
172
+ if (typeof heartbeatAt === "string") {
173
+ const hbTs = Date.parse(heartbeatAt);
174
+ // Consider claims fresh if heartbeat is within 5 minutes.
175
+ const claimFreshnessMs = 5 * 60 * 1000;
176
+ if (Number.isFinite(hbTs) && now - hbTs < claimFreshnessMs) {
177
+ hasActiveWork = true;
178
+ }
179
+ }
180
+ }
181
+ } catch {
182
+ // No claim file or invalid — not actively attached.
183
+ }
184
+ }
185
+
186
+ if (!hasActiveWork) {
187
+ // Check task files for in_progress tasks.
188
+ // Tasks live at tasks/<taskListId>/<id>.json — scan all subdirectories.
189
+ try {
190
+ const tasksDir = path.join(teamDir, "tasks");
191
+ const taskListDirs = await fs.promises.readdir(tasksDir);
192
+ for (const listDir of taskListDirs) {
193
+ if (hasActiveWork) break;
194
+ const listPath = path.join(tasksDir, listDir);
195
+ let listStat: fs.Stats;
196
+ try {
197
+ listStat = await fs.promises.stat(listPath);
198
+ } catch {
199
+ continue;
200
+ }
201
+ if (!listStat.isDirectory()) continue;
202
+
203
+ const taskFiles = await fs.promises.readdir(listPath);
204
+ for (const tf of taskFiles) {
205
+ if (!tf.endsWith(".json")) continue;
206
+ try {
207
+ const raw = await fs.promises.readFile(path.join(listPath, tf), "utf8");
208
+ const parsed: unknown = JSON.parse(raw);
209
+ if (typeof parsed === "object" && parsed !== null && (parsed as Record<string, unknown>).status === "in_progress") {
210
+ hasActiveWork = true;
211
+ break;
212
+ }
213
+ } catch {
214
+ // ignore individual task read errors
215
+ }
216
+ }
217
+ }
218
+ } catch {
219
+ // No tasks dir — fine.
220
+ }
221
+ }
222
+
223
+ if (hasActiveWork) {
224
+ skipped.push({ teamId, reason: "has active work" });
225
+ continue;
226
+ }
227
+
228
+ if (dryRun) {
229
+ removed.push(teamId);
230
+ continue;
231
+ }
232
+
233
+ try {
234
+ const result = await cleanupTeamDir(teamsRootAbs, teamDir, { teamId, repoCwd });
235
+ warnings.push(...result.warnings);
236
+ removed.push(teamId);
237
+ } catch (err: unknown) {
238
+ const msg = err instanceof Error ? err.message : String(err);
239
+ warnings.push(`Failed to remove ${teamId}: ${msg}`);
240
+ }
241
+ }
242
+
243
+ return { scanned: teamEntries.length, removed, skipped, warnings };
31
244
  }
@@ -4,6 +4,12 @@ import { spawn } from "node:child_process";
4
4
  import { getTeamsHooksDir } from "./paths.js";
5
5
  import type { TeamTask } from "./task-store.js";
6
6
 
7
+ /**
8
+ * Hook contract version. Increment on breaking changes only.
9
+ * See docs/hook-contract.md for the full compatibility policy.
10
+ */
11
+ export const HOOK_CONTRACT_VERSION = 1;
12
+
7
13
  export type TeamsHookEvent = "idle" | "task_completed" | "task_failed";
8
14
 
9
15
  export type TeamsHookInvocation = {
@@ -17,6 +23,43 @@ export type TeamsHookInvocation = {
17
23
  completedTask?: TeamTask | null;
18
24
  };
19
25
 
26
+ /**
27
+ * Structured context payload passed to hooks via PI_TEAMS_HOOK_CONTEXT_JSON.
28
+ *
29
+ * `task` is null for "idle" events and may also be null for task_completed /
30
+ * task_failed events when the task was cleared before the leader processed the
31
+ * worker's idle notification (race condition). Hook authors must guard access.
32
+ *
33
+ * For task_failed events, `task.status` is typically "pending" — the worker
34
+ * resets the task status before emitting the idle notification.
35
+ *
36
+ * See docs/hook-contract.md for the full schema and compatibility policy.
37
+ */
38
+ export interface HookContextPayload {
39
+ version: typeof HOOK_CONTRACT_VERSION;
40
+ event: TeamsHookEvent;
41
+ team: {
42
+ id: string;
43
+ dir: string;
44
+ taskListId: string;
45
+ style: string;
46
+ };
47
+ member: string | null;
48
+ timestamp: string | null;
49
+ task: {
50
+ id: string;
51
+ subject: string;
52
+ description: string;
53
+ owner: string | null;
54
+ status: string;
55
+ blockedBy: string[];
56
+ blocks: string[];
57
+ metadata: Record<string, unknown>;
58
+ createdAt: string;
59
+ updatedAt: string;
60
+ } | null;
61
+ }
62
+
20
63
  export type TeamsHookRunResult = {
21
64
  ran: boolean;
22
65
  hookPath?: string;
@@ -27,6 +70,8 @@ export type TeamsHookRunResult = {
27
70
  stdout: string;
28
71
  stderr: string;
29
72
  error?: string;
73
+ /** The contract version used for this invocation. */
74
+ contractVersion: typeof HOOK_CONTRACT_VERSION;
30
75
  };
31
76
 
32
77
  function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
@@ -126,10 +171,10 @@ function truncateField(value: string, max: number): string {
126
171
  return `${value.slice(0, Math.max(0, max - 1))}…`;
127
172
  }
128
173
 
129
- function getHookContextJson(invocation: TeamsHookInvocation): string {
174
+ export function buildHookContextPayload(invocation: TeamsHookInvocation): HookContextPayload {
130
175
  const task = invocation.completedTask;
131
- const payload = {
132
- version: 1,
176
+ return {
177
+ version: HOOK_CONTRACT_VERSION,
133
178
  event: invocation.event,
134
179
  team: {
135
180
  id: invocation.teamId,
@@ -154,7 +199,10 @@ function getHookContextJson(invocation: TeamsHookInvocation): string {
154
199
  }
155
200
  : null,
156
201
  };
157
- return JSON.stringify(payload);
202
+ }
203
+
204
+ function getHookContextJson(invocation: TeamsHookInvocation): string {
205
+ return JSON.stringify(buildHookContextPayload(invocation));
158
206
  }
159
207
 
160
208
  export function getHookBaseName(event: TeamsHookEvent): string {
@@ -280,6 +328,7 @@ export async function runTeamsHook(opts: {
280
328
  durationMs: 0,
281
329
  stdout: "",
282
330
  stderr: "",
331
+ contractVersion: HOOK_CONTRACT_VERSION,
283
332
  };
284
333
  }
285
334
 
@@ -293,6 +342,7 @@ export async function runTeamsHook(opts: {
293
342
  durationMs: 0,
294
343
  stdout: "",
295
344
  stderr: "",
345
+ contractVersion: HOOK_CONTRACT_VERSION,
296
346
  };
297
347
  }
298
348
 
@@ -302,7 +352,7 @@ export async function runTeamsHook(opts: {
302
352
  const baseEnv: NodeJS.ProcessEnv = {
303
353
  ...env,
304
354
  PI_TEAMS_HOOK_EVENT: opts.invocation.event,
305
- PI_TEAMS_HOOK_CONTEXT_VERSION: "1",
355
+ PI_TEAMS_HOOK_CONTEXT_VERSION: String(HOOK_CONTRACT_VERSION),
306
356
  PI_TEAMS_HOOK_CONTEXT_JSON: getHookContextJson(opts.invocation),
307
357
  PI_TEAMS_TEAM_ID: opts.invocation.teamId,
308
358
  PI_TEAMS_TEAM_DIR: opts.invocation.teamDir,
@@ -336,6 +386,7 @@ export async function runTeamsHook(opts: {
336
386
  stdout: "",
337
387
  stderr: "",
338
388
  error: msg,
389
+ contractVersion: HOOK_CONTRACT_VERSION,
339
390
  };
340
391
  }
341
392
 
@@ -349,6 +400,7 @@ export async function runTeamsHook(opts: {
349
400
  stdout: res.stdout,
350
401
  stderr: res.stderr,
351
402
  error: res.error,
403
+ contractVersion: HOOK_CONTRACT_VERSION,
352
404
  };
353
405
  }
354
406