dsh-plugin-t-expert 0.3.84 → 0.3.89

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 (41) hide show
  1. package/README.md +29 -56
  2. package/THIRD-PARTY-NOTICES +6 -31
  3. package/data/experts/engineering/engineering-deepseek-harness-project-expert.md +5 -5
  4. package/lib/bootstrap.js +11 -33
  5. package/lib/catalog.js +1 -1
  6. package/lib/client.js +20830 -21811
  7. package/lib/i18n.js +0 -4
  8. package/lib/index.js +173 -862
  9. package/lib/remote-schemas.js +5 -104
  10. package/lib/remote.js +7 -174
  11. package/lib/schedule.js +99 -26
  12. package/lib/skill.js +11 -12
  13. package/package.json +3 -12
  14. package/vendor/third-party-licenses/README.md +0 -1
  15. package/data/t-team.config.json +0 -1558
  16. package/data/team-profiles.py +0 -367
  17. package/data/teams.json +0 -791
  18. package/data/teams.resolved.json +0 -1594
  19. package/lib/command.js +0 -251
  20. package/lib/plan-check.js +0 -166
  21. package/lib/squads.js +0 -446
  22. package/lib/teams/assignee-contract.js +0 -47
  23. package/lib/teams/capabilities.js +0 -106
  24. package/lib/teams/command.js +0 -147
  25. package/lib/teams/event-types.js +0 -12
  26. package/lib/teams/events.js +0 -60
  27. package/lib/teams/harness-compat.js +0 -561
  28. package/lib/teams/index.js +0 -463
  29. package/lib/teams/members.js +0 -619
  30. package/lib/teams/profiles.js +0 -572
  31. package/lib/teams/quality-gates.js +0 -905
  32. package/lib/teams/scheduler.js +0 -642
  33. package/lib/teams/snapshot.js +0 -184
  34. package/lib/teams/state.js +0 -946
  35. package/lib/teams/tool-names.js +0 -11
  36. package/lib/teams/tools.js +0 -2346
  37. package/lib/teams/types.js +0 -22
  38. package/lib/teams/web-routes.js +0 -86
  39. package/skills/dsh-harness-languages/SKILL.md +0 -175
  40. package/skills/dsh-harness-project/SKILL.md +0 -236
  41. package/vendor/dsh-agent-teams/LICENSE +0 -21
