@aiwuk/team 0.0.0-stage → 5.0.8

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 (117) hide show
  1. package/CHANGELOG.md +175 -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 +113 -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-models.js +84 -0
  69. package/member-task-control.js +242 -0
  70. package/memory-backends.js +89 -0
  71. package/memory-embed.js +70 -0
  72. package/memory-hub.js +140 -0
  73. package/memory-openviking.js +101 -0
  74. package/memory-redact.js +34 -0
  75. package/memory-store.js +270 -0
  76. package/memory-sync-queue.js +28 -0
  77. package/model-policy.js +85 -0
  78. package/model-prefs.js +104 -0
  79. package/observability-api.js +76 -0
  80. package/observability-projection.js +116 -0
  81. package/onboarding.js +47 -0
  82. package/opencode.js +74 -0
  83. package/package.json +155 -4
  84. package/plan-gate.js +92 -0
  85. package/preset-guard.js +47 -0
  86. package/request-evidence.js +41 -0
  87. package/roles.js +105 -0
  88. package/roster-defaults.js +70 -0
  89. package/route-health.js +39 -0
  90. package/runtime-observability.js +78 -0
  91. package/runtime-view.js +21 -0
  92. package/scripts/memory-cli.mjs +138 -0
  93. package/search-engines.js +119 -0
  94. package/search.js +164 -0
  95. package/session-policy.js +59 -0
  96. package/session-rotation.js +206 -0
  97. package/settings-schema.js +40 -0
  98. package/shared/managed-config.js +84 -0
  99. package/single-model.js +44 -0
  100. package/skills/project-role-theme/SKILL.md +46 -0
  101. package/task-bridge.js +32 -0
  102. package/task-control-api.js +36 -0
  103. package/task-control-audit.js +60 -0
  104. package/team.js +101 -0
  105. package/teammate-context.js +120 -0
  106. package/teammate-wait.js +18 -0
  107. package/template.js +46 -0
  108. package/theme-plugin.js +172 -0
  109. package/theme.js +115 -0
  110. package/token-discipline.js +116 -0
  111. package/token-metrics.js +37 -0
  112. package/tool-names.js +10 -0
  113. package/tool-schema.js +23 -0
  114. package/transcribe.js +104 -0
  115. package/ui.js +113 -0
  116. package/usage.js +96 -0
  117. package/workflow.js +246 -0
