pi-extended-teams 2.1.16

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 (130) hide show
  1. package/README.md +105 -0
  2. package/assets/pi-extended-teams-agent-navigation.png +0 -0
  3. package/assets/pi-extended-teams-in-action.png +0 -0
  4. package/extensions/agents/read-agent-report.ts +181 -0
  5. package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
  6. package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
  7. package/extensions/agents/read-agent.test.ts +3077 -0
  8. package/extensions/agents/read-agent.ts +1119 -0
  9. package/extensions/agents/write-agent.test.ts +513 -0
  10. package/extensions/agents/write-agent.ts +392 -0
  11. package/extensions/events/register-events.test.ts +465 -0
  12. package/extensions/events/register-events.ts +409 -0
  13. package/extensions/index.test.ts +1659 -0
  14. package/extensions/index.ts +1228 -0
  15. package/extensions/internal/agent-session-files.test.ts +164 -0
  16. package/extensions/internal/agent-session-files.ts +320 -0
  17. package/extensions/internal/debug.ts +44 -0
  18. package/extensions/internal/model-selection.ts +82 -0
  19. package/extensions/internal/pi-command.test.ts +191 -0
  20. package/extensions/internal/pi-command.ts +224 -0
  21. package/extensions/internal/pi-runtime-api.test.ts +43 -0
  22. package/extensions/internal/pi-runtime-api.ts +78 -0
  23. package/extensions/internal/schema.ts +19 -0
  24. package/extensions/internal/session-context-reference.test.ts +290 -0
  25. package/extensions/internal/session-context-reference.ts +430 -0
  26. package/extensions/internal/session-files.test.ts +225 -0
  27. package/extensions/internal/session-files.ts +273 -0
  28. package/extensions/internal/session-usage.ts +59 -0
  29. package/extensions/resources/spawn-resource-plan.test.ts +233 -0
  30. package/extensions/resources/spawn-resource-plan.ts +247 -0
  31. package/extensions/runtime/active-agent-sleep.test.ts +157 -0
  32. package/extensions/runtime/active-agent-sleep.ts +117 -0
  33. package/extensions/runtime/nested-read-agents.ts +22 -0
  34. package/extensions/runtime/pending-child-controller.test.ts +169 -0
  35. package/extensions/runtime/pending-child-controller.ts +280 -0
  36. package/extensions/runtime/types.ts +68 -0
  37. package/extensions/team/contracts.test.ts +111 -0
  38. package/extensions/team/lifecycle.test.ts +1095 -0
  39. package/extensions/team/lifecycle.ts +502 -0
  40. package/extensions/team/recipient-closure.test.ts +97 -0
  41. package/extensions/team/recipient-closure.ts +120 -0
  42. package/extensions/team/roster.test.ts +129 -0
  43. package/extensions/team/roster.ts +165 -0
  44. package/extensions/team/team-contracts.json +43 -0
  45. package/extensions/team/writer-screens.test.ts +50 -0
  46. package/extensions/team/writer-screens.ts +156 -0
  47. package/extensions/tools/agent-communication-tools.test.ts +306 -0
  48. package/extensions/tools/agent-communication-tools.ts +183 -0
  49. package/extensions/tools/coordination-tools.test.ts +670 -0
  50. package/extensions/tools/coordination-tools.ts +304 -0
  51. package/extensions/tools/delegation-guard.test.ts +95 -0
  52. package/extensions/tools/delegation-guard.ts +65 -0
  53. package/extensions/tools/file-claim-tools.test.ts +104 -0
  54. package/extensions/tools/file-claim-tools.ts +68 -0
  55. package/extensions/tools/model-tools.ts +53 -0
  56. package/extensions/tools/predefined-tools.test.ts +647 -0
  57. package/extensions/tools/predefined-tools.ts +331 -0
  58. package/extensions/tools/read-helper.test.ts +35 -0
  59. package/extensions/tools/task-runtime-tools.test.ts +334 -0
  60. package/extensions/tools/task-runtime-tools.ts +227 -0
  61. package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
  62. package/extensions/tools/team-tools.ts +1270 -0
  63. package/extensions/ui/agent-follow-view.test.ts +520 -0
  64. package/extensions/ui/agent-follow-view.ts +779 -0
  65. package/extensions/ui/agent-navigation.test.ts +52 -0
  66. package/extensions/ui/agent-navigation.ts +55 -0
  67. package/extensions/ui/ansi.ts +16 -0
  68. package/extensions/ui/extensions-command.test.ts +336 -0
  69. package/extensions/ui/extensions-command.ts +282 -0
  70. package/extensions/ui/favorite-models-command.test.ts +280 -0
  71. package/extensions/ui/favorite-models-command.ts +423 -0
  72. package/extensions/ui/frame.ts +77 -0
  73. package/extensions/ui/input.ts +17 -0
  74. package/extensions/ui/read-agent-status.test.ts +117 -0
  75. package/extensions/ui/read-agent-status.ts +125 -0
  76. package/extensions/ui/renderers.ts +243 -0
  77. package/extensions/ui/status-widget.test.ts +143 -0
  78. package/extensions/ui/status-widget.ts +440 -0
  79. package/extensions/ui-frame.test.ts +69 -0
  80. package/package.json +75 -0
  81. package/skills/teams.md +256 -0
  82. package/src/adapters/terminal-registry.ts +78 -0
  83. package/src/adapters/tmux-adapter.test.ts +276 -0
  84. package/src/adapters/tmux-adapter.ts +200 -0
  85. package/src/orchestration/index.ts +437 -0
  86. package/src/orchestration/orchestrator.test.ts +396 -0
  87. package/src/orchestration/types.ts +125 -0
  88. package/src/utils/atomic-json.ts +21 -0
  89. package/src/utils/claims.test.ts +137 -0
  90. package/src/utils/claims.ts +169 -0
  91. package/src/utils/hooks.test.ts +75 -0
  92. package/src/utils/hooks.ts +35 -0
  93. package/src/utils/lifecycle-tombstone.test.ts +105 -0
  94. package/src/utils/lifecycle-tombstone.ts +268 -0
  95. package/src/utils/lock.race.child.ts +44 -0
  96. package/src/utils/lock.race.test.ts +198 -0
  97. package/src/utils/lock.test.ts +90 -0
  98. package/src/utils/lock.ts +186 -0
  99. package/src/utils/messaging.test.ts +337 -0
  100. package/src/utils/messaging.ts +441 -0
  101. package/src/utils/model-resolution.test.ts +231 -0
  102. package/src/utils/model-resolution.ts +322 -0
  103. package/src/utils/models.test.ts +8 -0
  104. package/src/utils/models.ts +115 -0
  105. package/src/utils/paths.ts +81 -0
  106. package/src/utils/predefined-teams/types.ts +48 -0
  107. package/src/utils/predefined-teams.save-template.test.ts +74 -0
  108. package/src/utils/predefined-teams.test.ts +441 -0
  109. package/src/utils/predefined-teams.ts +471 -0
  110. package/src/utils/read-helper-queue.ts +99 -0
  111. package/src/utils/report-events.test.ts +154 -0
  112. package/src/utils/report-events.ts +222 -0
  113. package/src/utils/runtime.test.ts +314 -0
  114. package/src/utils/runtime.ts +261 -0
  115. package/src/utils/security.test.ts +43 -0
  116. package/src/utils/settings.test.ts +480 -0
  117. package/src/utils/settings.ts +645 -0
  118. package/src/utils/shared-memory.test.ts +80 -0
  119. package/src/utils/shared-memory.ts +81 -0
  120. package/src/utils/tasks.race.test.ts +44 -0
  121. package/src/utils/tasks.test.ts +229 -0
  122. package/src/utils/tasks.ts +396 -0
  123. package/src/utils/teams.ts +231 -0
  124. package/src/utils/terminal-adapter.ts +103 -0
  125. package/src/utils/thinking-levels.test.ts +56 -0
  126. package/src/utils/thinking-levels.ts +47 -0
  127. package/src/utils/workflow-metadata.test.ts +11 -0
  128. package/src/utils/workflow-metadata.ts +88 -0
  129. package/src/utils/write-queue.test.ts +251 -0
  130. package/src/utils/write-queue.ts +202 -0
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import * as paths from "./paths";
6
+ import { deleteSharedMemory, readSharedMemory, writeSharedMemory } from "./shared-memory";
7
+
8
+ const testDir = path.join(os.tmpdir(), "pi-extended-teams-shared-memory-" + Date.now());
9
+ const memoryPath = path.join(testDir, "shared-memory.json");
10
+
11
+ describe("shared memory utilities", () => {
12
+ beforeEach(() => {
13
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
14
+ fs.mkdirSync(testDir, { recursive: true });
15
+ vi.spyOn(paths, "sharedMemoryPath").mockReturnValue(memoryPath);
16
+ });
17
+
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
21
+ });
22
+
23
+ it("writes and reads shared memory entries", async () => {
24
+ const entry = await writeSharedMemory("team", "alice", "api-contract", "GET /users returns users");
25
+
26
+ expect(entry).toMatchObject({
27
+ key: "api-contract",
28
+ value: "GET /users returns users",
29
+ author: "alice",
30
+ });
31
+ expect(entry.updatedAt).toEqual(expect.any(Number));
32
+
33
+ expect(await readSharedMemory("team", "api-contract")).toEqual([entry]);
34
+ });
35
+
36
+ it("lists entries sorted by key", async () => {
37
+ await writeSharedMemory("team", "alice", "z", "last");
38
+ await writeSharedMemory("team", "bob", "a", "first");
39
+
40
+ expect((await readSharedMemory("team")).map(entry => entry.key)).toEqual(["a", "z"]);
41
+ });
42
+
43
+ it("replaces existing keys", async () => {
44
+ await writeSharedMemory("team", "alice", "decision", "old");
45
+ await writeSharedMemory("team", "bob", "decision", "new");
46
+
47
+ expect(await readSharedMemory("team", "decision")).toMatchObject([
48
+ { key: "decision", value: "new", author: "bob" },
49
+ ]);
50
+ });
51
+
52
+ it("deletes entries", async () => {
53
+ const entry = await writeSharedMemory("team", "alice", "decision", "keep");
54
+
55
+ expect(await deleteSharedMemory("team", "decision")).toEqual(entry);
56
+ expect(await readSharedMemory("team", "decision")).toEqual([]);
57
+ expect(await deleteSharedMemory("team", "decision")).toBeNull();
58
+ });
59
+
60
+ it("never truncates the live memory file in place", async () => {
61
+ await writeSharedMemory("team", "alice", "decision", "keep");
62
+ const writes: string[] = [];
63
+ const realWriteFileSync = fs.writeFileSync;
64
+ vi.spyOn(fs, "writeFileSync").mockImplementation(((target: any, data: any, options: any) => {
65
+ writes.push(String(target));
66
+ return realWriteFileSync(target, data, options);
67
+ }) as typeof fs.writeFileSync);
68
+
69
+ await writeSharedMemory("team", "bob", "other", "value");
70
+
71
+ expect(writes).not.toContain(memoryPath);
72
+ expect((await readSharedMemory("team")).map((entry) => entry.key).sort()).toEqual(["decision", "other"]);
73
+ expect(fs.readdirSync(testDir).filter((entry) => entry.endsWith(".tmp"))).toEqual([]);
74
+ });
75
+
76
+ it("rejects blank keys", async () => {
77
+ await expect(writeSharedMemory("team", "alice", " ", "value")).rejects.toThrow("must not be empty");
78
+ await expect(deleteSharedMemory("team", " ")).rejects.toThrow("must not be empty");
79
+ });
80
+ });
@@ -0,0 +1,81 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { writeJsonAtomic } from "./atomic-json";
4
+ import { withLock } from "./lock";
5
+ import { sharedMemoryPath } from "./paths";
6
+
7
+ export interface SharedMemoryEntry {
8
+ key: string;
9
+ value: string;
10
+ author: string;
11
+ updatedAt: number;
12
+ }
13
+
14
+ function ensureMemoryPath(teamName: string): string {
15
+ const p = sharedMemoryPath(teamName);
16
+ const dir = path.dirname(p);
17
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
18
+ return p;
19
+ }
20
+
21
+ function readMemoryRaw(p: string): Record<string, SharedMemoryEntry> {
22
+ if (!fs.existsSync(p)) return {};
23
+ const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
24
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
25
+ }
26
+
27
+ function writeMemoryRaw(p: string, memory: Record<string, SharedMemoryEntry>): void {
28
+ writeJsonAtomic(p, memory);
29
+ }
30
+
31
+ export async function writeSharedMemory(
32
+ teamName: string,
33
+ author: string,
34
+ key: string,
35
+ value: string
36
+ ): Promise<SharedMemoryEntry> {
37
+ const normalizedKey = key.trim();
38
+ if (!normalizedKey) throw new Error("Shared memory key must not be empty.");
39
+
40
+ const p = ensureMemoryPath(teamName);
41
+ return await withLock(p, async () => {
42
+ const memory = readMemoryRaw(p);
43
+ const entry: SharedMemoryEntry = {
44
+ key: normalizedKey,
45
+ value,
46
+ author,
47
+ updatedAt: Date.now(),
48
+ };
49
+ memory[normalizedKey] = entry;
50
+ writeMemoryRaw(p, memory);
51
+ return entry;
52
+ });
53
+ }
54
+
55
+ export async function readSharedMemory(teamName: string, key?: string): Promise<SharedMemoryEntry[]> {
56
+ const p = ensureMemoryPath(teamName);
57
+ return await withLock(p, async () => {
58
+ const memory = readMemoryRaw(p);
59
+ if (key) {
60
+ const entry = memory[key.trim()];
61
+ return entry ? [entry] : [];
62
+ }
63
+ return Object.values(memory).sort((a, b) => a.key.localeCompare(b.key));
64
+ });
65
+ }
66
+
67
+ export async function deleteSharedMemory(teamName: string, key: string): Promise<SharedMemoryEntry | null> {
68
+ const normalizedKey = key.trim();
69
+ if (!normalizedKey) throw new Error("Shared memory key must not be empty.");
70
+
71
+ const p = ensureMemoryPath(teamName);
72
+ return await withLock(p, async () => {
73
+ const memory = readMemoryRaw(p);
74
+ const entry = memory[normalizedKey] || null;
75
+ if (entry) {
76
+ delete memory[normalizedKey];
77
+ writeMemoryRaw(p, memory);
78
+ }
79
+ return entry;
80
+ });
81
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { createTask, listTasks } from "./tasks";
6
+ import * as paths from "./paths";
7
+
8
+ const testDir = path.join(os.tmpdir(), "pi-tasks-race-test-" + Date.now());
9
+
10
+ describe("Tasks Race Condition Bug", () => {
11
+ beforeEach(() => {
12
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
13
+ fs.mkdirSync(testDir, { recursive: true });
14
+
15
+ vi.spyOn(paths, "taskDir").mockReturnValue(testDir);
16
+ vi.spyOn(paths, "configPath").mockReturnValue(path.join(testDir, "config.json"));
17
+ fs.writeFileSync(path.join(testDir, "config.json"), JSON.stringify({ name: "test-team" }));
18
+ });
19
+
20
+ afterEach(() => {
21
+ vi.restoreAllMocks();
22
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
23
+ });
24
+
25
+ it("should potentially fail to create unique IDs under high concurrency (Demonstrating Bug 1)", async () => {
26
+ const numTasks = 20;
27
+ const promises = [];
28
+
29
+ for (let i = 0; i < numTasks; i++) {
30
+ promises.push(createTask("test-team", `Task ${i}`, `Desc ${i}`));
31
+ }
32
+
33
+ const results = await Promise.all(promises);
34
+ const ids = results.map(r => r.id);
35
+ const uniqueIds = new Set(ids);
36
+
37
+ // If Bug 1 exists (getTaskId outside the lock but actually it is inside the lock in createTask),
38
+ // this test might still pass because createTask locks the directory.
39
+ // WAIT: I noticed createTask uses withLock(lockPath, ...) where lockPath = dir.
40
+ // Let's re-verify createTask in src/utils/tasks.ts
41
+
42
+ expect(uniqueIds.size).toBe(numTasks);
43
+ });
44
+ });
@@ -0,0 +1,229 @@
1
+ // Project: pi-extended-teams
2
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import {
7
+ createTask,
8
+ updateTask,
9
+ readTask,
10
+ listTasks,
11
+ submitPlan,
12
+ evaluatePlan,
13
+ markOwnerTasksBlockedByFileClaims,
14
+ clearOwnerFileClaimBlocks,
15
+ updateTaskGuarded,
16
+ } from "./tasks";
17
+ import * as paths from "./paths";
18
+ import * as teams from "./teams";
19
+
20
+ // Keep this suite isolated from messaging tests and parallel Vitest workers.
21
+ let testDir: string;
22
+
23
+ describe("Tasks Utilities", () => {
24
+ beforeEach(() => {
25
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-extended-teams-tasks-"));
26
+
27
+ // Override paths to use testDir
28
+ vi.spyOn(paths, "taskDir").mockReturnValue(testDir);
29
+ vi.spyOn(paths, "configPath").mockReturnValue(path.join(testDir, "config.json"));
30
+
31
+ // Create a dummy team config
32
+ fs.writeFileSync(path.join(testDir, "config.json"), JSON.stringify({ name: "test-team" }));
33
+ });
34
+
35
+ afterEach(() => {
36
+ vi.restoreAllMocks();
37
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
38
+ });
39
+
40
+ it("should create a task successfully", async () => {
41
+ const task = await createTask("test-team", "Test Subject", "Test Description");
42
+ expect(task.id).toBe("1");
43
+ expect(task.subject).toBe("Test Subject");
44
+ expect(fs.existsSync(path.join(testDir, "1.json"))).toBe(true);
45
+ });
46
+
47
+ it("should update a task successfully", async () => {
48
+ await createTask("test-team", "Test Subject", "Test Description");
49
+ const updated = await updateTask("test-team", "1", { status: "in_progress" });
50
+ expect(updated.status).toBe("in_progress");
51
+
52
+ const taskData = JSON.parse(fs.readFileSync(path.join(testDir, "1.json"), "utf-8"));
53
+ expect(taskData.status).toBe("in_progress");
54
+ });
55
+
56
+ it("should guard task updates by version and operation id", async () => {
57
+ const task = await createTask("test-team", "Guarded", "Description");
58
+ const result = await updateTaskGuarded(
59
+ "test-team",
60
+ task.id,
61
+ { status: "in_progress" },
62
+ { expectedStatus: "pending", expectedVersion: task.version, operationId: "op-1" }
63
+ );
64
+
65
+ expect(result.updated).toBe(true);
66
+ expect(result.idempotent).toBe(false);
67
+ expect(result.task.status).toBe("in_progress");
68
+
69
+ const replay = await updateTaskGuarded(
70
+ "test-team",
71
+ task.id,
72
+ { status: "completed" },
73
+ { expectedStatus: "pending", expectedVersion: task.version, operationId: "op-1" }
74
+ );
75
+ expect(replay.updated).toBe(false);
76
+ expect(replay.idempotent).toBe(true);
77
+ expect(replay.task.status).toBe("in_progress");
78
+
79
+ await expect(updateTaskGuarded(
80
+ "test-team",
81
+ task.id,
82
+ { status: "completed" },
83
+ { expectedStatus: "pending", expectedVersion: task.version }
84
+ )).rejects.toThrow(/status guard failed/);
85
+ });
86
+
87
+ it("should submit a plan successfully", async () => {
88
+ const task = await createTask("test-team", "Test Subject", "Test Description");
89
+ const plan = "Step 1: Do something\nStep 2: Profit";
90
+ const updated = await submitPlan("test-team", task.id, plan);
91
+ expect(updated.status).toBe("planning");
92
+ expect(updated.plan).toBe(plan);
93
+
94
+ const taskData = JSON.parse(fs.readFileSync(path.join(testDir, `${task.id}.json`), "utf-8"));
95
+ expect(taskData.status).toBe("planning");
96
+ expect(taskData.plan).toBe(plan);
97
+ });
98
+
99
+ it("should fail to submit an empty plan", async () => {
100
+ const task = await createTask("test-team", "Empty Test", "Should fail");
101
+ await expect(submitPlan("test-team", task.id, "")).rejects.toThrow("Plan must not be empty");
102
+ await expect(submitPlan("test-team", task.id, " ")).rejects.toThrow("Plan must not be empty");
103
+ });
104
+
105
+ it("should list tasks", async () => {
106
+ await createTask("test-team", "Task 1", "Desc 1");
107
+ await createTask("test-team", "Task 2", "Desc 2");
108
+ const tasksList = await listTasks("test-team");
109
+ expect(tasksList.length).toBe(2);
110
+ expect(tasksList[0].id).toBe("1");
111
+ expect(tasksList[1].id).toBe("2");
112
+ });
113
+
114
+ it("should have consistent lock paths (Fixed BUG 2)", async () => {
115
+ // This test verifies that both updateTask and readTask now use the same lock path
116
+ // Both should now lock `${taskId}.json.lock`
117
+
118
+ await createTask("test-team", "Bug Test", "Testing lock consistency");
119
+ const taskId = "1";
120
+
121
+ const taskFile = path.join(testDir, `${taskId}.json`);
122
+ const commonLockFile = `${taskFile}.lock`;
123
+
124
+ // 1. Holding the common lock
125
+ fs.writeFileSync(commonLockFile, "9999");
126
+
127
+ // 2. Try updateTask, it should fail
128
+ // Using small retries to speed up the test and avoid fake timer issues with native setTimeout
129
+ await expect(updateTask("test-team", taskId, { status: "in_progress" }, 2)).rejects.toThrow("Could not acquire lock");
130
+
131
+ // 3. Try readTask, it should fail too
132
+ await expect(readTask("test-team", taskId, 2)).rejects.toThrow("Could not acquire lock");
133
+
134
+ fs.unlinkSync(commonLockFile);
135
+ });
136
+
137
+ it("should approve a plan successfully", async () => {
138
+ const task = await createTask("test-team", "Plan Test", "Should be approved");
139
+ await submitPlan("test-team", task.id, "Wait for it...");
140
+
141
+ const approved = await evaluatePlan("test-team", task.id, "approve");
142
+ expect(approved.status).toBe("in_progress");
143
+ expect(approved.planFeedback).toBe("");
144
+ });
145
+
146
+ it("should reject a plan with feedback", async () => {
147
+ const task = await createTask("test-team", "Plan Test", "Should be rejected");
148
+ await submitPlan("test-team", task.id, "Wait for it...");
149
+
150
+ const feedback = "Not good enough!";
151
+ const rejected = await evaluatePlan("test-team", task.id, "reject", feedback);
152
+ expect(rejected.status).toBe("planning");
153
+ expect(rejected.planFeedback).toBe(feedback);
154
+ });
155
+
156
+ it("should fail to evaluate a task not in 'planning' status", async () => {
157
+ const task = await createTask("test-team", "Status Test", "Invalid status for eval");
158
+ // status is "pending"
159
+ await expect(evaluatePlan("test-team", task.id, "approve")).rejects.toThrow("must be in 'planning' status");
160
+ });
161
+
162
+ it("should fail to evaluate a task without a plan", async () => {
163
+ const task = await createTask("test-team", "Plan Missing Test", "No plan submitted");
164
+ await updateTask("test-team", task.id, { status: "planning" }); // bypass submitPlan to have no plan
165
+ await expect(evaluatePlan("test-team", task.id, "approve")).rejects.toThrow("no plan has been submitted");
166
+ });
167
+
168
+ it("should fail to reject a plan without feedback", async () => {
169
+ const task = await createTask("test-team", "Feedback Test", "Should require feedback");
170
+ await submitPlan("test-team", task.id, "My plan");
171
+ await expect(evaluatePlan("test-team", task.id, "reject")).rejects.toThrow("Feedback is required when rejecting a plan");
172
+ await expect(evaluatePlan("test-team", task.id, "reject", " ")).rejects.toThrow("Feedback is required when rejecting a plan");
173
+ });
174
+
175
+ it("marks owned open tasks blocked by file-claim conflicts", async () => {
176
+ const task = await createTask("test-team", "Claim Block Test", "Should be blocked");
177
+ await updateTask("test-team", task.id, {
178
+ status: "in_progress",
179
+ owner: "alice",
180
+ blockedBy: ["upstream-task"],
181
+ });
182
+
183
+ const conflicts = [{ path: "src/a.ts", heldBy: "bob" }];
184
+ const updated = await markOwnerTasksBlockedByFileClaims(
185
+ "test-team",
186
+ "alice",
187
+ conflicts,
188
+ "2026-06-14T00:00:00.000Z"
189
+ );
190
+
191
+ expect(updated.map(t => t.id)).toEqual([task.id]);
192
+ const blockedTask = await readTask("test-team", task.id);
193
+ expect(blockedTask.blockedBy).toEqual(["upstream-task", "file-claim:src%2Fa.ts:bob"]);
194
+ expect(blockedTask.metadata?.fileClaimBlock).toEqual({
195
+ blockedAt: "2026-06-14T00:00:00.000Z",
196
+ conflicts,
197
+ });
198
+ });
199
+
200
+ it("clears file-claim blockers after the owner gets the claim", async () => {
201
+ const task = await createTask("test-team", "Claim Clear Test", "Should unblock");
202
+ await updateTask("test-team", task.id, {
203
+ status: "in_progress",
204
+ owner: "alice",
205
+ blockedBy: ["upstream-task"],
206
+ });
207
+ await markOwnerTasksBlockedByFileClaims(
208
+ "test-team",
209
+ "alice",
210
+ [{ path: "src/a.ts", heldBy: "bob" }],
211
+ "2026-06-14T00:00:00.000Z"
212
+ );
213
+
214
+ const updated = await clearOwnerFileClaimBlocks("test-team", "alice", ["src/a.ts"]);
215
+
216
+ expect(updated.map(t => t.id)).toEqual([task.id]);
217
+ const unblockedTask = await readTask("test-team", task.id);
218
+ expect(unblockedTask.blockedBy).toEqual(["upstream-task"]);
219
+ expect(unblockedTask.metadata?.fileClaimBlock).toBeUndefined();
220
+ });
221
+
222
+ it("should sanitize task IDs in all file operations", async () => {
223
+ const dirtyId = "../evil-id";
224
+ // sanitizeName should throw on this dirtyId
225
+ await expect(readTask("test-team", dirtyId)).rejects.toThrow(/Invalid name: "..\/evil-id"/);
226
+ await expect(updateTask("test-team", dirtyId, { status: "in_progress" })).rejects.toThrow(/Invalid name: "..\/evil-id"/);
227
+ await expect(evaluatePlan("test-team", dirtyId, "approve")).rejects.toThrow(/Invalid name: "..\/evil-id"/);
228
+ });
229
+ });