@ctrl-spc/cs 0.7.7 → 0.7.8

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.
@@ -112,6 +112,10 @@
112
112
  // The runtime import comes FIRST, deliberately: `tsc` elides a type-only import
113
113
  // and takes the leading comment with it, so a file whose first statement is
114
114
  // `import type` loses its v3 header in the published `dist/`.
115
+ import { presentDesign } from '../design-review.js';
116
+ import { canonicalArgsHash, getDocumentHandler, proposeProjectContextHandler, placeWorkItemHandler, resolveFeedbackHandler, PROJECT_DOCUMENT_TYPES, errorMessage } from '../product-tools.js';
117
+ import { workflowToolForName, workflowToolAlwaysAllowed } from '../workflow-tool-mentions.js';
118
+ import { captureUrlScreenshot } from '../browser.js';
115
119
  import { createServer as createHttpServer } from 'node:http';
116
120
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
117
121
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
@@ -126,7 +130,7 @@ import { workBrief } from './prompt.js';
126
130
  here is the same fact without a second round trip. */
127
131
  import { harness } from './spawn.js';
128
132
  import { listCodebases } from '../codebases.js';
129
- import { buildWorkflow, duplicateWorkflow, editWorkflow, readWorkflow, rewordStage } from '../workflows.js';
133
+ import { buildWorkflow, duplicateWorkflow, editWorkflow, readWorkflow, rewordStage, workflowToolPermission, validateWorkflowToolTarget, WORKFLOW_TOOL_TEACHING } from '../workflows.js';
130
134
  /* ═══ 38-panel3-steps: THE FIFTH NEUTRAL MODULE, AND A DECISION LIKE THE OTHERS.
131
135
  ═══ Stages and steps are the work item's own record, shared by both
132
136
  generations and read by the web's Steps section. `steps.ts` owns the rows so
@@ -738,7 +742,173 @@ async function stageForWriting(caller, stageId, consequence) {
738
742
  await carriedWorkItem(caller, stage.workItemId, consequence);
739
743
  return stage;
740
744
  }
745
+ function productResult(result) {
746
+ const text = result.content.filter((item) => Boolean(item && typeof item === 'object' && 'text' in item)).map(item => item.text).join('\n');
747
+ if (result.isError)
748
+ throw new Error(text);
749
+ return text;
750
+ }
741
751
  const TOOLS = [
752
+ {
753
+ name: 'get_document', levels: ALL,
754
+ description: 'Read a project context document or instruction in full by id. Read-only.',
755
+ input: { id: z.string().uuid() },
756
+ handler: async (caller, args) => productResult(await getDocumentHandler(caller.client, args)),
757
+ },
758
+ {
759
+ name: 'add_comment', levels: ALL,
760
+ description: 'Leave a durable note or handover on a work item. ' + FIREWALL_WRITING_RULE,
761
+ input: { work_item_id: z.string().uuid(), body: z.string().min(1) },
762
+ handler: async (caller, args) => {
763
+ const { work_item_id, body } = args;
764
+ await carriedWorkItem(caller, work_item_id, 'a comment cannot be added to it');
765
+ const comment = await only(caller.client.from('comments')
766
+ .insert({ task_id: work_item_id, body, author_id: caller.userId }).select('id'), 'add', 'the comment');
767
+ return `Added comment ${comment.id}.`;
768
+ },
769
+ },
770
+ {
771
+ name: 'create_product_idea', levels: [1],
772
+ description: 'Capture a potential improvement without committing to implementation. First read existing work items, including Product Ideas, and the product to avoid duplicates. Only a human can promote an idea. ' + FIREWALL_WRITING_RULE,
773
+ input: { project_id: z.string().uuid(), title: z.string().min(1), description: z.string().optional() },
774
+ handler: async (caller, args) => {
775
+ const a = args;
776
+ const id = await returned(caller.client.rpc('create_product_idea', {
777
+ p_project: a.project_id, p_name: a.title, p_description: a.description ?? '', p_owner: caller.userId,
778
+ }), 'create', 'the product idea');
779
+ if (!id)
780
+ throw new Error('The product idea was not created.');
781
+ await receipt(caller, 'work_item', id, a.title);
782
+ return `Created Product Idea ${a.title}, id ${id}. No work has started. Only a human can promote it.`;
783
+ },
784
+ },
785
+ {
786
+ name: 'place_work_item', levels: [1],
787
+ description: 'Move a work item into an epic or sprint in its project. Null removes placement. Its status stays the same.',
788
+ input: { work_item_id: z.string().uuid(), epic_id: z.string().uuid().nullable().optional(),
789
+ sprint_id: z.string().uuid().nullable().optional(), before_work_item_id: z.string().uuid().optional() },
790
+ handler: async (caller, args) => productResult(await placeWorkItemHandler(caller.client, args)),
791
+ },
792
+ {
793
+ name: 'reorder_backlog', levels: [1],
794
+ description: 'Move one backlog work item before another, or to the bottom. Read the current order first and explain the reason.',
795
+ input: { project_id: z.string().uuid(), task_id: z.string().uuid(), before_task_id: z.string().uuid().optional(), reason: z.string().min(1) },
796
+ handler: async (caller, args) => {
797
+ const a = args;
798
+ const items = await rows(caller.client.from('tasks').select('id, revision')
799
+ .eq('project_id', a.project_id).eq('status', 'backlog').is('archived_at', null).eq('is_idea', false).order('position').order('created_at').order('id'), 'read', 'the backlog');
800
+ const moving = items.find(item => item.id === a.task_id);
801
+ if (!moving || (a.before_task_id && !items.some(item => item.id === a.before_task_id)))
802
+ throw new Error('Both items must be live backlog work in this project. Nothing was moved.');
803
+ if (a.task_id === a.before_task_id)
804
+ throw new Error('An item cannot be placed before itself. Nothing was moved.');
805
+ await returned(caller.client.rpc('save_task_if_current', { p_task_id: a.task_id,
806
+ p_expected_revision: moving.revision, p_changes: {}, p_tag_ids: null, p_reorder: true,
807
+ p_before_task_id: a.before_task_id ?? null }), 'reorder', 'the backlog');
808
+ return `Reordered the backlog: ${a.reason}`;
809
+ },
810
+ },
811
+ {
812
+ name: 'propose_project_context', levels: [2, 3],
813
+ description: 'Submit repository guidance for human review in Project settings. Pending proposals for this scan are replaced; accepted context is never changed. Name the scanned codebase and use repository-relative source paths. ' + FIREWALL_WRITING_RULE,
814
+ input: { work_item_id: z.string().uuid(), codebase: z.string().min(1), proposals: z.array(z.object({
815
+ title: z.string().min(1), type: z.enum(PROJECT_DOCUMENT_TYPES), content: z.string().min(1),
816
+ source_path: z.string().min(1), reason: z.string().min(1), scope: z.enum(['project', 'codebase']),
817
+ })) },
818
+ handler: async (caller, args) => {
819
+ const { work_item_id, ...a } = args;
820
+ await carriedWorkItem(caller, work_item_id, 'project context cannot be proposed for it');
821
+ return productResult(await proposeProjectContextHandler(caller.client, null, { ...a, task_id: work_item_id }));
822
+ },
823
+ },
824
+ {
825
+ name: 'record_context_exploration', levels: ALL,
826
+ description: 'Save explored context and research in the work item’s Findings artifact. Repeated calls update the same artifact. ' + FIREWALL_WRITING_RULE,
827
+ input: { work_item_id: z.string().uuid(), content: z.string().min(1) },
828
+ handler: async (caller, args) => {
829
+ const { work_item_id, content } = args;
830
+ await carriedWorkItem(caller, work_item_id, 'findings cannot be recorded on it');
831
+ const existing = await rows(caller.client.from('artifacts').select('id')
832
+ .eq('task_id', work_item_id).eq('purpose_key', 'workflow_findings').is('deleted_at', null), 'read', 'existing findings');
833
+ if (existing.length > 1)
834
+ throw new Error('More than one Findings artifact exists. Choose the artifact to edit explicitly.');
835
+ const artifact = existing[0]
836
+ ? await only(caller.client.from('artifacts').update({ content, updated_by: caller.userId })
837
+ .eq('id', existing[0].id).select('id'), 'update', 'the findings')
838
+ : await only(caller.client.from('artifacts').insert({ task_id: work_item_id, title: 'Findings',
839
+ type: 'analysis', format: 'md', content, purpose_key: 'workflow_findings', created_by: caller.userId }).select('id'), 'record', 'the findings');
840
+ await receiptOnce(caller, 'artifact', artifact.id, 'Findings');
841
+ return `Recorded Findings artifact ${artifact.id}.`;
842
+ },
843
+ },
844
+ {
845
+ name: 'resolve_feedback', levels: ALL,
846
+ description: 'Mark specified feedback rounds addressed after revising their artifact. Supply the new revision. This does not approve the artifact.',
847
+ input: { work_item_id: z.string().uuid(), feedback_ids: z.array(z.string().uuid()).min(1), revision: z.number().int().positive() },
848
+ handler: async (caller, args) => {
849
+ const a = args;
850
+ await carriedWorkItem(caller, a.work_item_id, 'its feedback cannot be resolved');
851
+ await validateWorkflowToolTarget(caller.client, a.work_item_id, 'resolve_feedback', a);
852
+ return productResult(await resolveFeedbackHandler(caller.client, { taskId: a.work_item_id }, a));
853
+ },
854
+ },
855
+ ...['present_mocks', 'present_wireframes'].map((name) => ({
856
+ name, levels: [2],
857
+ description: 'Create a self-contained HTML design and open its artifact review. Use html, with scripts inline for interactive mocks. Only the active conversation owner may present for review. Revise the same artifact for changes; do not present it again. Stop after opening review. ' + FIREWALL_WRITING_RULE,
858
+ input: { work_item_id: z.string().uuid(), title: z.string().min(1), html: z.string().min(1),
859
+ ...(name === 'present_wireframes' ? { kind: z.enum(['ui', 'architecture']) } : {}) },
860
+ handler: async (caller, args) => {
861
+ if (!caller.isOwner || !caller.processToken)
862
+ throw new Error('Escalate this design presentation to the current conversation owner. Nothing was written.');
863
+ const a = args;
864
+ await carriedWorkItem(caller, a.work_item_id, 'a design cannot be presented for it');
865
+ const mock = name === 'present_mocks';
866
+ const result = await presentDesign(caller.client, caller.userId, a.work_item_id, a, {
867
+ tool: name, unit: mock ? 'mock' : 'diagram', htmlSpec: 'self-contained HTML',
868
+ artifactType: mock ? 'mock' : a.kind === 'architecture' ? 'diagram' : 'wireframe',
869
+ artifactNoun: mock ? 'mock' : 'wireframe',
870
+ }, async (kind, id) => {
871
+ if (kind === 'artifact')
872
+ await receiptOnce(caller, 'artifact', id, a.title);
873
+ }, async (review) => {
874
+ await asked(caller, { work_item_id: a.work_item_id, related_artifact_id: review.artifactId,
875
+ category: 'wireframe_review', context: review.title, question: review.question,
876
+ answer_mode: 'single_select', options: review.options }, true);
877
+ const decision = await only(caller.client.from('decisions').select('id')
878
+ .eq('related_artifact_id', review.artifactId).eq('category', 'wireframe_review'), 'read', 'the design review');
879
+ return decision.id;
880
+ });
881
+ const text = productResult(result);
882
+ return text + '\nStop now. The conversation resumes when the user answers. Do not poll.';
883
+ },
884
+ })),
885
+ {
886
+ name: 'propose_scope_change', levels: [2],
887
+ description: 'Present a proposed change to approved scope, with the user request, classification, and quoted evidence, as an artifact for review. Only the conversation owner may open its approval. Stop; permission to propose does not approve expanded work. ' + FIREWALL_WRITING_RULE,
888
+ input: { work_item_id: z.string().uuid(), request: z.string().min(1),
889
+ classification: z.enum(['completion_gap', 'clarification', 'expansion', 'replacement']),
890
+ evidence: z.string().min(1), measured_against_version: z.number().int().positive() },
891
+ handler: async (caller, args) => {
892
+ if (!caller.isOwner || !caller.processToken)
893
+ throw new Error('Escalate the scope proposal to the current conversation owner. Nothing was written.');
894
+ const a = args;
895
+ await carriedWorkItem(caller, a.work_item_id, 'a scope change cannot be proposed for it');
896
+ const artifact = await only(caller.client.from('artifacts').insert({ task_id: a.work_item_id,
897
+ title: 'Scope change proposal', type: 'spec', format: 'md', created_by: caller.userId,
898
+ content: `# Proposed scope change\n\n${a.request}\n\nClassification: ${a.classification}\n\nCompared with approved scope version ${a.measured_against_version}:\n\n${a.evidence}\n\nProposed only. Do not implement until the user approves.`,
899
+ }).select('id'), 'create', 'the scope change proposal');
900
+ await receipt(caller, 'artifact', artifact.id, 'Scope change proposal');
901
+ try {
902
+ return await asked(caller, { work_item_id: a.work_item_id, related_artifact_id: artifact.id,
903
+ category: 'scope_change', context: 'Review the proposed change before any expanded work begins.',
904
+ question: 'Approve this scope change, or request changes?', answer_mode: 'single_select',
905
+ options: ['approve', 'request changes'] }, true);
906
+ }
907
+ catch (error) {
908
+ throw new Error(`Proposal artifact ${artifact.id} was created, but review did not open: ${errorMessage(error)}. Do not create it again. Use ask_question to request approval of this artifact; do not implement it.`);
909
+ }
910
+ },
911
+ },
742
912
  // ── Read the record ──────────────────────────────────────────────────────
743
913
  {
744
914
  name: 'list_projects',
@@ -789,6 +959,9 @@ const TOOLS = [
789
959
  .eq('id', work_item_id), 'read', `work item ${work_item_id}`);
790
960
  const artifacts = await rows(client.from('artifacts').select('id, type, title, created_at').eq('task_id', work_item_id)
791
961
  .is('deleted_at', null).order('created_at'), 'read', `the artifacts on work item ${work_item_id}`);
962
+ const feedback = await rows(client.from('artifact_feedback').select('*').eq('task_id', work_item_id).order('created_at'), 'read', 'the artifact feedback');
963
+ const decisions = await rows(client.from('decisions').select('id, category, question, state, selected_options, answer_note, related_artifact_id').eq('task_id', work_item_id).order('asked_at'), 'read', 'the work item decisions');
964
+ const comments = await rows(client.from('comments').select('id, body, created_at').eq('task_id', work_item_id).order('created_at'), 'read', 'the work item comments');
792
965
  return [
793
966
  `${item.name}`,
794
967
  `id ${item.id}`,
@@ -801,6 +974,7 @@ const TOOLS = [
801
974
  'DESCRIPTION',
802
975
  item.description.trim() === '' ? '(empty)' : item.description,
803
976
  '',
977
+ 'DECISIONS', JSON.stringify(decisions), 'FEEDBACK', JSON.stringify(feedback), 'COMMENTS', JSON.stringify(comments),
804
978
  `ARTIFACTS ${artifacts.length}`,
805
979
  listed(artifacts.map((a) => line(a.id, a.type, a.title ?? '(untitled)')), 'none'),
806
980
  ].join('\n');
@@ -1879,12 +2053,12 @@ const TOOLS = [
1879
2053
  {
1880
2054
  name: 'create_sprint',
1881
2055
  levels: [1],
1882
- description: 'Create a sprint in a project. Both dates are required and the start must not be after the end.',
2056
+ description: 'Create an ordered batch of work in a project. Dates are optional; use them only for a stated calendar constraint.',
1883
2057
  input: {
1884
2058
  project_id: z.string(),
1885
2059
  name: z.string().min(1),
1886
- start_date: z.string().describe('YYYY-MM-DD'),
1887
- end_date: z.string().describe('YYYY-MM-DD'),
2060
+ start_date: z.string().optional().describe('YYYY-MM-DD'),
2061
+ end_date: z.string().optional().describe('YYYY-MM-DD'),
1888
2062
  },
1889
2063
  handler: async (caller, args) => {
1890
2064
  const { project_id, name, start_date, end_date } = args;
@@ -2332,6 +2506,26 @@ const TOOLS = [
2332
2506
  // because a conversation is one person's. An artifact belongs to a work item
2333
2507
  // the whole org can see, so it goes where every other artifact goes. Two
2334
2508
  // visibility models, two buckets, both deliberate.
2509
+ {
2510
+ name: 'attach_screenshot',
2511
+ levels: [2, 3],
2512
+ description: 'Capture a web page and keep the screenshot as an artifact on this work item. Pass url; the tool takes the picture. ' + WORKFLOW_TOOL_TEACHING,
2513
+ input: {
2514
+ url: z.string().url(), work_item_id: z.string().uuid(), title: z.string().min(1).max(200),
2515
+ platform: z.literal('web'), target: z.string().min(1).max(200),
2516
+ },
2517
+ handler: async (caller, args) => {
2518
+ const a = args;
2519
+ await carriedWorkItem(caller, a.work_item_id, 'a screenshot cannot be attached to it');
2520
+ const capture = await captureUrlScreenshot(a.url);
2521
+ try {
2522
+ return await toolNamed('attach_image_artifact').handler(caller, { ...a, path: capture.path });
2523
+ }
2524
+ finally {
2525
+ await capture.cleanup();
2526
+ }
2527
+ },
2528
+ },
2335
2529
  {
2336
2530
  name: 'attach_image_artifact',
2337
2531
  levels: [2, 3],
@@ -2600,7 +2794,11 @@ const TOOLS = [
2600
2794
  + 'or sends back for changes. Only the current Level 2 conversation owner may name it, and '
2601
2795
  + 'its current process token is required. Leave it out for every ordinary question.'),
2602
2796
  },
2603
- handler: async (caller, args) => asked(caller, args, true),
2797
+ handler: async (caller, args) => {
2798
+ if (String(args.category).startsWith('workflow_tool_'))
2799
+ throw new Error('That category is reserved for tool permissions. The tool opens its own approval question; do not create one yourself.');
2800
+ return asked(caller, args, true);
2801
+ },
2604
2802
  },
2605
2803
  {
2606
2804
  name: 'escalate',
@@ -2905,9 +3103,60 @@ async function receiptOnce(caller, kind, refId, label) {
2905
3103
  * the same reason `cs show` exists: a rule nobody can print is a rule nobody
2906
3104
  * can check. */
