tascan-mcp 3.16.1 → 3.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -288,6 +288,12 @@ API keys are scoped to your organization and support rate limiting (60 requests/
288
288
 
289
289
  ## Changelog
290
290
 
291
+ ### v3.17.0 — 2026-09-28
292
+ - **Fan-out (phase 1b), 101 tools.** Three new tools over the phase 1a REST surface (`/coord/fanouts`, migration 224): `tascan_create_fanout` (1-30 RESEARCH seats plus one synthesizer under ONE cycle root T_F — zero Decision cards, zero Parked cards, zero pages per seat; requires `agent:dispatch:code`; fails closed `fanout_ceiling_unset` until the org owner sets a spending ceiling), `tascan_get_fanout` (`view=status|report|rollup|verify`, read tier), `tascan_control_fanout` (`pause|resume|cancel|close_barrier` — no `amend`, no `answer` in phase 1; requires `agent:dispatch:code`). `dry_run` on create is forwarded to the route as-is; the route has no server-side preflight for this path yet, so it does not yet prevent a real create — see the tool description.
293
+
294
+ ### v3.16.2 — 2026-09-28
295
+ - 98 tools. Rolls up 3.16.0 to 3.16.2: `tascan_get_usage` (GET /usage, per-key daily actions and rate-limit headers, migration 220); the device control plane read/revoke tools (`tascan_list_devices`, `tascan_get_device`, `tascan_revoke_device`; register and rotate stay admin-app only by design); `tascan_delegate_to_agent` (child keys with a TTL of 60 s to 24 h clamped to the parent, non-delegable flags refused, the plaintext returned once) and the per-key action budgets that fail closed (migration 221). Hosted MCP at https://app.tascan.io/mcp already serves this version; this release brings the stdio package up to it.
296
+
291
297
  ### v3.15.0 — 2026-09-19
292
298
 
293
299
  - `tascan_create_cycle` gains an optional `reviews[]` array — a multi-lens review panel (design item 14a): 1–8 lenses, each `{lens, brief, provider: openai|anthropic|gemini, model?, blocking? (default true), max_tool_calls? (openai only)}`, forwarded to `POST /coord/cycles`; at least one lens must be blocking. Omit it for the single OpenAI review.
package/index.js CHANGED
@@ -53,7 +53,7 @@ async function api(method, path, body, idemKey) {
53
53
  api.key = API_KEY; // raw-fetch tools read the key from here (S99: those functions now require auth)
54
54
 
55
55
  const server = new Server(
56
- { name: 'tascan', version: '3.16.1' },
56
+ { name: 'tascan', version: '3.16.2' },
57
57
  { capabilities: { tools: {} } }
58
58
  );
59
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tascan-mcp",
3
- "version": "3.16.1",
3
+ "version": "3.17.0",
4
4
  "mcpName": "io.github.snowbikemike/tascan-mcp",
5
5
  "description": "TaScan MCP Server — Closed-loop autonomous operations protocol. 70 tools for projects, tasks, workers, QR codes, NFC tags, worker marketplace with consented SMS invites, geofenced zones with rules (work site / hazard + AI-verified PPE checkpoints / containment / restricted, breach alerts, zone compliance audit), AI condition assessment, worker passports, verification-gated gig payments, client invoicing from verified work (overtime / day rate / per diem / expenses), shareable report links (completion, client service report, project, evidence pack), signed Action Receipts (Ed25519 JWS), cross-entity search, duplicate-worker detection, worker identity merge, AI issue analysis, and autonomous remediation dispatch. 15 patents filed (470+ claims). Task. Scan. Done.",
6
6
  "type": "module",
@@ -15,6 +15,7 @@
15
15
  "index.js",
16
16
  "tools.cjs",
17
17
  "tools-devices.cjs",
18
+ "tools-fanout.cjs",
18
19
  "scopes.cjs",
19
20
  "README.md",
20
21
  "LICENSE"
package/scopes.cjs CHANGED
@@ -34,7 +34,13 @@ const DISPATCH_CODE = 'agent:dispatch:code';
34
34
  // requires full, and unreachable through OAuth (mcp-oauth.js's consent flow passes no device flag, so clamp()
35
35
  // below never grants it to a connector token even though clamp is capable of granting it to an owner).
36
36
  const DEVICE_ADMIN = 'device:admin';
37
- const SCOPE_VALUES = ['read', 'write', 'full', DISPATCH, DISPATCH_CODE, DEVICE_ADMIN];
37
+ // B2 (migration 221): delegate — a FOURTH additive permission, never implied by a tier — owner-only, requires
38
+ // full (like device:admin), and unreachable through OAuth (mcp-oauth.js's consent flow passes no delegate flag,
39
+ // so clamp() below never grants it to a connector token even though clamp is capable of granting it to an owner).
40
+ // A key holding it may mint time-limited, narrower child keys (coord_delegate_key) — read/write only, never a
41
+ // wider or lateral grant (I-6).
42
+ const DELEGATE = 'delegate';
43
+ const SCOPE_VALUES = ['read', 'write', 'full', DISPATCH, DISPATCH_CODE, DEVICE_ADMIN, DELEGATE];
38
44
  const ALL_SCOPES = SCOPE_VALUES.slice();
39
45
 
40
46
  // Seed agent inbox (tools.cjs AGENT_REGISTRY, agent-execute, agent.js, migrations 046/137/151).
@@ -74,23 +80,25 @@ function dispatchKind(titles) {
74
80
  // (P0 / migration 216): a legacy row minted before this permission existed must not silently gain it.
75
81
  // null / undefined / [] → read, no dispatch, no device_admin (FAIL CLOSED)
76
82
  function normalize(v) {
77
- if (v == null) return { tier: 'read', dispatch: false, code: false, device_admin: false, legacy: false };
83
+ if (v == null) return { tier: 'read', dispatch: false, code: false, device_admin: false, delegate: false, legacy: false };
78
84
  const a = Array.isArray(v) ? v.map(String) : String(v).split(/[\s+,]+/).filter(Boolean);
79
- if (a.includes('*')) return { tier: 'full', dispatch: true, code: true, device_admin: true, legacy: true };
80
- if (a.includes('all')) return { tier: 'full', dispatch: true, code: true, device_admin: false, legacy: true };
85
+ if (a.includes('*')) return { tier: 'full', dispatch: true, code: true, device_admin: true, delegate: true, legacy: true };
86
+ if (a.includes('all')) return { tier: 'full', dispatch: true, code: true, device_admin: false, delegate: false, legacy: true };
81
87
  let tier = 'read';
82
88
  for (const t of TIERS) if (a.includes(t) && RANK[t] > RANK[tier]) tier = t;
83
89
  const dispatch = a.includes(DISPATCH);
84
90
  // code without dispatch is not a state the consent page or key UI can produce; treat as dispatch too
85
91
  const code = a.includes(DISPATCH_CODE);
86
92
  const deviceAdmin = a.includes(DEVICE_ADMIN);
87
- return { tier, dispatch: dispatch || code, code, device_admin: deviceAdmin, legacy: false };
93
+ const delegate = a.includes(DELEGATE);
94
+ return { tier, dispatch: dispatch || code, code, device_admin: deviceAdmin, delegate, legacy: false };
88
95
  }
89
96
  function toArray(n) {
90
97
  const out = TIERS.slice(0, RANK[n.tier] || 1);
91
98
  if (n.dispatch || n.code) out.push(DISPATCH);
92
99
  if (n.code) out.push(DISPATCH_CODE);
93
100
  if (n.device_admin) out.push(DEVICE_ADMIN);
101
+ if (n.delegate) out.push(DELEGATE);
94
102
  return out;
95
103
  }
96
104
  function toScopeString(n) { return toArray(n).join(' '); }
@@ -133,7 +141,7 @@ function echoScope(str) {
133
141
  // P0 (migration 216): device_admin is accepted here for completeness and unit-tested (a non-owner clamp drops
134
142
  // it), but the ONLY real caller — mcp-oauth.js's OAuth consent flow — never passes a device flag in `pick` at
135
143
  // all, so a connector token can never hold device:admin regardless of the granting admin's role.
136
- function clamp({ tier, dispatch, code, device_admin }, role) {
144
+ function clamp({ tier, dispatch, code, device_admin, delegate }, role) {
137
145
  const t = TIERS.includes(tier) ? tier : 'read';
138
146
  const owner = role === 'owner';
139
147
  const clamped = [];
@@ -144,7 +152,11 @@ function clamp({ tier, dispatch, code, device_admin }, role) {
144
152
  else if (!!code && !gc) clamped.push(DISPATCH_CODE); // ticked code without dispatch
145
153
  let gda = !!device_admin && owner;
146
154
  if (!!device_admin && !gda) clamped.push(DEVICE_ADMIN);
147
- return { granted: { tier: gt, dispatch: gd, code: gc, device_admin: gda, legacy: false }, clamped };
155
+ // B2 (migration 221): delegate is owner-only AND requires the final (already-clamped) tier to be full — the
156
+ // same "only an owner-held, full-tier key may ever mint a delegation" rule 221's own CHECK enforces in storage.
157
+ let gdel = !!delegate && owner && gt === 'full';
158
+ if (!!delegate && !gdel) clamped.push(DELEGATE);
159
+ return { granted: { tier: gt, dispatch: gd, code: gc, device_admin: gda, delegate: gdel, legacy: false }, clamped };
148
160
  }
149
161
 
150
162
  // POST /api/v1/keys body.scopes → canonical array or null (rejects 'all', '*', junk, empty,
@@ -154,6 +166,7 @@ function validateStored(arr) {
154
166
  if (!arr.every(s => typeof s === 'string' && SCOPE_VALUES.includes(s))) return null;
155
167
  if (arr.includes(DISPATCH_CODE) && !arr.includes(DISPATCH)) return null;
156
168
  if (arr.includes(DEVICE_ADMIN) && !arr.includes('full')) return null;
169
+ if (arr.includes(DELEGATE) && !arr.includes('full')) return null;
157
170
  return toArray(normalize(arr));
158
171
  }
159
172
 
@@ -171,6 +184,7 @@ function check(granted, need) {
171
184
  if (need.human_only) return { code: 'human_session_required', required: 'human_session_required', granted_scope: toScopeString(g) };
172
185
  if ((RANK[g.tier] || 0) < (RANK[need.tier] || 99)) return deny(need.tier);
173
186
  if (need.device_admin && !g.device_admin) return deny(DEVICE_ADMIN);
187
+ if (need.delegate && !g.delegate) return deny(DELEGATE);
174
188
  if (need.dispatch === 'code' && !g.code) return deny(DISPATCH_CODE);
175
189
  if (need.dispatch === true && !g.dispatch) return deny(DISPATCH);
176
190
  return null;
@@ -205,6 +219,11 @@ const TOOL_SCOPES = {
205
219
  tascan_post_message: W,
206
220
  // creating a cycle queues a CODE:/SHELL: prompt on the AI Inbox → agent:dispatch:code, always
207
221
  tascan_create_cycle: { tier: 'write', dispatch: 'code' },
222
+ // Fan-out phase 1b (2026-09-28): three tools over coord-fanout-lib.js's REST routes — same shapes as
223
+ // ROUTE_SCOPES' coord_fanouts / coord_fanout / coord_fanout_control below (:363-365).
224
+ tascan_create_fanout: { tier: 'write', dispatch: 'code' },
225
+ tascan_get_fanout: R,
226
+ tascan_control_fanout: { tier: 'write', dispatch: 'code' },
208
227
  // M1 (2026-09-23): ten tools wrapping routes that already existed in api-v1.js / coord-dispatch-lib.js /
209
228
  // receipt.js but had no MCP tool. tascan_verify_receipt: receipt.js's POST /receipts/verify is a PUBLIC
210
229
  // route (no scope check at all inside receipt.js) — read is the floor because mcp-endpoint.js still
@@ -250,7 +269,14 @@ const TOOL_SCOPES = {
250
269
  tascan_list_devices: R, tascan_get_device: R,
251
270
  tascan_revoke_device: { tier: 'full', device_admin: true },
252
271
  // B1 (migration 220): GET /usage wrapped as a tool — read tier, same as every other read-only usage/analytics tool.
253
- tascan_get_usage: R
272
+ tascan_get_usage: R,
273
+ // B2 (migration 221): per-key budgets + delegated child keys. tascan_get_budget is the caller's own row (read
274
+ // tier, same idiom as tascan_get_usage). tascan_delegate_to_agent mints (NOT destructiveHint — see tools.cjs);
275
+ // tascan_revoke_delegation is destructive+idempotent. Both need write + delegate (owner-only to grant, I-6).
276
+ tascan_get_budget: R,
277
+ tascan_delegate_to_agent: { tier: 'write', delegate: true },
278
+ tascan_list_delegations: R,
279
+ tascan_revoke_delegation: { tier: 'write', delegate: true }
254
280
  };
255
281
  // Tools classified here that do not yet exist in tools.cjs (other workstreams' patches). Empty since
256
282
  // tascan_get_receipt landed in tools.cjs (S100 phase 2b); harnesses assert TOOL_SCOPES ⊇ tools.cjs.
@@ -336,6 +362,12 @@ const ROUTE_SCOPES = {
336
362
  // coordination layer (api-v1-helpers.js parsePath handlers task_messages / coord_cycles / coord_cycle_report / build / build_file)
337
363
  task_messages: { GET: R, POST: CYCLE_WRITE }, // trail messages; POST answer on a dispatcher question → coord_answer_question
338
364
  coord_cycles: { GET: R, POST: { tier: 'write', dispatch: 'code' } }, // list roots / coord_create_cycle (T1 = CODE:/SHELL: on the AI Inbox)
365
+ // Fan-out phase 1a-ii (master item 4): matched in coord-fanout-lib.js's own matchFanoutRoute, not
366
+ // api-v1-helpers.js's parsePath ROUTES table — same D6/D8 idiom as coord_dispatcher_action/coord_build_diff below.
367
+ // coordFanoutLib.handleFanoutRoute self-gates with these exact shapes too (belt-and-suspenders).
368
+ coord_fanouts: { GET: R, POST: { tier: 'write', dispatch: 'code' } }, // GET /coord/fanouts (list) / POST (create, T_F = CODE:/SHELL:-equivalent dispatch)
369
+ coord_fanout: { GET: R }, // GET /coord/fanouts/:id[?view=status|report|rollup|verify]
370
+ coord_fanout_control: { POST: { tier: 'write', dispatch: 'code' } }, // POST /coord/fanouts/:id/control (pause|resume|cancel|close_barrier)
339
371
  coord_integrate: { POST: WD }, // D4 (181): POST /coord/cycles/:root/integrate — the dispatcher records the deploy id (agent:dispatch)
340
372
  coord_dispatcher_action: { POST: WD }, // D6 (194): POST /coord/cycles/:root/dispatcher-actions — a chief-of-staff action becomes a receipt (agent:dispatch); the handler self-gates too
341
373
  coord_build_diff: { POST: WD }, // D8 item 6 (196): POST /coord/builds/:ref/diff — the dispatcher stores an already-computed diff (agent:dispatch); the handler self-gates too
@@ -352,6 +384,16 @@ const ROUTE_SCOPES = {
352
384
  // V9b (204): GET/PUT /evidence/policy/:task_id — read a task's evidence-policy progress / author + pin one.
353
385
  evidence_policy: { GET: R, PUT: W }, // GET /builds/:build_ref/file?path=&offset=&limit= ≤ 12,000 chars per call
354
386
  keys: 'admin', key: 'admin',
387
+ // B2 (migration 221): key_budget (PUT/GET /keys/:id/budget) joins the admin-JWT-only idiom (api-v1.js's
388
+ // DECISION_ASSERTION_ADMIN_HANDLERS-style set) — a tsk_/service bearer is refused 403 human_session_required
389
+ // before this table is ever consulted (I-3: no key, ever, sets or reads ANOTHER key's budget); the whole entry
390
+ // is the 'admin' marker, same as keys/key above, since the handler self-gates (owner-only for PUT, any admin
391
+ // role for GET). budget (GET /budget, no :id) is the CALLING key's own row — an ordinary read-tier key route.
392
+ // delegations/delegation need write + delegate (owner-only to grant, I-6).
393
+ key_budget: 'admin',
394
+ budget: { GET: R },
395
+ delegations: { POST: { tier: 'write', delegate: true }, GET: R },
396
+ delegation: { DELETE: { tier: 'write', delegate: true } },
355
397
  // H1 (migration 215): human decision binding — admin-JWT-only, same gate-skip idiom as keys/key (api-v1.js's
356
398
  // DECISION_ASSERTION_ADMIN_HANDLERS uses authenticateAdminSession and refuses a tsk_/service key 403 human_session_required).
357
399
  decision_assertion_options: 'admin', decision_assertions: 'admin', decision_assertion: 'admin',
@@ -387,6 +429,7 @@ const SCOPE_LABEL = {
387
429
  [DISPATCH]: 'the "agent:dispatch" permission ("Let this app hand tasks to my AI agents")',
388
430
  [DISPATCH_CODE]: 'the "agent:dispatch:code" permission ("...including code and shell tasks on my computer")',
389
431
  [DEVICE_ADMIN]: 'the "device:admin" permission (register/rotate/revoke devices) — owner-only, requires full, and not reachable through OAuth',
432
+ [DELEGATE]: 'the "delegate" permission (mint time-limited child API keys) — owner-only, requires full, and not reachable through OAuth',
390
433
  unclassified: 'a classification (this tool is not in the scope map)'
391
434
  };
392
435
  function reconnectHint(required) {
@@ -415,7 +458,7 @@ function restDenialHeaders(d) {
415
458
  }
416
459
 
417
460
  module.exports = {
418
- SCOPE_VALUES, ALL_SCOPES, TIERS, RANK, DISPATCH, DISPATCH_CODE, DEVICE_ADMIN, AGENT_INBOX_IDS,
461
+ SCOPE_VALUES, ALL_SCOPES, TIERS, RANK, DISPATCH, DISPATCH_CODE, DEVICE_ADMIN, DELEGATE, AGENT_INBOX_IDS,
419
462
  TASK_TYPES, LOCAL_TASK_TYPES, CLOUD_TASK_TYPES, REVIEWER_TASK_TYPES, LOCAL_ONLY_TASK_TYPES, CODE_TASK_TYPES, taskType, dispatchScopeFor, dispatchKind,
420
463
  normalize, toArray, toScopeString, parseRequested, echoScope, SCOPE_PARAM_MAX, clamp, validateStored, check,
421
464
  TOOL_SCOPES, PENDING_TOOLS, ROUTE_SCOPES, requiredForTool, requiredForRoute,
@@ -0,0 +1,175 @@
1
+ // Fan-out phase 1b (2026-09-28, briefs/2026-09-28-fanout-phase1b-mcp-tools.md; master
2
+ // briefs/2026-09-27-fanout-phase1.md, r3): three MCP tools over the 1a-ii REST surface
3
+ // (coord-fanout-lib.js's matchFanoutRoute / handleFanoutRoute, wired into api-v1.js) -- never Supabase
4
+ // directly, same split-file pattern as tools-devices.cjs (spread into TOOLS by one line there).
5
+ //
6
+ // tascan_create_fanout: POST /coord/fanouts. Every preflight problem the route can see (fanout_ceiling_unset,
7
+ // over_ceiling, cap_below_plan, idempotency_body_mismatch, fanout_open_limit, seats_invalid, reserve_floor_unset,
8
+ // no_authority, key_required) reaches the caller VERBATIM: api() (tascan-mcp/index.js / mcp-endpoint.js) throws
9
+ // Error(data.error) on a non-2xx response and the transport prints "Error: <message>" unchanged -- this file adds
10
+ // no try/catch around that path on purpose. dry_run is forwarded to the route as-is (literal passthrough, the
11
+ // brief's own word); the route built in 1a-ii has NO server-side preflight mode for this path (unlike
12
+ // tascan_create_cycle's dry_run, which /coord/cycles DOES implement) -- a create call with dry_run true still
13
+ // creates a REAL fan-out today. Documented here, in the tool description and at runtime, rather than silently
14
+ // guessed: fixing that gap means touching coord-fanout-lib.js, which is out of this brief's six deliverables.
15
+ //
16
+ // tascan_get_fanout: GET /coord/fanouts/:id?view=. tascan_control_fanout: POST /coord/fanouts/:id/control, action
17
+ // enum WITHOUT amend and WITHOUT answer (master: no such actions in phase 1) -- exactly coord-fanout-lib.js's own
18
+ // CONTROL_ACTIONS.
19
+ 'use strict';
20
+
21
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+ const VIEWS = ['status', 'report', 'rollup', 'verify'];
23
+ const CONTROL_ACTIONS = ['pause', 'resume', 'cancel', 'close_barrier'];
24
+
25
+ module.exports = [
26
+ {
27
+ name: 'tascan_create_fanout',
28
+ description: 'Create a fan-out: 1-30 RESEARCH seats plus one synthesizer under ONE cycle root T_F (POST /coord/fanouts). Zero Decision cards, zero Parked cards, zero pages per seat -- only a control action or the closing roll-up reaches a human. Requires an API key with agent:dispatch:code -- an admin session has no dispatch budget (key_required). Consumes seats.length + 1 dispatch units BEFORE the authoritative create; a refusal only the RPC can see (cap_below_plan, fanout_open_limit, bad agent_id, reserve_floor_unset) still consumes them, by design. Fails closed fanout_ceiling_unset until the owner sets a ceiling; over_ceiling when cap exceeds it. idempotency_key replays duplicate within 24h on the same plan, else idempotency_body_mismatch. dry_run is forwarded as-is; the route has no preflight mode yet, so dry_run true still creates for real -- never a way to avoid creating. Leaves fanout_created; every seat, the synth and the roll-up leave their own signed completion. Track with tascan_get_fanout.',
29
+ inputSchema: {
30
+ type: 'object',
31
+ properties: {
32
+ project_id: { type: 'string', description: 'Working project (UUID). The fan-out root T_F is minted on its Questions list (coord_ensure_lists).' },
33
+ title: { type: 'string', description: 'Short human title (1-200 chars). T_F becomes "FANOUT: <title>"; seats become "RESEARCH: [seat-NN] <title>"; the synth becomes "RESEARCH: [synth] <title>".' },
34
+ seats: {
35
+ type: 'array',
36
+ description: '1-30 seats. Each names the agent that runs it; brief/role fall back to seat_defaults when omitted.',
37
+ items: {
38
+ type: 'object',
39
+ properties: {
40
+ agent_id: { type: 'string', description: 'A registered agent_registry id in this org, matching ^[a-z][a-z0-9-]{0,39}$. Never a system-* worker.' },
41
+ brief: { type: 'string', description: 'This seat\'s own prompt (1-40000 chars). Falls back to seat_defaults.brief when omitted -- one of the two is required.' },
42
+ role: { type: 'string', description: 'Optional label for this seat (1-100 chars), e.g. "topic A".' }
43
+ },
44
+ required: ['agent_id']
45
+ }
46
+ },
47
+ seat_defaults: {
48
+ type: 'object',
49
+ description: 'Shared defaults applied when a seat names no brief of its own.',
50
+ properties: {
51
+ brief: { type: 'string', description: 'Default seat prompt (1-40000 chars), used by any seat that names no brief of its own.' },
52
+ est_micro_usd: { type: 'integer', description: 'Positive integer micro-USD estimate per seat, used by the RPC\'s cap_below_plan check.' },
53
+ review_est_micro_usd: { type: 'integer', description: 'Non-negative integer micro-USD estimate per seat review. Must be > 0 when seat_review is per_seat.' }
54
+ },
55
+ required: ['est_micro_usd']
56
+ },
57
+ synth: {
58
+ type: 'object',
59
+ description: 'The synthesizer that runs once the barrier closes, reading every done seat\'s stored output as DATA.',
60
+ properties: {
61
+ brief: { type: 'string', description: 'The synthesizer\'s prompt (1-40000 chars).' },
62
+ max_cost_micro_usd: { type: 'integer', description: 'Positive integer micro-USD reserved for the synth run; reserved out of cap up front, released back into the seat budget only at barrier close.' }
63
+ },
64
+ required: ['brief', 'max_cost_micro_usd']
65
+ },
66
+ canary: { type: 'integer', description: 'How many seats release immediately; the rest stay held on T_F until the first canary settles done. Default min(2, seats.length).' },
67
+ min_ok: { type: 'integer', description: 'Minimum seats that must finish done before the synth barrier can close. Default ceil(seats.length / 2).' },
68
+ breaker_k: { type: 'integer', description: 'Paid (non-infrastructure) seat failures before the breaker pauses the fan-out (2-5, default 2). An expired run never counts.' },
69
+ deadline_min: { type: 'integer', description: 'Minutes before an idle seat is dropped and the barrier re-evaluated, and before a stuck synthesizing fan-out closes deadline (10-1440, default 240).' },
70
+ seat_review: { type: 'string', enum: ['per_seat', 'none'], description: 'per_seat mints one review per seat (allowed for at most 10 seats); none skips seat review. Default per_seat for <=10 seats, none above that.' },
71
+ cap: { type: 'integer', description: 'This fan-out\'s total micro-USD budget, synth reserve included. Refused over_ceiling if it exceeds the org\'s fan-out spending ceiling.' },
72
+ idempotency_key: { type: 'string', description: 'Optional replay key (<= 200 chars). The same key within 24h on the same plan returns duplicate:true with the existing fanout_id; on a different plan it is refused idempotency_body_mismatch.' },
73
+ dry_run: { type: 'boolean', description: 'Forwarded to POST /coord/fanouts as given. The route has no server-side preflight for this path today, so true still performs a REAL create -- do not rely on it to avoid creating a fan-out.' }
74
+ },
75
+ required: ['project_id', 'title', 'seats', 'synth', 'cap']
76
+ },
77
+ annotations: { title: 'Create Fan-out', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
78
+ handler: async (args, api) => {
79
+ const projectId = String(args.project_id || '').trim();
80
+ if (!UUID_RE.test(projectId)) throw new Error('project_id must be a UUID');
81
+ if (!args.title || typeof args.title !== 'string') throw new Error('title is required');
82
+ if (!Array.isArray(args.seats) || !args.seats.length) throw new Error('seats is required (1-30 entries)');
83
+ if (!Number.isInteger(args.cap) || args.cap <= 0) throw new Error('cap is required (a positive integer, this fan-out\'s total micro-USD budget)');
84
+ if (!args.synth || typeof args.synth !== 'object') throw new Error('synth is required ({brief, max_cost_micro_usd})');
85
+
86
+ const body = {
87
+ project_id: projectId,
88
+ title: args.title,
89
+ seats: args.seats.map(s => ({
90
+ agent_id: s && s.agent_id,
91
+ ...(s && s.brief != null ? { brief: s.brief } : {}),
92
+ ...(s && s.role != null ? { role: s.role } : {})
93
+ })),
94
+ synth: { brief: args.synth.brief, max_cost_micro_usd: args.synth.max_cost_micro_usd },
95
+ cap: args.cap
96
+ };
97
+ if (args.seat_defaults) body.seat_defaults = args.seat_defaults;
98
+ for (const k of ['canary', 'min_ok', 'breaker_k', 'deadline_min', 'seat_review', 'idempotency_key']) {
99
+ if (args[k] != null) body[k] = args[k];
100
+ }
101
+ const dryRun = args.dry_run === true || args.dry_run === 'true';
102
+ if (args.dry_run != null) body.dry_run = dryRun;
103
+
104
+ const result = await api('POST', '/coord/fanouts', body, args.idempotency_key);
105
+ const d = result.data || {};
106
+ if (d.duplicate) {
107
+ return `DUPLICATE — nothing was created. A fan-out with the same idempotency_key and plan exists from the last 24h.\nfanout_id: ${d.fanout_id}\n\nRead it: tascan_get_fanout fanout_id=${d.fanout_id}`;
108
+ }
109
+ const seatLines = Array.isArray(d.seats)
110
+ ? d.seats.map(s => ` seat-${String(s.seat_no).padStart(2, '0')}: ${s.seat_id} (${s.released ? 'released' : 'held on T_F'}, ${s.status})`).join('\n')
111
+ : ' (none returned)';
112
+ let text = `Fan-out created.\nfanout_id (T_F): ${d.fanout_id}\nplan_sha256: ${d.plan_sha256}\nstatus: ${d.status}\nsynth_task_id: ${d.synth_task_id}\npoll_after_s: ${d.poll_after_s}\nseats:\n${seatLines}\n\nFollow with tascan_get_fanout fanout_id=${d.fanout_id} (view=status).`;
113
+ if (dryRun) {
114
+ text = 'NOTE: dry_run has no server-side preflight on this route yet — the fan-out below was created for REAL, not simulated.\n\n' + text;
115
+ }
116
+ return text;
117
+ }
118
+ },
119
+ {
120
+ name: 'tascan_get_fanout',
121
+ description: 'Read a fan-out (GET /coord/fanouts/:id?view=). view=status (default): state, seat counts, spend, cap, synth_deadline_at, poll_after_s. view=report: the full cycle report (get_cycle_report) — every seat, review and control step. view=rollup: the exact roll-up response_value plus its leaves (path, sha256, completion_id) once closed. view=verify: the server recomputes rollup_ref over the frozen leaves and returns ok / mismatches — the same check scripts/verify-fanout.js performs independently from another machine, which is the trusted proof; this view is a convenience, not a substitute. A foreign org\'s fanout_id answers 404, never 403, so another org\'s fan-out never even appears to exist. Read tier, no dispatch permission required.',
122
+ inputSchema: {
123
+ type: 'object',
124
+ properties: {
125
+ fanout_id: { type: 'string', description: 'Fan-out id (UUID) — the T_F root, from tascan_create_fanout or tascan_list_cycles.' },
126
+ view: { type: 'string', enum: VIEWS, description: 'status (default), report, rollup or verify.' }
127
+ },
128
+ required: ['fanout_id']
129
+ },
130
+ annotations: { title: 'Get Fan-out', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
131
+ handler: async (args, api) => {
132
+ const id = String(args.fanout_id || '').trim();
133
+ if (!UUID_RE.test(id)) throw new Error('fanout_id must be a UUID');
134
+ const view = args.view != null && args.view !== '' ? String(args.view) : 'status';
135
+ if (!VIEWS.includes(view)) throw new Error(`view must be one of: ${VIEWS.join(', ')}`);
136
+ const result = await api('GET', `/coord/fanouts/${id}?view=${view}`);
137
+ const out = `Fan-out ${id} (view=${view}):\n\n${JSON.stringify(result.data, null, 2)}`;
138
+ return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
139
+ }
140
+ },
141
+ {
142
+ name: 'tascan_control_fanout',
143
+ description: 'Control a running fan-out (POST /coord/fanouts/:id/control). pause holds every unclaimed seat and stops the orphan pass from re-firing them; resume releases them again; cancel supersedes every unclaimed task and closes once quiescent; close_barrier drops unsettled or named exclude_seats and forces the synth barrier now instead of waiting for more seats. No amend and no answer in phase 1. Every action mints a signed control-child completion FIRST, as the org\'s own system worker (coord_fanout_control_receipt) — never coord_dispatcher_action, so a control action can never sign into another org\'s Chief of Staff identity. idempotency_key replays the prior outcome (replayed:true) and applies nothing twice; a replayed resume after a later pause leaves the fan-out paused. Refused fanout_closed once the roll-up has closed. Requires agent:dispatch:code.',
144
+ inputSchema: {
145
+ type: 'object',
146
+ properties: {
147
+ fanout_id: { type: 'string', description: 'Fan-out id (UUID) — the T_F root.' },
148
+ action: { type: 'string', enum: CONTROL_ACTIONS, description: 'pause, resume, cancel or close_barrier. No amend, no answer (phase 1).' },
149
+ reason: { type: 'string', description: 'Optional free-text reason (<= 4000 chars), recorded on the control child\'s completion.' },
150
+ exclude_seats: { type: 'array', items: { type: 'integer' }, description: 'close_barrier only: 1-based seat numbers to drop from the barrier instead of waiting for them to settle.' },
151
+ idempotency_key: { type: 'string', description: 'Optional replay key (<= 200 chars). The same key returns the prior outcome (replayed:true) and applies nothing a second time.' }
152
+ },
153
+ required: ['fanout_id', 'action']
154
+ },
155
+ annotations: { title: 'Control Fan-out', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
156
+ handler: async (args, api) => {
157
+ const id = String(args.fanout_id || '').trim();
158
+ if (!UUID_RE.test(id)) throw new Error('fanout_id must be a UUID');
159
+ const action = String(args.action || '');
160
+ if (!CONTROL_ACTIONS.includes(action)) throw new Error(`action must be one of: ${CONTROL_ACTIONS.join(', ')}`);
161
+ const body = { action };
162
+ if (args.reason != null) body.reason = args.reason;
163
+ if (args.exclude_seats != null) {
164
+ if (!Array.isArray(args.exclude_seats) || !args.exclude_seats.every(n => Number.isInteger(n) && n > 0)) {
165
+ throw new Error('exclude_seats must be an array of positive integers');
166
+ }
167
+ body.exclude_seats = args.exclude_seats;
168
+ }
169
+ if (args.idempotency_key != null) body.idempotency_key = args.idempotency_key;
170
+ const result = await api('POST', `/coord/fanouts/${id}/control`, body, args.idempotency_key);
171
+ const d = result.data || {};
172
+ return `${d.replayed ? 'REPLAYED (idempotent — nothing applied twice)' : 'Applied'}: ${action} on fan-out ${id}.\nfanout_status: ${d.fanout_status}\nreceipt_completion_id: ${d.receipt_completion_id}\n\n${JSON.stringify(d, null, 2)}`;
173
+ }
174
+ }
175
+ ];
package/tools.cjs CHANGED
@@ -2750,8 +2750,83 @@ const TOOLS = [
2750
2750
  return lines.join('\n');
2751
2751
  }
2752
2752
  },
2753
+ // B2 (migration 221): per-key action budgets (#10, I-3) + delegated, time-limited child keys (#11, I-6, I-10).
2754
+ {
2755
+ name: 'tascan_get_budget',
2756
+ description: 'Read THIS credential\'s own per-key action budget (GET /budget): daily limits (sms, invites, payment-request cents per day and per request, dispatch, human pages) and today\'s spend against them. A NULL limit (or no budget row at all) means unlimited on that counter. A key can only ever see its OWN budget, never another key\'s (I-3, no tool or key ever reads another key\'s row). Read tier.',
2757
+ inputSchema: { type: 'object', properties: {} },
2758
+ annotations: { title: 'Get Budget', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2759
+ handler: async (args, api) => {
2760
+ const result = await api('GET', '/budget');
2761
+ const d = result.data || {};
2762
+ const b = d.budget || {}, u = d.usage || {};
2763
+ const rows = [
2764
+ ['sms_per_day', 'sms_count', 'sms/day'], ['invites_per_day', 'invites_count', 'invites/day'],
2765
+ ['payment_request_cents_per_day', 'payment_request_cents_count', 'payment request cents/day'],
2766
+ ['payment_request_cents_max', null, 'payment request cents/request (max)'],
2767
+ ['dispatch_per_day', 'dispatch_count', 'dispatch/day'], ['human_pages_per_day', 'human_pages_count', 'human pages/day']
2768
+ ];
2769
+ const lines = ["this key's budget (null = unlimited):"];
2770
+ for (const [limitKey, usageKey, label] of rows) {
2771
+ const limit = b[limitKey] != null ? b[limitKey] : 'unlimited';
2772
+ const used = usageKey ? (u[usageKey] != null ? u[usageKey] : 0) : null;
2773
+ lines.push(` ${label}: ${used != null ? used + ' / ' : ''}${limit}`);
2774
+ }
2775
+ return lines.join('\n') + '\n\n' + JSON.stringify(d, null, 2);
2776
+ }
2777
+ },
2778
+ {
2779
+ name: 'tascan_delegate_to_agent',
2780
+ description: 'Mint a time-limited, NARROWER child API key (POST /delegations, roadmap #11) — only read/write scopes may be requested; full, agent:dispatch, agent:dispatch:code, device:admin, delegate and webhooks:manage are always refused by name (I-6). TTL defaults to 1 hour (3600s), clamps to [60 seconds, 24 hours], and clamps further to THIS key\'s own expiry if it has one — a delegated key can never outlive its grantor. The child\'s tier is always a SUBSET of this key\'s own effective tier, never wider or lateral. Requires the delegate permission (owner-only to grant, requires full). The returned raw key is shown to you exactly ONCE — it is never stored server-side and cannot be retrieved again (I-10). Revoking this key (or any ancestor) cascades immediately to every key it delegated.',
2781
+ inputSchema: {
2782
+ type: 'object',
2783
+ properties: {
2784
+ scopes: { type: 'array', items: { type: 'string', enum: ['read', 'write'] }, description: 'Scopes to request for the child key — read and/or write only.' },
2785
+ ttl_seconds: { type: 'number', description: 'How long the child key lives, in seconds (default 3600 = 1h; clamped to [60, 86400] and to this key\'s own expiry).' },
2786
+ resource_constraints: { type: 'object', description: 'Optional free-form narrowing (e.g. project/list ids) recorded on the child key as metadata.' }
2787
+ },
2788
+ required: ['scopes']
2789
+ },
2790
+ annotations: { title: 'Delegate To Agent', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
2791
+ handler: async (args, api) => {
2792
+ if (!Array.isArray(args.scopes) || !args.scopes.length) throw new Error('scopes is required (array of "read"/"write")');
2793
+ const result = await api('POST', '/delegations', { scopes: args.scopes, ttl_seconds: args.ttl_seconds, resource_constraints: args.resource_constraints });
2794
+ const d = result.data || {};
2795
+ return `Delegated key ${d.key_id} minted, scopes [${(d.scopes || []).join(', ')}], expires ${d.expires_at}.\nSAVE THIS KEY NOW — it will not be shown again:\n${d.key}`;
2796
+ }
2797
+ },
2798
+ {
2799
+ name: 'tascan_list_delegations',
2800
+ description: 'List delegated child keys (GET /delegations): this key\'s own direct children (id, label, scopes, active/revoked, depth, expires_at) — never the raw credential. An admin session sees every delegated key in the org. Read tier.',
2801
+ inputSchema: { type: 'object', properties: {} },
2802
+ annotations: { title: 'List Delegations', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2803
+ handler: async (args, api) => {
2804
+ const result = await api('GET', '/delegations');
2805
+ const data = result.data || [];
2806
+ const out = `${data.length} delegated key(s)\n\n${JSON.stringify(data, null, 2)}`;
2807
+ return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
2808
+ }
2809
+ },
2810
+ {
2811
+ name: 'tascan_revoke_delegation',
2812
+ description: 'Revoke a delegated child key immediately (DELETE /delegations/:key_id) — self-revoke, revoke by an ancestor key, or by an admin session of the org; any other caller is refused. Revoking cascades to every key IT delegated, in turn (the database trigger, not application code). Idempotent: revoking an already-revoked key reports already_revoked, no error.',
2813
+ inputSchema: {
2814
+ type: 'object',
2815
+ properties: { key_id: { type: 'string', description: 'The delegated key id to revoke (from tascan_list_delegations)' } },
2816
+ required: ['key_id']
2817
+ },
2818
+ annotations: { title: 'Revoke Delegation', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
2819
+ handler: async (args, api) => {
2820
+ if (!args.key_id) throw new Error('key_id is required');
2821
+ const result = await api('DELETE', `/delegations/${args.key_id}`);
2822
+ const d = result.data || {};
2823
+ return d.already_revoked ? `Key ${args.key_id} was already revoked.` : `Key ${args.key_id} revoked.`;
2824
+ }
2825
+ },
2753
2826
  // P0 (migration 216): device control plane — split out (194 KB already close to the ~200 KB bundle cap).
2754
- ...require('./tools-devices.cjs')
2827
+ ...require('./tools-devices.cjs'),
2828
+ // Fan-out phase 1b (2026-09-28): three tools over the 1a-ii REST surface — split out, same reason.
2829
+ ...require('./tools-fanout.cjs')
2755
2830
  ];
2756
2831
 
2757
2832
  module.exports = { TOOLS, AGENT_REGISTRY, dynamicAgents };