tascan-mcp 3.9.0 → 3.11.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.
Files changed (4) hide show
  1. package/README.md +25 -2
  2. package/index.js +1 -1
  3. package/package.json +4 -4
  4. package/tools.cjs +245 -6
package/README.md CHANGED
@@ -107,7 +107,7 @@ Add as a custom connector in Claude (Settings > Connectors). When you first use
107
107
  | Tool | Description | Type |
108
108
  |------|-------------|------|
109
109
  | `tascan_list_tasks` | List all tasks in an event | Read |
110
- | `tascan_get_task` | Get task details including completions | Read |
110
+ | `tascan_get_task` | Get task details including completions; each completion carries `photo_url` (raw storage path) + `photo_signed_url` (fetchable, ~1h, `null` when no photo) | Read |
111
111
  | `tascan_add_tasks` | Bulk-create tasks in an event | Create |
112
112
  | `tascan_update_task` | Update task title, type, flags, order | Update |
113
113
  | `tascan_delete_task` | Delete a task and its completions | Delete |
@@ -125,7 +125,15 @@ Add as a custom connector in Claude (Settings > Connectors). When you first use
125
125
  | `tascan_generate_qr` | Generate a QR code for an event | Create |
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
- | `tascan_get_report` | Get completion report for an event (optional `include_responses` returns submitted response data) | Read |
128
+ | `tascan_get_report` | Get completion report for an event (optional `include_responses` returns submitted response data plus a per-task `photos[]` list of `{ path, signed_url, completed_at, worker_name }`) | 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)
@@ -254,6 +262,21 @@ API keys are scoped to your organization and support rate limiting (60 requests/
254
262
  - Photo-required tasks won't show as complete until the photo uploads
255
263
  - Check the event's response mode (single vs. multi-response)
256
264
 
265
+ **Photo evidence links**
266
+ - `photo_url` is the raw storage object path (stable, never changes). `photo_signed_url` is a fetchable HTTPS link that expires after ~1 hour (server default `TASCAN_PHOTO_URL_TTL=3600`). Re-run the read tool for a fresh link; never store the signed URL.
267
+ - `photo_signed_url: null` with a non-null `photo_url` means signing was unavailable at read time (storage hiccup). The read still succeeds — retry for the link.
268
+
269
+ ## Changelog
270
+
271
+ ### v3.11.0 — 2026-09-11
272
+ - **Signed photo URLs in every completion payload.** Photo evidence was invisible to AI agents: `photo_url` was a bare private-bucket path with no host or token. Every read path that returns a completion now also returns `photo_signed_url` (short-lived, default 1h, tunable via `TASCAN_PHOTO_URL_TTL`), `null` when there is no photo. Covered: `tascan_get_task` (task + subtask completions), `tascan_get_report` (new per-task `photos[]` when `include_responses` is true, replacing the bare `[+photo]` marker), `tascan_list_subtasks`, `tascan_query_responses`, `tascan_list_issues` / issue analysis (issue + injury photos), `tascan_zone_compliance` (PPE checkpoint photos), `tascan_condition_history` (assessment + baseline photos).
273
+ - One batched storage signing call per response (a 40-task list fires one request, not 40). Signing failures log and degrade to `photo_signed_url: null` with the raw path intact; the tool call never fails.
274
+ - Single shared signing helper on the server (`photo-sign-lib`) now backs the API, the photo proxy used by the Completion / Client / Project / Evidence Pack reports, and every AI vision fetcher, so URL construction cannot drift again.
275
+ - Remote endpoint `serverInfo.version` now reads from this package instead of a stale hardcoded string.
276
+
277
+ ### v3.10.0 — 2026-09-03
278
+ - 69 tools: invoices, P2P worker payments, hazard zones with AI-verified PPE checkpoints, full RLS lockdown, agent claims.
279
+
257
280
  ## Privacy Policy
258
281
 
259
282
  TaScan collects and processes task completion data, worker information (name, phone, email), GPS coordinates (with consent), and photos uploaded during task completion. Data is stored securely in Supabase with row-level security policies. API access is authenticated and rate-limited.
package/index.js CHANGED
@@ -45,7 +45,7 @@ async function api(method, path, body) {
45
45
  }
46
46
 
47
47
  const server = new Server(
48
- { name: 'tascan', version: '3.9.0' },
48
+ { name: 'tascan', version: '3.11.0' },
49
49
  { capabilities: { tools: {} } }
50
50
  );
51
51
 
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "tascan-mcp",
3
- "version": "3.9.0",
3
+ "version": "3.11.0",
4
4
  "mcpName": "io.github.snowbikemike/tascan-mcp",
5
- "description": "TaScan MCP Server — Closed-loop autonomous operations protocol. 61 tools for projects, tasks, workers, QR codes, NFC tags, geofenced zones, AI condition assessment, worker passports, verification-gated gig payments, cross-entity search, duplicate-worker detection, worker identity merge, AI issue analysis, and autonomous remediation dispatch. 15 patents filed (470+ claims). Task. Scan. Done.",
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": "./index.js"
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
@@ -395,7 +395,7 @@ const TOOLS = [
395
395
  },
396
396
  {
397
397
  name: 'tascan_get_task',
398
- description: 'Get details of a specific task including completions',
398
+ description: 'Get details of a specific task including completions and subtasks. Each completion carries photo_url (raw storage path, stable) and photo_signed_url (short-lived fetchable URL, ~1h; null when no photo) so you can actually view the photo evidence.',
399
399
  inputSchema: {
400
400
  type: 'object',
401
401
  properties: { task_id: { type: 'string', description: 'Task ID' } },
@@ -733,7 +733,7 @@ const TOOLS = [
733
733
  },
734
734
  {
735
735
  name: 'tascan_get_report',
736
- description: 'Get completion report for a task list (event) including task status, completions, workers, and photos. Set include_responses to also return the actual submitted response data (numbers, text, choices) for each completed task.',
736
+ description: 'Get completion report for a task list (event) including task status, completions, workers, and photos. Set include_responses to also return the actual submitted response data (numbers, text, choices) for each completed task plus a per-task photos list with fetchable signed URLs (short-lived, ~1h) for the photo evidence.',
737
737
  inputSchema: {
738
738
  type: 'object',
739
739
  properties: {
@@ -762,7 +762,14 @@ const TOOLS = [
762
762
  text += ` -> ${value != null ? value : '(no value)'}${note ? ' — ' + note : ''} (${resp.worker_name}, ${resp.completed_at})\n`;
763
763
  }
764
764
  }
765
+ // Photo evidence: retrievable signed URLs (task.photos = [{ path, signed_url, completed_at, worker_name }])
766
+ if (args.include_responses && task.photos && task.photos.length > 0) {
767
+ for (const ph of task.photos) {
768
+ text += ` photo: ${ph.signed_url || ph.path + ' (signing unavailable)'} (${ph.worker_name}, ${ph.completed_at})\n`;
769
+ }
770
+ }
765
771
  }
772
+ if (args.include_responses) text += '\nphotos: signed URLs expire (~1h) — re-run this tool for fresh links.\n';
766
773
  return text;
767
774
  }
768
775
  },
@@ -793,8 +800,11 @@ const TOOLS = [
793
800
  text += `${when} · ${resp.list_name} · ${label}: ${value != null ? value : '(no value)'}`;
794
801
  if (note) text += ` — ${note}`;
795
802
  text += ` (${resp.worker_name})\n`;
803
+ // Photo evidence: fetchable signed URL (falls back to the raw path if signing was unavailable)
804
+ if (resp.photo_url) text += ` photo: ${resp.photo_signed_url || resp.photo_url + ' (signing unavailable)'}\n`;
796
805
  }
797
806
  if (r.responses.length === 0) text += '(no responses found)\n';
807
+ else if (r.responses.some(x => x.photo_url)) text += '\nphotos: signed URLs expire (~1h) — re-run this tool for fresh links.\n';
798
808
  return text;
799
809
  }
800
810
  },
