taskflow-mcp-core 0.1.8 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,13 +21,93 @@
21
21
  * - taskflow_verify : statically verify a flow (no execution)
22
22
  * - taskflow_compile : render a flow as a DAG diagram (SVG image) + status line
23
23
  * - taskflow_peek : inspect a stored run's intermediate phase output
24
+ * - taskflow_trace : read a run's append-only event trace
25
+ * - taskflow_replay : re-evaluate a recorded trace under alternate knobs (zero tokens)
26
+ * - taskflow_why_stale / taskflow_recompute / taskflow_reconcile_workspace
27
+ * - taskflow_save / taskflow_search
24
28
  */
25
29
  import { RpcError, RPC, serveStdio } from "./jsonrpc.js";
26
30
  import { renderFlowSvg, renderFlowOutline, svgToBase64 } from "./svg.js";
27
- import { discoverAgents, executeTaskflow, getFlowDiagnosed, listFlows, newRunId, peekRun, saveRun, DEFAULT_KEPT_RUNS, DEFAULT_RUN_AGE_DAYS, readDefineFile, describeLoadFailure, compileTaskflow, verifyTaskflow, desugar, isShorthand, validateTaskflow, readSubagentSettings, readMeta, saveFlowWithMeta, bumpReuseInSidecar, deriveMeta, searchLibrary, } from "taskflow-core";
31
+ import { readFileSync, realpathSync } from "node:fs";
32
+ import { basename, dirname, join, resolve, relative, isAbsolute } from "node:path";
33
+ import { tmpdir } from "node:os";
34
+ import { fileURLToPath } from "node:url";
35
+ import { discoverAgents, executeTaskflow, getFlowDiagnosed, listFlows, newRunId, peekRun, saveRun, DEFAULT_KEPT_RUNS, DEFAULT_RUN_AGE_DAYS, readDefineFile, describeLoadFailure, compileTaskflow, verifyTaskflow, desugar, isShorthand, validateTaskflow, resolveArgs, cwdBridgeModeFromEnv, directoryIdentity, readSubagentSettings, readMeta, saveFlowWithMeta, bumpReuseInSidecar, deriveMeta, searchLibrary, replayRun, upgradeTraceEvent, reconcileResolveOnlyWorkspace, WORKSPACE_RECONCILE_ACKNOWLEDGEMENT, workspaceReconcileAllowedFromEnv, } from "taskflow-core";
28
36
  import { runsDir, traceFilePath, FileTraceSink, readTrace, loadRunDiagnosed, readMapOf, declaredReadMapOfDef, formatWhyStale, recomputeTaskflow, } from "taskflow-core";
29
37
  const PROTOCOL_VERSION = "2025-06-18";
