@ctrl-spc/cs 0.7.10 → 0.7.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { listStructureArtifactsHandler, getStructureArtifactHandler, searchAgentCardsHandler, createStructureArtifactHandler, updateStructureArtifactHandler } from './product-tools.js';
2
+ import { createEpicHandler, createSprintHandler, listStructureHandler, updateStructureHandler } from './product-tools.js';
3
+ export { createEpicHandler, createSprintHandler, listStructureHandler, updateStructureHandler } from './product-tools.js';
4
+ import { listArtifactFoldersHandler, setArtifactFolderHandler, workItemDependencyHandler } from './product-tools.js';
1
5
  import { presentDesign } from './design-review.js';
2
6
  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
7
  export { canonicalArgsHash, placeWorkItemHandler, resolveFeedbackHandler, getDocumentHandler, proposeProjectContextHandler } from './product-tools.js';
@@ -248,7 +252,7 @@ function validateGrounding(value) {
248
252
  * reused verbatim so get_task and create_artifact hand back the same shape, plus
249
253
  * `revision` so the agent has the optimistic-concurrency token update_artifact
250
254
  * needs for `expected_revision` (mirrors how get_task returns tasks.revision). */
251
- const ARTIFACT_COLUMNS = 'id,task_id,type,format,title,purpose_key,system_kind,content,storage_path,created_by,from_agent,created_at,revision';
255
+ const ARTIFACT_COLUMNS = 'id,task_id,type,format,title,purpose_key,system_kind,content,storage_path,created_by,from_agent,created_at,revision,folder_name';
252
256
  /** Columns a decision row exposes to the agent (D3) — reused verbatim so get_task
253
257
  * and ask_question hand back the same shape. RLS `decisions_select` grants the
254
258
  * signed-in user read via `app.can_access_task`. */
@@ -835,6 +839,7 @@ connectionTodoId = null) {
835
839
  .from('artifacts')
836
840
  .insert({
837
841
  task_id: args.task_id,
842
+ folder_name: args.folder_name ?? null,
838
843
  type: args.type,
839
844
  format: args.format ?? 'md',
840
845
  ...(args.title?.trim()
@@ -2105,357 +2110,6 @@ connectionTodoId = null) {
2105
2110
  return errorResult(`create_task failed: ${err.message}`);
2106
2111
  }
2107
2112
  }
2108
- /* ---------------------------------------------------------------------------
2109
- * Agile skeleton (feature 32, Slice 1) — `create_epic` / `create_sprint`,
2110
- * beside the grown `create_task` above.
2111
- *
2112
- * The agent lays out the WHOLE skeleton from one feature ask: an epic (what
2113
- * for), work items under it, sprints (ordered batches). Step −1 rulings
2114
- * (ux.md, 2026-07-30): DIRECT WRITE as the user under RLS — a skeleton of
2115
- * backlog items is un-started work, closer to an idea than an edit, so no
2116
- * propose queue; SMALL ORTHOGONAL TOOLS, not a composite draft pass;
2117
- * IDEMPOTENCE = read-before-write plus a duplicate-name refusal that teaches
2118
- * extending the existing structure instead of minting a parallel one.
2119
- *
2120
- * THE CALIBRATION RULE LIVES IN THE DESCRIPTIONS (teach-in-the-description
2121
- * doctrine): agents dramatically overestimate how long building takes because
2122
- * their training is saturated with human engineering timelines. The
2123
- * descriptions state the bias outright, size sprints as ordered batches of
2124
- * agent-executable work, and forbid duration estimates on items unless the
2125
- * user asked. Nothing here stores an estimate — there is no column for one,
2126
- * deliberately.
2127
- *
2128
- * GRANTS, verified against the seeded rows rather than assumed: members hold
2129
- * ('member','epic','create') (20260727080000 §4) and ('member','sprint',
2130
- * 'create') (20260708000000 init.sql §9), and no later migration revokes
2131
- * either — so the epics_insert / sprints_insert RLS policies already admit
2132
- * every org member and NO new grants migration is needed. A denial for some
2133
- * future role surfaces through the normal error path.
2134
- *
2135
- * NO cliv2_agent_outputs attribution row is written for an epic or a sprint:
2136
- * the kind check is ('artifact','comment','task','decision') and widening it
2137
- * is a migration this slice does not need — the skeleton's provenance is
2138
- * legible through its work items, which create_task already attributes.
2139
- * ------------------------------------------------------------------------- */
2140
- /** Date args are refused by shape HERE (the resolveClient idiom) so a
2141
- * malformed date gets a clean tool error instead of a Postgres cast failure
2142
- * after other work happened. */
2143
- const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
2144
- /** The project a skeleton tool writes into, resolved by id: UUID-shape-checked
2145
- * before the read, and a miss (no such project, or one outside the caller's
2146
- * orgs — RLS makes those indistinguishable on purpose) refused by id. */
2147
- async function resolveSkeletonProject(client, projectIdArg, tool) {
2148
- const projectId = typeof projectIdArg === 'string' ? projectIdArg.trim() : '';
2149
- if (!projectId || !UUID_RE.test(projectId)) {
2150
- return {
2151
- ok: false,
2152
- error: errorResult(`${tool}: "${projectId}" is not a project id. Call list_tasks to see your projects and their ids. ` +
2153
- 'Nothing was created.'),
2154
- };
2155
- }
2156
- const row = await must(client.from('projects').select('id, name').eq('id', projectId).maybeSingle());
2157
- if (!row) {
2158
- return {
2159
- ok: false,
2160
- error: errorResult(`${tool}: no project found for id "${projectId}". Nothing was created.`),
2161
- };
2162
- }
2163
- return { ok: true, value: row };
2164
- }
2165
- /** Refuse a malformed date arg by name; `undefined` passes (the arg is
2166
- * optional everywhere it appears). */
2167
- function validDateArg(tool, field, value) {
2168
- if (value === undefined)
2169
- return null;
2170
- if (!ISO_DATE_RE.test(value)) {
2171
- return errorResult(`${tool}: ${field} must be an ISO date (YYYY-MM-DD), got "${value}". Nothing was created.`);
2172
- }
2173
- return null;
2174
- }
2175
- /**
2176
- * Create an Epic in a project — writes public.epics as the user (RLS
2177
- * epics_insert, the 'epic'/'create' grant every member holds).
2178
- *
2179
- * THE DUPLICATE-NAME REFUSAL IS THE IDEMPOTENCE MECHANISM (Step −1 ruling 4):
2180
- * a re-run, or a "feature ABC v2" ask, must EXTEND the existing epic, not
2181
- * mint a second one — 24c's updating-beats-creating rule applied to
2182
- * structure. The match is case-insensitive on the trimmed name, against LIVE
2183
- * epics only: an archived epic has left the picker and can take no new work,
2184
- * so a fresh epic re-using its name is a legitimate new start, not a
2185
- * duplicate. The refusal lists the project's live epics so it doubles as the
2186
- * read surface the skeleton pass otherwise lacks (no epic-listing tool ships
2187
- * in this slice).
2188
- */
2189
- export async function createEpicHandler(client, args) {
2190
- try {
2191
- const project = await resolveSkeletonProject(client, args.project_id, 'create_epic');
2192
- if (!project.ok)
2193
- return project.error;
2194
- const name = typeof args.name === 'string' ? args.name.trim() : '';
2195
- if (!name)
2196
- return errorResult('create_epic requires a non-empty name. Nothing was created.');
2197
- for (const [field, value] of [
2198
- ['start_date', args.start_date],
2199
- ['target_date', args.target_date],
2200
- ]) {
2201
- const refused = validDateArg('create_epic', field, value);
2202
- if (refused)
2203
- return refused;
2204
- }
2205
- // Mirrors the DB's epics_target_not_before_start check, refused where the
2206
- // agent can fix it instead of as a constraint violation.
2207
- if (args.start_date && args.target_date && args.target_date < args.start_date) {
2208
- return errorResult(`create_epic: target_date (${args.target_date}) is before start_date (${args.start_date}). ` +
2209
- 'Nothing was created.');
2210
- }
2211
- const epics = (await must(client.from('epics').select('id, name, archived_at').eq('project_id', project.value.id))) ?? [];
2212
- const live = epics.filter((epic) => epic.archived_at === null);
2213
- const duplicate = live.find((epic) => epic.name.trim().toLowerCase() === name.toLowerCase());
2214
- if (duplicate) {
2215
- const listing = live.map((epic) => `"${epic.name}" (${epic.id})`).join(', ');
2216
- return errorResult(`create_epic: an epic named "${duplicate.name}" already exists in "${project.value.name}" ` +
2217
- `(id ${duplicate.id}). Do not create a duplicate — EXTEND the existing epic: create the new ` +
2218
- `work items with epic_id ${duplicate.id}, and reshape what already hangs under it. A re-run ` +
2219
- 'or a "v2" ask extends the existing skeleton; it never mints a second one. Live epics in ' +
2220
- `this project: ${listing}. Nothing was created.`);
2221
- }
2222
- const epic = await must(client
2223
- .from('epics')
2224
- .insert({
2225
- project_id: project.value.id,
2226
- name,
2227
- ...(args.description?.trim() ? { description: args.description.trim() } : {}),
2228
- ...(args.start_date ? { start_date: args.start_date } : {}),
2229
- ...(args.target_date ? { target_date: args.target_date } : {}),
2230
- })
2231
- .select('id, project_id, name, description, start_date, target_date, created_at')
2232
- .single());
2233
- if (!epic)
2234
- throw new Error('Epic insert returned no row.');
2235
- return textResult({
2236
- epic,
2237
- note: 'Created as an empty epic — no work has started and none is declared. Place work items under ' +
2238
- "it with create_task's epic_id, sequence them with create_sprint + sprint_id, and add NO " +
2239
- 'duration estimates to anything unless the user asked: sequencing is the value.',
2240
- });
2241
- }
2242
- catch (err) {
2243
- return errorResult(`create_epic failed: ${errorMessage(err)}`);
2244
- }
2245
- }
2246
- /**
2247
- * Create a Sprint in a project — writes public.sprints as the user (RLS
2248
- * sprints_insert, the 'sprint'/'create' grant every member holds). Dates are
2249
- * optional (28 made the columns nullable): for agent-executed work a sprint is
2250
- * an ORDERED BATCH, not a time-box, and an invented fortnight is exactly the
2251
- * human-timeline bias this feature exists to counter.
2252
- *
2253
- * Same duplicate-name posture as create_epic, for the same reason: a re-run
2254
- * must extend the existing sprint sequence, never mint a parallel one.
2255
- */
2256
- export async function createSprintHandler(client, args) {
2257
- try {
2258
- const project = await resolveSkeletonProject(client, args.project_id, 'create_sprint');
2259
- if (!project.ok)
2260
- return project.error;
2261
- const name = typeof args.name === 'string' ? args.name.trim() : '';
2262
- if (!name)
2263
- return errorResult('create_sprint requires a non-empty name. Nothing was created.');
2264
- for (const [field, value] of [
2265
- ['start_date', args.start_date],
2266
- ['end_date', args.end_date],
2267
- ]) {
2268
- const refused = validDateArg('create_sprint', field, value);
2269
- if (refused)
2270
- return refused;
2271
- }
2272
- // Mirrors the sprints table's own start <= end check (init.sql), refused
2273
- // where the agent can fix it.
2274
- if (args.start_date && args.end_date && args.end_date < args.start_date) {
2275
- return errorResult(`create_sprint: end_date (${args.end_date}) is before start_date (${args.start_date}). ` +
2276
- 'Nothing was created.');
2277
- }
2278
- const sprints = (await must(client.from('sprints').select('id, name, archived_at').eq('project_id', project.value.id))) ?? [];
2279
- const live = sprints.filter((sprint) => sprint.archived_at === null);
2280
- const duplicate = live.find((sprint) => sprint.name.trim().toLowerCase() === name.toLowerCase());
2281
- if (duplicate) {
2282
- const listing = live.map((sprint) => `"${sprint.name}" (${sprint.id})`).join(', ');
2283
- return errorResult(`create_sprint: a sprint named "${duplicate.name}" already exists in "${project.value.name}" ` +
2284
- `(id ${duplicate.id}). Reuse it — place items into it with create_task's sprint_id — or name ` +
2285
- 'the NEXT batch in the sequence; a re-run extends the existing sprint sequence, it never ' +
2286
- `mints a parallel one. Live sprints in this project: ${listing}. Nothing was created.`);
2287
- }
2288
- const sprint = await must(client
2289
- .from('sprints')
2290
- .insert({
2291
- project_id: project.value.id,
2292
- name,
2293
- ...(args.start_date ? { start_date: args.start_date } : {}),
2294
- ...(args.end_date ? { end_date: args.end_date } : {}),
2295
- })
2296
- .select('id, project_id, name, start_date, end_date, created_at')
2297
- .single());
2298
- if (!sprint)
2299
- throw new Error('Sprint insert returned no row.');
2300
- return textResult({
2301
- sprint,
2302
- note: 'Created. Sprints for agent-executed work are ordered batches: the first holds what unblocks ' +
2303
- "everything else, each sized to what agents deliver in a session or a day. Place items with " +
2304
- "create_task's sprint_id, and add NO duration estimates unless the user asked.",
2305
- });
2306
- }
2307
- catch (err) {
2308
- return errorResult(`create_sprint failed: ${errorMessage(err)}`);
2309
- }
2310
- }
2311
- const STRUCTURE_TABLE = { epic: 'epics', sprint: 'sprints' };
2312
- /**
2313
- * WHAT STRUCTURE THIS PROJECT HAS — the read that made "read before you write"
2314
- * possible (I13).
2315
- *
2316
- * LIVE ONES BY DEFAULT, because that is what "what exists" means to an agent
2317
- * about to add to it: an archived epic is not something to extend, and listing
2318
- * it invites a duplicate-name refusal the agent cannot act on. `include_archived`
2319
- * is deliberately NOT offered — nothing in this phase's Gherkin needs it, and
2320
- * YAGNI beats a flag with no caller.
2321
- *
2322
- * BOTH KINDS IN ONE CALL. They are always wanted together, and two tools would
2323
- * mean two round trips for the one question an agent actually has.
2324
- */
2325
- export async function listStructureHandler(client, args) {
2326
- try {
2327
- const project = await resolveSkeletonProject(client, args.project_id, 'list_structure');
2328
- if (!project.ok)
2329
- return project.error;
2330
- const [epics, sprints] = await Promise.all([
2331
- must(client
2332
- .from('epics')
2333
- .select('id, name, description, start_date, target_date, created_at')
2334
- .eq('project_id', project.value.id)
2335
- .is('archived_at', null)
2336
- .order('created_at', { ascending: true })),
2337
- must(client
2338
- .from('sprints')
2339
- .select('id, name, start_date, end_date, created_at')
2340
- .eq('project_id', project.value.id)
2341
- .is('archived_at', null)
2342
- .order('created_at', { ascending: true })),
2343
- ]);
2344
- return textResult({
2345
- project: project.value,
2346
- epics: epics ?? [],
2347
- sprints: sprints ?? [],
2348
- note: 'Live epics and sprints only. Extend these rather than creating a near-duplicate — a ' +
2349
- 'case-insensitive name match is refused. Place a work item with place_work_item, and see ' +
2350
- "where items already sit in list_tasks's epic and sprint fields.",
2351
- });
2352
- }
2353
- catch (err) {
2354
- return errorResult(`list_structure failed: ${errorMessage(err)}`);
2355
- }
2356
- }
2357
- /**
2358
- * RENAME OR ARCHIVE an epic or a sprint (I13).
2359
- *
2360
- * THE ARCHIVE RULE IS THE DATABASE'S, NOT THIS FUNCTION'S. A trigger refuses
2361
- * archiving anything that still has unfinished work
2362
- * (`app.guard_epic_archive` / `app.guard_sprint_archive`, SQLSTATE PT423). This
2363
- * does not pre-check it — a client-side check would read a snapshot of a table
2364
- * this write does not touch, so it can be stale, and duplicating the rule means
2365
- * two places to keep in step. It translates the refusal instead.
2366
- *
2367
- * THE MACHINE TOKEN NEVER REACHES THE AGENT. The raise's message is
2368
- * `epic_archive_blocked`, which the shipped web UI is explicit must never be
2369
- * rendered; the SAME rule applies to a tool result, which is read by an agent
2370
- * that will repeat it to the user. Keyed off SQLSTATE, exactly as
2371
- * `archiveErrorMessage` in ProjectSettings.tsx does, and the sentence says what
2372
- * would unblock it — the Gherkin asserts all three.
2373
- */
2374
- export async function updateStructureHandler(client, args) {
2375
- try {
2376
- const kind = typeof args.kind === 'string' ? args.kind.trim().toLowerCase() : '';
2377
- if (kind !== 'epic' && kind !== 'sprint') {
2378
- return errorResult(`update_structure: kind must be "epic" or "sprint", got "${args.kind ?? ''}". Nothing was changed.`);
2379
- }
2380
- const table = STRUCTURE_TABLE[kind];
2381
- const id = typeof args.id === 'string' ? args.id.trim() : '';
2382
- if (!id || !UUID_RE.test(id)) {
2383
- return errorResult(`update_structure: "${id}" is not a ${kind} id. Call list_structure to see them. Nothing was changed.`);
2384
- }
2385
- const name = typeof args.name === 'string' ? args.name.trim() : undefined;
2386
- if (args.name !== undefined && !name) {
2387
- return errorResult(`update_structure: name cannot be blank. Nothing was changed.`);
2388
- }
2389
- if (name === undefined && args.archived === undefined) {
2390
- return errorResult('update_structure: pass a new name, archived, or both — there is nothing to change. ' +
2391
- 'Nothing was changed.');
2392
- }
2393
- const current = await must(client.from(table).select('id, project_id, name, archived_at').eq('id', id).maybeSingle());
2394
- if (!current) {
2395
- return errorResult(`update_structure: no ${kind} found for id "${id}". Nothing was changed.`);
2396
- }
2397
- /* THE SAME DUPLICATE-NAME RULE THE CREATE TOOLS ENFORCE. Without it, rename
2398
- is a way around the refusal `create_epic` gives — two live epics with one
2399
- name, reached by creating under a throwaway name and renaming. Scoped to
2400
- LIVE rows in the same project, and it ignores the row being renamed so
2401
- that setting a name to itself is not a conflict. */
2402
- if (name !== undefined && name.toLowerCase() !== current.name.trim().toLowerCase()) {
2403
- const siblings = (await must(client
2404
- .from(table)
2405
- .select('id, name')
2406
- .eq('project_id', current.project_id)
2407
- .is('archived_at', null))) ?? [];
2408
- const clash = siblings.find((row) => row.id !== id && row.name.trim().toLowerCase() === name.toLowerCase());
2409
- if (clash) {
2410
- return errorResult(`update_structure: a live ${kind} named "${clash.name}" already exists in this project ` +
2411
- `(id ${clash.id}). Use that one, or pick a different name. Nothing was changed.`);
2412
- }
2413
- }
2414
- const patch = {};
2415
- if (name !== undefined)
2416
- patch.name = name;
2417
- /* THE DATABASE OWNS THE TIMESTAMP. The guard trigger coalesces whatever is
2418
- sent on the archiving transition to `now()`, so this sends a marker
2419
- instant rather than pretending to choose one; restoring sends null, which
2420
- the trigger leaves alone. */
2421
- if (args.archived !== undefined)
2422
- patch.archived_at = args.archived ? new Date().toISOString() : null;
2423
- const { data, error } = await client
2424
- .from(table)
2425
- .update(patch)
2426
- .eq('id', id)
2427
- .select('id, project_id, name, archived_at')
2428
- .maybeSingle();
2429
- if (error) {
2430
- /* PT423 — the archive guard. The sentence is OURS: the raise's message is
2431
- a machine token (`epic_archive_blocked`) and must never be repeated to
2432
- the user. The database's own `hint` says the same thing, but keying off
2433
- the stable SQLSTATE rather than parsing prose is what the web does. */
2434
- if (error.code === 'PT423') {
2435
- return errorResult(`update_structure: this ${kind} still has work items that are not done, so it cannot be ` +
2436
- `archived. Finish them or move them to another ${kind} first, then archive it. ` +
2437
- 'Nothing was changed.');
2438
- }
2439
- throw new Error(error.message);
2440
- }
2441
- /* RLS REFUSING A WRITE RETURNS NO ERROR AND NO ROW — the silent-failure
2442
- shape this repo has been bitten by before. Reported as a refusal rather
2443
- than as success. */
2444
- if (!data) {
2445
- return errorResult(`update_structure: the ${kind} could not be changed — it may have been removed, or you may ` +
2446
- 'not have permission. Nothing was changed.');
2447
- }
2448
- return textResult({
2449
- [kind]: data,
2450
- note: args.archived
2451
- ? `Archived. Its work items survive and keep their other placements.`
2452
- : 'Updated.',
2453
- });
2454
- }
2455
- catch (err) {
2456
- return errorResult(`update_structure failed: ${errorMessage(err)}`);
2457
- }
2458
- }
2459
2113
  /* ═══════════════════════ 16d SLICE 3a — THE PERMISSION GATE ═══════════════
2460
2114
  `.implementations/z-done-16d-inbox-and-permission/ux.md` § "3a — One tool asks
2461
2115
  first, and waits".
@@ -8680,9 +8334,18 @@ export const TOOL_NAMES = [
8680
8334
  'create_epic',
8681
8335
  'create_sprint',
8682
8336
  // !Cleanup Phase 5 (I13, I14): the structure was write-only until these.
8337
+ 'search_agent_cards', 'list_structure_artifacts',
8338
+ 'get_structure_artifact',
8339
+ 'create_structure_artifact',
8340
+ 'update_structure_artifact',
8683
8341
  'list_structure',
8684
8342
  'update_structure',
8685
8343
  'place_work_item',
8344
+ 'list_artifact_folders',
8345
+ 'set_artifact_folder',
8346
+ 'list_work_item_dependencies',
8347
+ 'add_work_item_dependency',
8348
+ 'remove_work_item_dependency',
8686
8349
  'begin_work',
8687
8350
  'end_work',
8688
8351
  // 18c Slice 1: the run's own activity, unasked, on the request's card.
@@ -8935,6 +8598,7 @@ runTodoIdSource = null) {
8935
8598
  inputSchema: {
8936
8599
  workflow_tool_instance: z.string().uuid().optional().describe('The id of this tool mention in the active workflow stage. ' + WORKFLOW_TOOL_TEACHING),
8937
8600
  task_id: z.string(),
8601
+ folder_name: z.string().max(80).nullable().optional().describe('Optional folder. Read list_artifact_folders first to reuse an existing name.'),
8938
8602
  type: z.enum(['analysis', 'plan', 'spec', 'diagram', 'mock', 'wireframe', 'user_story']),
8939
8603
  format: z.enum(['md', 'html', 'json', 'svg']).optional().describe('Defaults to md'),
8940
8604
  title: z.string().min(1).optional(),
@@ -9259,6 +8923,7 @@ runTodoIdSource = null) {
9259
8923
  "create_task's sprint_id.",
9260
8924
  inputSchema: {
9261
8925
  project_id: z.string().describe('Project id the sprint belongs to'),
8926
+ description: z.string().optional(),
9262
8927
  name: z.string().min(1).describe('The batch name — refused if a live sprint already carries it'),
9263
8928
  start_date: z.string().optional().describe('ISO date (YYYY-MM-DD) — only for a real calendar constraint'),
9264
8929
  end_date: z.string().optional().describe('ISO date (YYYY-MM-DD) — only for a real calendar constraint'),
@@ -9283,6 +8948,7 @@ runTodoIdSource = null) {
9283
8948
  'because they are not something to extend. To see where each work item already sits, read ' +
9284
8949
  "list_tasks's epic and sprint fields. Read-only.",
9285
8950
  inputSchema: {
8951
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
9286
8952
  project_id: z.string().describe('Project whose epics and sprints to list'),
9287
8953
  },
9288
8954
  }, async (args) => {
@@ -9290,7 +8956,7 @@ runTodoIdSource = null) {
9290
8956
  return listStructureHandler(client, args);
9291
8957
  });
9292
8958
  server.registerTool('update_structure', {
9293
- description: 'Rename or archive an epic or a sprint, WITH THE USER\'S PERMISSION nothing changes until ' +
8959
+ description: 'Edit an epic or sprint name, description, dates, or archive state. Null clears a date; use target_date for epics and end_date for sprints. Follow workflow tool permission when provided. Otherwise nothing changes until ' +
9294
8960
  'they approve, and you will be asked to call again with identical arguments once they have. ' +
9295
8961
  'NEEDS AN OPEN WORK SESSION (begin_work) because the approval is recorded against it: if you ' +
9296
8962
  'have no work item to open one on, say what you would rename or archive and let the user do ' +
@@ -9303,8 +8969,13 @@ runTodoIdSource = null) {
9303
8969
  'rename is refused if another live epic or sprint of that project already carries the name, ' +
9304
8970
  'the same rule create_epic and create_sprint enforce.',
9305
8971
  inputSchema: {
8972
+ workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING),
9306
8973
  kind: z.enum(['epic', 'sprint']).describe('Which kind of thing to change'),
9307
8974
  id: z.string().describe('Its id, from list_structure'),
8975
+ description: z.string().optional(),
8976
+ start_date: z.string().nullable().optional(),
8977
+ end_date: z.string().nullable().optional(),
8978
+ target_date: z.string().nullable().optional(),
9308
8979
  name: z.string().optional().describe('A new name. Omit to leave it alone'),
9309
8980
  archived: z
9310
8981
  .boolean()
@@ -9320,10 +8991,76 @@ runTodoIdSource = null) {
9320
8991
  creating it, so it sits in the same class as `create_epic` and
9321
8992
  `create_sprint` rather than with `create_task`. */
9322
8993
  const structureArgs = args;
9323
- const what = structureArgs.archived === true ? 'Archive' : 'Rename';
9324
- return runGated(client, userId, openSessions.get(connectionId) ?? null, 'update_structure', structureArgs, `${what} the ${structureArgs.kind} ${structureArgs.name ? `to “${structureArgs.name}”` : ''}`.trim(), () => updateStructureHandler(client, structureArgs),
9325
- // 18k Slice 10b the run this call belongs to, so approving it resumes the work.
9326
- await runTodoIdOf());
8994
+ const write = () => updateStructureHandler(client, structureArgs);
8995
+ const summary = `${structureArgs.archived === true ? 'Archive' : structureArgs.archived === false ? 'Restore' : 'Update'} the ${structureArgs.kind} details`;
8996
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'update_structure', args, write, async () => runGated(client, userId, openSessions.get(connectionId) ?? null, 'update_structure', args, summary, write, await runTodoIdOf()), await runTodoIdOf());
8997
+ });
8998
+ server.registerTool('search_agent_cards', {
8999
+ description: 'Find your agent conversations by literal text in titles or messages, or by work item they worked on. Includes archived cards unless archived is specified. Follow next_offset to read every page. Read-only.',
9000
+ inputSchema: { query: z.string().max(500).optional(), work_item_id: z.string().uuid().optional(), archived: z.boolean().optional(), limit: z.number().int().min(1).max(1000).optional(), offset: z.number().int().min(0).optional() },
9001
+ }, async (args) => searchAgentCardsHandler(client, args));
9002
+ server.registerTool('list_structure_artifacts', {
9003
+ description: 'Read active artifacts attached directly to an epic or sprint.',
9004
+ inputSchema: { kind: z.enum(['epic', 'sprint']), structure_id: z.string().uuid(), workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING) },
9005
+ }, async (args) => {
9006
+ touchSession(connectionId);
9007
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'list_structure_artifacts', args, () => listStructureArtifactsHandler(client, args.kind, args.structure_id), undefined, await runTodoIdOf());
9008
+ });
9009
+ server.registerTool('get_structure_artifact', {
9010
+ description: 'Read a full epic or sprint artifact and its current revision.',
9011
+ inputSchema: { artifact_id: z.string().uuid(), workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING) },
9012
+ }, async (args) => {
9013
+ touchSession(connectionId);
9014
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'get_structure_artifact', args, () => getStructureArtifactHandler(client, args.artifact_id), undefined, await runTodoIdOf());
9015
+ });
9016
+ server.registerTool('create_structure_artifact', {
9017
+ description: 'Create a titled artifact on an epic or sprint. It appears on its management page.',
9018
+ inputSchema: { kind: z.enum(['epic', 'sprint']), structure_id: z.string().uuid(), title: z.string().min(1).max(200), type: z.enum(['analysis', 'plan', 'spec', 'user_story', 'diagram', 'mock', 'wireframe']), format: z.enum(['md', 'html', 'json', 'svg']).optional(), content: z.string().min(1), workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING) },
9019
+ }, async (args) => {
9020
+ touchSession(connectionId);
9021
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'create_structure_artifact', args, () => createStructureArtifactHandler(client, userId, args), undefined, await runTodoIdOf());
9022
+ });
9023
+ server.registerTool('update_structure_artifact', {
9024
+ description: 'Update an epic or sprint artifact using the revision you read. Concurrent edits refuse without overwriting. archived=true removes it; false restores it.',
9025
+ inputSchema: { artifact_id: z.string().uuid(), expected_revision: z.number().int().positive(), title: z.string().min(1).max(200).optional(), type: z.enum(['analysis', 'plan', 'spec', 'user_story', 'diagram', 'mock', 'wireframe']).optional(), format: z.enum(['md', 'html', 'json', 'svg']).optional(), content: z.string().optional(), archived: z.boolean().optional(), workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING) },
9026
+ }, async (args) => {
9027
+ touchSession(connectionId);
9028
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'update_structure_artifact', args, () => updateStructureArtifactHandler(client, args), undefined, await runTodoIdOf());
9029
+ });
9030
+ server.registerTool('list_artifact_folders', {
9031
+ description: 'Read existing artifact folder names and counts on a work item before choosing a folder.',
9032
+ inputSchema: { work_item_id: z.string().uuid(), workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING) },
9033
+ }, async (args) => {
9034
+ touchSession(connectionId);
9035
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'list_artifact_folders', args, () => listArtifactFoldersHandler(client, args.work_item_id), undefined, await runTodoIdOf());
9036
+ });
9037
+ server.registerTool('set_artifact_folder', {
9038
+ description: 'Move an artifact into one folder, replacing its previous folder. Null clears it to Unfiled. Content and approval stay unchanged.',
9039
+ inputSchema: { artifact_id: z.string().uuid(), folder_name: z.string().max(80).nullable(), workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING) },
9040
+ }, async (args) => {
9041
+ touchSession(connectionId);
9042
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'set_artifact_folder', args, () => setArtifactFolderHandler(client, args.artifact_id, args.folder_name), undefined, await runTodoIdOf());
9043
+ });
9044
+ server.registerTool('list_work_item_dependencies', {
9045
+ description: 'Read blockers, dependents, and whether this work item is waiting.',
9046
+ inputSchema: { work_item_id: z.string().uuid(), workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING) },
9047
+ }, async (args) => {
9048
+ touchSession(connectionId);
9049
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'list_work_item_dependencies', args, () => workItemDependencyHandler(client, 'list', args), undefined, await runTodoIdOf());
9050
+ });
9051
+ server.registerTool('add_work_item_dependency', {
9052
+ description: 'Make work_item_id wait for depends_on_work_item_id to reach Done. Same-project work items only; self-links and cycles are refused.',
9053
+ inputSchema: { work_item_id: z.string().uuid(), depends_on_work_item_id: z.string().uuid(), workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING) },
9054
+ }, async (args) => {
9055
+ touchSession(connectionId);
9056
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'add_work_item_dependency', args, () => workItemDependencyHandler(client, 'add', args), undefined, await runTodoIdOf());
9057
+ });
9058
+ server.registerTool('remove_work_item_dependency', {
9059
+ description: 'Remove exactly this dependency edge without changing either work item status.',
9060
+ inputSchema: { work_item_id: z.string().uuid(), depends_on_work_item_id: z.string().uuid(), workflow_tool_instance: z.string().uuid().optional().describe(WORKFLOW_TOOL_TEACHING) },
9061
+ }, async (args) => {
9062
+ touchSession(connectionId);
9063
+ return runWorkflowTool(client, userId, openSessions.get(connectionId) ?? null, 'remove_work_item_dependency', args, () => workItemDependencyHandler(client, 'remove', args), undefined, await runTodoIdOf());
9327
9064
  });
9328
9065
  server.registerTool('place_work_item', {
9329
9066
  description: 'Move a work item into an epic and/or a sprint, after it was created. Pass work_item_id and ' +