2907
3105
  export function toolNamesForLevel(level, isOwner = false) {
2908
- return TOOLS.filter((t) => t.levels.includes(level) && !(isOwner && t.name === 'escalate'))
3106
+ return TOOLS.filter((t) => toolAvailable(t, level, isOwner))
2909
3107
  .map((t) => t.name);
2910
3108
  }
3109
+ /** The stage owns the setting; the existing question path owns permission.
3110
+ * An exact-call fingerprint prevents approval of one edit authorizing another. */
3111
+ async function invokeWorkflowTool(caller, tool, args) {
3112
+ const canonical = workflowToolForName(tool.name);
3113
+ const a = args;
3114
+ const { workflow_tool_instance: instance, workflow_work_item_id: explicitSource, ...inputs } = a;
3115
+ const invoke = () => tool.handler(caller, inputs);
3116
+ if (!canonical || workflowToolAlwaysAllowed(canonical))
3117
+ return invoke();
3118
+ const attached = await loadAttachments(caller.client, caller.cardId);
3119
+ const ids = attached.filter(item => item.kind === 'work_item').map(item => item.ref_id);
3120
+ let workItemId = explicitSource;
3121
+ if (!workItemId && ids.length === 1)
3122
+ workItemId = ids[0];
3123
+ if (!workItemId && ids.includes(String(a.work_item_id ?? a.task_id)))
3124
+ workItemId = String(a.work_item_id ?? a.task_id);
3125
+ if (!workItemId && tool.name === 'create_step') {
3126
+ const stage = await stageById(caller.client, String(a.stage_id));
3127
+ workItemId = stage?.workItemId ?? undefined;
3128
+ }
3129
+ if (!workItemId && ids.length > 1)
3130
+ throw new Error('Name workflow_work_item_id from the card attachments so the correct stage controls this action.');
3131
+ if (workItemId)
3132
+ await carriedWorkItem(caller, workItemId, 'its workflow permissions cannot be used', attached);
3133
+ const permission = await workflowToolPermission(caller.client, workItemId ?? null, canonical, instance);
3134
+ if (!tool.levels.includes(caller.level) && !permission)
3135
+ throw new Error('This planning action requires an explicit tool mention in the current workflow stage. Nothing was written.');
3136
+ if (permission)
3137
+ await validateWorkflowToolTarget(caller.client, workItemId, canonical, a);
3138
+ if (!permission || permission.approval === 'not-required')
3139
+ return invoke();
3140
+ const fingerprint = canonicalArgsHash({ tool: tool.name, workItemId, instance: permission.id, args: inputs });
3141
+ const category = `workflow_tool_${fingerprint}`;
3142
+ const questions = await withAskContent(caller.client, await rows(caller.client.from('panel3_asks').select(`pending_run_id, answered_at, ${ASK_CONTENT_COLUMNS}`).eq('card_id', caller.cardId), 'read', 'workflow tool approvals'));
3143
+ const previous = questions.find(question => question.category === category && question.decision_id !== null);
3144
+ if (previous?.answered_at) {
3145
+ if (previous.selected_options?.length === 1 && previous.selected_options[0] === 'approve')
3146
+ return invoke();
3147
+ throw new Error('The user did not approve this workflow tool action. Nothing was written.');
3148
+ }
3149
+ if (previous)
3150
+ throw new Error('This workflow tool action is waiting for user approval. Stop until the user answers.');
3151
+ if (caller.level === 3)
3152
+ throw new Error('This workflow tool action requires user approval. Escalate the exact action to the conversation owner and stop; the owner must perform the approved call.');
3153
+ await asked(caller, {
3154
+ question: `May I ${tool.name.replaceAll('_', ' ')}${a.title ? `: ${String(a.title).slice(0, 180)}` : ''}?`,
3155
+ category, context: 'This use of the tool requires approval in the workflow stage.',
3156
+ answer_mode: 'single_select', options: ['approve', 'deny'], work_item_id: workItemId,
3157
+ }, true);
3158
+ throw new Error('This workflow tool action is waiting for user approval. Nothing was written. Stop until the user answers, then retry the same call with the same arguments.');
3159
+ }
2911
3160
  /**
2912
3161
  * One tool's handler, by name. Exported for the same reason `toolNamesForLevel`
2913
3162
  * is: a test that only calls `workBrief` with a hand-built array proves the
@@ -2927,6 +3176,11 @@ export function toolShape(name) {
2927
3176
  const { description, input } = toolNamed(name);
2928
3177
  return { description, input };
2929
3178
  }
3179
+ function toolAvailable(tool, level, isOwner) {
3180
+ if (isOwner && tool.name === 'escalate')
3181
+ return false;
3182
+ return tool.levels.includes(level) || (level === 2 && isOwner && workflowToolForName(tool.name) !== undefined);
3183
+ }
2930
3184
  function toolNamed(name) {
2931
3185
  const tool = TOOLS.find((t) => t.name === name);
2932
3186
  if (!tool)
@@ -2948,9 +3202,14 @@ function buildServer(caller) {
2948
3202
  + 'hold rather than one that is missing.',
2949
3203
  });
2950
3204
  for (const tool of TOOLS) {
2951
- if (!tool.levels.includes(caller.level) || (caller.isOwner && tool.name === 'escalate'))
3205
+ if (!toolAvailable(tool, caller.level, caller.isOwner))
2952
3206
  continue;
2953
- server.registerTool(tool.name, { description: tool.description, inputSchema: tool.input }, (async (args) => {
3207
+ server.registerTool(tool.name, { description: tool.description + ' ' + WORKFLOW_TOOL_TEACHING, inputSchema: { ...tool.input,
3208
+ ...(workflowToolForName(tool.name) ? {
3209
+ workflow_tool_instance: z.string().uuid().optional(),
3210
+ workflow_work_item_id: z.string().uuid().optional().describe('The attached work item whose workflow stage instructs this action. Needed when the card carries multiple work items.'),
3211
+ } : {}),
3212
+ } }, (async (args) => {
2954
3213
  try {
2955
3214
  /* ═══ NOTHING THIS RUN READ GOES BACK OUT THROUGH A TOOL. ═══ Every
2956
3215
  argument of every tool, redacted before the handler sees it: see
@@ -2959,7 +3218,7 @@ function buildServer(caller) {
2959
3218
  on; `writeAnswer` in run.ts is the other. It is identity for the
2960
3219
  runs that read no credential, which is nearly all of them. */
2961
3220
  const safe = redactArgs(secretScope(caller), args);
2962
- return { content: [{ type: 'text', text: await tool.handler(caller, safe) }] };
3221
+ return { content: [{ type: 'text', text: await invokeWorkflowTool(caller, tool, safe) }] };
2963
3222
  }
2964
3223
  catch (error) {
2965
3224
  /* ═══ A FAILURE REACHES THE AGENT AS A FAILURE. ═══ Constraint 7, at