30
- const SERVER_INFO = { name: "taskflow", title: "Taskflow", version: "0.1.5" };
38
+ const SERVER_VERSION = readServerVersion();
39
+ const SERVER_INFO = { name: "taskflow", title: "Taskflow", version: SERVER_VERSION };
40
+ function readServerVersion() {
41
+ try {
42
+ const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
43
+ const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8"));
44
+ if (typeof pkg === "object" && pkg !== null && "version" in pkg) {
45
+ const version = pkg.version;
46
+ if (typeof version === "string" && version)
47
+ return version;
48
+ }
49
+ }
50
+ catch {
51
+ // Keep the handshake available even in unusual embedded/bundled layouts.
52
+ }
53
+ return "0.2.1";
54
+ }
55
+ function validateToolValue(value, schema, path) {
56
+ const errors = [];
57
+ const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined;
58
+ if (enumValues && !enumValues.some((candidate) => Object.is(candidate, value))) {
59
+ errors.push(`${path} must be one of ${enumValues.map(String).join(", ")}`);
60
+ return errors;
61
+ }
62
+ const type = schema.type;
63
+ if (type === "object") {
64
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
65
+ return [`${path} must be an object`];
66
+ }
67
+ const object = value;
68
+ const properties = (schema.properties ?? {});
69
+ for (const required of Array.isArray(schema.required) ? schema.required : []) {
70
+ if (typeof required === "string" && !(required in object))
71
+ errors.push(`${path}.${required} is required`);
72
+ }
73
+ if (schema.additionalProperties === false) {
74
+ for (const key of Object.keys(object)) {
75
+ if (!(key in properties))
76
+ errors.push(`${path}.${key} is not allowed`);
77
+ }
78
+ }
79
+ for (const [key, childSchema] of Object.entries(properties)) {
80
+ if (key in object)
81
+ errors.push(...validateToolValue(object[key], childSchema, `${path}.${key}`));
82
+ }
83
+ return errors;
84
+ }
85
+ if (type === "array") {
86
+ if (!Array.isArray(value))
87
+ return [`${path} must be an array`];
88
+ const itemSchema = schema.items;
89
+ if (typeof itemSchema === "object" && itemSchema !== null) {
90
+ value.forEach((item, index) => errors.push(...validateToolValue(item, itemSchema, `${path}[${index}]`)));
91
+ }
92
+ return errors;
93
+ }
94
+ if (type === "string" && typeof value !== "string")
95
+ errors.push(`${path} must be a string`);
96
+ if (type === "number" && (typeof value !== "number" || !Number.isFinite(value)))
97
+ errors.push(`${path} must be a finite number`);
98
+ if (type === "integer" && (typeof value !== "number" || !Number.isSafeInteger(value)))
99
+ errors.push(`${path} must be an integer`);
100
+ if (type === "boolean" && typeof value !== "boolean")
101
+ errors.push(`${path} must be a boolean`);
102
+ return errors;
103
+ }
104
+ function validateToolArguments(tool, value) {
105
+ const args = value ?? {};
106
+ const errors = validateToolValue(args, tool.inputSchema, "arguments");
107
+ if (errors.length > 0)
108
+ throw new RpcError(RPC.INVALID_PARAMS, `Invalid ${tool.name} arguments: ${errors.join("; ")}`);
109
+ return args;
110
+ }
31
111
  /**
32
112
  * MCP tools/call result with a single text block.
33
113
  *
@@ -116,8 +196,43 @@ function count(n, noun) {
116
196
  * Output text is truncated so a fan-out's full transcripts don't flood the
117
197
  * host context — pass json:true for the complete record. Mirrors the pi
118
198
  * adapter's formatTrace. */
