tascan-mcp 3.15.0 → 3.16.1

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
@@ -83,7 +83,7 @@ https://app.tascan.io/mcp
83
83
 
84
84
  Add as a custom connector in Claude (Settings > Connectors). When you first use a tool, you'll be redirected to sign in with your TaScan email and password — just like connecting GitHub or Netlify. OAuth 2.0 with PKCE handles everything automatically.
85
85
 
86
- ## Tools (36)
86
+ ## Tools (90)
87
87
 
88
88
  ### Projects
89
89
  | Tool | Description | Type |
@@ -158,6 +158,26 @@ Add as a custom connector in Claude (Settings > Connectors). When you first use
158
158
  | `tascan_register_agent` | Register a new AI agent with capabilities | Create |
159
159
  | `tascan_dispatch_to_agent` | Route a task to a specific AI agent for execution | Create |
160
160
 
161
+ ### Protocol Layer — Coordination, Receipts, Evidence, Verification, Analytics
162
+
163
+ This table lists only the 10 tools M1 (2026-09-23) added; the coordination-cycle tools from earlier milestones
164
+ (`tascan_create_cycle`, `tascan_post_message`, `tascan_record_integration`, `tascan_get_build`,
165
+ `tascan_get_build_file`, `tascan_get_cycle_report`, `tascan_project_digest`, `tascan_get_receipt`) are documented
166
+ inline in their tool descriptions (`tascan_list_tools` / your MCP client's tool browser shows all 90).
167
+
168
+ | Tool | Description | Type |
169
+ |------|-------------|------|
170
+ | `tascan_list_cycles` | List coordination-cycle roots (id, title, status, build_ref, deploy_id), filterable by project/status | Read |
171
+ | `tascan_get_task_trail` | Read a task's trail messages (question/answer/handoff/note), newest last | Read |
172
+ | `tascan_verify_receipt` | Independently verify an Action Receipt JWS against the reference verifier (works for a foreign issuer too) | None |
173
+ | `tascan_get_build_diff` | Store an already-computed diff for one build-bundle file against a base commit | Write + Dispatch |
174
+ | `tascan_dispatcher_action` | Record a chief-of-staff dispatcher action (approve/revise/park/deploy/decision/...) as a receipt | Write + Dispatch |
175
+ | `tascan_post_evidence` | Post one evidence event (actor did action to object at a time) into the evidence ledger | Write |
176
+ | `tascan_evidence_policy` | Read (action=get) or author/pin (action=set) a task's evidence policy | Read/Write |
177
+ | `tascan_request_verification` | Enqueue an autonomous verification job (http_probe or doc_check) for a completion | Write |
178
+ | `tascan_list_verifications` | Read a completion's verification verdicts and job queue | Read |
179
+ | `tascan_org_analytics` | Read org-wide analytics: the org rollup or paginated AI issue-resolution history | Read |
180
+
161
181
  ## Usage Examples
162
182
 
163
183
  ### Example 1: Set up a construction site inspection
package/index.js CHANGED
@@ -15,6 +15,7 @@ import { createRequire } from 'module';
15
15
 
16
16
  const require = createRequire(import.meta.url);
17
17
  const { TOOLS } = require('./tools.cjs');
18
+ const crypto = require('crypto'); // B1 (migration 220): crypto.randomUUID() is a Node built-in, no new dependency
18
19
 
19
20
  const API_BASE = process.env.TASCAN_API_URL || 'https://app.tascan.io/api/v1';
20
21
  const API_KEY = process.env.TASCAN_API_KEY || '';
@@ -24,8 +25,11 @@ if (!API_KEY) {
24
25
  process.exit(1);
25
26
  }
26
27
 
27
- // Transport-bound REST call handed to every tool handler
28
- async function api(method, path, body) {
28
+ // Transport-bound REST call handed to every tool handler. B1 (migration 220): every POST/PUT sends an
29
+ // Idempotency-Key — a fresh crypto.randomUUID() per call by default, or idemKey verbatim when a tool (e.g.
30
+ // tascan_send_sms, tascan_request_payment, tascan_add_tasks) passes one through so a RETRIED call reuses the
31
+ // same key instead of minting a new reservation (and re-running the handler) every time.
32
+ async function api(method, path, body, idemKey) {
29
33
  const opts = {
30
34
  method,
31
35
  headers: {
@@ -36,6 +40,9 @@ async function api(method, path, body) {
36
40
  if (body && (method === 'POST' || method === 'PUT')) {
37
41
  opts.body = JSON.stringify(body);
38
42
  }
43
+ if (method === 'POST' || method === 'PUT') {
44
+ opts.headers['Idempotency-Key'] = idemKey || crypto.randomUUID();
45
+ }
39
46
  const resp = await fetch(API_BASE + path, opts);
40
47
  const data = await resp.json();
41
48
  if (!resp.ok) {
@@ -46,7 +53,7 @@ async function api(method, path, body) {
46
53
  api.key = API_KEY; // raw-fetch tools read the key from here (S99: those functions now require auth)
47
54
 
48
55
  const server = new Server(
49
- { name: 'tascan', version: '3.15.0' },
56
+ { name: 'tascan', version: '3.16.1' },
50
57
  { capabilities: { tools: {} } }
51
58
  );
52
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tascan-mcp",
3
- "version": "3.15.0",
3
+ "version": "3.16.1",
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",
@@ -14,6 +14,7 @@
14
14
  "files": [
15
15
  "index.js",
16
16
  "tools.cjs",
17
+ "tools-devices.cjs",
17
18
  "scopes.cjs",
18
19
  "README.md",
19
20
  "LICENSE"
package/scopes.cjs CHANGED
@@ -30,7 +30,11 @@ const TIERS = ['read', 'write', 'full'];
30
30
  const RANK = { read: 1, write: 2, full: 3 };
31
31
  const DISPATCH = 'agent:dispatch';
32
32
  const DISPATCH_CODE = 'agent:dispatch:code';
33
- const SCOPE_VALUES = ['read', 'write', 'full', DISPATCH, DISPATCH_CODE];
33
+ // P0 (migration 216): device control plane. A THIRD additive permission, never implied by a tier — owner-only,
34
+ // requires full, and unreachable through OAuth (mcp-oauth.js's consent flow passes no device flag, so clamp()
35
+ // below never grants it to a connector token even though clamp is capable of granting it to an owner).
36
+ const DEVICE_ADMIN = 'device:admin';
37
+ const SCOPE_VALUES = ['read', 'write', 'full', DISPATCH, DISPATCH_CODE, DEVICE_ADMIN];
34
38
  const ALL_SCOPES = SCOPE_VALUES.slice();
35
39
 
36
40
  // Seed agent inbox (tools.cjs AGENT_REGISTRY, agent-execute, agent.js, migrations 046/137/151).
@@ -40,11 +44,11 @@ const AGENT_INBOX_IDS = ['f745d0fa-421a-42e5-85be-0a3f9acd28e8'];
40
44
  // ─── Task-type routing (mirrors SQL agent_task_type(title), migration 151) ─────
41
45
  // Prefix-only: the title must START with TYPE: (after any leading non-letters such
42
46
  // as an urgency marker). No keyword heuristics anywhere.
43
- const TASK_TYPES = ['CODE', 'SHELL', 'MCP', 'PLAN', 'WRITE', 'RESEARCH', 'REVIEW'];
44
- const LOCAL_TASK_TYPES = ['CODE', 'SHELL', 'PLAN', 'DEFAULT', 'MCP'];
47
+ const TASK_TYPES = ['CODE', 'SHELL', 'MCP', 'PLAN', 'CAPTURE', 'WRITE', 'RESEARCH', 'REVIEW']; // CAPTURE (T1 2026-09-24): a review_only cycle's build step — local executor, no model
48
+ const LOCAL_TASK_TYPES = ['CODE', 'SHELL', 'PLAN', 'DEFAULT', 'MCP', 'CAPTURE'];
45
49
  const CLOUD_TASK_TYPES = ['RESEARCH', 'WRITE', 'DEFAULT'];
46
50
  const REVIEWER_TASK_TYPES = ['REVIEW']; // agent-review-background.js (155): OpenAI reviewer runner, read-tier key
47
- const LOCAL_ONLY_TASK_TYPES = ['CODE', 'SHELL', 'PLAN', 'MCP']; // the cloud runner refuses these; DEFAULT goes either way
51
+ const LOCAL_ONLY_TASK_TYPES = ['CODE', 'SHELL', 'PLAN', 'MCP', 'CAPTURE']; // the cloud runner refuses these; DEFAULT goes either way. CAPTURE (T1): agent_route_for('CAPTURE') = 'local' (migration 214)
48
52
  const CODE_TASK_TYPES = ['CODE', 'SHELL'];
49
53
  const TASK_PREFIX_RE = new RegExp('^(' + TASK_TYPES.join('|') + '):');
50
54
 
@@ -64,24 +68,29 @@ function dispatchKind(titles) {
64
68
  }
65
69
 
66
70
  // ─── Normalization ────────────────────────────────────────────
67
- // api_keys.scopes (array) | oauth_grants.scope (string) | null → { tier, dispatch, code, legacy }
68
- // 'all' or '*' anywhere → full + dispatch + code (service key sentinel / pre-150 row)
69
- // null / undefined / [] → read, no dispatch (FAIL CLOSED)
71
+ // api_keys.scopes (array) | oauth_grants.scope (string) | null → { tier, dispatch, code, device_admin, legacy }
72
+ // '*' (the service-key sentinel) → full + dispatch + code + device_admin
73
+ // 'all' (the pre-150 legacy row) → full + dispatch + code, but NEVER device_admin — split from '*' on purpose
74
+ // (P0 / migration 216): a legacy row minted before this permission existed must not silently gain it.
75
+ // null / undefined / [] → read, no dispatch, no device_admin (FAIL CLOSED)
70
76
  function normalize(v) {
71
- if (v == null) return { tier: 'read', dispatch: false, code: false, legacy: false };
77
+ if (v == null) return { tier: 'read', dispatch: false, code: false, device_admin: false, legacy: false };
72
78
  const a = Array.isArray(v) ? v.map(String) : String(v).split(/[\s+,]+/).filter(Boolean);
73
- if (a.includes('all') || a.includes('*')) return { tier: 'full', dispatch: true, code: true, legacy: true };
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 };
74
81
  let tier = 'read';
75
82
  for (const t of TIERS) if (a.includes(t) && RANK[t] > RANK[tier]) tier = t;
76
83
  const dispatch = a.includes(DISPATCH);
77
84
  // code without dispatch is not a state the consent page or key UI can produce; treat as dispatch too
78
85
  const code = a.includes(DISPATCH_CODE);
79
- return { tier, dispatch: dispatch || code, code, legacy: false };
86
+ const deviceAdmin = a.includes(DEVICE_ADMIN);
87
+ return { tier, dispatch: dispatch || code, code, device_admin: deviceAdmin, legacy: false };
80
88
  }
81
89
  function toArray(n) {
82
90
  const out = TIERS.slice(0, RANK[n.tier] || 1);
83
91
  if (n.dispatch || n.code) out.push(DISPATCH);
84
92
  if (n.code) out.push(DISPATCH_CODE);
93
+ if (n.device_admin) out.push(DEVICE_ADMIN);
85
94
  return out;
86
95
  }
87
96
  function toScopeString(n) { return toArray(n).join(' '); }
@@ -119,9 +128,12 @@ function echoScope(str) {
119
128
  }
120
129
 
121
130
  // Consent form → granted. Missing/invalid tier → read (fail closed). code requires dispatch.
122
- // role: admin_users.role — only 'owner' may grant full or either dispatch permission.
123
- // Returns { granted, clamped: [ 'full' | 'agent:dispatch' | 'agent:dispatch:code' ... ] }.
124
- function clamp({ tier, dispatch, code }, role) {
131
+ // role: admin_users.role — only 'owner' may grant full, either dispatch permission, or device:admin.
132
+ // Returns { granted, clamped: [ 'full' | 'agent:dispatch' | 'agent:dispatch:code' | 'device:admin' ... ] }.
133
+ // P0 (migration 216): device_admin is accepted here for completeness and unit-tested (a non-owner clamp drops
134
+ // it), but the ONLY real caller — mcp-oauth.js's OAuth consent flow — never passes a device flag in `pick` at
135
+ // 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) {
125
137
  const t = TIERS.includes(tier) ? tier : 'read';
126
138
  const owner = role === 'owner';
127
139
  const clamped = [];
@@ -130,27 +142,35 @@ function clamp({ tier, dispatch, code }, role) {
130
142
  let gd = !!dispatch, gc = !!code && gd;
131
143
  if (gd && !owner) { gd = false; gc = false; clamped.push(DISPATCH); }
132
144
  else if (!!code && !gc) clamped.push(DISPATCH_CODE); // ticked code without dispatch
133
- return { granted: { tier: gt, dispatch: gd, code: gc, legacy: false }, clamped };
145
+ let gda = !!device_admin && owner;
146
+ if (!!device_admin && !gda) clamped.push(DEVICE_ADMIN);
147
+ return { granted: { tier: gt, dispatch: gd, code: gc, device_admin: gda, legacy: false }, clamped };
134
148
  }
135
149
 
136
150
  // POST /api/v1/keys body.scopes → canonical array or null (rejects 'all', '*', junk, empty,
137
- // and code-without-dispatch).
151
+ // code-without-dispatch, and device:admin-without-full).
138
152
  function validateStored(arr) {
139
153
  if (!Array.isArray(arr) || !arr.length || arr.length > SCOPE_VALUES.length) return null;
140
154
  if (!arr.every(s => typeof s === 'string' && SCOPE_VALUES.includes(s))) return null;
141
155
  if (arr.includes(DISPATCH_CODE) && !arr.includes(DISPATCH)) return null;
156
+ if (arr.includes(DEVICE_ADMIN) && !arr.includes('full')) return null;
142
157
  return toArray(normalize(arr));
143
158
  }
144
159
 
145
- // granted (normalized) vs need ({ tier, dispatch?: true | 'code' | 'if_inbox' }) → null when allowed,
146
- // else { code: 'insufficient_scope', required, granted_scope }.
147
- // need === null → unclassified → deny (fail closed)
148
- // dispatch 'if_inbox' is not decidable without the DB: MCP lets REST decide; REST resolves it first.
160
+ // granted (normalized) vs need ({ tier, dispatch?: true | 'code' | 'if_inbox' | 'if_cycle', device_admin?: true,
161
+ // human_only?: true }) → null when allowed, else a denial object.
162
+ // need === null → unclassified → deny (fail closed)
163
+ // need.human_only → denied for EVERY grant (a { code: 'human_session_required' } denial, checked FIRST:
164
+ // no tier or permission, including the service-key sentinel, ever satisfies it — P0
165
+ // register/rotate are admin-session-only, never a key, per invariant I-3).
166
+ // dispatch 'if_inbox' / 'if_cycle' is not decidable without the DB: MCP lets REST decide; REST resolves it first.
149
167
  function check(granted, need) {
150
168
  const g = granted || normalize(null);
151
169
  const deny = required => ({ code: 'insufficient_scope', required, granted_scope: toScopeString(g) });
152
170
  if (!need) return deny('unclassified');
171
+ if (need.human_only) return { code: 'human_session_required', required: 'human_session_required', granted_scope: toScopeString(g) };
153
172
  if ((RANK[g.tier] || 0) < (RANK[need.tier] || 99)) return deny(need.tier);
173
+ if (need.device_admin && !g.device_admin) return deny(DEVICE_ADMIN);
154
174
  if (need.dispatch === 'code' && !g.code) return deny(DISPATCH_CODE);
155
175
  if (need.dispatch === true && !g.dispatch) return deny(DISPATCH);
156
176
  return null;
@@ -185,6 +205,23 @@ const TOOL_SCOPES = {
185
205
  tascan_post_message: W,
186
206
  // creating a cycle queues a CODE:/SHELL: prompt on the AI Inbox → agent:dispatch:code, always
187
207
  tascan_create_cycle: { tier: 'write', dispatch: 'code' },
208
+ // M1 (2026-09-23): ten tools wrapping routes that already existed in api-v1.js / coord-dispatch-lib.js /
209
+ // receipt.js but had no MCP tool. tascan_verify_receipt: receipt.js's POST /receipts/verify is a PUBLIC
210
+ // route (no scope check at all inside receipt.js) — read is the floor because mcp-endpoint.js still
211
+ // requires SOME connected key for every tools/call. tascan_get_build_diff wraps the REAL route (POST
212
+ // /coord/builds/:ref/diff, coord-dispatch-lib matchBuildDiffRoute/handleBuildDiffRoute, which self-gates
213
+ // write+dispatch) — the brief's table said GET, but the parser has no GET for this path; see the M1
214
+ // changes doc. tascan_dispatcher_action mirrors handleDispatcherActionRoute's own self-gate (write+dispatch).
215
+ tascan_list_cycles: R, tascan_get_task_trail: R, tascan_verify_receipt: R,
216
+ tascan_evidence_policy: a => (a && a.action === 'set' ? { ...W } : { ...R }),
217
+ tascan_list_verifications: R, tascan_org_analytics: R,
218
+ tascan_post_evidence: W, tascan_request_verification: W,
219
+ tascan_get_build_diff: WD, tascan_dispatcher_action: WD,
220
+ // fix (found while wiring M1's coverage check): tascan_record_integration (D4, migration 181) was never
221
+ // added to this map — an unclassified tool is DENIED (scopes.check fails closed on need === null), so it
222
+ // was unreachable via MCP despite its route (coord_integrate, below) being classified. Matches
223
+ // handleCoordIntegrate's own gate (write + agent:dispatch).
224
+ tascan_record_integration: WD,
188
225
  // write (27)
189
226
  tascan_create_project: W, tascan_update_project: W, tascan_delete_project: W, tascan_create_event: W,
190
227
  tascan_update_event: W, tascan_delete_event: W, tascan_update_task: W, tascan_reply_with_list: W,
@@ -206,7 +243,14 @@ const TOOL_SCOPES = {
206
243
  tascan_invite_worker: F, tascan_assess_condition: F, tascan_request_payment: F,
207
244
  // argument-dependent (2)
208
245
  tascan_analyze_issue: a => (a.server_side_ai ? { ...F } : { ...R }),
209
- tascan_generate_report: a => (a.send_to_phone ? { ...F } : { ...W })
246
+ tascan_generate_report: a => (a.send_to_phone ? { ...F } : { ...W }),
247
+ // P0 (migration 216): device control plane. Register and rotate have NO MCP tool at all (C.2, P-15) — each
248
+ // mints a dev_live_ credential that would land in a model transcript, and each needs a passkey no MCP client
249
+ // can present. tascan_revoke_device is the kill switch: full + device:admin, no dispatch.
250
+ tascan_list_devices: R, tascan_get_device: R,
251
+ tascan_revoke_device: { tier: 'full', device_admin: true },
252
+ // 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
210
254
  };
211
255
  // Tools classified here that do not yet exist in tools.cjs (other workstreams' patches). Empty since
212
256
  // tascan_get_receipt landed in tools.cjs (S100 phase 2b); harnesses assert TOOL_SCOPES ⊇ tools.cjs.
@@ -225,6 +269,10 @@ const INBOX_WRITE = () => ({ tier: 'write', dispatch: 'if_inbox' });
225
269
  // agent:dispatch (agent:dispatch:code when the task — or the asker a question task stands for — is CODE:/SHELL:).
226
270
  // Resolved server-side by api-v1.js exactly like if_inbox; an ordinary task stays write tier. Design §4.4, invariant 18.
227
271
  const CYCLE_WRITE = () => ({ tier: 'write', dispatch: 'if_cycle' });
272
+ // P0 (migration 216): register and rotate are admin-session-only — no key, ever, not even full + device:admin
273
+ // (I-3). human_only makes check() deny unconditionally; api-v1.js's main handler answers that denial with
274
+ // 403 human_session_required instead of the ordinary insufficient_scope body.
275
+ const HUMAN = { tier: 'full', device_admin: true, human_only: true };
228
276
  const ROUTE_SCOPES = {
229
277
  projects: { GET: R, POST: W },
230
278
  project: { GET: R, PUT: W, DELETE: W },
@@ -278,16 +326,45 @@ const ROUTE_SCOPES = {
278
326
  analytics_platform: { GET: R },
279
327
  analytics_org: { GET: R },
280
328
  analytics_resolutions: { GET: R },
329
+ // B1 (migration 220): GET /usage — a tsk_/service key reads its own org's aggregate usage at read tier; an
330
+ // admin session of any role reaches the same handler with this gate skipped (api-v1.js's USAGE_HANDLERS, the
331
+ // same dual-auth idiom as devices/device below) and handleUsage itself refuses `by_key` to a non-owner.
332
+ usage: { GET: R },
281
333
  tags: { GET: R, POST: W },
282
334
  tag: { GET: R, PUT: W, DELETE: W },
283
335
  tag_scans: { GET: R },
284
336
  // coordination layer (api-v1-helpers.js parsePath handlers task_messages / coord_cycles / coord_cycle_report / build / build_file)
285
337
  task_messages: { GET: R, POST: CYCLE_WRITE }, // trail messages; POST answer on a dispatcher question → coord_answer_question
286
338
  coord_cycles: { GET: R, POST: { tier: 'write', dispatch: 'code' } }, // list roots / coord_create_cycle (T1 = CODE:/SHELL: on the AI Inbox)
339
+ coord_integrate: { POST: WD }, // D4 (181): POST /coord/cycles/:root/integrate — the dispatcher records the deploy id (agent:dispatch)
340
+ 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
+ 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
342
+ // Verification Layer V2 (180): jobs + verdicts on a completion
343
+ completion_verification_jobs: { POST: W }, // POST /completions/:id/verification-jobs — enqueue an http_probe (write tier)
344
+ completion_verifications: { GET: R }, // GET /completions/:id/verifications — V1 rows + jobs (read tier)
287
345
  coord_cycle_report: { GET: R }, // get_cycle_report (+ summary); an admin Supabase JWT also reaches it (Reports Hub) — api-v1 skips the key lane for that case
288
346
  build: { GET: R }, // GET /builds/:build_ref — bundle manifest (build_artifacts)
289
- build_file: { GET: R }, // GET /builds/:build_ref/file?path=&offset=&limit= ≤ 12,000 chars per call
347
+ build_file: { GET: R },
348
+ // V9a (203): POST /evidence — a device credential is resolved in api-v1.js's own authenticateEvidenceCredential;
349
+ // an ordinary worker/agent key needs write. Production refused every API-key post 403 unclassified from the v229
350
+ // deploy until 2026-09-22 21:40Z (the V9b harness caught it: 21 of 29 checks 403). Same lesson as D6 above.
351
+ evidence: { POST: W },
352
+ // V9b (204): GET/PUT /evidence/policy/:task_id — read a task's evidence-policy progress / author + pin one.
353
+ evidence_policy: { GET: R, PUT: W }, // GET /builds/:build_ref/file?path=&offset=&limit= ≤ 12,000 chars per call
290
354
  keys: 'admin', key: 'admin',
355
+ // H1 (migration 215): human decision binding — admin-JWT-only, same gate-skip idiom as keys/key (api-v1.js's
356
+ // DECISION_ASSERTION_ADMIN_HANDLERS uses authenticateAdminSession and refuses a tsk_/service key 403 human_session_required).
357
+ decision_assertion_options: 'admin', decision_assertions: 'admin', decision_assertion: 'admin',
358
+ admin_passkeys: 'admin', admin_passkey_options: 'admin', admin_passkey_revoke: 'admin',
359
+ // P0 (migration 216): device control plane. GET is read tier for both a session and a key. Register (POST
360
+ // /devices) and rotate need HUMAN (no key, ever). Revoke needs full + device:admin from a key, OR an admin
361
+ // session — api-v1.js runs a non-key bearer through authenticateAdminSession and skips this gate entirely for
362
+ // that path (the same skip idiom as keys/key and the H1 admin-JWT handlers above), checking owner/admin role
363
+ // in the handler instead.
364
+ devices: { GET: R, POST: HUMAN },
365
+ device: { GET: R },
366
+ device_rotate: { POST: HUMAN },
367
+ device_revoke: { POST: { tier: 'full', device_admin: true } },
291
368
  // receipt.js lanes (a separate function that calls scopes.check itself; classified HERE so there is one map).
292
369
  // The anonymous GET /api/v1/receipts/:id/public is credential-free and therefore not classified here.
293
370
  receipt: { GET: R }, // GET /api/v1/receipts/:id[?profile=public] (was hard-coded read in receipt.js)
@@ -309,9 +386,13 @@ const SCOPE_LABEL = {
309
386
  full: 'the "full" access level',
310
387
  [DISPATCH]: 'the "agent:dispatch" permission ("Let this app hand tasks to my AI agents")',
311
388
  [DISPATCH_CODE]: 'the "agent:dispatch:code" permission ("...including code and shell tasks on my computer")',
389
+ [DEVICE_ADMIN]: 'the "device:admin" permission (register/rotate/revoke devices) — owner-only, requires full, and not reachable through OAuth',
312
390
  unclassified: 'a classification (this tool is not in the scope map)'
313
391
  };
314
392
  function reconnectHint(required) {
393
+ if (required === DEVICE_ADMIN) {
394
+ return 'To fix: device:admin is never granted through OAuth — generate a tsk_ key with the device:admin checkbox ticked at app.tascan.io → Team → API Keys (owner only, requires Full).';
395
+ }
315
396
  let s = 'To fix: disconnect TaScan in your MCP client and connect it again — on the TaScan sign-in screen pick a higher access level';
316
397
  if (required === DISPATCH) s += ' and tick "Let this app hand tasks to my AI agents"';
317
398
  if (required === DISPATCH_CODE) s += ' and tick both agent checkboxes ("...including code and shell tasks on my computer")';
@@ -334,7 +415,7 @@ function restDenialHeaders(d) {
334
415
  }
335
416
 
336
417
  module.exports = {
337
- SCOPE_VALUES, ALL_SCOPES, TIERS, RANK, DISPATCH, DISPATCH_CODE, AGENT_INBOX_IDS,
418
+ SCOPE_VALUES, ALL_SCOPES, TIERS, RANK, DISPATCH, DISPATCH_CODE, DEVICE_ADMIN, AGENT_INBOX_IDS,
338
419
  TASK_TYPES, LOCAL_TASK_TYPES, CLOUD_TASK_TYPES, REVIEWER_TASK_TYPES, LOCAL_ONLY_TASK_TYPES, CODE_TASK_TYPES, taskType, dispatchScopeFor, dispatchKind,
339
420
  normalize, toArray, toScopeString, parseRequested, echoScope, SCOPE_PARAM_MAX, clamp, validateStored, check,
340
421
  TOOL_SCOPES, PENDING_TOOLS, ROUTE_SCOPES, requiredForTool, requiredForRoute,
@@ -0,0 +1,69 @@
1
+ // P0 (migration 216): device control plane MCP tools — read the registry and pull the kill switch. Split out
2
+ // of tools.cjs (194 KB already, close to the ~200 KB bundle cap) and spread into TOOLS by one line there.
3
+ //
4
+ // Register and rotate have NO MCP tool at all (roadmap C.2, P-15): each mints a dev_live_ credential that would
5
+ // land in a model transcript, and each needs a fresh passkey assertion (X-Decision-Assertion) that no MCP
6
+ // client can present — POST /devices and POST /devices/:id/rotate stay admin-session-only, reached from
7
+ // admin.js / admin-devices.js, never from here.
8
+ 'use strict';
9
+
10
+ module.exports = [
11
+ {
12
+ name: 'tascan_list_devices',
13
+ description: 'List registered devices (RFID portals, barcode guns, PLCs, cameras, robots, tools, sensors) in the organization. Filter by status (active/revoked) or kind; paginate with an opaque cursor. Never returns a credential hash.',
14
+ inputSchema: {
15
+ type: 'object',
16
+ properties: {
17
+ status: { type: 'string', enum: ['active', 'revoked'], description: 'Filter by device status' },
18
+ kind: { type: 'string', enum: ['rfid_portal', 'barcode', 'nfc_reader', 'tool', 'plc', 'camera', 'robot', 'sensor', 'other'], description: 'Filter by device kind' },
19
+ limit: { type: 'number', description: 'Max rows to return (1-200, default 50)' },
20
+ cursor: { type: 'string', description: 'Opaque cursor from a previous response next_cursor' }
21
+ }
22
+ },
23
+ annotations: { title: 'List Devices', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
24
+ handler: async (args, api) => {
25
+ const qs = [];
26
+ if (args.status !== undefined) qs.push(`status=${encodeURIComponent(args.status)}`);
27
+ if (args.kind !== undefined) qs.push(`kind=${encodeURIComponent(args.kind)}`);
28
+ if (args.limit !== undefined) qs.push(`limit=${encodeURIComponent(args.limit)}`);
29
+ if (args.cursor !== undefined) qs.push(`cursor=${encodeURIComponent(args.cursor)}`);
30
+ const result = await api('GET', '/devices' + (qs.length ? '?' + qs.join('&') : ''));
31
+ const data = result.data || [];
32
+ const out = `${data.length} device(s)${result.has_more ? ' (more available, pass cursor: ' + result.next_cursor + ')' : ''}\n\n${JSON.stringify(data, null, 2)}`;
33
+ return out.length > 12000 ? out.slice(0, 12000) + '\n...(truncated at 12000 chars)' : out;
34
+ }
35
+ },
36
+ {
37
+ name: 'tascan_get_device',
38
+ description: 'Get one device by id: kind, name, status, location zone, credential rotation timestamps, revocation reason, and the last 20 evidence_events ids it produced. Never returns a credential hash.',
39
+ inputSchema: {
40
+ type: 'object',
41
+ properties: { device_id: { type: 'string', description: 'Device ID' } },
42
+ required: ['device_id']
43
+ },
44
+ annotations: { title: 'Get Device', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
45
+ handler: async (args, api) => {
46
+ if (!args.device_id) throw new Error('device_id is required');
47
+ const result = await api('GET', `/devices/${args.device_id}`);
48
+ return JSON.stringify(result.data, null, 2);
49
+ }
50
+ },
51
+ {
52
+ name: 'tascan_revoke_device',
53
+ description: 'Revoke a device credential immediately — the kill switch for a lost, stolen, or decommissioned device (RFID portal, barcode gun, PLC, camera, robot, tool, sensor). Its next evidence post is refused. Idempotent: revoking an already-revoked device reports already_revoked and mints no second receipt. Requires the device:admin permission (owner-only, needs full, never reachable through OAuth).',
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ device_id: { type: 'string', description: 'Device ID to revoke' },
58
+ reason: { type: 'string', description: 'Why this device is being revoked (up to 500 characters)' }
59
+ },
60
+ required: ['device_id']
61
+ },
62
+ annotations: { title: 'Revoke Device', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
63
+ handler: async (args, api) => {
64
+ if (!args.device_id) throw new Error('device_id is required');
65
+ const result = await api('POST', `/devices/${args.device_id}/revoke`, { reason: args.reason });
66
+ return result.already ? `Device ${args.device_id} was already revoked.` : `Device ${args.device_id} revoked.`;
67
+ }
68
+ }
69
+ ];
package/tools.cjs CHANGED
@@ -271,7 +271,7 @@ const TOOLS = [
271
271
  properties: {
272
272
  title: { type: 'string', description: 'Task title' },
273
273
  description: { type: 'string', description: 'Task description' },
274
- response_type: { type: 'string', enum: ['checkbox', 'photo', 'text', 'number', 'date', 'choice'], description: 'CRITICAL: "text" for names, phones, emails, notes, addresses, any free-form input. "photo" for tasks needing photographic proof (inspections, serial numbers, packed cases). "checkbox" ONLY for simple yes/no confirmations. "number" for numeric values. "date" for dates. "choice" for multiple-choice (needs response_config.options). Most info-collection tasks should be "text", most verification tasks should be "photo".' },
274
+ response_type: { type: 'string', enum: ['checkbox', 'photo', 'text', 'number', 'date', 'choice', 'link', 'url', 'file'], description: 'CRITICAL: "text" for names, phones, emails, notes, addresses, any free-form input. "photo" for tasks needing photographic proof (inspections, serial numbers, packed cases). "checkbox" ONLY for simple yes/no confirmations. "number" for numeric values. "date" for dates. "choice" for multiple-choice (needs response_config.options). "link" = the worker SUBMITS a URL (auto-verified by an http probe). "url" = the worker VISITS a preset URL and confirms. "file" = the worker uploads a file. Most info-collection tasks should be "text", most verification tasks should be "photo".' },
275
275
  response_config: { type: 'object', description: 'Response configuration. For "choice": {options: ["A","B","C"]}. For "text": {placeholder, multiline}. For "number": {placeholder}. For "date": {label, include_text}.' },
276
276
  is_safety_checkpoint: { type: 'boolean', description: 'Safety-critical task flag' },
277
277
  requires_photo: { type: 'boolean', description: 'Require photo on completion' },
@@ -284,7 +284,7 @@ const TOOLS = [
284
284
  type: 'object',
285
285
  properties: {
286
286
  title: { type: 'string', description: 'Subtask title (e.g. "Set 1")' },
287
- response_type: { type: 'string', enum: ['checkbox', 'number', 'text', 'choice'], description: 'Typed subtask response. "number" for per-set values (reps/weight), "text" for notes, "choice" needs response_config.options. Default checkbox.' },
287
+ response_type: { type: 'string', enum: ['checkbox', 'number', 'text', 'choice', 'link', 'url', 'file'], description: 'Typed subtask response. "number" for per-set values (reps/weight), "text" for notes, "choice" needs response_config.options. "link" = the worker submits a URL. "url" = the worker visits a preset URL and confirms. "file" = the worker uploads a file. Default checkbox.' },
288
288
  response_config: { type: 'object', description: 'Same shape as task response_config' },
289
289
  requires_photo: { type: 'boolean' }
290
290
  },
@@ -296,13 +296,14 @@ const TOOLS = [
296
296
  required: ['title']
297
297
  },
298
298
  description: 'Array of tasks to create'
299
- }
299
+ },
300
+ idempotency_key: { type: 'string', description: 'B1: optional replay key (up to 1024 chars, no braces) sent verbatim as the Idempotency-Key header instead of a fresh one per call — a retried call with the SAME key replays the original response instead of creating the tasks twice.' }
300
301
  },
301
302
  required: ['list_id', 'tasks']
302
303
  },
303
304
  annotations: { title: 'Add Tasks', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
304
305
  handler: async (args, api) => {
305
- const result = await api('POST', `/lists/${args.list_id}/tasks`, args.tasks);
306
+ const result = await api('POST', `/lists/${args.list_id}/tasks`, args.tasks, args.idempotency_key);
306
307
  return `${result.count} task(s) created in list ${args.list_id}\n\n${JSON.stringify(result.data, null, 2)}`;
307
308
  }
308
309
  },
@@ -505,7 +506,7 @@ const TOOLS = [
505
506
  task_id: { type: 'string', description: 'Task ID' },
506
507
  title: { type: 'string', description: 'New title' },
507
508
  description: { type: 'string', description: 'New description' },
508
- response_type: { type: 'string', enum: ['checkbox', 'photo', 'text', 'number', 'date', 'choice'], description: 'See tascan_add_tasks for guidance. "text" for info collection, "photo" for visual proof, "checkbox" for yes/no only.' },
509
+ response_type: { type: 'string', enum: ['checkbox', 'photo', 'text', 'number', 'date', 'choice', 'link', 'url', 'file'], description: 'See tascan_add_tasks for guidance. "text" for info collection, "photo" for visual proof, "checkbox" for yes/no only, "link" for the worker submitting a URL, "url" for the worker visiting a preset URL and confirming, "file" for the worker uploading a file.' },
509
510
  response_config: { type: 'object', description: 'Response configuration. For "choice": {options: [...]}. See tascan_add_tasks.' },
510
511
  is_safety_checkpoint: { type: 'boolean', description: 'Safety-critical flag' },
511
512
  requires_photo: { type: 'boolean', description: 'Require photo' },
@@ -539,7 +540,7 @@ const TOOLS = [
539
540
  properties: {
540
541
  title: { type: 'string' },
541
542
  description: { type: 'string' },
542
- response_type: { type: 'string', enum: ['checkbox', 'text', 'number', 'date', 'choice', 'photo'], description: '"date" for scheduling windows, "text" for info requests, "choice" needs response_config.options' },
543
+ response_type: { type: 'string', enum: ['checkbox', 'text', 'number', 'date', 'choice', 'photo', 'link', 'url', 'file'], description: '"date" for scheduling windows, "text" for info requests, "choice" needs response_config.options. "link" = the worker submits a URL. "url" = the worker visits a preset URL and confirms. "file" = the worker uploads a file.' },
543
544
  response_config: { type: 'object' }
544
545
  },
545
546
  required: ['title']
@@ -580,7 +581,7 @@ const TOOLS = [
580
581
  properties: {
581
582
  title: { type: 'string', description: 'Subtask title (e.g. "Set 1")' },
582
583
  description: { type: 'string', description: 'Optional detail' },
583
- response_type: { type: 'string', enum: ['checkbox', 'number', 'text', 'choice'], description: 'Default checkbox. "number" for per-set values. "choice" needs response_config.options.' },
584
+ response_type: { type: 'string', enum: ['checkbox', 'number', 'text', 'choice', 'link', 'url', 'file'], description: 'Default checkbox. "number" for per-set values. "choice" needs response_config.options. "link" = the worker submits a URL. "url" = the worker visits a preset URL and confirms. "file" = the worker uploads a file.' },
584
585
  response_config: { type: 'object', description: 'For "choice": {options: [...]}. For "number"/"text": {placeholder}.' },
585
586
  requires_photo: { type: 'boolean', description: 'Require photo on completion' },
586
587
  sort_order: { type: 'number', description: 'Explicit position (defaults to end, in array order)' }
@@ -621,7 +622,7 @@ const TOOLS = [
621
622
  subtask_id: { type: 'string', description: 'Subtask ID' },
622
623
  title: { type: 'string' },
623
624
  description: { type: 'string' },
624
- response_type: { type: 'string', enum: ['checkbox', 'number', 'text', 'choice'] },
625
+ response_type: { type: 'string', enum: ['checkbox', 'number', 'text', 'choice', 'link', 'url', 'file'], description: '"link" = the worker submits a URL. "url" = the worker visits a preset URL and confirms. "file" = the worker uploads a file.' },
625
626
  response_config: { type: 'object' },
626
627
  requires_photo: { type: 'boolean' },
627
628
  sort_order: { type: 'number' }
@@ -1112,7 +1113,8 @@ const TOOLS = [
1112
1113
  phone: { type: 'string', description: 'Raw phone number (e.g. "+17025551234") — used when no worker_id given' },
1113
1114
  message: { type: 'string', description: 'Message text. Transactional and work-related only.' },
1114
1115
  list_id: { type: 'string', description: 'Optional task list ID — appends a tap-to-open worker checklist link' },
1115
- include_link: { type: 'boolean', description: 'When a list_id is given, append the tap-to-open link to the SMS body (default true). Set false to send the message text alone — the link is still returned for you to share another way.' }
1116
+ include_link: { type: 'boolean', description: 'When a list_id is given, append the tap-to-open link to the SMS body (default true). Set false to send the message text alone — the link is still returned for you to share another way.' },
1117
+ idempotency_key: { type: 'string', description: 'B1: optional replay key (up to 1024 chars, no braces) sent verbatim as the Idempotency-Key header instead of a fresh one per call — a retried call with the SAME key replays the original response instead of sending a second text.' }
1116
1118
  },
1117
1119
  required: ['message']
1118
1120
  },
@@ -1124,7 +1126,7 @@ const TOOLS = [
1124
1126
  message: args.message,
1125
1127
  list_id: args.list_id,
1126
1128
  include_link: args.include_link
1127
- });
1129
+ }, args.idempotency_key);
1128
1130
  const d = result.data;
1129
1131
  let text = `SMS sent!\n`;
1130
1132
  text += ` To: ${d.worker_name ? d.worker_name + ' (' + d.to + ')' : d.to}\n`;
@@ -1441,8 +1443,8 @@ const TOOLS = [
1441
1443
  enforce_on_list: { type: 'boolean', description: 'Zone-lock the task list — it cannot be started from outside the radius' },
1442
1444
  notify_on_enter: { type: 'boolean', description: 'Email the manager when a worker enters this zone (danger areas)' },
1443
1445
  notify_on_exit: { type: 'boolean', description: 'Email the manager when a worker leaves this zone (accountability — sign in then disappear)' },
1444
- auto_clock_in: { type: 'boolean', description: 'Entering the zone writes a shift_start clock-in event' },
1445
- auto_clock_out: { type: 'boolean', description: 'Leaving the zone writes a shift_end clock-out event' },
1446
+ auto_clock_in: { type: 'boolean', description: 'Writes a shift_start when the WORKER PAGE, while open, sees the phone enter the zone; detection is pull-based, there is no background geofencing. For a background-free presence event use a gate NFC/QR scan.' },
1447
+ auto_clock_out: { type: 'boolean', description: 'Writes a shift_end when the WORKER PAGE, while open, sees the phone leave the zone; detection is pull-based, there is no background geofencing. For a background-free presence event use a gate NFC/QR scan.' },
1446
1448
  notify_email: { type: 'string', description: 'Alert recipient override — defaults to all org admins' },
1447
1449
  polygon: { type: 'array', items: { type: 'array', items: { type: 'number' } }, description: 'Polygon/rectangle zone instead of a circle: vertices as [[lat,lng], ...], at least 3. lat/lng/radius_m are then computed (centroid + bounding radius) — still pass lat/lng but they are overridden.' },
1448
1450
  description: { type: 'string', description: 'Shown to workers on the Site Gate page' },
@@ -1496,7 +1498,8 @@ const TOOLS = [
1496
1498
  radius_m: { type: 'number' }, task_list_id: { type: 'string' },
1497
1499
  enforce_on_list: { type: 'boolean' }, is_active: { type: 'boolean' },
1498
1500
  notify_on_enter: { type: 'boolean' }, notify_on_exit: { type: 'boolean' },
1499
- auto_clock_in: { type: 'boolean' }, auto_clock_out: { type: 'boolean' },
1501
+ auto_clock_in: { type: 'boolean', description: 'Writes a shift_start when the WORKER PAGE, while open, sees the phone enter the zone; detection is pull-based, there is no background geofencing. For a background-free presence event use a gate NFC/QR scan.' },
1502
+ auto_clock_out: { type: 'boolean', description: 'Writes a shift_end when the WORKER PAGE, while open, sees the phone leave the zone; detection is pull-based, there is no background geofencing. For a background-free presence event use a gate NFC/QR scan.' },
1500
1503
  notify_email: { type: 'string' },
1501
1504
  polygon: { type: 'array', items: { type: 'array', items: { type: 'number' } }, description: 'Replace geometry with a polygon ([[lat,lng],...], ≥3 vertices); pass null to revert to a circle' },
1502
1505
  kind: { type: 'string', enum: ['work_site', 'hazard', 'containment', 'restricted'] },
@@ -1799,13 +1802,15 @@ const TOOLS = [
1799
1802
  amount_cents: { type: 'number', description: 'Amount in cents ($1 min, $10,000 max)' },
1800
1803
  payer_email: { type: 'string', description: 'Who pays — receives the pay link on verification' },
1801
1804
  payer_name: { type: 'string' },
1802
- memo: { type: 'string', description: 'What the payment is for (shown to the payer)' }
1805
+ memo: { type: 'string', description: 'What the payment is for (shown to the payer)' },
1806
+ idempotency_key: { type: 'string', description: 'B1: optional replay key (up to 1024 chars, no braces) sent verbatim as the Idempotency-Key header instead of a fresh one per call — a retried call with the SAME key replays the original pledge instead of creating a second one.' }
1803
1807
  },
1804
1808
  required: ['task_list_id', 'worker_id', 'amount_cents', 'payer_email']
1805
1809
  },
1806
1810
  annotations: { title: 'Request Verified Payment', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1807
1811
  handler: async (args, api) => {
1808
- const result = await api('POST', '/payments', args);
1812
+ const { idempotency_key, ...body } = args;
1813
+ const result = await api('POST', '/payments', body, idempotency_key);
1809
1814
  const p = result.data;
1810
1815
  return `Payment pledged: $${(p.amount_cents / 100).toFixed(2)} to worker ${p.worker_id}\n\nID: ${p.id}\nStatus: ${p.status}\nPayer: ${p.payer_email}\n\n${result.note || 'Pay link is issued automatically when the list is verified complete.'}`;
1811
1816
  }
@@ -1992,16 +1997,20 @@ const TOOLS = [
1992
1997
  // SECURITY DEFINER RPCs behind human completions. The cycle report is the audit surface.
1993
1998
  {
1994
1999
  name: 'tascan_create_cycle',
1995
- description: 'Start an unattended build-review-decide cycle (protocol v0.2). Queues T1 CODE: (or SHELL:) with your build_brief on the AI Inbox and T2 REVIEW: with your review_brief, born blocked on T1. The local executor builds, stores the exact bytes of artifact_paths as a bundle (build_ref = sha256 over the manifest), the independent reviewer reviews THAT bundle, an approve verdict mints a Decision task for the human authority (one SMS), Approve mints an Integrate task for the deploy id. Revise verdicts spawn revisions (cap max_revisions, default 3); reject, human Reject, scope violations or exhausted revisions PARK the cycle (a Parked task with Resume with notes / Close). REQUIRES agent:dispatch:code. Duplicate protection: the same idempotency_key, or the same briefs + paths, within 24 h returns the existing root instead of queueing again (created=false). Optional `reviews[]` attaches a multi-lens review panel (design item 14a) in place of the single OpenAI review — one review task mints per lens and every blocking lens must approve before the Decision task mints. Track with tascan_get_cycle_report (root_id). Nothing spawns a cycle on its own.',
2000
+ description: 'Start an unattended build-review-decide cycle (protocol v0.2). Queues T1 CODE: (or SHELL:) with your build_brief on the AI Inbox and T2 REVIEW: with your review_brief, born blocked on T1. T1 (2026-09-24 throughput): kind=review_only mints T1 as CAPTURE: instead — the executor stores the bundle from the repo at HEAD without a model call and the review runs on that; dry_run=true runs the dispatch PREFLIGHT only (nothing queued) and prints every problem at once (codes title_too_long, brief_rule, brief_names_unbundled_path, artifact_over_cap, migration_as_context (tascan repo only), idempotency_replay, kind_invalid — bad kind, bad revisable_by, review_only + task_type SHELL/RESEARCH, review_only + max_questions 0), then what the API could NOT check (unchecked[]: artifact_missing_at_head always — only the executor sees the repo, and a CAPTURE naming a missing path fails with "[artifact_missing_at_head]" in its error; artifact_over_cap for any path without a byte count — pass artifact_bytes; idempotency_replay only if its lookup failed) and warnings[] (a build_brief naming a context file outside the bundle); a real create that the API refuses prints the same problems[] list (when the transport hands the tool only the first problem\'s text, the tool re-runs the preflight and prints the whole list). The local executor builds, stores the exact bytes of artifact_paths as a bundle (build_ref = sha256 over the manifest), the independent reviewer reviews THAT bundle, an approve verdict mints a Decision task for the human authority (one SMS), Approve mints an Integrate task for the deploy id. Revise verdicts spawn revisions (cap max_revisions, default 3); reject, human Reject, scope violations or exhausted revisions PARK the cycle (a Parked task with Resume with notes / Close). REQUIRES agent:dispatch:code. Duplicate protection: the same idempotency_key, or (keyless) the same briefs + paths on a live root, within 24 h is refused by the preflight as idempotency_replay (400, nothing queued, the existing root_id in the problem) and printed as DUPLICATE with that root_id; only a replay the preflight could not see (a race) comes back from the RPC as 200 created=false, printed the same way. Optional `reviews[]` attaches a multi-lens review panel (design item 14a) in place of the single OpenAI review — one review task mints per lens and every blocking lens must approve before the Decision task mints. Track with tascan_get_cycle_report (root_id). Nothing spawns a cycle on its own.',
1996
2001
  inputSchema: {
1997
2002
  type: 'object',
1998
2003
  properties: {
1999
2004
  project_id: { type: 'string', description: 'Working project (UUID). Its Decisions and Agent Questions lists are created on the first cycle (coord_ensure_lists). Requires the org human authority to be configured (coord_set_authority) — otherwise 403.' },
2000
- title: { type: 'string', description: 'Short human title (1-200 chars). T1 becomes "CODE: <title>", T2 "REVIEW: <title>".' },
2005
+ title: { type: 'string', description: 'Short human title (1-200 chars). T1 becomes "CODE: <title>" ("CAPTURE: <title>" when kind=review_only), T2 "REVIEW: <title>".' },
2001
2006
  build_brief: { type: 'string', description: 'The executor prompt (1-40000 chars). Executed verbatim by the local Claude Code runner as a CODE:/SHELL: task — write it as a complete instruction, name the files, forbid nothing the runner already forbids (deploy/commit/push are refused by its context).' },
2002
2007
  review_brief: { type: 'string', description: 'The reviewer prompt (1-40000 chars). The reviewer reads the stored bundle (tascan_get_build / tascan_get_build_file), the task text and the filtered trail; it returns approve, revise, reject or needs_input as strict JSON bound to the build_ref. Still required even when `reviews[]` is given (kept as T2\'s legacy description text).' },
2003
2008
  artifact_paths: { type: 'array', items: { type: 'string' }, description: 'Repo-relative paths the build binds (1-64). Exactly these files are stored as the bundle and hashed into build_ref; files the executor touches OUTSIDE them fail the scope check and park the cycle. No .., not absolute, no drive letter, no backslash.' },
2004
- task_type: { type: 'string', enum: ['CODE', 'SHELL'], description: 'T1 prefix (default CODE).' },
2009
+ task_type: { type: 'string', enum: ['CODE', 'SHELL', 'RESEARCH'], description: 'T1 prefix (default CODE). CAPTURE is never caller-settable: kind=review_only makes T1 "CAPTURE:" itself. With kind=review_only the value is sent as given — CODE is accepted (T1 is still CAPTURE), SHELL / RESEARCH are refused by the API as kind_invalid.' },
2010
+ kind: { type: 'string', enum: ['build', 'review_only'], description: 'Cycle kind (default build). review_only = no model builds anything: T1 is "CAPTURE: <title>", a local-executor task that stores artifact_paths from the repo at HEAD as the bundle (same build_ref, size cap and scope check as a CODE build) and completes in seconds; T2 REVIEW then reviews that bundle. Use it to get an independent review of documents or of code already committed by hand. Stored as coord.kind_of_cycle on the root.' },
2011
+ revisable_by: { type: 'string', enum: ['dispatcher', 'executor'], description: 'review_only cycles only — who fixes a revise verdict. dispatcher (default): the cycle parks on revise as today and you re-dispatch. executor: a revise spawns a revision CAPTURE task (up to max_revisions) that is born blocked on a question task "Revise the documents, then answer to release" addressed to you — fix the files in the repo, answer that question (tascan_post_message kind=answer), and the capture re-runs on the fixed HEAD. Build cycles always revise through the executor regardless of this field.' },
2012
+ dry_run: { type: 'boolean', description: 'true = PREFLIGHT only: the API validates the whole dispatch and returns every problem at once (code, path, detail) plus the unchecked[] list (what only the executor / the RPC can decide) and warnings[] — nothing is queued, no idempotency key is consumed. Recommended before every real create. Default false.' },
2013
+ artifact_bytes: { type: 'object', additionalProperties: { type: 'integer' }, description: 'Optional {path: bytes} for artifact_paths — the byte length of each file in your checkout. The API cannot read the repo, so artifact_over_cap (262144-byte reviewable cap per file) is only checked for paths you give a byte count; every other path is reported under unchecked (artifact_over_cap, unchecked_at_api) — never guessed from an earlier stored bundle. Same on dry_run and the real create.' },
2005
2014
  max_revisions: { type: 'integer', description: 'Revision cap (default 3): at most max_revisions + 1 builds and reviews.' },
2006
2015
  max_questions: { type: 'integer', description: 'Questions a runner may ask per task before the attempt fails (default 3).' },
2007
2016
  repo: { type: 'string', description: 'Which codebase on the executor the build runs in — an alias from the executor\'s allowlist (tascan-agent/repos.json), e.g. "tascan" (default), "merchskipper", "inkskipper", "rangerlizzy", "cardvault", "take1", "fitcheck", "safegrid", "eternity", "maniacs". artifact_paths are relative to that repo. An unknown alias is refused by the executor (repo_unknown) and the cycle parks.' },
@@ -2027,7 +2036,7 @@ const TOOLS = [
2027
2036
  checkpoint_description: { type: 'string', description: 'Optional description template for the Decision task; the build_ref, preview and findings summary are appended.' },
2028
2037
  integrate_title: { type: 'string', description: 'Optional title template for the Integrate task (default "Integrate: <title>").' },
2029
2038
  integrate_description: { type: 'string', description: 'Optional description template for the Integrate task.' },
2030
- idempotency_key: { type: 'string', description: 'Optional caller key (≤ 200 chars). The same key within 24 h returns the existing cycle (created=false) instead of a duplicate dispatch.' }
2039
+ idempotency_key: { type: 'string', description: 'Optional caller key (≤ 200 chars). The same key within 24 h is refused as idempotency_replay (nothing queued) and printed as DUPLICATE with the existing root_id — safe to retry after a lost answer.' }
2031
2040
  },
2032
2041
  required: ['project_id', 'title', 'build_brief', 'review_brief', 'artifact_paths']
2033
2042
  },
@@ -2035,9 +2044,21 @@ const TOOLS = [
2035
2044
  handler: async (args, api) => {
2036
2045
  const paths = Array.isArray(args.artifact_paths) ? args.artifact_paths : (typeof args.artifact_paths === 'string' ? [args.artifact_paths] : []);
2037
2046
  if (!paths.length) throw new Error('artifact_paths is required (1-64 repo-relative paths)');
2047
+ // T1 (2026-09-24): cycle kind + revisable_by + dry_run. Not validated here: an unknown kind / revisable_by is sent
2048
+ // as given and the API's preflight names it kind_invalid in problems[] with everything else wrong (one list, one
2049
+ // round trip — and the same name migration 214 uses). CAPTURE is the one task_type a caller can never set.
2050
+ const kind = args.kind == null || args.kind === '' ? 'build' : String(args.kind).trim().toLowerCase();
2051
+ let revisableBy = null;
2052
+ if (args.revisable_by != null && args.revisable_by !== '') revisableBy = String(args.revisable_by).trim().toLowerCase();
2053
+ if (args.task_type != null && String(args.task_type).trim().toUpperCase() === 'CAPTURE') throw new Error('task_type must be CODE, SHELL or RESEARCH — CAPTURE is set by kind=review_only, never by the caller');
2054
+ const dryRun = args.dry_run === true || args.dry_run === 'true';
2038
2055
  const options = {};
2039
2056
  if (args.repo) options.repo = String(args.repo);
2040
2057
  for (const k of ['task_type', 'max_revisions', 'max_questions', 'max_cost_micro_usd', 'preview_url']) if (args[k] != null && args[k] !== '') options[k] = args[k];
2058
+ // kind goes on the wire only when it is not the default (a build create keeps the pre-T1 shape); task_type is
2059
+ // forwarded as given even for review_only — the API refuses SHELL / RESEARCH there as kind_invalid.
2060
+ if (kind !== 'build') options.kind = kind;
2061
+ if (revisableBy) options.revisable_by = revisableBy;
2041
2062
  if (Array.isArray(args.reviews) && args.reviews.length) options.reviews = args.reviews;
2042
2063
  const templates = {};
2043
2064
  if (args.checkpoint_title || args.checkpoint_description) templates.checkpoint = { ...(args.checkpoint_title ? { title: args.checkpoint_title } : {}), ...(args.checkpoint_description ? { description: args.checkpoint_description } : {}) };
@@ -2045,7 +2066,88 @@ const TOOLS = [
2045
2066
  if (Object.keys(templates).length) options.templates = templates;
2046
2067
  const body = { project_id: args.project_id, title: args.title, build_brief: args.build_brief, review_brief: args.review_brief, artifact_paths: paths, options };
2047
2068
  if (args.idempotency_key) body.idempotency_key = args.idempotency_key;
2048
- const result = await api('POST', '/coord/cycles', body);
2069
+ // artifact_bytes {path: bytes}: the only way the API can decide artifact_over_cap for a file it has never stored.
2070
+ // Forwarded verbatim (the API keeps the entries that name an artifact path and are non-negative integers).
2071
+ if (args.artifact_bytes && typeof args.artifact_bytes === 'object' && !Array.isArray(args.artifact_bytes)) {
2072
+ const ab = {};
2073
+ for (const [p, n] of Object.entries(args.artifact_bytes)) if (Number.isInteger(Number(n)) && Number(n) >= 0) ab[p] = Number(n);
2074
+ if (Object.keys(ab).length) body.artifact_bytes = ab;
2075
+ }
2076
+ // Preflight rendering — the API's shape (api-v1 coordCyclePreflight):
2077
+ // problems [{code, path?, detail}] blocking; the real POST refuses with the FULL list
2078
+ // warnings [{code, path, brief, detail}] informational (build_brief naming a context file)
2079
+ // unchecked [{code, status:'unchecked_at_api', paths?, detail}] what only the executor / the RPC decides
2080
+ // One renderer prints a dry_run answer and a refused real create, so the dispatcher reads one shape either way.
2081
+ const listOf = (r, key) => (r && Array.isArray(r[key])) ? r[key] : (r && r.data && Array.isArray(r.data[key])) ? r.data[key] : [];
2082
+ const renderProblems = (list) => (Array.isArray(list) ? list : []).map((p, i) => {
2083
+ const o = (p && typeof p === 'object') ? p : { code: String(p) };
2084
+ return `${i + 1}. ${o.code || 'unknown'}${o.path ? ` — ${o.path}` : ''}${o.detail ? `: ${o.detail}` : ''}`;
2085
+ }).join('\n');
2086
+ const renderUnchecked = (list) => (Array.isArray(list) && list.length)
2087
+ ? '\n\nUNCHECKED at the API (' + list.length + ') — the executor applies these when it captures / builds; a failure there parks the cycle:\n' + list.map(u => {
2088
+ const o = (u && typeof u === 'object') ? u : { code: String(u) };
2089
+ const where = Array.isArray(o.paths) && o.paths.length ? ` [${o.paths.join(', ')}]` : (o.path ? ` — ${o.path}` : '');
2090
+ return `- ${o.code || 'unknown'}${where}${o.detail ? `: ${o.detail}` : ''}`;
2091
+ }).join('\n')
2092
+ : '';
2093
+ const renderWarnings = (list) => (Array.isArray(list) && list.length)
2094
+ ? '\n\nWARNINGS (' + list.length + ', not refusals):\n' + list.map(w => {
2095
+ const o = (w && typeof w === 'object') ? w : { code: String(w) };
2096
+ return `- ${o.code || 'unknown'}${o.path ? ` — ${o.path}` : ''}${o.brief ? ` (${o.brief})` : ''}${o.detail ? `: ${o.detail}` : ''}`;
2097
+ }).join('\n')
2098
+ : '';
2099
+ const cap = (text) => (text.length > 12000 ? text.slice(0, 12000) + '\n… [truncated at 12000 chars]' : text);
2100
+ // The api() bridge throws on a non-2xx; a refused create (400 {error, problems[]}) reaches the handler as an
2101
+ // Error that may carry the body as .problems / .data / .body (bridge-attached) or as a JSON message.
2102
+ const problemsOfError = (e) => {
2103
+ if (!e) return null;
2104
+ for (const src of [e.problems, e.data && e.data.problems, e.body && e.body.problems, e.response && e.response.problems]) if (Array.isArray(src)) return src;
2105
+ const msg = typeof e.message === 'string' ? e.message.trim() : '';
2106
+ if (msg.startsWith('{')) { try { const j = JSON.parse(msg); if (Array.isArray(j.problems)) return j.problems; } catch (_) { /* not JSON */ } }
2107
+ return null;
2108
+ };
2109
+ const tailLines = `\nkind: ${kind}` + (revisableBy ? `\nrevisable_by: ${revisableBy}` : '') + `\nartifact_paths: ${paths.join(', ')}`;
2110
+ if (dryRun) {
2111
+ const result = await api('POST', '/coord/cycles?dry_run=1', { ...body, dry_run: true });
2112
+ const list = listOf(result, 'problems'), warnings = listOf(result, 'warnings'), unchecked = listOf(result, 'unchecked');
2113
+ const ok = result.ok === true || (result.data && result.data.ok === true) || (list.length === 0 && result.ok !== false);
2114
+ // "0 problem(s)" is never "accepted as written": the API has no repo, so existence at HEAD (and the size of
2115
+ // any path without a byte count) is decided by the executor at capture / build time — printed under UNCHECKED.
2116
+ let text = `PREFLIGHT: ${list.length} problem(s)` + (ok && !list.length ? ' — nothing the API can check is wrong; the items under UNCHECKED are decided by the executor.' : '') + '\nNothing was queued (dry_run).';
2117
+ if (list.length) text += '\n' + renderProblems(list) + '\n\nFix every item above, then call again with dry_run=false.';
2118
+ text += renderUnchecked(unchecked) + renderWarnings(warnings) + tailLines;
2119
+ return cap(text);
2120
+ }
2121
+ let result;
2122
+ try {
2123
+ result = await api('POST', '/coord/cycles', body);
2124
+ } catch (e) {
2125
+ let list = problemsOfError(e), warnings = null, unchecked = null, how = 'the API answered 400 with problems[]';
2126
+ if (!list) {
2127
+ // Both real bridges (tascan-mcp/index.js, mcp-endpoint.js) throw Error(data.error) and drop the rest of
2128
+ // the 400 body — and the API sets error = problems[0].detail, so the dispatcher would see ONE problem per
2129
+ // round trip, the exact cost the preflight exists to remove. Re-run the preflight (writes nothing, same
2130
+ // scope tier) and print the whole list — but only when the API's error text IS one of the preflight
2131
+ // details, so a refusal from somewhere else (RPC 22023, 403, 503, a post-create failure) is never
2132
+ // re-labelled as a preflight refusal. Any failure of the re-run falls back to the original error.
2133
+ const msg = typeof e.message === 'string' ? e.message.trim() : '';
2134
+ let pre = null;
2135
+ try { pre = await api('POST', '/coord/cycles?dry_run=1', { ...body, dry_run: true }); } catch (_) { pre = null; }
2136
+ const preList = listOf(pre, 'problems');
2137
+ if (msg && preList.length && preList.some(p => p && typeof p.detail === 'string' && p.detail.trim() === msg)) {
2138
+ list = preList; warnings = listOf(pre, 'warnings'); unchecked = listOf(pre, 'unchecked');
2139
+ how = 'the transport surfaced only the first problem; the list below is the preflight re-run on the same body';
2140
+ }
2141
+ }
2142
+ if (!list) throw e;
2143
+ // A retry after a lost 201 is refused as idempotency_replay with the existing root in the problem: print it as
2144
+ // the DUPLICATE answer (root_id + where to read it), not as a refusal to "fix" — changing the key would dispatch twice.
2145
+ const only = list.length === 1 && list[0] && typeof list[0] === 'object' ? list[0] : null;
2146
+ if (only && only.code === 'idempotency_replay' && only.root_id) {
2147
+ return `DUPLICATE — nothing was queued. A cycle with the same ${only.by === 'brief_sha256' ? 'briefs and paths' : 'idempotency_key'} exists from the last 24 h (the API refused the replay: idempotency_replay).\nroot_id: ${only.root_id}\n\nRead it: tascan_get_cycle_report root_id=${only.root_id}`;
2148
+ }
2149
+ return cap(`REFUSED — nothing was queued. PREFLIGHT: ${list.length} problem(s) (${how})\n${renderProblems(list)}\n\nFix every item above and call again (dry_run=true first to confirm).` + renderUnchecked(unchecked) + renderWarnings(warnings));
2150
+ }
2049
2151
  const d = result.data || {};
2050
2152
  if (result.duplicate || d.created === false) {
2051
2153
  return `DUPLICATE — nothing was queued. A cycle with the same ${args.idempotency_key ? 'idempotency_key' : 'briefs and paths'} exists from the last 24 h (status ${d.status || 'active'}).\nroot_id: ${d.root_id}\nreview_id: ${d.review_id || 'n/a'}\n\nRead it: tascan_get_cycle_report root_id=${d.root_id}`;
@@ -2053,7 +2155,16 @@ const TOOLS = [
2053
2155
  const reviewLine = Array.isArray(d.review_ids) && d.review_ids.length > 0
2054
2156
  ? `review_ids (T2 REVIEW panel, ${d.review_ids.length} lens${d.review_ids.length > 1 ? 'es' : ''}, blocked on T1): ${d.review_ids.join(', ')}`
2055
2157
  : `review_id (T2 REVIEW, blocked on T1): ${d.review_id}`;
2056
- return `Cycle created.\nroot_id (T1 ${options.task_type || 'CODE'}: build): ${d.root_id}\n${reviewLine}\nbrief_sha256: ${d.brief_sha256}\ndecisions_list_id: ${d.decisions_list_id}\nquestions_list_id: ${d.questions_list_id}\nartifact_paths: ${paths.join(', ')}\n\nNext: the local executor claims T1 on its next poll (≤ 30 s), stores the bundle and finishes with build_ref; T2 is released from that finish. Follow with tascan_get_cycle_report root_id=${d.root_id} or tascan_get_task task_id=${d.root_id} (agent + trail blocks). Nobody is texted until the reviewer approves.`;
2158
+ const t1 = kind === 'review_only' ? 'CAPTURE: capture, no model call' : `${options.task_type || 'CODE'}: build`;
2159
+ const next = kind === 'review_only'
2160
+ ? `Next: the local executor claims T1 CAPTURE on its next poll (≤ 30 s), stores artifact_paths from the repo at HEAD as the bundle WITHOUT invoking a model and finishes with build_ref; T2 is released from that finish. On a revise verdict the cycle ${revisableBy === 'executor' ? 'mints a revision CAPTURE task blocked on a question to you ("Revise the documents, then answer to release") — fix the files, then answer it with tascan_post_message kind=answer' : 'parks (revisable_by=dispatcher) and you re-dispatch after fixing the files'}.`
2161
+ : 'Next: the local executor claims T1 on its next poll (≤ 30 s), stores the bundle and finishes with build_ref; T2 is released from that finish.';
2162
+ // The legacy first three lines are a pinned shape (panel-api-harness P5b): kind / revisable_by print only when
2163
+ // non-default, after the review line, so a plain build cycle's summary is byte-identical to pre-T1.
2164
+ const kindLines = (kind === 'review_only' ? `\nkind: review_only` : '') + (revisableBy ? `\nrevisable_by: ${revisableBy}` : '');
2165
+ // A 201 carries the preflight's warnings[] beside data{} (unchecked[] too, but the executor decides those and a
2166
+ // failure parks the cycle visibly — only the warnings are worth the dispatcher's eyes on a success).
2167
+ return cap(`Cycle created.\nroot_id (T1 ${t1}): ${d.root_id}\n${reviewLine}${kindLines}\nbrief_sha256: ${d.brief_sha256}\ndecisions_list_id: ${d.decisions_list_id}\nquestions_list_id: ${d.questions_list_id}\nartifact_paths: ${paths.join(', ')}\n\n${next} Follow with tascan_get_cycle_report root_id=${d.root_id} or tascan_get_task task_id=${d.root_id} (agent + trail blocks). Nobody is texted until the reviewer approves.` + renderWarnings(listOf(result, 'warnings')));
2057
2168
  }
2058
2169
  },
2059
2170
  {
@@ -2086,6 +2197,34 @@ const TOOLS = [
2086
2197
  return text + '\n\n' + JSON.stringify(m, null, 2);
2087
2198
  }
2088
2199
  },
2200
+ {
2201
+ name: 'tascan_record_integration',
2202
+ description: 'D4: the dispatcher records the Netlify deploy id for an authorized cycle — Integrate cards stop landing on Mike for something a key can prove instead (protocol v0.2, migration 181, coord_record_integration). Requires agent:dispatch. The root must be `authorized` (its checkpoint already Approved) with an open Integrate task; deploy_id must be a real Netlify deploy id — 24 lowercase hex, optionally "deployed:<id>" and/or a trailing build-ref hex prefix ("Deploy" and anything else is refused, bad_deploy_id). On success the root flips to `integrated`, the existing "Integrated: deploy …" trail note is posted, and the completion is stamped with your key as the actor (source api:key:<id>) — never as a human on the page. Idempotent: replaying the SAME deploy_id after the root is already integrated returns the same completion (replayed:true); a DIFFERENT deploy_id after integration is refused (409) without changing anything. A human may still complete the Integrate card on the worker page as an ops fallback, but only with a real deploy id too.',
2203
+ inputSchema: {
2204
+ type: 'object',
2205
+ properties: {
2206
+ root_id: { type: 'string', description: 'The cycle root = the T1 build task id (tascan_get_cycle_report / tascan_create_cycle). Must currently be authorized (checkpoint Approved, Integrate task open).' },
2207
+ deploy_id: { type: 'string', description: 'The Netlify deploy id: 24 lowercase hex, optionally prefixed "deployed:" and/or followed by a space and a ≥12-hex prefix of the build_ref.' },
2208
+ note: { type: 'string', description: 'Optional note (≤ 2000 chars) recorded alongside the completion — e.g. what changed in this deploy.' }
2209
+ },
2210
+ required: ['root_id', 'deploy_id']
2211
+ },
2212
+ annotations: { title: 'Record Integration', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2213
+ handler: async (args, api) => {
2214
+ const id = String(args.root_id || '').trim().toLowerCase();
2215
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) throw new Error('root_id must be a UUID');
2216
+ const deployId = String(args.deploy_id || '').trim();
2217
+ if (!deployId) throw new Error('deploy_id is required');
2218
+ const body = { deploy_id: deployId };
2219
+ if (args.note) body.note = args.note;
2220
+ const result = await api('POST', `/coord/cycles/${id}/integrate`, body);
2221
+ const d = result.data || {};
2222
+ let text = `Integration recorded for root ${d.root_id || id}: deploy_id ${d.deploy_id}, build ${d.build_ref}.`;
2223
+ text += d.replayed ? '\n(replay — this deploy id was already recorded; nothing new was written.)' : (d.integrated ? '\nRoot status: integrated.' : '');
2224
+ text += `\ncompletion_id: ${d.completion_id}`;
2225
+ return text + '\n\n' + JSON.stringify(d, null, 2);
2226
+ }
2227
+ },
2089
2228
  {
2090
2229
  name: 'tascan_get_build',
2091
2230
  description: 'Manifest of a stored build bundle by build_ref (protocol v0.2 build_artifacts): the exact files the executor produced for the cycle\'s artifact_paths, each with sha256, byte length and whether text content is stored (binary or over-cap files keep the sha only). build_ref = sha256 over the manifest, computed in the database once; the reviewer reviews THESE bytes, the human approves THIS ref, the integrate task records THIS ref. Read tier. Use tascan_get_build_file to read a file. Truncated at 12000 chars. Reading the manifest is discovery, not a read of any file.',
@@ -2193,7 +2332,426 @@ const TOOLS = [
2193
2332
  const result = await api('GET', `/projects/${id}/digest`);
2194
2333
  return result.markdown || '(no digest returned)';
2195
2334
  }
2196
- }
2335
+ },
2336
+ // ─── M1 (2026-09-23): MCP catches up with the protocol — ten tools for routes that already existed in
2337
+ // api-v1.js / coord-dispatch-lib.js / receipt.js but had no MCP tool. See
2338
+ // docs/protocol/briefs/2026-09-23-m1-mcp-protocol-tools.md and the paired changes doc. ───────────────────
2339
+ {
2340
+ name: 'tascan_list_cycles',
2341
+ description: 'List coordination-cycle roots in the organization (protocol v0.2, GET /coord/cycles): root id, title, status, task_type, project, build_ref, deploy_id and the T2 review id, newest first. Filter by project_id and/or status. Read tier — a reviewer\'s read-only key can call this too. Follow up with tascan_get_cycle_report root_id=... for the full audit trail of any row.',
2342
+ inputSchema: {
2343
+ type: 'object',
2344
+ properties: {
2345
+ project_id: { type: 'string', description: 'Optional project UUID to filter to one project\'s cycles.' },
2346
+ status: { type: 'string', description: 'Optional cycle status to filter on (e.g. active, parked, authorized, integrated) — lowercase letters and underscores only, 1-20 chars.' },
2347
+ limit: { type: 'integer', description: 'Max rows to return (default 50, max 200).' }
2348
+ }
2349
+ },
2350
+ annotations: { title: 'List Cycles', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2351
+ handler: async (args, api) => {
2352
+ const qs = [];
2353
+ if (args.project_id != null && args.project_id !== '') {
2354
+ const pid = String(args.project_id).trim().toLowerCase();
2355
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(pid)) throw new Error('project_id must be a UUID');
2356
+ qs.push(`project_id=${encodeURIComponent(pid)}`);
2357
+ }
2358
+ if (args.status != null && args.status !== '') {
2359
+ const st = String(args.status).trim();
2360
+ if (!/^[a-z_]{1,20}$/.test(st)) throw new Error('status must be 1-20 lowercase letters/underscores');
2361
+ qs.push(`status=${encodeURIComponent(st)}`);
2362
+ }
2363
+ if (args.limit != null) qs.push(`limit=${encodeURIComponent(String(args.limit))}`);
2364
+ const result = await api('GET', `/coord/cycles${qs.length ? '?' + qs.join('&') : ''}`);
2365
+ const rows = result.data || [];
2366
+ const lines = [`${result.count != null ? result.count : rows.length} cycle(s)`, ''];
2367
+ rows.forEach(r => lines.push(`${r.root_id} [${r.status}] ${r.title} (${r.task_type || 'CODE'}, project ${r.project_name || r.project_id}${r.deploy_id ? ', deploy ' + r.deploy_id : ''})`));
2368
+ const out = lines.join('\n') + '\n\n' + JSON.stringify(result.data, null, 2);
2369
+ return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
2370
+ }
2371
+ },
2372
+ {
2373
+ name: 'tascan_get_task_trail',
2374
+ description: 'Read a task\'s trail messages (protocol v0.2 trail_messages, GET /tasks/:id/messages): question, answer, handoff and note/discussion entries, last 100, newest last (reading order). Optionally filter to a comma-separated set of kinds. Read tier. Post with tascan_post_message.',
2375
+ inputSchema: {
2376
+ type: 'object',
2377
+ properties: {
2378
+ task_id: { type: 'string', description: 'Task ID (UUID).' },
2379
+ kinds: { type: 'string', description: 'Optional comma-separated kind filter, e.g. question,answer,handoff,note (discussion is stored as note; both names are accepted).' }
2380
+ },
2381
+ required: ['task_id']
2382
+ },
2383
+ annotations: { title: 'Get Task Trail', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2384
+ handler: async (args, api) => {
2385
+ const id = String(args.task_id || '').trim().toLowerCase();
2386
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) throw new Error('task_id must be a UUID');
2387
+ const qs = args.kinds ? `?kinds=${encodeURIComponent(String(args.kinds))}` : '';
2388
+ const result = await api('GET', `/tasks/${id}/messages${qs}`);
2389
+ const msgs = result.data || [];
2390
+ const t = result.task || {};
2391
+ const lines = [`TASK TRAIL: ${t.title || id} (${msgs.length} of up to 100, oldest first)`, t.is_cycle_task ? `cycle task — coord.kind ${t.coord && t.coord.kind}${t.coord && t.coord.blocked_by ? ', blocked by ' + t.coord.blocked_by : ''}` : 'ordinary task', ''];
2392
+ msgs.forEach(m => lines.push(`[${m.created_at}] ${m.kind} by ${m.actor_type}:${m.actor_id}${m.addressee ? ' -> ' + m.addressee : ''}: ${String(m.body || '').slice(0, 300)}`));
2393
+ const out = lines.join('\n') + '\n\n' + JSON.stringify(result, null, 2);
2394
+ return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
2395
+ }
2396
+ },
2397
+ {
2398
+ name: 'tascan_verify_receipt',
2399
+ description: 'Independently verify a TaScan Action Receipt JWS (protocol 6.8, POST /receipts/verify): size, envelope, signature, issuer origin, key lifecycle, hashing profile, value semantics, schema and chain check, with an online issued/serial check for the reference issuer. Public route: no TaScan scope is required by the API itself, though this MCP connection still needs some valid key to place any tools/call. Works for both the full and the public export profile. Keys come only from the issuer\'s own well-known key document (https://app.tascan.io/.well-known/tascan-receipt-keys.json for the reference issuer, or the equivalent well-known path for a foreign one) — this tool never supplies keys itself. Never verifies locally — always calls the reference verifier.',
2400
+ inputSchema: {
2401
+ type: 'object',
2402
+ properties: {
2403
+ jws: { type: 'string', description: 'The compact JWS to verify (from tascan_get_receipt\'s output, or any TaScan-format receipt).' }
2404
+ },
2405
+ required: ['jws']
2406
+ },
2407
+ annotations: { title: 'Verify Receipt', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
2408
+ handler: async (args, api) => {
2409
+ const jws = String(args.jws || '').trim();
2410
+ if (!jws) throw new Error('jws is required');
2411
+ const r = await api('POST', '/receipts/verify', { jws });
2412
+ let receiptId = 'unknown';
2413
+ const sub = r.claims && typeof r.claims.sub === 'string' ? r.claims.sub : null;
2414
+ const m = sub && sub.match(/^tascan:\/\/evidence\/([0-9a-f-]{36})$/);
2415
+ if (m) receiptId = m[1];
2416
+ const lines = [
2417
+ `valid: ${r.valid}`,
2418
+ `reason: ${r.reason || 'none'}`,
2419
+ `kid: ${r.kid || 'unknown'}`,
2420
+ `profile: ${r.profile || 'unknown'}`,
2421
+ `schema_valid: ${r.schema_valid === undefined ? 'not checked' : r.schema_valid}`,
2422
+ `receipt id (from claims.sub): ${receiptId}`,
2423
+ `issuer: ${r.issuer || 'unknown'} (key_source ${r.key_source || 'unknown'})`,
2424
+ `online check: ${r.online ? 'yes' : 'no'} — status ${r.status || 'unknown'}${r.issued != null ? ', issued ' + r.issued : ''}${r.latest_serial != null ? ', latest_serial ' + r.latest_serial : ''}`,
2425
+ r.schema_errors ? `schema_errors: ${JSON.stringify(r.schema_errors)}` : null,
2426
+ r.detail ? `detail: ${r.detail}` : null
2427
+ ].filter(x => x !== null).join('\n');
2428
+ const out = lines + '\n\n' + JSON.stringify(r, null, 2);
2429
+ return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
2430
+ }
2431
+ },
2432
+ {
2433
+ name: 'tascan_get_build_diff',
2434
+ description: 'Store an already-computed diff for one file of a build bundle against a base commit (protocol v0.2 item 6, POST /coord/builds/:build_ref/diff — the parser has no GET for this path; a deployed function ships no git object database, so tascan-agent/deployer.js computes the diff text with its own persistent worktree and this route only validates + stores it via coord_set_artifact_diff). build_sha256 must equal the artifact\'s own stored sha256 or the call is refused; storing a diff replaces that file\'s stored full content with the diff going forward. There is currently no REST route that reads a stored diff back (GET /builds/:build_ref and /builds/:build_ref/file do not surface it) — this tool only writes one. Requires agent:dispatch.',
2435
+ inputSchema: {
2436
+ type: 'object',
2437
+ properties: {
2438
+ build_ref: { type: 'string', description: 'sha256:<64 hex> (or the bare 64 hex) of the build the diff is about.' },
2439
+ path: { type: 'string', description: 'Repo-relative path from the build manifest (tascan_get_build).' },
2440
+ base_ref: { type: 'string', description: 'The commit-ish the diff was computed against (1-200 chars).' },
2441
+ text: { type: 'string', description: 'The already-computed diff text (up to 262144 characters). This tool never computes a diff itself.' },
2442
+ build_sha256: { type: 'string', description: '64-hex sha256 of the file at build_ref/path — must match the stored artifact\'s own sha256.' },
2443
+ base_sha256: { type: 'string', description: 'Optional 64-hex sha256 of the file at base_ref.' },
2444
+ truncated: { type: 'boolean', description: 'True if text is itself a truncated diff.' }
2445
+ },
2446
+ required: ['build_ref', 'path', 'base_ref', 'text', 'build_sha256']
2447
+ },
2448
+ annotations: { title: 'Submit Build Diff', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
2449
+ handler: async (args, api) => {
2450
+ const ref = String(args.build_ref || '').trim().toLowerCase();
2451
+ if (!/^(sha256:)?[0-9a-f]{64}$/.test(ref)) throw new Error('build_ref must be sha256:<64 hex>');
2452
+ const path = String(args.path || '').trim();
2453
+ if (!path) throw new Error('path is required');
2454
+ const baseRef = String(args.base_ref || '').trim();
2455
+ if (!baseRef) throw new Error('base_ref is required');
2456
+ const text = args.text;
2457
+ if (typeof text !== 'string') throw new Error('text is required (the already-computed diff)');
2458
+ const buildSha256 = String(args.build_sha256 || '').trim().toLowerCase();
2459
+ if (!/^[0-9a-f]{64}$/.test(buildSha256)) throw new Error('build_sha256 must be a 64-hex sha256');
2460
+ const body = { path, base_ref: baseRef, text, build_sha256: buildSha256 };
2461
+ if (args.base_sha256 != null) {
2462
+ const baseSha256 = String(args.base_sha256).trim().toLowerCase();
2463
+ if (!/^[0-9a-f]{64}$/.test(baseSha256)) throw new Error('base_sha256 must be a 64-hex sha256');
2464
+ body.base_sha256 = baseSha256;
2465
+ }
2466
+ if (args.truncated != null) body.truncated = !!args.truncated;
2467
+ const ref2 = ref.startsWith('sha256:') ? ref : 'sha256:' + ref;
2468
+ const result = await api('POST', `/coord/builds/${encodeURIComponent(ref2)}/diff`, body);
2469
+ const d = result.data || {};
2470
+ const out = `Diff stored for ${d.path || path} (build ${d.build_ref || ref2}).\n\n${JSON.stringify(d, null, 2)}`;
2471
+ return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
2472
+ }
2473
+ },
2474
+ {
2475
+ name: 'tascan_dispatcher_action',
2476
+ description: 'Record a chief-of-staff dispatcher action on a coordination cycle as a receipt (protocol v0.2 D6, POST /coord/cycles/:root/dispatcher-actions): hand_review_approve, hand_review_revise, park, bundle_recovery, migration_apply, deploy or decision. Mints a completed task + completion under the Chief of Staff worker and a dispatcher_action ledger event on the root. kind=deploy additionally requires evidence.deploy_id (a Netlify deploy id) and composes the same integrate-as-note every root gets with coord_record_integration when the root is authorized — one call that does both records. Requires agent:dispatch.',
2477
+ inputSchema: {
2478
+ type: 'object',
2479
+ properties: {
2480
+ root_id: { type: 'string', description: 'The cycle root task id (tascan_list_cycles / tascan_create_cycle).' },
2481
+ kind: { type: 'string', enum: ['hand_review_approve', 'hand_review_revise', 'park', 'bundle_recovery', 'migration_apply', 'deploy', 'decision'], description: 'Which dispatcher action this is.' },
2482
+ summary: { type: 'string', description: 'Human-readable summary (1-4000 chars) — becomes the recorded completion notes.' },
2483
+ evidence: { type: 'object', description: 'Optional evidence object (serializes to at most 8000 chars). For kind=deploy this must include deploy_id: 24 lowercase hex, optionally prefixed deployed: and/or followed by a build-ref hex prefix.' }
2484
+ },
2485
+ required: ['root_id', 'kind', 'summary']
2486
+ },
2487
+ annotations: { title: 'Dispatcher Action', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
2488
+ handler: async (args, api) => {
2489
+ const id = String(args.root_id || '').trim().toLowerCase();
2490
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) throw new Error('root_id must be a UUID');
2491
+ const KINDS = ['hand_review_approve', 'hand_review_revise', 'park', 'bundle_recovery', 'migration_apply', 'deploy', 'decision'];
2492
+ const kind = String(args.kind || '');
2493
+ if (!KINDS.includes(kind)) throw new Error(`kind must be one of: ${KINDS.join(', ')}`);
2494
+ const summary = String(args.summary || '').trim();
2495
+ if (!summary) throw new Error('summary is required');
2496
+ if (summary.length > 4000) throw new Error('summary must be 4000 characters or fewer');
2497
+ const body = { kind, summary };
2498
+ if (args.evidence !== undefined && args.evidence !== null) body.evidence = args.evidence;
2499
+ const result = await api('POST', `/coord/cycles/${id}/dispatcher-actions`, body);
2500
+ const d = result.data || {};
2501
+ let text = `Dispatcher action recorded: ${d.kind || kind} on root ${d.root_id || id}.\ntask_id: ${d.task_id}\ncompletion_id: ${d.completion_id}`;
2502
+ if (d.integrate) text += `\nintegrate: ${d.integrate.rpc ? 'coord_record_integration ran (root authorized)' : 'note only (root not yet authorized)'}`;
2503
+ const out = text + '\n\n' + JSON.stringify(d, null, 2);
2504
+ return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
2505
+ }
2506
+ },
2507
+ {
2508
+ name: 'tascan_post_evidence',
2509
+ description: 'Post one evidence event into the evidence ledger (protocol v0.2 V9a, POST /evidence): an actor did an action to an object at a point in time, optionally with a location and provenance. idempotency_key is required — the same (org, idempotency_key) always returns the same row, never a second insert. Optionally correlate the event to a task or task list. Write tier; a device credential (structurally different from an API key) is not reachable through this MCP connection, so device_id is never set here.',
2510
+ inputSchema: {
2511
+ type: 'object',
2512
+ properties: {
2513
+ actor: {
2514
+ type: 'object',
2515
+ description: 'Who did it.',
2516
+ properties: {
2517
+ kind: { type: 'string', enum: ['worker', 'agent', 'device'], description: 'worker requires an active, unmerged worker id in your organization; agent/device ids are recorded as given.' },
2518
+ id: { type: 'string', description: 'The actor\'s id.' }
2519
+ },
2520
+ required: ['kind', 'id']
2521
+ },
2522
+ action: { type: 'string', description: 'What happened, e.g. inspected, scanned, calibrated.' },
2523
+ object: {
2524
+ type: 'object',
2525
+ description: 'What it happened to.',
2526
+ properties: {
2527
+ kind: { type: 'string', enum: ['asset', 'tag', 'serial', 'url'], description: 'What kind of thing object.ref names.' },
2528
+ ref: { type: 'string', description: 'The object reference (asset id, tag id, serial number, or URL).' }
2529
+ },
2530
+ required: ['kind', 'ref']
2531
+ },
2532
+ occurred_at: { type: 'string', description: 'ISO timestamp for when the event actually happened.' },
2533
+ location: {
2534
+ type: 'object',
2535
+ description: 'Optional location.',
2536
+ properties: {
2537
+ zone_id: { type: 'string', description: 'Optional geofence zone UUID.' },
2538
+ lat: { type: 'number' },
2539
+ lng: { type: 'number' }
2540
+ }
2541
+ },
2542
+ provenance: { type: 'object', description: 'Optional provenance object. provenance.kind first_party_scan is written only by the scan_events trigger and is refused from a caller; provenance.signature triggers device-signature verification.' },
2543
+ correlation: {
2544
+ type: 'object',
2545
+ description: 'Optional correlation hint.',
2546
+ properties: {
2547
+ task_id: { type: 'string', description: 'A task UUID in your organization to correlate this evidence to.' },
2548
+ task_list_id: { type: 'string', description: 'A task list UUID in your organization to correlate this evidence to (used when task_id is absent or does not resolve).' }
2549
+ }
2550
+ },
2551
+ idempotency_key: { type: 'string', description: 'Required, 1-200 chars. The same key on the same org replays the same stored row.' }
2552
+ },
2553
+ required: ['actor', 'action', 'object', 'occurred_at', 'idempotency_key']
2554
+ },
2555
+ annotations: { title: 'Post Evidence', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2556
+ handler: async (args, api) => {
2557
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2558
+ const ACTOR_KINDS = ['worker', 'agent', 'device'], OBJECT_KINDS = ['asset', 'tag', 'serial', 'url'];
2559
+ if (!args.actor || typeof args.actor !== 'object' || !ACTOR_KINDS.includes(args.actor.kind) || !args.actor.id) throw new Error(`actor.kind must be one of ${ACTOR_KINDS.join(', ')} and actor.id is required`);
2560
+ if (args.actor.kind === 'worker' && !UUID_RE.test(String(args.actor.id))) throw new Error('actor.id must be a UUID when actor.kind is worker');
2561
+ if (!args.object || typeof args.object !== 'object' || !OBJECT_KINDS.includes(args.object.kind) || !args.object.ref) throw new Error(`object.kind must be one of ${OBJECT_KINDS.join(', ')} and object.ref is required`);
2562
+ if ((args.object.kind === 'asset' || args.object.kind === 'tag') && !UUID_RE.test(String(args.object.ref))) throw new Error('object.ref must be a UUID when object.kind is asset or tag');
2563
+ const action = String(args.action || '').trim();
2564
+ if (!action) throw new Error('action is required');
2565
+ const occurredAt = args.occurred_at ? new Date(args.occurred_at) : null;
2566
+ if (!occurredAt || Number.isNaN(occurredAt.getTime())) throw new Error('occurred_at must be a valid ISO timestamp');
2567
+ const idem = String(args.idempotency_key || '').trim();
2568
+ if (!idem || idem.length > 200) throw new Error('idempotency_key is required (1-200 characters)');
2569
+ if (args.location != null) {
2570
+ if (typeof args.location !== 'object' || Array.isArray(args.location)) throw new Error('location must be an object');
2571
+ if (args.location.zone_id != null && !UUID_RE.test(String(args.location.zone_id))) throw new Error('location.zone_id must be a UUID');
2572
+ }
2573
+ if (args.correlation != null) {
2574
+ if (typeof args.correlation !== 'object' || Array.isArray(args.correlation)) throw new Error('correlation must be an object');
2575
+ if (args.correlation.task_id != null && !UUID_RE.test(String(args.correlation.task_id))) throw new Error('correlation.task_id must be a UUID');
2576
+ if (args.correlation.task_list_id != null && !UUID_RE.test(String(args.correlation.task_list_id))) throw new Error('correlation.task_list_id must be a UUID');
2577
+ }
2578
+ const body = { actor: { kind: args.actor.kind, id: args.actor.id }, action, object: { kind: args.object.kind, ref: args.object.ref }, occurred_at: occurredAt.toISOString(), idempotency_key: idem };
2579
+ if (args.location) body.location = args.location;
2580
+ if (args.provenance) body.provenance = args.provenance;
2581
+ if (args.correlation) body.correlation = args.correlation;
2582
+ const result = await api('POST', '/evidence', body);
2583
+ const d = result.data || {};
2584
+ const out = `Evidence event ${result.replayed ? '(replay of an existing row)' : 'recorded'}: id ${d.id}, actor ${d.actor_kind}:${d.actor_id}, action ${d.action}, object ${d.object_kind}:${d.object_ref}.\n\n${JSON.stringify(d, null, 2)}`;
2585
+ return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
2586
+ }
2587
+ },
2588
+ {
2589
+ name: 'tascan_evidence_policy',
2590
+ description: 'Read or author a task\'s evidence policy (protocol v0.2 V9b, GET|PUT /evidence/policy/:task_id — one route, two methods, so one tool). action=get evaluates the pinned policy against the evidence ledger so far: whether it is usable and satisfied, and its progress (read tier). action=set authors/updates a policy in your org\'s namespace when definition is given (definition.require is a non-empty array of requirement objects; definition.min_count, when set, must be an integer from 1 through definition.require.length) or, when definition is omitted, pins the task to an existing active policy_id (write tier).',
2591
+ inputSchema: {
2592
+ type: 'object',
2593
+ properties: {
2594
+ action: { type: 'string', enum: ['get', 'set'], description: 'get = GET (evaluate, read tier). set = PUT (author/pin, write tier).' },
2595
+ task_id: { type: 'string', description: 'Task ID (UUID) in your organization.' },
2596
+ policy_id: { type: 'string', description: 'action=set only, required: lowercase letters/digits/underscore/dot, starting with a letter, up to 80 chars, e.g. loto_v1.' },
2597
+ version: { type: 'integer', description: 'action=set only: optional version for a new/updated policy definition (default 1). Ignored when only pinning an existing policy.' },
2598
+ definition: { type: 'object', description: 'action=set only, optional: policy definition to create or update — require (array, required), min_count, window, any_of, allow_fallback. Omit to pin the task to the existing active policy_id instead.' }
2599
+ },
2600
+ required: ['action', 'task_id']
2601
+ },
2602
+ annotations: { title: 'Evidence Policy', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2603
+ handler: async (args, api) => {
2604
+ const id = String(args.task_id || '').trim().toLowerCase();
2605
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) throw new Error('task_id must be a UUID');
2606
+ if (args.action === 'get') {
2607
+ const result = await api('GET', `/evidence/policy/${id}`);
2608
+ const d = result.data || {};
2609
+ const lines = [`usable: ${d.usable}`, d.reason ? `reason: ${d.reason}` : null, d.policy_id ? `policy_id: ${d.policy_id} v${d.policy_version}` : null, `satisfied: ${d.satisfied}${d.satisfied_via ? ' via ' + d.satisfied_via : ''}`, d.progress ? `progress: ${d.progress}` : null].filter(x => x !== null).join('\n');
2610
+ const out = lines + '\n\n' + JSON.stringify(d, null, 2);
2611
+ return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
2612
+ }
2613
+ if (args.action !== 'set') throw new Error("action must be 'get' or 'set'");
2614
+ const policyId = String(args.policy_id || '').trim();
2615
+ if (!/^[a-z][a-z0-9_.]{0,79}$/.test(policyId)) throw new Error('policy_id is required and must match ^[a-z][a-z0-9_.]{0,79}$');
2616
+ const body = { policy_id: policyId };
2617
+ if (args.version != null) body.version = args.version;
2618
+ if (args.definition !== undefined && args.definition !== null) body.definition = args.definition;
2619
+ const result = await api('PUT', `/evidence/policy/${id}`, body);
2620
+ const d = result.data || {};
2621
+ const out = `Evidence policy pinned: ${d.policy_id} on task ${id}${d.evidence_policy_version != null ? ' v' + d.evidence_policy_version : ''}.\n\n${JSON.stringify(d, null, 2)}`;
2622
+ return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
2623
+ }
2624
+ },
2625
+ {
2626
+ name: 'tascan_request_verification',
2627
+ description: 'Enqueue an autonomous verification job for a task completion (Verification Layer V2 + V7 doc_check, POST /completions/:completion_id/verification-jobs): http_probe checks a URL (params url, and optionally expect_status, expect_content_type, expect_sha256), doc_check runs a named policy against params.url and params.policy_id. Read the result with tascan_list_verifications once the job runs. Write tier.',
2628
+ inputSchema: {
2629
+ type: 'object',
2630
+ properties: {
2631
+ completion_id: { type: 'string', description: 'task_completions.id (UUID) — from tascan_get_task -> completions[].id.' },
2632
+ check_type: { type: 'string', enum: ['http_probe', 'doc_check'], description: 'Which autonomous check to run.' },
2633
+ params: { type: 'object', description: 'For http_probe: url, and optionally expect_status, expect_content_type, expect_sha256. For doc_check: url and policy_id, where policy_id names an active doc_check policy.' },
2634
+ run_at: { type: 'string', description: 'Optional ISO timestamp to schedule the job for (default now).' },
2635
+ idempotency_key: { type: 'string', description: 'Optional replay key (up to 200 chars).' }
2636
+ },
2637
+ required: ['completion_id', 'check_type', 'params']
2638
+ },
2639
+ annotations: { title: 'Request Verification', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
2640
+ handler: async (args, api) => {
2641
+ const id = String(args.completion_id || '').trim().toLowerCase();
2642
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) throw new Error('completion_id must be a UUID');
2643
+ const checkType = String(args.check_type || '');
2644
+ if (checkType !== 'http_probe' && checkType !== 'doc_check') throw new Error("check_type must be 'http_probe' or 'doc_check'");
2645
+ if (!args.params || typeof args.params !== 'object' || Array.isArray(args.params)) throw new Error('params must be an object');
2646
+ if (checkType === 'doc_check' && !args.params.policy_id) throw new Error('params.policy_id is required for check_type doc_check');
2647
+ const body = { check_type: checkType, params: args.params };
2648
+ if (args.run_at) body.run_at = args.run_at;
2649
+ if (args.idempotency_key) body.idempotency_key = args.idempotency_key;
2650
+ const result = await api('POST', `/completions/${id}/verification-jobs`, body);
2651
+ const d = result.data || {};
2652
+ const out = `Verification job queued: ${d.id} (${d.check_type}, state ${d.state}).\n\n${JSON.stringify(d, null, 2)}`;
2653
+ return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
2654
+ }
2655
+ },
2656
+ {
2657
+ name: 'tascan_list_verifications',
2658
+ description: 'Read a task completion\'s verification verdicts and job queue (Verification Layer V2, GET /completions/:completion_id/verifications): completion_verifications rows (state, method, verifier, policy, confidence, finding) plus verification_jobs rows (state, attempts, last_error, result). Read tier.',
2659
+ inputSchema: {
2660
+ type: 'object',
2661
+ properties: { completion_id: { type: 'string', description: 'task_completions.id (UUID) — from tascan_get_task -> completions[].id.' } },
2662
+ required: ['completion_id']
2663
+ },
2664
+ annotations: { title: 'List Verifications', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2665
+ handler: async (args, api) => {
2666
+ const id = String(args.completion_id || '').trim().toLowerCase();
2667
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) throw new Error('completion_id must be a UUID');
2668
+ const result = await api('GET', `/completions/${id}/verifications`);
2669
+ const d = result.data || {};
2670
+ const vs = d.verifications || [], js = d.jobs || [];
2671
+ const lines = [`${vs.length} verdict(s), ${js.length} job(s) for completion ${d.completion_id || id}`, ''];
2672
+ vs.forEach(v => lines.push(`verdict ${v.state} by ${v.verifier_kind}:${v.verifier_id || ''} policy ${v.policy_id || 'none'} confidence ${v.confidence != null ? v.confidence : 'n/a'} at ${v.observed_at}`));
2673
+ js.forEach(j => lines.push(`job ${j.id} ${j.check_type} state ${j.state} attempts ${j.attempts}/${j.max_attempts}${j.last_error ? ' last_error ' + j.last_error : ''}`));
2674
+ const out = lines.join('\n') + '\n\n' + JSON.stringify(d, null, 2);
2675
+ return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
2676
+ }
2677
+ },
2678
+ {
2679
+ name: 'tascan_org_analytics',
2680
+ description: 'Read organization-wide analytics: view org (GET /analytics/org, the get_org_analytics rollup) or view resolutions (GET /analytics/resolutions, paginated AI issue-resolution history, filterable by severity, category, and pattern-detected). Read tier.',
2681
+ inputSchema: {
2682
+ type: 'object',
2683
+ properties: {
2684
+ view: { type: 'string', enum: ['org', 'resolutions'], description: 'Which analytics surface to read.' },
2685
+ limit: { type: 'integer', description: 'resolutions only: page size (default 50, max 200).' },
2686
+ offset: { type: 'integer', description: 'resolutions only: page offset.' },
2687
+ severity: { type: 'string', description: 'resolutions only: filter by classification_severity.' },
2688
+ category: { type: 'string', description: 'resolutions only: filter by issue_category.' },
2689
+ pattern: { type: 'boolean', description: 'resolutions only: true to filter to pattern_detected rows only.' }
2690
+ },
2691
+ required: ['view']
2692
+ },
2693
+ annotations: { title: 'Org Analytics', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2694
+ handler: async (args, api) => {
2695
+ if (args.view !== 'org' && args.view !== 'resolutions') throw new Error("view must be 'org' or 'resolutions'");
2696
+ if (args.view === 'org') {
2697
+ const result = await api('GET', '/analytics/org');
2698
+ const d = result.data || {};
2699
+ const ai = d.ai_stats || {};
2700
+ const lines = [
2701
+ `org analytics: ${d.total_projects != null ? d.total_projects : 0} project(s), ${d.total_completions != null ? d.total_completions : 0} completion(s), ${d.total_workers != null ? d.total_workers : 0} worker(s)`,
2702
+ `issues: ${d.total_issues != null ? d.total_issues : 0} total, ${d.total_ai_resolutions != null ? d.total_ai_resolutions : 0} AI-resolved`,
2703
+ `ai_stats: avg_urgency ${ai.avg_urgency != null ? ai.avg_urgency : 'n/a'}, avg_confidence ${ai.avg_confidence != null ? ai.avg_confidence : 'n/a'}, avg_resolution_minutes ${ai.avg_resolution_minutes != null ? ai.avg_resolution_minutes : 'n/a'}, patterns_detected ${ai.patterns_detected != null ? ai.patterns_detected : 0}`
2704
+ ];
2705
+ const out = lines.join('\n') + '\n\n' + JSON.stringify(d, null, 2);
2706
+ return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
2707
+ }
2708
+ const qs = [];
2709
+ if (args.limit != null) qs.push(`limit=${encodeURIComponent(String(args.limit))}`);
2710
+ if (args.offset != null) qs.push(`offset=${encodeURIComponent(String(args.offset))}`);
2711
+ if (args.severity) qs.push(`severity=${encodeURIComponent(String(args.severity))}`);
2712
+ if (args.category) qs.push(`category=${encodeURIComponent(String(args.category))}`);
2713
+ if (args.pattern === true) qs.push('pattern=true');
2714
+ const result = await api('GET', `/analytics/resolutions${qs.length ? '?' + qs.join('&') : ''}`);
2715
+ const rows = result.data || [];
2716
+ const out = `${rows.length} resolution(s) (limit ${result.pagination && result.pagination.limit}, offset ${result.pagination && result.pagination.offset})\n\n` + JSON.stringify(result, null, 2);
2717
+ return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
2718
+ }
2719
+ },
2720
+ {
2721
+ name: 'tascan_get_usage',
2722
+ description: 'Read your organization\'s usage and quota snapshot for a billing period (GET /usage): SMS, email and AI call counts against their plan limits, the 100-receipt-per-month hard stop (used, allowance, hard_stop), the rate-limit window (60 requests per minute), and coordination-cycle spend in micro-USD for build roots created in that period. period defaults to the current UTC month (YYYY-MM). by_key additionally breaks coordination spend down per dispatch-scoped API key, refused (403 owner_required) to anything but an owner admin session -- a tsk_ key never gets it. Read tier.',
2723
+ inputSchema: {
2724
+ type: 'object',
2725
+ properties: {
2726
+ period: { type: 'string', description: 'Billing period as YYYY-MM (default: current UTC month).' },
2727
+ by_key: { type: 'boolean', description: 'Break coordination spend down per dispatch-scoped API key -- owner admin session only.' }
2728
+ }
2729
+ },
2730
+ annotations: { title: 'Get Usage', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2731
+ handler: async (args, api) => {
2732
+ const qs = [];
2733
+ if (args.period) qs.push(`period=${encodeURIComponent(String(args.period))}`);
2734
+ if (args.by_key === true) qs.push('by_key=true');
2735
+ const result = await api('GET', `/usage${qs.length ? '?' + qs.join('&') : ''}`);
2736
+ const d = result.data || {};
2737
+ const lines = [
2738
+ `usage for ${d.period || 'this period'} (plan ${d.plan || 'unknown'}):`,
2739
+ ` sms ${d.sms ? d.sms.used : 0} / ${d.sms ? d.sms.limit : '?'}`,
2740
+ ` email ${d.email ? d.email.used : 0} / ${d.email ? d.email.limit : '?'}`,
2741
+ ` ai ${d.ai ? d.ai.used : 0} / ${d.ai ? d.ai.limit : '?'}`,
2742
+ ` receipts ${d.receipts ? d.receipts.used : 0} / ${d.receipts && d.receipts.allowance != null ? d.receipts.allowance : '?'}${d.receipts && d.receipts.hard_stop ? ' (HARD STOP)' : ''}`,
2743
+ ` rate limit: ${d.rate_limit ? d.rate_limit.max_requests : 60} requests / ${d.rate_limit ? d.rate_limit.window_minutes : 1} minute(s)`,
2744
+ ` coordination spend: ${d.coordination_spend_micro_usd != null ? d.coordination_spend_micro_usd : 0} micro-USD`
2745
+ ];
2746
+ if (Array.isArray(d.by_key)) {
2747
+ lines.push(' by key:');
2748
+ d.by_key.forEach(k => lines.push(` ${k.label || k.key_id} (${k.key_id}): ${k.coordination_spend_micro_usd} micro-USD`));
2749
+ }
2750
+ return lines.join('\n');
2751
+ }
2752
+ },
2753
+ // P0 (migration 216): device control plane — split out (194 KB already close to the ~200 KB bundle cap).
2754
+ ...require('./tools-devices.cjs')
2197
2755
  ];
2198
2756
 
2199
2757
  module.exports = { TOOLS, AGENT_REGISTRY, dynamicAgents };