@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.
@@ -0,0 +1,442 @@
1
+ /** Product data operations shared by the terminal and panel. Session lifecycle,
2
+ * permission questions and agent routing stay with their respective harnesses. */
3
+ import { createHash } from 'node:crypto';
4
+ import { readableWriteError } from './firewall.js';
5
+ import { hostedRemoteIdentity } from './git-remote.js';
6
+ import { ABSOLUTE_PATH_RE } from './local-paths.js';
7
+ export async function must(query) {
8
+ const { data, error } = await query;
9
+ if (error)
10
+ throw new Error(readableWriteError(error.message));
11
+ return data;
12
+ }
13
+ export const errorMessage = (err) => (err instanceof Error ? err.message : String(err));
14
+ export function textResult(payload) {
15
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
16
+ }
17
+ export function errorResult(message) {
18
+ return { content: [{ type: 'text', text: message }], isError: true };
19
+ }
20
+ export const TASK_PLACEMENT = 'epic:epics(id,name), sprint:sprints(id,name)';
21
+ export async function placeWorkItemHandler(client, args) {
22
+ try {
23
+ const id = typeof args.work_item_id === 'string' ? args.work_item_id.trim() : '';
24
+ if (!id || !UUID_RE.test(id)) {
25
+ return errorResult(`place_work_item: "${id}" is not a work item id. Call list_tasks to see them. Nothing was changed.`);
26
+ }
27
+ if (args.epic_id === undefined && args.sprint_id === undefined) {
28
+ return errorResult('place_work_item: pass epic_id, sprint_id, or both — there is nothing to place. Pass null to ' +
29
+ 'take it out of one. Nothing was changed.');
30
+ }
31
+ const task = await must(client.from('tasks').select('id, revision, name').eq('id', id).maybeSingle());
32
+ if (!task) {
33
+ return errorResult(`place_work_item: no work item found for id "${id}". Nothing was changed.`);
34
+ }
35
+ /* NULL MEANS "TAKE IT OUT", and the RPC spells that as an empty string:
36
+ `nullif(p_changes ->> 'epic_id', '')::uuid`. Sending JSON null would read
37
+ back as the string "null" and fail the uuid cast, so the two are
38
+ translated here rather than left for the agent to discover. */
39
+ const changes = {};
40
+ if (args.epic_id !== undefined)
41
+ changes.epic_id = args.epic_id === null ? '' : String(args.epic_id);
42
+ if (args.sprint_id !== undefined)
43
+ changes.sprint_id = args.sprint_id === null ? '' : String(args.sprint_id);
44
+ const { error } = await client.rpc('save_task_if_current', {
45
+ p_task_id: id,
46
+ p_expected_revision: task.revision,
47
+ p_changes: changes,
48
+ p_tag_ids: null,
49
+ /* Renormalise the destination order so the placement is deterministic. */
50
+ p_reorder: true,
51
+ p_before_task_id: args.before_work_item_id ?? null,
52
+ });
53
+ if (error) {
54
+ const message = error.message ?? '';
55
+ if (message.includes('edit_conflict')) {
56
+ return errorResult(`place_work_item: "${task.name}" was changed by someone else while this was being placed. ` +
57
+ 'Call list_tasks again and retry. Nothing was changed.');
58
+ }
59
+ /* The database's own words for a cross-project epic or sprint are already
60
+ plain ("epic must belong to the task's project"), so they are passed
61
+ through rather than replaced with a worse paraphrase. */
62
+ return errorResult(`place_work_item: ${message} Nothing was changed.`);
63
+ }
64
+ const moved = await must(client
65
+ .from('tasks')
66
+ .select(`id, name, status, ${TASK_PLACEMENT}`)
67
+ .eq('id', id)
68
+ .maybeSingle());
69
+ return textResult({
70
+ work_item: moved,
71
+ note: 'Placed. The board shows this immediately.',
72
+ });
73
+ }
74
+ catch (err) {
75
+ return errorResult(`place_work_item failed: ${errorMessage(err)}`);
76
+ }
77
+ }
78
+ export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
79
+ export async function resolveFeedbackHandler(client, session, args) {
80
+ try {
81
+ if (!session) {
82
+ return errorResult('resolve_feedback needs an open work session — call begin_work first.');
83
+ }
84
+ if (!Array.isArray(args.feedback_ids) || args.feedback_ids.length === 0) {
85
+ return errorResult('resolve_feedback requires feedback_ids (at least one feedback round id).');
86
+ }
87
+ if (!Number.isInteger(args.revision) || args.revision < 1) {
88
+ return errorResult('resolve_feedback requires revision (a positive integer — the artifact revision that addressed the rounds).');
89
+ }
90
+ const ids = [...new Set(args.feedback_ids.map((id) => id.trim()))];
91
+ const malformed = ids.filter((id) => !UUID_RE.test(id));
92
+ if (malformed.length) {
93
+ return errorResult(`resolve_feedback: not valid feedback round id(s): ${malformed.join(', ')}.`);
94
+ }
95
+ const rows = (await must(client
96
+ .from('artifact_feedback')
97
+ .update({ status: 'resolved', resolved_in_revision: args.revision })
98
+ .in('id', ids)
99
+ .select('id'))) ?? [];
100
+ const updated = rows.map((row) => row.id);
101
+ if (updated.length !== ids.length) {
102
+ const missing = ids.filter((id) => !updated.includes(id));
103
+ return errorResult(`resolve_feedback: ${updated.length} of ${ids.length} round(s) updated. Not found (or not accessible): ` +
104
+ `${missing.join(', ')}.${updated.length ? ` Already marked resolved: ${updated.join(', ')}.` : ''} ` +
105
+ 'Check the ids against the most recent get_task `feedback` list.');
106
+ }
107
+ return textResult({ resolved: updated, revision: args.revision });
108
+ }
109
+ catch (err) {
110
+ return errorResult(`resolve_feedback failed: ${err.message}`);
111
+ }
112
+ }
113
+ export const PROJECT_DOCUMENT_TYPES = ['instructions', 'architecture', 'design', 'conventions', 'other'];
114
+ export const noProjectContextError = (tool) => `${tool} needs to know which project. Call begin_work first, or pass task_id.`;
115
+ export const countCharacters = (text) => /[\uD800-\uDFFF]/.test(text) ? [...text].length : text.length;
116
+ export async function resolveContextProject(client, session, taskIdArg, tool) {
117
+ if (taskIdArg !== undefined && taskIdArg.trim() === '') {
118
+ return {
119
+ ok: false,
120
+ error: errorResult(`${tool}: task_id was blank. Pass a task id, or omit it to use the open session's task.`),
121
+ };
122
+ }
123
+ const taskId = taskIdArg?.trim() || session?.taskId;
124
+ if (!taskId)
125
+ return { ok: false, error: errorResult(noProjectContextError(tool)) };
126
+ if (!UUID_RE.test(taskId)) {
127
+ return { ok: false, error: errorResult(`${tool}: not a valid task id: "${taskId}".`) };
128
+ }
129
+ const task = await must(client
130
+ .from('tasks')
131
+ .select('project_id, projects(name)')
132
+ .eq('id', taskId)
133
+ .is('archived_at', null)
134
+ .maybeSingle());
135
+ if (!task)
136
+ return { ok: false, error: errorResult(`${tool}: no task found for id "${taskId}".`) };
137
+ // PostgREST returns a to-one embed as an object, but has returned an array
138
+ // for the same shape across versions, so both are unwrapped. A MISSING name
139
+ // is not defaulted to '': `tasks.project_id` is a non-null FK, so a blank
140
+ // project in the envelope could only mean the read did not return what it
141
+ // was asked for, and answering with an empty project name is a silent wrong
142
+ // value in a field the agent uses to know which project it is reading.
143
+ const embedded = Array.isArray(task.projects) ? (task.projects[0] ?? null) : task.projects;
144
+ if (!embedded?.name) {
145
+ return {
146
+ ok: false,
147
+ error: errorResult(`${tool}: could not read the project for task "${taskId}". Try again, ` +
148
+ 'and if it keeps happening report it — a task always has a project.'),
149
+ };
150
+ }
151
+ return { ok: true, value: { id: task.project_id, name: embedded.name } };
152
+ }
153
+ export async function resolveContextCodebase(client, projectId, wanted, tool) {
154
+ const rows = (await must(client
155
+ .from('cliv2_codebases')
156
+ .select('id, name, git_remote_url')
157
+ .eq('project_id', projectId)
158
+ .order('created_at', { ascending: true }))) ?? [];
159
+ // `normalizeRemoteUrl` already lowercases (src/git-remote.ts:32) and is
160
+ // idempotent, and `git_remote_url` is STORED canonical (src/codebases.ts:87),
161
+ // so the identity comparison subsumes any raw-value comparison. Name matching
162
+ // stays case-insensitive.
163
+ const needle = wanted.toLowerCase();
164
+ const identity = hostedRemoteIdentity(wanted) ?? null;
165
+ const found = rows.filter((row) => row.name.toLowerCase() === needle ||
166
+ (identity !== null && row.git_remote_url.toLowerCase() === identity));
167
+ if (found.length === 0) {
168
+ return {
169
+ ok: false,
170
+ error: errorResult(`${tool}: no codebase named "${wanted}" in this project. ` +
171
+ (rows.length
172
+ ? // Deduped: `name` has no unique constraint (only (project_id,
173
+ // git_remote_url) does), so two codebases can share one name.
174
+ // Listing it twice tells the agent nothing and reads as a bug.
175
+ `Known codebases: ${[...new Set(rows.map((row) => row.name))].join(', ')}.`
176
+ : 'This project has no codebases yet.')),
177
+ };
178
+ }
179
+ if (found.length > 1) {
180
+ return {
181
+ ok: false,
182
+ error: errorResult(`${tool}: more than one codebase named "${wanted}" in this project. ` +
183
+ `Matching git remotes: ${found.map((row) => row.git_remote_url).join(', ')}. ` +
184
+ 'Pass the git remote instead of the name.'),
185
+ };
186
+ }
187
+ return { ok: true, value: { id: found[0].id, name: found[0].name } };
188
+ }
189
+ export async function getDocumentHandler(client, args) {
190
+ const id = typeof args.id === 'string' ? args.id.trim() : '';
191
+ if (!id) {
192
+ return errorResult('get_document requires id — the id of the document to read, as the web app’s “Copy for agent” ' +
193
+ 'button pastes it.');
194
+ }
195
+ // Shape-checked once, here at the boundary, exactly as resolveContextProject
196
+ // does with task_id: without it a mistyped id comes back as a Postgres
197
+ // "invalid input syntax for type uuid", which reads like a bug in the tool.
198
+ if (!UUID_RE.test(id)) {
199
+ return errorResult(`get_document: not a valid document id: "${id}".`);
200
+ }
201
+ try {
202
+ // Context documents first, then instructions. RLS scopes both reads, so a
203
+ // row the caller may not see is simply absent and falls through to the
204
+ // not-found below.
205
+ const documents = await must(client
206
+ .from('project_documents')
207
+ .select('title, content, codebase_id, type')
208
+ .eq('id', id));
209
+ const document = (documents ?? [])[0];
210
+ if (document)
211
+ return textResult(shapeDocument(document));
212
+ const instructions = await must(client.from('agent_instructions').select('title, content, codebase_id').eq('id', id));
213
+ const instruction = (instructions ?? [])[0];
214
+ if (instruction) {
215
+ return textResult({
216
+ ...shapeDocument(instruction),
217
+ // Said plainly, so an agent that fetched one does not conclude these are
218
+ // reference material it may choose to consult: it already has them.
219
+ note: 'This is an agent instruction. Every instruction on this project is already delivered in ' +
220
+ 'your prompt — reading one here does not make it optional.',
221
+ });
222
+ }
223
+ return errorResult(`get_document: no document with id ${id}. It may have been deleted, or belong to a project you ` +
224
+ 'cannot see. Call get_project_context to read the documents on the project you are working.');
225
+ }
226
+ catch (err) {
227
+ return errorResult(`get_document failed: ${errorMessage(err)}`);
228
+ }
229
+ }
230
+ export function shapeDocument(row) {
231
+ return {
232
+ title: row.title,
233
+ ...(row.type ? { type: row.type } : {}),
234
+ scope: row.codebase_id === null ? 'project' : 'codebase',
235
+ content: row.content,
236
+ };
237
+ }
238
+ export const PROPOSAL_SCOPES = ['project', 'codebase'];
239
+ export const MAX_PROPOSAL_TITLE_CHARS = 100;
240
+ export const MAX_PROPOSALS = 50;
241
+ export const MAX_PROPOSAL_BATCH_CHARS = 400_000;
242
+ export const ESCAPING_PATH_RE = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
243
+ export const REVIEW_LOCATION = 'Project settings → Project context';
244
+ export function validateProposal(proposal, index, project, scanned) {
245
+ const at = `proposal ${index + 1}`;
246
+ const refuse = (message) => ({
247
+ ok: false,
248
+ error: errorResult(`propose_project_context: ${at} ${message}`),
249
+ });
250
+ if (!proposal || typeof proposal !== 'object') {
251
+ return refuse('is not an object. Each proposal needs title, type, content, source_path, reason and scope.');
252
+ }
253
+ const title = typeof proposal.title === 'string' ? proposal.title.trim() : '';
254
+ if (!title)
255
+ return refuse('has no title.');
256
+ if (countCharacters(title) > MAX_PROPOSAL_TITLE_CHARS) {
257
+ return refuse(`has a ${countCharacters(title)}-character title; the limit is ${MAX_PROPOSAL_TITLE_CHARS}. ` +
258
+ 'Shorten it — a document title is a heading, not a summary.');
259
+ }
260
+ if (!PROJECT_DOCUMENT_TYPES.includes(proposal.type)) {
261
+ return refuse(`("${title}") has type "${String(proposal.type)}", which is not a context document type. ` +
262
+ `Use one of: ${PROJECT_DOCUMENT_TYPES.join(', ')}.`);
263
+ }
264
+ if (!PROPOSAL_SCOPES.includes(proposal.scope)) {
265
+ return refuse(`("${title}") has scope "${String(proposal.scope)}". Use "project" for context that applies to the ` +
266
+ `whole project, or "codebase" for context that belongs to "${scanned.name}" alone.`);
267
+ }
268
+ const content = typeof proposal.content === 'string' ? proposal.content : '';
269
+ if (content.trim() === '') {
270
+ return refuse(`("${title}") has no content. Propose only what you actually read — an empty document helps nobody.`);
271
+ }
272
+ const reason = typeof proposal.reason === 'string' ? proposal.reason.trim() : '';
273
+ if (!reason) {
274
+ return refuse(`("${title}") has no reason. Every proposal carries one line saying why this belongs in the ` +
275
+ "project's context, so the user can judge the judgement and not just the text.");
276
+ }
277
+ const rawPath = typeof proposal.source_path === 'string' ? proposal.source_path.trim() : '';
278
+ if (!rawPath) {
279
+ return refuse(`("${title}") has no source_path. Name the file in the repo this content came from.`);
280
+ }
281
+ if (ABSOLUTE_PATH_RE.test(rawPath)) {
282
+ return refuse(`("${title}") has an absolute source_path: "${rawPath}". Pass it relative to the root of ` +
283
+ `"${scanned.name}" (e.g. "docs/architecture.md"). Absolute local paths must never leave this machine.`);
284
+ }
285
+ if (ESCAPING_PATH_RE.test(rawPath)) {
286
+ return refuse(`("${title}") has a source_path that climbs out of the repo: "${rawPath}". Propose only files ` +
287
+ `inside "${scanned.name}".`);
288
+ }
289
+ // A leading "./" is the one rewrite made, because it changes nothing about
290
+ // which file is named and "./AGENTS.md" renders as noise on a card.
291
+ const sourcePath = rawPath.replace(/^\.\//, '');
292
+ return {
293
+ ok: true,
294
+ value: {
295
+ project_id: project.id,
296
+ codebase_id: proposal.scope === 'project' ? null : scanned.id,
297
+ scanned_codebase_id: scanned.id,
298
+ title,
299
+ type: proposal.type,
300
+ content,
301
+ source_path: sourcePath,
302
+ reason,
303
+ },
304
+ };
305
+ }
306
+ export async function proposeProjectContextHandler(client, session, args) {
307
+ try {
308
+ const resolvedProject = await resolveContextProject(client, session, args.task_id, 'propose_project_context');
309
+ if (!resolvedProject.ok)
310
+ return resolvedProject.error;
311
+ const project = resolvedProject.value;
312
+ // The scanned codebase is REQUIRED — every proposal, project-scoped ones
313
+ // included, records which repo the claim came from, and a project-scoped
314
+ // proposal with no provenance is unreviewable.
315
+ const wanted = args.codebase?.trim();
316
+ if (!wanted) {
317
+ return errorResult('propose_project_context: name the codebase you scanned in `codebase` (its name or git remote). ' +
318
+ 'Every proposal records which repo it came from.');
319
+ }
320
+ const resolvedCodebase = await resolveContextCodebase(client, project.id, wanted, 'propose_project_context');
321
+ if (!resolvedCodebase.ok)
322
+ return resolvedCodebase.error;
323
+ const scanned = resolvedCodebase.value;
324
+ // AN EMPTY BATCH IS A REAL SCAN RESULT, NOT A MISTAKE. `[]` says the repo
325
+ // holds no standing context now, and it clears whatever an earlier scan left
326
+ // pending — without it there is no path from a real scan back to "no
327
+ // proposals" (user ruling, 2026-07-27). A MISSING or non-array `proposals`
328
+ // is still a malformed call, and is refused so the empty-scan meaning stays
329
+ // something the agent has to state on purpose.
330
+ const proposals = args.proposals;
331
+ if (!Array.isArray(proposals)) {
332
+ return errorResult('propose_project_context: `proposals` must be an array. Send the documents you found, or send ' +
333
+ '`[]` to report that this repo holds no standing context — an empty scan is a finding, and it ' +
334
+ 'clears anything an earlier scan left waiting for review.');
335
+ }
336
+ if (proposals.length > MAX_PROPOSALS) {
337
+ return errorResult(`propose_project_context: ${proposals.length} proposals is more than the limit of ${MAX_PROPOSALS}. ` +
338
+ 'Nothing was written. Propose the documents that carry standing context for the whole team, ' +
339
+ 'not every file you read.');
340
+ }
341
+ const rows = [];
342
+ for (const [index, proposal] of proposals.entries()) {
343
+ const validated = validateProposal(proposal, index, project, scanned);
344
+ if (!validated.ok)
345
+ return validated.error;
346
+ rows.push(validated.value);
347
+ }
348
+ // Measured AFTER per-proposal validation so the agent hears about a broken
349
+ // proposal before it hears about the batch's size — the specific fault is
350
+ // more useful than the aggregate one.
351
+ const totalChars = rows.reduce((total, row) => total +
352
+ countCharacters(row.content) +
353
+ countCharacters(row.title) +
354
+ countCharacters(row.reason) +
355
+ countCharacters(row.source_path), 0);
356
+ if (totalChars > MAX_PROPOSAL_BATCH_CHARS) {
357
+ return errorResult(`propose_project_context: this batch is ${totalChars.toLocaleString('en-US')} characters, over the ` +
358
+ `limit of ${MAX_PROPOSAL_BATCH_CHARS.toLocaleString('en-US')}. Nothing was written, and nothing was ` +
359
+ 'truncated. Send the batch in smaller parts, or drop the documents that are too long to be ' +
360
+ 'standing context.');
361
+ }
362
+ // A RE-SCAN REPLACES: this user's pending rows for this (project, scanned
363
+ // codebase) go first. RLS scopes the delete to the user; the two eq filters
364
+ // scope it to this scan's subject, so another codebase's pending proposals —
365
+ // and another user's — are untouched. `.select('id')` makes the replaced
366
+ // COUNT knowable, which is what the result reports instead of leaving the
367
+ // agent to guess whether its earlier scan is still standing.
368
+ const replaced = (await must(client
369
+ .from('cliv2_context_proposals')
370
+ .delete()
371
+ .eq('project_id', project.id)
372
+ .eq('scanned_codebase_id', scanned.id)
373
+ .select('id'))) ?? [];
374
+ // Nothing to insert on an empty scan — the delete above WAS the whole call.
375
+ if (rows.length)
376
+ await must(client.from('cliv2_context_proposals').insert(rows));
377
+ const projectScoped = rows.filter((row) => row.codebase_id === null).length;
378
+ const codebaseScoped = rows.length - projectScoped;
379
+ const noun = rows.length === 1 ? 'proposal' : 'proposals';
380
+ const sentences = [];
381
+ if (rows.length === 0) {
382
+ sentences.push(`Nothing was found: this scan of "${scanned.name}" turned up no standing context to propose for ` +
383
+ `"${project.name}", so nothing is waiting for review.`);
384
+ sentences.push(replaced.length
385
+ ? `It cleared ${replaced.length} proposal${replaced.length === 1 ? '' : 's'} from an earlier scan ` +
386
+ `of "${scanned.name}", which no longer stand — a scan reports what the repo holds now.`
387
+ : 'There was nothing pending to clear.');
388
+ sentences.push('No document was touched: accepted context documents are not proposals and are unaffected. Tell ' +
389
+ 'the user the repo holds no standing context worth importing.');
390
+ }
391
+ else {
392
+ sentences.push(`${rows.length} context document ${noun} ${rows.length === 1 ? 'is' : 'are'} now waiting for review ` +
393
+ `in the web app, under ${REVIEW_LOCATION} for "${project.name}".`);
394
+ if (replaced.length) {
395
+ sentences.push(`This re-scan replaced ${replaced.length} proposal${replaced.length === 1 ? '' : 's'} from an ` +
396
+ `earlier scan of "${scanned.name}" — a scan reports what the repo holds now.`);
397
+ }
398
+ sentences.push('Nothing has been created yet: the user accepts or rejects each one there, and only an accept ' +
399
+ 'makes it a context document. Tell them where to look.');
400
+ }
401
+ return textResult({
402
+ project: project.name,
403
+ scanned_codebase: scanned.name,
404
+ proposed: rows.length,
405
+ project_scoped: projectScoped,
406
+ codebase_scoped: codebaseScoped,
407
+ replaced: replaced.length,
408
+ review_in: REVIEW_LOCATION,
409
+ instruction: sentences.join(' '),
410
+ });
411
+ }
412
+ catch (err) {
413
+ return errorResult(`propose_project_context failed: ${errorMessage(err)}`);
414
+ }
415
+ }
416
+ /** Stable hash of a tool's arguments.
417
+ *
418
+ * KEYS ARE SORTED and the digest is over canonical JSON, because
419
+ * `JSON.stringify` preserves insertion order: the same call built two ways
420
+ * would otherwise hash differently and a legitimate re-call would find no
421
+ * grant. `undefined` values are dropped for the same reason — an argument the
422
+ * agent omitted and one it passed as undefined are the same call.
423
+ *
424
+ * Nested objects are canonicalised too. A shallow sort would let
425
+ * `{a:{x:1,y:2}}` and `{a:{y:2,x:1}}` — the same call — miss each other. */
426
+ export function canonicalArgsHash(args) {
427
+ const canonical = (value) => {
428
+ if (Array.isArray(value))
429
+ return value.map(canonical);
430
+ if (value && typeof value === 'object') {
431
+ const out = {};
432
+ for (const key of Object.keys(value).sort()) {
433
+ const v = value[key];
434
+ if (v !== undefined)
435
+ out[key] = canonical(v);
436
+ }
437
+ return out;
438
+ }
439
+ return value;
440
+ };
441
+ return createHash('sha256').update(JSON.stringify(canonical(args))).digest('hex');
442
+ }
@@ -0,0 +1,93 @@
1
+ /** Shared by the stage editor and both tool servers. The stage body remains
2
+ * the record: each Markdown link names one invocation, never a tool-wide grant. */
3
+ export const WORKFLOW_TOOLS = [
4
+ { id: 'create_artifact', name: 'Create Artifact', detail: 'Write a user story, plan, spec, diagram, or interactive mock.', alwaysAllowed: false, advanced: false },
5
+ { id: 'update_artifact', name: 'Edit Artifact', detail: 'Revise an existing artifact.', alwaysAllowed: false, advanced: false },
6
+ { id: 'ask_question', name: 'Ask Question', detail: 'Ask for feedback, a decision, or approval. Always available.', alwaysAllowed: true, advanced: false },
7
+ { id: 'attach_screenshot', name: 'Screenshot', detail: 'Capture a web page and attach the image as an artifact.', alwaysAllowed: false, advanced: false },
8
+ { id: 'get_task', name: 'Read Work Item', detail: 'Read its description, artifacts, decisions, and feedback.', alwaysAllowed: true, advanced: false },
9
+ { id: 'get_project_context', name: 'Read Project Context', detail: 'Read project instructions, architecture, design, and conventions.', alwaysAllowed: true, advanced: false },
10
+ { id: 'get_document', name: 'Read Document', detail: 'Read a project context document or instruction in full.', alwaysAllowed: true, advanced: false },
11
+ { id: 'present_mocks', name: 'Present Mock', detail: 'Create interactive HTML options and open their design review.', alwaysAllowed: false, advanced: false },
12
+ { id: 'present_wireframes', name: 'Present Wireframe', detail: 'Create wireframes or diagrams and open their design review.', alwaysAllowed: false, advanced: false },
13
+ { id: 'create_task', name: 'Create Work Item', detail: 'Turn approved plans into work or capture a follow-up.', alwaysAllowed: false, advanced: false },
14
+ { id: 'update_task', name: 'Edit Work Item', detail: 'Update the description, name, status, or due date.', alwaysAllowed: false, advanced: false },
15
+ { id: 'add_comment', name: 'Add Comment', detail: 'Leave a lasting note or handover on a work item.', alwaysAllowed: false, advanced: false },
16
+ { id: 'record_context_exploration', name: 'Record Findings', detail: 'Save explored context and research on the work item.', alwaysAllowed: false, advanced: false },
17
+ { id: 'resolve_feedback', name: 'Resolve Feedback', detail: 'Mark feedback addressed after revising the artifact.', alwaysAllowed: false, advanced: false },
18
+ { id: 'propose_scope_change', name: 'Propose Scope Change', detail: 'Submit a change to approved scope for the user to decide.', alwaysAllowed: false, advanced: false },
19
+ { id: 'create_product_idea', name: 'Create Product Idea', detail: 'Capture future work without starting implementation.', alwaysAllowed: false, advanced: false },
20
+ { id: 'create_epic', name: 'Create Epic', detail: 'Group related work items in a project.', alwaysAllowed: false, advanced: true },
21
+ { id: 'create_sprint', name: 'Create Sprint', detail: 'Create an ordered batch of work.', alwaysAllowed: false, advanced: true },
22
+ { id: 'place_work_item', name: 'Move Work Item', detail: 'Place work in an epic or sprint, or remove that placement.', alwaysAllowed: false, advanced: true },
23
+ { id: 'reorder_backlog', name: 'Reorder Backlog', detail: 'Change the order in which backlog work is picked up.', alwaysAllowed: false, advanced: true },
24
+ { id: 'propose_project_context', name: 'Propose Project Context', detail: 'Submit project guidance for review in Project settings.', alwaysAllowed: false, advanced: true },
25
+ { id: 'create_step', name: 'Create Execution Step', detail: 'Record a trackable step inside the current workflow stage.', alwaysAllowed: false, advanced: true },
26
+ ];
27
+ export function workflowToolAlwaysAllowed(tool) {
28
+ return WORKFLOW_TOOLS.some(entry => entry.id === tool && entry.alwaysAllowed);
29
+ }
30
+ /** Names differ between harnesses; saved mentions use the same product action. */
31
+ export function workflowToolForName(name) {
32
+ const canonical = { get_work_item: 'get_task', create_work_item: 'create_task',
33
+ update_work_item: 'update_task', attach_image_artifact: 'attach_screenshot' }[name] ?? name;
34
+ return WORKFLOW_TOOLS.find(tool => tool.id === canonical)?.id;
35
+ }
36
+ export function toolMentionText(tool, id, approval = 'required') {
37
+ const entry = WORKFLOW_TOOLS.find(entry => entry.id === tool);
38
+ return `[@${entry.name}](ctrl-spc://tool/${tool}/${id}?approval=${workflowToolAlwaysAllowed(tool) ? 'not-required' : approval})`;
39
+ }
40
+ export function toolMentions(body) {
41
+ const mentions = [];
42
+ const pattern = /\[@[^\]\n]+\]\(ctrl-spc:\/\/tool\/([a-z_]+)\/([0-9a-f-]{36})\?approval=(required|not-required)\)/g;
43
+ for (const match of body.matchAll(pattern)) {
44
+ if (!WORKFLOW_TOOLS.some(entry => entry.id === match[1]))
45
+ continue;
46
+ mentions.push({
47
+ id: match[2], tool: match[1],
48
+ approval: workflowToolAlwaysAllowed(match[1]) ? 'not-required' : match[3],
49
+ from: match.index, to: match.index + match[0].length,
50
+ });
51
+ }
52
+ return mentions;
53
+ }
54
+ export function validateToolMentions(body) {
55
+ const mentions = toolMentions(body);
56
+ if ((body.match(/ctrl-spc:\/\/tool\//g) ?? []).length !== mentions.length) {
57
+ throw new Error('A tool mention is incomplete or unsupported. Remove it and insert the tool again with @.');
58
+ }
59
+ if (new Set(mentions.map(mention => mention.id)).size !== mentions.length) {
60
+ throw new Error('Two tool mentions share an identity. Reinsert the copied mention with @ so each use has its own setting.');
61
+ }
62
+ }
63
+ export function displayToolMentions(body) {
64
+ let display = body;
65
+ for (const mention of toolMentions(body).reverse()) {
66
+ const name = WORKFLOW_TOOLS.find(tool => tool.id === mention.tool).name;
67
+ const permission = workflowToolAlwaysAllowed(mention.tool) ? 'always allowed'
68
+ : mention.approval === 'required' ? 'approval required' : 'no approval needed';
69
+ display = display.slice(0, mention.from) + `**@${name}** (${permission})` + display.slice(mention.to);
70
+ }
71
+ return display;
72
+ }
73
+ export const WORKFLOW_TOOL_TEACHING = 'Tool mentions in workflow stage documents are instructions for individual uses. ' +
74
+ 'Pass workflow_tool_instance with the id in that mention when calling the named tool. ' +
75
+ 'Follow the surrounding instruction and stage order; never borrow another mention to bypass approval. ' +
76
+ 'approval=not-required means the user already permits THAT use: perform it without asking again. ' +
77
+ 'approval=required means CALL THE NAMED TOOL with its complete intended arguments and instance id: ' +
78
+ 'the tool itself opens the permission question before writing anything. Do not open a separate ask_question for permission to use that tool. ' +
79
+ 'When the user approves that tool question, retry the same call with exactly the same arguments. Read tools and Ask Question never need permission. Permission does not grant access to another project or change your agent level. If an action is only available to your coordinator, escalate that action with its mention id. ' +
80
+ 'Approval to use a tool is separate from approval of its output: still ask every review question written in the stage. Present Mock and Present Wireframe open their own output review; do not ask a duplicate review question. Proposing a scope or context change never accepts it. ' +
81
+ 'If your level cannot ask the user, escalate the review to the conversation owner and stop.';
82
+ /** Agents may preserve existing settings, but cannot author their own grants. */
83
+ export function assertAgentToolMentions(body, previous = '') {
84
+ validateToolMentions(body);
85
+ const before = toolMentions(previous);
86
+ for (const mention of toolMentions(body)) {
87
+ if (workflowToolAlwaysAllowed(mention.tool) || mention.approval === 'required')
88
+ continue;
89
+ if (!before.some(old => old.id === mention.id && old.tool === mention.tool && old.approval === mention.approval)) {
90
+ throw new Error('Only the user can turn off approval for a workflow tool mention. Keep approval required and let them change it in Workflows.');
91
+ }
92
+ }
93
+ }