tascan-mcp 3.13.0 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -268,6 +268,15 @@ API keys are scoped to your organization and support rate limiting (60 requests/
268
268
 
269
269
  ## Changelog
270
270
 
271
+ ### v3.14.0 — 2026-09-18
272
+ - **Coordination layer live** (migration 155): `tascan_create_cycle`, `tascan_get_cycle_report`, `tascan_get_build`, `tascan_get_build_file`, `tascan_post_message` now run against the released schema — build → independent review → one human decision → integrate, with signed receipts at every step.
273
+ - **Scheduled SMS** (migration 157): `tascan_schedule_sms` (full tier), `tascan_list_scheduled_sms` (read), `tascan_cancel_scheduled_sms` (full) — TaScan's own 5-minute scheduler sends through the same guarded lane as `tascan_send_sms`; nothing outside TaScan has to stay awake.
274
+ - `tascan_get_receipt`: `profile` argument (`full` | `public`); public-profile receipts are now published for the protocol page's build trail.
275
+ - 78 tools. Hosted MCP (https://app.tascan.io/mcp) already serves this version; this release brings the stdio package to parity.
276
+
277
+ ### v3.13.1 — 2026-09-14
278
+ - Docs only. `tascan_get_task` and `tascan_get_receipt` now say it plainly: a completion with status `completed` means the executor returned and a result was recorded — it does **not** mean the result was accepted. Acceptance is the receipt's `verification.result` under a named policy, and in v0.1 no policy runs for agent tasks (`no_policy_run`), so read the recorded response before treating an agent completion as success (protocol §8.3 C11). The receipt description also lists the eleven-step verifier (65,536-byte size gate, real-calendar datetimes, GPS bounds, token-free locators, `verifier.identity` vocabulary). No tool additions — still 70 tools.
279
+
271
280
  ### v3.13.0 — 2026-09-14
272
281
  - **Authorization scopes.** Every key and OAuth grant now carries a tier — `read`, `write`, or `full` — plus two additive permissions: `agent:dispatch` (hand PLAN/RESEARCH/WRITE/REVIEW/DEFAULT tasks to an AI agent inbox) and `agent:dispatch:code` (CODE/SHELL tasks, which run on the org's own machine). Tools are classified in `scopes.cjs`; a call outside the grant returns an `isError` result naming the scope to reconnect with. Existing OAuth connections must reconnect and tick the dispatch boxes on the consent page to dispatch again.
273
282
  - **`tascan_get_receipt` (new, 70 tools).** Fetches the signed Action Receipt (Ed25519 JWS, TaScan Protocol v0.1) for one completion: what was done, by whom, evidence hashes, verification, ledger chain head — with per-field provenance. Verify offline against `https://app.tascan.io/.well-known/tascan-receipt-keys.json` or online via `POST /api/v1/receipts/verify`. Spec: https://app.tascan.io/docs/protocol/TASCAN-PROTOCOL-v0.1.md
package/index.js CHANGED
@@ -46,7 +46,7 @@ async function api(method, path, body) {
46
46
  api.key = API_KEY; // raw-fetch tools read the key from here (S99: those functions now require auth)
47
47
 
48
48
  const server = new Server(
49
- { name: 'tascan', version: '3.13.0' },
49
+ { name: 'tascan', version: '3.14.0' },
50
50
  { capabilities: { tools: {} } }
51
51
  );
52
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tascan-mcp",
3
- "version": "3.13.0",
3
+ "version": "3.14.0",
4
4
  "mcpName": "io.github.snowbikemike/tascan-mcp",
5
5
  "description": "TaScan MCP Server — Closed-loop autonomous operations protocol. 70 tools for projects, tasks, workers, QR codes, NFC tags, worker marketplace with consented SMS invites, geofenced zones with rules (work site / hazard + AI-verified PPE checkpoints / containment / restricted, breach alerts, zone compliance audit), AI condition assessment, worker passports, verification-gated gig payments, client invoicing from verified work (overtime / day rate / per diem / expenses), shareable report links (completion, client service report, project, evidence pack), signed Action Receipts (Ed25519 JWS), cross-entity search, duplicate-worker detection, worker identity merge, AI issue analysis, and autonomous remediation dispatch. 15 patents filed (470+ claims). Task. Scan. Done.",
6
6
  "type": "module",
package/scopes.cjs CHANGED
@@ -42,7 +42,8 @@ const AGENT_INBOX_IDS = ['f745d0fa-421a-42e5-85be-0a3f9acd28e8'];
42
42
  // as an urgency marker). No keyword heuristics anywhere.
43
43
  const TASK_TYPES = ['CODE', 'SHELL', 'MCP', 'PLAN', 'WRITE', 'RESEARCH', 'REVIEW'];
44
44
  const LOCAL_TASK_TYPES = ['CODE', 'SHELL', 'PLAN', 'DEFAULT', 'MCP'];
45
- const CLOUD_TASK_TYPES = ['RESEARCH', 'WRITE', 'REVIEW', 'DEFAULT'];
45
+ const CLOUD_TASK_TYPES = ['RESEARCH', 'WRITE', 'DEFAULT'];
46
+ const REVIEWER_TASK_TYPES = ['REVIEW']; // agent-review-background.js (155): OpenAI reviewer runner, read-tier key
46
47
  const LOCAL_ONLY_TASK_TYPES = ['CODE', 'SHELL', 'PLAN', 'MCP']; // the cloud runner refuses these; DEFAULT goes either way
47
48
  const CODE_TASK_TYPES = ['CODE', 'SHELL'];
48
49
  const TASK_PREFIX_RE = new RegExp('^(' + TASK_TYPES.join('|') + '):');
@@ -176,6 +177,12 @@ const TOOL_SCOPES = {
176
177
  tascan_list_payments: R, tascan_get_worker_passport: R, tascan_server_info: R, tascan_find: R,
177
178
  tascan_get_worker: R, tascan_find_duplicate_workers: R,
178
179
  tascan_get_receipt: R, // W3 receipt.js (GET /api/v1/receipts/:id — read tier there too)
180
+ // coordination layer (protocol v0.2 design item 1, migration 155) — read tier: the reviewer's ['read'] key may call these
181
+ tascan_get_build: R, tascan_get_build_file: R, tascan_get_cycle_report: R,
182
+ // write here; REST upgrades POST /tasks/:id/messages to agent:dispatch (:code for CODE/SHELL askers) when the task is a cycle task
183
+ tascan_post_message: W,
184
+ // creating a cycle queues a CODE:/SHELL: prompt on the AI Inbox → agent:dispatch:code, always
185
+ tascan_create_cycle: { tier: 'write', dispatch: 'code' },
179
186
  // write (27)
180
187
  tascan_create_project: W, tascan_update_project: W, tascan_delete_project: W, tascan_create_event: W,
181
188
  tascan_update_event: W, tascan_delete_event: W, tascan_update_task: W, tascan_reply_with_list: W,
@@ -193,6 +200,7 @@ const TOOL_SCOPES = {
193
200
  tascan_dispatch_to_agent: a => ({ tier: 'write', dispatch: dispatchKind(a.task) }),
194
201
  // full (7): sends to a human, spends AI credits, or pledges money
195
202
  tascan_dispatch_instruction: F, tascan_auto_resolve: F, tascan_send_sms: F, tascan_send_task_email: F,
203
+ tascan_schedule_sms: F, tascan_list_scheduled_sms: R, tascan_cancel_scheduled_sms: F,
196
204
  tascan_invite_worker: F, tascan_assess_condition: F, tascan_request_payment: F,
197
205
  // argument-dependent (2)
198
206
  tascan_analyze_issue: a => (a.server_side_ai ? { ...F } : { ...R }),
@@ -211,6 +219,10 @@ function requiredForTool(name, args) {
211
219
  // ─── Route map: EVERY handler name in api-v1-helpers.js parsePath × accepted methods. ─────
212
220
  // (handler, method) not listed → null → 403 unclassified. 'admin' = admin-JWT routes (gate skipped).
213
221
  const INBOX_WRITE = () => ({ tier: 'write', dispatch: 'if_inbox' });
222
+ // Posting on a CYCLE task (tasks.coord IS NOT NULL) can become executor prompt text or reviewer input, so it needs
223
+ // agent:dispatch (agent:dispatch:code when the task — or the asker a question task stands for — is CODE:/SHELL:).
224
+ // Resolved server-side by api-v1.js exactly like if_inbox; an ordinary task stays write tier. Design §4.4, invariant 18.
225
+ const CYCLE_WRITE = () => ({ tier: 'write', dispatch: 'if_cycle' });
214
226
  const ROUTE_SCOPES = {
215
227
  projects: { GET: R, POST: W },
216
228
  project: { GET: R, PUT: W, DELETE: W },
@@ -246,6 +258,9 @@ const ROUTE_SCOPES = {
246
258
  find: { GET: R },
247
259
  scans: { GET: R },
248
260
  sms: { POST: F },
261
+ sms_schedule: { POST: F },
262
+ sms_scheduled: { GET: R },
263
+ sms_scheduled_item: { GET: R, DELETE: F },
249
264
  sms_status: { GET: R },
250
265
  marketplace: { GET: R },
251
266
  marketplace_invite: { POST: F },
@@ -263,7 +278,17 @@ const ROUTE_SCOPES = {
263
278
  tags: { GET: R, POST: W },
264
279
  tag: { GET: R, PUT: W, DELETE: W },
265
280
  tag_scans: { GET: R },
266
- keys: 'admin', key: 'admin'
281
+ // coordination layer (api-v1-helpers.js parsePath handlers task_messages / coord_cycles / coord_cycle_report / build / build_file)
282
+ task_messages: { GET: R, POST: CYCLE_WRITE }, // trail messages; POST answer on a dispatcher question → coord_answer_question
283
+ coord_cycles: { GET: R, POST: { tier: 'write', dispatch: 'code' } }, // list roots / coord_create_cycle (T1 = CODE:/SHELL: on the AI Inbox)
284
+ 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
285
+ build: { GET: R }, // GET /builds/:build_ref — bundle manifest (build_artifacts)
286
+ build_file: { GET: R }, // GET /builds/:build_ref/file?path=&offset=&limit= ≤ 12,000 chars per call
287
+ keys: 'admin', key: 'admin',
288
+ // receipt.js lanes (a separate function that calls scopes.check itself; classified HERE so there is one map).
289
+ // The anonymous GET /api/v1/receipts/:id/public is credential-free and therefore not classified here.
290
+ receipt: { GET: R }, // GET /api/v1/receipts/:id[?profile=public] (was hard-coded read in receipt.js)
291
+ receipt_publish: { POST: W, DELETE: W } // open / close the anonymous public-profile path
267
292
  };
268
293
 
269
294
  function requiredForRoute(handler, method, body) {
@@ -307,7 +332,7 @@ function restDenialHeaders(d) {
307
332
 
308
333
  module.exports = {
309
334
  SCOPE_VALUES, ALL_SCOPES, TIERS, RANK, DISPATCH, DISPATCH_CODE, AGENT_INBOX_IDS,
310
- TASK_TYPES, LOCAL_TASK_TYPES, CLOUD_TASK_TYPES, LOCAL_ONLY_TASK_TYPES, CODE_TASK_TYPES, taskType, dispatchScopeFor, dispatchKind,
335
+ TASK_TYPES, LOCAL_TASK_TYPES, CLOUD_TASK_TYPES, REVIEWER_TASK_TYPES, LOCAL_ONLY_TASK_TYPES, CODE_TASK_TYPES, taskType, dispatchScopeFor, dispatchKind,
311
336
  normalize, toArray, toScopeString, parseRequested, echoScope, SCOPE_PARAM_MAX, clamp, validateStored, check,
312
337
  TOOL_SCOPES, PENDING_TOOLS, ROUTE_SCOPES, requiredForTool, requiredForRoute,
313
338
  mcpDenialText, restDenialBody, restDenialHeaders
package/tools.cjs CHANGED
@@ -420,7 +420,7 @@ const TOOLS = [
420
420
  },
421
421
  {
422
422
  name: 'tascan_get_task',
423
- 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. Tasks dispatched to an AI agent also carry an "agent" block (state claimed|running|completed|failed|expired|released, attempts, current run with runner/trace_id/error) — the only place agent failures are reported.',
423
+ 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. Tasks dispatched to an AI agent also carry an "agent" block (state claimed|running|completed|failed|expired|released, attempts, current run with runner/trace_id/error) — the only place agent failures are reported. A completion with status "completed" means the executor returned and its result was recorded (a model refusal, a wrong answer or an administrative note all "complete"); it does NOT mean the requested result was accepted. Acceptance is the completion evidence_check / the receipt verification.result under a named policy, and in v0.1 no policy exists for agent tasks (exact-output and rubric policies are v0.2) — check the recorded response text yourself before treating an agent completion as success (protocol §2.6, §3.2, §8.3 C11).',
424
424
  inputSchema: {
425
425
  type: 'object',
426
426
  properties: { task_id: { type: 'string', description: 'Task ID' } },
@@ -442,17 +442,34 @@ const TOOLS = [
442
442
  },
443
443
  {
444
444
  name: 'tascan_get_receipt',
445
- description: 'Fetch the signed Action Receipt (Ed25519 JWS) for one completed task. Takes the completion_id (tascan_get_task -> completions[].id). Returns a human-readable summary (what was done, by whom, verification level, evidence hashes, AI verification result, outcome, ledger chain) plus receipt_id/serial/kid, the compact JWS, and the public verify URL. Anyone can verify the JWS offline against the JWKS or online by POSTing {jws} to the verify URL. Read tier.',
445
+ description: 'Fetch the signed Action Receipt (Ed25519 JWS) for one completed task by completion_id (tascan_get_task -> completions[].id). Returns a readable summary (what, who, verification, evidence hashes, outcome, ledger chain) plus receipt_id/serial/kid, the compact JWS and the public verify URL. Verify offline against the JWKS or online by POSTing a JSON body whose jws field holds the compact receipt. Read outcome and verification separately: outcome completed = the executor returned and a result was recorded; verification.result = the verdict of a named policy; all-null verification with reason no_policy_run = no policy ran. Never treat outcome=completed as success without a policy verdict you trust (protocol 8.3 C11). Verifier: 6.8. profile=public returns the separately signed public export profile (protocol 6.10): it withholds the raw org, list, project, worker, run and trace ids (each a 16-hex id_hash) and storage locators, and binds to the full receipt - the form for anyone outside the org. Read tier.',
446
446
  inputSchema: {
447
447
  type: 'object',
448
- properties: { completion_id: { type: 'string', description: 'task_completions.id (UUID) - from tascan_get_task -> completions[].id' } },
448
+ properties: {
449
+ completion_id: { type: 'string', description: 'task_completions.id (UUID) - from tascan_get_task -> completions[].id' },
450
+ profile: { type: 'string', enum: ['full', 'public'], description: 'full (default) = the org view with the unsigned private block; public = the separately signed public export profile: no raw org, list, project, worker, run or trace id (each is a 16-hex id_hash), no storage locators, plus a binding to the full receipt it was derived from - the form to hand to anyone outside the org' }
451
+ },
449
452
  required: ['completion_id']
450
453
  },
451
454
  annotations: { title: 'Get Action Receipt', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
452
455
  handler: async (args, api) => {
453
456
  const id = String(args.completion_id || '').trim().toLowerCase();
454
457
  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');
455
- const env = await api('GET', `/receipts/${id}`); // receipt.js returns the envelope itself (no data wrapper)
458
+ // profile: absent → full; an EXPLICIT value must be one of the two lanes — anything else is an error, never a
459
+ // silent fall-back to the full (org-private) envelope.
460
+ const profile = args.profile === undefined || args.profile === null ? 'full' : args.profile;
461
+ if (profile !== 'full' && profile !== 'public') throw new Error("profile must be 'full' or 'public'");
462
+ const env = await api('GET', `/receipts/${id}${profile === 'public' ? '?profile=public' : ''}`); // receipt.js returns the envelope itself (no data wrapper)
463
+ if (profile === 'public') {
464
+ // Fail CLOSED on the boundary: the deployed receipt endpoint, the npm package and the connector schema roll out
465
+ // separately, so an endpoint that predates the public profile ignores ?profile=public and answers its FULL
466
+ // envelope (raw ids + the unsigned private block). A public request is served ONLY by a public-profile
467
+ // envelope: view/profile public, no private block, and the JWS header typ tascan-receipt-public+jwt.
468
+ let typ = null;
469
+ try { typ = JSON.parse(Buffer.from(String(env && env.jws || '').split('.')[0], 'base64url').toString('utf8')).typ; } catch (e) { typ = null; }
470
+ const isPublic = env && typeof env === 'object' && env.profile === 'public' && env.view === 'public' && !('private' in env) && typ === 'tascan-receipt-public+jwt';
471
+ if (!isPublic) throw new Error('the receipt endpoint did not return the public export profile for profile=public (got ' + (env && typeof env === 'object' ? 'view ' + (env.view || 'unknown') + ', typ ' + (typ || 'none') + ('private' in env ? ', a private block' : '') : 'no envelope') + '); the deployed API may predate protocol 6.10 - nothing from this response is shown, ask for the full profile explicitly or retry after the deploy');
472
+ }
456
473
  const r = (env.claims && env.claims.receipt) || {};
457
474
  const v = x => (x && typeof x === 'object' && 'value' in x) ? x.value : x;
458
475
  const a = r.action || {}, ex = r.executor || {}, ts = r.timestamps || {}, ver = r.verification || null, ch = r.chain || {};
@@ -460,10 +477,10 @@ const TOOLS = [
460
477
  const verify = env.verify || {};
461
478
  const lines = [
462
479
  '=== TASCAN ACTION RECEIPT ===',
463
- `Receipt: ${env.receipt_id} (serial ${env.serial}, kid ${env.kid}, status ${env.status}, view ${env.view})`,
480
+ `Receipt: ${env.receipt_id} (serial ${env.serial}, kid ${env.kid}, status ${env.status}, view ${env.view}${env.profile === 'public' ? `, public profile${env.published ? ', PUBLISHED at ' + env.url : ', not published'}` : ''})`,
464
481
  `Subject: ${env.subject} (the Evidence row; the Action is task ${v(a.task_id)})`,
465
482
  `Action: ${(env.private && env.private.title) || '(title signed as sha256 ' + String(v(a.title_sha256) || '').slice(0, 16) + '…)'} [task ${v(a.task_id)} in list ${(env.private && env.private.list_name) || v(a.list_id)}]`,
466
- `Executor: ${(env.private && env.private.executor_display) || v(ex.id) || 'unknown'} (${v(ex.kind)}, verification_level ${v(ex.verification_level)}${v(ex.runner) ? `, runner ${v(ex.runner)}, trace ${v(ex.trace_id)}, attempt ${v(ex.attempt)}` : ''})`,
483
+ `Executor: ${(env.private && env.private.executor_display) || v(ex.id) || (v(ex.id_hash) ? 'id_hash ' + v(ex.id_hash) + ' (public profile)' : null) || 'unknown'} (${v(ex.kind)}, verification_level ${v(ex.verification_level)}${v(ex.runner) ? `, runner ${v(ex.runner)}, trace ${v(ex.trace_id) || (v(ex.trace_id_hash) ? 'id_hash ' + v(ex.trace_id_hash) : null)}, attempt ${v(ex.attempt)}` : ''})`,
467
484
  `Outcome: ${v(r.outcome)} | completed ${v(ts.completed)} | recorded ${v(ts.recorded)}`,
468
485
  `Evidence: ${(r.evidence || []).map(e => `${v(e.type)} sha256:${String(v(e.sha256) || '').slice(0, 16)}...`).join(', ') || 'none'}`,
469
486
  `Verification: ${vr ? `${vr.verified ? 'VERIFIED' : 'NOT verified'} (confidence ${vr.confidence}) by ${vf && vf.kind}${vf && vf.identity ? ' ' + vf.identity : ''}, policy ${v(ver.policy_id)}@${v(ver.policy_version)}${env.private && env.private.verification_reason ? ' - ' + env.private.verification_reason + ' (unsigned; sha256 signed)' : ''}` : 'none recorded'}`,
@@ -673,7 +690,7 @@ const TOOLS = [
673
690
  },
674
691
  {
675
692
  name: 'tascan_complete_task',
676
- description: 'Complete a task on behalf of a worker. Inserts a completion record and timer event. Use this to simulate or record task completions via the API.',
693
+ description: 'Complete an ORDINARY task on behalf of a worker. Inserts a completion record and timer event. Use this to simulate or record task completions via the API. Coordination-cycle tasks (a CODE:/REVIEW: build or review, a Decision / Question / Integrate / Parked card on a project Decisions list — tasks that carry `coord`) are refused with 403 for every key tier: builds and reviews are completed by their runner, decisions only by the human on the worker page.',
677
694
  inputSchema: {
678
695
  type: 'object',
679
696
  properties: {
@@ -1119,6 +1136,52 @@ const TOOLS = [
1119
1136
  return text;
1120
1137
  }
1121
1138
  },
1139
+ {
1140
+ name: 'tascan_schedule_sms',
1141
+ description: 'Schedule a transactional TaScan SMS for a future time (up to 90 days out): the text is sent by TaScan\'s own scheduler (every 5 minutes) through the same guarded lane as tascan_send_sms — recipient must be a worker of your org or a phone the org already knows, STOP opt-outs honoured, burst limits and the SMS quota apply, the "TaScan:" prefix is added, and a list_id appends a tap-to-open checklist link. Use this for reminders (e.g. "log your out time" each show night) — nothing outside TaScan needs to stay awake. Returns the scheduled row id; cancel with tascan_cancel_scheduled_sms while it is still pending. The same idempotency_key within an org returns the existing row instead of a duplicate.',
1142
+ inputSchema: {
1143
+ type: 'object',
1144
+ properties: {
1145
+ send_at: { type: 'string', description: 'When to send — ISO 8601 with a timezone offset, e.g. "2026-09-25T18:30:00-07:00" (Las Vegas is -07:00 in September). Delivery happens on the next 5-minute tick at or after this time.' },
1146
+ message: { type: 'string', description: 'Message text (1-400 chars, links stripped). Transactional and work-related only.' },
1147
+ worker_id: { type: 'string', description: 'Worker ID — texts their phone on file (preferred over raw phone)' },
1148
+ phone: { type: 'string', description: 'Raw phone (E.164) — must already belong to a worker or roster entry of your org' },
1149
+ list_id: { type: 'string', description: 'Optional task list ID — appends the tap-to-open checklist link' },
1150
+ include_link: { type: 'boolean', description: 'Append the list link (default true when list_id is given)' },
1151
+ idempotency_key: { type: 'string', description: 'Optional caller key (≤ 200 chars) — replays return the existing scheduled row' }
1152
+ },
1153
+ required: ['send_at', 'message']
1154
+ },
1155
+ annotations: { title: 'Schedule SMS', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1156
+ handler: async (args, api) => {
1157
+ const result = await api('POST', '/sms/schedule', { send_at: args.send_at, message: args.message, worker_id: args.worker_id, phone: args.phone, list_id: args.list_id, include_link: args.include_link, idempotency_key: args.idempotency_key });
1158
+ const d = result.data || {};
1159
+ return `${d.created === false ? 'Already scheduled (same idempotency_key)' : 'Scheduled'}: id ${d.id}\n send_at ${d.send_at} status ${d.status}\n to ${d.worker_id ? 'worker ' + d.worker_id : d.phone}${d.list_id ? ' list ' + d.list_id + (d.include_link === false ? ' (no link)' : ' (+ link)') : ''}\n message: ${d.message}\nCancel while pending: tascan_cancel_scheduled_sms id=${d.id}`;
1160
+ }
1161
+ },
1162
+ {
1163
+ name: 'tascan_list_scheduled_sms',
1164
+ description: 'List your org\'s scheduled texts (default: pending + sending, soonest first; status=sent|failed|cancelled to see history). Each row shows send_at, status, attempts, the Twilio sid once sent, and the last error for a failed row.',
1165
+ inputSchema: { type: 'object', properties: { status: { type: 'string', enum: ['pending', 'sending', 'sent', 'failed', 'cancelled'], description: 'Filter by status (default pending + sending)' } } },
1166
+ annotations: { title: 'List Scheduled SMS', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1167
+ handler: async (args, api) => {
1168
+ const result = await api('GET', '/sms/scheduled' + (args.status ? '?status=' + encodeURIComponent(args.status) : ''));
1169
+ const rows = result.data || [];
1170
+ if (!rows.length) return 'No scheduled texts' + (args.status ? ` with status ${args.status}` : ' pending') + '.';
1171
+ return rows.map(r => `${r.id} ${r.send_at} ${r.status}${r.attempts ? ' (attempt ' + r.attempts + ')' : ''} to ${r.worker_id ? 'worker ' + r.worker_id : r.phone}${r.list_id ? ' list ' + r.list_id : ''}\n ${r.message}${r.provider_ref ? '\n sid ' + r.provider_ref : ''}${r.error ? '\n error: ' + r.error : ''}`).join('\n');
1172
+ }
1173
+ },
1174
+ {
1175
+ name: 'tascan_cancel_scheduled_sms',
1176
+ description: 'Cancel a scheduled text that has not been sent yet (status pending). A row already sending, sent, failed or cancelled is refused (409) — a sent text cannot be recalled.',
1177
+ inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'The scheduled_sms row id from tascan_schedule_sms / tascan_list_scheduled_sms' } }, required: ['id'] },
1178
+ annotations: { title: 'Cancel Scheduled SMS', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
1179
+ handler: async (args, api) => {
1180
+ const result = await api('DELETE', '/sms/scheduled/' + encodeURIComponent(String(args.id || '')));
1181
+ const d = result.data || {};
1182
+ return `Cancelled scheduled text ${d.id} (was due ${d.send_at}).`;
1183
+ }
1184
+ },
1122
1185
  {
1123
1186
  name: 'tascan_get_sms_status',
1124
1187
  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.',
@@ -1921,6 +1984,172 @@ const TOOLS = [
1921
1984
  const result = await api('DELETE', `/workers/${args.worker_id}`);
1922
1985
  return `Worker ${args.worker_id} tombstoned (soft delete — record retained for audit).`;
1923
1986
  }
1987
+ },
1988
+ // ─── Coordination layer (protocol v0.2 design item 1, migration 155) ─────────────────────────────
1989
+ // A cycle = T1 CODE/SHELL build (local executor, stores the build bundle → build_ref) → T2 REVIEW (independent
1990
+ // reviewer runner, same build_ref) → T3 Decision (the human authority on the project's Decisions list, one SMS)
1991
+ // → T4 Integrate (deploy id). Nothing here can approve, deploy, page a human or move the DAG: those are
1992
+ // SECURITY DEFINER RPCs behind human completions. The cycle report is the audit surface.
1993
+ {
1994
+ name: 'tascan_create_cycle',
1995
+ description: 'Start an unattended build-review-decide cycle (protocol v0.2). Queues T1 CODE: (or SHELL:) with your build_brief on the AI Inbox and T2 REVIEW: with your review_brief, born blocked on T1. The local executor builds, stores the exact bytes of artifact_paths as a bundle (build_ref = sha256 over the manifest), the independent reviewer reviews THAT bundle, an approve verdict mints a Decision task for the human authority (one SMS), Approve mints an Integrate task for the deploy id. Revise verdicts spawn revisions (cap max_revisions, default 3); reject, human Reject, scope violations or exhausted revisions PARK the cycle (a Parked task with Resume with notes / Close). REQUIRES agent:dispatch:code. Duplicate protection: the same idempotency_key, or the same briefs + paths, within 24 h returns the existing root instead of queueing again (created=false). Track with tascan_get_cycle_report (root_id). Nothing spawns a cycle on its own.',
1996
+ inputSchema: {
1997
+ type: 'object',
1998
+ properties: {
1999
+ 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>".' },
2001
+ 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
+ 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.' },
2003
+ 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).' },
2005
+ max_revisions: { type: 'integer', description: 'Revision cap (default 3): at most max_revisions + 1 builds and reviews.' },
2006
+ max_questions: { type: 'integer', description: 'Questions a runner may ask per task before the attempt fails (default 3).' },
2007
+ max_cost_micro_usd: { type: 'integer', description: 'Per-cycle spend cap summed over every attempt, in micro-USD (default 5000000 = USD 5). A claim that could overrun it is refused (budget_exhausted).' },
2008
+ preview_url: { type: 'string', description: 'Optional https preview link shown on the Decision task.' },
2009
+ checkpoint_title: { type: 'string', description: 'Optional title template for the Decision task (default "Decision: <title>").' },
2010
+ checkpoint_description: { type: 'string', description: 'Optional description template for the Decision task; the build_ref, preview and findings summary are appended.' },
2011
+ integrate_title: { type: 'string', description: 'Optional title template for the Integrate task (default "Integrate: <title>").' },
2012
+ integrate_description: { type: 'string', description: 'Optional description template for the Integrate task.' },
2013
+ idempotency_key: { type: 'string', description: 'Optional caller key (≤ 200 chars). The same key within 24 h returns the existing cycle (created=false) instead of a duplicate dispatch.' }
2014
+ },
2015
+ required: ['project_id', 'title', 'build_brief', 'review_brief', 'artifact_paths']
2016
+ },
2017
+ annotations: { title: 'Create Cycle', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
2018
+ handler: async (args, api) => {
2019
+ const paths = Array.isArray(args.artifact_paths) ? args.artifact_paths : (typeof args.artifact_paths === 'string' ? [args.artifact_paths] : []);
2020
+ if (!paths.length) throw new Error('artifact_paths is required (1-64 repo-relative paths)');
2021
+ const options = {};
2022
+ 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];
2023
+ const templates = {};
2024
+ if (args.checkpoint_title || args.checkpoint_description) templates.checkpoint = { ...(args.checkpoint_title ? { title: args.checkpoint_title } : {}), ...(args.checkpoint_description ? { description: args.checkpoint_description } : {}) };
2025
+ if (args.integrate_title || args.integrate_description) templates.integrate = { ...(args.integrate_title ? { title: args.integrate_title } : {}), ...(args.integrate_description ? { description: args.integrate_description } : {}) };
2026
+ if (Object.keys(templates).length) options.templates = templates;
2027
+ const body = { project_id: args.project_id, title: args.title, build_brief: args.build_brief, review_brief: args.review_brief, artifact_paths: paths, options };
2028
+ if (args.idempotency_key) body.idempotency_key = args.idempotency_key;
2029
+ const result = await api('POST', '/coord/cycles', body);
2030
+ const d = result.data || {};
2031
+ if (result.duplicate || d.created === false) {
2032
+ 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}`;
2033
+ }
2034
+ return `Cycle created.\nroot_id (T1 ${options.task_type || 'CODE'}: build): ${d.root_id}\nreview_id (T2 REVIEW, blocked on T1): ${d.review_id}\nbrief_sha256: ${d.brief_sha256}\ndecisions_list_id: ${d.decisions_list_id}\nquestions_list_id: ${d.questions_list_id}\nartifact_paths: ${paths.join(', ')}\n\nNext: the local executor claims T1 on its next poll (≤ 30 s), stores the bundle and finishes with build_ref; T2 is released from that finish. Follow with tascan_get_cycle_report root_id=${d.root_id} or tascan_get_task task_id=${d.root_id} (agent + trail blocks). Nobody is texted until the reviewer approves.`;
2035
+ }
2036
+ },
2037
+ {
2038
+ name: 'tascan_post_message',
2039
+ description: 'Post a message on a task trail (protocol v0.2 trail_messages): kind question, answer, handoff or discussion. A message never completes a task, never satisfies a gate and never pages anyone. The actor is stamped from your credential (key:<id>, actor_type "key" — a credential, never a human), never from the body; the executor and the reviewer consume a key\'s answers only when the key holds agent:dispatch. On an ordinary task this is write tier. On a CYCLE task (one with coord) it needs agent:dispatch (agent:dispatch:code when the task, or the asker a question task stands for, is CODE:/SHELL:) because the text can become executor prompt or reviewer input. kind=answer on a dispatcher-addressed question task answers it through coord_answer_question and releases the blocked asker; a human-addressed question is answered only on the worker page (403 here). finding (reviewer runner) and decision (human completion) cannot be posted. Body ≤ 8000 chars; idempotency_key makes a replay return the same message.',
2040
+ inputSchema: {
2041
+ type: 'object',
2042
+ properties: {
2043
+ task_id: { type: 'string', description: 'Task ID (UUID). For an answer: the QUESTION task id (tascan_get_task on the asker shows coord.blocked_by).' },
2044
+ kind: { type: 'string', enum: ['question', 'answer', 'handoff', 'discussion'], description: 'question = a question for the record (does not block anything); answer = answers a dispatcher question task and releases the asker; handoff = hand work or context to the next agent; discussion = a note.' },
2045
+ body: { type: 'string', description: 'The message text (1-8000 chars). Treated as DATA by every reader; on a cycle task it may be prepended to the executor prompt as TRAIL INPUT.' },
2046
+ reply_to: { type: 'string', description: 'Optional message id this replies to.' },
2047
+ build_ref: { type: 'string', description: 'Optional sha256:<64 hex> the message is about (defaults to the cycle task\'s bound build).' },
2048
+ addressee: { type: 'string', enum: ['human', 'dispatcher'], description: 'Optional, kind=question only: who the question is for (recorded; nobody is paged).' },
2049
+ idempotency_key: { type: 'string', description: 'Optional replay key (≤ 200 chars): the same key on the same task returns the same message id.' }
2050
+ },
2051
+ required: ['task_id', 'kind', 'body']
2052
+ },
2053
+ annotations: { title: 'Post Trail Message', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2054
+ handler: async (args, api) => {
2055
+ const id = String(args.task_id || '').trim().toLowerCase();
2056
+ 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');
2057
+ const body = { kind: args.kind, body: args.body };
2058
+ for (const k of ['reply_to', 'build_ref', 'addressee', 'idempotency_key']) if (args[k]) body[k] = args[k];
2059
+ const result = await api('POST', `/tasks/${id}/messages`, body);
2060
+ const m = result.data || {};
2061
+ let text = `Posted ${m.kind || args.kind} message ${m.id} on task ${m.task_id || id} as ${m.actor_id || 'you'}` + (m.build_ref ? ` (build ${m.build_ref})` : '') + '.';
2062
+ if (result.question_task_id) text += `\nAnswered question task ${result.question_task_id}; asker ${result.asker_id} ${result.released ? 'RELEASED — its runner picks it up on the next poll' : 'was not released (already released or not blocked on this question)'}.`;
2063
+ if (result.note) text += '\n' + result.note;
2064
+ return text + '\n\n' + JSON.stringify(m, null, 2);
2065
+ }
2066
+ },
2067
+ {
2068
+ name: 'tascan_get_build',
2069
+ 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.',
2070
+ inputSchema: {
2071
+ type: 'object',
2072
+ properties: { build_ref: { type: 'string', description: 'sha256:<64 hex> (or the bare 64 hex) — from tascan_get_task (coord.build_ref / agent.runs[].build_ref) or tascan_get_cycle_report.' } },
2073
+ required: ['build_ref']
2074
+ },
2075
+ annotations: { title: 'Get Build Manifest', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2076
+ handler: async (args, api) => {
2077
+ const ref = String(args.build_ref || '').trim().toLowerCase();
2078
+ if (!/^(sha256:)?[0-9a-f]{64}$/.test(ref)) throw new Error('build_ref must be sha256:<64 hex>');
2079
+ const result = await api('GET', `/builds/${encodeURIComponent(ref.startsWith('sha256:') ? ref : 'sha256:' + ref)}`);
2080
+ const d = result.data || {};
2081
+ const lines = [`BUILD ${d.build_ref}`, `run ${d.run_id} · task ${d.task_id}${d.task_title ? ' · ' + d.task_title : ''} · ${d.file_count} file(s)`, ''];
2082
+ (d.files || []).forEach(f => lines.push(`${f.path} sha256:${f.sha256} ${f.byte_length} B${f.content_type ? ' ' + f.content_type : ''}${f.has_content ? '' : ' (no stored text: binary or over cap)'}`));
2083
+ lines.push('', `Read a file: tascan_get_build_file build_ref=${d.build_ref} path=<path>`, 'This manifest proves nothing about the file contents: every file under review must be read with tascan_get_build_file until its chunks cover the whole file (a result that says TRUNCATED is continued from next_offset).');
2084
+ const out = lines.join('\n');
2085
+ return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
2086
+ }
2087
+ },
2088
+ {
2089
+ name: 'tascan_get_build_file',
2090
+ description: 'Read one file from a stored build bundle by build_ref and path (protocol v0.2 build_artifacts) — the exact bytes the executor produced, not a working-tree read. Returns up to 12000 chars per call with offset/limit paging (next_offset when truncated), plus the file\'s sha256 and byte length. Binary or over-cap files return no content (the sha256 still binds them). Read tier; this is what the independent reviewer reads. The header lines (path, build, sha256, chars a-b of total) are the record a reviewer\'s read is bound to; read every chunk until the range covers the whole file.',
2091
+ inputSchema: {
2092
+ type: 'object',
2093
+ properties: {
2094
+ build_ref: { type: 'string', description: 'sha256:<64 hex> (or the bare 64 hex).' },
2095
+ path: { type: 'string', description: 'Repo-relative path exactly as listed by tascan_get_build.' },
2096
+ offset: { type: 'integer', description: 'Character offset to start from (default 0).' },
2097
+ limit: { type: 'integer', description: 'Characters to return (1-12000, default 12000).' }
2098
+ },
2099
+ required: ['build_ref', 'path']
2100
+ },
2101
+ annotations: { title: 'Get Build File', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2102
+ handler: async (args, api) => {
2103
+ const ref = String(args.build_ref || '').trim().toLowerCase();
2104
+ if (!/^(sha256:)?[0-9a-f]{64}$/.test(ref)) throw new Error('build_ref must be sha256:<64 hex>');
2105
+ const path = String(args.path || '').trim();
2106
+ if (!path) throw new Error('path is required');
2107
+ const qs = new URLSearchParams({ path });
2108
+ if (args.offset != null) qs.set('offset', String(args.offset));
2109
+ if (args.limit != null) qs.set('limit', String(args.limit));
2110
+ const result = await api('GET', `/builds/${encodeURIComponent(ref.startsWith('sha256:') ? ref : 'sha256:' + ref)}/file?${qs.toString()}`);
2111
+ const d = result.data || {};
2112
+ const head = `FILE ${d.path} (build ${d.build_ref})\nsha256:${d.sha256} ${d.byte_length} B${d.content_type ? ' ' + d.content_type : ''}\nchars ${d.offset}-${(d.offset || 0) + (d.chars_returned || 0)} of ${d.total_chars}${d.truncated ? ` — TRUNCATED, continue with offset=${d.next_offset}` : ''}`;
2113
+ if (d.content == null) return head + '\n\n' + (d.note || 'No stored text for this file.');
2114
+ return head + '\n\n----- content -----\n' + d.content;
2115
+ }
2116
+ },
2117
+ {
2118
+ name: 'tascan_get_cycle_report',
2119
+ description: 'The audit report of one coordination cycle by its root task id (protocol v0.2, get_cycle_report): every step task (build, review, checkpoint, integrate, question, parked) with its revision and state, every execution attempt with runner, outcome, build_ref and usage/cost, every completion (receipt id = completion id, receipt hash), every reviewer verdict, every human decision and answer, the full trail (messages), the ledger events and the hash-chain verdict per task, plus spend against the cap. A computed summary (stage, attempts, verdicts, decisions, receipts, spend, chains_ok) comes first; pass full=true for the complete JSON (large). Read tier. This is the ONLY per-cycle notification surface: cycle steps do not e-mail or text anyone except the one checkpoint / human-question SMS.',
2120
+ inputSchema: {
2121
+ type: 'object',
2122
+ properties: {
2123
+ root_id: { type: 'string', description: 'The cycle root = the T1 build task id (returned by tascan_create_cycle; a non-root cycle task returns its root_id in the error).' },
2124
+ full: { type: 'boolean', description: 'true = the complete report JSON (tasks, runs, completions, messages, events, bundles, chains) after the summary, capped at 12000 chars. Default: summary only.' }
2125
+ },
2126
+ required: ['root_id']
2127
+ },
2128
+ annotations: { title: 'Get Cycle Report', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2129
+ handler: async (args, api) => {
2130
+ const id = String(args.root_id || '').trim().toLowerCase();
2131
+ 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');
2132
+ const result = await api('GET', `/coord/cycles/${id}/report`);
2133
+ const d = result.data || {}, r = d.root || {}, s = d.summary || {};
2134
+ const short = ref => ref ? String(ref).replace(/^sha256:/, '').slice(0, 12) + '…' : 'none';
2135
+ const lines = [
2136
+ `=== CYCLE REPORT: ${r.title || id} ===`,
2137
+ `root ${r.id} · status ${r.status} · stage ${s.stage} · project ${r.project}`,
2138
+ `build_ref ${s.latest_build_ref || r.build_ref || 'none yet'}${s.deploy_id ? ' · deploy ' + s.deploy_id : ''} · brief_sha256 ${r.brief_sha256 || 'n/a'}`,
2139
+ `attempts ${(s.attempts && s.attempts.total) || 0} (${Object.entries((s.attempts && s.attempts.by_runner) || {}).map(([k, v]) => k + ' ' + v).join(', ') || 'none'}; ${(s.attempts && s.attempts.failed_or_expired) || 0} failed/expired) · spend USD ${s.spend_usd != null ? s.spend_usd.toFixed(2) : '0.00'} · ledger chains ${s.chains_ok === true ? 'OK' : s.chains_ok === false ? 'BROKEN' : 'n/a'} (${s.chains_checked || 0} tasks)`,
2140
+ ''
2141
+ ];
2142
+ lines.push('STEPS');
2143
+ (s.steps || []).forEach(st => lines.push(` ${st.kind} r${st.revision}${st.seq ? '.' + st.seq : ''} ${st.title} [${st.blocked_by ? 'blocked' : st.completed ? 'completed' : st.released_at ? 'released' : 'open'}; ${st.attempts || 0} attempt(s)] ${st.id}`));
2144
+ if (s.verdicts && s.verdicts.length) { lines.push('', 'VERDICTS'); s.verdicts.forEach(v => lines.push(` r${v.revision} ${v.verdict || 'missing'} on ${short(v.build_ref)} ${v.from_reviewer ? '' : '(NOT the reviewer identity) '}completion ${v.completion_id}`)); }
2145
+ if (s.decisions && s.decisions.length) { lines.push('', 'DECISIONS'); s.decisions.forEach(v => lines.push(` ${v.decision} by ${v.actor_id} on ${short(v.build_ref)} completion ${v.completion_id || 'n/a'}`)); }
2146
+ if (s.questions && s.questions.length) { lines.push('', 'QUESTIONS'); s.questions.forEach(q => lines.push(` to ${q.addressee}: ${q.title} — ${q.answered ? 'answered' : 'AWAITING ANSWER'} (${q.id})`)); }
2147
+ if (s.receipts && s.receipts.length) { lines.push('', 'RECEIPTS (tascan_get_receipt completion_id=…)'); s.receipts.forEach(c => lines.push(` ${c.completion_id} ${c.kind || ''} ${c.source || ''} ${c.receipt_hash ? 'sha256:' + String(c.receipt_hash).slice(0, 12) + '…' : 'unsigned'}`)); }
2148
+ lines.push('', `TRAIL: ${s.messages || 0} message(s) · LEDGER: ${s.events || 0} event(s) ${Object.entries(s.event_types || {}).map(([k, v]) => k + '×' + v).join(' ')}`);
2149
+ let out = lines.join('\n');
2150
+ if (args.full) out += '\n\n' + JSON.stringify(d, null, 2);
2151
+ return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars — read sections with tascan_get_task / tascan_get_receipt]' : out;
2152
+ }
1924
2153
  }
1925
2154
  ];
1926
2155