@ctrl-spc/cs 0.7.7 → 0.7.9

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.
@@ -598,6 +598,12 @@ export function workBrief(level, responsibility, boundary, workItemId, attachmen
598
598
  'time, and before any reading that takes a while. It does not end your turn and it is not',
599
599
  'your reply; you carry on straight afterwards.',
600
600
  '',
601
+ 'WORKFLOW AUTHORING',
602
+ 'When asked to create a workflow, use create_workflow to save it; do not execute the workflow.',
603
+ 'Ask clarifying questions with ask_question only when the missing answer changes its behavior.',
604
+ 'Write the requested tool mentions and per-use permissions in the stage bodies using the tool schema instructions.',
605
+ 'The saved workflow appears as a reviewable card in this conversation. Do not substitute a plan artifact or a prose-only reply.',
606
+ '',
601
607
  'TRACEABILITY BEFORE A CODEBASE FILE CHANGES',
602
608
  'A codebase-file change means ANY file change: source, config, docs, tests, migrations,',
603
609
  'generated inputs, and assets all count. "Non-code" means no codebase file changes at all.',
@@ -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, validateApprovalAction } 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, WORKFLOW_AUTHORING_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,180 @@ 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: 'require_approval', levels: [1, 2],
754
+ description: 'Pause for the user to approve a specific action before you perform it, including editing code, running a migration, or pushing commits. Name the action and exact scope using relative paths, never absolute local paths. Stop on pending or denial and retry the identical call after the answer. Only an approved result permits that action. This tool does not execute it.',
755
+ input: { action: z.string().trim().min(1).max(240), details: z.string().trim().min(1).max(8000) },
756
+ handler: async (_caller, args) => JSON.stringify({ approved: true, action: args.action, details: args.details,
757
+ instruction: 'The user approved only this action and scope. You may now perform it.' }),
758
+ },
759
+ {
760
+ name: 'get_document', levels: ALL,
761
+ description: 'Read a project context document or instruction in full by id. Read-only.',
762
+ input: { id: z.string().uuid() },
763
+ handler: async (caller, args) => productResult(await getDocumentHandler(caller.client, args)),
764
+ },
765
+ {
766
+ name: 'add_comment', levels: ALL,
767
+ description: 'Leave a durable note or handover on a work item. ' + FIREWALL_WRITING_RULE,
768
+ input: { work_item_id: z.string().uuid(), body: z.string().min(1) },
769
+ handler: async (caller, args) => {
770
+ const { work_item_id, body } = args;
771
+ await carriedWorkItem(caller, work_item_id, 'a comment cannot be added to it');
772
+ const comment = await only(caller.client.from('comments')
773
+ .insert({ task_id: work_item_id, body, author_id: caller.userId }).select('id'), 'add', 'the comment');
774
+ return `Added comment ${comment.id}.`;
775
+ },
776
+ },
777
+ {
778
+ name: 'create_product_idea', levels: [1],
779
+ 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,
780
+ input: { project_id: z.string().uuid(), title: z.string().min(1), description: z.string().optional() },
781
+ handler: async (caller, args) => {
782
+ const a = args;
783
+ const id = await returned(caller.client.rpc('create_product_idea', {
784
+ p_project: a.project_id, p_name: a.title, p_description: a.description ?? '', p_owner: caller.userId,
785
+ }), 'create', 'the product idea');
786
+ if (!id)
787
+ throw new Error('The product idea was not created.');
788
+ await receipt(caller, 'work_item', id, a.title);
789
+ return `Created Product Idea ${a.title}, id ${id}. No work has started. Only a human can promote it.`;
790
+ },
791
+ },
792
+ {
793
+ name: 'place_work_item', levels: [1],
794
+ description: 'Move a work item into an epic or sprint in its project. Null removes placement. Its status stays the same.',
795
+ input: { work_item_id: z.string().uuid(), epic_id: z.string().uuid().nullable().optional(),
796
+ sprint_id: z.string().uuid().nullable().optional(), before_work_item_id: z.string().uuid().optional() },
797
+ handler: async (caller, args) => productResult(await placeWorkItemHandler(caller.client, args)),
798
+ },
799
+ {
800
+ name: 'reorder_backlog', levels: [1],
801
+ description: 'Move one backlog work item before another, or to the bottom. Read the current order first and explain the reason.',
802
+ input: { project_id: z.string().uuid(), task_id: z.string().uuid(), before_task_id: z.string().uuid().optional(), reason: z.string().min(1) },
803
+ handler: async (caller, args) => {
804
+ const a = args;
805
+ const items = await rows(caller.client.from('tasks').select('id, revision')
806
+ .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');
807
+ const moving = items.find(item => item.id === a.task_id);
808
+ if (!moving || (a.before_task_id && !items.some(item => item.id === a.before_task_id)))
809
+ throw new Error('Both items must be live backlog work in this project. Nothing was moved.');
810
+ if (a.task_id === a.before_task_id)
811
+ throw new Error('An item cannot be placed before itself. Nothing was moved.');
812
+ await returned(caller.client.rpc('save_task_if_current', { p_task_id: a.task_id,
813
+ p_expected_revision: moving.revision, p_changes: {}, p_tag_ids: null, p_reorder: true,
814
+ p_before_task_id: a.before_task_id ?? null }), 'reorder', 'the backlog');
815
+ return `Reordered the backlog: ${a.reason}`;
816
+ },
817
+ },
818
+ {
819
+ name: 'propose_project_context', levels: [2, 3],
820
+ 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,
821
+ input: { work_item_id: z.string().uuid(), codebase: z.string().min(1), proposals: z.array(z.object({
822
+ title: z.string().min(1), type: z.enum(PROJECT_DOCUMENT_TYPES), content: z.string().min(1),
823
+ source_path: z.string().min(1), reason: z.string().min(1), scope: z.enum(['project', 'codebase']),
824
+ })) },
825
+ handler: async (caller, args) => {
826
+ const { work_item_id, ...a } = args;
827
+ await carriedWorkItem(caller, work_item_id, 'project context cannot be proposed for it');
828
+ return productResult(await proposeProjectContextHandler(caller.client, null, { ...a, task_id: work_item_id }));
829
+ },
830
+ },
831
+ {
832
+ name: 'record_context_exploration', levels: ALL,
833
+ description: 'Save explored context and research in the work item’s Findings artifact. Repeated calls update the same artifact. ' + FIREWALL_WRITING_RULE,
834
+ input: { work_item_id: z.string().uuid(), content: z.string().min(1) },
835
+ handler: async (caller, args) => {
836
+ const { work_item_id, content } = args;
837
+ await carriedWorkItem(caller, work_item_id, 'findings cannot be recorded on it');
838
+ const existing = await rows(caller.client.from('artifacts').select('id')
839
+ .eq('task_id', work_item_id).eq('purpose_key', 'workflow_findings').is('deleted_at', null), 'read', 'existing findings');
840
+ if (existing.length > 1)
841
+ throw new Error('More than one Findings artifact exists. Choose the artifact to edit explicitly.');
842
+ const artifact = existing[0]
843
+ ? await only(caller.client.from('artifacts').update({ content, updated_by: caller.userId })
844
+ .eq('id', existing[0].id).select('id'), 'update', 'the findings')
845
+ : await only(caller.client.from('artifacts').insert({ task_id: work_item_id, title: 'Findings',
846
+ type: 'analysis', format: 'md', content, purpose_key: 'workflow_findings', created_by: caller.userId }).select('id'), 'record', 'the findings');
847
+ await receiptOnce(caller, 'artifact', artifact.id, 'Findings');
848
+ return `Recorded Findings artifact ${artifact.id}.`;
849
+ },
850
+ },
851
+ {
852
+ name: 'resolve_feedback', levels: ALL,
853
+ description: 'Mark specified feedback rounds addressed after revising their artifact. Supply the new revision. This does not approve the artifact.',
854
+ input: { work_item_id: z.string().uuid(), feedback_ids: z.array(z.string().uuid()).min(1), revision: z.number().int().positive() },
855
+ handler: async (caller, args) => {
856
+ const a = args;
857
+ await carriedWorkItem(caller, a.work_item_id, 'its feedback cannot be resolved');
858
+ await validateWorkflowToolTarget(caller.client, a.work_item_id, 'resolve_feedback', a);
859
+ return productResult(await resolveFeedbackHandler(caller.client, { taskId: a.work_item_id }, a));
860
+ },
861
+ },
862
+ ...['present_mocks', 'present_wireframes'].map((name) => ({
863
+ name, levels: [2],
864
+ 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,
865
+ input: { work_item_id: z.string().uuid(), title: z.string().min(1), html: z.string().min(1),
866
+ ...(name === 'present_wireframes' ? { kind: z.enum(['ui', 'architecture']) } : {}) },
867
+ handler: async (caller, args) => {
868
+ if (!caller.isOwner || !caller.processToken)
869
+ throw new Error('Escalate this design presentation to the current conversation owner. Nothing was written.');
870
+ const a = args;
871
+ await carriedWorkItem(caller, a.work_item_id, 'a design cannot be presented for it');
872
+ const mock = name === 'present_mocks';
873
+ const result = await presentDesign(caller.client, caller.userId, a.work_item_id, a, {
874
+ tool: name, unit: mock ? 'mock' : 'diagram', htmlSpec: 'self-contained HTML',
875
+ artifactType: mock ? 'mock' : a.kind === 'architecture' ? 'diagram' : 'wireframe',
876
+ artifactNoun: mock ? 'mock' : 'wireframe',
877
+ }, async (kind, id) => {
878
+ if (kind === 'artifact')
879
+ await receiptOnce(caller, 'artifact', id, a.title);
880
+ }, async (review) => {
881
+ await asked(caller, { work_item_id: a.work_item_id, related_artifact_id: review.artifactId,
882
+ category: 'wireframe_review', context: review.title, question: review.question,
883
+ answer_mode: 'single_select', options: review.options }, true);
884
+ const decision = await only(caller.client.from('decisions').select('id')
885
+ .eq('related_artifact_id', review.artifactId).eq('category', 'wireframe_review'), 'read', 'the design review');
886
+ return decision.id;
887
+ });
888
+ const text = productResult(result);
889
+ return text + '\nStop now. The conversation resumes when the user answers. Do not poll.';
890
+ },
891
+ })),
892
+ {
893
+ name: 'propose_scope_change', levels: [2],
894
+ 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,
895
+ input: { work_item_id: z.string().uuid(), request: z.string().min(1),
896
+ classification: z.enum(['completion_gap', 'clarification', 'expansion', 'replacement']),
897
+ evidence: z.string().min(1), measured_against_version: z.number().int().positive() },
898
+ handler: async (caller, args) => {
899
+ if (!caller.isOwner || !caller.processToken)
900
+ throw new Error('Escalate the scope proposal to the current conversation owner. Nothing was written.');
901
+ const a = args;
902
+ await carriedWorkItem(caller, a.work_item_id, 'a scope change cannot be proposed for it');
903
+ const artifact = await only(caller.client.from('artifacts').insert({ task_id: a.work_item_id,
904
+ title: 'Scope change proposal', type: 'spec', format: 'md', created_by: caller.userId,
905
+ 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.`,
906
+ }).select('id'), 'create', 'the scope change proposal');
907
+ await receipt(caller, 'artifact', artifact.id, 'Scope change proposal');
908
+ try {
909
+ return await asked(caller, { work_item_id: a.work_item_id, related_artifact_id: artifact.id,
910
+ category: 'scope_change', context: 'Review the proposed change before any expanded work begins.',
911
+ question: 'Approve this scope change, or request changes?', answer_mode: 'single_select',
912
+ options: ['approve', 'request changes'] }, true);
913
+ }
914
+ catch (error) {
915
+ 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.`);
916
+ }
917
+ },
918
+ },
742
919
  // ── Read the record ──────────────────────────────────────────────────────
743
920
  {
744
921
  name: 'list_projects',
@@ -789,6 +966,9 @@ const TOOLS = [
789
966
  .eq('id', work_item_id), 'read', `work item ${work_item_id}`);
790
967
  const artifacts = await rows(client.from('artifacts').select('id, type, title, created_at').eq('task_id', work_item_id)
791
968
  .is('deleted_at', null).order('created_at'), 'read', `the artifacts on work item ${work_item_id}`);
969
+ const feedback = await rows(client.from('artifact_feedback').select('*').eq('task_id', work_item_id).order('created_at'), 'read', 'the artifact feedback');
970
+ 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');
971
+ 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
972
  return [
793
973
  `${item.name}`,
794
974
  `id ${item.id}`,
@@ -801,6 +981,7 @@ const TOOLS = [
801
981
  'DESCRIPTION',
802
982
  item.description.trim() === '' ? '(empty)' : item.description,
803
983
  '',
984
+ 'DECISIONS', JSON.stringify(decisions), 'FEEDBACK', JSON.stringify(feedback), 'COMMENTS', JSON.stringify(comments),
804
985
  `ARTIFACTS ${artifacts.length}`,
805
986
  listed(artifacts.map((a) => line(a.id, a.type, a.title ?? '(untitled)')), 'none'),
806
987
  ].join('\n');
@@ -990,7 +1171,7 @@ const TOOLS = [
990
1171
  described and can write it down as it was described. A launcher writes one
991
1172
  line and exits; a worker owns one piece of somebody else's process. */
992
1173
  levels: [2],
993
- description: "Write a new workflow into this organisation's library: a name, what it is about, and its "
1174
+ description: WORKFLOW_AUTHORING_TEACHING + "Write a new workflow into this organisation's library: a name, what it is about, and its "
994
1175
  + 'stages in order, each with a one-line description and the markdown document an agent '
995
1176
  + 'following it reads. Stages are numbered from 1, the way get_workflow prints them. exits '
996
1177
  + 'are the conditional ways back: from_stage N, if condition, go back to to_stage M, where M '
@@ -1879,12 +2060,12 @@ const TOOLS = [
1879
2060
  {
1880
2061
  name: 'create_sprint',
1881
2062
  levels: [1],
1882
- description: 'Create a sprint in a project. Both dates are required and the start must not be after the end.',
2063
+ description: 'Create an ordered batch of work in a project. Dates are optional; use them only for a stated calendar constraint.',
1883
2064
  input: {
1884
2065
  project_id: z.string(),
1885
2066
  name: z.string().min(1),
1886
- start_date: z.string().describe('YYYY-MM-DD'),
1887
- end_date: z.string().describe('YYYY-MM-DD'),
2067
+ start_date: z.string().optional().describe('YYYY-MM-DD'),
2068
+ end_date: z.string().optional().describe('YYYY-MM-DD'),
1888
2069
  },
1889
2070
  handler: async (caller, args) => {
1890
2071
  const { project_id, name, start_date, end_date } = args;
@@ -2332,6 +2513,26 @@ const TOOLS = [
2332
2513
  // because a conversation is one person's. An artifact belongs to a work item
2333
2514
  // the whole org can see, so it goes where every other artifact goes. Two
2334
2515
  // visibility models, two buckets, both deliberate.
2516
+ {
2517
+ name: 'attach_screenshot',
2518
+ levels: [2, 3],
2519
+ 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,
2520
+ input: {
2521
+ url: z.string().url(), work_item_id: z.string().uuid(), title: z.string().min(1).max(200),
2522
+ platform: z.literal('web'), target: z.string().min(1).max(200),
2523
+ },
2524
+ handler: async (caller, args) => {
2525
+ const a = args;
2526
+ await carriedWorkItem(caller, a.work_item_id, 'a screenshot cannot be attached to it');
2527
+ const capture = await captureUrlScreenshot(a.url);
2528
+ try {
2529
+ return await toolNamed('attach_image_artifact').handler(caller, { ...a, path: capture.path });
2530
+ }
2531
+ finally {
2532
+ await capture.cleanup();
2533
+ }
2534
+ },
2535
+ },
2335
2536
  {
2336
2537
  name: 'attach_image_artifact',
2337
2538
  levels: [2, 3],
@@ -2600,7 +2801,11 @@ const TOOLS = [
2600
2801
  + 'or sends back for changes. Only the current Level 2 conversation owner may name it, and '
2601
2802
  + 'its current process token is required. Leave it out for every ordinary question.'),
2602
2803
  },
2603
- handler: async (caller, args) => asked(caller, args, true),
2804
+ handler: async (caller, args) => {
2805
+ if (String(args.category).startsWith('workflow_tool_'))
2806
+ throw new Error('That category is reserved for tool permissions. The tool opens its own approval question; do not create one yourself.');
2807
+ return asked(caller, args, true);
2808
+ },
2604
2809
  },
2605
2810
  {
2606
2811
  name: 'escalate',
@@ -2905,9 +3110,62 @@ async function receiptOnce(caller, kind, refId, label) {
2905
3110
  * the same reason `cs show` exists: a rule nobody can print is a rule nobody
2906
3111
  * can check. */
2907
3112
  export function toolNamesForLevel(level, isOwner = false) {
2908
- return TOOLS.filter((t) => t.levels.includes(level) && !(isOwner && t.name === 'escalate'))
3113
+ return TOOLS.filter((t) => toolAvailable(t, level, isOwner))
2909
3114
  .map((t) => t.name);
2910
3115
  }
3116
+ /** The stage owns the setting; the existing question path owns permission.
3117
+ * An exact-call fingerprint prevents approval of one edit authorizing another. */
3118
+ async function invokeWorkflowTool(caller, tool, args) {
3119
+ const canonical = workflowToolForName(tool.name);
3120
+ const a = args;
3121
+ const { workflow_tool_instance: instance, workflow_work_item_id: explicitSource, ...inputs } = a;
3122
+ const invoke = () => tool.handler(caller, inputs);
3123
+ if (!canonical || workflowToolAlwaysAllowed(canonical))
3124
+ return invoke();
3125
+ if (canonical === 'require_approval')
3126
+ validateApprovalAction(String(a.action), String(a.details));
3127
+ const attached = await loadAttachments(caller.client, caller.cardId);
3128
+ const ids = attached.filter(item => item.kind === 'work_item').map(item => item.ref_id);
3129
+ let workItemId = explicitSource;
3130
+ if (!workItemId && ids.length === 1)
3131
+ workItemId = ids[0];
3132
+ if (!workItemId && ids.includes(String(a.work_item_id ?? a.task_id)))
3133
+ workItemId = String(a.work_item_id ?? a.task_id);
3134
+ if (!workItemId && tool.name === 'create_step') {
3135
+ const stage = await stageById(caller.client, String(a.stage_id));
3136
+ workItemId = stage?.workItemId ?? undefined;
3137
+ }
3138
+ if (!workItemId && ids.length > 1)
3139
+ throw new Error('Name workflow_work_item_id from the card attachments so the correct stage controls this action.');
3140
+ if (workItemId)
3141
+ await carriedWorkItem(caller, workItemId, 'its workflow permissions cannot be used', attached);
3142
+ const permission = await workflowToolPermission(caller.client, workItemId ?? null, canonical, instance);
3143
+ if (!tool.levels.includes(caller.level) && !permission)
3144
+ throw new Error('This planning action requires an explicit tool mention in the current workflow stage. Nothing was written.');
3145
+ if (permission)
3146
+ await validateWorkflowToolTarget(caller.client, workItemId, canonical, a);
3147
+ if (canonical !== 'require_approval' && (!permission || permission.approval === 'not-required'))
3148
+ return invoke();
3149
+ const fingerprint = canonicalArgsHash({ tool: tool.name, workItemId, instance: permission?.id ?? null, args: inputs });
3150
+ const category = `workflow_tool_${fingerprint}`;
3151
+ 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'));
3152
+ const previous = questions.find(question => question.category === category && question.decision_id !== null);
3153
+ if (previous?.answered_at) {
3154
+ if (previous.selected_options?.length === 1 && previous.selected_options[0] === 'approve')
3155
+ return invoke();
3156
+ throw new Error('The user did not approve this workflow tool action. Nothing was written.');
3157
+ }
3158
+ if (previous)
3159
+ throw new Error('This workflow tool action is waiting for user approval. Stop until the user answers.');
3160
+ if (caller.level === 3)
3161
+ 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.');
3162
+ await asked(caller, {
3163
+ question: canonical === 'require_approval' ? `May I ${String(a.action)}?` : `May I ${tool.name.replaceAll('_', ' ')}${a.title ? `: ${String(a.title).slice(0, 180)}` : ''}?`,
3164
+ category, context: canonical === 'require_approval' ? String(a.details) : 'This use of the tool requires approval in the workflow stage.',
3165
+ answer_mode: 'single_select', options: ['approve', 'deny'], work_item_id: workItemId,
3166
+ }, true);
3167
+ 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.');
3168
+ }
2911
3169
  /**
2912
3170
  * One tool's handler, by name. Exported for the same reason `toolNamesForLevel`
2913
3171
  * is: a test that only calls `workBrief` with a hand-built array proves the
@@ -2927,6 +3185,11 @@ export function toolShape(name) {
2927
3185
  const { description, input } = toolNamed(name);
2928
3186
  return { description, input };
2929
3187
  }
3188
+ function toolAvailable(tool, level, isOwner) {
3189
+ if (isOwner && tool.name === 'escalate')
3190
+ return false;
3191
+ return tool.levels.includes(level) || (level === 2 && isOwner && workflowToolForName(tool.name) !== undefined);
3192
+ }
2930
3193
  function toolNamed(name) {
2931
3194
  const tool = TOOLS.find((t) => t.name === name);
2932
3195
  if (!tool)
@@ -2948,9 +3211,14 @@ function buildServer(caller) {
2948
3211
  + 'hold rather than one that is missing.',
2949
3212
  });
2950
3213
  for (const tool of TOOLS) {
2951
- if (!tool.levels.includes(caller.level) || (caller.isOwner && tool.name === 'escalate'))
3214
+ if (!toolAvailable(tool, caller.level, caller.isOwner))
2952
3215
  continue;
2953
- server.registerTool(tool.name, { description: tool.description, inputSchema: tool.input }, (async (args) => {
3216
+ server.registerTool(tool.name, { description: tool.description + ' ' + WORKFLOW_TOOL_TEACHING, inputSchema: { ...tool.input,
3217
+ ...(workflowToolForName(tool.name) ? {
3218
+ workflow_tool_instance: z.string().uuid().optional(),
3219
+ 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.'),
3220
+ } : {}),
3221
+ } }, (async (args) => {
2954
3222
  try {
2955
3223
  /* ═══ NOTHING THIS RUN READ GOES BACK OUT THROUGH A TOOL. ═══ Every
2956
3224
  argument of every tool, redacted before the handler sees it: see
@@ -2959,7 +3227,7 @@ function buildServer(caller) {
2959
3227
  on; `writeAnswer` in run.ts is the other. It is identity for the
2960
3228
  runs that read no credential, which is nearly all of them. */
2961
3229
  const safe = redactArgs(secretScope(caller), args);
2962
- return { content: [{ type: 'text', text: await tool.handler(caller, safe) }] };
3230
+ return { content: [{ type: 'text', text: await invokeWorkflowTool(caller, tool, safe) }] };
2963
3231
  }
2964
3232
  catch (error) {
2965
3233
  /* ═══ A FAILURE REACHES THE AGENT AS A FAILURE. ═══ Constraint 7, at