@@ -818,8 +828,10 @@ const TOOLS = [
818
828
  text += `${i+1}. [${iss.status.toUpperCase()}] ${iss.title}\n`;
819
829
  text += ` ID: ${iss.id} | Type: ${iss.type} | Category: ${iss.category} | Severity: ${iss.severity || 'unset'}\n`;
820
830
  if (iss.tasks) text += ` Task: ${iss.tasks.title}\n`;
831
+ if (iss.photo_url) text += ` Photo: ${iss.photo_signed_url || iss.photo_url + ' (signing unavailable)'}\n`;
821
832
  text += ` Reported: ${iss.created_at}\n\n`;
822
833
  });
834
+ if (issues.some(x => x.photo_url)) text += 'photos: signed URLs expire (~1h) — re-run this tool for fresh links.\n';
823
835
  return text;
824
836
  }
825
837
  },
@@ -1210,7 +1222,9 @@ const TOOLS = [
1210
1222
  properties: {
1211
1223
  q: { type: 'string', description: 'Skill, category, name, or civilian job title — e.g. "forklift", "LED wall", "AV technician"' },
1212
1224
  min_completions: { type: 'number', description: 'Only workers with at least this many verified completions' },
1213
- limit: { type: 'number', description: 'Max cards (default 25, max 50)' }
1225
+ limit: { type: 'number', description: 'Max cards (default 25, max 50)' },
1226
+ city: { type: 'string', description: 'Filter by the worker\'s opt-in home city, e.g. "Las Vegas"' },
1227
+ available: { type: 'boolean', description: 'Only workers who marked themselves available on their passport' }
1214
1228
  },
1215
1229
  required: []
1216
1230
  },