@@ -1,619 +0,0 @@
1
- /**
2
- * Member subagent lifecycle: spawn a continuable child per member, deliver
3
- * messages into its FIFO inbox, and observe its activity.
4
- *
5
- * Members are durable continuable subagents of the captain, so a member keeps
6
- * its conversation across turns and across harness restarts: the captain
7
- * queues its next turn through {@link deliverToMember}, it works through its turn
8
- * (updating team state through the `t_team_*` tools), and becomes idle
9
- * again. Its final assistant message is not readable programmatically, so the
10
- * member persists its report into the captain's mailbox and the task records,
11
- * which the captain reads through `t_team_status`.
12
- * @module t-team/members
13
- */
14
- import { installModelSelection } from '@deepseek-ai/dsh-agent';
15
- // Declaration merge only: makes ctx.subagents visible.
16
- import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent';
17
- import { createUserMessage, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
18
- import { join } from 'node:path';
19
- import { guardSubagentDelivery, installContinuableMemberSetup, queueMemberPrompt, reportHarnessContract, sessionOwnEvents } from "./harness-compat.js";
20
- import { acknowledgeMailbox, appendMailbox, CAPTAIN_KEY, createMessage, readRetiredMemberIds, readTeamSync, readTeam, releaseMailboxDelivery, withTeamLock, writeTeam } from "./state.js";
21
- import { appendTeamEvent, captainSessionOf } from "./events.js";
22
- import { TERMINAL_TASK_STATUSES } from "./types.js";
23
- import { CAPTAIN_TOOL_NAMES } from "./tool-names.js";
24
- /** Persona snapshot of a profile protocol; the full text lives on team.json. */
25
- export const PERSONA_PROTOCOL_MAX_CHARS = 400;
26
- /** Captain-only T Team tools hidden from newly spawned members. */
27
- const MEMBER_DENIED_TOOLS = CAPTAIN_TOOL_NAMES;
28
- /**
29
- * Restore the SessionId brand on a value that round-tripped through the
30
- * durable team file. The brand is erased by JSON serialization; the value
31
- * originated from `startContinuable`/`agent.id`, so this cast is the boundary
32
- * restoration, not a new assertion.
33
- */
34
- function brandedSessionId(value) {
35
- return value;
36
- }
37
- /**
38
- * Validate a resolved roster against every provider catalog before any child
39
- * session is created. Catalogs are advisory when empty (some adapters accept
40
- * dynamic model ids), but a non-empty catalog is authoritative enough to
41
- * catch a typo that would otherwise boot a child and fail on its first turn.
42
- */
43
- export async function validateMemberLlmSelections(ctx, selections, signal) {
44
- const catalogs = new Map();
45
- for (const selection of selections) {
46
- if (signal?.aborted === true)
47
- throw signal.reason ?? new Error('member model validation was cancelled');
48
- let catalog = catalogs.get(selection.provider);
49
- if (catalog === undefined) {
50
- catalog = await ctx.llm.listModels(selection.provider);
51
- catalogs.set(selection.provider, catalog);
52
- }
53
- if (catalog.length === 0 || catalog.some((model) => model.id === selection.model))
54
- continue;
55
- const available = catalog.slice(0, 8).map((model) => model.id).join(', ');
56
- throw new Error(`unknown member model "${selection.model}" for provider "${selection.provider}"`
57
- + `${available === '' ? '' : ` (available: ${available}${catalog.length > 8 ? ', …' : ''})`}`);
58
- }
59
- }
60
- const MEMBER_LABEL_PREFIX = 't-team:';
61
- const FALLBACK_FAILURE_CODES = new Set(['QUOTA', 'RATE_LIMIT', 'AUTH', 'MISSING_CREDENTIAL', 'NO_ADAPTER']);
62
- export function isFallbackFailureCode(code) {
63
- return FALLBACK_FAILURE_CODES.has(code);
64
- }
65
- /** Pure state transition used by the request-error handler and TDD tests. */
66
- export function selectFallbackRoute(current, fallback, failureCode, alreadySwitched) {
67
- if (alreadySwitched || fallback === undefined || !isFallbackFailureCode(failureCode)) {
68
- return { retry: false, switched: alreadySwitched, selection: current };
69
- }
70
- return { retry: true, switched: true, selection: fallback };
71
- }
72
- /** Deliver a durable member report to the live captain at its next model step. */
73
- export function steerCaptainReport(captain, from, content, logger) {
74
- try {
75
- captain.steer(createUserMessage({
76
- content: [{ type: 'text', text: `T Team message from member ${from}:\n\n${content}` }],
77
- source: { kind: 'plugin', plugin: 't-team' },
78
- }));
79
- return true;
80
- }
81
- catch (error) {
82
- // 投递失败必须留痕:调用方只拿到一个布尔值(true=实时投递 / false=回落持久信箱),
83
- // 不留日志就只能靠猜「这次为什么走了信箱」。steer 的异常根因在这里唯一可见。
84
- logger?.warn?.(`[t-team] 向队长实时投递消息失败,已回落到持久信箱:${error instanceof Error ? error.message : String(error)}`);
85
- return false;
86
- }
87
- }
88
- /** Record a final turn failure, never an intermediate request retry. */
89
- export async function failMemberOpenAttempt(ctx, stateRoot, teamId, memberName, failure, fallbackSession, observed) {
90
- const summary = `${failure.message} (code ${failure.code})`;
91
- const lockKey = `team:${stateRoot}:${teamId}`;
92
- const prepared = await withTeamLock(lockKey, async () => {
93
- const team = await readTeam(stateRoot, teamId);
94
- if (team === undefined || team.halted === true || team.captainSessionId !== observed.captainSessionId)
95
- return;
96
- const member = team.members.find(candidate => candidate.name === memberName
97
- && candidate.id === observed.memberId && candidate.status !== 'removed');
98
- if (member === undefined)
99
- return;
100
- const task = team.tasks.find(candidate => candidate.assignee === memberName
101
- && (candidate.status === 'claimed' || candidate.status === 'in_progress'));
102
- // A reassign, completion, or recreated member may win the lock while the
103
- // final error is queued. Only the capability observed at that event may fail.
104
- if (task?.id !== observed.task?.id || task?.attemptId !== observed.task?.attemptId
105
- || task?.attempt !== observed.task?.attempt)
106
- return;
107
- if (task === undefined && member.status !== 'working')
108
- return;
109
- if (task !== undefined) {
110
- task.status = 'failed';
111
- task.output = summary;
112
- task.updatedAt = Date.now();
113
- }
114
- if (ctx.agents.get(brandedSessionId(member.id))?.status !== 'running')
115
- member.status = 'idle';
116
- const message = {
117
- ...createMessage(memberName, CAPTAIN_KEY, task === undefined
118
- ? `Member "${memberName}" hit an unrecoverable turn failure: ${summary}. No open attempt was owned.`
119
- : `Member "${memberName}" hit an unrecoverable turn failure: ${summary}. Task ${task.id} ("${task.subject}") was marked failed; reassign it or retry when ready.`),
120
- deliveryClaimedAt: Date.now(),
121
- };
122
- await writeTeam(stateRoot, team);
123
- await appendMailbox(stateRoot, team.id, CAPTAIN_KEY, message);
124
- if (task !== undefined) {
125
- appendTeamEvent(ctx, captainSessionOf(ctx, team.captainSessionId, fallbackSession), 't-team/task-updated', {
126
- teamId, taskId: task.id, status: task.status, assignee: memberName, output: task.output,
127
- });
128
- }
129
- appendTeamEvent(ctx, captainSessionOf(ctx, team.captainSessionId, fallbackSession), 't-team/message-sent', {
130
- teamId: team.id,
131
- messageId: message.id,
132
- from: memberName,
133
- to: CAPTAIN_KEY,
134
- content: message.content,
135
- ts: message.ts,
136
- });
137
- return { captainSessionId: team.captainSessionId, message };
138
- });
139
- if (prepared === undefined)
140
- return false;
141
- // Use the same lease/acknowledgment contract as send_message, outside the
142
- // team lock: steering can synchronously start another agent turn.
143
- const captain = ctx.agents.get(brandedSessionId(prepared.captainSessionId));
144
- const delivered = captain !== undefined && steerCaptainReport(captain, memberName, prepared.message.content, ctx.logger);
145
- await withTeamLock(lockKey, () => delivered
146
- ? acknowledgeMailbox(stateRoot, teamId, CAPTAIN_KEY, [prepared.message.id])
147
- : releaseMailboxDelivery(stateRoot, teamId, CAPTAIN_KEY, [prepared.message.id]));
148
- return true;
149
- }
150
- async function updateFallbackState(stateRoot, teamId, memberName, fallback, ctx) {
151
- await withTeamLock(`team:${stateRoot}:${teamId}`, async () => {
152
- const team = await readTeam(stateRoot, teamId);
153
- if (team === undefined)
154
- return;
155
- const member = team.members.find(candidate => candidate.name === memberName);
156
- if (member === undefined)
157
- return;
158
- member.activeProvider = fallback.provider;
159
- member.activeModel = fallback.model;
160
- member.fallbackActive = true;
161
- await writeTeam(stateRoot, team);
162
- });
163
- void ctx;
164
- }
165
- function pendingSelectionKey(parentSessionId, label) {
166
- return `${parentSessionId}\u0000${label}`;
167
- }
168
- function selectionFromMember(member) {
169
- if (member?.provider === undefined || member.model === undefined)
170
- return undefined;
171
- const provider = (member.activeProvider ?? member.provider).trim();
172
- const model = (member.activeModel ?? member.model).trim();
173
- if (provider === '' || model === '')
174
- return undefined;
175
- // Effort ids belong to the original model. After a fallback, its own
176
- // provider default remains authoritative, including after cold recovery.
177
- const reasoningEffort = member.fallbackActive === true ? undefined : member.reasoningEffort?.trim();
178
- return {
179
- provider,
180
- model,
181
- ...reasoningEffort === undefined || reasoningEffort === '' ? {} : { reasoningEffort },
182
- ...member.fallback === undefined || member.fallbackActive === true ? {} : { fallback: member.fallback },
183
- };
184
- }
185
- function modelSelection(selection) {
186
- return {
187
- provider: selection.provider,
188
- model: selection.model,
189
- ...selection.reasoningEffort === undefined
190
- ? {}
191
- : { reasoningEffort: ReasoningEffortId(selection.reasoningEffort) },
192
- };
193
- }
194
- /**
195
- * Resolve one member's complete model selection. Ordinary members snapshot the
196
- * captain's current request route and reasoning effort. When provider or model
197
- * changes, effort is intentionally omitted so the target model materializes
198
- * its own default instead of receiving an adapter-owned id from another route.
199
- * An explicit effort overrides either policy; the sentinel "default" also
200
- * selects the target model's default. The final effort is validated against
201
- * the target model before a child is created.
202
- */
203
- export async function resolveMemberLlmSelection(ctx, captain, request, signal) {
204
- const explicitProvider = request.provider?.trim();
205
- const explicitModel = request.model?.trim();
206
- const defaultModel = request.defaultModel?.trim();
207
- const explicitEffort = request.reasoningEffort?.trim();
208
- const fallback = request.fallback;
209
- if (request.provider !== undefined && explicitProvider === '') {
210
- throw new Error('member LLM provider must not be empty');
211
- }
212
- if (request.model !== undefined && explicitModel === '') {
213
- throw new Error('member model must not be empty');
214
- }
215
- if (request.defaultModel !== undefined && defaultModel === '') {
216
- throw new Error('configured memberModel must not be empty');
217
- }
218
- if (request.reasoningEffort !== undefined && explicitEffort === '') {
219
- throw new Error('member reasoning effort must not be empty');
220
- }
221
- if (explicitProvider !== undefined && explicitModel === undefined) {
222
- throw new Error('an explicit member LLM provider requires an explicit member model');
223
- }
224
- const current = captain.session.requestHeader()?.config;
225
- const currentProvider = current?.provider ?? captain.options.provider;
226
- const currentModel = current?.model ?? captain.options.model;
227
- const provider = explicitProvider ?? currentProvider;
228
- const model = explicitModel ?? defaultModel ?? currentModel;
229
- if (provider === undefined || model === undefined) {
230
- throw new Error('cannot resolve the member LLM route from the current captain session');
231
- }
232
- // Effort ids belong to one exact provider/model capability. Preserve the
233
- // captain's effort only on the same route; a changed route must resolve its
234
- // own default. Explicit effort still wins, while "default" forces that
235
- // target-default behavior even when the route did not change.
236
- const sameRoute = provider === currentProvider && model === currentModel;
237
- const reasoningEffort = explicitEffort === undefined
238
- ? sameRoute
239
- ? current?.reasoningEffort
240
- : undefined
241
- : explicitEffort === 'default'
242
- ? undefined
243
- : ReasoningEffortId(explicitEffort);
244
- const resolved = await ctx.llm.resolveCallConfig({
245
- provider,
246
- model,
247
- ...reasoningEffort === undefined
248
- ? {}
249
- : { reasoningEffort },
250
- }, signal);
251
- return {
252
- provider: resolved.provider,
253
- model: resolved.model,
254
- ...resolved.reasoningEffort === undefined
255
- ? {}
256
- : { reasoningEffort: String(resolved.reasoningEffort) },
257
- ...fallback === undefined ? {} : { fallback },
258
- };
259
- }
260
- /**
261
- * Install the member selection bridge for every fresh or cold-resumed
262
- * continuable child. Fresh creation reads the pending in-memory selection;
263
- * cold resume restores the same selection from the owning team's durable
264
- * record. Legacy members without a complete saved route retain Harness's
265
- * descriptor provider/model behavior.
266
- */
267
- export function installMemberSelectionRuntime(ctx, stateDir, onFailureSettled) {
268
- const pending = new Map();
269
- installContinuableMemberSetup(ctx, (childCtx, child) => {
270
- const descriptor = foldSubagentDescriptor(sessionOwnEvents(child.session));
271
- if (descriptor?.mode !== 'continuable' || !descriptor.label.startsWith(MEMBER_LABEL_PREFIX)) {
272
- return () => undefined;
273
- }
274
- const parentSessionId = child.session.header.parentSession;
275
- if (parentSessionId === undefined)
276
- return () => undefined;
277
- const identity = descriptor.label.slice(MEMBER_LABEL_PREFIX.length);
278
- const separator = identity.indexOf(':');
279
- if (separator < 1 || separator === identity.length - 1)
280
- return () => undefined;
281
- const teamId = identity.slice(0, separator);
282
- const memberName = identity.slice(separator + 1);
283
- const workspace = child.session.header.cwd ?? process.cwd();
284
- const stateRoot = join(workspace, stateDir);
285
- const key = pendingSelectionKey(parentSessionId, descriptor.label);
286
- let selection = pending.get(key);
287
- if (selection === undefined) {
288
- const team = readTeamSync(stateRoot, teamId);
289
- if (team?.captainSessionId !== parentSessionId)
290
- return () => undefined;
291
- const durableMember = team.members.find(member => member.name === memberName);
292
- selection = selectionFromMember(durableMember);
293
- if (selection !== undefined && (descriptor.agentProvider !== durableMember?.provider || descriptor.agentModel !== durableMember?.model)) {
294
- throw new Error(`t-team: saved model route for member "${memberName}" does not match its subagent descriptor`);
295
- }
296
- }
297
- let lastFailedTurn;
298
- // Harness emits agent/error only after request recovery is exhausted. In
299
- // particular, llm-retry's "always" policy calls downstream request-error
300
- // listeners before it retries; that waterfall cannot declare a turn dead.
301
- const disposeFailure = childCtx.on('agent/error', async (payload) => {
302
- if (payload.agent.id !== child.id || payload.turn === lastFailedTurn)
303
- return;
304
- lastFailedTurn = payload.turn;
305
- try {
306
- // Capture the attempt synchronously at the event, before any lock wait
307
- // can let a captain reassign it or replace the member/team generation.
308
- const snapshot = readTeamSync(stateRoot, teamId);
309
- if (snapshot?.captainSessionId !== parentSessionId)
310
- return;
311
- const member = snapshot.members.find(item => item.id === child.id && item.name === memberName && item.status !== 'removed');
312
- if (member === undefined)
313
- return;
314
- const task = snapshot.tasks.find(item => item.assignee === memberName
315
- && (item.status === 'claimed' || item.status === 'in_progress'));
316
- const failure = payload.error instanceof LlmError ? payload.error.failure : {
317
- code: 'UNKNOWN',
318
- message: payload.error instanceof Error ? payload.error.message : String(payload.error),
319
- };
320
- const recorded = await failMemberOpenAttempt(ctx, stateRoot, teamId, memberName, failure, child.session, {
321
- captainSessionId: parentSessionId, memberId: child.id, task,
322
- });
323
- if (!recorded)
324
- return;
325
- // The final-error event precedes driver quiescence. Observe the real
326
- // lifecycle and kick explicitly even if its idle event was missed.
327
- // Never force an active Agent's status to idle or retry the failed task.
328
- await child.whenIdle();
329
- await withTeamLock(`team:${stateRoot}:${teamId}`, async () => {
330
- const team = await readTeam(stateRoot, teamId);
331
- const current = team?.members.find(item => item.id === child.id && item.name === memberName && item.status !== 'removed');
332
- if (team?.captainSessionId !== parentSessionId || current === undefined || child.status !== 'idle')
333
- return;
334
- if (current.status !== 'idle') {
335
- current.status = 'idle';
336
- await writeTeam(stateRoot, team);
337
- }
338
- });
339
- await onFailureSettled?.(workspace, teamId, memberName);
340
- }
341
- catch (error) {
342
- ctx.logger.warn(`t-team: failed to record member turn failure: ${String(error)}`);
343
- }
344
- });
345
- // Legacy teams still need failure reporting even without a saved route.
346
- if (selection === undefined)
347
- return disposeFailure;
348
- const selectionRef = { current: modelSelection(selection), assembled: undefined };
349
- const disposeSelection = installModelSelection(childCtx, selectionRef);
350
- const fallback = selection.fallback;
351
- let switched = false;
352
- const disposeFallback = childCtx.on('agent/request-error', async (payload, next) => {
353
- if (payload.agent.id !== child.id || payload.signal.aborted)
354
- return next();
355
- const transition = selectFallbackRoute(selectionRef.current ?? { provider: selection.provider, model: selection.model }, fallback, payload.failure.code, switched);
356
- // selectFallbackRoute only retries when a fallback route exists; the
357
- // explicit guard narrows that fact for the compiler.
358
- if (fallback !== undefined && transition.retry) {
359
- switched = transition.switched;
360
- selectionRef.current = transition.selection;
361
- // Request recovery repeats buildRequest inside the current step; it
362
- // does not re-run prompt assembly. Override that captured route too,
363
- // otherwise the authorized retry would hit the failed primary again.
364
- selectionRef.assembled = transition.selection;
365
- await updateFallbackState(stateRoot, teamId, memberName, fallback, ctx).catch((error) => {
366
- ctx.logger.warn(`t-team: failed to persist fallback route: ${String(error)}`);
367
- });
368
- ctx.logger.warn(`t-team: member ${child.id} switching to fallback ${fallback.provider}/${fallback.model} after ${payload.failure.code}`);
369
- return { kind: 'retry' };
370
- }
371
- return next();
372
- });
373
- return () => {
374
- disposeFallback();
375
- disposeSelection();
376
- disposeFailure();
377
- };
378
- });
379
- return {
380
- isPendingMember(agent) {
381
- const parent = agent.session.header.parentSession;
382
- const descriptor = foldSubagentDescriptor(sessionOwnEvents(agent.session));
383
- return parent !== undefined && descriptor?.mode === 'continuable'
384
- && pending.has(pendingSelectionKey(parent, descriptor.label));
385
- },
386
- async withPending(parentSessionId, label, selection, operation) {
387
- const key = pendingSelectionKey(parentSessionId, label);
388
- if (pending.has(key)) {
389
- throw new Error(`member model selection is already pending for "${label}"`);
390
- }
391
- pending.set(key, selection);
392
- try {
393
- return await operation();
394
- }
395
- finally {
396
- pending.delete(key);
397
- }
398
- },
399
- };
400
- }
401
- function configuredExecutionPrompt(member, config) {
402
- const prompt = member.executionPrompt?.trim() || config.executionPrompt?.trim();
403
- return prompt === undefined || prompt === '' ? undefined : prompt;
404
- }
405
- function truncatedPersonaProtocol(protocol) {
406
- if (protocol === undefined || protocol.trim() === '')
407
- return '(none)';
408
- if (protocol.length <= PERSONA_PROTOCOL_MAX_CHARS)
409
- return protocol;
410
- return `${protocol.slice(0, PERSONA_PROTOCOL_MAX_CHARS)}… [truncated]`;
411
- }
412
- function assignedNonTerminalCount(team, memberName) {
413
- return team.tasks.filter(task => (task.assignee === memberName && !TERMINAL_TASK_STATUSES.includes(task.status))).length;
414
- }
415
- /**
416
- * The member's system prompt (persona), shadowing the deployment persona for
417
- * that child. Self-contained: it replaces the whole persona section.
418
- * Frozen at spawn: draft must already carry the Team goal and profile protocol.
419
- * @param team - the team the member joined.
420
- * @param member - the member record (name/role are read before spawning).
421
- * @param stateDir - configured state directory, so the member can locate the
422
- * team files with its own file tools.
423
- */
424
- export function memberPersona(team, member, stateDir, executionPrompt) {
425
- const goal = team.description?.trim() || '(not provided)';
426
- const injectedPrompt = member.executionPrompt?.trim() || executionPrompt?.trim();
427
- const protocol = truncatedPersonaProtocol(team.profile?.protocol);
428
- return `You are ${member.name}, a member of the multi-agent team "${team.name}" running inside DeepSeek Harness T Team. The captain leads the team; you are a worker member${member.role ? ` with the role: ${member.role}` : ''}.
429
-
430
- Team context:
431
- - Team id: ${team.id}
432
- - Your name inside the team (use it as \`from\`/identity): ${member.name}
433
- - Team goal: ${goal}
434
- - Profile protocol: ${protocol}
435
- ${injectedPrompt === undefined || injectedPrompt === '' ? '' : `- Execution guidance:
436
- ${injectedPrompt}
437
- `}- The team state lives under ${stateDir}/${team.id}/ (team.json and inbox/*.jsonl). You may inspect these files read-only for diagnostics, but never edit them directly; use the t_team_* tools so JSON escaping and concurrent updates stay safe.
438
- - The captain and your teammates reach you through messages. Each message you receive is a new turn: act on it and end your turn with a concise reply.
439
- When you receive a task, treat the assignment prompt's dependency results as source material. Do not ignore them.
440
-
441
- Working rules:
442
- 1. When you receive a task assignment, call t_team_claim_task with the task id. Keep the returned attempt_id: include it in every t_team_update_task call for that execution attempt. Then mark the task in_progress.
443
- 2. Work thoroughly with your available tools; do not cut corners.
444
- 3. When finishing a task:
445
- - use status=completed only when the task's success criteria are satisfied;
446
- - use status=failed when blocking findings or validation failures mean downstream work must not proceed;
447
- - include a concise output in either case;
448
- - a stale-attempt rejection means the captain reassigned or took over the task; stop touching that task and wait for new work.
449
- claimed cannot jump to completed. Mark in_progress first, then completed or failed.
450
- Include attempt_id on every update, then become idle.
451
- 4. Write the report exactly once. t_team_update_task IS your report: the captain and every downstream task read output/summary/tried from it. Then send_message (to=captain) a ONE-LINE notice ("<the task id> completed — result in the task output") — that line is what wakes the captain, it must not restate the report — and keep your closing turn message to ONE line as well, because it is delivered to the captain in full. A completion written out three times costs three times the context and says nothing new. Use a longer send_message only for what the task record cannot carry: a blocker, or a decision only the captain can make.
452
- 5. To ask a teammate something, use t_team_send_message with to=<teammate name>; the message lands in their mailbox and wakes them directly — teammates talk to each other without the captain in the loop. The same applies to the captain (to=captain).
453
- 6. After your turn becomes idle, the shared task scheduler may assign your next ready task automatically. Never claim a second task while you still own unfinished work.
454
- 7. If you already own an open attempt (claimed or in_progress) and receive mail, treat it as guidance for that same attempt_id unless the mail explicitly tells you to stop or fail. Do not claim a new task in that turn.
455
- 8. Do not start a teammate's assigned task. Do not privately tell the next-stage member to start; the scheduler assigns unlocked work after you become idle.
456
- 9. You are a worker: do not create or delete teams, reassign tasks, or add/remove members — that is the captain's job.
457
- 10. Quality-gate kinds carry a contract (kind, objective, inScope, acceptance, verify). Stay inside inScope. Do not mark your own implementation as review pass. Review/requirements complete only with verdict=pass; needs_revision/reject must fail with findings. Mail is not a formal next review.`;
458
- }
459
- /**
460
- * The initial user message delivered when the member is created.
461
- * Counts non-terminal tasks already assigned to this member on the in-memory draft.
462
- * @param team - the team the member joined.
463
- * @param memberName - canonical member name used to count assigned pending work.
464
- */
465
- export function memberWelcome(team, memberName) {
466
- const assigned = assignedNonTerminalCount(team, memberName);
467
- return `You have joined the team "${team.name}" as a member. Wait for an automatic assignment or a captain message.
468
- Current team status: ${team.tasks.length} task(s), ${assigned} pending task(s) assigned to you.
469
- Do not start work until the scheduler or captain assigns a task in this turn.`;
470
- }
471
- /**
472
- * Spawn one member as a durable continuable subagent of the captain and fill
473
- * `member.id` with its child session id. On failure nothing is persisted.
474
- * @param ctx - the plugin context (injects `subagents`).
475
- * @param config - member runtime knobs.
476
- * @param selections - fresh/cold child model-selection bridge.
477
- * @param llmSelection - resolved provider/model/reasoning snapshot.
478
- * @param captain - the exact live captain agent (the calling agent).
479
- * @param team - the team record (read-only here).
480
- * @param member - the member draft whose `id` is filled on success.
481
- * @param stateDir - configured state directory (for the persona).
482
- * @param signal - caller cancellation, forwarded to the start.
483
- */
484
- export async function spawnMember(ctx, config, selections, llmSelection, captain, team, member, stateDir, signal) {
485
- // Fail loud at the first use: provider registration is a sibling plugin's
486
- // effect and may settle after this plugin mounts. Capability checks here
487
- // mirror what startContinuable would reject, with an actionable error.
488
- const provider = ctx.subagents.getProvider(config.provider);
489
- if (provider === undefined) {
490
- throw new Error(`t-team: no subagent provider "${config.provider}" is registered (available: ${ctx.subagents.list().join(', ') || 'none'}) — `
491
- + 'check that the subagent provider row (e.g. subagent-spawn) is mounted in the composition');
492
- }
493
- if (provider.prepareContinuable === undefined) {
494
- throw new Error(`t-team: provider "${config.provider}" does not support continuable members`);
495
- }
496
- if (!provider.capabilities.persona) {
497
- throw new Error(`t-team: provider "${config.provider}" cannot apply a member persona`);
498
- }
499
- if (!provider.capabilities.toolFilter) {
500
- throw new Error(`t-team: provider "${config.provider}" cannot restrict captain-only tools for members`);
501
- }
502
- const label = `${MEMBER_LABEL_PREFIX}${team.id}:${member.name}`;
503
- const start = await selections.withPending(captain.id, label, llmSelection, () => (ctx.subagents.startContinuable({
504
- provider: config.provider,
505
- label,
506
- request: {
507
- prompt: [{ type: 'text', text: memberWelcome(team, member.name) }],
508
- parent: captain,
509
- persona: memberPersona(team, member, stateDir, config.executionPrompt),
510
- toolFilter: { deny: [...MEMBER_DENIED_TOOLS] },
511
- agentOptions: {
512
- provider: llmSelection.provider,
513
- model: llmSelection.model,
514
- ...llmSelection.reasoningEffort === undefined
515
- ? {}
516
- : { reasoningEffort: ReasoningEffortId(llmSelection.reasoningEffort) },
517
- },
518
- ...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {},
519
- },
520
- signal,
521
- })));
522
- member.id = start.childId;
523
- }
524
- /**
525
- * Deliver one message to a member as its next FIFO turn. Best effort: a
526
- * failure (member gone or not continuable) is logged and reported as `false`
527
- * so the caller can decide (mailbox delivery still happened).
528
- *
529
- * Any team sender can route through this helper: the captain is the direct
530
- * parent of every member, and the caller passes the captain's live Agent
531
- * (its own when the captain calls, the registry-resolved one when a member
532
- * sends) — mirroring the Claude Code mailbox model where the writer writes
533
- * the target's inbox and the target picks it up on its own.
534
- * @param ctx - the plugin context (injects `subagents`).
535
- * @param captain - the exact live captain agent (the member's direct parent).
536
- * @param childId - the member's durable child session id.
537
- * @param text - the message content.
538
- * @param signal - caller cancellation, forwarded to the delivery.
539
- * @returns whether the member inbox accepted the message.
540
- */
541
- export async function deliverToMember(ctx, captain, childId, text, signal) {
542
- try {
543
- await queueMemberPrompt(ctx.subagents, captain, brandedSessionId(childId), [{ type: 'text', text }], signal);
544
- return true;
545
- }
546
- catch (error) {
547
- // 审计修复(#8):投递失败过去只记一行 warn,然后任务被静默回滚成 pending,队长
548
- // 看起来「重派成功」实则永远唤不醒成员。升级为 error 并保留根因,让宿主契约漂移
549
- // 这类系统性问题在日志里可定位,而不是混在普通 warn 里被忽略。
550
- ctx.logger.error(`t-team: prompt delivery to member ${childId} failed: ${String(error)}`);
551
- return false;
552
- }
553
- }
554
- /**
555
- * Request cancellation of one live member's current turn. Best effort, fire
556
- * and return; the target may keep running until it observes the signal.
557
- * @param ctx - the plugin context (injects `subagents`).
558
- * @param captain - the exact live captain agent (the member's parent).
559
- * @param childId - the member's durable child session id.
560
- */
561
- export function interruptMember(ctx, captain, childId) {
562
- try {
563
- ctx.subagents.interrupt(brandedSessionId(childId), { kind: 'ancestor', agent: captain });
564
- }
565
- catch (error) {
566
- ctx.logger.warn(`t-team: interrupt of member ${childId} failed: ${String(error)}`);
567
- }
568
- }
569
- /**
570
- * Install the missing per-child retirement boundary above Harness rc.6.
571
- *
572
- * Upstream `interrupt()` deliberately preserves continuable sessions and the
573
- * upstream seam exposes no targeted forget/retire method. The durable
574
- * T Team index therefore rejects every inbox delivery before it can cold-resume a
575
- * retired member. Catalog rows deliberately remain discoverable: Harness rc.8
576
- * uses the direct-child catalog to authorize historical transcript reads and
577
- * `openSubagent()`, so filtering those rows would make an archived member's
578
- * persisted conversation inaccessible. Exact ids keep unrelated subagents
579
- * untouched while the delivery boundary still prevents further model turns.
580
- */
581
- export function installRetiredMemberGuard(ctx, stateDir) {
582
- // B-2:装守卫的同时做一次契约体检,**在装之前**报 ——
583
- // 未实测的宿主版本、或形状与实测不符,都要在日志里留下一句可执行的话;否则上游换了契约
584
- // 的唯一症状是"队长叫不醒成员 / 已退休成员被复活",那时只能靠猜(见 harness-compat.js)。
585
- // 已实测时完全不出声;同一份签名只报一次(挂载与每次热重载都会经过这里)。
586
- reportHarnessContract(ctx, ctx.logger);
587
- guardSubagentDelivery(ctx, async (parent, childId) => {
588
- const retired = await readRetiredMemberIds(join(parent.session.header.cwd ?? process.cwd(), stateDir));
589
- return retired.has(childId);
590
- });
591
- }
592
- /**
593
- * Snapshot the real driver activity for durable member ids.
594
- *
595
- * The team record is the membership authority, so this path intentionally no
596
- * longer depends on `listChildren()`'s versioned projection shape. Harness
597
- * rc.8 changed those rows to branded `SessionId` values plus residency-only
598
- * `activity`; neither is needed to answer whether the live Agent driver is
599
- * running, idle, or absent/ready.
600
- * @param ctx - the plugin context (only needs `ctx.get`; `agents` itself is optional).
601
- * @param memberIds - child ids restored from the durable team record.
602
- * @returns child id → live activity.
603
- */
604
- export function memberActivity(ctx, memberIds) {
605
- const activity = new Map();
606
- // `agents` 是**可选服务**:宿主侧静态 `inject` 只有 tools/subagents/systemPrompt,所以
607
- // 属性访问 `ctx.agents` 在那种 ctx 上会抛 `cannot get property "agents" without inject`,
608
- // 而调用方(团队面板的快照收集)用 try/catch 把它吞成「skipped unreadable team state」——
609
- // 整支队伍从面板消失,只留一句没有原因可查的告警(2026-09-13 查明)。规则与
610
- // lib/index.js 里 `ctx.get("agents")` 一致:可选服务必须惰查,缺了降级成 ready。
611
- const agents = typeof ctx?.get === 'function' ? ctx.get('agents') : undefined;
612
- for (const id of memberIds) {
613
- if (id === '')
614
- continue;
615
- const live = agents?.get?.(brandedSessionId(id));
616
- activity.set(id, live === undefined ? 'ready' : live.status);
617
- }
618
- return activity;
619
- }