@aiwuk/team 0.0.0-stage → 5.0.3

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 (116) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/LICENSE +21 -0
  3. package/README.md +149 -2
  4. package/README.zh-CN.md +150 -0
  5. package/access.js +93 -0
  6. package/activity-summary.js +36 -0
  7. package/allocation.js +34 -0
  8. package/board-dispatch.js +141 -0
  9. package/brand.js +41 -0
  10. package/client/00-core-zh.js +284 -0
  11. package/client/01-i18n-en.js +265 -0
  12. package/client/02-styles.js +336 -0
  13. package/client/02b-stage-styles.js +65 -0
  14. package/client/03-shared.js +82 -0
  15. package/client/03b-collapsible-list.js +34 -0
  16. package/client/03c-config-panel.js +605 -0
  17. package/client/04-tool-cards.js +110 -0
  18. package/client/04a-dispatch-confirm.js +162 -0
  19. package/client/04b-more-cards.js +78 -0
  20. package/client/04c-model-picker.js +68 -0
  21. package/client/04d-confirm-dock.js +40 -0
  22. package/client/05-policy.js +217 -0
  23. package/client/06-usage.js +195 -0
  24. package/client/06b-model-source.js +67 -0
  25. package/client/07-board-parts.js +312 -0
  26. package/client/07a-role-theme.js +25 -0
  27. package/client/08-artifacts-report.js +375 -0
  28. package/client/08a-task-control.js +126 -0
  29. package/client/08b-stage-sprites.js +97 -0
  30. package/client/08c-stage-colors.js +55 -0
  31. package/client/08c-stage-scene.js +238 -0
  32. package/client/08d-stage-adapter.js +65 -0
  33. package/client/08e-stage-activity.js +83 -0
  34. package/client/09-team-board.js +181 -0
  35. package/client/09b-role-view.js +95 -0
  36. package/client/09c-model-tabs.js +20 -0
  37. package/client/10-overview.js +364 -0
  38. package/client/10b-onboarding.js +121 -0
  39. package/client/11-register.js +249 -0
  40. package/client.js +5053 -0
  41. package/config-projection.js +40 -0
  42. package/config.js +411 -0
  43. package/cordis.patch.yml +110 -0
  44. package/defaults.js +222 -0
  45. package/dispatch-confirm.js +293 -0
  46. package/dispatch-routes-store.js +31 -0
  47. package/failover.js +85 -0
  48. package/file-limits.js +119 -0
  49. package/free-worker.mjs +125 -0
  50. package/http-guard.js +14 -0
  51. package/i18n/roles.en.json +23 -0
  52. package/i18n/roles.zh.json +23 -0
  53. package/i18n/server.en.json +659 -0
  54. package/i18n/server.zh.json +659 -0
  55. package/i18n.js +34 -0
  56. package/icon.svg +7 -0
  57. package/index.js +111 -0
  58. package/integrations-config.js +301 -0
  59. package/integrations-messages.js +62 -0
  60. package/integrations.js +17 -0
  61. package/lead-route.js +30 -0
  62. package/locale/en.json +6 -0
  63. package/locale/zh.json +6 -0
  64. package/long-context.js +30 -0
  65. package/managed-config-route.js +49 -0
  66. package/managed-ref.js +38 -0
  67. package/managed-resolve.js +47 -0
  68. package/member-task-control.js +242 -0
  69. package/memory-backends.js +89 -0
  70. package/memory-embed.js +70 -0
  71. package/memory-hub.js +150 -0
  72. package/memory-openviking.js +101 -0
  73. package/memory-redact.js +34 -0
  74. package/memory-store.js +219 -0
  75. package/memory-sync-queue.js +28 -0
  76. package/model-policy.js +85 -0
  77. package/model-prefs.js +104 -0
  78. package/observability-api.js +76 -0
  79. package/observability-projection.js +141 -0
  80. package/onboarding.js +47 -0
  81. package/opencode.js +74 -0
  82. package/package.json +153 -4
  83. package/plan-gate.js +92 -0
  84. package/preset-guard.js +47 -0
  85. package/request-evidence.js +41 -0
  86. package/roles.js +69 -0
  87. package/roster-defaults.js +70 -0
  88. package/route-health.js +39 -0
  89. package/runtime-observability.js +78 -0
  90. package/runtime-view.js +21 -0
  91. package/scripts/memory-cli.mjs +138 -0
  92. package/search-engines.js +119 -0
  93. package/search.js +164 -0
  94. package/session-policy.js +59 -0
  95. package/session-rotation.js +206 -0
  96. package/settings-schema.js +40 -0
  97. package/shared/managed-config.js +84 -0
  98. package/single-model.js +44 -0
  99. package/skills/project-role-theme/SKILL.md +46 -0
  100. package/task-bridge.js +32 -0
  101. package/task-control-api.js +36 -0
  102. package/task-control-audit.js +60 -0
  103. package/team.js +101 -0
  104. package/teammate-context.js +120 -0
  105. package/teammate-wait.js +18 -0
  106. package/template.js +46 -0
  107. package/theme-plugin.js +172 -0
  108. package/theme.js +115 -0
  109. package/token-discipline.js +116 -0
  110. package/token-metrics.js +37 -0
  111. package/tool-names.js +10 -0
  112. package/tool-schema.js +23 -0
  113. package/transcribe.js +104 -0
  114. package/ui.js +113 -0
  115. package/usage.js +92 -0
  116. package/workflow.js +246 -0