package/team.js ADDED
@@ -0,0 +1,101 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Shared runtime: pins each Team member to one project and resolves its effective role.
3
+ import { confirmedRoute, withConfirmedRoute } from './dispatch-confirm.js';
4
+ import { baseName } from './session-rotation.js';
5
+ import { resolveRole, baseConfig, withAutoRoutes } from './config.js';
6
+ import { projectRoot, loadProject, loadProjectSync, defaultRoot } from './theme.js';
7
+ import { ownsCapabilityTeam } from './preset-guard.js';
8
+ import { autoMatch } from './defaults.js';
9
+ import { applyModelPolicy } from './model-policy.js';
10
+ import { readGlobalPolicy, readGlobalPolicySync, saveGlobalPolicy } from './session-policy.js';
11
+ /**
12
+ * Detect routes from the live DSH catalog. Re-runs only when the registered provider set changes,
13
+ * so adding or removing a channel is picked up without a restart and a request costs one sync call.
14
+ * A degraded result (catalog errors or an unassigned capability) is retried at most once per
15
+ * `retryMs`, so a channel that was not ready at startup heals without a manual refresh. A retry that
16
+ * still finds nothing stays unassigned: requests remain blocked (fail-closed, no downgrade).
17
+ */
18
+ export const DETECT_RETRY_MS = 30_000;
19
+ const degraded = r => r.errors.length > 0 || r.report.some(x => x.status === 'unassigned');
20
+ export function createDetector(llm, preset, { now = Date.now, retryMs = DETECT_RETRY_MS } = {}) {
21
+ let signature; let pending; let lastAt = 0; let current = { base: preset, report: [], unknown: [], errors: [] };
22
+ const providers = () => (llm?.listProviders?.() ?? []).map(p => p.id);
23
+ const detect = async () => {
24
+ const ids = providers();
25
+ const entries = []; const errors = [];
26
+ for (const provider of ids) {
27
+ try { for (const m of await llm.listModels(provider)) entries.push({ provider, model: m.id }); }
28
+ catch (error) { errors.push({ provider, error: String(error?.message ?? error).slice(0, 200) }); }
29
+ }
30
+ const match = autoMatch(entries, preset.capabilities);
31
+ let base = preset;
32
+ try { base = withAutoRoutes(preset, match); }
33
+ catch (error) { errors.push({ provider: '*', error: modelText('team.494', { 0: error.message }) }); }
34
+ current = { base, entries, report: match.report, pools: match.pools, unknown: match.unknown, errors, detectedAt: new Date().toISOString() };
35
+ return current;
36
+ };
37
+ return {
38
+ get: () => current,
39
+ async ensure(force = false) {
40
+ // Serialize reads; an explicit refresh always re-reads models even when provider IDs are unchanged.
41
+ if (pending) await pending;
42
+ const sig = providers().join('|');
43
+ if (!force && sig === signature && !(degraded(current) && now() - lastAt >= retryMs)) return current;
44
+ signature = sig; lastAt = now();
45
+ pending = detect().finally(() => { pending = undefined; });
46
+ return pending;
47
+ },
48
+ };
49
+ }
50
+ export function createTeam(ctx, config = {}, { root = defaultRoot(), detect } = {}) {
51
+ const preset = baseConfig(config);
52
+ const detector = createDetector(ctx.llm, preset, detect);
53
+ const pins = new WeakMap();
54
+ const policyBase = policy => policy.disabledRoutes.length
55
+ ? withAutoRoutes(preset, autoMatch(detector.get().entries ?? [], preset.capabilities, { disabledRoutes: policy.disabledRoutes }))
56
+ : detector.get().base;
57
+ // Pinned once per Agent: a later cd into a subdirectory or elsewhere keeps the same project.
58
+ const pin = async agent => {
59
+ if (!pins.has(agent)) pins.set(agent, projectRoot(await ctx.workingDirectory.ensure(agent), root));
60
+ return pins.get(agent);
61
+ };
62
+ return {
63
+ get base() { return detector.get().base; },
64
+ preset, root,
65
+ detection: () => detector.get(),
66
+ refresh: () => detector.ensure(true),
67
+ owns: agent => ownsCapabilityTeam(ctx, agent) && !!ctx.agentTeams.tryMembership(agent),
68
+ pin,
69
+ pinned: agent => pins.get(agent),
70
+ async load(agent) {
71
+ await detector.ensure();
72
+ const policy = await this.policy(agent);
73
+ return { ...await loadProject(await pin(agent), policyBase(policy), root), policy };
74
+ },
75
+ // The model policy is profile-wide: every save applies to all capability-team sessions from their next request.
76
+ loadSync(project, _agent) {
77
+ const policy = readGlobalPolicySync(root);
78
+ return { ...loadProjectSync(project, policyBase(policy), root), policy };
79
+ },
80
+ async policy(_agent) {
81
+ return readGlobalPolicy(root);
82
+ },
83
+ async savePolicy(agent, disabledRoutes, expectedRevision) {
84
+ if (ctx.agentTeams.membership(agent).role !== 'lead') throw new Error(modelText('team.495'));
85
+ return saveGlobalPolicy(disabledRoutes, expectedRevision, root);
86
+ },
87
+ async resolve(agent) {
88
+ const { effective, policy } = await this.load(agent);
89
+ const membership = ctx.agentTeams.membership(agent);
90
+ const role = resolveRole(membership, effective);
91
+ // Routes are confirmed per role, so a rotation successor (<role>-rN) keeps the route the user chose.
92
+ const confirmed = membership?.role === 'teammate' && membership.root ? confirmedRoute(membership.root.id, baseName(membership.name)) : undefined;
93
+ const resolved = applyModelPolicy(withConfirmedRoute(role, confirmed), policy.disabledRoutes);
94
+ // The Lead follows the user's pick (lead-route.js), which needs its tier's models and the policy to stay same-tier.
95
+ return membership?.role === 'teammate' ? resolved
96
+ : { ...resolved, leadContext: { tierModels: [...(effective.tiers?.[role.tier] ?? [])], disabledRoutes: [...policy.disabledRoutes] } };
97
+ },
98
+ // After a save, the saving Lead moves to the configured root if it was a different directory.
99
+ repin(agent, project) { pins.set(agent, project); },
100
+ };
101
+ }
@@ -0,0 +1,120 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Keep a teammate's fixed prompt small. Measured: before any work a teammate carried ~110k characters
3
+ // (a 326-entry skill catalog of 56k, 57 tool schemas of 40k, system and runtime context), about 55k tokens.
4
+ // 1. Lead-only plugin tools are registered on the lead's agent only, teammate-only tools on teammates only.
5
+ // 2. The skill catalog DSH injects is shortened for teammates (name + one short line), or limited / dropped
6
+ // per capability (`capabilities.<id>.skills`: 'compact' | 'full' | 'none' | [names or prefixes ending in *]).
7
+ import { ownsCapabilityTeam } from './preset-guard.js';
8
+ export const LEAD_ONLY_TOOLS = new Set(['capability_dispatch_plan', 'capability_free_task', 'capability_team_save', 'capability_team_import',
9
+ 'capability_rotate_member', 'capability_review_plan', 'capability_model_prefs', 'capability_task_budget', 'capability_member_override']);
10
+ export const TEAMMATE_ONLY_TOOLS = new Set(['capability_handoff', 'capability_submit_plan']);
11
+ export const CATALOG_DESCRIPTION_CHARS = 72;
12
+ export const CATALOG_MAX_CHARS = 26000;
13
+ const isTeammateAgent = agent => agent?.session?.header?.parentSession !== undefined;
14
+ /**
15
+ * A ctx whose tools.register keeps lead-only and teammate-only tools back and installs them per agent when that
16
+ * agent is created. Everything else passes through to the host unchanged.
17
+ * Agents that already existed when the plugin loaded (or reloaded) never fire agent/created, so the scoped tools
18
+ * are also installed for every agent in ctx.agents.list() once registration finished, and lazily on the first
19
+ * agent/pre-step of an agent. Installation is idempotent per agent and tool name.
20
+ */
21
+ export function scopeTools(ctx) {
22
+ const lead = [], teammate = [];
23
+ const installed = new WeakMap();
24
+ // Registrations live on the agent's own ctx, which outlives a preset switch (a blank session reused for another
25
+ // preset is recomposed without agent/created or agent/disposed), so each one keeps its disposer and every call
26
+ // re-checks the preset: these tools must never act outside the capability-team preset.
27
+ const gated = (def, agent) => ({ ...def, async execute(args, exec) {
28
+ if (!ownsCapabilityTeam(ctx, exec?.agent ?? agent)) throw new Error(modelText('teammate-context.496'));
29
+ return def.execute(args, exec);
30
+ } });
31
+ const uninstall = agent => {
32
+ const done = installed.get(agent);
33
+ if (!done) return;
34
+ installed.delete(agent);
35
+ for (const dispose of done.values()) { try { dispose?.(); } catch { /* already removed */ } }
36
+ };
37
+ const install = agent => {
38
+ if (!agent?.ctx?.tools || !ownsCapabilityTeam(ctx, agent)) return;
39
+ const done = installed.get(agent) ?? new Map();
40
+ installed.set(agent, done);
41
+ for (const def of isTeammateAgent(agent) ? teammate : lead) {
42
+ if (done.has(def.name)) continue;
43
+ try { done.set(def.name, agent.ctx.tools.register(gated(def, agent))); } catch { done.set(def.name, undefined); /* already present for this agent */ }
44
+ }
45
+ };
46
+ const tools = new Proxy(ctx.tools, {
47
+ get(target, prop) {
48
+ if (prop === 'register') return def => {
49
+ if (LEAD_ONLY_TOOLS.has(def.name)) { lead.push(def); return () => {}; }
50
+ if (TEAMMATE_ONLY_TOOLS.has(def.name)) { teammate.push(def); return () => {}; }
51
+ return target.register(def);
52
+ };
53
+ const value = target[prop];
54
+ return typeof value === 'function' ? value.bind(target) : value;
55
+ },
56
+ });
57
+ const scoped = new Proxy(ctx, { get: (target, prop) => (prop === 'tools' ? tools : target[prop]) });
58
+ ctx.on('agent/created', ({ agent }) => { install(agent); });
59
+ ctx.on('agent/pre-step', (payload, next) => { install(payload?.agent); return next(); });
60
+ ctx.on('agent/disposed', ({ agent }) => uninstall(agent));
61
+ ctx.on('agent-preset/selected', sessionId => {
62
+ let agent; try { agent = ctx.agents?.get?.(sessionId); } catch { return; }
63
+ if (!agent) return;
64
+ if (ownsCapabilityTeam(ctx, agent)) install(agent); else uninstall(agent);
65
+ });
66
+ // Plugin tools register synchronously during apply; existing agents are backfilled right after it returns.
67
+ const installExisting = () => { let agents = []; try { agents = ctx.agents?.list?.() ?? []; } catch { /* host without agents service */ } for (const agent of agents) install(agent); };
68
+ queueMicrotask(installExisting);
69
+ return { ctx: scoped, deferred: () => ({ lead: [...lead], teammate: [...teammate] }), installExisting };
70
+ }
71
+ /** First clause of a description, at most `max` characters. */
72
+ export function shortDescription(text, max = CATALOG_DESCRIPTION_CHARS) {
73
+ const s = String(text ?? '').replace(/\s+/g, ' ').trim();
74
+ const cut = s.split(/(?<=[。.!?!?;;])\s*/)[0] ?? s;
75
+ return cut.length <= max ? cut : `${cut.slice(0, max - 1)}…`;
76
+ }
77
+ const matches = (name, rules) => rules.some(r => (r.endsWith('*') ? name.startsWith(r.slice(0, -1)) : name === r));
78
+ /** Rewrite one skill-catalog message for a policy; undefined means drop it. Entries (used for digests) are kept. */
79
+ export function rewriteCatalog(message, policy, maxChars = CATALOG_MAX_CHARS) {
80
+ if (policy === 'full' && message.content?.reduce((n, c) => n + (c.text?.length ?? 0), 0) <= maxChars) return message;
81
+ const entries = message.source?.entries ?? [];
82
+ const kept = Array.isArray(policy) ? entries.filter(e => matches(e.name, policy)) : entries;
83
+ if (policy === 'none' || (Array.isArray(policy) && !kept.length)) return undefined;
84
+ const lines = []; let used = 500;
85
+ for (const e of kept) {
86
+ const line = `- \`${e.name}\`${e.description ? `: ${shortDescription(e.description)}` : ''}`;
87
+ if (used + line.length + 1 > maxChars) continue;
88
+ lines.push(line); used += line.length + 1;
89
+ }
90
+ const text = ['<system-reminder>', 'Skills you can load with the `skill` tool (short list; load one before using it):', '', '<available_skills>', ...lines, '</available_skills>',
91
+ 'If the task clearly matches a skill, call the `skill` tool with its exact name before acting, then follow its full instructions.', '</system-reminder>'].join('\n');
92
+ return { ...message, content: [{ type: 'text', text }] };
93
+ }
94
+ /** Validate `capabilities.<id>.skills`. */
95
+ export function validateSkillsPolicy(value, label) {
96
+ if (value === undefined) return undefined;
97
+ if (['compact', 'full', 'none'].includes(value)) return value;
98
+ if (Array.isArray(value) && value.length <= 200 && value.every(v => typeof v === 'string' && /^[\w.:@/-]+\*?$/.test(v))) return [...new Set(value)];
99
+ throw new Error(modelText('teammate-context.497', { 0: label }));
100
+ }
101
+ /**
102
+ * Shorten the skill catalog for teammates. Registered with `prepend` so it is the outermost agent/pre-step
103
+ * listener and sees the catalog message the skill tool adds downstream.
104
+ */
105
+ export function applySkillCatalog(ctx, policyOf) {
106
+ ctx.on('agent/pre-step', async (payload, next) => {
107
+ const decision = await next();
108
+ if (decision?.kind === 'reject' || !ownsCapabilityTeam(ctx, payload.agent) || !isTeammateAgent(payload.agent) || !Array.isArray(decision?.messages)) return decision;
109
+ let policy;
110
+ try { policy = policyOf(payload.agent) ?? 'compact'; } catch { policy = 'compact'; }
111
+ const messages = []; let remaining = CATALOG_MAX_CHARS;
112
+ for (const m of decision.messages) {
113
+ if (m?.source?.kind !== 'skill-catalog') { messages.push(m); continue; }
114
+ if (remaining < 500) continue;
115
+ const next = rewriteCatalog(m, policy, remaining);
116
+ if (next) { messages.push(next); remaining -= next.content.reduce((n, c) => n + (c.text?.length ?? 0), 0); }
117
+ }
118
+ return { ...decision, messages };
119
+ }, { prepend: true });
120
+ }
@@ -0,0 +1,18 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Teammates never block in wait_agent. DSH's wait_agent only observes changes after the call starts, so a Lead
3
+ // message that lands while the teammate's model request is in flight is missed and the teammate idles until the
4
+ // timeout. Ending the turn instead is lossless: send_message starts or resumes an inactive teammate.
5
+ import { ownsCapabilityTeam } from './preset-guard.js';
6
+ import { restrictiveToolName } from './tool-names.js';
7
+ export const TEAMMATE_WAIT_DENIAL = modelText('teammate-wait.498');
8
+ /** Pure decision: a denial for a teammate's wait_agent call, otherwise undefined. */
9
+ export function teammateWaitDenial(exec, membership) {
10
+ if (membership?.role !== 'teammate') return undefined;
11
+ return restrictiveToolName(exec?.name, new Set(['wait_agent'])) === 'wait_agent' ? TEAMMATE_WAIT_DENIAL : undefined;
12
+ }
13
+ export function applyTeammateWait(ctx) {
14
+ ctx.effect(() => ctx.tools.guard(exec => {
15
+ if (!ownsCapabilityTeam(ctx, exec.agent)) return undefined;
16
+ return teammateWaitDenial(exec, ctx.agentTeams.tryMembership(exec.agent));
17
+ }));
18
+ }
package/template.js ADDED
@@ -0,0 +1,46 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Portable team templates: roles, capability duties and policies, workflow and failover. Channels, models, backups
3
+ // and long-context routes belong to one user's DSH catalog, so they are never exported; after an import, automatic
4
+ // matching (or a later save) assigns them.
5
+ export const TEMPLATE_FORMAT = 'capability-team-template';
6
+ export const TEMPLATE_VERSION = 1;
7
+ const PORTABLE = ['job', 'whenToUse', 'tier', 'search', 'prefer', 'access', 'maxTokens', 'planFirst'];
8
+ const isMap = v => !!v && typeof v === 'object' && !Array.isArray(v);
9
+ const pick = (value, keys) => Object.fromEntries(keys.filter(k => value[k] !== undefined).map(k => [k, value[k]]));
10
+ /** Template of one effective configuration. */
11
+ export function toTemplate(effective) {
12
+ return {
13
+ format: TEMPLATE_FORMAT, version: TEMPLATE_VERSION, name: effective.name,
14
+ workflow: effective.workflow, failover: effective.failover,
15
+ capabilities: Object.fromEntries(Object.entries(effective.capabilities).map(([id, c]) => [id, pick(c, PORTABLE)])),
16
+ roles: effective.roles,
17
+ };
18
+ }
19
+ /**
20
+ * Save patch that makes a project's team match a template: capability fields merge (keeping routes), roles are
21
+ * replaced, and a template capability field that is absent clears the project's value back to the default.
22
+ */
23
+ export function templatePatch(template, current) {
24
+ if (!isMap(template) || template.format !== TEMPLATE_FORMAT) throw new Error(modelText('template.499', { 0: TEMPLATE_FORMAT }));
25
+ if (template.version !== TEMPLATE_VERSION) throw new Error(modelText('template.500', { 0: template.version, 1: TEMPLATE_VERSION }));
26
+ const extra = Object.keys(template).filter(k => !['format', 'version', 'name', 'workflow', 'failover', 'capabilities', 'roles'].includes(k));
27
+ if (extra.length) throw new Error(modelText('template.501', { 0: extra.join(', ') }));
28
+ if (!isMap(template.capabilities) || !isMap(template.roles)) throw new Error(modelText('template.502'));
29
+ if (!isMap(template.roles.lead)) throw new Error(modelText('template.503'));
30
+ const capabilities = {};
31
+ for (const [id, c] of Object.entries(template.capabilities)) {
32
+ if (!isMap(c)) throw new Error(modelText('template.504', { 0: id }));
33
+ const unknown = Object.keys(c).filter(k => !PORTABLE.includes(k));
34
+ if (unknown.length) throw new Error(modelText('template.505', { 0: id, 1: unknown.join(', ') }));
35
+ // Fields the template leaves out are cleared so the project ends up matching the template.
36
+ const cleared = { search: false, prefer: [] };
37
+ capabilities[id] = Object.fromEntries(PORTABLE.map(k => [k, c[k] ?? (k in cleared ? cleared[k] : null)]));
38
+ }
39
+ const roles = { ...template.roles };
40
+ for (const id of Object.keys(current.roles)) if (!(id in roles)) roles[id] = false;
41
+ return {
42
+ ...(template.name === undefined ? {} : { name: template.name }),
43
+ workflow: template.workflow ?? null, failover: template.failover ?? null,
44
+ capabilities, roles,
45
+ };
46
+ }
@@ -0,0 +1,172 @@
1
+ import { m as modelText, t } from './i18n.js';
2
+ import { readFileSync } from 'node:fs';
3
+ import { roleView, describeChanges, resolveRole, seed } from './config.js';
4
+ import { modelCatalog, changedRoutes, verifyRoutes } from './allocation.js';
5
+ import { saveProject } from './theme.js';
6
+ import { params } from './tool-schema.js';
7
+ import { registerTeamReader, registerPolicyWriter } from './runtime-view.js';
8
+ import { modelPolicyRows, applyModelPolicy, policyRoleView } from './model-policy.js';
9
+ import { accessSummary } from './access.js';
10
+ import { toTemplate, templatePatch, TEMPLATE_FORMAT } from './template.js';
11
+ import { ROLE_THEMES, themePatch } from './roster-defaults.js';
12
+ import { singleModelFrom } from './single-model.js';
13
+ const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] };
14
+ const ENTRY_RULE = modelText('theme-plugin.506');
15
+ /** Model-facing team context for one Agent, rebuilt from the pinned project on every step. */
16
+ export function teamContext(team, agent, membership, failover, workflow, planGate) {
17
+ const project = team.pinned(agent);
18
+ if (!project) return modelText('theme-plugin.507');
19
+ let loaded;
20
+ try { loaded = team.loadSync(project, agent); }
21
+ catch (error) { return modelText('theme-plugin.508', { 0: error.message }); }
22
+ let self;
23
+ try {
24
+ const { failover: _policy, access, ...role } = applyModelPolicy(resolveRole(membership, loaded.effective), loaded.policy?.disabledRoutes);
25
+ self = role;
26
+ const limits = accessSummary(access);
27
+ if (limits) self.access = modelText('theme-plugin.509', { 0: limits });
28
+ const planState = planGate?.status(agent, membership, role);
29
+ if (planState) self.plan = planState;
30
+ const live = failover?.status(agent);
31
+ const allowed = [role, ...(role.fallbacks ?? [])].map(r => `${r.provider}/${r.model}`);
32
+ const wide = role.longContext ? `${role.longContext.provider}/${role.longContext.model}` : undefined;
33
+ if (live?.active && live.active === wide) self.currentRoute = modelText('theme-plugin.510', { 0: wide });
34
+ else if (live && !live.primary && allowed.includes(live.active)) self.currentRoute = modelText('theme-plugin.511', { 0: live.active });
35
+ } catch (error) { self = { error: error.message }; }
36
+ const roster = roleView(loaded.effective).map(entry => {
37
+ try {
38
+ const { id, displayName, capability, whenToUse, provider, model, access, singleModel } = applyModelPolicy(entry, loaded.policy?.disabledRoutes);
39
+ const limits = accessSummary(access);
40
+ return { id, displayName, capability, ...(whenToUse ? { whenToUse } : {}), model: provider ? `${provider}/${model}${singleModel ? modelText('theme-plugin.512') : ''}` : modelText('theme-plugin.513'), ...(limits ? { access: limits } : {}) };
41
+ } catch (error) { return { id: entry.id, displayName: entry.displayName, model: modelText('theme-plugin.514'), error: error.message }; }
42
+ });
43
+ const status = membership?.role === 'lead' ? workflow?.status(agent, loaded.effective.workflow) : undefined;
44
+ return modelText('theme-plugin.515', { 0: loaded.effective.name, 1: loaded.revision, 2: project, 3: JSON.stringify(self), 4: JSON.stringify(roster), 5: status ? `\n${status}` : '' });
45
+ }
46
+ async function confirm(ctx, exec, lines, revision) {
47
+ const answer = await ctx.userQuestions.ask({ agent: exec.agent, signal: exec.signal, questions: [{
48
+ id: 'confirm', header: t('theme-plugin.516'),
49
+ question: t('theme-plugin.517', { 0: lines.length }),
50
+ detail: t('theme-plugin.518', { 0: lines.join('\n'), 1: revision }),
51
+ options: [{ label: t('theme-plugin.519'), description: t('theme-plugin.520') }, { label: t('theme-plugin.521'), description: t('theme-plugin.522') }],
52
+ }] });
53
+ const item = answer.answers.find(a => a.id === 'confirm');
54
+ if (!item?.selected.includes(t('theme-plugin.523'))) throw new Error(modelText('theme-plugin.524', { 0: item?.custom ? modelText('theme-plugin.525', { 0: item.custom }) : '' }));
55
+ }
56
+ export function apply(ctx, team, failover, workflow, planGate, taskControl) {
57
+ const content = readFileSync(new URL('./skills/project-role-theme/SKILL.md', import.meta.url), 'utf8');
58
+ ctx.effect(() => ctx.skills.register({ name: 'project-role-theme', source: 'runtime', content,
59
+ description: modelText('theme-plugin.526') }));
60
+ ctx.effect(() => ctx.tools.register({ name: 'capability_team_read', parameters: params(), output,
61
+ description: modelText('theme-plugin.527'),
62
+ async execute(_args, exec) {
63
+ await team.refresh();
64
+ const { project, revision, overrides, effective, policy } = await team.load(exec.agent);
65
+ const { report, unknown, errors, detectedAt } = team.detection();
66
+ return JSON.stringify({ project, revision, defaults: team.base, overrides, effective, roles: policyRoleView(roleView(effective), policy.disabledRoutes), coolingRoutes: failover?.cooling() ?? [],
67
+ autoMatch: { detectedAt, report, unrecognized: unknown.slice(0, 50), unrecognizedCount: unknown.length, errors } });
68
+ },
69
+ }));
70
+ ctx.effect(() => ctx.tools.register({ name: 'capability_model_catalog', parameters: params(), output,
71
+ description: modelText('theme-plugin.528'),
72
+ async execute(_args, exec) {
73
+ const { effective } = await team.load(exec.agent);
74
+ return JSON.stringify(await modelCatalog(ctx.llm, effective.tiers));
75
+ },
76
+ }));
77
+ ctx.effect(() => ctx.tools.register({ name: 'capability_team_save', output,
78
+ description: modelText('theme-plugin.529'),
79
+ parameters: params({
80
+ expected_revision: { type: 'integer', description: modelText('theme-plugin.530') },
81
+ name: { type: 'string', description: modelText('theme-plugin.531') },
82
+ tiers: { type: 'object', additionalProperties: true, description: modelText('theme-plugin.532', { 0: ENTRY_RULE }) },
83
+ capabilities: { type: 'object', additionalProperties: true, description: modelText('theme-plugin.533', { 0: ENTRY_RULE }) },
84
+ failover: { oneOf: [{ type: 'object', additionalProperties: true }, { type: 'null' }], description: modelText('theme-plugin.534') },
85
+ workflow: { oneOf: [{ type: 'object', additionalProperties: true }, { type: 'null' }], description: modelText('theme-plugin.535') },
86
+ roles: { type: 'object', additionalProperties: true, description: modelText('theme-plugin.536', { 0: ENTRY_RULE }) },
87
+ single_model: { type: 'string', enum: ['use-default', 'off'], description: modelText('theme-plugin.537') },
88
+ }, ['expected_revision']),
89
+ async execute(args, exec) {
90
+ const singleModel = args.single_model === 'off' ? null : args.single_model === 'use-default' ? singleModelFrom(ctx.get?.('agentDefaultModel')?.currentSelection?.()) : undefined;
91
+ const patch = { name: args.name, failover: args.failover, workflow: args.workflow, tiers: args.tiers, capabilities: args.capabilities, roles: args.roles, singleModel };
92
+ return JSON.stringify(await saveConfirmed(exec, patch, args.expected_revision));
93
+ },
94
+ }));
95
+ /** Validate, check new routes against the catalog, and save only after the user approves the full diff. */
96
+ async function saveConfirmed(exec, patch, expectedRevision) {
97
+ if (ctx.agentTeams.membership(exec.agent).role !== 'lead') throw new Error(modelText('theme-plugin.538'));
98
+ const project = await team.pin(exec.agent);
99
+ const result = await saveProject(project, patch, expectedRevision, { base: team.base, root: team.root,
100
+ async check(before, after) {
101
+ const lines = describeChanges(before, after);
102
+ if (!lines.length) throw new Error(modelText('theme-plugin.539'));
103
+ const routes = changedRoutes(before, after);
104
+ const single = after.singleModel && `${after.singleModel.provider}/${after.singleModel.model}` !== `${before.singleModel?.provider}/${before.singleModel?.model}`
105
+ ? [{ capability: modelText('theme-plugin.540'), ...after.singleModel }] : [];
106
+ if (routes.length || single.length) verifyRoutes([...routes, ...single], await modelCatalog(ctx.llm, after.tiers));
107
+ await confirm(ctx, exec, lines, expectedRevision);
108
+ } });
109
+ return { saved: true, project: result.project, revision: result.revision, roles: roleView(result.effective) };
110
+ }
111
+ ctx.effect(() => ctx.tools.register({ name: 'capability_team_export', parameters: params(), output,
112
+ description: modelText('theme-plugin.541', { 0: TEMPLATE_FORMAT }),
113
+ async execute(_args, exec) {
114
+ const { effective } = await team.load(exec.agent);
115
+ return JSON.stringify(toTemplate(effective), null, 2);
116
+ },
117
+ }));
118
+ ctx.effect(() => ctx.tools.register({ name: 'capability_team_import', output,
119
+ description: modelText('theme-plugin.542', { 0: Object.keys(ROLE_THEMES).join(' / ') }),
120
+ parameters: params({
121
+ expected_revision: { type: 'integer', description: modelText('theme-plugin.543') },
122
+ template: { type: 'object', additionalProperties: true, description: modelText('theme-plugin.544', { 0: TEMPLATE_FORMAT }) },
123
+ theme: { type: 'string', enum: Object.keys(ROLE_THEMES), description: modelText('theme-plugin.545') },
124
+ }, ['expected_revision']),
125
+ async execute(args, exec) {
126
+ const { effective } = await team.load(exec.agent);
127
+ if ((args.template === undefined) === (args.theme === undefined)) throw new Error(modelText('theme-plugin.546'));
128
+ const patch = args.theme === undefined ? templatePatch(args.template, effective) : themePatch(args.theme, effective);
129
+ const result = await saveConfirmed(exec, patch, args.expected_revision);
130
+ const unassigned = result.roles.filter(r => r.provider === undefined).map(r => r.id);
131
+ return JSON.stringify({ ...result, ...(unassigned.length ? { unassigned, next: modelText('theme-plugin.547') } : {}) });
132
+ },
133
+ }));
134
+ // Install the team context in each Team member's own scope, like the native Team identity.
135
+ const installed = new Map();
136
+ const install = async agent => {
137
+ if (installed.has(agent) || !ownsCapabilityTeam(ctx, agent) || ctx.agentTeams.tryMembership(agent) === undefined) return;
138
+ installed.set(agent, undefined);
139
+ // Pin before the first step; a failure must not block creation and resurfaces on the next request.
140
+ await team.pin(agent).catch(() => {});
141
+ if (!installed.has(agent)) return; // disposed meanwhile
142
+ const removeReader = registerTeamReader(agent, async () => {
143
+ await team.refresh();
144
+ const { project, revision, effective, policy } = await team.load(agent);
145
+ return { project, revision, name: effective.name, ...(effective.singleModel ? { singleModel: effective.singleModel } : {}), roles: policyRoleView(roleView(effective), policy.disabledRoutes), workflow: effective.workflow,
146
+ modelPolicy: { ...modelPolicyRows(effective.tiers, roleView(effective), policy.disabledRoutes, [...(team.detection().pools ?? []), ...(team.detection().unknown ?? [])]), revision: policy.revision },
147
+ autoMatch: team.detection().report, catalogErrors: team.detection().errors ?? [], actualRequest: null,
148
+ ...(effective.tierDrift ? { tierDrift: effective.tierDrift } : {}),
149
+ modelNotice: t('theme-plugin.548') };
150
+ });
151
+ const removeContext = agent.ctx.systemPrompt.context({ name: 'capability-team:roster', order: 98, required: true,
152
+ text: () => (ownsCapabilityTeam(ctx, agent) ? teamContext(team, agent, ctx.agentTeams.tryMembership(agent), failover, workflow, planGate) : '') });
153
+ const removeBudgetContext = taskControl && ctx.agentTeams.tryMembership(agent)?.role === 'lead'
154
+ ? agent.ctx.systemPrompt.context({ name: 'capability-team:task-budget', order: 99, required: true,
155
+ text: () => (ownsCapabilityTeam(ctx, agent) ? taskControl.summary(agent) : '') }) : () => {};
156
+ const removeWriter = registerPolicyWriter(agent, (routes, revision) => team.savePolicy(agent, routes, revision));
157
+ installed.set(agent, () => { removeReader(); removeWriter(); removeContext(); removeBudgetContext(); });
158
+ };
159
+ for (const agent of ctx.agents.list()) void install(agent);
160
+ ctx.on('agent/created', async ({ agent }) => { await install(agent); });
161
+ ctx.on('agent/disposed', ({ agent }) => { installed.get(agent)?.(); installed.delete(agent); });
162
+ // A blank session reused for another preset is recomposed without agent/created or agent/disposed.
163
+ ctx.on('agent-preset/selected', sessionId => {
164
+ let agent; try { agent = ctx.agents?.get?.(sessionId); } catch { return; }
165
+ if (!agent) return;
166
+ if (ownsCapabilityTeam(ctx, agent)) { void install(agent); return; }
167
+ installed.get(agent)?.(); installed.delete(agent);
168
+ });
169
+ ctx.effect(() => () => { for (const dispose of installed.values()) dispose?.(); installed.clear(); });
170
+ }
171
+ export { seed };
172
+ import { ownsCapabilityTeam } from './preset-guard.js';
package/theme.js ADDED
@@ -0,0 +1,115 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Per-project team configuration store: overrides on top of the preset base.
3
+ import { mkdir, readFile, writeFile, rename, unlink, open, stat } from 'node:fs/promises';
4
+ import { readFileSync, statSync, existsSync } from 'node:fs';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { resolve, join, dirname } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ import { effectiveConfig, migrateProject, applyPatch, seed } from './config.js';
9
+ import { pinRoster } from './roster-defaults.js';
10
+ export const STALE_LOCK_MS = 30000;
11
+ export function defaultRoot() {
12
+ return process.env.CAPABILITY_TEAM_HOME || join(homedir(), '.dsh', 'capability-team-themes');
13
+ }
14
+ function identity(dir) {
15
+ const project = resolve(dir);
16
+ return process.platform === 'win32' ? project.toLowerCase() : project;
17
+ }
18
+ export function themePath(cwd, root = defaultRoot()) {
19
+ return join(root, createHash('sha256').update(identity(cwd)).digest('hex') + '.json');
20
+ }
21
+ /** Nearest ancestor (inclusive) that already owns a configuration, else the directory itself. */
22
+ export function projectRoot(cwd, root = defaultRoot()) {
23
+ let dir = resolve(cwd);
24
+ for (;;) {
25
+ if (existsSync(themePath(dir, root))) return dir;
26
+ const parent = dirname(dir);
27
+ if (parent === dir) return resolve(cwd);
28
+ dir = parent;
29
+ }
30
+ }
31
+ const cache = new Map();
32
+ function parse(raw, path) {
33
+ try { return migrateProject(JSON.parse(raw)); }
34
+ catch (error) { throw new Error(modelText('theme.549', { 0: path, 1: error.message })); }
35
+ }
36
+ const EMPTY = Object.freeze({ revision: 0, tiers: {}, capabilities: {}, roles: {} });
37
+ function remember(path, info, raw) {
38
+ const value = parse(raw, path);
39
+ cache.set(path, { mtimeMs: info.mtimeMs, size: info.size, value });
40
+ return value;
41
+ }
42
+ function cached(path, info) {
43
+ const hit = cache.get(path);
44
+ return hit && hit.mtimeMs === info.mtimeMs && hit.size === info.size ? hit.value : undefined;
45
+ }
46
+ /** Read stored overrides; cached by mtime and size so a request costs one stat. */
47
+ export async function readOverrides(project, root = defaultRoot()) {
48
+ const path = themePath(project, root);
49
+ let info;
50
+ try { info = await stat(path); } catch (error) { if (error.code === 'ENOENT') { cache.delete(path); return EMPTY; } throw error; }
51
+ return cached(path, info) ?? remember(path, info, await readFile(path, 'utf8'));
52
+ }
53
+ /** Synchronous variant for prompt assembly, which cannot await. */
54
+ export function readOverridesSync(project, root = defaultRoot()) {
55
+ const path = themePath(project, root);
56
+ let info;
57
+ try { info = statSync(path); } catch (error) { if (error.code === 'ENOENT') { cache.delete(path); return EMPTY; } throw error; }
58
+ return cached(path, info) ?? remember(path, info, readFileSync(path, 'utf8'));
59
+ }
60
+ export async function loadProject(project, base = seed, root) {
61
+ const overrides = await readOverrides(project, root);
62
+ return { project, revision: overrides.revision, overrides, effective: effectiveConfig(overrides, base, { trustedFrom: overrides }) };
63
+ }
64
+ export function loadProjectSync(project, base = seed, root) {
65
+ const overrides = readOverridesSync(project, root);
66
+ return { project, revision: overrides.revision, overrides, effective: effectiveConfig(overrides, base, { trustedFrom: overrides }) };
67
+ }
68
+ function alive(pid) {
69
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
70
+ }
71
+ export async function acquireLock(lockPath) {
72
+ for (let attempt = 0; attempt < 2; attempt++) {
73
+ try {
74
+ const handle = await open(lockPath, 'wx');
75
+ try { await handle.writeFile(JSON.stringify({ pid: process.pid, at: Date.now() })); }
76
+ catch (error) { await handle.close(); await unlink(lockPath); throw error; }
77
+ return handle;
78
+ } catch (error) {
79
+ if (error.code !== 'EEXIST') throw error;
80
+ let owner = {};
81
+ try { owner = JSON.parse(await readFile(lockPath, 'utf8')); } catch { /* empty or partial lock: judged by file age */ }
82
+ const since = Number(owner.at) || (await stat(lockPath).catch(() => ({ mtimeMs: Date.now() }))).mtimeMs;
83
+ const knownOwner = Number.isSafeInteger(owner.pid) && owner.pid > 0;
84
+ // A confirmation dialog can stay open for minutes. Age never invalidates a live owner's lock.
85
+ const stale = knownOwner ? !alive(owner.pid) : Date.now() - since > STALE_LOCK_MS;
86
+ if (attempt || !stale) throw new Error(modelText('theme.550'));
87
+ await unlink(lockPath).catch(e => { if (e.code !== 'ENOENT') throw e; });
88
+ }
89
+ }
90
+ }
91
+ /**
92
+ * Apply one patch under a lock with revision CAS; the merged result must validate.
93
+ * `check(before, after)` may reject, for example on a catalog or confirmation failure.
94
+ */
95
+ export async function saveProject(project, patch, expectedRevision, { base = seed, root = defaultRoot(), check } = {}) {
96
+ const path = themePath(project, root);
97
+ await mkdir(dirname(path), { recursive: true });
98
+ const lock = await acquireLock(path + '.lock');
99
+ const tmp = path + '.' + randomUUID() + '.tmp';
100
+ try {
101
+ const previous = await loadProject(project, base, root);
102
+ if (!Number.isSafeInteger(expectedRevision) || previous.revision !== expectedRevision) throw new Error(modelText('theme.551', { 0: previous.revision }));
103
+ const nextOverrides = pinRoster(applyPatch(previous.overrides, patch), previous.overrides, base.roles);
104
+ const effective = effectiveConfig(nextOverrides, base, { trustedFrom: previous.overrides });
105
+ if (check) await check(previous.effective, effective);
106
+ const stored = { ...nextOverrides, revision: previous.revision + 1, project: resolve(project) };
107
+ await writeFile(tmp, JSON.stringify(stored, null, 2), { encoding: 'utf8', flag: 'wx' });
108
+ await rename(tmp, path);
109
+ cache.delete(path);
110
+ return { project: resolve(project), revision: stored.revision, effective };
111
+ } finally {
112
+ try { await unlink(tmp).catch(e => { if (e.code !== 'ENOENT') throw e; }); }
113
+ finally { try { await lock.close(); } finally { await unlink(path + '.lock').catch(e => { if (e.code !== 'ENOENT') throw e; }); } }
114
+ }
115
+ }