@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.
package/dist/mcp.js CHANGED
@@ -1,4 +1,7 @@
1
- import { createHash, randomUUID } from 'node:crypto';
1
+ import { presentDesign } from './design-review.js';
2
+ import { must, errorMessage, textResult, errorResult, canonicalArgsHash, TASK_PLACEMENT, placeWorkItemHandler, UUID_RE, resolveFeedbackHandler, PROJECT_DOCUMENT_TYPES, countCharacters, resolveContextProject, resolveContextCodebase, getDocumentHandler, PROPOSAL_SCOPES, MAX_PROPOSALS, MAX_PROPOSAL_BATCH_CHARS, proposeProjectContextHandler } from './product-tools.js';
3
+ export { canonicalArgsHash, placeWorkItemHandler, resolveFeedbackHandler, getDocumentHandler, proposeProjectContextHandler } from './product-tools.js';
4
+ import { randomUUID } from 'node:crypto';
2
5
  import { execFile } from 'node:child_process';
3
6
  import { chmodSync, existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
7
  import { homedir } from 'node:os';
@@ -11,14 +14,16 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
14
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
12
15
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
13
16
  import { z } from 'zod';
17
+ import { workflowToolPermission, validateWorkflowToolTarget, WORKFLOW_TOOL_TEACHING, assertAgentToolMentions } from './workflows.js';
18
+ import { workflowToolForName } from './workflow-tool-mentions.js';
14
19
  import { TOOLS_SERVER_PORT, SESSION_TTL_MS } from './env.js';
15
20
  import { agentPath } from './agents.js';
16
21
  import { mcpToken, readMcpToken, readSession } from './config.js';
17
- import { readableWriteError, FIREWALL_WRITING_RULE } from './firewall.js';
22
+ import { FIREWALL_WRITING_RULE } from './firewall.js';
18
23
  /* The path rule itself, moved out so `workflows.ts` can ask the same question
19
24
  before it builds a workflow. `refuseAbsolutePaths` below is still v2's own
20
25
  wrapper: it is the one that answers in a `CallToolResult`. */
21
- import { ABSOLUTE_PATH_RE, absolutePathToken } from './local-paths.js';
26
+ import { absolutePathToken } from './local-paths.js';
22
27
  import { readPngScreenshot, screenshotArtifactId, screenshotRequestId, } from './screenshots.js';
23
28
  /* 18c Slice 7 correction: the product captures the PNG itself, because a
24
29
  spawned worker has no reachable way to run a capture command. The whole
@@ -239,26 +244,6 @@ function validateGrounding(value) {
239
244
  }
240
245
  return { ok: true, value: manifest };
241
246
  }
242
- /** Awaits a Supabase call and throws on error — callers catch once, at the
243
- * handler boundary (mirrors v1's `must`). */
244
- async function must(query) {
245
- const { data, error } = await query;
246
- if (error)
247
- throw new Error(readableWriteError(error.message));
248
- return data;
249
- }
250
- /** The message of a thrown value, WITHOUT assuming it is an `Error`. `(err as
251
- * Error).message` is a lie the type system permits: a thrown string, a
252
- * `DOMException` from an aborted fetch, or a rejected non-Error makes it a
253
- * `TypeError` raised from inside the very catch block that exists to contain
254
- * failures — which escapes the catch and defeats it. */
255
- const errorMessage = (err) => (err instanceof Error ? err.message : String(err));
256
- function textResult(payload) {
257
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
258
- }
259
- function errorResult(message) {
260
- return { content: [{ type: 'text', text: message }], isError: true };
261
- }
262
247
  /** Columns v1 returns for a freshly created artifact (cli/src/mcp.ts:2239) —
263
248
  * reused verbatim so get_task and create_artifact hand back the same shape, plus
264
249
  * `revision` so the agent has the optimistic-concurrency token update_artifact
@@ -270,12 +255,6 @@ const ARTIFACT_COLUMNS = 'id,task_id,type,format,title,purpose_key,system_kind,c
270
255
  const DECISION_COLUMNS = 'id,task_id,state,category,context,question,answer_mode,options,answer,selected_options,answer_note,question_comment_id,related_artifact_id,asked_by,asked_by_agent_run_id,asked_at,decided_by,decided_at';
271
256
  /** Tags come from the same `task_tags`→`tags` join the web reads. */
272
257
  const TASK_TAGS = 'task_tags:task_tags!task_tags_task_id_fkey(tag:tags!task_tags_tag_id_fkey(id,name))';
273
- /* !Cleanup PHASE 5 (I13) — WHERE EACH ITEM SITS. `epics` and `sprints` are
274
- to-one relations off real FKs (`tasks.epic_id`, `tasks.sprint_id`), so
275
- PostgREST embeds them, but it returns an ARRAY shape whenever it cannot prove
276
- the cardinality — the same hazard `readAttachedItems` handles for `projects`.
277
- Both shapes are handled below rather than assumed. */
278
- const TASK_PLACEMENT = 'epic:epics(id,name), sprint:sprints(id,name)';
279
258
  /** The one embedded row, whichever shape PostgREST chose. Null stays null: an
280
259
  * item in no epic is the common case, not a missing value. */
281
260
  function oneOf(embedded) {
@@ -2477,83 +2456,6 @@ export async function updateStructureHandler(client, args) {
2477
2456
  return errorResult(`update_structure failed: ${errorMessage(err)}`);
2478
2457
  }
2479
2458
  }
2480
- /**
2481
- * MOVE A WORK ITEM BETWEEN EPICS AND SPRINTS (I14) — the thing nothing could do.
2482
- *
2483
- * THROUGH `save_task_if_current`, THE SHIPPED PATH. It is the same RPC the
2484
- * board's own epic picker calls, so an agent's move is identical to a user's:
2485
- * the same allowed-field whitelist, the same compare-and-swap on `revision`, the
2486
- * same project-membership validation for the epic and sprint. Writing the
2487
- * columns directly would bypass all of it.
2488
- *
2489
- * THE REVISION IS READ HERE rather than asked of the agent. An agent has no way
2490
- * to know a revision it was never given, and requiring one would make every move
2491
- * a two-call dance whose first call this function can make correctly. The
2492
- * conflict case is still real and still reported: a peer editing between the
2493
- * read and the write loses the swap, and the agent is told to retry.
2494
- *
2495
- * DETERMINISTIC POSITION, which the Gherkin asks for by name. `p_reorder`
2496
- * renormalises the destination's positions to integers, so "after the move, this
2497
- * item is where you put it" is true rather than approximately true — and
2498
- * `before_work_item_id` names the neighbour to land in front of.
2499
- */
2500
- export async function placeWorkItemHandler(client, args) {
2501
- try {
2502
- const id = typeof args.work_item_id === 'string' ? args.work_item_id.trim() : '';
2503
- if (!id || !UUID_RE.test(id)) {
2504
- return errorResult(`place_work_item: "${id}" is not a work item id. Call list_tasks to see them. Nothing was changed.`);
2505
- }
2506
- if (args.epic_id === undefined && args.sprint_id === undefined) {
2507
- return errorResult('place_work_item: pass epic_id, sprint_id, or both — there is nothing to place. Pass null to ' +
2508
- 'take it out of one. Nothing was changed.');
2509
- }
2510
- const task = await must(client.from('tasks').select('id, revision, name').eq('id', id).maybeSingle());
2511
- if (!task) {
2512
- return errorResult(`place_work_item: no work item found for id "${id}". Nothing was changed.`);
2513
- }
2514
- /* NULL MEANS "TAKE IT OUT", and the RPC spells that as an empty string:
2515
- `nullif(p_changes ->> 'epic_id', '')::uuid`. Sending JSON null would read
2516
- back as the string "null" and fail the uuid cast, so the two are
2517
- translated here rather than left for the agent to discover. */
2518
- const changes = {};
2519
- if (args.epic_id !== undefined)
2520
- changes.epic_id = args.epic_id === null ? '' : String(args.epic_id);
2521
- if (args.sprint_id !== undefined)
2522
- changes.sprint_id = args.sprint_id === null ? '' : String(args.sprint_id);
2523
- const { error } = await client.rpc('save_task_if_current', {
2524
- p_task_id: id,
2525
- p_expected_revision: task.revision,
2526
- p_changes: changes,
2527
- p_tag_ids: null,
2528
- /* Renormalise the destination order so the placement is deterministic. */
2529
- p_reorder: true,
2530
- p_before_task_id: args.before_work_item_id ?? null,
2531
- });
2532
- if (error) {
2533
- const message = error.message ?? '';
2534
- if (message.includes('edit_conflict')) {
2535
- return errorResult(`place_work_item: "${task.name}" was changed by someone else while this was being placed. ` +
2536
- 'Call list_tasks again and retry. Nothing was changed.');
2537
- }
2538
- /* The database's own words for a cross-project epic or sprint are already
2539
- plain ("epic must belong to the task's project"), so they are passed
2540
- through rather than replaced with a worse paraphrase. */
2541
- return errorResult(`place_work_item: ${message} Nothing was changed.`);
2542
- }
2543
- const moved = await must(client
2544
- .from('tasks')
2545
- .select(`id, name, status, ${TASK_PLACEMENT}`)
2546
- .eq('id', id)
2547
- .maybeSingle());
2548
- return textResult({
2549
- work_item: moved,
2550
- note: 'Placed. The board shows this immediately.',
2551
- });
2552
- }
2553
- catch (err) {
2554
- return errorResult(`place_work_item failed: ${errorMessage(err)}`);
2555
- }
2556
- }
2557
2459
  /* ═══════════════════════ 16d SLICE 3a — THE PERMISSION GATE ═══════════════
2558
2460
  `.implementations/z-done-16d-inbox-and-permission/ux.md` § "3a — One tool asks
2559
2461
  first, and waits".
@@ -2575,33 +2477,6 @@ export async function placeWorkItemHandler(client, args) {
2575
2477
  THE GRANT BINDS TO THE EXACT CALL. `argsHash` is over the canonical arguments,
2576
2478
  so approving "create epic Onboarding" grants exactly that — not "Billing", and
2577
2479
  not a second Onboarding. */
2578
- /** Stable hash of a tool's arguments.
2579
- *
2580
- * KEYS ARE SORTED and the digest is over canonical JSON, because
2581
- * `JSON.stringify` preserves insertion order: the same call built two ways
2582
- * would otherwise hash differently and a legitimate re-call would find no
2583
- * grant. `undefined` values are dropped for the same reason — an argument the
2584
- * agent omitted and one it passed as undefined are the same call.
2585
- *
2586
- * Nested objects are canonicalised too. A shallow sort would let
2587
- * `{a:{x:1,y:2}}` and `{a:{y:2,x:1}}` — the same call — miss each other. */
2588
- export function canonicalArgsHash(args) {
2589
- const canonical = (value) => {
2590
- if (Array.isArray(value))
2591
- return value.map(canonical);
2592
- if (value && typeof value === 'object') {
2593
- const out = {};
2594
- for (const key of Object.keys(value).sort()) {
2595
- const v = value[key];
2596
- if (v !== undefined)
2597
- out[key] = canonical(v);
2598
- }
2599
- return out;
2600
- }
2601
- return value;
2602
- };
2603
- return createHash('sha256').update(JSON.stringify(canonical(args))).digest('hex');
2604
- }
2605
2480
  /** The sentinel category the web keys on to render a permission card, and the
2606
2481
  * reserved labels the approval predicate keys on.
2607
2482
  *
@@ -2751,6 +2626,26 @@ async function consumeGrant(client, grantId) {
2751
2626
  .update({ state: 'consumed', consumed_at: new Date().toISOString() })
2752
2627
  .eq('id', grantId));
2753
2628
  }
2629
+ async function permissionForWorkflowCall(client, session, tool, args) {
2630
+ const permission = await workflowToolPermission(client, session?.taskId ?? null, tool, args.workflow_tool_instance);
2631
+ if (permission)
2632
+ await validateWorkflowToolTarget(client, session.taskId, tool, args);
2633
+ return permission;
2634
+ }
2635
+ /** Apply the current stage permission before the tool's ordinary write path. */
2636
+ export async function runWorkflowTool(client, userId, session, tool, args, write, fallback = write, connectionTodoId = null) {
2637
+ try {
2638
+ const permission = await permissionForWorkflowCall(client, session, tool, args);
2639
+ if (!permission)
2640
+ return fallback();
2641
+ if (permission.approval === 'not-required')
2642
+ return write();
2643
+ return runGated(client, userId, session, tool, args, `Use ${tool.replaceAll('_', ' ')}: ${String(args.title ?? 'the workflow action')}`, write, connectionTodoId);
2644
+ }
2645
+ catch (error) {
2646
+ return errorResult(error.message);
2647
+ }
2648
+ }
2754
2649
  /**
2755
2650
  * 16d SLICE 3b — ASK, THEN WRITE, THEN SPEND: the whole gated call in one place.
2756
2651
  *
@@ -2923,6 +2818,20 @@ export async function runDirected(client, userId, session, tool, args, summary,
2923
2818
  * the same source `report_activity` reads, which is 18c Slice 1's ruling that
2924
2819
  * provenance cannot be a matter of agent judgement. */
2925
2820
  connectionTodoId = null) {
2821
+ try {
2822
+ const workflowTool = workflowToolForName(tool);
2823
+ if (workflowTool) {
2824
+ const permission = await permissionForWorkflowCall(client, session, workflowTool, args);
2825
+ if (permission) {
2826
+ if (permission.approval === 'not-required')
2827
+ return write();
2828
+ return runGated(client, userId, session, tool, args, summary, write, connectionTodoId);
2829
+ }
2830
+ }
2831
+ }
2832
+ catch (error) {
2833
+ return errorResult(errorMessage(error));
2834
+ }
2926
2835
  const instruction = await readTodoInstruction(client, todoId);
2927
2836
  const direction = checkDirection(instruction, directedBy);
2928
2837
  if (direction.directed)
@@ -3085,12 +2994,11 @@ export async function reorderBacklogHandler(client, userId, session, args) {
3085
2994
  test passed. `\n` cannot appear in a task name. */
3086
2995
  const summary = `Reorder the backlog: ${after.map((i, n) => `${n + 1}. ${i.name}`).join('\n')}` +
3087
2996
  `\nWhy: ${args.reason.trim()}`;
3088
- return runGated(client, userId, session, 'reorder_backlog', args, summary, async () => {
2997
+ const write = async () => {
3089
2998
  /* THE SHIPPED PRIMITIVE. `p_reorder := true` makes the RPC renumber the
3090
2999
  whole status column with the moved item placed before
3091
3000
  `p_before_task_id` — the same statement the web board runs, so agent
3092
3001
  and human reorders cannot disagree.
3093
-
3094
3002
  `p_expected_revision` is the moved row's CURRENT revision, read back
3095
3003
  here rather than taken from the agent: the agent has been waiting for
3096
3004
  an approval, so any revision it captured before asking is stale by
@@ -3115,7 +3023,8 @@ export async function reorderBacklogHandler(client, userId, session, args) {
3115
3023
  order: applied.map((i, n) => ({ rank: n + 1, id: i.id, name: i.name })),
3116
3024
  note: 'The user approved this order. Do not reorder again unless they ask.',
3117
3025
  });
3118
- });
3026
+ };
3027
+ return runWorkflowTool(client, userId, session, 'reorder_backlog', args, write, () => runGated(client, userId, session, 'reorder_backlog', args, summary, write));
3119
3028
  }
3120
3029
  catch (err) {
3121
3030
  return errorResult(`reorder_backlog failed: ${errorMessage(err)} Nothing was moved.`);
@@ -3488,330 +3397,29 @@ connectionTodoId = null) {
3488
3397
  return errorResult(`ask_question failed: ${err.message}`);
3489
3398
  }
3490
3399
  }
3491
- /** Option labels the review outcome reserves (feature 09, Phase 2): 'approve'
3492
- * is the single-shape approval sentinel `answer_task_decision_v2`'s machinery
3493
- * keys on to auto-write `artifact_approvals`, and 'request changes' is the
3494
- * iterate outcome appended to EVERY review decision's options. A user-named
3495
- * option matching either (case-insensitively) would collide with the outcome
3496
- * derivation, so validation rejects them. */
3497
- const RESERVED_OPTION_LABELS = ['approve', 'request changes'];
3498
- /**
3499
- * Present 1..N renders to the user for fullscreen review — the composition BOTH
3500
- * review tools run (feature 09-present-wireframes-tool Phase 2, which REPLACED
3501
- * Phase 1's free_text shape; feature 10-present-mocks-tool Phase 1–2). The caller
3502
- * supplies a `spec`; the plumbing below is identical for wireframes and mocks
3503
- * on purpose, so the web's ONE review surface serves both. A COMPOSITION of
3504
- * existing plumbing — no new tables, no migrations, no new RPCs:
3505
- * 1. each render becomes a normal artifact on the SESSION'S task, inserted
3506
- * exactly the way create_artifact does (AS THE USER, `from_agent` null —
3507
- * the deployed guard reserves agent attribution for the coordinated path
3508
- * this feature does not use — plus the same best-effort
3509
- * cliv2_agent_outputs attribution, one row per artifact): type
3510
- * 'wireframe' for a UI diagram, 'diagram' for an architecture diagram,
3511
- * 'mock' for an interactive mock, format 'html'. The multi shape
3512
- * (options: 2..11 of {label, html})
3513
- * creates one artifact per option, in order, and maps option ↔ artifact
3514
- * through purpose_key 'wireframe_option:<presentation-uuid>:<idx>' on
3515
- * EVERY option artifact (idx 0 included) — the web matches decision
3516
- * options[i] to the artifact with idx i. Idx 0 is the PRIMARY: it carries
3517
- * the presentation title verbatim (idx > 0 titles are '<title> — <label>')
3518
- * and the review decision relates to it. The single shape (html) stays
3519
- * exactly Phase 1's one untagged artifact — NO purpose_key.
3520
- * 2. the review request becomes a single_select decision opened through the
3521
- * SAME `cliv2_ask_question` RPC ask_question uses — category
3522
- * 'wireframe_review' is the sentinel the web keys on to render the
3523
- * fullscreen review surface instead of a plain question, the context
3524
- * carries the title, and `p_related_artifact` points at the primary
3525
- * artifact. Options: ['approve', 'request changes'] (single) or
3526
- * [...labels, 'request changes'] (multi). One uniform outcome for 1..N:
3527
- * the user either APPROVES — 'approve' is the exact shape
3528
- * answer_task_decision_v2 keys on to auto-write artifact_approvals;
3529
- * picking an option label IS approving that option (derived in the web) —
3530
- * or requests changes; their comment rides along as answer_note. Hence
3531
- * 'approve' / 'request changes' are RESERVED label values, and the DB's
3532
- * 12-option decision cap bounds the labels at 11.
3533
- * The review loop (Phase 5 — cumulative feedback rounds): the user sends any
3534
- * number of feedback rounds, any time, without waiting — each is a web-written
3535
- * `artifact_feedback` row tagged with the presentation's PRIMARY (idx 0)
3536
- * artifact and its artifact_revision at send time. That tag is the version
3537
- * stamp of the presentation as a whole, NOT a per-option pointer: in a
3538
- * multi-option presentation, which option a note concerns is named inline in
3539
- * the note's "(…)" option label within the round's body. The agent reads the
3540
- * rounds from get_task's `feedback` list (oldest first). The
3541
- * decision above is the APPROVAL GATE only: it stays open across rounds and
3542
- * decides ONLY when the user approves (selected_options[0] 'approve' or an
3543
- * option label; answer_note may carry a final comment). To iterate, the agent
3544
- * revises the presented render IN PLACE with update_artifact — the DB bumps
3545
- * its revision automatically, so later rounds self-identify against the new
3546
- * version — and keeps polling; it must NOT call the presentation tool again for
3547
- * iterations of the same design. Requires an open session (like ask_question —
3548
- * the decision lives on the session's task, so there's no task_id arg). Full
3549
- * version history is a later phase.
3550
- */
3551
- async function presentationHandler(client, userId, session, args, spec,
3552
- /** 18c Slice 6: the request THIS CONNECTION is working. See
3553
- * createArtifactHandler's attribution block (ux.md § "Slice 6"). */
3554
- connectionTodoId = null) {
3555
- try {
3556
- if (!session) {
3557
- return errorResult(`${spec.tool} needs an open work session — call begin_work first.`);
3558
- }
3559
- const noItem = requiresWorkItem(session, spec.tool);
3560
- if (noItem)
3561
- return noItem;
3562
- if (!args.title || !args.title.trim())
3563
- return errorResult(`${spec.tool} requires a non-empty title.`);
3564
- const extra = spec.extraValidation?.();
3565
- if (extra)
3566
- return errorResult(extra);
3567
- // Exactly ONE of the two shapes: html (a single render, exactly Phase 1)
3568
- // or options (2..11 competing renders).
3569
- const hasHtml = args.html !== undefined;
3570
- const hasOptions = args.options !== undefined;
3571
- if (hasHtml && hasOptions) {
3572
- return errorResult(`${spec.tool} takes either html (one ${spec.unit}) or options (2..11 competing ${spec.unit}s), not both.`);
3573
- }
3574
- if (!hasHtml && !hasOptions) {
3575
- return errorResult(`${spec.tool} requires html (one ${spec.unit}) or options (2..11 competing ${spec.unit}s).`);
3576
- }
3577
- const multi = hasOptions;
3578
- let opts = [];
3579
- if (multi) {
3580
- if (!Array.isArray(args.options) || args.options.length < 2 || args.options.length > 11) {
3581
- return errorResult(`${spec.tool} requires 2..11 options (use html for a single ${spec.unit}; a review decision is capped at 12 options including the reserved one).`);
3582
- }
3583
- opts = args.options.map((option) => ({ label: (option.label ?? '').trim(), html: option.html ?? '' }));
3584
- if (opts.some((o) => !o.label)) {
3585
- return errorResult(`${spec.tool} requires a non-empty label for every option.`);
3586
- }
3587
- if (opts.some((o) => !o.html.trim())) {
3588
- return errorResult(`${spec.tool} requires non-empty html for every option (${spec.htmlSpec}).`);
3589
- }
3590
- const lowered = opts.map((o) => o.label.toLowerCase());
3591
- if (new Set(lowered).size !== lowered.length) {
3592
- return errorResult(`${spec.tool} requires distinct option labels (case-insensitive).`);
3593
- }
3594
- if (lowered.some((label) => RESERVED_OPTION_LABELS.includes(label))) {
3595
- return errorResult(`${spec.tool} option labels 'approve' and 'request changes' are reserved review outcomes — rename the option.`);
3596
- }
3597
- }
3598
- else if (!args.html || !args.html.trim()) {
3599
- return errorResult(`${spec.tool} requires non-empty html (${spec.htmlSpec}).`);
3600
- }
3601
- const title = args.title.trim();
3602
- // Sentence-initial form of the artifact noun, for the attribution warning.
3603
- const capitalNoun = spec.artifactNoun.charAt(0).toUpperCase() + spec.artifactNoun.slice(1);
3604
- // The review decision's question — the recovery message below quotes it
3605
- // LITERALLY, so both must come from this one string.
3606
- const question = `Review the ${spec.artifactNoun} and leave feedback.`;
3607
- // One entry per artifact to create, in presentation order (idx 0 = the
3608
- // primary). Multi tags EVERY entry with the option ↔ artifact mapping key;
3609
- // single stays untagged, exactly Phase 1.
3610
- const presentationId = multi ? randomUUID() : null;
3611
- const renders = multi
3612
- ? opts.map((option, idx) => ({
3613
- title: idx === 0 ? title : `${title} — ${option.label}`,
3614
- html: option.html,
3615
- purpose_key: `wireframe_option:${presentationId}:${idx}`,
3616
- }))
3617
- : [{ title, html: args.html, purpose_key: null }];
3618
- // The review decision's options: the approval outcome(s) first, 'request
3619
- // changes' always last (the DB caps decision options at 12, hence ≤11 labels).
3620
- const reviewOptions = multi ? [...opts.map((o) => o.label), 'request changes'] : ['approve', 'request changes'];
3621
- // The renders land on the session's task. Confirm it is live under the
3622
- // user's RLS first (mirrors create_artifact) so a task archived after
3623
- // begin_work gets a clean error instead of an opaque insert failure.
3624
- const task = await must(client.from('tasks').select('id').eq('id', session.taskId).is('archived_at', null).maybeSingle());
3625
- if (!task)
3626
- return errorResult(`No task found for id "${session.taskId}".`);
3627
- // 1. Each render as a normal artifact — the SAME insert create_artifact
3628
- // does, one row per render, in presentation order.
3629
- const created = [];
3630
- const attributionWarnings = [];
3631
- for (const render of renders) {
3632
- let row = null;
3633
- try {
3634
- row = await must(client
3635
- .from('artifacts')
3636
- .insert({
3637
- task_id: session.taskId,
3638
- type: spec.artifactType,
3639
- format: 'html',
3640
- // 18d Slice 3, gate A: a mock or wireframe IS "a presented
3641
- // document" in story 2's list, and its body is agent-authored
3642
- // HTML the user opens fullscreen.
3643
- title: redactSecrets(connectionTodoId, render.title),
3644
- ...(render.purpose_key ? { purpose_key: render.purpose_key } : {}),
3645
- content: redactSecrets(connectionTodoId, render.html),
3646
- created_by: userId,
3647
- from_agent: null,
3648
- agent_run_id: null,
3649
- })
3650
- .select(ARTIFACT_COLUMNS)
3651
- .single());
3652
- if (!row)
3653
- throw new Error('Artifact insert returned no row.');
3654
- }
3655
- catch (err) {
3656
- // Nothing created yet → fatal and generic, exactly the Phase 1 path.
3657
- if (created.length === 0)
3658
- throw err;
3659
- // A later option insert failing orphans the already-created options:
3660
- // return them (ids included) with guidance the agent can actually act
3661
- // on. No tool can delete an artifact (update_artifact has no deleted_at
3662
- // and there is no delete tool), so "clean up" is not an instruction the
3663
- // agent can execute — removal is a manual web action, and the agent's
3664
- // job is to surface it and hold off re-presenting (a blind retry would
3665
- // duplicate the created options).
3666
- const createdIds = created.map((row) => row.id).join(', ');
3667
- const failure = {
3668
- artifacts: created,
3669
- error: `${spec.tool}: created ${created.length} of ${renders.length} option artifacts, then the insert for ` +
3670
- `'${render.title}' failed: ${err.message}. No review request was opened. There is no delete ` +
3671
- `tool: the created artifacts (${createdIds}) are inert until removed manually in the web. Report this ` +
3672
- `failure to the user with those ids, and do NOT call ${spec.tool} again (it would duplicate the ` +
3673
- 'created options) until the user confirms.',
3674
- };
3675
- if (attributionWarnings.length)
3676
- failure.attribution_warning = attributionWarnings.join(' ');
3677
- return { ...textResult(failure), isError: true };
3678
- }
3679
- created.push(row);
3680
- // Best-effort session attribution per artifact (mirrors create_artifact:
3681
- // the session's task matches by construction). The artifact already exists
3682
- // and is part of the real return value, so a failed attribution write must
3683
- // NOT fail the tool — it is surfaced in the returned text, never dropped.
3684
- //
3685
- /* 18c SLICE 6: `todo_id` records WHICH RUN produced this render, off the
3686
- connection rather than the session. See createArtifactHandler's
3687
- attribution block (ux.md § "Slice 6"). This tool refuses outright
3688
- without a session AND without a work item (requiresWorkItem above), so
3689
- its session is always work-item-anchored and its `todoId` is always
3690
- null: this is the exact case gap 13 names, and the connection is the
3691
- only place the request can be read from. */
3692
- try {
3693
- await must(client.from('cliv2_agent_outputs').insert({
3694
- user_id: userId,
3695
- session_id: session.sessionId,
3696
- kind: 'artifact',
3697
- product_id: row.id,
3698
- todo_id: connectionTodoId,
3699
- }));
3700
- }
3701
- catch (err) {
3702
- attributionWarnings.push(`${capitalNoun} artifact created, but recording session attribution failed: ${err.message}`);
3703
- }
3704
- }
3705
- const primary = created[0];
3706
- // 2. The review request as a single_select decision — the SAME RPC
3707
- // ask_question uses; 'wireframe_review' is the sentinel category the web
3708
- // keys on (feature 09). The artifacts above already exist, so from here a
3709
- // failure must NOT be the generic fatal error: swallowing them would orphan
3710
- // them in the DB (plain cards with no "feedback requested"), leave the
3711
- // agent with no ids to recover with, and invite a blind retry that
3712
- // duplicates artifacts. So this step gets its OWN try/catch (the FIRST
3713
- // artifact-insert failure path above stays fatal and generic), and on
3714
- // failure the error result STILL carries the created artifact(s) plus a
3715
- // message telling the agent to retry the review step — via ask_question
3716
- // with related_artifact_id — or clean up, not re-present.
3717
- let decision;
3718
- try {
3719
- const decisionId = await must(client.rpc('cliv2_ask_question', {
3720
- p_session: session.sessionId,
3721
- p_category: 'wireframe_review',
3722
- p_context: title,
3723
- p_question: question,
3724
- p_answer_mode: 'single_select',
3725
- p_options: reviewOptions,
3726
- p_related_artifact: primary.id,
3727
- }));
3728
- if (!decisionId)
3729
- throw new Error('Review request insert returned no decision id.');
3730
- // Best-effort attribution for the decision (mirrors ask_question exactly),
3731
- // 18c Slice 6 `todo_id` included.
3732
- try {
3733
- await must(client.from('cliv2_agent_outputs').insert({
3734
- user_id: userId,
3735
- session_id: session.sessionId,
3736
- kind: 'decision',
3737
- product_id: decisionId,
3738
- todo_id: connectionTodoId,
3739
- }));
3740
- }
3741
- catch (err) {
3742
- attributionWarnings.push(`Review request created, but recording session attribution failed: ${err.message}`);
3743
- }
3744
- // Re-fetch the created decision with the SAME columns get_task returns, so
3745
- // the agent sees what it made and can later read the feedback back with
3746
- // get_task.
3747
- const fetched = await must(client.from('decisions').select(DECISION_COLUMNS).eq('id', decisionId).maybeSingle());
3748
- if (!fetched)
3749
- throw new Error('Created review decision could not be re-fetched.');
3750
- decision = fetched;
3751
- }
3752
- catch (err) {
3753
- // The recovery instruction must be LITERAL and complete: the web's review
3754
- // surface matches ONLY category === 'wireframe_review' (a sentinel that is
3755
- // deliberately not in any tool description, and that mocks reuse verbatim
3756
- // — there is no 'mock_review'), and the decision-consistency
3757
- // guard makes category immutable — so a plausible-but-wrong retry category
3758
- // would leave the review permanently stuck as a generic question. Spell out
3759
- // the exact ask_question call, with the real title, the full literal
3760
- // options array, and the primary artifact id. The multi failure payload
3761
- // carries ALL created artifacts (ids included).
3762
- const optionsLiteral = `[${reviewOptions.map((label) => `'${label}'`).join(', ')}]`;
3763
- const failure = {
3764
- ...(multi ? { artifacts: created } : { artifact: primary }),
3765
- error: `${spec.tool}: the ${spec.artifactNoun} artifact${multi ? 's were' : ' was'} created, but opening the review request failed: ` +
3766
- `${err.message}. Do NOT call ${spec.tool} again (it would duplicate the artifact${multi ? 's' : ''}) — ` +
3767
- `retry the review step with ask_question({ category: 'wireframe_review', context: '${title}', ` +
3768
- `question: '${question}', answer_mode: 'single_select', ` +
3769
- `options: ${optionsLiteral}, related_artifact_id: '${primary.id}' }) — the category string must be exactly ` +
3770
- "'wireframe_review' or the web will not show the review UI — or clean up.",
3771
- };
3772
- if (attributionWarnings.length)
3773
- failure.attribution_warning = attributionWarnings.join(' ');
3774
- return { ...textResult(failure), isError: true };
3775
- }
3776
- const payload = {
3777
- ...(multi ? { artifacts: created } : { artifact: primary }),
3778
- decision,
3779
- instruction: multi
3780
- ? `The user has been asked to review ${created.length} competing options. They send CUMULATIVE feedback ` +
3781
- "rounds — any number, any time, without waiting for you: poll get_task and read them from its `feedback` " +
3782
- 'list (oldest first). Every round is tagged with the presentation PRIMARY artifact (the first option) and ' +
3783
- 'its artifact_revision at send time — the version stamp of the presentation, NOT a per-option pointer. ' +
3784
- "Which OPTION a note concerns is named inline in the note's \"(…)\" option label within the round's body. " +
3785
- 'This decision is the approval gate only: it stays open across rounds and decides ONLY when the user ' +
3786
- 'approves — once its state is decided, selected_options[0] is the approved option label and answer_note ' +
3787
- 'may carry a final comment. To act on feedback, revise the option artifact each note names IN PLACE with ' +
3788
- 'update_artifact (its revision bumps automatically), then call resolve_feedback with the ids of the ' +
3789
- "rounds that revision actually addressed and the revised OPTION artifact's NEW revision number — only " +
3790
- 'rounds you genuinely acted on. Option revisions are their own number-space (not the primary v-tags): ' +
3791
- 'the review surface shows a multi round as resolved WITHOUT a version number, so name WHICH option you ' +
3792
- 'revised in any accompanying update, and never expect the primary revision to move when only options ' +
3793
- "changed. A round the user flips back to 'reopened' is open feedback again: address it on the next pass " +
3794
- `and re-resolve. Keep polling. Do NOT call ${spec.tool} ` +
3795
- 'again for iterations of this same design.'
3796
- : `The user has been asked to review the ${spec.artifactNoun}. They send CUMULATIVE feedback rounds — any number, any ` +
3797
- "time, without waiting for you: poll get_task and read them from its `feedback` list (oldest first), each " +
3798
- 'tagged with the artifact_revision it critiques so you know which version the notes are about. This ' +
3799
- 'decision is the approval gate only: it stays open across rounds and decides ONLY when the user approves ' +
3800
- "— once its state is decided, selected_options[0] is 'approve' and answer_note may carry a final comment. " +
3801
- `To act on feedback, revise this ${spec.artifactNoun} IN PLACE with update_artifact (its revision bumps ` +
3802
- 'automatically, so later rounds name the new version), then call resolve_feedback with the ids of the ' +
3803
- 'rounds that revision actually addressed and the NEW revision number — only rounds you genuinely acted ' +
3804
- "on. A round the user flips back to 'reopened' is open feedback again: address it on the next pass and " +
3805
- `re-resolve. Keep polling. Do NOT call ${spec.tool} ` +
3806
- 'again for iterations of this same design.',
3807
- };
3808
- if (attributionWarnings.length)
3809
- payload.attribution_warning = attributionWarnings.join(' ');
3810
- return textResult(payload);
3811
- }
3812
- catch (err) {
3813
- return errorResult(`${spec.tool} failed: ${err.message}`);
3814
- }
3400
+ async function presentationHandler(client, userId, session, args, spec, connectionTodoId = null) {
3401
+ if (!session)
3402
+ return errorResult(`${spec.tool} needs an open work session call begin_work first.`);
3403
+ const noItem = requiresWorkItem(session, spec.tool);
3404
+ if (noItem)
3405
+ return noItem;
3406
+ return presentDesign(client, userId, session.taskId, {
3407
+ ...args, title: redactSecrets(connectionTodoId, args.title),
3408
+ ...(args.html === undefined ? {} : { html: redactSecrets(connectionTodoId, args.html) }),
3409
+ ...(!Array.isArray(args.options) ? {} : { options: args.options.map(option => ({ label: option?.label, html: redactSecrets(connectionTodoId, option?.html ?? '') })) }),
3410
+ }, spec, async (kind, id) => {
3411
+ await must(client.from('cliv2_agent_outputs').insert({ user_id: userId, session_id: session.sessionId,
3412
+ kind, product_id: id, todo_id: connectionTodoId }));
3413
+ }, async (review) => {
3414
+ const id = await must(client.rpc('cliv2_ask_question', {
3415
+ p_session: session.sessionId, p_category: 'wireframe_review', p_context: review.title,
3416
+ p_question: review.question, p_answer_mode: 'single_select', p_options: review.options,
3417
+ p_related_artifact: review.artifactId,
3418
+ }));
3419
+ if (!id)
3420
+ throw new Error('Review request insert returned no decision id.');
3421
+ return id;
3422
+ });
3815
3423
  }
3816
3424
  /**
3817
3425
  * present_wireframes (feature 09) — 1..N lo-fi HTML wireframes (kind 'ui') or
@@ -3877,66 +3485,6 @@ connectionTodoId = null) {
3877
3485
  artifactNoun: 'mock',
3878
3486
  }, connectionTodoId);
3879
3487
  }
3880
- /** Loose UUID shape check (any version), so a malformed id gets a clean tool
3881
- * error naming it instead of a Postgres `invalid input syntax for type uuid`
3882
- * failing the whole batch. */
3883
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3884
- /**
3885
- * Mark feedback rounds resolved by a revision (feature 09, Phase 5 — hybrid
3886
- * resolution). After update_artifact ships a revision, the agent calls this
3887
- * with the ids of the rounds that revision actually addressed and the NEW
3888
- * revision number; each round flips to status 'resolved' with
3889
- * resolved_in_revision = revision. The user can flip a round back to
3890
- * 'reopened' in the web when the change wasn't material, and this same call
3891
- * re-resolves it on the next pass. This is the ONE write the CLI makes to
3892
- * `artifact_feedback`: a plain UPDATE as the user through RLS
3893
- * `artifact_feedback_update` (task access + the 'comment' grant) and the
3894
- * column-level grant on the four resolution columns. The DB's status-only
3895
- * guard keeps round bodies immutable and stamps status_changed_at/_by itself
3896
- * (never trusted from here), so only status + resolved_in_revision are sent.
3897
- *
3898
- * The UPDATE's `.in(...)` silently skips ids that don't exist, aren't visible
3899
- * under RLS, or belong to a task the user can't touch — so the result rows are
3900
- * compared against the request and ANY shortfall is an error that lists the
3901
- * missing ids (and the ones that DID update, since those flips committed).
3902
- * Requires an open session, like the sibling review tools. Exported (like
3903
- * presentWireframesHandler) so the self-check can exercise it with a stub
3904
- * client.
3905
- */
3906
- export async function resolveFeedbackHandler(client, session, args) {
3907
- try {
3908
- if (!session) {
3909
- return errorResult('resolve_feedback needs an open work session — call begin_work first.');
3910
- }
3911
- if (!Array.isArray(args.feedback_ids) || args.feedback_ids.length === 0) {
3912
- return errorResult('resolve_feedback requires feedback_ids (at least one feedback round id).');
3913
- }
3914
- if (!Number.isInteger(args.revision) || args.revision < 1) {
3915
- return errorResult('resolve_feedback requires revision (a positive integer — the artifact revision that addressed the rounds).');
3916
- }
3917
- const ids = [...new Set(args.feedback_ids.map((id) => id.trim()))];
3918
- const malformed = ids.filter((id) => !UUID_RE.test(id));
3919
- if (malformed.length) {
3920
- return errorResult(`resolve_feedback: not valid feedback round id(s): ${malformed.join(', ')}.`);
3921
- }
3922
- const rows = (await must(client
3923
- .from('artifact_feedback')
3924
- .update({ status: 'resolved', resolved_in_revision: args.revision })
3925
- .in('id', ids)
3926
- .select('id'))) ?? [];
3927
- const updated = rows.map((row) => row.id);
3928
- if (updated.length !== ids.length) {
3929
- const missing = ids.filter((id) => !updated.includes(id));
3930
- return errorResult(`resolve_feedback: ${updated.length} of ${ids.length} round(s) updated. Not found (or not accessible): ` +
3931
- `${missing.join(', ')}.${updated.length ? ` Already marked resolved: ${updated.join(', ')}.` : ''} ` +
3932
- 'Check the ids against the most recent get_task `feedback` list.');
3933
- }
3934
- return textResult({ resolved: updated, revision: args.revision });
3935
- }
3936
- catch (err) {
3937
- return errorResult(`resolve_feedback failed: ${err.message}`);
3938
- }
3939
- }
3940
3488
  /**
3941
3489
  * Record the user's answer to an OPEN decision on the work item (feature 05 / D4 —
3942
3490
  * the run-free twin of v1's `record_user_input`). Unlike v1, this does NOT go through a
@@ -5341,40 +4889,6 @@ export async function readSkillFileHandler(client, args) {
5341
4889
  return errorResult(`read_skill_file failed: ${errorMessage(err)}`);
5342
4890
  }
5343
4891
  }
5344
- // ---------------------------------------------------------------------------
5345
- // Project context (feature 13a, Phase 3) — `get_project_context`.
5346
- //
5347
- // READ-ONLY, and read-only over a V1-OWNED PRODUCT TABLE. `public.project_documents`
5348
- // is the table the web app writes under Project settings → Project context (Phase 1)
5349
- // and on a codebase page (Phase 2). AGENTS.md § "Database schema naming" allows
5350
- // exactly this: the v2 surface may `select` shared product data through the signed-in
5351
- // user's RLS, as the web app does; what it may not do is WRITE outside `cliv2_*` or
5352
- // hold its own state there. This tool writes nothing and adds no `cliv2_*` row.
5353
- //
5354
- // SCOPE IS FILTERED IN THE QUERY, NOT IN JS. A document is project-scoped when
5355
- // `codebase_id is null` and codebase-scoped when `codebase_id = <id>` — the whole
5356
- // model, per 20260726220000_project_documents_codebase_scope.sql. Both scopes are
5357
- // read with their own predicate (`.is('codebase_id', null)` / `.eq('codebase_id', id)`)
5358
- // so a codebase document can NEVER reach a project-only response, whatever the
5359
- // caller passed. Filtering after the fact would make that a code-path property
5360
- // instead of a query property; both earlier phases were reviewed on this point.
5361
- //
5362
- // NO OPEN SESSION IS REQUIRED when `task_id` is passed. That is a deliberate
5363
- // departure from the '<tool> needs an open work session — call begin_work first.'
5364
- // gate every WRITE tool carries: reading a project's standing context BEFORE
5365
- // opening a work session is a legitimate and expected flow (an agent orienting
5366
- // itself), and the tool mutates nothing that would need attributing to a session.
5367
- // With neither a session nor a task_id there is genuinely no project to resolve,
5368
- // and the refusal is phrased as the instruction (the doctrine at registerTool):
5369
- // it names both ways out.
5370
- // ---------------------------------------------------------------------------
5371
- /** The five types `public.project_documents.type` admits, verbatim from the
5372
- * migration's check constraint and the web composer's Select. */
5373
- const PROJECT_DOCUMENT_TYPES = ['instructions', 'architecture', 'design', 'conventions', 'other'];
5374
- /** Phase 4's `propose_project_context` resolves the project exactly as Phase 3's
5375
- * reader does, so the refusal copy is written once and parameterized by the
5376
- * tool doing the asking rather than duplicated and left to drift. */
5377
- const noProjectContextError = (tool) => `${tool} needs to know which project. Call begin_work first, or pass task_id.`;
5378
4892
  /** The empty-state note, verbatim from the approved transcript. It says where the
5379
4893
  * documents come from, so an agent that finds none doesn't try to create one —
5380
4894
  * there is no write path, by design.
@@ -5501,11 +5015,6 @@ async function fetchProjectDocumentTypes(client, projectId, codebaseId) {
5501
5015
  return [...new Set((rows ?? []).map((row) => row.type))];
5502
5016
  }
5503
5017
  const jsonBytes = (value) => Buffer.byteLength(JSON.stringify(value, null, 2), 'utf8');
5504
- /** CHARACTERS, meaning code points — what `content_length` claims to be and what a
5505
- * person counting emoji would say. `String.length` is UTF-16 code units, which
5506
- * double-counts every astral character (60,000 emoji would report 120,000).
5507
- * The surrogate test short-circuits the allocating spread for the ordinary case. */
5508
- const countCharacters = (text) => /[\uD800-\uDFFF]/.test(text) ? [...text].length : text.length;
5509
5018
  /**
5510
5019
  * Fit the shaped documents into MAX_RESPONSE_BYTES, in order, cutting content
5511
5020
  * first and dropping the tail only when even the metadata will not fit.
@@ -5636,115 +5145,6 @@ function fitToBudget(envelope, documents, extraNotes) {
5636
5145
  }
5637
5146
  return { documents: fitted, truncated, omitted: 0 };
5638
5147
  }
5639
- /**
5640
- * PROJECT RESOLUTION, shared by `get_project_context` (Phase 3) and
5641
- * `propose_project_context` (Phase 4). Both paths reduce to one task id — `task_id` when given,
5642
- * otherwise the open session's task (the session registry carries `taskId`, not a
5643
- * project) — and the project is read off that task. Agents therefore never have to
5644
- * find, or be trusted with, a project id. An archived or deleted task resolves to
5645
- * nothing and is refused by id, which is also what happens when a session's task
5646
- * was archived or deleted after begin_work.
5647
- *
5648
- * The task id is UUID-shape-checked BEFORE the read, exactly as resolve_feedback
5649
- * does, so `{ task_id: "task-1" }` gets a clean tool error naming it instead of a
5650
- * Postgres `invalid input syntax for type uuid`. A `task_id` that was supplied but
5651
- * is blank is refused rather than falling through to the open session's task —
5652
- * silently answering about a DIFFERENT task and reporting success is the worst
5653
- * possible reading of " ".
5654
- *
5655
- * The project's NAME rides along on the task select (`projects(name)`) rather than
5656
- * costing a second round-trip; the FK makes the embed non-null, so there is no
5657
- * "no project for task" state to write copy for.
5658
- */
5659
- async function resolveContextProject(client, session, taskIdArg, tool) {
5660
- if (taskIdArg !== undefined && taskIdArg.trim() === '') {
5661
- return {
5662
- ok: false,
5663
- error: errorResult(`${tool}: task_id was blank. Pass a task id, or omit it to use the open session's task.`),
5664
- };
5665
- }
5666
- const taskId = taskIdArg?.trim() || session?.taskId;
5667
- if (!taskId)
5668
- return { ok: false, error: errorResult(noProjectContextError(tool)) };
5669
- if (!UUID_RE.test(taskId)) {
5670
- return { ok: false, error: errorResult(`${tool}: not a valid task id: "${taskId}".`) };
5671
- }
5672
- const task = await must(client
5673
- .from('tasks')
5674
- .select('project_id, projects(name)')
5675
- .eq('id', taskId)
5676
- .is('archived_at', null)
5677
- .maybeSingle());
5678
- if (!task)
5679
- return { ok: false, error: errorResult(`${tool}: no task found for id "${taskId}".`) };
5680
- // PostgREST returns a to-one embed as an object, but has returned an array
5681
- // for the same shape across versions, so both are unwrapped. A MISSING name
5682
- // is not defaulted to '': `tasks.project_id` is a non-null FK, so a blank
5683
- // project in the envelope could only mean the read did not return what it
5684
- // was asked for, and answering with an empty project name is a silent wrong
5685
- // value in a field the agent uses to know which project it is reading.
5686
- const embedded = Array.isArray(task.projects) ? (task.projects[0] ?? null) : task.projects;
5687
- if (!embedded?.name) {
5688
- return {
5689
- ok: false,
5690
- error: errorResult(`${tool}: could not read the project for task "${taskId}". Try again, ` +
5691
- 'and if it keeps happening report it — a task always has a project.'),
5692
- };
5693
- }
5694
- return { ok: true, value: { id: task.project_id, name: embedded.name } };
5695
- }
5696
- /**
5697
- * CODEBASE MATCHING is over the project's own `cliv2_codebases` rows only, on
5698
- * either the display name or the canonical `host/path` git remote (case-insensitive;
5699
- * a raw remote URL is canonicalized with the same `hostedRemoteIdentity` the CLI
5700
- * uses to register one, so `https://github.com/acme/web.git` matches `github.com/acme/web`).
5701
- * A miss names the known codebases rather than just refusing — the agent can retry
5702
- * without a second call.
5703
- *
5704
- * A NAME CAN BE AMBIGUOUS. `cliv2_codebases` is unique on `(project_id,
5705
- * git_remote_url)` and NOT on `name`, so one project holding `github.com/acme/web`
5706
- * and `gitlab.com/acme/web` — both named "web" — is normal. Taking the first hit
5707
- * would silently answer about whichever was created first, with nothing in the
5708
- * response for the agent to notice. Every match is collected and more than one is
5709
- * an error listing the candidates by remote, phrased like the unknown-codebase
5710
- * miss: it tells the agent exactly what to pass instead.
5711
- */
5712
- async function resolveContextCodebase(client, projectId, wanted, tool) {
5713
- const rows = (await must(client
5714
- .from('cliv2_codebases')
5715
- .select('id, name, git_remote_url')
5716
- .eq('project_id', projectId)
5717
- .order('created_at', { ascending: true }))) ?? [];
5718
- // `normalizeRemoteUrl` already lowercases (src/git-remote.ts:32) and is
5719
- // idempotent, and `git_remote_url` is STORED canonical (src/codebases.ts:87),
5720
- // so the identity comparison subsumes any raw-value comparison. Name matching
5721
- // stays case-insensitive.
5722
- const needle = wanted.toLowerCase();
5723
- const identity = hostedRemoteIdentity(wanted) ?? null;
5724
- const found = rows.filter((row) => row.name.toLowerCase() === needle ||
5725
- (identity !== null && row.git_remote_url.toLowerCase() === identity));
5726
- if (found.length === 0) {
5727
- return {
5728
- ok: false,
5729
- error: errorResult(`${tool}: no codebase named "${wanted}" in this project. ` +
5730
- (rows.length
5731
- ? // Deduped: `name` has no unique constraint (only (project_id,
5732
- // git_remote_url) does), so two codebases can share one name.
5733
- // Listing it twice tells the agent nothing and reads as a bug.
5734
- `Known codebases: ${[...new Set(rows.map((row) => row.name))].join(', ')}.`
5735
- : 'This project has no codebases yet.')),
5736
- };
5737
- }
5738
- if (found.length > 1) {
5739
- return {
5740
- ok: false,
5741
- error: errorResult(`${tool}: more than one codebase named "${wanted}" in this project. ` +
5742
- `Matching git remotes: ${found.map((row) => row.git_remote_url).join(', ')}. ` +
5743
- 'Pass the git remote instead of the name.'),
5744
- };
5745
- }
5746
- return { ok: true, value: { id: found[0].id, name: found[0].name } };
5747
- }
5748
5148
  /**
5749
5149
  * Read the context documents for the project a task belongs to, optionally also
5750
5150
  * those of one of its codebases, optionally narrowed to some types. Project and
@@ -5884,340 +5284,6 @@ async function unnarrowedEmptyNote(client, projectId) {
5884
5284
  }
5885
5285
  return NO_PROJECT_DOCUMENTS_NOTE;
5886
5286
  }
5887
- export async function getDocumentHandler(client, args) {
5888
- const id = typeof args.id === 'string' ? args.id.trim() : '';
5889
- if (!id) {
5890
- return errorResult('get_document requires id — the id of the document to read, as the web app’s “Copy for agent” ' +
5891
- 'button pastes it.');
5892
- }
5893
- // Shape-checked once, here at the boundary, exactly as resolveContextProject
5894
- // does with task_id: without it a mistyped id comes back as a Postgres
5895
- // "invalid input syntax for type uuid", which reads like a bug in the tool.
5896
- if (!UUID_RE.test(id)) {
5897
- return errorResult(`get_document: not a valid document id: "${id}".`);
5898
- }
5899
- try {
5900
- // Context documents first, then instructions. RLS scopes both reads, so a
5901
- // row the caller may not see is simply absent and falls through to the
5902
- // not-found below.
5903
- const documents = await must(client
5904
- .from('project_documents')
5905
- .select('title, content, codebase_id, type')
5906
- .eq('id', id));
5907
- const document = (documents ?? [])[0];
5908
- if (document)
5909
- return textResult(shapeDocument(document));
5910
- const instructions = await must(client.from('agent_instructions').select('title, content, codebase_id').eq('id', id));
5911
- const instruction = (instructions ?? [])[0];
5912
- if (instruction) {
5913
- return textResult({
5914
- ...shapeDocument(instruction),
5915
- // Said plainly, so an agent that fetched one does not conclude these are
5916
- // reference material it may choose to consult: it already has them.
5917
- note: 'This is an agent instruction. Every instruction on this project is already delivered in ' +
5918
- 'your prompt — reading one here does not make it optional.',
5919
- });
5920
- }
5921
- return errorResult(`get_document: no document with id ${id}. It may have been deleted, or belong to a project you ` +
5922
- 'cannot see. Call get_project_context to read the documents on the project you are working.');
5923
- }
5924
- catch (err) {
5925
- return errorResult(`get_document failed: ${errorMessage(err)}`);
5926
- }
5927
- }
5928
- /** The one shape both tables report in, so a caller never has to branch on
5929
- * which table answered. `scope` is what `codebase_id` MEANS — null is the
5930
- * project's own document, a value is that codebase's. */
5931
- function shapeDocument(row) {
5932
- return {
5933
- title: row.title,
5934
- ...(row.type ? { type: row.type } : {}),
5935
- scope: row.codebase_id === null ? 'project' : 'codebase',
5936
- content: row.content,
5937
- };
5938
- }
5939
- // ---------------------------------------------------------------------------
5940
- // Project context (feature 13a, Phase 4) — `propose_project_context`.
5941
- //
5942
- // THE ONLY WRITE THIS FEATURE'S CLI SURFACE MAKES, AND IT CREATES NO DOCUMENT.
5943
- // An agent that has just read the repo it is working in hands over a batch of
5944
- // PROPOSALS; approval happens in the web app, one by one or all at once, and
5945
- // only an accept creates a `public.project_documents` row. So this tool writes
5946
- // `public.cliv2_context_proposals` — a `cliv2_*` table, per AGENTS.md
5947
- // § "Database schema naming": v2 may READ shared product data through the
5948
- // user's RLS (that is what Phase 3 does), but its own state and every write it
5949
- // makes live behind the `cliv2_` prefix. `project_id`, `codebase_id` and
5950
- // `scanned_codebase_id` are held BY VALUE with no FK, for the same reason.
5951
- //
5952
- // SCOPE IS PER PROPOSAL, NOT PER CALL. A settled user ruling: one scan produces
5953
- // both project-scoped and codebase-scoped proposals at once and they are
5954
- // reviewed together in project settings. `scope: "project"` stores
5955
- // `codebase_id = null`; `scope: "codebase"` stores the SCANNED codebase's id.
5956
- // There is deliberately no way to target a different codebase — the agent read
5957
- // one repo, and a proposal aimed somewhere it never looked is a claim it cannot
5958
- // support.
5959
- //
5960
- // A RE-SCAN REPLACES, AND AN EMPTY SCAN CLEARS. Before inserting, this user's
5961
- // pending rows for (project_id, scanned_codebase_id) are deleted. A scan
5962
- // reports what the repo holds NOW; it carries no memory of past answers — so a
5963
- // scan that finds NOTHING is a real finding and is accepted: it runs the same
5964
- // replace-delete, inserts nothing, and reports what it cleared. Refusing it
5965
- // would mean a repo whose context files were deleted could never get back to
5966
- // "no proposals", which the review surface models as a state (user ruling,
5967
- // 2026-07-27). `codebase` stays required either way — a scan always names the
5968
- // repo it read. The user ruled explicitly
5969
- // that rejecting a proposal DISCARDS it and stores nothing — there is no
5970
- // rejected list and no "already rejected" state to suppress against, and a
5971
- // later scan is free to propose the same thing again (ux.md § Corrections,
5972
- // Phase 4 / 2026-07-27). Anything that looked like suppression here would be
5973
- // inventing that state.
5974
- //
5975
- // NOTHING IS EVER SILENTLY TRUNCATED. The batch is bounded by a proposal COUNT
5976
- // and a total CHARACTER budget, and a batch over either is REFUSED with the
5977
- // limit and the actual figure named. Phase 3's log is a catalogue of what
5978
- // silent truncation costs; a write path has an easier answer than a read path —
5979
- // refuse, and let the agent send a smaller batch — so it takes it.
5980
- //
5981
- // NO ABSOLUTE PATH REACHES THE CLOUD — IN `source_path`. That column, and not
5982
- // the whole row, is what this rule covers, because `source_path` is
5983
- // PRODUCT-EMITTED: the agent derives it from the filesystem it just walked, and
5984
- // it is rendered in `mono` on every proposal card by hosted browser JS, where
5985
- // this product hard-enforces that absolute local paths never appear (AGENTS.md;
5986
- // the contract test at web/src/lib/hosted-path-privacy.contract.test.ts).
5987
- // `content` is deliberately NOT policed the same way: it is AUTHORED prose that
5988
- // the agent read verbatim out of the repo, a hand-typed document may legally
5989
- // contain any text at all, and a check there would refuse AGENTS.md itself —
5990
- // the very document this feature exists to import (user ruling, 2026-07-27).
5991
- // The table cannot police the path either — no check constraint tells a legal
5992
- // relative path from an absolute one across POSIX, Windows drive-letter,
5993
- // drive-relative and UNC forms without knowing the platform — so this tool
5994
- // does, and it REFUSES rather than rewriting: silently rewriting a path the
5995
- // agent sent is how a proposal ends up pointing at a file that isn't there.
5996
- // ---------------------------------------------------------------------------
5997
- /** The scopes a proposal may carry. `project` stores `codebase_id = null`;
5998
- * `codebase` stores the scanned codebase's id. */
5999
- const PROPOSAL_SCOPES = ['project', 'codebase'];
6000
- /** `public.project_documents.title` is `length(title) <= 100` (Phase 1 §1).
6001
- * Refused HERE so the agent learns at propose time, rather than the user
6002
- * discovering at accept time that a proposal can never become a document. */
6003
- const MAX_PROPOSAL_TITLE_CHARS = 100;
6004
- /** How many proposals one scan may hand over. A review surface is a human
6005
- * reading cards; a hundred of them is not a review. Refused, never trimmed. */
6006
- const MAX_PROPOSALS = 50;
6007
- /** Total characters across the whole batch (content + title + reason +
6008
- * source_path). `content` has no length cap on `project_documents` and gets
6009
- * none here — a bound this table enforced but the document table did not would
6010
- * make a legal document un-acceptable — so the BATCH is what is bounded, which
6011
- * is the thing that actually protects the write. */
6012
- const MAX_PROPOSAL_BATCH_CHARS = 400_000;
6013
- /** A `..` segment under either separator. A relative path that climbs out of
6014
- * the scanned repo names a file the scan had no business reading, and it is
6015
- * refused for the same privacy reason an absolute path is. */
6016
- const ESCAPING_PATH_RE = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
6017
- /** Where the user reviews what was proposed. The agent's job ends at proposing,
6018
- * so the success result has to say this in prose it can relay verbatim. */
6019
- const REVIEW_LOCATION = 'Project settings → Project context';
6020
- /**
6021
- * Validate one proposal into the row it will become, or refuse it by index AND
6022
- * by title — an agent that sent twelve proposals needs to know WHICH one, and
6023
- * an index alone is a poor handle when it is re-reading its own array.
6024
- */
6025
- function validateProposal(proposal, index, project, scanned) {
6026
- const at = `proposal ${index + 1}`;
6027
- const refuse = (message) => ({
6028
- ok: false,
6029
- error: errorResult(`propose_project_context: ${at} ${message}`),
6030
- });
6031
- if (!proposal || typeof proposal !== 'object') {
6032
- return refuse('is not an object. Each proposal needs title, type, content, source_path, reason and scope.');
6033
- }
6034
- const title = typeof proposal.title === 'string' ? proposal.title.trim() : '';
6035
- if (!title)
6036
- return refuse('has no title.');
6037
- if (countCharacters(title) > MAX_PROPOSAL_TITLE_CHARS) {
6038
- return refuse(`has a ${countCharacters(title)}-character title; the limit is ${MAX_PROPOSAL_TITLE_CHARS}. ` +
6039
- 'Shorten it — a document title is a heading, not a summary.');
6040
- }
6041
- if (!PROJECT_DOCUMENT_TYPES.includes(proposal.type)) {
6042
- return refuse(`("${title}") has type "${String(proposal.type)}", which is not a context document type. ` +
6043
- `Use one of: ${PROJECT_DOCUMENT_TYPES.join(', ')}.`);
6044
- }
6045
- if (!PROPOSAL_SCOPES.includes(proposal.scope)) {
6046
- return refuse(`("${title}") has scope "${String(proposal.scope)}". Use "project" for context that applies to the ` +
6047
- `whole project, or "codebase" for context that belongs to "${scanned.name}" alone.`);
6048
- }
6049
- const content = typeof proposal.content === 'string' ? proposal.content : '';
6050
- if (content.trim() === '') {
6051
- return refuse(`("${title}") has no content. Propose only what you actually read — an empty document helps nobody.`);
6052
- }
6053
- const reason = typeof proposal.reason === 'string' ? proposal.reason.trim() : '';
6054
- if (!reason) {
6055
- return refuse(`("${title}") has no reason. Every proposal carries one line saying why this belongs in the ` +
6056
- "project's context, so the user can judge the judgement and not just the text.");
6057
- }
6058
- const rawPath = typeof proposal.source_path === 'string' ? proposal.source_path.trim() : '';
6059
- if (!rawPath) {
6060
- return refuse(`("${title}") has no source_path. Name the file in the repo this content came from.`);
6061
- }
6062
- if (ABSOLUTE_PATH_RE.test(rawPath)) {
6063
- return refuse(`("${title}") has an absolute source_path: "${rawPath}". Pass it relative to the root of ` +
6064
- `"${scanned.name}" (e.g. "docs/architecture.md"). Absolute local paths must never leave this machine.`);
6065
- }
6066
- if (ESCAPING_PATH_RE.test(rawPath)) {
6067
- return refuse(`("${title}") has a source_path that climbs out of the repo: "${rawPath}". Propose only files ` +
6068
- `inside "${scanned.name}".`);
6069
- }
6070
- // A leading "./" is the one rewrite made, because it changes nothing about
6071
- // which file is named and "./AGENTS.md" renders as noise on a card.
6072
- const sourcePath = rawPath.replace(/^\.\//, '');
6073
- return {
6074
- ok: true,
6075
- value: {
6076
- project_id: project.id,
6077
- codebase_id: proposal.scope === 'project' ? null : scanned.id,
6078
- scanned_codebase_id: scanned.id,
6079
- title,
6080
- type: proposal.type,
6081
- content,
6082
- source_path: sourcePath,
6083
- reason,
6084
- },
6085
- };
6086
- }
6087
- /**
6088
- * Hand over a batch of proposed context documents for a repo the agent has read.
6089
- *
6090
- * WRITES PENDING ROWS ONLY. No `project_documents` row is created here under any
6091
- * argument — the web app's accept does that. The success result therefore ends
6092
- * by telling the agent where the human reviews them, because that is the only
6093
- * remaining step and the agent cannot take it.
6094
- *
6095
- * REQUIRES NO OPEN SESSION when `task_id` is passed, matching Phase 3. This is a
6096
- * write, and every other write tool in this file demands a session — but the
6097
- * session exists to ATTRIBUTE work to a task, and a proposal is attributed to a
6098
- * project and a codebase, neither of which the session supplies. Scanning a repo
6099
- * before opening a work session is the ordinary flow (an agent orienting itself),
6100
- * and refusing it would buy nothing.
6101
- *
6102
- * THE REPLACE AND THE INSERT ARE NOT ATOMIC. Two statements, no transaction —
6103
- * PostgREST has none to offer, and an RPC to get one would put v2 logic in the
6104
- * database for a case that cannot corrupt anything: the delete is scoped to this
6105
- * user's rows for exactly this (project, scanned codebase), so a failure between
6106
- * the two leaves the previous scan's proposals gone and the new ones unwritten.
6107
- * The recovery is to run the scan again, which is precisely what this tool does.
6108
- * The insert failing after the delete is reported as a failure, so nothing is
6109
- * silently lost.
6110
- */
6111
- export async function proposeProjectContextHandler(client, session, args) {
6112
- try {
6113
- const resolvedProject = await resolveContextProject(client, session, args.task_id, 'propose_project_context');
6114
- if (!resolvedProject.ok)
6115
- return resolvedProject.error;
6116
- const project = resolvedProject.value;
6117
- // The scanned codebase is REQUIRED — every proposal, project-scoped ones
6118
- // included, records which repo the claim came from, and a project-scoped
6119
- // proposal with no provenance is unreviewable.
6120
- const wanted = args.codebase?.trim();
6121
- if (!wanted) {
6122
- return errorResult('propose_project_context: name the codebase you scanned in `codebase` (its name or git remote). ' +
6123
- 'Every proposal records which repo it came from.');
6124
- }
6125
- const resolvedCodebase = await resolveContextCodebase(client, project.id, wanted, 'propose_project_context');
6126
- if (!resolvedCodebase.ok)
6127
- return resolvedCodebase.error;
6128
- const scanned = resolvedCodebase.value;
6129
- // AN EMPTY BATCH IS A REAL SCAN RESULT, NOT A MISTAKE. `[]` says the repo
6130
- // holds no standing context now, and it clears whatever an earlier scan left
6131
- // pending — without it there is no path from a real scan back to "no
6132
- // proposals" (user ruling, 2026-07-27). A MISSING or non-array `proposals`
6133
- // is still a malformed call, and is refused so the empty-scan meaning stays
6134
- // something the agent has to state on purpose.
6135
- const proposals = args.proposals;
6136
- if (!Array.isArray(proposals)) {
6137
- return errorResult('propose_project_context: `proposals` must be an array. Send the documents you found, or send ' +
6138
- '`[]` to report that this repo holds no standing context — an empty scan is a finding, and it ' +
6139
- 'clears anything an earlier scan left waiting for review.');
6140
- }
6141
- if (proposals.length > MAX_PROPOSALS) {
6142
- return errorResult(`propose_project_context: ${proposals.length} proposals is more than the limit of ${MAX_PROPOSALS}. ` +
6143
- 'Nothing was written. Propose the documents that carry standing context for the whole team, ' +
6144
- 'not every file you read.');
6145
- }
6146
- const rows = [];
6147
- for (const [index, proposal] of proposals.entries()) {
6148
- const validated = validateProposal(proposal, index, project, scanned);
6149
- if (!validated.ok)
6150
- return validated.error;
6151
- rows.push(validated.value);
6152
- }
6153
- // Measured AFTER per-proposal validation so the agent hears about a broken
6154
- // proposal before it hears about the batch's size — the specific fault is
6155
- // more useful than the aggregate one.
6156
- const totalChars = rows.reduce((total, row) => total +
6157
- countCharacters(row.content) +
6158
- countCharacters(row.title) +
6159
- countCharacters(row.reason) +
6160
- countCharacters(row.source_path), 0);
6161
- if (totalChars > MAX_PROPOSAL_BATCH_CHARS) {
6162
- return errorResult(`propose_project_context: this batch is ${totalChars.toLocaleString('en-US')} characters, over the ` +
6163
- `limit of ${MAX_PROPOSAL_BATCH_CHARS.toLocaleString('en-US')}. Nothing was written, and nothing was ` +
6164
- 'truncated. Send the batch in smaller parts, or drop the documents that are too long to be ' +
6165
- 'standing context.');
6166
- }
6167
- // A RE-SCAN REPLACES: this user's pending rows for this (project, scanned
6168
- // codebase) go first. RLS scopes the delete to the user; the two eq filters
6169
- // scope it to this scan's subject, so another codebase's pending proposals —
6170
- // and another user's — are untouched. `.select('id')` makes the replaced
6171
- // COUNT knowable, which is what the result reports instead of leaving the
6172
- // agent to guess whether its earlier scan is still standing.
6173
- const replaced = (await must(client
6174
- .from('cliv2_context_proposals')
6175
- .delete()
6176
- .eq('project_id', project.id)
6177
- .eq('scanned_codebase_id', scanned.id)
6178
- .select('id'))) ?? [];
6179
- // Nothing to insert on an empty scan — the delete above WAS the whole call.
6180
- if (rows.length)
6181
- await must(client.from('cliv2_context_proposals').insert(rows));
6182
- const projectScoped = rows.filter((row) => row.codebase_id === null).length;
6183
- const codebaseScoped = rows.length - projectScoped;
6184
- const noun = rows.length === 1 ? 'proposal' : 'proposals';
6185
- const sentences = [];
6186
- if (rows.length === 0) {
6187
- sentences.push(`Nothing was found: this scan of "${scanned.name}" turned up no standing context to propose for ` +
6188
- `"${project.name}", so nothing is waiting for review.`);
6189
- sentences.push(replaced.length
6190
- ? `It cleared ${replaced.length} proposal${replaced.length === 1 ? '' : 's'} from an earlier scan ` +
6191
- `of "${scanned.name}", which no longer stand — a scan reports what the repo holds now.`
6192
- : 'There was nothing pending to clear.');
6193
- sentences.push('No document was touched: accepted context documents are not proposals and are unaffected. Tell ' +
6194
- 'the user the repo holds no standing context worth importing.');
6195
- }
6196
- else {
6197
- sentences.push(`${rows.length} context document ${noun} ${rows.length === 1 ? 'is' : 'are'} now waiting for review ` +
6198
- `in the web app, under ${REVIEW_LOCATION} for "${project.name}".`);
6199
- if (replaced.length) {
6200
- sentences.push(`This re-scan replaced ${replaced.length} proposal${replaced.length === 1 ? '' : 's'} from an ` +
6201
- `earlier scan of "${scanned.name}" — a scan reports what the repo holds now.`);
6202
- }
6203
- sentences.push('Nothing has been created yet: the user accepts or rejects each one there, and only an accept ' +
6204
- 'makes it a context document. Tell them where to look.');
6205
- }
6206
- return textResult({
6207
- project: project.name,
6208
- scanned_codebase: scanned.name,
6209
- proposed: rows.length,
6210
- project_scoped: projectScoped,
6211
- codebase_scoped: codebaseScoped,
6212
- replaced: replaced.length,
6213
- review_in: REVIEW_LOCATION,
6214
- instruction: sentences.join(' '),
6215
- });
6216
- }
6217
- catch (err) {
6218
- return errorResult(`propose_project_context failed: ${errorMessage(err)}`);
6219
- }
6220
- }
6221
5287
  // ---------------------------------------------------------------------------
6222
5288
  // Client context intake (feature 24c, Slice 1) — `get_client_context` /
6223
5289
  // `propose_client_context`.
@@ -8293,7 +7359,7 @@ export async function listStepsHandler(client, args) {
8293
7359
  }
8294
7360
  const stageRows = (await must(client
8295
7361
  .from('cliv2_stages')
8296
- .select('id, title, position, source_kind, source_label, source_ref, created_at')
7362
+ .select('id, title, position, source_kind, source_label, source_ref, source_workflow_ref, created_at')
8297
7363
  .eq('work_item_id', workItemId)
8298
7364
  .order('position', { ascending: true })
8299
7365
  .order('created_at', { ascending: true }))) ?? [];
@@ -8308,6 +7374,15 @@ export async function listStepsHandler(client, args) {
8308
7374
  // THE shared answer — the RPC computes it over the same rows under the
8309
7375
  // same RLS, and what it said is what gets returned (drift rule).
8310
7376
  const upNext = await must(client.rpc('cliv2_steps_up_next', { p_work_item_id: workItemId }));
7377
+ const documentIds = [...new Set(stageRows.filter(stage => stage.source_workflow_ref && stage.source_ref).map(stage => stage.source_ref))];
7378
+ const documents = documentIds.length
7379
+ ? await must(client.from('cliv2_workflow_stages').select('id, body').in('id', documentIds))
7380
+ : [];
7381
+ const stageDocuments = new Map((documents ?? []).map(document => [document.id, document.body]));
7382
+ for (const id of documentIds) {
7383
+ if (!stageDocuments.has(id))
7384
+ throw new Error(`The saved workflow stage ${id} is unavailable.`);
7385
+ }
8311
7386
  const stages = stageRows.map((stage) => ({
8312
7387
  id: stage.id,
8313
7388
  title: stage.title,
@@ -8315,6 +7390,7 @@ export async function listStepsHandler(client, args) {
8315
7390
  source_kind: stage.source_kind,
8316
7391
  source_label: stage.source_label,
8317
7392
  source_ref: stage.source_ref,
7393
+ ...(stage.source_workflow_ref ? { workflow_id: stage.source_workflow_ref, body: stageDocuments.get(stage.source_ref) } : {}),
8318
7394
  steps: stepRows
8319
7395
  .filter((step) => step.stage_id === stage.id)
8320
7396
  .map(({ stage_id: _stageId, created_at: _createdAt, ...step }) => step),
@@ -8875,8 +7951,10 @@ export async function buildWorkflowHandler(client, args, fromAgent) {
8875
7951
  if (typeof stage.description === 'string') {
8876
7952
  prose.push([`stages[${index}].description`, stage.description]);
8877
7953
  }
8878
- if (typeof stage.body === 'string')
7954
+ if (typeof stage.body === 'string') {
7955
+ assertAgentToolMentions(stage.body);
8879
7956
  prose.push([`stages[${index}].body`, stage.body]);
7957
+ }
8880
7958
  });
8881
7959
  (Array.isArray(args.exits) ? args.exits : []).forEach((exit, index) => {
8882
7960
  if (typeof exit.condition === 'string') {
@@ -8971,8 +8049,10 @@ export async function editWorkflowHandler(client, args, fromAgent) {
8971
8049
  if (typeof stage.description === 'string') {
8972
8050
  prose.push([`stages[${index}].description`, stage.description]);
8973
8051
  }
8974
- if (typeof stage.body === 'string')
8052
+ if (typeof stage.body === 'string') {
8053
+ assertAgentToolMentions(stage.body);
8975
8054
  prose.push([`stages[${index}].body`, stage.body]);
8055
+ }
8976
8056
  });
8977
8057
  (Array.isArray(args.exits) ? args.exits : []).forEach((exit, index) => {
8978
8058
  if (typeof exit.condition === 'string') {
@@ -9729,7 +8809,7 @@ const SERVER_INSTRUCTIONS = `You are connected to CTRL+SPC, where this user's wo
9729
8809
 
9730
8810
  The terminal conversation disappears when the process does. What you put in CTRL+SPC is what survives.
9731
8811
 
9732
- Everything you write into CTRL+SPC is saved the same way, whichever tool you use. ${FIREWALL_WRITING_RULE}`;
8812
+ Everything you write into CTRL+SPC is saved the same way, whichever tool you use. ${FIREWALL_WRITING_RULE}\n\n${WORKFLOW_TOOL_TEACHING}`;
9733
8813
  export function buildToolsServer(client, userId, machineId, connectionId,
9734
8814
  /** 18c SLICE 1 — the request (`cliv2_loose_todos.id`) this connection is
9735
8815
  * working, from the connection's own URL rather than a tool argument the
@@ -9814,10 +8894,13 @@ runTodoIdSource = null) {
9814
8894
  .optional()
9815
8895
  .describe('From a client intake run: the client this idea came from. The project must be attached to this client, '
9816
8896
  + 'and the call is refused if it is not.'),
8897
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
9817
8898
  },
9818
8899
  }, async (args) => {
9819
- touchSession(connectionId);
9820
- return createProductIdeaHandler(client, userId, args);
8900
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'create_product_idea', args, async () => {
8901
+ touchSession(connectionId);
8902
+ return createProductIdeaHandler(client, userId, args);
8903
+ }, undefined, await runTodoIdOf());
9821
8904
  });
9822
8905
  server.registerTool('get_task', {
9823
8906
  description: 'Read a full task — its tags, comments, artifacts, decisions (so you can read the answers to questions ' +
@@ -9829,7 +8912,9 @@ runTodoIdSource = null) {
9829
8912
  'addressed them; mark rounds resolved with resolve_feedback after revising). ' +
9830
8913
  'Read-only; it has no status or presence side effects. '
9831
8914
  + 'If the item is a Product Idea (pre-backlog, not agreed work), the result carries a product_idea_boundary block — follow it: explore and write artifacts, never implement.',
9832
- inputSchema: { id: z.string().describe('Task id') },
8915
+ inputSchema: { id: z.string().describe('Task id'),
8916
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
8917
+ },
9833
8918
  }, async ({ id }) => {
9834
8919
  touchSession(connectionId);
9835
8920
  return getTaskHandler(client, { id });
@@ -9851,6 +8936,7 @@ runTodoIdSource = null) {
9851
8936
  CITATION_LINE_TEACHING + ' ' +
9852
8937
  FIREWALL_WRITING_RULE,
9853
8938
  inputSchema: {
8939
+ workflow_tool_instance: z.string().uuid().optional().describe('The id of this tool mention in the active workflow stage. ' + WORKFLOW_TOOL_TEACHING),
9854
8940
  task_id: z.string(),
9855
8941
  type: z.enum(['analysis', 'plan', 'spec', 'diagram', 'mock', 'wireframe', 'user_story']),
9856
8942
  format: z.enum(['md', 'html', 'json', 'svg']).optional().describe('Defaults to md'),
@@ -9884,18 +8970,10 @@ runTodoIdSource = null) {
9884
8970
  }, async (args) => {
9885
8971
  touchSession(connectionId);
9886
8972
  const artifactArgs = args;
9887
- return runDirected(client, userId, openSessions.get(connectionId) ?? null, 'create_artifact', artifactArgs, `Write the ${artifactArgs.type} "${artifactArgs.title ?? 'untitled'}"`, artifactArgs.directed_by, artifactArgs.todo_id,
9888
- /* 18c Slice 6: the connection's own request id, so the receipt on the
9889
- finished card can list what this run made. Resolved INSIDE the
9890
- deferred write, not before it, because runDirected may not call the
9891
- write at all. */
9892
- /* 18c Slice 6, gap 4: the session for ATTRIBUTION, which may have ended.
9893
- The artifact is created either way; what this decides is whether the
9894
- run that made it is recorded. Saving an artifact is routinely the
9895
- LAST thing a run does, which is exactly the case gap 4 names. */
9896
- async () => createArtifactHandler(client, userId, sessionForAttribution(connectionId), args, await runTodoIdOf()),
9897
- // 18k Slice 10b — the connection's own run id, for the permission row.
9898
- await runTodoIdOf());
8973
+ // Resolve attribution inside the write: the gate may stop before it runs,
8974
+ // and a finished session still identifies the run that made the artifact.
8975
+ const write = async () => createArtifactHandler(client, userId, sessionForAttribution(connectionId), artifactArgs, await runTodoIdOf());
8976
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'create_artifact', args, write, async () => runDirected(client, userId, openSessions.get(connectionId) ?? null, 'create_artifact', artifactArgs, `Write the ${artifactArgs.type} "${artifactArgs.title ?? 'untitled'}"`, artifactArgs.directed_by, artifactArgs.todo_id, write, await runTodoIdOf()), await runTodoIdOf());
9899
8977
  });
9900
8978
  server.registerTool('attach_screenshot', {
9901
8979
  description:
@@ -9918,6 +8996,7 @@ runTodoIdSource = null) {
9918
8996
  'and the exact browser/simulator/emulator target. No local path is ever uploaded or ' +
9919
8997
  'stored. Requires an open session (begin_work).',
9920
8998
  inputSchema: {
8999
+ workflow_tool_instance: z.string().uuid().optional().describe('The id of this tool mention in the active workflow stage. ' + WORKFLOW_TOOL_TEACHING),
9921
9000
  url: z
9922
9001
  .string()
9923
9002
  .optional()
@@ -9933,9 +9012,9 @@ runTodoIdSource = null) {
9933
9012
  },
9934
9013
  }, async (args) => {
9935
9014
  touchSession(connectionId);
9936
- return attachScreenshotHandler(client, userId, openSessions.get(connectionId) ?? null, args,
9937
- // `deps` left at its default; the request id follows it (18c Slice 6).
9938
- {}, await runTodoIdOf());
9015
+ // Keep screenshot capture deferred until its workflow approval passes.
9016
+ const write = async () => attachScreenshotHandler(client, userId, openSessions.get(connectionId) ?? null, args, {}, await runTodoIdOf());
9017
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'attach_screenshot', args, write, undefined, await runTodoIdOf());
9939
9018
  });
9940
9019
  server.registerTool('update_task', {
9941
9020
  description: "On a Product Idea the description is the human's own thinking: you may write it only while it is EMPTY, and never edit or overwrite one they wrote — put your analysis in an artifact (create_artifact) instead. "
@@ -9957,10 +9036,13 @@ runTodoIdSource = null) {
9957
9036
  description: z.string().optional(),
9958
9037
  due_date: z.string().nullable().optional().describe('ISO date, or null to clear'),
9959
9038
  }),
9039
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
9960
9040
  },
9961
9041
  }, async (args) => {
9962
- touchSession(connectionId);
9963
- return updateTaskHandler(client, args);
9042
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'update_task', args, async () => {
9043
+ touchSession(connectionId);
9044
+ return updateTaskHandler(client, args);
9045
+ }, undefined, await runTodoIdOf());
9964
9046
  });
9965
9047
  server.registerTool('update_artifact', {
9966
9048
  description: 'Edit an artifact you can access — its title, content, type, or format. Pass expected_revision from the ' +
@@ -9968,6 +9050,7 @@ runTodoIdSource = null) {
9968
9050
  'The change appears in the web UI. ' +
9969
9051
  FIREWALL_WRITING_RULE,
9970
9052
  inputSchema: {
9053
+ workflow_tool_instance: z.string().uuid().optional().describe('The id of this tool mention in the active workflow stage. ' + WORKFLOW_TOOL_TEACHING),
9971
9054
  id: z.string(),
9972
9055
  expected_revision: z
9973
9056
  .number()
@@ -9983,7 +9066,7 @@ runTodoIdSource = null) {
9983
9066
  },
9984
9067
  }, async (args) => {
9985
9068
  touchSession(connectionId);
9986
- return updateArtifactHandler(client, args, await runTodoIdOf());
9069
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'update_artifact', args, async () => updateArtifactHandler(client, args, await runTodoIdOf()), undefined, await runTodoIdOf());
9987
9070
  });
9988
9071
  server.registerTool('archive_artifact', {
9989
9072
  description: 'Retire ONE artifact that is genuinely obsolete — superseded by a rewrite, or scrapped because the ' +
@@ -10043,11 +9126,14 @@ runTodoIdSource = null) {
10043
9126
  inputSchema: {
10044
9127
  task_id: z.string().describe('Task id'),
10045
9128
  body: z.string().min(1),
9129
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
10046
9130
  },
10047
9131
  }, async (args) => {
10048
- touchSession(connectionId);
10049
- // 18c Slice 6, gap 4: the session for ATTRIBUTION, which may have ended.
10050
- return addCommentHandler(client, userId, sessionForAttribution(connectionId), args, await runTodoIdOf());
9132
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'add_comment', args, async () => {
9133
+ touchSession(connectionId);
9134
+ // 18c Slice 6, gap 4: the session for ATTRIBUTION, which may have ended.
9135
+ return addCommentHandler(client, userId, sessionForAttribution(connectionId), args, await runTodoIdOf());
9136
+ }, undefined, await runTodoIdOf());
10051
9137
  });
10052
9138
  server.registerTool('create_task', {
10053
9139
  // Feature 32: the calibration rule lives HERE (teach-in-the-description
@@ -10074,6 +9160,7 @@ runTodoIdSource = null) {
10074
9160
  .optional()
10075
9161
  .describe('Sprint to place the task into — a live sprint of this SAME project (create_sprint)'),
10076
9162
  ...DIRECTED_BY_SCHEMA,
9163
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
10077
9164
  },
10078
9165
  }, async (args) => {
10079
9166
  touchSession(connectionId);
@@ -10087,8 +9174,7 @@ runTodoIdSource = null) {
10087
9174
  });
10088
9175
  server.registerTool('reorder_backlog', {
10089
9176
  description: "Change the order of a project's backlog, WITH THE USER'S PERMISSION. Board order decides " +
10090
- 'what gets picked up next, so this always asks first and shows them the resulting order: it ' +
10091
- 'is never applied silently, even when they asked for it. Move ONE item at a time, before ' +
9177
+ 'what gets picked up next, so it asks first unless this workflow occurrence already permits it. Move ONE item at a time, before ' +
10092
9178
  'another item or to the bottom. Nothing is moved until they approve.',
10093
9179
  inputSchema: {
10094
9180
  project_id: z.string().describe('Project whose backlog is being reordered'),
@@ -10102,6 +9188,7 @@ runTodoIdSource = null) {
10102
9188
  .min(1)
10103
9189
  .describe('Why this order is better, in plain words for the user — they are approving a change to ' +
10104
9190
  'what gets worked on next. e.g. "The session bug blocks the other two auth items."'),
9191
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
10105
9192
  },
10106
9193
  }, async (args) => {
10107
9194
  touchSession(connectionId);
@@ -10149,6 +9236,7 @@ runTodoIdSource = null) {
10149
9236
  description: z.string().optional().describe('What the epic is for and how the work under it is cut'),
10150
9237
  start_date: z.string().optional().describe('ISO date (YYYY-MM-DD) — only when the user gave one'),
10151
9238
  target_date: z.string().optional().describe('ISO date (YYYY-MM-DD) — a hope, not an estimate; only when the user gave one'),
9239
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
10152
9240
  },
10153
9241
  }, async (args) => {
10154
9242
  touchSession(connectionId);
@@ -10159,9 +9247,7 @@ runTodoIdSource = null) {
10159
9247
  that need it, NOT a change to every tool — there is no shared dispatch
10160
9248
  to hook, each registerTool passes its own closure. */
10161
9249
  const epicArgs = args;
10162
- return runGated(client, userId, openSessions.get(connectionId) ?? null, 'create_epic', epicArgs, `Create the epic “${epicArgs.name}”`, () => createEpicHandler(client, epicArgs),
10163
- // 18k Slice 10b — the run this call belongs to, so approving it resumes the work.
10164
- await runTodoIdOf());
9250
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'create_epic', epicArgs, () => createEpicHandler(client, epicArgs), async () => runGated(client, userId, openSessions.get(connectionId) ?? null, 'create_epic', epicArgs, `Create the epic “${epicArgs.name}”`, () => createEpicHandler(client, epicArgs), await runTodoIdOf()), await runTodoIdOf());
10165
9251
  });
10166
9252
  server.registerTool('create_sprint', {
10167
9253
  description: 'Create a Sprint in a project. For agent-executed work a sprint is an ORDERED BATCH, not a ' +
@@ -10179,6 +9265,7 @@ runTodoIdSource = null) {
10179
9265
  name: z.string().min(1).describe('The batch name — refused if a live sprint already carries it'),
10180
9266
  start_date: z.string().optional().describe('ISO date (YYYY-MM-DD) — only for a real calendar constraint'),
10181
9267
  end_date: z.string().optional().describe('ISO date (YYYY-MM-DD) — only for a real calendar constraint'),
9268
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
10182
9269
  },
10183
9270
  }, async (args) => {
10184
9271
  touchSession(connectionId);
@@ -10188,9 +9275,7 @@ runTodoIdSource = null) {
10188
9275
  ruling. `create_task` deliberately does NOT: it is the ordinary class,
10189
9276
  and 4a gives it `directed_by` instead. */
10190
9277
  const sprintArgs = args;
10191
- return runGated(client, userId, openSessions.get(connectionId) ?? null, 'create_sprint', sprintArgs, `Create the sprint “${sprintArgs.name}”`, () => createSprintHandler(client, sprintArgs),
10192
- // 18k Slice 10b — the run this call belongs to, so approving it resumes the work.
10193
- await runTodoIdOf());
9278
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'create_sprint', sprintArgs, () => createSprintHandler(client, sprintArgs), async () => runGated(client, userId, openSessions.get(connectionId) ?? null, 'create_sprint', sprintArgs, `Create the sprint “${sprintArgs.name}”`, () => createSprintHandler(client, sprintArgs), await runTodoIdOf()), await runTodoIdOf());
10194
9279
  });
10195
9280
  /* ═══ !Cleanup PHASE 5 — READ AND MANAGE THE STRUCTURE (I13, I14) ═══ */
10196
9281
  server.registerTool('list_structure', {
@@ -10266,6 +9351,7 @@ runTodoIdSource = null) {
10266
9351
  .string()
10267
9352
  .optional()
10268
9353
  .describe('Place it immediately before this item. Omit to send it to the end'),
9354
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
10269
9355
  },
10270
9356
  }, async (args) => {
10271
9357
  touchSession(connectionId);
@@ -10274,7 +9360,7 @@ runTodoIdSource = null) {
10274
9360
  `create_task`'s class, not the project-wide class the ruling names. The
10275
9361
  epic and sprint themselves are unchanged by this — only which one an
10276
9362
  item points at. */
10277
- return placeWorkItemHandler(client, args);
9363
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'place_work_item', args, () => placeWorkItemHandler(client, args), undefined, await runTodoIdOf());
10278
9364
  });
10279
9365
  server.registerTool('begin_work', {
10280
9366
  description: 'Open a work session so the outputs you create afterward are attributed to this run. ' +
@@ -10752,6 +9838,7 @@ runTodoIdSource = null) {
10752
9838
  'best guesses — do not pad the list to cover every possibility, and do not narrow the question just to ' +
10753
9839
  'make your options exhaustive.',
10754
9840
  inputSchema: {
9841
+ workflow_tool_instance: z.string().uuid().optional().describe('The Ask Question mention id. Asking never requires permission.'),
10755
9842
  category: z.string().min(1),
10756
9843
  context: z.string().min(1),
10757
9844
  question: z
@@ -10849,10 +9936,13 @@ runTodoIdSource = null) {
10849
9936
  .optional()
10850
9937
  .describe('2..11 competing diagrams, each with a short distinct label (exactly one of html / options; ' +
10851
9938
  "the labels 'approve' and 'request changes' are reserved)"),
9939
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
10852
9940
  },
10853
9941
  }, async (args) => {
10854
- touchSession(connectionId);
10855
- return presentWireframesHandler(client, userId, openSessions.get(connectionId) ?? null, args, await runTodoIdOf());
9942
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'present_wireframes', args, async () => {
9943
+ touchSession(connectionId);
9944
+ return presentWireframesHandler(client, userId, openSessions.get(connectionId) ?? null, args, await runTodoIdOf());
9945
+ }, undefined, await runTodoIdOf());
10856
9946
  });
10857
9947
  server.registerTool('present_mocks', {
10858
9948
  description: 'Present WORKING, INTERACTIVE HTML mocks to the user for fullscreen review in the web work item — ' +
@@ -10914,10 +10004,13 @@ runTodoIdSource = null) {
10914
10004
  .optional()
10915
10005
  .describe('2..11 competing mocks, each self-contained and interactive, with a short distinct label ' +
10916
10006
  "(exactly one of html / options; the labels 'approve' and 'request changes' are reserved)"),
10007
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
10917
10008
  },
10918
10009
  }, async (args) => {
10919
- touchSession(connectionId);
10920
- return presentMocksHandler(client, userId, openSessions.get(connectionId) ?? null, args, await runTodoIdOf());
10010
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'present_mocks', args, async () => {
10011
+ touchSession(connectionId);
10012
+ return presentMocksHandler(client, userId, openSessions.get(connectionId) ?? null, args, await runTodoIdOf());
10013
+ }, undefined, await runTodoIdOf());
10921
10014
  });
10922
10015
  server.registerTool('resolve_feedback', {
10923
10016
  description: 'Mark feedback rounds resolved after you revised the presented artifact: pass the ids of the rounds the ' +
@@ -10935,10 +10028,13 @@ runTodoIdSource = null) {
10935
10028
  .int()
10936
10029
  .positive()
10937
10030
  .describe('The artifact revision that addressed these rounds (from the update_artifact result)'),
10031
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
10938
10032
  },
10939
10033
  }, async (args) => {
10940
- touchSession(connectionId);
10941
- return resolveFeedbackHandler(client, openSessions.get(connectionId) ?? null, args);
10034
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'resolve_feedback', args, async () => {
10035
+ touchSession(connectionId);
10036
+ return resolveFeedbackHandler(client, openSessions.get(connectionId) ?? null, args);
10037
+ }, undefined, await runTodoIdOf());
10942
10038
  });
10943
10039
  server.registerTool('record_user_input', {
10944
10040
  description: "Record the user's answer to an open decision on your work item (e.g. an answer they gave you directly). " +
@@ -10973,10 +10069,13 @@ runTodoIdSource = null) {
10973
10069
  FIREWALL_WRITING_RULE,
10974
10070
  inputSchema: {
10975
10071
  content: z.string().min(1),
10072
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
10976
10073
  },
10977
10074
  }, async (args) => {
10978
- touchSession(connectionId);
10979
- return recordContextExplorationHandler(client, userId, openSessions.get(connectionId) ?? null, args, await runTodoIdOf());
10075
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'record_context_exploration', args, async () => {
10076
+ touchSession(connectionId);
10077
+ return recordContextExplorationHandler(client, userId, openSessions.get(connectionId) ?? null, args, await runTodoIdOf());
10078
+ }, undefined, await runTodoIdOf());
10980
10079
  });
10981
10080
  server.registerTool('reserve_work_paths', {
10982
10081
  /* 18c SLICE 9: THE DESCRIPTION SAYS *BEFORE*, AND SAYS WHERE IT SHOWS.
@@ -11164,6 +10263,7 @@ runTodoIdSource = null) {
11164
10263
  .array(z.enum(PROJECT_DOCUMENT_TYPES))
11165
10264
  .optional()
11166
10265
  .describe('Only these document types; omitted returns every type'),
10266
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
11167
10267
  },
11168
10268
  }, async (args) => {
11169
10269
  touchSession(connectionId);
@@ -11180,6 +10280,7 @@ runTodoIdSource = null) {
11180
10280
  .string()
11181
10281
  .min(1)
11182
10282
  .describe('Document id, as the web app’s “Copy for agent” button pastes it'),
10283
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
11183
10284
  },
11184
10285
  }, async (args) => {
11185
10286
  touchSession(connectionId);
@@ -11228,10 +10329,13 @@ runTodoIdSource = null) {
11228
10329
  }))
11229
10330
  .describe('The proposals from this scan — send `[]` if the repo holds no standing context, which clears ' +
11230
10331
  'anything an earlier scan left waiting'),
10332
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
11231
10333
  },
11232
10334
  }, async (args) => {
11233
- touchSession(connectionId);
11234
- return proposeProjectContextHandler(client, openSessions.get(connectionId) ?? null, args);
10335
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'propose_project_context', args, async () => {
10336
+ touchSession(connectionId);
10337
+ return proposeProjectContextHandler(client, openSessions.get(connectionId) ?? null, args);
10338
+ }, undefined, await runTodoIdOf());
11235
10339
  });
11236
10340
  server.registerTool('list_clients', {
11237
10341
  description: 'List the clients you can see in CTRL+SPC, each with its attached projects. THE ENTRY POINT ' +
@@ -11378,10 +10482,13 @@ runTodoIdSource = null) {
11378
10482
  classification: z.enum(SCOPE_CLASSIFICATIONS),
11379
10483
  evidence: z.string().min(1).describe('The scope.md text you judged this against, quoted'),
11380
10484
  measured_against_version: z.number().int().describe('The approved scope version you read, e.g. 1'),
10485
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
11381
10486
  },
11382
10487
  }, async (args) => {
11383
- touchSession(connectionId);
11384
- return proposeScopeChangeHandler(client, args);
10488
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'propose_scope_change', args, async () => {
10489
+ touchSession(connectionId);
10490
+ return proposeScopeChangeHandler(client, args);
10491
+ }, undefined, await runTodoIdOf());
11385
10492
  });
11386
10493
  server.registerTool('create_stage', {
11387
10494
  description: 'Create a stage, a container in a WORK ITEM\'s step spine ("Implement", ' +
@@ -11450,10 +10557,13 @@ runTodoIdSource = null) {
11450
10557
  source_label: z.string().min(1),
11451
10558
  source_ref: z.string().optional(),
11452
10559
  status: z.enum(STEP_STATUSES).optional().describe("Defaults to 'pending'"),
10560
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
11453
10561
  },
11454
10562
  }, async (args) => {
11455
- touchSession(connectionId);
11456
- return createStepHandler(client, args, await runTodoIdOf());
10563
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'create_step', args, async () => {
10564
+ touchSession(connectionId);
10565
+ return createStepHandler(client, args, await runTodoIdOf());
10566
+ }, undefined, await runTodoIdOf());
11457
10567
  });
11458
10568
  server.registerTool('update_step', {
11459
10569
  description: 'THIS IS HOW YOU RECORD PROGRESS ON A STEP. Update the step as things become true — when ' +