@ctrl-spc/cs 0.7.6 → 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/design-review.js +242 -0
- package/dist/mcp.js +163 -1053
- package/dist/panel3/prompt.js +20 -1
- package/dist/panel3/run.js +22 -9
- package/dist/panel3/tools.js +282 -13
- package/dist/product-tools.js +442 -0
- package/dist/workflow-tool-mentions.js +93 -0
- package/dist/workflows.js +99 -0
- package/package.json +1 -1
package/dist/mcp.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
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
|
-
|
|
9820
|
-
|
|
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
|
-
|
|
9888
|
-
|
|
9889
|
-
|
|
9890
|
-
|
|
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
|
-
|
|
9937
|
-
|
|
9938
|
-
|
|
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
|
-
|
|
9963
|
-
|
|
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
|
-
|
|
10049
|
-
|
|
10050
|
-
|
|
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
|
|
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
|
-
|
|
10855
|
-
|
|
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
|
-
|
|
10920
|
-
|
|
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
|
-
|
|
10941
|
-
|
|
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
|
-
|
|
10979
|
-
|
|
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
|
-
|
|
11234
|
-
|
|
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
|
-
|
|
11384
|
-
|
|
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
|
-
|
|
11456
|
-
|
|
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 ' +
|