@@ -1220,6 +1234,8 @@ const TOOLS = [
1220
1234
  if (args.q) qs.set('q', args.q);
1221
1235
  if (args.min_completions) qs.set('min_completions', String(args.min_completions));
1222
1236
  if (args.limit) qs.set('limit', String(args.limit));
1237
+ if (args.city) qs.set('city', args.city);
1238
+ if (args.available) qs.set('available', 'true');
1223
1239
  const result = await api('GET', '/marketplace' + (qs.toString() ? '?' + qs.toString() : ''));
1224
1240
  const cards = result.data || [];
1225
1241
  if (!cards.length) return 'No discoverable workers match. Workers appear after opting in on their own passport (profile page → "List me").';
@@ -1228,12 +1244,52 @@ const TOOLS = [
1228
1244
  if (w.skills.length) t += '\n Skills: ' + w.skills.map(s => `${s.skill} (${s.verified_task_count}×)`).join(', ');
1229
1245
  const civ = [...new Set(w.skills.map(s => s.civilian_equivalent).filter(Boolean))];
1230
1246
  if (civ.length) t += '\n Civilian equivalent: ' + civ.join(' · ');
1231
- t += '\n Passport: ' + w.passport_url;
1247
+ const where = [w.city ? 'Based in ' + w.city : null, w.rate ? 'Rate ' + w.rate : null, w.available ? 'Available' : null].filter(Boolean).join(' · ');
1248
+ if (where) t += '\n ' + where;
1249
+ t += '\n Passport: ' + w.passport_url + '\n worker_id: ' + w.worker_id + ' (use with tascan_invite_worker)';
1232
1250
  return t;
1233
1251
  }).join('\n\n');
1234
1252
  }
1235
1253
  },
1236
1254
 