package/ui.js ADDED
@@ -0,0 +1,113 @@
1
+ /** Profile-wide, read-only diagnostics; never activates routing for unrelated sessions. */
2
+ import { m, t } from './i18n.js';
3
+ import { readTeamSnapshot, saveTeamPolicy } from './runtime-view.js';
4
+ import { readRequestEvidence, readTeamEvidence } from './request-evidence.js';
5
+ import { applyConfigProjection } from './config-projection.js';
6
+ import { applyObservabilityProjections } from './observability-projection.js';
7
+ import { applyObservabilityApi } from './observability-api.js';
8
+ import { applyTaskControlApi } from './task-control-api.js';
9
+ import { applyDispatchConfirmApi } from './dispatch-confirm.js';
10
+ import { applyOnboardingApi } from './onboarding.js';
11
+ import { registerManagedConfigRoute } from './managed-config-route.js';
12
+ import { schemastery } from './settings-schema.js';
13
+ export const inject = ['commands', 'agentPresets', 'tools'];
14
+ // Only the welcome card's acknowledgement is a live setting (the client renders its own card, so no auto page).
15
+ const z = schemastery('@aiwuk/team');
16
+ export const Config = z ? z.object({ welcomeVersion: z.string().volatile() }) : undefined;
17
+ /**
18
+ * Live roster state from the host's Agent Teams service: status and owned tasks. No log reads, cheap to poll.
19
+ * The host's member provider/model is not kept in sync with real requests, so it is deliberately not forwarded.
20
+ */
21
+ export function teamActivity(ctx, agent) {
22
+ const teams = ctx.get?.('agentTeams');
23
+ if (!teams) return { members: [], tasks: [], unavailable: t('ui.593') };
24
+ let members, tasks = [];
25
+ try {
26
+ members = teams.listMembers(agent).map(m => ({ id: m.id, name: m.name, role: m.role, status: m.status, diagnostics: m.diagnostics ?? [] }));
27
+ } catch (error) { return { members: [], tasks: [], unavailable: t('ui.594', { 0: error.message ?? error }) }; }
28
+ try {
29
+ tasks = (teams.listTasks?.(agent) ?? []).filter(task => task.status !== 'deleted')
30
+ .map(task => ({ id: task.id, subject: task.subject, status: task.status, ...(task.ownerName ? { ownerName: task.ownerName } : {}) }));
31
+ } catch { /* task board is optional for the activity view */ }
32
+ return { members, tasks };
33
+ }
34
+ export async function inspectTeamRuntime(ctx, agent) {
35
+ const preset = ctx.agentPresets.composedPreset(agent.ctx) ?? null;
36
+ const roster = await ctx.agentPresets.list();
37
+ const definition = roster.find(p => p.id === 'capability-team');
38
+ const required = ['capability_team_read', 'capability_dispatch_plan', 'capability_free_task'];
39
+ const visible = ctx.tools.schemas(agent).map(t => t.name);
40
+ const missingTools = required.filter(name => !visible.includes(name));
41
+ let team = null, teamError = null;
42
+ if (preset === 'capability-team') {
43
+ try { team = await readTeamSnapshot(agent); }
44
+ catch (error) { teamError = String(error.message ?? error); }
45
+ }
46
+ const memberEvidence = preset === 'capability-team'
47
+ ? await readTeamEvidence(ctx.get?.('sessionQuery'), ctx.get?.('agentTeams'), agent) : [];
48
+ return {
49
+ sessionId: agent.id, preset, teamServicePresent: !!ctx.get?.('agentTeams'),
50
+ declared: !!definition, activationError: definition?.broken ?? null,
51
+ ready: preset === 'capability-team' && !!definition && !definition.broken && missingTools.length === 0 && team !== null && !teamError,
52
+ missingTools,
53
+ requestEvidence: memberEvidence.find(member => member.sessionId === agent.id) ?? await readRequestEvidence(ctx.get?.('sessionQuery'), agent.id),
54
+ memberEvidence,
55
+ team, teamError,
56
+ activity: preset === 'capability-team' ? teamActivity(ctx, agent) : null,
57
+ explanation: !definition ? t('ui.595')
58
+ : definition.broken ? t('ui.596', { 0: definition.broken })
59
+ : preset !== 'capability-team' ? t('ui.597')
60
+ : missingTools.length ? t('ui.598', { 0: missingTools.join(', ') })
61
+ : teamError ? t('ui.599')
62
+ : team === null ? t('ui.600')
63
+ : t('ui.601'),
64
+ };
65
+ }
66
+ /**
67
+ * The host logs every command result into the conversation, so the panel's snapshot is kept small:
68
+ * catalog rows become [key, tier, primaryFor?, fallbackFor?] tuples, derived/duplicate fields are dropped.
69
+ */
70
+ export function compactStatus(status) {
71
+ const { team, requestEvidence, memberEvidence, ...rest } = status;
72
+ const self = memberEvidence.some(member => member.sessionId === status.sessionId);
73
+ const out = { ...rest, memberEvidence, ...(self ? {} : { requestEvidence }) };
74
+ if (!team) return { ...out, team };
75
+ const { autoMatch: _autoMatch, modelPolicy, ...teamRest } = team;
76
+ if (!modelPolicy) return { ...out, team: teamRest };
77
+ const { tierCatalog: _tiers, rows, ...policyRest } = modelPolicy;
78
+ const tuple = row => {
79
+ const value = [row.key, row.tiers?.[0] ?? null];
80
+ if (row.primaryFor?.length || row.fallbackFor?.length) value.push(row.primaryFor ?? []);
81
+ if (row.fallbackFor?.length) value.push(row.fallbackFor);
82
+ return value;
83
+ };
84
+ return { ...out, team: { ...teamRest, modelPolicy: { ...policyRest, rows: (rows ?? []).map(tuple) } } };
85
+ }
86
+ export async function savePolicyCommand(ctx, { agent, rawInput }) {
87
+ if (ctx.agentPresets.composedPreset(agent.ctx) !== 'capability-team') throw new Error(m('ui.602'));
88
+ const input = JSON.parse(rawInput);
89
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(k => !['disabledRoutes', 'expectedRevision'].includes(k))) throw new Error(m('ui.603'));
90
+ const result = await saveTeamPolicy(agent, input.disabledRoutes, input.expectedRevision);
91
+ return { kind: 'success', text: JSON.stringify(result) };
92
+ }
93
+ export function apply(ctx) {
94
+ applyConfigProjection(ctx);
95
+ applyObservabilityProjections(ctx);
96
+ applyObservabilityApi(ctx);
97
+ applyTaskControlApi(ctx);
98
+ applyDispatchConfirmApi(ctx);
99
+ applyOnboardingApi(ctx);
100
+ registerManagedConfigRoute(ctx, 'team');
101
+ ctx.inject?.(['settings'], scoped => { scoped.effect(() => scoped.settings.configure({ auto: false }, ctx.fiber)); });
102
+ ctx.effect(() => ctx.commands.register({
103
+ name: 'capability-model-policy',
104
+ description: t('ui.604'),
105
+ input: { hint: t('ui.605') },
106
+ handler: invocation => savePolicyCommand(ctx, invocation),
107
+ }));
108
+ ctx.effect(() => ctx.commands.register({
109
+ name: 'capability-status',
110
+ description: t('ui.606'),
111
+ handler: async ({ agent }) => ({ kind: 'success', text: JSON.stringify(compactStatus(await inspectTeamRuntime(ctx, agent))) }),
112
+ }));
113
+ }
package/usage.js ADDED
@@ -0,0 +1,92 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Own-session usage. Durable assistant/attempt settles a failed request, so the next
3
+ // settlement is a distinct attempt even when plugin retry emits no llm/retry-started.
4
+ // Token usage per team member:
5
+ // each Assistant settlement contributes its last usage sample; a failed assistant/attempt
6
+ // and llm/retry-started close the slot. Same-slot message corrections replace it. Samples are attributed to the
7
+ // route of the latest request header, which is the route the attempt was sent to.
8
+ const BUCKETS = ['uncachedInputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'];
9
+ export const zeroUsage = () => ({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
10
+ const numeric = value => Number.isFinite(value) && value >= 0 ? Math.min(value, Number.MAX_SAFE_INTEGER) : 0;
11
+ const bucketsFrom = usage => ({ uncachedInputTokens: numeric(usage.inputTokens), outputTokens: numeric(usage.outputTokens),
12
+ cacheReadTokens: numeric(usage.cacheReadTokens), cacheWriteTokens: numeric(usage.cacheWriteTokens) });
13
+ const shift = (into, buckets, sign) => { for (const key of BUCKETS) into[key] = Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, into[key] + sign * buckets[key])); };
14
+ /** Budget-counted tokens = uncached input + cache write + output; excludes cache reads and is not a bill amount. */
15
+ export const billable = usage => (usage?.uncachedInputTokens ?? 0) + (usage?.cacheWriteTokens ?? 0) + (usage?.outputTokens ?? 0);
16
+ export function sampleOf(event) {
17
+ if (event.type === 'assistant/message' && event.data?.usage && typeof event.data.usage === 'object' && !Array.isArray(event.data.usage)) return event.data.usage;
18
+ if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return undefined;
19
+ const stream = Array.isArray(event.data?.stream) ? event.data.stream : [];
20
+ for (let i = stream.length - 1; i >= 0; i--) if (stream[i]?.type === 'chunk' && stream[i].chunk?.type === 'usage') return stream[i].chunk.usage;
21
+ return undefined;
22
+ }
23
+ export const OWN_USAGE_KEY = 'capabilityTeamUsage';
24
+ export function initOwnUsage(inherited = 0) {
25
+ return { inherited, cursor: -1, totals: zeroUsage(), routes: [], route: null, last: null, settledAttempt: false, reportedSamples: 0, attemptsWithoutUsage: 0, coverage: 'unknown', truncated: false };
26
+ }
27
+ /** Pure incremental fold. A durable attempt is a settlement, not an intermediate usage chunk. */
28
+ export function applyOwnUsage(state, event) {
29
+ if (Number.isSafeInteger(event.seq) && (event.seq < state.inherited || event.seq <= state.cursor)) return state;
30
+ if (!['request/header', 'llm/retry-started', 'assistant/attempt', 'assistant/message'].includes(event.type)) return state;
31
+ const next = { ...state, cursor: Number.isSafeInteger(event.seq) ? event.seq : state.cursor };
32
+ if (event.type === 'request/header') {
33
+ const config = event.data?.header?.config;
34
+ if (typeof config?.provider === 'string' && config.provider.length <= 128 && typeof config?.model === 'string' && config.model.length <= 512) next.route = `${config.provider}/${config.model}`;
35
+ // Headers can be deduplicated by the host. Never rely on a header alone as an attempt boundary.
36
+ return next;
37
+ }
38
+ if (event.type === 'llm/retry-started') {
39
+ if (state.last?.turn === event.data?.turn && state.last?.step === event.data?.step) next.last = null;
40
+ next.settledAttempt = false;
41
+ return next;
42
+ }
43
+ const sample = sampleOf(event);
44
+ if (!sample || typeof sample !== 'object' || Array.isArray(sample) || !['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'].some(key => typeof sample[key] === 'number')) {
45
+ next.attemptsWithoutUsage += 1;
46
+ next.last = null;
47
+ next.settledAttempt = event.type === 'assistant/attempt';
48
+ next.coverage = 'partial';
49
+ return next;
50
+ }
51
+ const totals = { ...state.totals }, routes = new Map(state.routes.map(row => [row.route, { ...row }]));
52
+ const buckets = bucketsFrom(sample), key = state.route ?? 'unknown';
53
+ if (Object.values(sample).some(value => typeof value === 'number' && (!Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER))) next.attemptsWithoutUsage += 1;
54
+ const { turn, step } = event.data;
55
+ if (!state.settledAttempt && state.last?.turn === turn && state.last?.step === step) {
56
+ shift(totals, state.last.buckets, -1);
57
+ if (routes.has(state.last.route)) shift(routes.get(state.last.route), state.last.buckets, -1);
58
+ }
59
+ if (!routes.has(key)) routes.set(key, { route: key, ...zeroUsage() });
60
+ shift(totals, buckets, 1); shift(routes.get(key), buckets, 1);
61
+ if (routes.size > 24 || BUCKETS.some(k => totals[k] === Number.MAX_SAFE_INTEGER)) next.truncated = true;
62
+ next.totals = totals; next.routes = [...routes.values()].slice(-24); next.last = { turn, step, route: key, buckets };
63
+ next.settledAttempt = event.type === 'assistant/attempt';
64
+ next.reportedSamples += 1;
65
+ next.coverage = next.attemptsWithoutUsage ? 'partial' : 'complete';
66
+ return next;
67
+ }
68
+ /** `lastRoute` is the route of this session's latest request header: the model a request was actually sent to. */
69
+ export function ownUsageView(state) {
70
+ return { schemaVersion: 1, scope: 'own-session-events', totals: state.totals, routes: state.routes, lastRoute: state.route ?? null,
71
+ reportedSamples: state.reportedSamples, attemptsWithoutUsage: state.attemptsWithoutUsage, coverage: state.coverage, truncated: state.truncated };
72
+ }
73
+ /** Usage of one session log, excluding events inherited from a parent session. */
74
+ export function foldUsage(snapshot) {
75
+ const own = snapshot.events.slice(snapshot.inheritedEventCount ?? 0).reduce(applyOwnUsage, initOwnUsage());
76
+ return { totals: own.totals, routes: own.routes.filter(u => billable(u) || u.cacheReadTokens), lastRoute: own.route ?? null,
77
+ reportedSamples: own.reportedSamples, attemptsWithoutUsage: own.attemptsWithoutUsage, coverage: own.coverage };
78
+ }
79
+ /** Own-session budget totals only. Missing/unknown usage does not block; no inherited host fallback. */
80
+ export function sessionUsage(projections, session) {
81
+ try {
82
+ const state = projections?.stateOf(session, OWN_USAGE_KEY);
83
+ return state ? state.totals ?? state : undefined;
84
+ } catch { return undefined; }
85
+ }
86
+ /** Denial for a role that has used up its per-session token budget, or undefined. */
87
+ export function budgetDenial(role, usage) {
88
+ if (!role.maxTokens || !usage) return undefined;
89
+ const used = billable(usage);
90
+ if (used < role.maxTokens) return undefined;
91
+ return modelText('usage.607', { 0: role.displayName ?? role.id, 1: role.capability, 2: used, 3: role.maxTokens });
92
+ }
package/workflow.js ADDED
@@ -0,0 +1,246 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Dispatch workflow: for each user request the Lead analyses the conversation, then either assigns
3
+ // registered roles or justifies handling it alone. A tool guard enforces the declared plan, so the
4
+ // Lead cannot silently do specialist work itself. All limits are project data (config.workflow).
5
+ import { fileLimitRules } from './file-limits.js';
6
+ import { tokenRuleText } from './token-discipline.js';
7
+ import { baseName } from './session-rotation.js';
8
+ import { arrayArg, planTasks } from './board-dispatch.js';
9
+ import { params } from './tool-schema.js';
10
+ import { accessSummary } from './access.js';
11
+
12
+ export const WORKFLOW_MODES = ['enforce', 'remind', 'off'];
13
+ const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] };
14
+
15
+ /** Classify one tool name for the Lead under the given workflow policy. */
16
+ export const FREE_TOOL = 'capability_free_task';
17
+ const FREE_TOOL_TEXT = {
18
+ none: modelText('workflow.608'),
19
+ read: modelText('workflow.609'),
20
+ write: modelText('workflow.610'),
21
+ };
22
+ const DECISION = { delegate: modelText('workflow.611'), free: modelText('workflow.612'), direct: modelText('workflow.613') };
23
+ const OWN_TOOLS = ['capability_dispatch_plan', 'capability_submit_plan', 'capability_review_plan', 'capability_task_budget', 'capability_member_override', 'capability_handoff', 'capability_rotate_member', 'capability_memory_search', 'capability_memory_write', 'capability_model_prefs'];
24
+ /** Hosts may expose tools as `mcp__<server>__<word>_<name>` (e.g. `brisk_spawn_teammate`); match on `<name>`. */
25
+ export function canonicalTool(name, workflow) {
26
+ const raw = String(name ?? '').replace(/^mcp__.+?__/, '');
27
+ const known = [FREE_TOOL, ...OWN_TOOLS, ...workflow.coordinationTools, ...workflow.analysisTools];
28
+ if (known.includes(raw)) return raw;
29
+ const tail = raw.slice(raw.indexOf('_') + 1);
30
+ return raw.includes('_') && known.includes(tail) ? tail : raw;
31
+ }
32
+ export function toolClass(name, workflow) {
33
+ name = canonicalTool(name, workflow);
34
+ if (name === FREE_TOOL) return 'free';
35
+ // The team's own planning tools always coordinate, whatever a project lists.
36
+ if (OWN_TOOLS.includes(name) || workflow.coordinationTools.includes(name)) return 'coordination';
37
+ if (workflow.analysisTools.includes(name)) return 'analysis';
38
+ return 'execution';
39
+ }
40
+
41
+ /**
42
+ * Pure decision for one Lead tool call; mutates `state` counters only when the call is allowed.
43
+ * Returns a denial reason string, or undefined to allow.
44
+ */
45
+ export function decide(state, exec, workflow) {
46
+ const kind = toolClass(exec.name, workflow);
47
+ const plan = state.plan;
48
+ const replan = modelText('workflow.614');
49
+ if (kind === 'coordination') {
50
+ if (canonicalTool(exec.name, workflow) !== 'spawn_teammate') return undefined;
51
+ const name = exec.arguments?.name;
52
+ if (!plan) return modelText('workflow.615');
53
+ if (plan.mode !== 'delegate' || !plan.assignments.some(a => a.role === name)) {
54
+ return modelText('workflow.616', { 0: name, 1: planSummary(plan), 2: replan });
55
+ }
56
+ return undefined;
57
+ }
58
+ if (!plan) {
59
+ if (kind === 'analysis' && state.analysisUsed < workflow.analysisBudget) { state.analysisUsed += 1; return undefined; }
60
+ const why = kind === 'analysis' ? modelText('workflow.617', { 0: workflow.analysisBudget }) : modelText('workflow.618', { 0: exec.name });
61
+ return modelText('workflow.619', { 0: why });
62
+ }
63
+ if (kind === 'free') {
64
+ if (plan.mode === 'free') return undefined;
65
+ return modelText('workflow.620', { 0: planSummary(plan), 1: replan });
66
+ }
67
+ if (kind === 'analysis') return undefined;
68
+ if (plan.mode === 'free') {
69
+ return modelText('workflow.621', { 0: FREE_TOOL, 1: exec.name, 2: replan });
70
+ }
71
+ if (plan.mode === 'delegate') {
72
+ return modelText('workflow.622', { 0: planSummary(plan), 1: exec.name, 2: replan });
73
+ }
74
+ // Only the user's answer on the confirmation card sets this; plan fields supplied by the model never waive the budget.
75
+ if (state.directConfirmed) return undefined;
76
+ if (state.directUsed < workflow.directBudget) { state.directUsed += 1; return undefined; }
77
+ return modelText('workflow.623', { 0: workflow.directBudget, 1: replan, 2: replan });
78
+ }
79
+
80
+ export function planSummary(plan) {
81
+ if (!plan) return modelText('workflow.624');
82
+ if (plan.mode === 'direct') return modelText('workflow.625', { 0: plan.reason });
83
+ if (plan.mode === 'free') return modelText('workflow.626', { 0: plan.reason });
84
+ return plan.assignments.map(a => `${a.displayName ?? a.role}(${a.role})`).join('、');
85
+ }
86
+
87
+ /** Validate a dispatch plan against the effective team configuration. */
88
+ export function validatePlan(args, effective) {
89
+ const text = (v, max, label) => {
90
+ if (typeof v !== 'string' || !v.trim() || v.length > max) throw new Error(modelText('workflow.627', { 0: label, 1: max }));
91
+ return v.trim();
92
+ };
93
+ const analysis = text(args.analysis, 4000, modelText('workflow.628'));
94
+ if (!['direct', 'delegate', 'free'].includes(args.mode)) throw new Error(modelText('workflow.629'));
95
+ const assignments = arrayArg(args.assignments) ?? [];
96
+ if (!Array.isArray(assignments) || assignments.length > 20) throw new Error(modelText('workflow.630'));
97
+ if (args.mode !== 'delegate') {
98
+ if (assignments.length) throw new Error(modelText('workflow.631', { 0: args.mode }));
99
+ const plan = { mode: args.mode, analysis, reason: text(args.reason, 500, modelText('workflow.632', { 0: args.mode })), assignments: [] };
100
+ // userRequested stays out of the plan: only the user's answer, held in workflow state, can lift the budget.
101
+ return plan;
102
+ }
103
+ if (!assignments.length) throw new Error(modelText('workflow.633'));
104
+ const seen = new Set();
105
+ const list = assignments.map((a, i) => {
106
+ const role = text(a?.role, 64, modelText('workflow.634', { 0: i + 1 }));
107
+ if (role === 'lead') throw new Error(modelText('workflow.635'));
108
+ const entry = effective.roles[role];
109
+ if (!entry) throw new Error(modelText('workflow.636', { 0: role, 1: Object.keys(effective.roles).filter(r => r !== 'lead').join('、') }));
110
+ if (seen.has(role)) throw new Error(modelText('workflow.637', { 0: role }));
111
+ seen.add(role);
112
+ if (a.complexity !== undefined && !['simple','medium','complex'].includes(a.complexity)) throw new Error(modelText('workflow.638'));
113
+ return { role, complexity: a.complexity ?? 'medium', displayName: entry.displayName, capability: entry.capability, task: text(a.task, 2000, modelText('workflow.639', { 0: role })) };
114
+ });
115
+ return { mode: 'delegate', analysis, ...(args.reason ? { reason: text(args.reason, 500, modelText('workflow.640')) } : {}), assignments: list };
116
+ }
117
+
118
+ import { ownsCapabilityTeam } from './preset-guard.js';
119
+ export function createWorkflow(ctx, team, { onDelegate, onAssignments, dispatchConfirm, rotation, board } = {}) {
120
+ const states = new WeakMap();
121
+ const state = agent => {
122
+ if (!states.has(agent)) states.set(agent, { plan: undefined, analysisUsed: 0, directUsed: 0 });
123
+ return states.get(agent);
124
+ };
125
+ const isLead = agent => ownsCapabilityTeam(ctx, agent) && ctx.agentTeams.tryMembership(agent)?.role === 'lead';
126
+ const effectiveOf = agent => {
127
+ const project = team.pinned(agent);
128
+ return project ? team.loadSync(project, agent).effective : team.base;
129
+ };
130
+ return {
131
+ state,
132
+ /** A new user request starts a new dispatch cycle; teammate reports keep the current plan. */
133
+ onClaimed(agent, message) {
134
+ if (message?.source?.kind !== 'user' || !isLead(agent)) return;
135
+ states.set(agent, { plan: undefined, analysisUsed: 0, directUsed: 0 });
136
+ const invalidation=onAssignments?.(agent, []);
137
+ void invalidation?.catch?.(() => {}); // a newer dispatch may supersede this invalidation
138
+ },
139
+ guard(exec) {
140
+ if (!isLead(exec.agent)) return undefined;
141
+ let effective;
142
+ try { effective = effectiveOf(exec.agent); }
143
+ catch (error) { return modelText('workflow.641', { 0: error.message }); }
144
+ const workflow = effective.workflow;
145
+ if (workflow.mode !== 'enforce') return undefined;
146
+ // A successor planned by capability_rotate_member stands in for its assigned role.
147
+ const name = exec.arguments?.name;
148
+ if (canonicalTool(exec.name, workflow) === 'spawn_teammate' && rotation?.plannedSuccessor(exec.agent.id, name)) {
149
+ const plan = state(exec.agent).plan;
150
+ if (plan?.mode === 'delegate' && plan.assignments.some(a => a.role === baseName(name))) return undefined;
151
+ }
152
+ return decide(state(exec.agent), exec, workflow);
153
+ },
154
+ /** Model-facing status line for the Lead's team context. */
155
+ status(agent, workflow) {
156
+ if (workflow.mode === 'off') return undefined;
157
+ const s = state(agent);
158
+ const head = workflow.mode === 'enforce' ? modelText('workflow.642') : modelText('workflow.643');
159
+ // Budgets only, no running counters: a counter changes every step and would resend the whole runtime
160
+ // context snapshot each time. The guard still enforces the limits and says how many were used.
161
+ const direct = s.plan?.mode !== 'direct' ? '' : s.directConfirmed ? modelText('workflow.644') : modelText('workflow.645', { 0: workflow.directBudget });
162
+ const plan = s.plan ? modelText('workflow.646', { 0: planSummary(s.plan), 1: direct })
163
+ : modelText('workflow.647', { 0: workflow.analysisBudget });
164
+ return modelText('workflow.648', { 0: head, 1: plan, 2: workflow.freeTools });
165
+ },
166
+ register() {
167
+ ctx.effect(() => ctx.tools.guard(exec => this.guard(exec)));
168
+ ctx.on('agent/inbox/claimed', ({ agent, message }) => this.onClaimed(agent, message));
169
+ ctx.effect(() => ctx.tools.register({ name: 'capability_dispatch_plan', output,
170
+ description: modelText('workflow.649'),
171
+ parameters: params({
172
+ analysis: { type: 'string', description: modelText('workflow.650') },
173
+ mode: { type: 'string', enum: ['delegate', 'free', 'direct'], description: modelText('workflow.651') },
174
+ reason: { type: 'string', description: modelText('workflow.652') },
175
+ userRequested: { type: 'boolean', description: modelText('workflow.653') },
176
+ assignments: { type: 'array', description: modelText('workflow.654'), items: params({
177
+ role: { type: 'string', description: modelText('workflow.655') },
178
+ complexity: { type: 'string', enum: ['simple','medium','complex'], description: modelText('workflow.656') },
179
+ task: { type: 'string', description: modelText('workflow.657') },
180
+ tasks: { type: 'array', description: modelText('workflow.658'), items: params({
181
+ key: { type: 'string', description: modelText('workflow.659') },
182
+ subject: { type: 'string', description: modelText('workflow.660') },
183
+ description: { type: 'string', description: modelText('workflow.661') },
184
+ write_scopes: { type: 'array', items: { type: 'string' }, description: modelText('workflow.662') },
185
+ blocked_by: { type: 'array', items: { type: 'string' }, description: modelText('workflow.663') },
186
+ }, ['subject']) },
187
+ }, ['role', 'task']) },
188
+ }, ['analysis', 'mode']),
189
+ async execute(args, exec) {
190
+ if (!isLead(exec.agent)) throw new Error(modelText('workflow.664'));
191
+ const { effective, policy } = await team.load(exec.agent);
192
+ const plan = validatePlan(args, effective);
193
+ // Board subtasks are validated before the user is asked, so a malformed split never reaches the card.
194
+ const rawTasks = plan.mode === 'delegate' ? Object.fromEntries((arrayArg(args.assignments) ?? []).filter(a => a?.tasks !== undefined).map(a => [String(a.role).trim(), a.tasks])) : {};
195
+ if (plan.mode === 'delegate') planTasks(plan.assignments, rawTasks);
196
+ // The user confirms roles and models first; a cancel throws, so no plan exists and nobody is dispatched.
197
+ const confirmation = plan.mode === 'delegate' && dispatchConfirm
198
+ ? await dispatchConfirm.confirm(exec, plan, effective, policy, effective.workflow.confirmDispatch) : null;
199
+ // Lifting the Lead's step limit is the user's call and lasts until their next message.
200
+ const cycle = state(exec.agent);
201
+ const directConfirmed = plan.mode === 'direct' && args.userRequested === true && effective.workflow.mode === 'enforce' && dispatchConfirm
202
+ ? await dispatchConfirm.confirmDirect(exec, plan) : false;
203
+ if (directConfirmed && state(exec.agent) !== cycle) throw new Error(modelText('workflow.665'));
204
+ const s = state(exec.agent);
205
+ s.plan = plan;
206
+ s.directConfirmed = directConfirmed;
207
+ if (plan.mode === 'delegate') onDelegate?.(ctx.agentTeams.membership(exec.agent), plan.assignments.map(a => a.role));
208
+ await onAssignments?.(exec.agent, plan.mode === 'delegate' ? plan.assignments : []);
209
+ // Structured result: the model reads the fields, the Web card renders them without parsing prose.
210
+ const result = { mode: plan.mode, decision: DECISION[plan.mode], analysis: plan.analysis, ...(plan.reason ? { reason: plan.reason } : {}) };
211
+ if (plan.mode === 'direct') {
212
+ Object.assign(result, directConfirmed
213
+ ? { directBudget: null, directUsed: s.directUsed, confirmed: { by: 'user', scope: 'turn' }, next: modelText('workflow.666') }
214
+ : { directBudget: effective.workflow.directBudget, directUsed: s.directUsed, next: modelText('workflow.667', { 0: effective.workflow.directBudget }) });
215
+ } else if (plan.mode === 'free') {
216
+ Object.assign(result, { freeTools: effective.workflow.freeTools,
217
+ next: modelText('workflow.668', { 0: FREE_TOOL_TEXT[effective.workflow.freeTools] }) });
218
+ } else {
219
+ result.assignments = plan.assignments.map(a => {
220
+ const c = effective.capabilities[a.capability];
221
+ const limits = accessSummary(c.access);
222
+ const route = confirmation?.routes.find(r => r.role === a.role);
223
+ const model = route?.primary ? `${route.primary.provider}/${route.primary.model}` : c.provider ? `${c.provider}/${c.model}` : modelText('workflow.669');
224
+ const fallbacks = (route?.fallbacks ?? c.fallbacks ?? []).slice(0, 3).map(f => `${f.provider}/${f.model}`);
225
+ return { role: a.role, complexity: a.complexity, estimatedSteps: {simple:15,medium:40,complex:80}[a.complexity], displayName: a.displayName, capability: a.capability, model, fallbacks, ...(limits ? { access: limits } : {}),
226
+ ...(c.planFirst ? { planFirst: modelText('workflow.670') } : {}), task: a.task };
227
+ });
228
+ result.next = modelText('workflow.671');
229
+ }
230
+ if (plan.mode !== 'free' && (effective.workflow.fileLimits || effective.workflow.tokens)) result.fileRules = [fileLimitRules(effective.workflow.fileLimits), tokenRuleText(effective.workflow.tokens)].filter(Boolean).join('\n');
231
+ if (plan.mode === 'delegate' && board) {
232
+ try {
233
+ const byRole = await board.create(exec.agent, plan, rawTasks);
234
+ if (byRole) {
235
+ for (const a of result.assignments) a.boardTasks = byRole[a.role] ?? [];
236
+ result.next = modelText('workflow.672');
237
+ }
238
+ } catch (error) { result.boardError = modelText('workflow.673', { 0: error.message }); }
239
+ }
240
+ if (confirmation?.asked) result.confirmed = { by: 'user', scope: confirmation.scope, changed: confirmation.routes.filter(r => r.changed).map(r => r.role) };
241
+ return JSON.stringify(result);
242
+ },
243
+ }));
244
+ },
245
+ };
246
+ }