119
- function formatTraceMcp(events, runId, flowName) {
199
+ const TRACE_DEFAULT_LIMIT = 200;
200
+ const TRACE_MAX_LIMIT = 1000;
201
+ const TRACE_MAX_RESPONSE_CHARS = 120_000;
202
+ const TRACE_JSON_STRING_LIMIT = 4_000;
203
+ function traceLimit(value) {
204
+ if (typeof value !== "number" || !Number.isFinite(value))
205
+ return TRACE_DEFAULT_LIMIT;
206
+ return Math.max(1, Math.min(TRACE_MAX_LIMIT, Math.floor(value)));
207
+ }
208
+ function boundedTraceEvents(events, requested) {
209
+ const limit = traceLimit(requested);
210
+ const bounded = events.slice(-limit).map((event) => JSON.parse(JSON.stringify(event, (_key, value) => typeof value === "string" && value.length > TRACE_JSON_STRING_LIMIT
211
+ ? `${value.slice(0, TRACE_JSON_STRING_LIMIT)}… (+${value.length - TRACE_JSON_STRING_LIMIT} chars)`
212
+ : value)));
213
+ while (bounded.length > 1) {
214
+ const candidate = JSON.stringify({ total: events.length, returned: bounded.length, truncated: bounded.length < events.length, events: bounded }, null, 2);
215
+ if (candidate.length <= TRACE_MAX_RESPONSE_CHARS)
216
+ break;
217
+ bounded.shift();
218
+ }
219
+ return { total: events.length, events: bounded };
220
+ }
221
+ export function formatTraceJsonMcp(events, requested) {
222
+ const bounded = boundedTraceEvents(events, requested);
223
+ return JSON.stringify({
224
+ total: bounded.total,
225
+ returned: bounded.events.length,
226
+ truncated: bounded.events.length < bounded.total,
227
+ events: bounded.events,
228
+ }, null, 2);
229
+ }
230
+ function formatTraceMcp(events, runId, flowName, requested) {
231
+ const bounded = boundedTraceEvents(events, requested);
232
+ events = bounded.events;
120
233
  const lines = [`Trace — ${flowName} / ${runId} (${events.length} events)`];
234
+ if (events.length < bounded.total)
235
+ lines.push(`Showing newest ${events.length} of ${bounded.total} events.`);
121
236
  lines.push("");
122
237
  const out = (text, limit = 400) => {
123
238
  if (!text)
@@ -149,9 +264,55 @@ function formatTraceMcp(events, runId, flowName) {
149
264
  }
150
265
  }
151
266
  lines.push("");
152
- lines.push("(Pass json:true for the complete machine-readable trace, including full subagent outputs.)");
267
+ lines.push("(Pass json:true for a bounded machine-readable envelope; total/returned/truncated report omitted events.)");
153
268
  return lines.join("\n");
154
269
  }
270
+ /** Human-readable offline replay report (MCP). Zero tokens — re-folds the
271
+ * recorded trace under alternate decision knobs. */
272
+ function formatReplayMcp(r, runId, flowName) {
273
+ const lines = [
274
+ `Replay — ${flowName} / ${runId} (${r.decisions.length} phase decision(s), zero tokens)`,
275
+ ];
276
+ lines.push("");
277
+ if (r.needsLiveRerun)
278
+ lines.push("⚠ Some phases need a live re-run (model/args override).");
279
+ lines.push(`Recorded usage cost ≈ $${r.totalUsage.cost.toFixed(4)} tokens in=${r.totalUsage.input} out=${r.totalUsage.output}`);
280
+ lines.push("");
281
+ for (const d of r.decisions) {
282
+ const prior = d.priorOutcome ? ` prior=${d.priorOutcome}` : "";
283
+ const next = d.replayedOutcome ? ` → ${d.replayedOutcome}` : "";
284
+ lines.push(` • ${d.phaseId}: [${d.outcome}]${prior}${next} — ${d.reason}`);
285
+ }
286
+ lines.push("");
287
+ lines.push("(Pass json:true for the full ReplayReport machine-readable record.)");
288
+ return lines.join("\n");
289
+ }
290
+ function parseReplayOverrides(args) {
291
+ const o = {};
292
+ if (typeof args.budgetMaxUSD === "number")
293
+ o.budgetMaxUSD = args.budgetMaxUSD;
294
+ if (typeof args.budgetMaxTokens === "number")
295
+ o.budgetMaxTokens = args.budgetMaxTokens;
296
+ if (args.thresholds && typeof args.thresholds === "object" && !Array.isArray(args.thresholds)) {
297
+ const t = {};
298
+ for (const [k, v] of Object.entries(args.thresholds)) {
299
+ if (typeof v === "number")
300
+ t[k] = v;
301
+ }
302
+ if (Object.keys(t).length)
303
+ o.thresholds = t;
304
+ }
305
+ if (args.models && typeof args.models === "object" && !Array.isArray(args.models)) {
306
+ const m = {};
307
+ for (const [k, v] of Object.entries(args.models)) {
308
+ if (typeof v === "string")
309
+ m[k] = v;
310
+ }
311
+ if (Object.keys(m).length)
312
+ o.models = m;
313
+ }
314
+ return o;
315
+ }
155
316
  /** Human-readable recompute dry-run report (MCP). Mirrors the pi adapter's
156
317
  * formatRecompute. */
157
318
  function formatRecomputeMcp(r) {
@@ -252,13 +413,40 @@ const TOOLS = [
252
413
  {
253
414
  name: "taskflow_trace",
254
415
  title: "Show a run's deterministic-replay event trace",
255
- description: "Read the append-only event trace a run recorded (each subagent call's input/output + the runtime's own decisions: gate verdicts, when-guard results, budget hits, cache hits, unreplayable markers). This is the foundation for deterministic replay (re-evaluating a run against changed thresholds/budget offline, zero tokens — landing in a future release). For now it is a read-only observability view of exactly what the run did. Output is human-readable by default; pass json:true for the complete machine-readable record.",
416
+ description: "Read the append-only event trace a run recorded (each subagent call's input/output + runtime decisions). Foundation for taskflow_replay. Responses are bounded; use limit to select up to 1000 newest events.",
256
417
  inputSchema: {
257
418
  type: "object",
258
419
  additionalProperties: false,
259
420
  properties: {
260
421
  runId: { type: "string", description: "The run to inspect (from a prior taskflow_run or the runs index)." },
261
- json: { type: "boolean", description: "Return the complete machine-readable trace (JSON) instead of a human-readable timeline. Use when you need the full subagent outputs (the human form truncates them)." },
422
+ json: { type: "boolean", description: "Return a bounded machine-readable envelope instead of a human timeline. The envelope reports total/returned/truncated; oversized strings are truncated." },
423
+ limit: { type: "number", minimum: 1, maximum: 1000, description: "Maximum newest trace events to return (default 200, max 1000). Large string fields and total response size are also bounded." },
424
+ },
425
+ required: ["runId"],
426
+ },
427
+ },
428
+ {
429
+ name: "taskflow_replay",
430
+ title: "Replay a recorded run under alternate decision knobs (zero tokens)",
431
+ description: "Re-evaluate a stored run's event trace offline against changed gate thresholds, budget caps, or model routes — without calling any model (zero tokens). Reports per-phase outcomes: reused, would-block, verdict-flipped, would-exceed-budget, needs-live-rerun, etc. Use after taskflow_trace when you want counterfactual analysis of a finished run.",
432
+ inputSchema: {
433
+ type: "object",
434
+ additionalProperties: false,
435
+ properties: {
436
+ runId: { type: "string", description: "The run whose trace to replay (from a prior taskflow_run)." },
437
+ json: { type: "boolean", description: "Return the full ReplayReport as JSON." },
438
+ budgetMaxUSD: { type: "number", description: "Alternate max USD budget for would-exceed-budget checks." },
439
+ budgetMaxTokens: { type: "number", description: "Alternate max token budget." },
440
+ thresholds: {
441
+ type: "object",
442
+ additionalProperties: { type: "number" },
443
+ description: "Map of phaseId → new score threshold (re-judges recorded gate-score events).",
444
+ },
445
+ models: {
446
+ type: "object",
447
+ additionalProperties: { type: "string" },
448
+ description: "Map of phaseId → model id (currently marks needs-live-rerun; quality cannot be re-judged offline).",
449
+ },
262
450
  },
263
451
  required: ["runId"],
264
452
  },
@@ -291,6 +479,24 @@ const TOOLS = [
291
479
  required: ["runId", "phaseId"],
292
480
  },
293
481
  },
482
+ {
483
+ name: "taskflow_reconcile_workspace",
484
+ title: "Acknowledge and reconcile the invocation workspace",
485
+ description: "Explicitly accept the current external filesystem state after a resolve-only writer failed with an unknown outcome. This does not restore files or prove correctness: inspect or repair the workspace first. The host operator must also launch Taskflow with TASKFLOW_WORKSPACE_RECONCILE_MODE=explicit. Reconciliation takes an exclusive whole-root lease, durably clears dirty-unknown intents, and advances the workspace generation.",
486
+ inputSchema: {
487
+ type: "object",
488
+ additionalProperties: false,
489
+ properties: {
490
+ acknowledgement: {
491
+ type: "string",
492
+ enum: [WORKSPACE_RECONCILE_ACKNOWLEDGEMENT],
493
+ description: `Must exactly equal: ${WORKSPACE_RECONCILE_ACKNOWLEDGEMENT}`,
494
+ },
495
+ reason: { type: "string", description: "Short audit reason describing what was inspected or repaired." },
496
+ },
497
+ required: ["acknowledgement"],
498
+ },
499
+ },
294
500
  {
295
501
  name: "taskflow_save",
296
502
  title: "Save a reusable taskflow",
@@ -328,9 +534,43 @@ const TOOLS = [
328
534
  },
329
535
  ];
330
536
  /** Resolve a flow from params: inline `define` (desugared), `defineFile` (disk), or saved `name`. */
537
+ function resolvePermittedDefineFile(cwd, requested) {
538
+ const lexical = resolve(cwd, requested);
539
+ let candidate = lexical;
540
+ try {
541
+ candidate = realpathSync(lexical);
542
+ }
543
+ catch {
544
+ // Keep the lexical path so a permitted-but-missing file still receives the
545
+ // normal "not found" diagnostic from readDefineFile. Resolve its existing
546
+ // parent so aliases such as macOS /tmp -> /private/tmp remain contained.
547
+ try {
548
+ candidate = join(realpathSync(dirname(lexical)), basename(lexical));
549
+ }
550
+ catch { /* parent missing */ }
551
+ }
552
+ const rootCandidates = [cwd, tmpdir(), ...(process.platform === "win32" ? [] : ["/tmp"])]
553
+ .map((root) => {
554
+ try {
555
+ return realpathSync(resolve(root));
556
+ }
557
+ catch {
558
+ return resolve(root);
559
+ }
560
+ });
561
+ const allowed = rootCandidates.some((root) => {
562
+ const rel = relative(root, candidate);
563
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
564
+ });
565
+ if (!allowed) {
566
+ throw new RpcError(RPC.INVALID_PARAMS, `defineFile must be contained in the server cwd or OS temp directory: ${requested}`);
567
+ }
568
+ return candidate;
569
+ }
331
570
  function resolveFlow(cwd, params) {
332
571
  if (params.define === undefined && typeof params.defineFile === "string" && params.defineFile.trim()) {
333
- const fromFile = readDefineFile(params.defineFile);
572
+ const filePath = resolvePermittedDefineFile(cwd, params.defineFile);
573
+ const fromFile = readDefineFile(filePath);
334
574
  if (!fromFile.ok)
335
575
  throw new RpcError(RPC.INVALID_PARAMS, describeLoadFailure(fromFile, "defineFile"));
336
576
  params = { ...params, define: fromFile.value };
@@ -357,8 +597,18 @@ function mkRunState(def, args, cwd) {
357
597
  createdAt: Date.now(),
358
598
  updatedAt: Date.now(),
359
599
  cwd,
600
+ invocationRootSnapshot: directoryIdentity(cwd),
360
601
  };
361
602
  }
603
+ export function persistTerminalRun(state, cleanupConfig, write = saveRun) {
604
+ try {
605
+ write(state, cleanupConfig);
606
+ return undefined;
607
+ }
608
+ catch (error) {
609
+ return error instanceof Error ? error.message : String(error);
610
+ }
611
+ }
362
612
  /**
363
613
  * Build the per-call tool handlers. `cwd` is the directory the server was
364
614
  * launched in (where saved flows + agents are discovered, and where subagents
@@ -367,11 +617,28 @@ function mkRunState(def, args, cwd) {
367
617
  */
368
618
  export function makeToolHandlers(cwd, runner) {
369
619
  return {
370
- taskflow_run: async (args) => {
620
+ taskflow_run: async (args, context) => {
621
+ const reusedSavedName = args.define === undefined && args.defineFile === undefined && typeof args.name === "string" && args.name.trim()
622
+ ? args.name.trim()
623
+ : undefined;
371
624
  const def = resolveFlow(cwd, args);
372
- const v = validateTaskflow(def);
373
- if (!v.ok)
374
- return textContent(`Flow is invalid:\n- ${v.errors.join("\n- ")}`, true);
625
+ const structural = validateTaskflow(def);
626
+ if (!structural.ok)
627
+ return textContent(`Flow is invalid:\n- ${structural.errors.join("\n- ")}`, true);
628
+ const providedArgs = args.args && typeof args.args === "object" && !Array.isArray(args.args)
629
+ ? args.args
630
+ : {};
631
+ const resolvedArgs = resolveArgs(def, providedArgs);
632
+ const invocation = validateTaskflow(def, { args: resolvedArgs, cwd });
633
+ if (!invocation.ok)
634
+ return textContent(`Flow invocation is invalid:\n- ${invocation.errors.join("\n- ")}`, true);
635
+ const usageAccounting = runner.usageAccounting;
636
+ if (def.budget && usageAccounting === "unavailable") {
637
+ return textContent("This host does not report token or cost usage, so taskflow refuses to run a budgeted flow: the declared ceiling could not be enforced. Remove `budget` only if unmetered execution is intentional, or use a host with usage accounting.", true);
638
+ }
639
+ if (def.budget?.maxUSD !== undefined && usageAccounting === "tokens-only") {
640
+ return textContent("This host reports token usage but not cost, so taskflow refuses budget.maxUSD: the declared dollar ceiling could not be enforced. Use budget.maxTokens or a host with cost accounting.", true);
641
+ }
375
642
  // Resolve model roles (e.g. {{fast}} -> a real model id) so the built-in
376
643
  // agents' placeholder models map to something the host can launch. This is
377
644
  // the same lookup the pi adapter does; without it every phase fails with
@@ -382,8 +649,11 @@ export function makeToolHandlers(cwd, runner) {
382
649
  cwd,
383
650
  agents,
384
651
  runTask: runner.runTask,
652
+ signal: context?.signal,
653
+ usageAccounting,
654
+ cwdBridgeMode: cwdBridgeModeFromEnv(),
385
655
  };
386
- const state = mkRunState(def, args.args ?? {}, cwd);
656
+ const state = mkRunState(def, resolvedArgs, cwd);
387
657
  // Deterministic-replay trace (best-effort, fail-open).
388
658
  deps.trace = new FileTraceSink(traceFilePath(runsDir(cwd), state.flowName, state.runId));
389
659
  if (args.incremental === true)
@@ -399,19 +669,18 @@ export function makeToolHandlers(cwd, runner) {
399
669
  saveRun(s, cleanupConfig);
400
670
  }
401
671
  };
672
+ let terminalPersistError;
402
673
  const res = await executeTaskflow(state, deps).finally(() => {
403
674
  // Terminal persist must survive a throwing runtime ("never lose work") —
404
675
  // and persistence itself must never sink a completed run.
405
- try {
406
- saveRun(state, cleanupConfig);
407
- }
408
- catch {
409
- /* fail-open */
410
- }
676
+ terminalPersistError = persistTerminalRun(state, cleanupConfig);
411
677
  });
412
- if (res.ok && args.reusedFromSearch === true && typeof args.name === "string" && args.name.trim()) {
678
+ if (terminalPersistError !== undefined) {
679
+ return textContent(`Taskflow execution finished, but terminal run persistence failed; no durable run ID can be promised: ${terminalPersistError}`, true);
680
+ }
681
+ if (res.ok && args.reusedFromSearch === true && reusedSavedName) {
413
682
  try {
414
- bumpReuseInSidecar(cwd, args.name);
683
+ bumpReuseInSidecar(cwd, reusedSavedName);
415
684
  }
416
685
  catch {
417
686
  /* fail-open: reuse bookkeeping is best-effort */
@@ -446,8 +715,25 @@ export function makeToolHandlers(cwd, runner) {
446
715
  if (events.length === 0)
447
716
  return textContent(`No trace recorded for run "${runId}" (the run predates tracing, or no trace sink was injected).`, true);
448
717
  if (args.json === true)
449
- return textContent(JSON.stringify(events, null, 2));
450
- return textContent(formatTraceMcp(events, run.runId, run.flowName));
718
+ return textContent(formatTraceJsonMcp(events, args.limit));
719
+ return textContent(formatTraceMcp(events, run.runId, run.flowName, args.limit));
720
+ },
721
+ taskflow_replay: async (args) => {
722
+ const runId = String(args.runId ?? "");
723
+ if (!runId)
724
+ return textContent("taskflow_replay requires `runId`.", true);
725
+ const runR = loadRunDiagnosed(cwd, runId);
726
+ if (!runR.ok)
727
+ return textContent(describeLoadFailure(runR, `Run "${runId}"`), true);
728
+ const run = runR.value;
729
+ const raw = readTrace(traceFilePath(runsDir(cwd), run.flowName, run.runId));
730
+ if (raw.length === 0)
731
+ return textContent(`No trace recorded for run "${runId}" (the run predates tracing, or no trace sink was injected).`, true);
732
+ const events = raw.map((e) => upgradeTraceEvent(e));
733
+ const report = replayRun(events, parseReplayOverrides(args));
734
+ if (args.json === true)
735
+ return textContent(JSON.stringify(report, null, 2));
736
+ return textContent(formatReplayMcp(report, run.runId, run.flowName));
451
737
  },
452
738
  taskflow_why_stale: async (args) => {
453
739
  const runId = String(args.runId ?? "");
@@ -462,7 +748,7 @@ export function makeToolHandlers(cwd, runner) {
462
748
  const seeds = typeof args.phaseId === "string" ? [args.phaseId] : [];
463
749
  return textContent(formatWhyStale(run.runId, run.flowName, reads, seeds, declared));
464
750
  },
465
- taskflow_recompute: async (args) => {
751
+ taskflow_recompute: async (args, context) => {
466
752
  // MCP exposes recompute as DRY-RUN ONLY (never spends tokens). To actually
467
753
  // re-execute, hosts use the Pi adapter's /tf recompute --apply.
468
754
  const runId = String(args.runId ?? "");
@@ -475,10 +761,30 @@ export function makeToolHandlers(cwd, runner) {
475
761
  const run = runR.value;
476
762
  const settings = readSubagentSettings();
477
763
  const { agents } = discoverAgents(cwd, "both", settings.modelRoles, settings.taskflow);
478
- const deps = { cwd, agents, runTask: runner.runTask };
764
+ const deps = { cwd, agents, runTask: runner.runTask, signal: context?.signal };
479
765
  const { report } = await recomputeTaskflow(run, deps, [phaseId], { dryRun: true });
480
766
  return textContent(formatRecomputeMcp(report));
481
767
  },
768
+ taskflow_reconcile_workspace: async (args, context) => {
769
+ try {
770
+ const result = await reconcileResolveOnlyWorkspace({
771
+ invocationRoot: cwd,
772
+ signal: context?.signal,
773
+ allowReconcile: workspaceReconcileAllowedFromEnv(),
774
+ }, {
775
+ acknowledgement: String(args.acknowledgement ?? ""),
776
+ reason: typeof args.reason === "string" ? args.reason : undefined,
777
+ signal: context?.signal,
778
+ });
779
+ const changed = result.reconciledIntentIds.length;
780
+ return textContent(changed === 0
781
+ ? `Workspace is already clean at generation ${result.generation}; no dirty intent was changed.`
782
+ : `Workspace reconciled: ${changed} dirty intent(s) accepted; generation ${result.previousGeneration} → ${result.generation}.`);
783
+ }
784
+ catch (error) {
785
+ return textContent(`Workspace reconciliation failed: ${error instanceof Error ? error.message : String(error)}`, true);
786
+ }
787
+ },
482
788
  taskflow_list: async () => {
483
789
  const flows = listFlows(cwd);
484
790
  if (flows.length === 0)
@@ -672,13 +978,17 @@ export function makeMcpHandlers(cwd, runner) {
672
978
  },
673
979
  ping: () => ({}),
674
980
  "tools/list": () => ({ tools: TOOLS }),
675
- "tools/call": async (params) => {
981
+ "tools/call": async (params, context) => {
676
982
  const p = (params ?? {});
677
983
  const tool = tools[p.name ?? ""];
678
984
  if (!tool)
679
985
  throw new RpcError(RPC.INVALID_PARAMS, `Unknown tool: ${p.name}`);
986
+ const descriptor = TOOLS.find((candidate) => candidate.name === p.name);
987
+ if (!descriptor)
988
+ throw new RpcError(RPC.INVALID_PARAMS, `Unknown tool schema: ${p.name}`);
989
+ const args = validateToolArguments(descriptor, p.arguments);
680
990
  void initialized; // tolerant: we don't hard-gate on initialize ordering
681
- return await tool(p.arguments ?? {});
991
+ return await tool(args, context);
682
992
  },
683
993
  };
684
994
  }