1255
+ {
1256
+ name: 'tascan_invite_worker',
1257
+ 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.',
1258
+ inputSchema: {
1259
+ type: 'object',
1260
+ properties: {
1261
+ worker_id: { type: 'string', description: 'worker_id from a marketplace card' },
1262
+ list_id: { type: 'string', description: 'Task list you want them on' },
1263
+ message: { type: 'string', description: 'Optional short intro prepended to the text (max 240 chars)' }
1264
+ },
1265
+ required: ['worker_id', 'list_id']
1266
+ },
1267
+ annotations: { title: 'Invite Marketplace Worker', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1268
+ handler: async (args, api) => {
1269
+ const result = await api('POST', '/marketplace/invite', args);
1270
+ const d = result.data;
1271
+ 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}`;
1272
+ }
1273
+ },
1274
+ {
1275
+ name: 'tascan_list_invites',
1276
+ 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.',
1277
+ inputSchema: {
1278
+ type: 'object',
1279
+ properties: { status: { type: 'string', enum: ['pending', 'accepted', 'declined', 'expired', 'failed'] } },
1280
+ required: []
1281
+ },
1282
+ annotations: { title: 'List Marketplace Invites', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1283
+ handler: async (args, api) => {
1284
+ const result = await api('GET', '/marketplace/invites' + (args.status ? '?status=' + args.status : ''));
1285
+ const rows = result.data || [];
1286
+ if (!rows.length) return 'No invites yet — search the marketplace, then tascan_invite_worker.';
1287
+ return rows.map(r => `${r.status.toUpperCase()} · "${r.list || '—'}" · sent ${String(r.sent_at).slice(0, 10)}` +
1288
+ (r.worker ? ` · ${r.worker.name} ${r.worker.phone} (worker_id ${r.worker.worker_id})` : '') +
1289
+ (r.status === 'pending' ? ` · expires ${String(r.expires_at).slice(0, 10)}` : '') + `\n invite ${r.id}`).join('\n');
1290
+ }
1291
+ },
1292
+
1237
1293
  // ─── Geofenced Zones (Patent 7: location-triggered delivery) ─────
1238
1294
  {
1239
1295
  name: 'tascan_create_zone',
@@ -1254,7 +1310,15 @@ const TOOLS = [
1254
1310
  auto_clock_out: { type: 'boolean', description: 'Leaving the zone writes a shift_end clock-out event' },
1255
1311
  notify_email: { type: 'string', description: 'Alert recipient override — defaults to all org admins' },
1256
1312
  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.' },
1257
- description: { type: 'string', description: 'Shown to workers on the Site Gate page' }
1313
+ description: { type: 'string', description: 'Shown to workers on the Site Gate page' },
1314
+ 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.' },
1315
+ 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)' },
1316
+ ppe_photo_required: { type: 'boolean', description: 'Hazard zones: pop a photo checkpoint on entry (default true when required_ppe is set)' },
1317
+ task_list_on_enter: { type: 'string', description: 'Task list dispatched to the worker (in-app + SMS) when they cross into the zone' },
1318
+ sms_worker_on_enter: { type: 'boolean', description: 'Text the worker the rule/list on entry, even if the app is closed' },
1319
+ sms_worker_on_exit: { type: 'boolean', description: 'Text the worker on exit' },
1320
+ enter_message: { type: 'string', description: 'What the worker sees / is texted on entry (default is generated from the rule)' },
1321
+ alert_on_breach: { type: 'boolean', description: 'Email + SMS the admin on containment-exit / restricted-enter (default true)' }
1258
1322
  },
1259
1323
  required: ['name', 'lat', 'lng']
1260
1324
  },
@@ -1299,7 +1363,12 @@ const TOOLS = [
1299
1363
  notify_on_enter: { type: 'boolean' }, notify_on_exit: { type: 'boolean' },
1300
1364
  auto_clock_in: { type: 'boolean' }, auto_clock_out: { type: 'boolean' },
1301
1365
  notify_email: { type: 'string' },
1302
- 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' }
1366
+ 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' },
1367
+ kind: { type: 'string', enum: ['work_site', 'hazard', 'containment', 'restricted'] },
1368
+ 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'] } },
1369
+ ppe_photo_required: { type: 'boolean' }, task_list_on_enter: { type: 'string' },
1370
+ sms_worker_on_enter: { type: 'boolean' }, sms_worker_on_exit: { type: 'boolean' },
1371
+ enter_message: { type: 'string' }, alert_on_breach: { type: 'boolean' }
1303
1372
  },
1304
1373
  required: ['zone_id']
1305
1374
  },
@@ -1311,6 +1380,34 @@ const TOOLS = [
1311
1380
  }
1312
1381
  },
1313
1382
 
1383
+ {
1384
+ name: 'tascan_zone_compliance',
1385
+ 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.',
1386
+ inputSchema: {
1387
+ type: 'object',
1388
+ properties: {
1389
+ project_id: { type: 'string' }, zone_id: { type: 'string' }, worker_id: { type: 'string' },
1390
+ since: { type: 'string', description: 'ISO date/time lower bound' }, until: { type: 'string', description: 'ISO date/time upper bound' }
1391
+ },
1392
+ required: []
1393
+ },
1394
+ annotations: { title: 'Zone Compliance Audit', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1395
+ handler: async (args, api) => {
1396
+ const qs = new URLSearchParams();
1397
+ ['project_id', 'zone_id', 'worker_id', 'since', 'until'].forEach(k => { if (args[k]) qs.set(k, args[k]); });
1398
+ const result = await api('GET', '/zones/compliance' + (qs.toString() ? '?' + qs.toString() : ''));
1399
+ const d = result.data || {};
1400
+ if (!d.zones || !d.zones.length) return 'No zones in scope. Create one with tascan_create_zone (kind: hazard, required_ppe: [...]).';
1401
+ const s = d.summary || {};
1402
+ let text = `Zones: ${d.zones.map(z => `${z.name} (${z.kind}${(z.required_ppe || []).length ? ', PPE: ' + z.required_ppe.join('/') : ''})`).join('; ')}\n` +
1403
+ `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`;
1404
+ const ev = (d.events || []).filter(e => e.event === 'ppe_check' || e.event === 'breach');
1405
+ 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');
1406
+ 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');
1407
+ return text;
1408
+ }
1409
+ },
1410
+
1314
1411
  // ─── Condition Ledger (Patent 12: longitudinal asset condition) ──
1315
1412
  {
1316
1413
  name: 'tascan_register_asset',
@@ -1388,7 +1485,10 @@ const TOOLS = [
1388
1485
  text += `\n${s.created_at.slice(0, 16).replace('T', ' ')} — ${s.condition_score ?? '?'}/100 (${s.condition_grade || '?'})`;
1389
1486
  if (s.delta_score != null) text += ` [${s.delta_score > 0 ? '+' : ''}${s.delta_score}]`;
1390
1487
  if (s.summary) text += `\n ${s.summary}`;
1488
+ if (s.photo_url) text += `\n photo: ${s.photo_signed_url || s.photo_url + ' (signing unavailable)'}`;
1391
1489
  });
1490
+ if (a.baseline_photo_url) text += `\n\nbaseline photo: ${a.baseline_photo_signed_url || a.baseline_photo_url + ' (signing unavailable)'}`;
1491
+ if (a.baseline_photo_url || (a.assessments || []).some(s => s.photo_url)) text += '\n\nphotos: signed URLs expire (~1h) — re-run this tool for fresh links.';
1392
1492
  return text;
1393
1493
  }
1394
1494
  },
@@ -1414,6 +1514,145 @@ const TOOLS = [
1414
1514
  },
1415
1515
 
1416
1516
  // ─── Gig Payments (verification-gated direct payer→worker) ───────
1517
+ {
1518
+ name: 'tascan_generate_report',
1519
+ 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.',
1520
+ inputSchema: {
1521
+ type: 'object',
1522
+ properties: {
1523
+ type: { type: 'string', enum: ['completion', 'service', 'project', 'evidence'] },
1524
+ list_id: { type: 'string', description: 'Required for completion / service' },
1525
+ project_id: { type: 'string', description: 'Required for project / evidence' },
1526
+ company_name: { type: 'string', description: 'Service report branding (defaults to the org name)' },
1527
+ message: { type: 'string', description: 'Service report: a note to the client shown under the header' },
1528
+ show_workers: { type: 'boolean', description: 'Service report: show worker names (default true)' },
1529
+ show_issues: { type: 'boolean', description: 'Service report: include reported issues (default false)' },
1530
+ send_to_phone: { type: 'string', description: 'Text the link to this number (E.164 or 10-digit US)' },
1531
+ send_note: { type: 'string', description: 'Short intro for the text, e.g. "Here is your report from Love Productions:"' }
1532
+ },
1533
+ required: ['type']
1534
+ },
1535
+ annotations: { title: 'Generate Report Link', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1536
+ handler: async (args, api) => {
1537
+ const result = await api('POST', '/reports', args);
1538
+ const d = result.data;
1539
+ let text = `${d.type.charAt(0).toUpperCase() + d.type.slice(1)} report ${d.created ? 'created' : 'ready'}:\n${d.url}`;
1540
+ if (d.note) text += `\n(${d.note})`;
1541
+ if (d.sms) text += d.sms.sent ? `\n\nTexted to ${d.sms.to}.` : `\n\nSMS not sent: ${d.sms.error}`;
1542
+ return text;
1543
+ }
1544
+ },
1545
+ {
1546
+ name: 'tascan_list_reports',
1547
+ description: 'List existing report links for a list or project (completion / service / project / evidence), newest first, with client acknowledgment status for service reports.',
1548
+ inputSchema: {
1549
+ type: 'object',
1550
+ properties: { list_id: { type: 'string' }, project_id: { type: 'string' } },
1551
+ required: []
1552
+ },
1553
+ annotations: { title: 'List Report Links', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1554
+ handler: async (args, api) => {
1555
+ const qs = new URLSearchParams();
1556
+ if (args.list_id) qs.set('list_id', args.list_id);
1557
+ if (args.project_id) qs.set('project_id', args.project_id);
1558
+ const result = await api('GET', '/reports' + (qs.toString() ? '?' + qs.toString() : ''));
1559
+ const rows = result.data || [];
1560
+ if (!rows.length) return 'No reports yet — use tascan_generate_report.';
1561
+ return rows.map(r => `${r.type.toUpperCase()} · ${r.title || ''} · ${r.created_at.slice(0, 10)}` +
1562
+ (r.acknowledged_at ? ` · ✓ acknowledged by ${r.acknowledged_by || 'client'} ${r.acknowledged_at.slice(0, 10)}` : (r.type === 'service' ? ' · not yet acknowledged' : '')) +
1563
+ (r.url ? `\n ${r.url}` : '')).join('\n');
1564
+ }
1565
+ },
1566
+ {
1567
+ name: 'tascan_create_invoice',
1568
+ 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.',
1569
+ inputSchema: {
1570
+ type: 'object',
1571
+ properties: {
1572
+ client_name: { type: 'string', description: 'Bill-to name (person or company)' },
1573
+ client_email: { type: 'string' },
1574
+ client_phone: { type: 'string' },
1575
+ project_id: { type: 'string', description: 'Bill every list in this project (auto line items)' },
1576
+ task_list_ids: { type: 'array', items: { type: 'string' }, description: 'Bill just these lists (auto line items)' },
1577
+ hourly_rate: { type: 'number', description: 'Dollars per hour for auto line items' },
1578
+ flat_rate_per_list: { type: 'number', description: 'Dollars per list for auto line items (used when no hourly_rate)' },
1579
+ min_hours: { type: 'number', description: 'Minimum billable hours per list (e.g. 1)' },
1580
+ 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: {
1581
+ mode: { type: 'string', enum: ['hourly', 'day_rate', 'flat'] },
1582
+ hourly_rate: { type: 'number' }, day_rate: { type: 'number' }, flat_rate_per_list: { type: 'number' }, min_hours: { type: 'number' },
1583
+ overtime: { type: 'boolean' }, overtime_after_hours: { type: 'number' }, overtime_multiplier: { type: 'number' },
1584
+ double_time_after_hours: { type: 'number' }, double_time_multiplier: { type: 'number' },
1585
+ per_diem: { type: 'number', description: 'Dollars per work day' }, per_diem_days: { type: 'number' },
1586
+ expenses: { type: 'array', items: { type: 'object', properties: { description: { type: 'string' }, amount: { type: 'number' }, quantity: { type: 'number' } }, required: ['description', 'amount'] } }
1587
+ } },
1588
+ 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'] } },
1589
+ tax_rate: { type: 'number', description: 'Fraction, e.g. 0.0825 for 8.25%' },
1590
+ due_date: { type: 'string', description: 'YYYY-MM-DD (default: 30 days out)' },
1591
+ notes: { type: 'string', description: 'Payment terms / thank-you shown on the invoice' },
1592
+ company_name: { type: 'string', description: 'Your company name on the attached Service Report (defaults to the org name)' },
1593
+ attach_service_report: { type: 'boolean', description: 'Mint + link a Service Report for single-list invoices (default true)' },
1594
+ 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' } } },
1595
+ status: { type: 'string', enum: ['draft', 'sent'], description: 'Default sent' }
1596
+ },
1597
+ required: ['client_name']
1598
+ },
1599
+ annotations: { title: 'Create Invoice', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1600
+ handler: async (args, api) => {
1601
+ const result = await api('POST', '/invoices', args);
1602
+ const inv = result.data;
1603
+ let text = `Invoice ${inv.invoice_number} — $${Number(inv.total).toFixed(2)} to ${inv.client_name} (${inv.status})\n\nLink: ${inv.url}\n`;
1604
+ if (inv.service_report_url) text += `Service Report (client acknowledges here): ${inv.service_report_url}\n`;
1605
+ 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');
1606
+ 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}`;
1607
+ return text;
1608
+ }
1609
+ },
1610
+ {
1611
+ name: 'tascan_list_invoices',
1612
+ 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.',
1613
+ inputSchema: {
1614
+ type: 'object',
1615
+ properties: {
1616
+ status: { type: 'string', enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled'] },
1617
+ project_id: { type: 'string' }
1618
+ },
1619
+ required: []
1620
+ },
1621
+ annotations: { title: 'List Invoices', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1622
+ handler: async (args, api) => {
1623
+ const qs = new URLSearchParams();
1624
+ if (args.status) qs.set('status', args.status);
1625
+ if (args.project_id) qs.set('project_id', args.project_id);
1626
+ const result = await api('GET', '/invoices' + (qs.toString() ? '?' + qs.toString() : ''));
1627
+ const rows = result.data || [];
1628
+ if (!rows.length) return 'No invoices yet.';
1629
+ const outstanding = rows.filter(r => r.status === 'sent' || r.status === 'overdue').reduce((s, r) => s + Number(r.total || 0), 0);
1630
+ 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') +
1631
+ `\n\nOutstanding: $${outstanding.toFixed(2)} across ${rows.filter(r => r.status === 'sent' || r.status === 'overdue').length} invoice(s)`;
1632
+ }
1633
+ },
1634
+ {
1635
+ name: 'tascan_update_invoice',
1636
+ description: 'Update an invoice: mark it paid (records paid_at), overdue, cancelled, or edit client details / notes / due date.',
1637
+ inputSchema: {
1638
+ type: 'object',
1639
+ properties: {
1640
+ invoice_id: { type: 'string' },
1641
+ status: { type: 'string', enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled'] },
1642
+ paid_at: { type: 'string', description: 'ISO timestamp (default now) when status = paid' },
1643
+ client_name: { type: 'string' }, client_email: { type: 'string' }, client_phone: { type: 'string' },
1644
+ notes: { type: 'string' }, due_date: { type: 'string' }
1645
+ },
1646
+ required: ['invoice_id']
1647
+ },
1648
+ annotations: { title: 'Update Invoice', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1649
+ handler: async (args, api) => {
1650
+ const { invoice_id, ...patch } = args;
1651
+ const result = await api('PUT', `/invoices/${invoice_id}`, patch);
1652
+ const inv = result.data;
1653
+ 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}`;
1654
+ }
1655
+ },
1417
1656
  {
1418
1657
  name: 'tascan_request_payment',
1419
1658
  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).',