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 +21 -1
- package/index.js +10 -3
- package/package.json +2 -1
- package/scopes.cjs +103 -22
- package/tools-devices.cjs +69 -0
- package/tools.cjs +580 -22
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 (
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
// '
|
|
69
|
-
//
|
|
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('
|
|
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
|
-
|
|
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
|
|
123
|
-
// Returns { granted, clamped: [ 'full' | 'agent:dispatch' | 'agent:dispatch:code' ... ] }.
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
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'
|
|
146
|
-
//
|
|
147
|
-
// need === null
|
|
148
|
-
//
|
|
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 },
|
|
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: '
|
|
1445
|
-
auto_clock_out: { type: 'boolean', description: '
|
|
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'
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|