indus-swarms 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +14 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/docs/claude-parity.md +151 -0
- package/docs/field-notes-teams-setup.md +107 -0
- package/docs/smoke-test-plan.md +146 -0
- package/eslint.config.js +74 -0
- package/extensions/teams/README.md +23 -0
- package/extensions/teams/activity-tracker.ts +234 -0
- package/extensions/teams/cleanup.ts +31 -0
- package/extensions/teams/fs-lock.ts +87 -0
- package/extensions/teams/hooks.ts +363 -0
- package/extensions/teams/index.ts +18 -0
- package/extensions/teams/leader-attach-commands.ts +221 -0
- package/extensions/teams/leader-inbox.ts +214 -0
- package/extensions/teams/leader-info-commands.ts +140 -0
- package/extensions/teams/leader-lifecycle-commands.ts +559 -0
- package/extensions/teams/leader-messaging-commands.ts +148 -0
- package/extensions/teams/leader-plan-commands.ts +95 -0
- package/extensions/teams/leader-spawn-command.ts +149 -0
- package/extensions/teams/leader-task-commands.ts +435 -0
- package/extensions/teams/leader-team-command.ts +382 -0
- package/extensions/teams/leader-teams-tool.ts +1075 -0
- package/extensions/teams/leader.ts +925 -0
- package/extensions/teams/mailbox.ts +131 -0
- package/extensions/teams/model-policy.ts +142 -0
- package/extensions/teams/names.ts +121 -0
- package/extensions/teams/paths.ts +37 -0
- package/extensions/teams/protocol.ts +241 -0
- package/extensions/teams/spawn-types.ts +36 -0
- package/extensions/teams/task-store.ts +544 -0
- package/extensions/teams/team-attach-claim.ts +205 -0
- package/extensions/teams/team-config.ts +335 -0
- package/extensions/teams/team-discovery.ts +59 -0
- package/extensions/teams/teammate-rpc.ts +261 -0
- package/extensions/teams/teams-panel.ts +1186 -0
- package/extensions/teams/teams-style.ts +322 -0
- package/extensions/teams/teams-ui-shared.ts +89 -0
- package/extensions/teams/teams-widget.ts +212 -0
- package/extensions/teams/worker.ts +605 -0
- package/extensions/teams/worktree.ts +103 -0
- package/package.json +53 -0
- package/scripts/e2e-rpc-test.mjs +277 -0
- package/scripts/integration-claim-test.mts +157 -0
- package/scripts/integration-hooks-remediation-test.mts +382 -0
- package/scripts/integration-spawn-overrides-test.mts +398 -0
- package/scripts/integration-todo-test.mts +533 -0
- package/scripts/lib/pi-workers.ts +105 -0
- package/scripts/smoke-test.mts +764 -0
- package/scripts/start-tmux-team.sh +91 -0
- package/skills/agent-teams/SKILL.md +180 -0
- package/tsconfig.strict.json +22 -0
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test for pi-agent-teams extension primitives.
|
|
3
|
+
*
|
|
4
|
+
* Tests: fs-lock, mailbox, task-store, team-config, protocol parsers, names.
|
|
5
|
+
* Does NOT require a running Pi session — exercises the library code directly.
|
|
6
|
+
*
|
|
7
|
+
* Usage: npx tsx scripts/smoke-test.mts
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
|
|
14
|
+
// We import from .ts source (tsx handles it)
|
|
15
|
+
import { withLock } from "../extensions/teams/fs-lock.js";
|
|
16
|
+
import { writeToMailbox, popUnreadMessages, getInboxPath } from "../extensions/teams/mailbox.js";
|
|
17
|
+
import {
|
|
18
|
+
createTask,
|
|
19
|
+
listTasks,
|
|
20
|
+
getTask,
|
|
21
|
+
updateTask,
|
|
22
|
+
completeTask,
|
|
23
|
+
clearTasks,
|
|
24
|
+
startAssignedTask,
|
|
25
|
+
claimNextAvailableTask,
|
|
26
|
+
unassignTasksForAgent,
|
|
27
|
+
formatTaskLine,
|
|
28
|
+
addTaskDependency,
|
|
29
|
+
removeTaskDependency,
|
|
30
|
+
isTaskBlocked,
|
|
31
|
+
} from "../extensions/teams/task-store.js";
|
|
32
|
+
import { ensureTeamConfig, loadTeamConfig, upsertMember, setMemberStatus, updateTeamHooksPolicy } from "../extensions/teams/team-config.js";
|
|
33
|
+
import { sanitizeName } from "../extensions/teams/names.js";
|
|
34
|
+
import { formatProviderModel, isDeprecatedTeammateModelId, resolveTeammateModelSelection } from "../extensions/teams/model-policy.js";
|
|
35
|
+
import { getTeamsNamingRules, getTeamsStrings } from "../extensions/teams/teams-style.js";
|
|
36
|
+
import {
|
|
37
|
+
getTeamsHookFailureAction,
|
|
38
|
+
getTeamsHookFollowupOwnerPolicy,
|
|
39
|
+
getTeamsHookMaxReopensPerTask,
|
|
40
|
+
resolveTeamsHookFollowupOwner,
|
|
41
|
+
runTeamsHook,
|
|
42
|
+
shouldCreateHookFollowupTask,
|
|
43
|
+
shouldReopenTaskOnHookFailure,
|
|
44
|
+
} from "../extensions/teams/hooks.js";
|
|
45
|
+
import { listDiscoveredTeams } from "../extensions/teams/team-discovery.js";
|
|
46
|
+
import {
|
|
47
|
+
acquireTeamAttachClaim,
|
|
48
|
+
assessAttachClaimFreshness,
|
|
49
|
+
heartbeatTeamAttachClaim,
|
|
50
|
+
releaseTeamAttachClaim,
|
|
51
|
+
} from "../extensions/teams/team-attach-claim.js";
|
|
52
|
+
import { getTeamHelpText } from "../extensions/teams/leader-team-command.js";
|
|
53
|
+
import {
|
|
54
|
+
TEAM_MAILBOX_NS,
|
|
55
|
+
isIdleNotification,
|
|
56
|
+
isShutdownApproved,
|
|
57
|
+
isShutdownRejected,
|
|
58
|
+
isTaskAssignmentMessage,
|
|
59
|
+
isShutdownRequestMessage,
|
|
60
|
+
isSetSessionNameMessage,
|
|
61
|
+
isPlanApprovalRequest,
|
|
62
|
+
isPeerDmSent,
|
|
63
|
+
isAbortRequestMessage,
|
|
64
|
+
isPlanApprovedMessage,
|
|
65
|
+
isPlanRejectedMessage,
|
|
66
|
+
} from "../extensions/teams/protocol.js";
|
|
67
|
+
|
|
68
|
+
// ── helpers ──────────────────────────────────────────────────────────
|
|
69
|
+
let passed = 0;
|
|
70
|
+
let failed = 0;
|
|
71
|
+
|
|
72
|
+
function assert(condition: boolean, label: string) {
|
|
73
|
+
if (condition) {
|
|
74
|
+
passed++;
|
|
75
|
+
console.log(` ✓ ${label}`);
|
|
76
|
+
} else {
|
|
77
|
+
failed++;
|
|
78
|
+
console.error(` ✗ ${label}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function assertEq(actual: unknown, expected: unknown, label: string) {
|
|
83
|
+
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
|
84
|
+
if (!ok) {
|
|
85
|
+
console.error(` actual: ${JSON.stringify(actual)}`);
|
|
86
|
+
console.error(` expected: ${JSON.stringify(expected)}`);
|
|
87
|
+
}
|
|
88
|
+
assert(ok, label);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
92
|
+
return typeof v === "object" && v !== null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-teams-smoke-"));
|
|
96
|
+
const teamDir = path.join(tmpRoot, "team-test");
|
|
97
|
+
const taskListId = "smoke-tl";
|
|
98
|
+
|
|
99
|
+
console.log(`\nSmoke test root: ${tmpRoot}\n`);
|
|
100
|
+
|
|
101
|
+
// ── 1. names ─────────────────────────────────────────────────────────
|
|
102
|
+
console.log("1. names.sanitizeName");
|
|
103
|
+
// sanitizeName replaces non-alnum/underscore/hyphen with hyphens, preserves case
|
|
104
|
+
assertEq(sanitizeName("Hello World!"), "Hello-World-", "non-alnum → hyphens");
|
|
105
|
+
assertEq(sanitizeName("agent_1"), "agent_1", "underscores kept");
|
|
106
|
+
assertEq(sanitizeName(""), "", "empty stays empty");
|
|
107
|
+
assertEq(sanitizeName("UPPER"), "UPPER", "case preserved");
|
|
108
|
+
|
|
109
|
+
// ── 1b. model policy ────────────────────────────────────────────────
|
|
110
|
+
console.log("\n1b. model-policy");
|
|
111
|
+
assert(isDeprecatedTeammateModelId("claude-sonnet-4"), "marks sonnet-4 alias as deprecated");
|
|
112
|
+
assert(
|
|
113
|
+
isDeprecatedTeammateModelId("anthropic.claude-sonnet-4-20250514-v1:0"),
|
|
114
|
+
"marks sonnet-4 dated/bedrock variants as deprecated",
|
|
115
|
+
);
|
|
116
|
+
assert(!isDeprecatedTeammateModelId("claude-sonnet-4-5"), "does not block sonnet-4-5");
|
|
117
|
+
assert(!isDeprecatedTeammateModelId("claude-sonnet-4.5"), "does not block sonnet-4.5");
|
|
118
|
+
assert(!isDeprecatedTeammateModelId("gpt-5.1-codex-mini"), "keeps current models allowed");
|
|
119
|
+
|
|
120
|
+
const modelResolvedExplicit = resolveTeammateModelSelection({
|
|
121
|
+
modelOverride: "openai-codex/gpt-5.1-codex-mini",
|
|
122
|
+
leaderProvider: "anthropic",
|
|
123
|
+
leaderModelId: "claude-sonnet-4-5",
|
|
124
|
+
});
|
|
125
|
+
assert(modelResolvedExplicit.ok, "resolveTeammateModelSelection accepts provider/model override");
|
|
126
|
+
if (modelResolvedExplicit.ok) {
|
|
127
|
+
assertEq(modelResolvedExplicit.value.source, "override", "explicit override source");
|
|
128
|
+
assertEq(formatProviderModel(modelResolvedExplicit.value.provider, modelResolvedExplicit.value.modelId), "openai-codex/gpt-5.1-codex-mini", "explicit override keeps provider/model");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const modelResolvedModelOnly = resolveTeammateModelSelection({
|
|
132
|
+
modelOverride: "gpt-5.1-codex-mini",
|
|
133
|
+
leaderProvider: "openai-codex",
|
|
134
|
+
leaderModelId: "gpt-5.1-codex-mini",
|
|
135
|
+
});
|
|
136
|
+
assert(modelResolvedModelOnly.ok, "resolveTeammateModelSelection accepts model-only override");
|
|
137
|
+
if (modelResolvedModelOnly.ok) {
|
|
138
|
+
assertEq(formatProviderModel(modelResolvedModelOnly.value.provider, modelResolvedModelOnly.value.modelId), "openai-codex/gpt-5.1-codex-mini", "model-only override inherits leader provider");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const modelResolvedInvalid = resolveTeammateModelSelection({ modelOverride: "openai-codex/" });
|
|
142
|
+
assert(!modelResolvedInvalid.ok, "resolveTeammateModelSelection rejects invalid provider/model override");
|
|
143
|
+
if (!modelResolvedInvalid.ok) {
|
|
144
|
+
assertEq(modelResolvedInvalid.reason, "invalid_override", "invalid override reason");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const modelResolvedDeprecated = resolveTeammateModelSelection({ modelOverride: "claude-sonnet-4" });
|
|
148
|
+
assert(!modelResolvedDeprecated.ok, "resolveTeammateModelSelection rejects deprecated override");
|
|
149
|
+
if (!modelResolvedDeprecated.ok) {
|
|
150
|
+
assertEq(modelResolvedDeprecated.reason, "deprecated_override", "deprecated override reason");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const modelResolvedDeprecatedLeader = resolveTeammateModelSelection({
|
|
154
|
+
leaderProvider: "anthropic",
|
|
155
|
+
leaderModelId: "claude-sonnet-4-20250514",
|
|
156
|
+
});
|
|
157
|
+
assert(modelResolvedDeprecatedLeader.ok, "resolveTeammateModelSelection handles deprecated leader model fallback");
|
|
158
|
+
if (modelResolvedDeprecatedLeader.ok) {
|
|
159
|
+
assertEq(modelResolvedDeprecatedLeader.value.source, "default", "deprecated leader model is not inherited");
|
|
160
|
+
assertEq(formatProviderModel(modelResolvedDeprecatedLeader.value.provider, modelResolvedDeprecatedLeader.value.modelId), null, "deprecated leader fallback has no explicit model");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── 2. fs-lock ───────────────────────────────────────────────────────
|
|
164
|
+
console.log("\n2. fs-lock.withLock");
|
|
165
|
+
{
|
|
166
|
+
const lockFile = path.join(tmpRoot, "test.lock");
|
|
167
|
+
const result = await withLock(lockFile, async () => 42, { label: "smoke" });
|
|
168
|
+
assertEq(result, 42, "withLock returns fn result");
|
|
169
|
+
assert(!fs.existsSync(lockFile), "lock file cleaned up after");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
{
|
|
173
|
+
// Stale lock is removed.
|
|
174
|
+
const lockFile = path.join(tmpRoot, "stale.lock");
|
|
175
|
+
fs.writeFileSync(lockFile, "stale");
|
|
176
|
+
const old = new Date(Date.now() - 120_000);
|
|
177
|
+
fs.utimesSync(lockFile, old, old);
|
|
178
|
+
|
|
179
|
+
const result = await withLock(lockFile, async () => "ok", { staleMs: 1, timeoutMs: 500 });
|
|
180
|
+
assertEq(result, "ok", "withLock removes stale lock file");
|
|
181
|
+
assert(!fs.existsSync(lockFile), "stale lock cleaned up after");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
{
|
|
185
|
+
// Contention: many concurrent callers should serialize without throwing.
|
|
186
|
+
const lockFile = path.join(tmpRoot, "contended.lock");
|
|
187
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
188
|
+
let counter = 0;
|
|
189
|
+
|
|
190
|
+
const runners = Array.from({ length: 20 }, () =>
|
|
191
|
+
withLock(
|
|
192
|
+
lockFile,
|
|
193
|
+
async () => {
|
|
194
|
+
counter += 1;
|
|
195
|
+
await sleep(5);
|
|
196
|
+
return counter;
|
|
197
|
+
},
|
|
198
|
+
{ timeoutMs: 5_000, pollMs: 2 },
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
await Promise.all(runners);
|
|
203
|
+
assertEq(counter, 20, "withLock serializes contended callers");
|
|
204
|
+
assert(!fs.existsSync(lockFile), "contended lock cleaned up after");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── 3. mailbox ───────────────────────────────────────────────────────
|
|
208
|
+
console.log("\n3. mailbox");
|
|
209
|
+
{
|
|
210
|
+
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, "agent1", {
|
|
211
|
+
from: "team-lead",
|
|
212
|
+
text: "hello agent1",
|
|
213
|
+
timestamp: "2025-01-01T00:00:00Z",
|
|
214
|
+
});
|
|
215
|
+
const inboxPath = getInboxPath(teamDir, TEAM_MAILBOX_NS, "agent1");
|
|
216
|
+
assert(fs.existsSync(inboxPath), "inbox file created");
|
|
217
|
+
|
|
218
|
+
const raw: unknown = JSON.parse(fs.readFileSync(inboxPath, "utf8"));
|
|
219
|
+
assert(Array.isArray(raw), "inbox json is array");
|
|
220
|
+
assertEq(Array.isArray(raw) ? raw.length : 0, 1, "one message in inbox");
|
|
221
|
+
const first = Array.isArray(raw) ? raw.at(0) : undefined;
|
|
222
|
+
assert(isRecord(first) && typeof first.read === "boolean", "message has boolean read");
|
|
223
|
+
if (isRecord(first) && typeof first.read === "boolean") {
|
|
224
|
+
assertEq(first.read, false, "message initially unread");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// pop
|
|
228
|
+
const msgs = await popUnreadMessages(teamDir, TEAM_MAILBOX_NS, "agent1");
|
|
229
|
+
assertEq(msgs.length, 1, "popUnreadMessages returns 1");
|
|
230
|
+
const m0 = msgs.at(0);
|
|
231
|
+
assert(m0 !== undefined, "pop returned first message");
|
|
232
|
+
if (m0) assertEq(m0.text, "hello agent1", "message text correct");
|
|
233
|
+
|
|
234
|
+
// re-pop should be empty
|
|
235
|
+
const msgs2 = await popUnreadMessages(teamDir, TEAM_MAILBOX_NS, "agent1");
|
|
236
|
+
assertEq(msgs2.length, 0, "second pop returns 0 (already read)");
|
|
237
|
+
|
|
238
|
+
// multiple messages
|
|
239
|
+
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, "agent1", {
|
|
240
|
+
from: "team-lead",
|
|
241
|
+
text: "msg2",
|
|
242
|
+
timestamp: "2025-01-01T00:01:00Z",
|
|
243
|
+
});
|
|
244
|
+
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, "agent1", {
|
|
245
|
+
from: "peer",
|
|
246
|
+
text: "msg3",
|
|
247
|
+
timestamp: "2025-01-01T00:02:00Z",
|
|
248
|
+
});
|
|
249
|
+
const msgs3 = await popUnreadMessages(teamDir, TEAM_MAILBOX_NS, "agent1");
|
|
250
|
+
assertEq(msgs3.length, 2, "pop returns 2 new unread messages");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── 4. task-store ────────────────────────────────────────────────────
|
|
254
|
+
console.log("\n4. task-store");
|
|
255
|
+
{
|
|
256
|
+
const t1 = await createTask(teamDir, taskListId, {
|
|
257
|
+
subject: "Write tests",
|
|
258
|
+
description: "Write unit tests for the extension",
|
|
259
|
+
owner: "agent1",
|
|
260
|
+
});
|
|
261
|
+
assert(typeof t1.id === "string" && t1.id.length > 0, "task created with id");
|
|
262
|
+
assertEq(t1.status, "pending", "new task is pending");
|
|
263
|
+
assertEq(t1.owner, "agent1", "owner set");
|
|
264
|
+
|
|
265
|
+
const t2 = await createTask(teamDir, taskListId, {
|
|
266
|
+
subject: "Fix lint",
|
|
267
|
+
description: "Fix all lint errors",
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const all = await listTasks(teamDir, taskListId);
|
|
271
|
+
assertEq(all.length, 2, "listTasks returns 2");
|
|
272
|
+
|
|
273
|
+
const fetched = await getTask(teamDir, taskListId, t1.id);
|
|
274
|
+
assertEq(fetched?.subject, "Write tests", "getTask returns correct task");
|
|
275
|
+
|
|
276
|
+
// update
|
|
277
|
+
const updated = await updateTask(teamDir, taskListId, t1.id, (cur) => ({
|
|
278
|
+
...cur,
|
|
279
|
+
status: "in_progress",
|
|
280
|
+
}));
|
|
281
|
+
assertEq(updated?.status, "in_progress", "updateTask changes status");
|
|
282
|
+
|
|
283
|
+
// startAssignedTask — requires task.owner === agentName && status === pending
|
|
284
|
+
// First assign t2 to agent2, then start it
|
|
285
|
+
await updateTask(teamDir, taskListId, t2.id, (cur) => ({ ...cur, owner: "agent2" }));
|
|
286
|
+
await startAssignedTask(teamDir, taskListId, t2.id, "agent2");
|
|
287
|
+
const t2after = await getTask(teamDir, taskListId, t2.id);
|
|
288
|
+
assertEq(t2after?.status, "in_progress", "startAssignedTask sets in_progress");
|
|
289
|
+
assertEq(t2after?.owner, "agent2", "startAssignedTask preserves owner");
|
|
290
|
+
|
|
291
|
+
// completeTask
|
|
292
|
+
await completeTask(teamDir, taskListId, t1.id, "agent1", "All tests passing");
|
|
293
|
+
const t1done = await getTask(teamDir, taskListId, t1.id);
|
|
294
|
+
assertEq(t1done?.status, "completed", "completeTask sets completed");
|
|
295
|
+
|
|
296
|
+
// formatTaskLine
|
|
297
|
+
assert(t1done !== null, "completed task can be re-fetched");
|
|
298
|
+
if (t1done) {
|
|
299
|
+
const line = formatTaskLine(t1done);
|
|
300
|
+
assert(line.includes("completed"), "formatTaskLine includes status");
|
|
301
|
+
assert(line.includes("Write tests"), "formatTaskLine includes subject");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// claimNextAvailableTask
|
|
305
|
+
const t3 = await createTask(teamDir, taskListId, {
|
|
306
|
+
subject: "Unclaimed task",
|
|
307
|
+
description: "nobody owns this",
|
|
308
|
+
});
|
|
309
|
+
const claimed = await claimNextAvailableTask(teamDir, taskListId, "agent3");
|
|
310
|
+
assert(claimed !== null, "claimNextAvailableTask finds a task");
|
|
311
|
+
assertEq(claimed?.owner, "agent3", "claimed task now owned by agent3");
|
|
312
|
+
|
|
313
|
+
// unassignTasksForAgent — unassigns all non-completed tasks for agent
|
|
314
|
+
// agent3 claimed a task above, unassign it
|
|
315
|
+
await unassignTasksForAgent(teamDir, taskListId, "agent3", "agent3 left");
|
|
316
|
+
const t3unassigned = await getTask(teamDir, taskListId, t3.id);
|
|
317
|
+
assertEq(t3unassigned?.owner, undefined, "unassignTasksForAgent clears owner");
|
|
318
|
+
|
|
319
|
+
// dependencies
|
|
320
|
+
const depRes = await addTaskDependency(teamDir, taskListId, t3.id, t2.id);
|
|
321
|
+
assert(depRes.ok, "addTaskDependency ok");
|
|
322
|
+
const t3fetched = await getTask(teamDir, taskListId, t3.id);
|
|
323
|
+
assert(t3fetched !== null, "getTask returns dependency task");
|
|
324
|
+
const blocked = t3fetched ? await isTaskBlocked(teamDir, taskListId, t3fetched) : false;
|
|
325
|
+
assert(blocked, "task is blocked by dependency");
|
|
326
|
+
|
|
327
|
+
const rmDep = await removeTaskDependency(teamDir, taskListId, t3.id, t2.id);
|
|
328
|
+
assert(rmDep.ok, "removeTaskDependency ok");
|
|
329
|
+
|
|
330
|
+
// clearTasks (completed only)
|
|
331
|
+
const clearResult = await clearTasks(teamDir, taskListId, "completed");
|
|
332
|
+
assert(clearResult.deletedTaskIds.length >= 1, "clearTasks deleted completed tasks");
|
|
333
|
+
assert(clearResult.skippedTaskIds.length >= 1, "clearTasks skipped non-completed");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── 5. team-config ───────────────────────────────────────────────────
|
|
337
|
+
console.log("\n5. team-config");
|
|
338
|
+
{
|
|
339
|
+
const cfg = await ensureTeamConfig(teamDir, {
|
|
340
|
+
teamId: "smoke-team",
|
|
341
|
+
taskListId: "smoke-tl",
|
|
342
|
+
leadName: "team-lead",
|
|
343
|
+
style: "normal",
|
|
344
|
+
});
|
|
345
|
+
assertEq(cfg.version, 1, "config version 1");
|
|
346
|
+
assertEq(cfg.teamId, "smoke-team", "teamId set");
|
|
347
|
+
assert(cfg.members.length >= 1, "has at least lead member");
|
|
348
|
+
const firstMember = cfg.members.at(0);
|
|
349
|
+
assert(firstMember !== undefined, "first member exists");
|
|
350
|
+
if (firstMember) assertEq(firstMember.role, "lead", "first member is lead");
|
|
351
|
+
|
|
352
|
+
// idempotent
|
|
353
|
+
const cfg2 = await ensureTeamConfig(teamDir, {
|
|
354
|
+
teamId: "smoke-team",
|
|
355
|
+
taskListId: "smoke-tl",
|
|
356
|
+
leadName: "team-lead",
|
|
357
|
+
style: "normal",
|
|
358
|
+
});
|
|
359
|
+
assertEq(cfg2.teamId, cfg.teamId, "ensureTeamConfig idempotent");
|
|
360
|
+
|
|
361
|
+
// upsertMember
|
|
362
|
+
const cfg3 = await upsertMember(teamDir, {
|
|
363
|
+
name: "agent1",
|
|
364
|
+
role: "worker",
|
|
365
|
+
status: "online",
|
|
366
|
+
});
|
|
367
|
+
assert(cfg3.members.some((m) => m.name === "agent1" && m.role === "worker"), "upsertMember adds worker");
|
|
368
|
+
|
|
369
|
+
// setMemberStatus
|
|
370
|
+
const cfg4 = await setMemberStatus(teamDir, "agent1", "offline");
|
|
371
|
+
assert(cfg4 !== null, "setMemberStatus returns config");
|
|
372
|
+
if (cfg4) {
|
|
373
|
+
assert(
|
|
374
|
+
cfg4.members.some((m) => m.name === "agent1" && m.status === "offline"),
|
|
375
|
+
"setMemberStatus changes status",
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// loadTeamConfig
|
|
380
|
+
const loaded = await loadTeamConfig(teamDir);
|
|
381
|
+
assert(loaded !== null, "loadTeamConfig returns config");
|
|
382
|
+
assertEq(loaded?.teamId, "smoke-team", "loadTeamConfig correct teamId");
|
|
383
|
+
|
|
384
|
+
// updateTeamHooksPolicy
|
|
385
|
+
const withHooks = await updateTeamHooksPolicy(teamDir, () => ({
|
|
386
|
+
failureAction: "reopen_followup",
|
|
387
|
+
maxReopensPerTask: 2,
|
|
388
|
+
followupOwner: "member",
|
|
389
|
+
}));
|
|
390
|
+
assert(withHooks !== null, "updateTeamHooksPolicy returns config");
|
|
391
|
+
assertEq(withHooks?.hooks?.failureAction, "reopen_followup", "updateTeamHooksPolicy sets failure action");
|
|
392
|
+
assertEq(withHooks?.hooks?.maxReopensPerTask, 2, "updateTeamHooksPolicy sets max reopens");
|
|
393
|
+
assertEq(withHooks?.hooks?.followupOwner, "member", "updateTeamHooksPolicy sets followup owner");
|
|
394
|
+
|
|
395
|
+
const clearedHooks = await updateTeamHooksPolicy(teamDir, () => undefined);
|
|
396
|
+
assert(clearedHooks !== null, "updateTeamHooksPolicy can clear policy");
|
|
397
|
+
assertEq(clearedHooks?.hooks, undefined, "updateTeamHooksPolicy clears hooks policy");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── 6. protocol parsers ──────────────────────────────────────────────
|
|
401
|
+
console.log("\n6. protocol parsers");
|
|
402
|
+
{
|
|
403
|
+
// idle notification
|
|
404
|
+
const idle = isIdleNotification(
|
|
405
|
+
JSON.stringify({ type: "idle_notification", from: "agent1", timestamp: "2025-01-01T00:00:00Z" }),
|
|
406
|
+
);
|
|
407
|
+
assert(idle !== null, "isIdleNotification parses valid");
|
|
408
|
+
assertEq(idle?.from, "agent1", "idle.from correct");
|
|
409
|
+
|
|
410
|
+
assert(isIdleNotification("not json") === null, "isIdleNotification rejects garbage");
|
|
411
|
+
assert(isIdleNotification(JSON.stringify({ type: "other" })) === null, "rejects wrong type");
|
|
412
|
+
|
|
413
|
+
// task assignment
|
|
414
|
+
const assign = isTaskAssignmentMessage(
|
|
415
|
+
JSON.stringify({ type: "task_assignment", taskId: "42", subject: "Do stuff" }),
|
|
416
|
+
);
|
|
417
|
+
assert(assign !== null, "isTaskAssignmentMessage parses valid");
|
|
418
|
+
assertEq(assign?.taskId, "42", "assign.taskId correct");
|
|
419
|
+
|
|
420
|
+
// shutdown request
|
|
421
|
+
const shutReq = isShutdownRequestMessage(
|
|
422
|
+
JSON.stringify({ type: "shutdown_request", requestId: "r1", from: "lead", reason: "done" }),
|
|
423
|
+
);
|
|
424
|
+
assert(shutReq !== null, "isShutdownRequestMessage parses valid");
|
|
425
|
+
assertEq(shutReq?.requestId, "r1", "shutReq.requestId correct");
|
|
426
|
+
|
|
427
|
+
// shutdown approved / rejected
|
|
428
|
+
const approved = isShutdownApproved(
|
|
429
|
+
JSON.stringify({ type: "shutdown_approved", requestId: "r1", from: "agent1" }),
|
|
430
|
+
);
|
|
431
|
+
assert(approved !== null, "isShutdownApproved parses valid");
|
|
432
|
+
|
|
433
|
+
const rejected = isShutdownRejected(
|
|
434
|
+
JSON.stringify({ type: "shutdown_rejected", requestId: "r1", from: "agent1", reason: "busy" }),
|
|
435
|
+
);
|
|
436
|
+
assert(rejected !== null, "isShutdownRejected parses valid");
|
|
437
|
+
|
|
438
|
+
// set session name
|
|
439
|
+
const setName = isSetSessionNameMessage(JSON.stringify({ type: "set_session_name", name: "my session" }));
|
|
440
|
+
assert(setName !== null, "isSetSessionNameMessage parses valid");
|
|
441
|
+
assertEq(setName?.name, "my session", "setName.name correct");
|
|
442
|
+
|
|
443
|
+
// plan approval request
|
|
444
|
+
const planReq = isPlanApprovalRequest(
|
|
445
|
+
JSON.stringify({ type: "plan_approval_request", requestId: "p1", from: "agent1", plan: "do X then Y" }),
|
|
446
|
+
);
|
|
447
|
+
assert(planReq !== null, "isPlanApprovalRequest parses valid");
|
|
448
|
+
|
|
449
|
+
// plan approved / rejected
|
|
450
|
+
const planOk = isPlanApprovedMessage(
|
|
451
|
+
JSON.stringify({ type: "plan_approved", requestId: "p1", from: "lead", timestamp: "t" }),
|
|
452
|
+
);
|
|
453
|
+
assert(planOk !== null, "isPlanApprovedMessage parses valid");
|
|
454
|
+
|
|
455
|
+
const planNo = isPlanRejectedMessage(
|
|
456
|
+
JSON.stringify({ type: "plan_rejected", requestId: "p1", from: "lead", feedback: "redo" }),
|
|
457
|
+
);
|
|
458
|
+
assert(planNo !== null, "isPlanRejectedMessage parses valid");
|
|
459
|
+
|
|
460
|
+
// peer DM
|
|
461
|
+
const dm = isPeerDmSent(
|
|
462
|
+
JSON.stringify({ type: "peer_dm_sent", from: "a1", to: "a2", summary: "hi" }),
|
|
463
|
+
);
|
|
464
|
+
assert(dm !== null, "isPeerDmSent parses valid");
|
|
465
|
+
|
|
466
|
+
// abort
|
|
467
|
+
const abort = isAbortRequestMessage(
|
|
468
|
+
JSON.stringify({ type: "abort_request", requestId: "ab1", from: "lead", taskId: "5" }),
|
|
469
|
+
);
|
|
470
|
+
assert(abort !== null, "isAbortRequestMessage parses valid");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── 7. indusagi CLI extension loading (non-interactive) ────────────────────
|
|
474
|
+
console.log("\n7. indusagi extension loading");
|
|
475
|
+
{
|
|
476
|
+
const { spawnSync } = await import("node:child_process");
|
|
477
|
+
|
|
478
|
+
// `indusagi` is expected to be installed in local dev, but it's usually not available in CI.
|
|
479
|
+
// Even locally, it may hang due to user-specific config, so treat this as a best-effort check.
|
|
480
|
+
const res = spawnSync("indusagi", ["--version"], {
|
|
481
|
+
cwd: process.cwd(),
|
|
482
|
+
timeout: 3_000,
|
|
483
|
+
encoding: "utf8",
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const errCode = (() => {
|
|
487
|
+
const e: unknown = res.error;
|
|
488
|
+
if (!e || typeof e !== "object") return undefined;
|
|
489
|
+
const c = (e as { code?: unknown }).code;
|
|
490
|
+
return typeof c === "string" ? c : undefined;
|
|
491
|
+
})();
|
|
492
|
+
|
|
493
|
+
if (errCode === "ENOENT") {
|
|
494
|
+
console.log(" (skipped) indusagi CLI not found on PATH");
|
|
495
|
+
} else if (errCode === "ETIMEDOUT") {
|
|
496
|
+
console.log(" (skipped) indusagi --version timed out");
|
|
497
|
+
} else if (res.status !== 0) {
|
|
498
|
+
console.log(" (skipped) indusagi --version returned non-zero exit code");
|
|
499
|
+
} else {
|
|
500
|
+
assert((res.stdout ?? "").trim().length > 0, "indusagi --version works");
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ── 8. styles (custom + naming rules) ───────────────────────────────
|
|
505
|
+
console.log("\n8. teams-style (custom styles)");
|
|
506
|
+
{
|
|
507
|
+
const prev = process.env.PI_TEAMS_ROOT_DIR;
|
|
508
|
+
process.env.PI_TEAMS_ROOT_DIR = tmpRoot;
|
|
509
|
+
|
|
510
|
+
// Write a custom style under <teamsRoot>/_styles/
|
|
511
|
+
const stylesDir = path.join(tmpRoot, "_styles");
|
|
512
|
+
fs.mkdirSync(stylesDir, { recursive: true });
|
|
513
|
+
fs.writeFileSync(
|
|
514
|
+
path.join(stylesDir, "smoke-custom.json"),
|
|
515
|
+
JSON.stringify(
|
|
516
|
+
{
|
|
517
|
+
extends: "pirate",
|
|
518
|
+
strings: { memberTitle: "Deckhand", memberPrefix: "Deckhand " },
|
|
519
|
+
naming: {
|
|
520
|
+
requireExplicitSpawnName: false,
|
|
521
|
+
autoNameStrategy: { kind: "pool", pool: ["pegleg"], fallbackBase: "deckhand" },
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
null,
|
|
525
|
+
2,
|
|
526
|
+
) + "\n",
|
|
527
|
+
"utf8",
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
const s = getTeamsStrings("smoke-custom");
|
|
531
|
+
assertEq(s.memberTitle, "Deckhand", "custom style overrides strings");
|
|
532
|
+
const naming = getTeamsNamingRules("smoke-custom");
|
|
533
|
+
assert(naming.requireExplicitSpawnName === false, "custom style naming rules parsed");
|
|
534
|
+
assert(naming.autoNameStrategy.kind === "pool", "custom style can use pool naming");
|
|
535
|
+
if (naming.autoNameStrategy.kind === "pool") {
|
|
536
|
+
assertEq(naming.autoNameStrategy.fallbackBase, "deckhand", "custom style fallbackBase parsed");
|
|
537
|
+
assertEq(naming.autoNameStrategy.pool.at(0), "pegleg", "custom style pool parsed");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// restore env
|
|
541
|
+
if (prev === undefined) delete process.env.PI_TEAMS_ROOT_DIR;
|
|
542
|
+
else process.env.PI_TEAMS_ROOT_DIR = prev;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ── 9. hooks (quality gates) ────────────────────────────────────────
|
|
546
|
+
console.log("\n9. teams-hooks (quality gates)");
|
|
547
|
+
{
|
|
548
|
+
const prevRoot = process.env.PI_TEAMS_ROOT_DIR;
|
|
549
|
+
const prevEnabled = process.env.PI_TEAMS_HOOKS_ENABLED;
|
|
550
|
+
process.env.PI_TEAMS_ROOT_DIR = tmpRoot;
|
|
551
|
+
process.env.PI_TEAMS_HOOKS_ENABLED = "1";
|
|
552
|
+
|
|
553
|
+
const hooksDir = path.join(tmpRoot, "_hooks");
|
|
554
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
555
|
+
|
|
556
|
+
const outFile = path.join(tmpRoot, "hook-ran.txt");
|
|
557
|
+
fs.writeFileSync(
|
|
558
|
+
path.join(hooksDir, "on_task_completed.js"),
|
|
559
|
+
"" +
|
|
560
|
+
"const fs = require('node:fs');\n" +
|
|
561
|
+
"const payload = {\n" +
|
|
562
|
+
" contextVersion: process.env.PI_TEAMS_HOOK_CONTEXT_VERSION || null,\n" +
|
|
563
|
+
" contextJson: process.env.PI_TEAMS_HOOK_CONTEXT_JSON || null,\n" +
|
|
564
|
+
" event: process.env.PI_TEAMS_HOOK_EVENT || null,\n" +
|
|
565
|
+
" taskId: process.env.PI_TEAMS_TASK_ID || null,\n" +
|
|
566
|
+
"};\n" +
|
|
567
|
+
`fs.writeFileSync(${JSON.stringify(outFile)}, JSON.stringify(payload) + '\\n', 'utf8');\n` +
|
|
568
|
+
"process.exit(0);\n",
|
|
569
|
+
"utf8",
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
const teamId = "smoke-team";
|
|
573
|
+
const teamDir = path.join(tmpRoot, teamId);
|
|
574
|
+
fs.mkdirSync(teamDir, { recursive: true });
|
|
575
|
+
|
|
576
|
+
const res = await runTeamsHook({
|
|
577
|
+
invocation: {
|
|
578
|
+
event: "task_completed",
|
|
579
|
+
teamId,
|
|
580
|
+
teamDir,
|
|
581
|
+
taskListId: teamId,
|
|
582
|
+
style: "pirate",
|
|
583
|
+
memberName: "agent1",
|
|
584
|
+
timestamp: new Date().toISOString(),
|
|
585
|
+
completedTask: {
|
|
586
|
+
id: "1",
|
|
587
|
+
subject: "Test task",
|
|
588
|
+
description: "",
|
|
589
|
+
owner: "agent1",
|
|
590
|
+
status: "completed",
|
|
591
|
+
blocks: [],
|
|
592
|
+
blockedBy: [],
|
|
593
|
+
metadata: {},
|
|
594
|
+
createdAt: new Date().toISOString(),
|
|
595
|
+
updatedAt: new Date().toISOString(),
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
cwd: tmpRoot,
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
assert(res.ran === true, "runs on_task_completed hook");
|
|
602
|
+
assert(res.exitCode === 0, "hook exit code is 0");
|
|
603
|
+
assert(fs.existsSync(outFile), "hook wrote output file");
|
|
604
|
+
const hookOutRaw = fs.readFileSync(outFile, "utf8").trim();
|
|
605
|
+
const hookOut = JSON.parse(hookOutRaw) as {
|
|
606
|
+
contextVersion: string | null;
|
|
607
|
+
contextJson: string | null;
|
|
608
|
+
event: string | null;
|
|
609
|
+
taskId: string | null;
|
|
610
|
+
};
|
|
611
|
+
assertEq(hookOut.contextVersion, "1", "hook context version env is set");
|
|
612
|
+
assertEq(hookOut.event, "task_completed", "hook event env is set");
|
|
613
|
+
assertEq(hookOut.taskId, "1", "hook task id env is set");
|
|
614
|
+
const hookContext = JSON.parse(hookOut.contextJson ?? "{}") as {
|
|
615
|
+
version?: number;
|
|
616
|
+
event?: string;
|
|
617
|
+
task?: { id?: string; status?: string } | null;
|
|
618
|
+
};
|
|
619
|
+
assertEq(hookContext.version, 1, "hook context payload version is 1");
|
|
620
|
+
assertEq(hookContext.event, "task_completed", "hook context payload includes event");
|
|
621
|
+
assertEq(hookContext.task?.id, "1", "hook context payload includes task id");
|
|
622
|
+
assertEq(hookContext.task?.status, "completed", "hook context payload includes task status");
|
|
623
|
+
|
|
624
|
+
assertEq(getTeamsHookFailureAction({}), "warn", "hook failure action defaults to warn");
|
|
625
|
+
assertEq(
|
|
626
|
+
getTeamsHookFailureAction({ PI_TEAMS_HOOKS_FAILURE_ACTION: "reopen_followup" }),
|
|
627
|
+
"reopen_followup",
|
|
628
|
+
"hook failure action reads explicit env",
|
|
629
|
+
);
|
|
630
|
+
assertEq(
|
|
631
|
+
getTeamsHookFailureAction({ PI_TEAMS_HOOKS_CREATE_TASK_ON_FAILURE: "1" }),
|
|
632
|
+
"followup",
|
|
633
|
+
"legacy hook followup env maps to followup action",
|
|
634
|
+
);
|
|
635
|
+
assert(shouldCreateHookFollowupTask("followup"), "followup action creates follow-up task");
|
|
636
|
+
assert(shouldCreateHookFollowupTask("reopen_followup"), "reopen_followup action creates follow-up task");
|
|
637
|
+
assert(shouldReopenTaskOnHookFailure("reopen"), "reopen action reopens completed task");
|
|
638
|
+
assert(shouldReopenTaskOnHookFailure("reopen_followup"), "reopen_followup action reopens completed task");
|
|
639
|
+
assertEq(getTeamsHookFollowupOwnerPolicy({}), "member", "hook follow-up owner policy defaults to member");
|
|
640
|
+
assertEq(getTeamsHookFollowupOwnerPolicy({ PI_TEAMS_HOOKS_FOLLOWUP_OWNER: "lead" }), "lead", "hook follow-up owner policy reads env");
|
|
641
|
+
assertEq(resolveTeamsHookFollowupOwner({ policy: "member", memberName: "agent1", leadName: "team-lead" }), "agent1", "member policy resolves to member");
|
|
642
|
+
assertEq(resolveTeamsHookFollowupOwner({ policy: "member", leadName: "team-lead" }), "team-lead", "member policy falls back to lead");
|
|
643
|
+
assertEq(resolveTeamsHookFollowupOwner({ policy: "none", memberName: "agent1", leadName: "team-lead" }), undefined, "none policy clears follow-up owner");
|
|
644
|
+
assertEq(getTeamsHookMaxReopensPerTask({}), 3, "hook max reopens default is 3");
|
|
645
|
+
assertEq(getTeamsHookMaxReopensPerTask({ PI_TEAMS_HOOKS_MAX_REOPENS_PER_TASK: "0" }), 0, "hook max reopens supports zero");
|
|
646
|
+
|
|
647
|
+
// restore env
|
|
648
|
+
if (prevRoot === undefined) delete process.env.PI_TEAMS_ROOT_DIR;
|
|
649
|
+
else process.env.PI_TEAMS_ROOT_DIR = prevRoot;
|
|
650
|
+
if (prevEnabled === undefined) delete process.env.PI_TEAMS_HOOKS_ENABLED;
|
|
651
|
+
else process.env.PI_TEAMS_HOOKS_ENABLED = prevEnabled;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ── 10. team discovery + attach claims ──────────────────────────────
|
|
655
|
+
console.log("\n10. team discovery + attach claims");
|
|
656
|
+
{
|
|
657
|
+
const discoverRoot = path.join(tmpRoot, "discover-root");
|
|
658
|
+
const aDir = path.join(discoverRoot, "team-a");
|
|
659
|
+
const bDir = path.join(discoverRoot, "team-b");
|
|
660
|
+
fs.mkdirSync(path.join(discoverRoot, "_styles"), { recursive: true });
|
|
661
|
+
|
|
662
|
+
await ensureTeamConfig(aDir, {
|
|
663
|
+
teamId: "team-a",
|
|
664
|
+
taskListId: "tasks-a",
|
|
665
|
+
leadName: "team-lead",
|
|
666
|
+
style: "normal",
|
|
667
|
+
});
|
|
668
|
+
await ensureTeamConfig(bDir, {
|
|
669
|
+
teamId: "team-b",
|
|
670
|
+
taskListId: "tasks-b",
|
|
671
|
+
leadName: "team-lead",
|
|
672
|
+
style: "pirate",
|
|
673
|
+
});
|
|
674
|
+
await upsertMember(bDir, {
|
|
675
|
+
name: "agent1",
|
|
676
|
+
role: "worker",
|
|
677
|
+
status: "online",
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
const claimA = await acquireTeamAttachClaim(aDir, "session-a");
|
|
681
|
+
assert(claimA.ok, "acquireTeamAttachClaim succeeds for first claimant");
|
|
682
|
+
const claimB = await acquireTeamAttachClaim(aDir, "session-b");
|
|
683
|
+
assert(!claimB.ok, "acquireTeamAttachClaim blocks second claimant without force");
|
|
684
|
+
const heartbeatA = await heartbeatTeamAttachClaim(aDir, "session-a");
|
|
685
|
+
assertEq(heartbeatA, "updated", "heartbeat updates owner claim");
|
|
686
|
+
const heartbeatB = await heartbeatTeamAttachClaim(aDir, "session-b");
|
|
687
|
+
assertEq(heartbeatB, "not_owner", "heartbeat rejects non-owner");
|
|
688
|
+
const releaseB = await releaseTeamAttachClaim(aDir, "session-b");
|
|
689
|
+
assertEq(releaseB, "not_owner", "release rejects non-owner");
|
|
690
|
+
const releaseA = await releaseTeamAttachClaim(aDir, "session-a");
|
|
691
|
+
assertEq(releaseA, "released", "release succeeds for owner");
|
|
692
|
+
|
|
693
|
+
const staleCheck = assessAttachClaimFreshness(
|
|
694
|
+
{
|
|
695
|
+
holderSessionId: "session-stale",
|
|
696
|
+
claimedAt: new Date(Date.now() - 120_000).toISOString(),
|
|
697
|
+
heartbeatAt: new Date(Date.now() - 90_000).toISOString(),
|
|
698
|
+
pid: 123,
|
|
699
|
+
},
|
|
700
|
+
);
|
|
701
|
+
assert(staleCheck.isStale, "assessAttachClaimFreshness marks old heartbeat as stale");
|
|
702
|
+
|
|
703
|
+
await acquireTeamAttachClaim(bDir, "session-c");
|
|
704
|
+
const discovered = await listDiscoveredTeams(discoverRoot);
|
|
705
|
+
assert(discovered.some((t) => t.teamId === "team-a"), "discovers first team");
|
|
706
|
+
assert(discovered.some((t) => t.teamId === "team-b"), "discovers second team");
|
|
707
|
+
assert(!discovered.some((t) => t.teamId.startsWith("_")), "ignores internal directories");
|
|
708
|
+
const b = discovered.find((t) => t.teamId === "team-b");
|
|
709
|
+
assert(b !== undefined, "team-b discovered");
|
|
710
|
+
if (b) {
|
|
711
|
+
assertEq(b.taskListId, "tasks-b", "discovered taskListId");
|
|
712
|
+
assertEq(b.style, "pirate", "discovered style");
|
|
713
|
+
assertEq(b.onlineWorkerCount, 1, "discovered online worker count");
|
|
714
|
+
assertEq(b.attachedBySessionId, "session-c", "discovered attach claim owner");
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ── 11. docs/help drift guard ────────────────────────────────────────
|
|
719
|
+
console.log("\n11. docs/help drift guard");
|
|
720
|
+
{
|
|
721
|
+
const help = getTeamHelpText();
|
|
722
|
+
assert(help.includes("/team style list"), "help mentions /team style list");
|
|
723
|
+
assert(help.includes("/team style init"), "help mentions /team style init");
|
|
724
|
+
assert(help.includes("/team attach <teamId> [--claim]"), "help mentions /team attach claim mode");
|
|
725
|
+
assert(help.includes("/team detach"), "help mentions /team detach");
|
|
726
|
+
|
|
727
|
+
const readmePath = path.join(process.cwd(), "README.md");
|
|
728
|
+
if (!fs.existsSync(readmePath)) {
|
|
729
|
+
console.log(" (skipped) README.md not found");
|
|
730
|
+
} else {
|
|
731
|
+
const readme = fs.readFileSync(readmePath, "utf8");
|
|
732
|
+
assert(readme.includes("/team style list"), "README mentions /team style list");
|
|
733
|
+
assert(readme.includes("/team attach <teamId> [--claim]"), "README mentions /team attach claim mode");
|
|
734
|
+
assert(readme.includes("/team detach"), "README mentions /team detach");
|
|
735
|
+
assert(readme.includes("\"action\": \"task_assign\""), "README mentions teams tool task_assign action");
|
|
736
|
+
assert(readme.includes("\"action\": \"task_dep_add\""), "README mentions teams tool task_dep_add action");
|
|
737
|
+
assert(readme.includes("\"action\": \"message_broadcast\""), "README mentions teams tool message_broadcast action");
|
|
738
|
+
assert(readme.includes("\"action\": \"member_kill\""), "README mentions teams tool member_kill action");
|
|
739
|
+
assert(readme.includes("\"action\": \"plan_approve\""), "README mentions teams tool plan_approve action");
|
|
740
|
+
assert(readme.includes("\"action\": \"hooks_policy_get\""), "README mentions teams tool hooks_policy_get action");
|
|
741
|
+
assert(readme.includes("\"action\": \"hooks_policy_set\""), "README mentions teams tool hooks_policy_set action");
|
|
742
|
+
assert(readme.includes("\"action\": \"model_policy_get\""), "README mentions teams tool model_policy_get action");
|
|
743
|
+
assert(readme.includes("\"action\": \"model_policy_check\""), "README mentions teams tool model_policy_check action");
|
|
744
|
+
assert(readme.includes("PI_TEAMS_HOOKS_FAILURE_ACTION"), "README mentions hook failure action policy");
|
|
745
|
+
assert(readme.includes("PI_TEAMS_HOOKS_MAX_REOPENS_PER_TASK"), "README mentions hook reopen cap policy");
|
|
746
|
+
assert(readme.includes("PI_TEAMS_HOOK_CONTEXT_JSON"), "README mentions hook context json contract");
|
|
747
|
+
assert(!readme.includes("claude-sonnet-4"), "README avoids deprecated leader model examples");
|
|
748
|
+
assert(readme.includes("task-centric view"), "README mentions panel task-centric view");
|
|
749
|
+
assert(readme.includes("`t` or `shift+t`"), "README mentions panel task toggle key");
|
|
750
|
+
assert(readme.includes("task view: `c` complete"), "README mentions panel task mutations");
|
|
751
|
+
assert(readme.includes("`r` reassign"), "README mentions panel task reassignment");
|
|
752
|
+
assert(readme.includes("_styles"), "README mentions _styles directory");
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ── summary ──────────────────────────────────────────────────────────
|
|
757
|
+
console.log(`\n${"═".repeat(50)}`);
|
|
758
|
+
console.log(` PASSED: ${passed} FAILED: ${failed}`);
|
|
759
|
+
console.log(`${"═".repeat(50)}\n`);
|
|
760
|
+
|
|
761
|
+
// cleanup
|
|
762
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
763
|
+
|
|
764
|
+
process.exit(failed > 0 ? 1 : 0);
|