tascan-mcp 3.8.0 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/index.js +1 -1
- package/package.json +4 -4
- package/tools.cjs +331 -19
package/README.md
CHANGED
|
@@ -126,6 +126,14 @@ Add as a custom connector in Claude (Settings > Connectors). When you first use
|
|
|
126
126
|
| `tascan_apply_template` | Apply a pre-built template to an event | Create |
|
|
127
127
|
| `tascan_list_templates` | List available task templates | Read |
|
|
128
128
|
| `tascan_get_report` | Get completion report for an event (optional `include_responses` returns submitted response data) | Read |
|
|
129
|
+
| `tascan_generate_report` | Mint a shareable Completion / Service (client-branded, acknowledgeable) / Project / Evidence Pack link; optionally text it | Write |
|
|
130
|
+
| `tascan_list_reports` | Existing report links for a list or project with acknowledgment status | Read |
|
|
131
|
+
| `tascan_invite_worker` | Consented intro to a marketplace worker: TaScan texts them, YES adds them to your org + sends the list, NO keeps them anonymous | Write |
|
|
132
|
+
| `tascan_list_invites` | Your marketplace invites and their status; accepted ones carry the worker's contact | Read |
|
|
133
|
+
| `tascan_zone_compliance` | Hazard-zone audit: crossings, AI-verified PPE checkpoint verdicts, breaches, injuries cross-referenced with the last PPE check (zones now carry `kind`, `required_ppe`, `task_list_on_enter`, worker SMS rules) | Read |
|
|
134
|
+
| `tascan_create_invoice` | Create a client invoice from explicit line items OR from verified work (project / lists × hourly or flat rate); returns the share link and, for single-list invoices, a linked client Service Report | Write |
|
|
135
|
+
| `tascan_list_invoices` | List invoices with status, totals, due dates, links; outstanding balance | Read |
|
|
136
|
+
| `tascan_update_invoice` | Mark paid / overdue / cancelled, edit client details, notes, due date | Write |
|
|
129
137
|
| `tascan_query_responses` | One task's responses across every list in a project — chronological progression series | Read |
|
|
130
138
|
|
|
131
139
|
### Closed-Loop Autonomous Operations (Patent Pending)
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tascan-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.0",
|
|
4
4
|
"mcpName": "io.github.snowbikemike/tascan-mcp",
|
|
5
|
-
"description": "TaScan MCP Server — Closed-loop autonomous operations protocol.
|
|
5
|
+
"description": "TaScan MCP Server — Closed-loop autonomous operations protocol. 69 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), 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",
|
|
7
7
|
"main": "index.js",
|
|
8
8
|
"bin": {
|
|
9
|
-
"tascan-mcp": "
|
|
9
|
+
"tascan-mcp": "index.js"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"start": "node index.js"
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|
|
43
|
-
"url": "https://github.com/snowbikemike/tascan-mcp.git"
|
|
43
|
+
"url": "git+https://github.com/snowbikemike/tascan-mcp.git"
|
|
44
44
|
},
|
|
45
45
|
"homepage": "https://tascan.io",
|
|
46
46
|
"bugs": {
|
package/tools.cjs
CHANGED
|
@@ -159,7 +159,8 @@ const TOOLS = [
|
|
|
159
159
|
name: { type: 'string', description: 'Event name' },
|
|
160
160
|
description: { type: 'string', description: 'Event description' },
|
|
161
161
|
team_mode: { type: 'boolean', description: 'Team mode — shared completions' },
|
|
162
|
-
multi_instance: { type: 'boolean', description: 'Multi-instance — each worker gets isolated copy' }
|
|
162
|
+
multi_instance: { type: 'boolean', description: 'Multi-instance — each worker gets isolated copy' },
|
|
163
|
+
assigned_worker_ids: { type: 'array', items: { type: 'string' }, description: 'Worker UUIDs to assign to this event. Each gets a personal tap-to-open link. Note: a worker holds one event assignment per project — assigning moves them.' }
|
|
163
164
|
},
|
|
164
165
|
required: ['project_id', 'name']
|
|
165
166
|
},
|
|
@@ -168,8 +169,14 @@ const TOOLS = [
|
|
|
168
169
|
const body = { name: args.name, description: args.description };
|
|
169
170
|
if (args.team_mode !== undefined) body.team_mode = args.team_mode;
|
|
170
171
|
if (args.multi_instance !== undefined) body.multi_instance = args.multi_instance;
|
|
172
|
+
if (args.assigned_worker_ids) body.assigned_worker_ids = args.assigned_worker_ids;
|
|
171
173
|
const result = await api('POST', `/projects/${args.project_id}/lists`, body);
|
|
172
|
-
|
|
174
|
+
let text = `Event created: ${result.data.name} (ID: ${result.data.id})\n\n${JSON.stringify(result.data, null, 2)}`;
|
|
175
|
+
if (result.data.assigned_workers?.length) {
|
|
176
|
+
text += '\n\nAssigned workers:';
|
|
177
|
+
for (const w of result.data.assigned_workers) text += `\n ${w.name}: ${w.worker_link}`;
|
|
178
|
+
}
|
|
179
|
+
return text;
|
|
173
180
|
}
|
|
174
181
|
},
|
|
175
182
|
{
|
|
@@ -211,7 +218,8 @@ const TOOLS = [
|
|
|
211
218
|
description: { type: 'string', description: 'New description' },
|
|
212
219
|
team_mode: { type: 'boolean', description: 'Team mode — shared completions' },
|
|
213
220
|
multi_instance: { type: 'boolean', description: 'Multi-instance — each worker gets isolated copy' },
|
|
214
|
-
timer_mode: { type: 'string', description: 'Timer mode (auto or manual)' }
|
|
221
|
+
timer_mode: { type: 'string', description: 'Timer mode (auto or manual)' },
|
|
222
|
+
assigned_worker_ids: { type: 'array', items: { type: 'string' }, description: 'Worker UUIDs to assign to this event (additive — workers not named are left alone). Each gets a personal tap-to-open link. Note: a worker holds one event assignment per project — assigning moves them.' }
|
|
215
223
|
},
|
|
216
224
|
required: ['list_id']
|
|
217
225
|
},
|
|
@@ -220,7 +228,12 @@ const TOOLS = [
|
|
|
220
228
|
const { list_id, ...updates } = args;
|
|
221
229
|
const body = Object.fromEntries(Object.entries(updates).filter(([, v]) => v !== undefined));
|
|
222
230
|
const result = await api('PUT', `/lists/${list_id}`, body);
|
|
223
|
-
|
|
231
|
+
let text = `Event updated: ${result.data.name}\n\n${JSON.stringify(result.data, null, 2)}`;
|
|
232
|
+
if (result.data.assigned_workers?.length) {
|
|
233
|
+
text += '\n\nAssigned workers:';
|
|
234
|
+
for (const w of result.data.assigned_workers) text += `\n ${w.name}: ${w.worker_link}`;
|
|
235
|
+
}
|
|
236
|
+
return text;
|
|
224
237
|
}
|
|
225
238
|
},
|
|
226
239
|
{
|
|
@@ -257,6 +270,7 @@ const TOOLS = [
|
|
|
257
270
|
requires_photo: { type: 'boolean', description: 'Require photo on completion' },
|
|
258
271
|
sort_order: { type: 'number', description: 'Explicit sort position (defaults to end of list, in array order)' },
|
|
259
272
|
required_equipment: { type: 'string', description: 'Equipment this task depends on (free text, e.g. "88 lb kettlebells")' },
|
|
273
|
+
assigned_to: { type: 'string', description: 'Worker to assign this task to — pass the worker UUID (validated against your org and stored as the worker\'s name, which is how the worker portal matches "my tasks"). A plain name string is also accepted as-is.' },
|
|
260
274
|
subtasks: {
|
|
261
275
|
type: 'array',
|
|
262
276
|
items: {
|
|
@@ -407,7 +421,8 @@ const TOOLS = [
|
|
|
407
421
|
is_safety_checkpoint: { type: 'boolean', description: 'Safety-critical flag' },
|
|
408
422
|
requires_photo: { type: 'boolean', description: 'Require photo' },
|
|
409
423
|
sort_order: { type: 'number', description: 'Sort position' },
|
|
410
|
-
required_equipment: { type: 'string', description: 'Equipment this task depends on (free text)' }
|
|
424
|
+
required_equipment: { type: 'string', description: 'Equipment this task depends on (free text)' },
|
|
425
|
+
assigned_to: { type: 'string', description: 'Worker to assign this task to — pass the worker UUID (validated against your org and stored as the worker\'s name, which is how the worker portal matches "my tasks"). A plain name string is also accepted as-is. Pass null to clear.' }
|
|
411
426
|
},
|
|
412
427
|
required: ['task_id']
|
|
413
428
|
},
|
|
@@ -610,11 +625,23 @@ const TOOLS = [
|
|
|
610
625
|
},
|
|
611
626
|
{
|
|
612
627
|
name: 'tascan_list_workers',
|
|
613
|
-
description: 'List
|
|
614
|
-
inputSchema: { type: 'object', properties: {
|
|
628
|
+
description: 'List workers (taskees) in the organization. Supports filtering by name/email/phone substring, contact-info presence, and last-activity date. Each row includes completion_count (total task completions).',
|
|
629
|
+
inputSchema: { type: 'object', properties: {
|
|
630
|
+
query: { type: 'string', description: 'Substring match on name, email, or phone' },
|
|
631
|
+
has_phone: { type: 'boolean', description: 'true = only workers with a phone on file; false = only without' },
|
|
632
|
+
has_email: { type: 'boolean', description: 'true/false — same for email' },
|
|
633
|
+
active_since: { type: 'string', description: 'ISO date/datetime — only workers with last_active_at on/after this' },
|
|
634
|
+
include_inactive: { type: 'boolean', description: 'true = also include inactive workers (e.g. tombstones left by a merge or delete) — useful for auditing right after tascan_merge_workers / tascan_delete_worker' }
|
|
635
|
+
} },
|
|
615
636
|
annotations: { title: 'List Workers', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
616
637
|
handler: async (args, api) => {
|
|
617
|
-
const
|
|
638
|
+
const qs = [];
|
|
639
|
+
if (args.query !== undefined) qs.push(`query=${encodeURIComponent(args.query)}`);
|
|
640
|
+
if (args.has_phone !== undefined) qs.push(`has_phone=${args.has_phone}`);
|
|
641
|
+
if (args.has_email !== undefined) qs.push(`has_email=${args.has_email}`);
|
|
642
|
+
if (args.active_since !== undefined) qs.push(`active_since=${encodeURIComponent(args.active_since)}`);
|
|
643
|
+
if (args.include_inactive === true) qs.push('include_inactive=true');
|
|
644
|
+
const result = await api('GET', '/workers' + (qs.length ? '?' + qs.join('&') : ''));
|
|
618
645
|
return JSON.stringify(result.data, null, 2);
|
|
619
646
|
}
|
|
620
647
|
},
|
|
@@ -983,7 +1010,8 @@ const TOOLS = [
|
|
|
983
1010
|
worker_id: { type: 'string', description: 'Worker ID — sends to their phone on file (preferred over raw phone)' },
|
|
984
1011
|
phone: { type: 'string', description: 'Raw phone number (e.g. "+17025551234") — used when no worker_id given' },
|
|
985
1012
|
message: { type: 'string', description: 'Message text. Transactional and work-related only.' },
|
|
986
|
-
list_id: { type: 'string', description: 'Optional task list ID — appends a tap-to-open worker checklist link' }
|
|
1013
|
+
list_id: { type: 'string', description: 'Optional task list ID — appends a tap-to-open worker checklist link' },
|
|
1014
|
+
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.' }
|
|
987
1015
|
},
|
|
988
1016
|
required: ['message']
|
|
989
1017
|
},
|
|
@@ -993,14 +1021,30 @@ const TOOLS = [
|
|
|
993
1021
|
worker_id: args.worker_id,
|
|
994
1022
|
phone: args.phone,
|
|
995
1023
|
message: args.message,
|
|
996
|
-
list_id: args.list_id
|
|
1024
|
+
list_id: args.list_id,
|
|
1025
|
+
include_link: args.include_link
|
|
997
1026
|
});
|
|
998
1027
|
const d = result.data;
|
|
999
1028
|
let text = `SMS sent!\n`;
|
|
1000
1029
|
text += ` To: ${d.worker_name ? d.worker_name + ' (' + d.to + ')' : d.to}\n`;
|
|
1001
|
-
text += ` Status: ${d.sms_status}
|
|
1002
|
-
text +=
|
|
1003
|
-
|
|
1030
|
+
text += ` Status: ${d.sms_status}`;
|
|
1031
|
+
if (d.twilio_sid) text += `\n Twilio SID: ${d.twilio_sid}`;
|
|
1032
|
+
text += `\n Message: ${d.message_body}`;
|
|
1033
|
+
if (d.short_url) text += `\n Short link: ${d.short_url}`;
|
|
1034
|
+
else if (d.worker_url) text += `\n Checklist link: ${d.worker_url}`;
|
|
1035
|
+
return text;
|
|
1036
|
+
}
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
name: 'tascan_get_sms_status',
|
|
1040
|
+
description: 'Check delivery status of a previously sent TaScan SMS by its Twilio SID (returned by tascan_send_sms). Shows queued/sent/delivered/undelivered/failed plus carrier error codes.',
|
|
1041
|
+
inputSchema: { type: 'object', properties: { twilio_sid: { type: 'string', description: 'Twilio message SID (SM...) from tascan_send_sms' } }, required: ['twilio_sid'] },
|
|
1042
|
+
annotations: { title: 'Get SMS Status', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1043
|
+
handler: async (args, api) => {
|
|
1044
|
+
const d = (await api('GET', `/sms/${args.twilio_sid}`)).data;
|
|
1045
|
+
let text = `SMS ${d.twilio_sid}\n Status: ${d.sms_status}\n To: ${d.to}\n Sent: ${d.sent_at || 'not yet'}`;
|
|
1046
|
+
if (d.num_segments) text += `\n Segments: ${d.num_segments}`;
|
|
1047
|
+
if (d.error_code) text += `\n Error ${d.error_code}: ${d.error_message || ''}`;
|
|
1004
1048
|
return text;
|
|
1005
1049
|
}
|
|
1006
1050
|
},
|
|
@@ -1166,7 +1210,9 @@ const TOOLS = [
|
|
|
1166
1210
|
properties: {
|
|
1167
1211
|
q: { type: 'string', description: 'Skill, category, name, or civilian job title — e.g. "forklift", "LED wall", "AV technician"' },
|
|
1168
1212
|
min_completions: { type: 'number', description: 'Only workers with at least this many verified completions' },
|
|
1169
|
-
limit: { type: 'number', description: 'Max cards (default 25, max 50)' }
|
|
1213
|
+
limit: { type: 'number', description: 'Max cards (default 25, max 50)' },
|
|
1214
|
+
city: { type: 'string', description: 'Filter by the worker\'s opt-in home city, e.g. "Las Vegas"' },
|
|
1215
|
+
available: { type: 'boolean', description: 'Only workers who marked themselves available on their passport' }
|
|
1170
1216
|
},
|
|
1171
1217
|
required: []
|
|
1172
1218
|
},
|
|
@@ -1176,6 +1222,8 @@ const TOOLS = [
|
|
|
1176
1222
|
if (args.q) qs.set('q', args.q);
|
|
1177
1223
|
if (args.min_completions) qs.set('min_completions', String(args.min_completions));
|
|
1178
1224
|
if (args.limit) qs.set('limit', String(args.limit));
|
|
1225
|
+
if (args.city) qs.set('city', args.city);
|
|
1226
|
+
if (args.available) qs.set('available', 'true');
|
|
1179
1227
|
const result = await api('GET', '/marketplace' + (qs.toString() ? '?' + qs.toString() : ''));
|
|
1180
1228
|
const cards = result.data || [];
|
|
1181
1229
|
if (!cards.length) return 'No discoverable workers match. Workers appear after opting in on their own passport (profile page → "List me").';
|
|
@@ -1184,12 +1232,52 @@ const TOOLS = [
|
|
|
1184
1232
|
if (w.skills.length) t += '\n Skills: ' + w.skills.map(s => `${s.skill} (${s.verified_task_count}×)`).join(', ');
|
|
1185
1233
|
const civ = [...new Set(w.skills.map(s => s.civilian_equivalent).filter(Boolean))];
|
|
1186
1234
|
if (civ.length) t += '\n Civilian equivalent: ' + civ.join(' · ');
|
|
1187
|
-
|
|
1235
|
+
const where = [w.city ? 'Based in ' + w.city : null, w.rate ? 'Rate ' + w.rate : null, w.available ? 'Available' : null].filter(Boolean).join(' · ');
|
|
1236
|
+
if (where) t += '\n ' + where;
|
|
1237
|
+
t += '\n Passport: ' + w.passport_url + '\n worker_id: ' + w.worker_id + ' (use with tascan_invite_worker)';
|
|
1188
1238
|
return t;
|
|
1189
1239
|
}).join('\n\n');
|
|
1190
1240
|
}
|
|
1191
1241
|
},
|
|
1192
1242
|
|
|
1243
|
+
{
|
|
1244
|
+
name: 'tascan_invite_worker',
|
|
1245
|
+
description: 'Invite a marketplace worker to a task list — the consented intro. TaScan texts the worker from its own number ("<Your org> wants you for <list>. Reply YES to share your contact and get the list, or NO to pass."). On YES the worker appears in your org with their name + phone, receives the list link, and you get a text + a thread message. On NO or silence (7 days) you never learn who they were. Use the worker_id from tascan_search_marketplace.',
|
|
1246
|
+
inputSchema: {
|
|
1247
|
+
type: 'object',
|
|
1248
|
+
properties: {
|
|
1249
|
+
worker_id: { type: 'string', description: 'worker_id from a marketplace card' },
|
|
1250
|
+
list_id: { type: 'string', description: 'Task list you want them on' },
|
|
1251
|
+
message: { type: 'string', description: 'Optional short intro prepended to the text (max 240 chars)' }
|
|
1252
|
+
},
|
|
1253
|
+
required: ['worker_id', 'list_id']
|
|
1254
|
+
},
|
|
1255
|
+
annotations: { title: 'Invite Marketplace Worker', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
1256
|
+
handler: async (args, api) => {
|
|
1257
|
+
const result = await api('POST', '/marketplace/invite', args);
|
|
1258
|
+
const d = result.data;
|
|
1259
|
+
return `Invite sent for "${d.list}" — status ${d.status}, expires ${String(d.expires_at).slice(0, 10)}.\nInvite ID: ${d.invite_id}\n${d.note}`;
|
|
1260
|
+
}
|
|
1261
|
+
},
|
|
1262
|
+
{
|
|
1263
|
+
name: 'tascan_list_invites',
|
|
1264
|
+
description: 'List marketplace invites you have sent and their status (pending / accepted / declined / expired / failed). Accepted invites include the worker\'s name and phone — that is the consent boundary; pending and declined never do.',
|
|
1265
|
+
inputSchema: {
|
|
1266
|
+
type: 'object',
|
|
1267
|
+
properties: { status: { type: 'string', enum: ['pending', 'accepted', 'declined', 'expired', 'failed'] } },
|
|
1268
|
+
required: []
|
|
1269
|
+
},
|
|
1270
|
+
annotations: { title: 'List Marketplace Invites', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1271
|
+
handler: async (args, api) => {
|
|
1272
|
+
const result = await api('GET', '/marketplace/invites' + (args.status ? '?status=' + args.status : ''));
|
|
1273
|
+
const rows = result.data || [];
|
|
1274
|
+
if (!rows.length) return 'No invites yet — search the marketplace, then tascan_invite_worker.';
|
|
1275
|
+
return rows.map(r => `${r.status.toUpperCase()} · "${r.list || '—'}" · sent ${String(r.sent_at).slice(0, 10)}` +
|
|
1276
|
+
(r.worker ? ` · ${r.worker.name} ${r.worker.phone} (worker_id ${r.worker.worker_id})` : '') +
|
|
1277
|
+
(r.status === 'pending' ? ` · expires ${String(r.expires_at).slice(0, 10)}` : '') + `\n invite ${r.id}`).join('\n');
|
|
1278
|
+
}
|
|
1279
|
+
},
|
|
1280
|
+
|
|
1193
1281
|
// ─── Geofenced Zones (Patent 7: location-triggered delivery) ─────
|
|
1194
1282
|
{
|
|
1195
1283
|
name: 'tascan_create_zone',
|
|
@@ -1210,7 +1298,15 @@ const TOOLS = [
|
|
|
1210
1298
|
auto_clock_out: { type: 'boolean', description: 'Leaving the zone writes a shift_end clock-out event' },
|
|
1211
1299
|
notify_email: { type: 'string', description: 'Alert recipient override — defaults to all org admins' },
|
|
1212
1300
|
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.' },
|
|
1213
|
-
description: { type: 'string', description: 'Shown to workers on the Site Gate page' }
|
|
1301
|
+
description: { type: 'string', description: 'Shown to workers on the Site Gate page' },
|
|
1302
|
+
kind: { type: 'string', enum: ['work_site', 'hazard', 'containment', 'restricted'], description: 'What the fence MEANS. work_site: expected here (auto clock-in, list on enter). hazard: enter allowed under conditions — required_ppe + photo checkpoint verified by AI vision, the OSHA row. containment: must stay inside; leaving = breach. restricted: must stay out; entering = breach. Default work_site.' },
|
|
1303
|
+
required_ppe: { type: 'array', items: { type: 'string', enum: ['hard_hat', 'safety_glasses', 'hi_vis_vest', 'gloves', 'steel_toe_boots', 'hearing_protection', 'harness', 'respirator', 'face_shield'] }, description: 'Hazard zones: PPE the worker must show at entry (pick-list so the audit reads the same words)' },
|
|
1304
|
+
ppe_photo_required: { type: 'boolean', description: 'Hazard zones: pop a photo checkpoint on entry (default true when required_ppe is set)' },
|
|
1305
|
+
task_list_on_enter: { type: 'string', description: 'Task list dispatched to the worker (in-app + SMS) when they cross into the zone' },
|
|
1306
|
+
sms_worker_on_enter: { type: 'boolean', description: 'Text the worker the rule/list on entry, even if the app is closed' },
|
|
1307
|
+
sms_worker_on_exit: { type: 'boolean', description: 'Text the worker on exit' },
|
|
1308
|
+
enter_message: { type: 'string', description: 'What the worker sees / is texted on entry (default is generated from the rule)' },
|
|
1309
|
+
alert_on_breach: { type: 'boolean', description: 'Email + SMS the admin on containment-exit / restricted-enter (default true)' }
|
|
1214
1310
|
},
|
|
1215
1311
|
required: ['name', 'lat', 'lng']
|
|
1216
1312
|
},
|
|
@@ -1255,7 +1351,12 @@ const TOOLS = [
|
|
|
1255
1351
|
notify_on_enter: { type: 'boolean' }, notify_on_exit: { type: 'boolean' },
|
|
1256
1352
|
auto_clock_in: { type: 'boolean' }, auto_clock_out: { type: 'boolean' },
|
|
1257
1353
|
notify_email: { type: 'string' },
|
|
1258
|
-
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' }
|
|
1354
|
+
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' },
|
|
1355
|
+
kind: { type: 'string', enum: ['work_site', 'hazard', 'containment', 'restricted'] },
|
|
1356
|
+
required_ppe: { type: 'array', items: { type: 'string', enum: ['hard_hat', 'safety_glasses', 'hi_vis_vest', 'gloves', 'steel_toe_boots', 'hearing_protection', 'harness', 'respirator', 'face_shield'] } },
|
|
1357
|
+
ppe_photo_required: { type: 'boolean' }, task_list_on_enter: { type: 'string' },
|
|
1358
|
+
sms_worker_on_enter: { type: 'boolean' }, sms_worker_on_exit: { type: 'boolean' },
|
|
1359
|
+
enter_message: { type: 'string' }, alert_on_breach: { type: 'boolean' }
|
|
1259
1360
|
},
|
|
1260
1361
|
required: ['zone_id']
|
|
1261
1362
|
},
|
|
@@ -1267,6 +1368,34 @@ const TOOLS = [
|
|
|
1267
1368
|
}
|
|
1268
1369
|
},
|
|
1269
1370
|
|
|
1371
|
+
{
|
|
1372
|
+
name: 'tascan_zone_compliance',
|
|
1373
|
+
description: 'Hazard-zone compliance audit (OSHA / insurance): every zone crossing, PPE checkpoint verdict (complied / failed with what was missing / skipped), and breach, plus injury reports cross-referenced with the worker\'s last PPE checkpoint before the injury. Scope by project or zone, optionally by worker and date range. Same rows the printable Evidence Pack shows.',
|
|
1374
|
+
inputSchema: {
|
|
1375
|
+
type: 'object',
|
|
1376
|
+
properties: {
|
|
1377
|
+
project_id: { type: 'string' }, zone_id: { type: 'string' }, worker_id: { type: 'string' },
|
|
1378
|
+
since: { type: 'string', description: 'ISO date/time lower bound' }, until: { type: 'string', description: 'ISO date/time upper bound' }
|
|
1379
|
+
},
|
|
1380
|
+
required: []
|
|
1381
|
+
},
|
|
1382
|
+
annotations: { title: 'Zone Compliance Audit', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1383
|
+
handler: async (args, api) => {
|
|
1384
|
+
const qs = new URLSearchParams();
|
|
1385
|
+
['project_id', 'zone_id', 'worker_id', 'since', 'until'].forEach(k => { if (args[k]) qs.set(k, args[k]); });
|
|
1386
|
+
const result = await api('GET', '/zones/compliance' + (qs.toString() ? '?' + qs.toString() : ''));
|
|
1387
|
+
const d = result.data || {};
|
|
1388
|
+
if (!d.zones || !d.zones.length) return 'No zones in scope. Create one with tascan_create_zone (kind: hazard, required_ppe: [...]).';
|
|
1389
|
+
const s = d.summary || {};
|
|
1390
|
+
let text = `Zones: ${d.zones.map(z => `${z.name} (${z.kind}${(z.required_ppe || []).length ? ', PPE: ' + z.required_ppe.join('/') : ''})`).join('; ')}\n` +
|
|
1391
|
+
`Hazard entries ${s.hazard_entries} · PPE checks ${s.ppe_checks} (complied ${s.ppe_complied || 0}, failed/skipped ${s.ppe_failed}) · Breaches ${s.breaches} · Injury reports ${s.injuries}\n`;
|
|
1392
|
+
const ev = (d.events || []).filter(e => e.event === 'ppe_check' || e.event === 'breach');
|
|
1393
|
+
if (ev.length) text += '\nFindings:\n' + ev.slice(-40).map(e => ` ${e.at.slice(0, 16).replace('T', ' ')} · ${e.worker || 'Worker'} · ${e.zone} · ${e.event === 'breach' ? 'BREACH' : 'PPE ' + String(e.verdict).toUpperCase()}${e.missing && e.missing.length ? ' — missing ' + e.missing.join(', ') : ''}${e.confidence != null ? ' (' + Math.round(e.confidence * 100) + '%)' : ''}`).join('\n');
|
|
1394
|
+
if ((d.injuries || []).length) text += '\n\nInjuries:\n' + d.injuries.map(i => ` ${i.at.slice(0, 16).replace('T', ' ')} · ${i.worker || 'Worker'} · ${i.severity || ''} · ${(i.description || '').slice(0, 80)}\n PPE before injury: ${i.ppe_status_before_injury ? i.ppe_status_before_injury.verdict.toUpperCase() + ' at ' + i.ppe_status_before_injury.zone + ' ' + i.ppe_status_before_injury.at.slice(11, 16) + (i.ppe_status_before_injury.missing.length ? ' (missing ' + i.ppe_status_before_injury.missing.join(', ') + ')' : '') : 'no checkpoint on record'}`).join('\n');
|
|
1395
|
+
return text;
|
|
1396
|
+
}
|
|
1397
|
+
},
|
|
1398
|
+
|
|
1270
1399
|
// ─── Condition Ledger (Patent 12: longitudinal asset condition) ──
|
|
1271
1400
|
{
|
|
1272
1401
|
name: 'tascan_register_asset',
|
|
@@ -1370,6 +1499,145 @@ const TOOLS = [
|
|
|
1370
1499
|
},
|
|
1371
1500
|
|
|
1372
1501
|
// ─── Gig Payments (verification-gated direct payer→worker) ───────
|
|
1502
|
+
{
|
|
1503
|
+
name: 'tascan_generate_report',
|
|
1504
|
+
description: 'Mint a shareable report and get its link. Types: completion (full proof-of-work for one list: tasks, responses, subtasks, photos, GPS + place names, timing, QR pair), service (client-facing version of a list with YOUR company branding and a Client Acknowledgment button — the ack files into the list thread), project (every list in a project rolled up), evidence (compliance Evidence Pack; admin sign-in required to view). Links are stable — the same list/project returns the same link. Optionally text the link to a phone through the TaScan SMS lane.',
|
|
1505
|
+
inputSchema: {
|
|
1506
|
+
type: 'object',
|
|
1507
|
+
properties: {
|
|
1508
|
+
type: { type: 'string', enum: ['completion', 'service', 'project', 'evidence'] },
|
|
1509
|
+
list_id: { type: 'string', description: 'Required for completion / service' },
|
|
1510
|
+
project_id: { type: 'string', description: 'Required for project / evidence' },
|
|
1511
|
+
company_name: { type: 'string', description: 'Service report branding (defaults to the org name)' },
|
|
1512
|
+
message: { type: 'string', description: 'Service report: a note to the client shown under the header' },
|
|
1513
|
+
show_workers: { type: 'boolean', description: 'Service report: show worker names (default true)' },
|
|
1514
|
+
show_issues: { type: 'boolean', description: 'Service report: include reported issues (default false)' },
|
|
1515
|
+
send_to_phone: { type: 'string', description: 'Text the link to this number (E.164 or 10-digit US)' },
|
|
1516
|
+
send_note: { type: 'string', description: 'Short intro for the text, e.g. "Here is your report from Love Productions:"' }
|
|
1517
|
+
},
|
|
1518
|
+
required: ['type']
|
|
1519
|
+
},
|
|
1520
|
+
annotations: { title: 'Generate Report Link', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1521
|
+
handler: async (args, api) => {
|
|
1522
|
+
const result = await api('POST', '/reports', args);
|
|
1523
|
+
const d = result.data;
|
|
1524
|
+
let text = `${d.type.charAt(0).toUpperCase() + d.type.slice(1)} report ${d.created ? 'created' : 'ready'}:\n${d.url}`;
|
|
1525
|
+
if (d.note) text += `\n(${d.note})`;
|
|
1526
|
+
if (d.sms) text += d.sms.sent ? `\n\nTexted to ${d.sms.to}.` : `\n\nSMS not sent: ${d.sms.error}`;
|
|
1527
|
+
return text;
|
|
1528
|
+
}
|
|
1529
|
+
},
|
|
1530
|
+
{
|
|
1531
|
+
name: 'tascan_list_reports',
|
|
1532
|
+
description: 'List existing report links for a list or project (completion / service / project / evidence), newest first, with client acknowledgment status for service reports.',
|
|
1533
|
+
inputSchema: {
|
|
1534
|
+
type: 'object',
|
|
1535
|
+
properties: { list_id: { type: 'string' }, project_id: { type: 'string' } },
|
|
1536
|
+
required: []
|
|
1537
|
+
},
|
|
1538
|
+
annotations: { title: 'List Report Links', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1539
|
+
handler: async (args, api) => {
|
|
1540
|
+
const qs = new URLSearchParams();
|
|
1541
|
+
if (args.list_id) qs.set('list_id', args.list_id);
|
|
1542
|
+
if (args.project_id) qs.set('project_id', args.project_id);
|
|
1543
|
+
const result = await api('GET', '/reports' + (qs.toString() ? '?' + qs.toString() : ''));
|
|
1544
|
+
const rows = result.data || [];
|
|
1545
|
+
if (!rows.length) return 'No reports yet — use tascan_generate_report.';
|
|
1546
|
+
return rows.map(r => `${r.type.toUpperCase()} · ${r.title || ''} · ${r.created_at.slice(0, 10)}` +
|
|
1547
|
+
(r.acknowledged_at ? ` · ✓ acknowledged by ${r.acknowledged_by || 'client'} ${r.acknowledged_at.slice(0, 10)}` : (r.type === 'service' ? ' · not yet acknowledged' : '')) +
|
|
1548
|
+
(r.url ? `\n ${r.url}` : '')).join('\n');
|
|
1549
|
+
}
|
|
1550
|
+
},
|
|
1551
|
+
{
|
|
1552
|
+
name: 'tascan_create_invoice',
|
|
1553
|
+
description: 'Create a client invoice and get its shareable link. Two ways to bill: (a) pass explicit line_items, or (b) pass project_id or task_list_ids plus hourly_rate (quarter-hour billing from first→last verified completion per list) or flat_rate_per_list, and TaScan builds one line per list from VERIFIED work ("<list> — 7/7 tasks verified · Sep 1 · 1.25h"); lists with no completions are skipped. A single-list invoice also mints a client-facing Service Report (acknowledge → pay) and links it. Returns invoice number, totals, url, and the work it billed.',
|
|
1554
|
+
inputSchema: {
|
|
1555
|
+
type: 'object',
|
|
1556
|
+
properties: {
|
|
1557
|
+
client_name: { type: 'string', description: 'Bill-to name (person or company)' },
|
|
1558
|
+
client_email: { type: 'string' },
|
|
1559
|
+
client_phone: { type: 'string' },
|
|
1560
|
+
project_id: { type: 'string', description: 'Bill every list in this project (auto line items)' },
|
|
1561
|
+
task_list_ids: { type: 'array', items: { type: 'string' }, description: 'Bill just these lists (auto line items)' },
|
|
1562
|
+
hourly_rate: { type: 'number', description: 'Dollars per hour for auto line items' },
|
|
1563
|
+
flat_rate_per_list: { type: 'number', description: 'Dollars per list for auto line items (used when no hourly_rate)' },
|
|
1564
|
+
min_hours: { type: 'number', description: 'Minimum billable hours per list (e.g. 1)' },
|
|
1565
|
+
billing: { type: 'object', description: 'Billing rules for auto line items. mode: hourly (default when hourly_rate given) | day_rate | flat. Overtime/double time are computed PER WORK DAY from verified completions: hours over overtime_after_hours (default 8) bill at overtime_multiplier (1.5×), hours over double_time_after_hours (12) at double_time_multiplier (2×); set overtime:false to disable. per_diem adds one line × work days (or per_diem_days). expenses are pass-through lines.', properties: {
|
|
1566
|
+
mode: { type: 'string', enum: ['hourly', 'day_rate', 'flat'] },
|
|
1567
|
+
hourly_rate: { type: 'number' }, day_rate: { type: 'number' }, flat_rate_per_list: { type: 'number' }, min_hours: { type: 'number' },
|
|
1568
|
+
overtime: { type: 'boolean' }, overtime_after_hours: { type: 'number' }, overtime_multiplier: { type: 'number' },
|
|
1569
|
+
double_time_after_hours: { type: 'number' }, double_time_multiplier: { type: 'number' },
|
|
1570
|
+
per_diem: { type: 'number', description: 'Dollars per work day' }, per_diem_days: { type: 'number' },
|
|
1571
|
+
expenses: { type: 'array', items: { type: 'object', properties: { description: { type: 'string' }, amount: { type: 'number' }, quantity: { type: 'number' } }, required: ['description', 'amount'] } }
|
|
1572
|
+
} },
|
|
1573
|
+
line_items: { type: 'array', description: 'Explicit lines instead of auto-billing', items: { type: 'object', properties: { description: { type: 'string' }, quantity: { type: 'number' }, unit_price: { type: 'number' } }, required: ['description', 'unit_price'] } },
|
|
1574
|
+
tax_rate: { type: 'number', description: 'Fraction, e.g. 0.0825 for 8.25%' },
|
|
1575
|
+
due_date: { type: 'string', description: 'YYYY-MM-DD (default: 30 days out)' },
|
|
1576
|
+
notes: { type: 'string', description: 'Payment terms / thank-you shown on the invoice' },
|
|
1577
|
+
company_name: { type: 'string', description: 'Your company name on the attached Service Report (defaults to the org name)' },
|
|
1578
|
+
attach_service_report: { type: 'boolean', description: 'Mint + link a Service Report for single-list invoices (default true)' },
|
|
1579
|
+
payment_options: { type: 'object', description: 'Pay-how-you-like buttons on the invoice (defaults to the org\'s saved handles). Keys: venmo (@handle), cashapp ($cashtag), paypal (paypal.me name), zelle (phone/email), applecash (phone), other (free text e.g. "cash or check"). Stripe card checkout is separate and only shows when the org has invoice_payments enabled.', properties: { venmo: { type: 'string' }, cashapp: { type: 'string' }, paypal: { type: 'string' }, zelle: { type: 'string' }, applecash: { type: 'string' }, other: { type: 'string' } } },
|
|
1580
|
+
status: { type: 'string', enum: ['draft', 'sent'], description: 'Default sent' }
|
|
1581
|
+
},
|
|
1582
|
+
required: ['client_name']
|
|
1583
|
+
},
|
|
1584
|
+
annotations: { title: 'Create Invoice', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
1585
|
+
handler: async (args, api) => {
|
|
1586
|
+
const result = await api('POST', '/invoices', args);
|
|
1587
|
+
const inv = result.data;
|
|
1588
|
+
let text = `Invoice ${inv.invoice_number} — $${Number(inv.total).toFixed(2)} to ${inv.client_name} (${inv.status})\n\nLink: ${inv.url}\n`;
|
|
1589
|
+
if (inv.service_report_url) text += `Service Report (client acknowledges here): ${inv.service_report_url}\n`;
|
|
1590
|
+
text += `\nLines:\n` + (inv.line_items || []).map(li => ` • ${li.description} — ${li.quantity} × $${Number(li.unit_price).toFixed(2)} = $${Number(li.amount).toFixed(2)}`).join('\n');
|
|
1591
|
+
text += `\n\nSubtotal $${Number(inv.subtotal).toFixed(2)}` + (Number(inv.tax_amount) ? ` · Tax $${Number(inv.tax_amount).toFixed(2)}` : '') + ` · Due ${inv.due_date}\nInvoice ID: ${inv.id}`;
|
|
1592
|
+
return text;
|
|
1593
|
+
}
|
|
1594
|
+
},
|
|
1595
|
+
{
|
|
1596
|
+
name: 'tascan_list_invoices',
|
|
1597
|
+
description: 'List invoices for the org (newest first) with status, client, total, due date and share link. Filter by status (draft/sent/paid/overdue/cancelled) or project.',
|
|
1598
|
+
inputSchema: {
|
|
1599
|
+
type: 'object',
|
|
1600
|
+
properties: {
|
|
1601
|
+
status: { type: 'string', enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled'] },
|
|
1602
|
+
project_id: { type: 'string' }
|
|
1603
|
+
},
|
|
1604
|
+
required: []
|
|
1605
|
+
},
|
|
1606
|
+
annotations: { title: 'List Invoices', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1607
|
+
handler: async (args, api) => {
|
|
1608
|
+
const qs = new URLSearchParams();
|
|
1609
|
+
if (args.status) qs.set('status', args.status);
|
|
1610
|
+
if (args.project_id) qs.set('project_id', args.project_id);
|
|
1611
|
+
const result = await api('GET', '/invoices' + (qs.toString() ? '?' + qs.toString() : ''));
|
|
1612
|
+
const rows = result.data || [];
|
|
1613
|
+
if (!rows.length) return 'No invoices yet.';
|
|
1614
|
+
const outstanding = rows.filter(r => r.status === 'sent' || r.status === 'overdue').reduce((s, r) => s + Number(r.total || 0), 0);
|
|
1615
|
+
return rows.map(r => `${r.invoice_number} · ${r.status.toUpperCase()} · $${Number(r.total).toFixed(2)} · ${r.client_name}${r.due_date ? ' · due ' + r.due_date : ''}${r.paid_at ? ' · paid ' + r.paid_at.slice(0, 10) : ''}\n ${r.url}\n id ${r.id}`).join('\n') +
|
|
1616
|
+
`\n\nOutstanding: $${outstanding.toFixed(2)} across ${rows.filter(r => r.status === 'sent' || r.status === 'overdue').length} invoice(s)`;
|
|
1617
|
+
}
|
|
1618
|
+
},
|
|
1619
|
+
{
|
|
1620
|
+
name: 'tascan_update_invoice',
|
|
1621
|
+
description: 'Update an invoice: mark it paid (records paid_at), overdue, cancelled, or edit client details / notes / due date.',
|
|
1622
|
+
inputSchema: {
|
|
1623
|
+
type: 'object',
|
|
1624
|
+
properties: {
|
|
1625
|
+
invoice_id: { type: 'string' },
|
|
1626
|
+
status: { type: 'string', enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled'] },
|
|
1627
|
+
paid_at: { type: 'string', description: 'ISO timestamp (default now) when status = paid' },
|
|
1628
|
+
client_name: { type: 'string' }, client_email: { type: 'string' }, client_phone: { type: 'string' },
|
|
1629
|
+
notes: { type: 'string' }, due_date: { type: 'string' }
|
|
1630
|
+
},
|
|
1631
|
+
required: ['invoice_id']
|
|
1632
|
+
},
|
|
1633
|
+
annotations: { title: 'Update Invoice', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1634
|
+
handler: async (args, api) => {
|
|
1635
|
+
const { invoice_id, ...patch } = args;
|
|
1636
|
+
const result = await api('PUT', `/invoices/${invoice_id}`, patch);
|
|
1637
|
+
const inv = result.data;
|
|
1638
|
+
return `Invoice ${inv.invoice_number} → ${inv.status.toUpperCase()}${inv.paid_at ? ' (paid ' + inv.paid_at.slice(0, 10) + ')' : ''} · $${Number(inv.total).toFixed(2)} · ${inv.client_name}\n${inv.url}`;
|
|
1639
|
+
}
|
|
1640
|
+
},
|
|
1373
1641
|
{
|
|
1374
1642
|
name: 'tascan_request_payment',
|
|
1375
1643
|
description: 'Pledge a payment on a task list: when the list is verified complete (every task done + photo evidence on photo-required tasks), the payer automatically receives a Stripe pay link that routes the money DIRECTLY to the worker (0% TaScan fee). No money moves and no card is stored at pledge time. The worker must have completed payout onboarding (Get Paid on their profile).',
|
|
@@ -1503,7 +1771,8 @@ const TOOLS = [
|
|
|
1503
1771
|
properties: {
|
|
1504
1772
|
worker_id: { type: 'string', description: 'Anchor worker to find duplicates OF (preferred — enables phone + GPS signals)' },
|
|
1505
1773
|
name: { type: 'string', description: 'Or: search duplicates by display name' },
|
|
1506
|
-
threshold: { type: 'number', description: 'Min confidence 0-1 (default 0.15)' }
|
|
1774
|
+
threshold: { type: 'number', description: 'Min confidence 0-1 (default 0.15)' },
|
|
1775
|
+
include_orphans: { type: 'boolean', description: 'Also consider org-less (orphan) worker records — off by default' }
|
|
1507
1776
|
},
|
|
1508
1777
|
required: []
|
|
1509
1778
|
},
|
|
@@ -1513,15 +1782,58 @@ const TOOLS = [
|
|
|
1513
1782
|
if (args.worker_id) qs.push(`worker_id=${args.worker_id}`);
|
|
1514
1783
|
if (args.name) qs.push(`name=${encodeURIComponent(args.name)}`);
|
|
1515
1784
|
if (args.threshold != null) qs.push(`threshold=${args.threshold}`);
|
|
1785
|
+
if (args.include_orphans) qs.push('include_orphans=true');
|
|
1516
1786
|
const result = await api('GET', '/workers/duplicates?' + qs.join('&'));
|
|
1517
1787
|
const d = result.data;
|
|
1518
1788
|
if (!d.candidates.length) return `No duplicate candidates for ${d.anchor.name || d.anchor.id}.`;
|
|
1519
1789
|
let text = `Duplicate candidates for ${d.anchor.name || ''} (${d.anchor.id || 'name search'}):\n`;
|
|
1520
1790
|
d.candidates.forEach(c => {
|
|
1521
|
-
text += `\n${c.name} (${c.id}) — confidence ${c.confidence}\n signals: ${JSON.stringify(c.signals)}\n created ${String(c.created_at).slice(0, 10)} · last active ${String(c.last_active_at || '').slice(0, 10)} · ${c.total_points} pts`;
|
|
1791
|
+
text += `\n${c.name} (${c.id}) — confidence ${c.confidence}\n signals: ${JSON.stringify(c.signals)}\n created ${String(c.created_at).slice(0, 10)} · last active ${String(c.last_active_at || '').slice(0, 10)} · ${c.total_points} pts · ${c.merge_ready ? 'MERGE-READY' : 'blockers: ' + (c.blockers || []).join(',')}`;
|
|
1522
1792
|
});
|
|
1793
|
+
if (d.suggested_merge) {
|
|
1794
|
+
text += `\n\nNext step: tascan_merge_workers(primary_worker_id=${d.suggested_merge.primary_worker_id}, duplicate_worker_ids=[${d.suggested_merge.duplicate_worker_ids.join(', ')}], dry_run=true)`;
|
|
1795
|
+
}
|
|
1523
1796
|
return text;
|
|
1524
1797
|
}
|
|
1798
|
+
},
|
|
1799
|
+
{
|
|
1800
|
+
name: 'tascan_merge_workers',
|
|
1801
|
+
description: 'Merge duplicate worker records into one canonical identity (Patent 4 identity consolidation). Reassigns every reference (completions, timer events, points, payments, rosters — 31 columns across 31 tables), backfills missing phone/email on the primary, sums points, and tombstones the duplicates (merged_into + is_active=false — NEVER hard-deletes). ALWAYS run with dry_run=true first and show Mike the counts; pass dry_run=false only after explicit confirmation.',
|
|
1802
|
+
inputSchema: {
|
|
1803
|
+
type: 'object',
|
|
1804
|
+
properties: {
|
|
1805
|
+
primary_worker_id: { type: 'string', description: 'The canonical worker that survives (usually the one with a phone)' },
|
|
1806
|
+
duplicate_worker_ids: { type: 'array', items: { type: 'string' }, description: 'Worker IDs to fold into the primary (max 20)' },
|
|
1807
|
+
dry_run: { type: 'boolean', description: 'true (default) = report reassignment counts only, change nothing. false = execute atomically.' }
|
|
1808
|
+
},
|
|
1809
|
+
required: ['primary_worker_id', 'duplicate_worker_ids']
|
|
1810
|
+
},
|
|
1811
|
+
annotations: { title: 'Merge Workers', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
1812
|
+
handler: async (args, api) => {
|
|
1813
|
+
const result = await api('POST', '/workers/merge', {
|
|
1814
|
+
primary_worker_id: args.primary_worker_id,
|
|
1815
|
+
duplicate_worker_ids: args.duplicate_worker_ids,
|
|
1816
|
+
dry_run: args.dry_run !== false
|
|
1817
|
+
});
|
|
1818
|
+
const d = result.data;
|
|
1819
|
+
const rows = Object.entries(d.counts || {}).map(([t, n]) => ` ${t}: ${n}`).join('\n') || ' (no references found)';
|
|
1820
|
+
const conf = (d.conflicts || []).length ? `\nConflicts:\n ${d.conflicts.join('\n ')}` : '';
|
|
1821
|
+
return `${d.dry_run ? 'DRY RUN — no changes made' : 'MERGE EXECUTED'}\nPrimary: ${d.primary_worker_id}\nDuplicates: ${(d.duplicate_worker_ids || []).join(', ')}\nReassignment counts:\n${rows}${conf}${d.dry_run ? '\n\nTo execute: call again with dry_run=false.' : '\nDuplicates tombstoned (merged_into set, is_active=false). Logged to worker_merge_log.'}`;
|
|
1822
|
+
}
|
|
1823
|
+
},
|
|
1824
|
+
{
|
|
1825
|
+
name: 'tascan_delete_worker',
|
|
1826
|
+
description: 'Tombstone a worker record (is_active=false). REFUSES if the worker has any task/subtask completions or payments — merge those into the real worker with tascan_merge_workers instead. Never hard-deletes.',
|
|
1827
|
+
inputSchema: {
|
|
1828
|
+
type: 'object',
|
|
1829
|
+
properties: { worker_id: { type: 'string', description: 'Worker ID to delete' } },
|
|
1830
|
+
required: ['worker_id']
|
|
1831
|
+
},
|
|
1832
|
+
annotations: { title: 'Delete Worker', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
1833
|
+
handler: async (args, api) => {
|
|
1834
|
+
const result = await api('DELETE', `/workers/${args.worker_id}`);
|
|
1835
|
+
return `Worker ${args.worker_id} tombstoned (soft delete — record retained for audit).`;
|
|
1836
|
+
}
|
|
1525
1837
|
}
|
|
1526
1838
|
];
|
|
1527
1839
|
|