@ctrl-spc/cs 0.7.10 → 0.7.12

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.
@@ -1,3 +1,5 @@
1
+ import { createEpicHandler, createSprintHandler, listStructureHandler, updateStructureHandler, listStructureArtifactsHandler, getStructureArtifactHandler, searchAgentCardsHandler, createStructureArtifactHandler, updateStructureArtifactHandler } from '../product-tools.js';
2
+ import { listArtifactFoldersHandler, setArtifactFolderHandler, workItemDependencyHandler } from '../product-tools.js';
1
3
  /**
2
4
  * ═══ AGENT PANEL v3: the tools server, and the per-level allowlist. ═══
3
5
  *
@@ -125,11 +127,12 @@ import { returned } from './client.js';
125
127
  import { readableWriteError, FIREWALL_WRITING_RULE } from '../firewall.js';
126
128
  import { rememberSecret, redactArgs } from './secrets.js';
127
129
  import { workBrief } from './prompt.js';
130
+ import { listCodexModels } from './codex-models.js';
128
131
  /* The harness this daemon spawns with, which is the process this server runs
129
132
  in. It is what `panel3_runs.harness` is written from at spawn, so reading it
130
133
  here is the same fact without a second round trip. */
131
134
  import { harness } from './spawn.js';
132
- import { listCodebases } from '../codebases.js';
135
+ import { listCodebases, listWorkItemCodebaseTargets, editWorkItemCodebaseTarget } from '../codebases.js';
133
136
  import { buildWorkflow, duplicateWorkflow, editWorkflow, readWorkflow, rewordStage, workflowToolPermission, validateWorkflowToolTarget, WORKFLOW_TOOL_TEACHING, WORKFLOW_AUTHORING_TEACHING } from '../workflows.js';
134
137
  /* ═══ 38-panel3-steps: THE FIFTH NEUTRAL MODULE, AND A DECISION LIKE THE OTHERS.
135
138
  ═══ Stages and steps are the work item's own record, shared by both
@@ -298,10 +301,9 @@ async function asked(caller, args, askPerson) {
298
301
  p_question: question,
299
302
  p_category: category,
300
303
  p_context: context,
301
- /* Undefined, not a default: the defaults live in the function signature,
302
- so there is one place that says what an unshaped question is. */
303
- p_answer_mode: answer_mode,
304
- p_options: options,
304
+ // Token-aware RPC overloads require these arguments, even for a free-text question.
305
+ p_answer_mode: answer_mode ?? 'free_text',
306
+ p_options: options ?? [],
305
307
  p_question_id: question_id ?? null,
306
308
  p_ask_person: askPerson,
307
309
  /* ═══ ONLY `ask_question` OFFERS THIS, AND THE DATABASE REFUSES IT ON AN
@@ -789,6 +791,36 @@ const TOOLS = [
789
791
  return `Created Product Idea ${a.title}, id ${id}. No work has started. Only a human can promote it.`;
790
792
  },
791
793
  },
794
+ {
795
+ name: 'list_artifact_folders', levels: ALL,
796
+ description: 'Read existing artifact folder names and counts before choosing a folder.',
797
+ input: { work_item_id: z.string().uuid() },
798
+ handler: async (caller, args) => productResult(await listArtifactFoldersHandler(caller.client, args.work_item_id)),
799
+ },
800
+ {
801
+ name: 'set_artifact_folder', levels: ALL,
802
+ description: 'Move an artifact into one folder, replacing its previous folder. Null clears it to Unfiled. Content and approval stay unchanged.',
803
+ input: { artifact_id: z.string().uuid(), folder_name: z.string().max(80).nullable() },
804
+ handler: async (caller, args) => productResult(await setArtifactFolderHandler(caller.client, args.artifact_id, args.folder_name)),
805
+ },
806
+ {
807
+ name: 'list_work_item_dependencies', levels: ALL,
808
+ description: 'Read blockers, dependents, and whether this work item is waiting.',
809
+ input: { work_item_id: z.string().uuid(), },
810
+ handler: async (caller, args) => productResult(await workItemDependencyHandler(caller.client, 'list', args)),
811
+ },
812
+ {
813
+ name: 'add_work_item_dependency', levels: [1, 2],
814
+ 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.',
815
+ input: { work_item_id: z.string().uuid(), depends_on_work_item_id: z.string().uuid(), },
816
+ handler: async (caller, args) => productResult(await workItemDependencyHandler(caller.client, 'add', args)),
817
+ },
818
+ {
819
+ name: 'remove_work_item_dependency', levels: [1, 2],
820
+ description: 'Remove exactly this dependency edge without changing either work item status.',
821
+ input: { work_item_id: z.string().uuid(), depends_on_work_item_id: z.string().uuid(), },
822
+ handler: async (caller, args) => productResult(await workItemDependencyHandler(caller.client, 'remove', args)),
823
+ },
792
824
  {
793
825
  name: 'place_work_item', levels: [1],
794
826
  description: 'Move a work item into an epic or sprint in its project. Null removes placement. Its status stays the same.',
@@ -797,13 +829,14 @@ const TOOLS = [
797
829
  handler: async (caller, args) => productResult(await placeWorkItemHandler(caller.client, args)),
798
830
  },
799
831
  {
800
- name: 'reorder_backlog', levels: [1],
832
+ name: 'reorder_backlog', levels: [1, 2],
801
833
  description: 'Move one backlog work item before another, or to the bottom. Read the current order first and explain the reason.',
802
834
  input: { project_id: z.string().uuid(), task_id: z.string().uuid(), before_task_id: z.string().uuid().optional(), reason: z.string().min(1) },
803
835
  handler: async (caller, args) => {
804
836
  const a = args;
805
837
  const items = await rows(caller.client.from('tasks').select('id, revision')
806
- .eq('project_id', a.project_id).eq('status', 'backlog').is('archived_at', null).eq('is_idea', false).order('position').order('created_at').order('id'), 'read', 'the backlog');
838
+ .eq('project_id', a.project_id).eq('status', 'backlog').is('archived_at', null).eq('is_idea', false)
839
+ .in('id', a.before_task_id ? [a.task_id, a.before_task_id] : [a.task_id]), 'read', 'the backlog');
807
840
  const moving = items.find(item => item.id === a.task_id);
808
841
  if (!moving || (a.before_task_id && !items.some(item => item.id === a.before_task_id)))
809
842
  throw new Error('Both items must be live backlog work in this project. Nothing was moved.');
@@ -931,32 +964,44 @@ const TOOLS = [
931
964
  {
932
965
  name: 'list_work_items',
933
966
  levels: ALL,
934
- description: 'The work items (tasks) on the board, newest first. Optionally filtered to one project and one '
935
- + 'status. Archived items and product ideas are not work items and are not listed.',
967
+ description: 'Live work items in saved manual board order, top to bottom within each project and status, '
968
+ + 'not creation order or a temporary UI sort. For backlog priority, pass project_id and status backlog. '
969
+ + 'Read all pages using next_offset until it is null before reporting the full order. Re-read after a reorder or a human change.',
936
970
  input: {
937
971
  project_id: z.string().optional().describe('Only items in this project.'),
938
972
  status: z.enum(['backlog', 'in_progress', 'done']).optional().describe('Only items in this status.'),
973
+ offset: z.number().int().min(0).default(0).describe('Use next_offset from the previous page; start at zero.'),
939
974
  },
940
975
  handler: async ({ client }, args) => {
941
- const { project_id, status } = args;
976
+ const { project_id, status, offset = 0 } = args;
942
977
  let query = client
943
978
  .from('tasks')
944
- .select('id, name, status, project_id, epic_id, sprint_id, due_date, created_at')
979
+ .select('id, name, status, project_id, epic_id, sprint_id, due_date, position, created_at', { count: 'exact' })
945
980
  .eq('is_idea', false)
946
981
  .is('archived_at', null)
947
- .order('created_at', { ascending: false });
982
+ .order('project_id').order('status').order('position').order('created_at').order('id');
948
983
  if (project_id)
949
984
  query = query.eq('project_id', project_id);
950
985
  if (status)
951
986
  query = query.eq('status', status);
952
- const items = await rows(query, 'read', 'work items');
953
- return listed(items.map((t) => line(t.id, t.status.padEnd(11), t.name, t.due_date ? `due ${t.due_date}` : null)), 'There are no work items matching that.');
987
+ const result = await query.range(offset, offset + 99);
988
+ const items = await rows(Promise.resolve(result), 'read', 'work items');
989
+ if (result.count === null)
990
+ throw new Error('Could not read the work-item total; the full order is unavailable.');
991
+ if (items.length === 0 && offset < result.count)
992
+ throw new Error('Work items changed while reading this page. Restart from offset zero.');
993
+ return JSON.stringify({
994
+ order: 'Saved manual board order, top to bottom within each project and status',
995
+ total: result.count,
996
+ next_offset: offset + items.length < result.count ? offset + items.length : null,
997
+ items,
998
+ });
954
999
  },
955
1000
  },
956
1001
  {
957
1002
  name: 'get_work_item',
958
1003
  levels: ALL,
959
- description: 'One work item in full: its description, status, placement, and the artifacts on it.',
1004
+ description: 'One work item in full: description, status, placement, saved codebase targets, available project codebases, and artifacts. Saved targets belong to the work item, not just this conversation.',
960
1005
  input: { work_item_id: z.string() },
961
1006
  handler: async ({ client }, args) => {
962
1007
  const { work_item_id } = args;
@@ -964,11 +1009,15 @@ const TOOLS = [
964
1009
  .from('tasks')
965
1010
  .select('id, name, description, status, due_date, project_id, created_at, epics(name), sprints(name)')
966
1011
  .eq('id', work_item_id), 'read', `work item ${work_item_id}`);
967
- const artifacts = await rows(client.from('artifacts').select('id, type, title, created_at').eq('task_id', work_item_id)
1012
+ const artifacts = await rows(client.from('artifacts').select('id, type, title, created_at, folder_name').eq('task_id', work_item_id)
968
1013
  .is('deleted_at', null).order('created_at'), 'read', `the artifacts on work item ${work_item_id}`);
969
1014
  const feedback = await rows(client.from('artifact_feedback').select('*').eq('task_id', work_item_id).order('created_at'), 'read', 'the artifact feedback');
970
1015
  const decisions = await rows(client.from('decisions').select('id, category, question, state, selected_options, answer_note, related_artifact_id').eq('task_id', work_item_id).order('asked_at'), 'read', 'the work item decisions');
971
1016
  const comments = await rows(client.from('comments').select('id, body, created_at').eq('task_id', work_item_id).order('created_at'), 'read', 'the work item comments');
1017
+ const codebases = (await listCodebases(client, item.project_id))
1018
+ .sort((a, b) => a.name.localeCompare(b.name))
1019
+ .map(({ id, name, gitRemoteUrl }) => ({ id, name, git_remote_url: gitRemoteUrl }));
1020
+ const targets = await listWorkItemCodebaseTargets(client, work_item_id);
972
1021
  return [
973
1022
  `${item.name}`,
974
1023
  `id ${item.id}`,
@@ -977,13 +1026,15 @@ const TOOLS = [
977
1026
  `epic ${item.epics?.name ?? 'none'}`,
978
1027
  `sprint ${item.sprints?.name ?? 'none'}`,
979
1028
  `due ${item.due_date ?? 'no date'}`,
1029
+ 'PROJECT CODEBASES', JSON.stringify(codebases),
1030
+ 'SAVED CODEBASE TARGETS', JSON.stringify(targets),
980
1031
  '',
981
1032
  'DESCRIPTION',
982
1033
  item.description.trim() === '' ? '(empty)' : item.description,
983
1034
  '',
984
1035
  'DECISIONS', JSON.stringify(decisions), 'FEEDBACK', JSON.stringify(feedback), 'COMMENTS', JSON.stringify(comments),
985
1036
  `ARTIFACTS ${artifacts.length}`,
986
- listed(artifacts.map((a) => line(a.id, a.type, a.title ?? '(untitled)')), 'none'),
1037
+ listed(artifacts.map((a) => line(a.id, a.type, a.title ?? '(untitled)', `folder: ${a.folder_name ?? 'Unfiled'}`)), 'none'),
987
1038
  ].join('\n');
988
1039
  },
989
1040
  },
@@ -1133,10 +1184,15 @@ const TOOLS = [
1133
1184
  `id ${workflow.id}`,
1134
1185
  `about ${workflow.description.trim() === '' ? 'no description' : workflow.description}`,
1135
1186
  '',
1187
+ `ending ${workflow.ending}`,
1188
+ `BRANCH CONDITIONS ${workflow.branches.length}`,
1189
+ ...workflow.branches.map(branch => `if ${branch.when}, run workflow ${branch.runsWorkflowId}, then resume this workflow`),
1190
+ '',
1136
1191
  `STAGES ${workflow.stages.length}`,
1137
1192
  ...workflow.stages.flatMap((stage, i) => [
1138
1193
  '',
1139
1194
  line(`${i + 1}. ${stage.name}`, stage.description.trim() === '' ? null : stage.description),
1195
+ `stage_id ${stage.id} (use stage number ${i + 1} for reword_stage and keep)`,
1140
1196
  '',
1141
1197
  /* THE WHOLE BODY. The body IS the stage document, so truncating it is
1142
1198
  truncating the process. An empty one is a stage nobody has written
@@ -1159,8 +1215,9 @@ const TOOLS = [
1159
1215
  '',
1160
1216
  ]
1161
1217
  : []),
1162
- 'This is a WORKFLOW: the process to FOLLOW for this work, stage by stage in the order '
1163
- + 'above. It is not a document to summarise, quote back or file away.',
1218
+ 'When assigned execution, follow this workflow stage by stage. When assigned authoring '
1219
+ + 'or review only, edit or inspect it without starting work. Stage IDs above identify '
1220
+ + 'library stages; IDs inside Markdown tool links identify tool occurrences, not stages.',
1164
1221
  ].join('\n');
1165
1222
  },
1166
1223
  },
@@ -1178,7 +1235,9 @@ const TOOLS = [
1178
1235
  + 'is earlier than N or the same stage; a forward jump is refused and nothing is written. '
1179
1236
  + 'Takes no organisation or project: the workflow goes into the organisation this '
1180
1237
  + "conversation's project belongs to. It appears on /workflows at once. Never put an "
1181
- + 'absolute local path in any field.',
1238
+ + 'absolute local path in any field. '
1239
+ + 'branches are global conditions: when a condition holds, run runs_workflow_id in the same organisation and resume. '
1240
+ + 'ending is end or next-in-backlog. Omitted settings are preserved on edit; branches: [] clears branches.',
1182
1241
  input: {
1183
1242
  name: z.string(),
1184
1243
  description: z.string().optional(),
@@ -1192,9 +1251,14 @@ const TOOLS = [
1192
1251
  to_stage: z.number().int(),
1193
1252
  condition: z.string(),
1194
1253
  })).optional(),
1254
+ branches: z.array(z.object({
1255
+ when: z.string().trim().min(1),
1256
+ runs_workflow_id: z.string().uuid(),
1257
+ })).optional().describe('the whole new branch list; omitted preserves current branches, [] clears them'),
1258
+ ending: z.enum(['end', 'next-in-backlog']).optional(),
1195
1259
  },
1196
1260
  handler: async (caller, args) => {
1197
- const { name, description, stages, exits } = args;
1261
+ const { name, description, stages, exits, branches, ending } = args;
1198
1262
  /* ═══ CHECKED HERE IN THE NUMBERING THE AGENT SENT. ═══
1199
1263
  `cliv2_build_workflow` enforces every one of these itself, and its
1200
1264
  messages count stages from 0, a numbering this tool never showed the
@@ -1237,6 +1301,8 @@ const TOOLS = [
1237
1301
  body: stage.body,
1238
1302
  })),
1239
1303
  exits: wanted,
1304
+ branches: branches?.map(branch => ({ when: branch.when, runsWorkflowId: branch.runs_workflow_id })),
1305
+ ending,
1240
1306
  fromAgent: harness(),
1241
1307
  });
1242
1308
  await receipt(caller, 'workflow', id, workflowName);
@@ -1332,8 +1398,12 @@ const TOOLS = [
1332
1398
  + 'must be earlier than from_stage or the same, and exits off one stage are tried in the order '
1333
1399
  + 'sent. name and description left out are left as they are; an empty description clears it. '
1334
1400
  + 'To change the wording of an '
1335
- + 'existing stage use reword_stage. Refused, with nothing written, while any work item is '
1336
- + 'running the workflow. Never put an absolute local path in any field.',
1401
+ + 'existing stage use reword_stage first, then keep its stage number here. Read the current '
1402
+ + 'workflow before editing. Never replace a retained stage with a new name/body entry just '
1403
+ + 'to rename it, copy its wording, or bypass a protected-body refusal. Refused, with nothing written, while any work item is '
1404
+ + 'running the workflow. Never put an absolute local path in any field. '
1405
+ + 'branches are global conditions: when a condition holds, run runs_workflow_id in the same organisation and resume. '
1406
+ + 'ending is end or next-in-backlog. Omitted settings are preserved on edit; branches: [] clears branches.',
1337
1407
  input: {
1338
1408
  workflow_id: z.string(),
1339
1409
  name: z.string().optional(),
@@ -1351,9 +1421,14 @@ const TOOLS = [
1351
1421
  to_stage: z.number().int(),
1352
1422
  condition: z.string(),
1353
1423
  })).optional().describe('the whole new list of conditional ways back; send [] for none'),
1424
+ branches: z.array(z.object({
1425
+ when: z.string().trim().min(1),
1426
+ runs_workflow_id: z.string().uuid(),
1427
+ })).optional().describe('the whole new branch list; omitted preserves current branches, [] clears them'),
1428
+ ending: z.enum(['end', 'next-in-backlog']).optional(),
1354
1429
  },
1355
1430
  handler: async (caller, args) => {
1356
- const { workflow_id, name, description, stages, exits } = args;
1431
+ const { workflow_id, name, description, stages, exits, branches, ending } = args;
1357
1432
  const workflowName = name?.trim();
1358
1433
  if (workflowName === '')
1359
1434
  throw new Error('a workflow needs a name. Nothing was written.');
@@ -1404,10 +1479,8 @@ const TOOLS = [
1404
1479
  description: description?.trim() ?? null,
1405
1480
  stages: entries,
1406
1481
  exits: zeroBasedExits(exits, entries.length),
1407
- /* THE BRANCHES GO BACK AS THEY CAME. The RPC rewrites them from what it
1408
- is sent and nothing in v3 sets one, so sending anything else would
1409
- delete what a person set on `/workflows`. */
1410
- branches: workflow.branches,
1482
+ branches: branches?.map(branch => ({ when: branch.when, runsWorkflowId: branch.runs_workflow_id })) ?? workflow.branches,
1483
+ ending,
1411
1484
  fromAgent: harness(),
1412
1485
  });
1413
1486
  /* THE LABEL IS THE NAME THAT SURVIVED THE EDIT, which is the reply's own:
@@ -1681,9 +1754,9 @@ const TOOLS = [
1681
1754
  input: { work_item_id: z.string() },
1682
1755
  handler: async ({ client }, args) => {
1683
1756
  const { work_item_id } = args;
1684
- const artifacts = await rows(client.from('artifacts').select('id, type, format, title, created_at').eq('task_id', work_item_id)
1757
+ const artifacts = await rows(client.from('artifacts').select('id, type, format, title, created_at, folder_name').eq('task_id', work_item_id)
1685
1758
  .is('deleted_at', null).order('created_at'), 'read', `the artifacts on work item ${work_item_id}`);
1686
- return listed(artifacts.map((a) => line(a.id, a.type, a.format, a.title ?? '(untitled)')), 'That work item has no artifacts.');
1759
+ return listed(artifacts.map((a) => line(a.id, a.type, a.format, a.title ?? '(untitled)', `folder: ${a.folder_name ?? 'Unfiled'}`)), 'That work item has no artifacts.');
1687
1760
  },
1688
1761
  },
1689
1762
  {
@@ -1694,7 +1767,7 @@ const TOOLS = [
1694
1767
  handler: async ({ client }, args) => {
1695
1768
  const { artifact_id } = args;
1696
1769
  const artifact = await only(client.from('artifacts')
1697
- .select('id, task_id, type, format, title, content, storage_path, revision, created_at')
1770
+ .select('id, task_id, type, format, title, content, storage_path, revision, created_at, folder_name')
1698
1771
  .eq('id', artifact_id), 'read', `artifact ${artifact_id}`);
1699
1772
  return [
1700
1773
  `${artifact.title ?? '(untitled)'}`,
@@ -1702,6 +1775,7 @@ const TOOLS = [
1702
1775
  `work item ${artifact.task_id}`,
1703
1776
  `type ${artifact.type} (${artifact.format})`,
1704
1777
  `revision ${artifact.revision}`,
1778
+ `folder ${artifact.folder_name ?? 'Unfiled'}`,
1705
1779
  '',
1706
1780
  /* A stored file and an empty body are different facts and are said
1707
1781
  differently. Returning '' for a PNG would read as an artifact with
@@ -1977,7 +2051,8 @@ const TOOLS = [
1977
2051
  levels: [2],
1978
2052
  description: 'Offer the person the ending for this card: put the finished work onto the main branch, or '
1979
2053
  + 'leave it on its branch. Call this when the card\'s work is DONE and the codebase lands on '
1980
- + 'the main branch. You do not write the question or the answers and you never merge anything: '
2054
+ + 'the main branch. First record verified completion with write_report(work_complete=true). '
2055
+ + 'Use say for progress; this tool cannot announce work you intend to do. You do not write the question or the answers and you never merge anything: '
1981
2056
  + 'the product composes both, and it performs the merge itself if they choose to put the work '
1982
2057
  + 'back. Say in one sentence what was done, in their words. After this call, stop immediately: '
1983
2058
  + 'you are started again with what the product did with their answer.',
@@ -1997,8 +2072,13 @@ const TOOLS = [
1997
2072
  + 'sentence saying what changed.');
1998
2073
  }
1999
2074
  const run = await only(client.from('panel3_runs')
2000
- .select('codebase_id, branch, base, card:panel3_cards!panel3_runs_card_id_fkey(project_id)')
2075
+ .select('codebase_id, branch, base, completion_requested_token, card:panel3_cards!panel3_runs_card_id_fkey(project_id)')
2001
2076
  .eq('id', runId), 'read', 'which branch this card\'s work is on');
2077
+ if (run.completion_requested_token !== processToken) {
2078
+ throw new Error('NOTHING WAS WRITTEN and nobody was asked. The assignment has not been declared complete in this turn. '
2079
+ + 'Finish and verify every requested codebase first, then call write_report with work_complete=true before offer_ending. '
2080
+ + 'Use say for a progress update, not offer_ending.');
2081
+ }
2002
2082
  if (!run.codebase_id || !run.branch || !run.base || !run.card?.project_id) {
2003
2083
  throw new Error('NOTHING WAS WRITTEN. This card has no codebase, branch and base recorded, so there is no '
2004
2084
  + 'ending to offer. Say what you did and finish the card.');
@@ -2048,34 +2128,65 @@ const TOOLS = [
2048
2128
  },
2049
2129
  },
2050
2130
  {
2051
- name: 'create_epic',
2052
- levels: [1],
2053
- description: 'Create an epic in a project. An epic groups work items that belong to one body of work.',
2054
- input: { project_id: z.string(), name: z.string().min(1) },
2131
+ name: 'create_epic', levels: [1, 2],
2132
+ description: 'Create an epic with optional description and dates. Read list_structure first to avoid duplicates.',
2133
+ input: { project_id: z.string().uuid(), name: z.string().min(1), description: z.string().optional(), start_date: z.string().optional(), target_date: z.string().optional() },
2055
2134
  handler: async (caller, args) => {
2056
- const { project_id, name } = args;
2057
- const epic = await only(caller.client.from('epics').insert({ project_id, name }).select('id, name'), 'create', `an epic called ${name}`);
2058
- await receipt(caller, 'epic', epic.id, epic.name);
2059
- return `Created epic ${epic.name}, id ${epic.id}.`;
2135
+ const text = productResult(await createEpicHandler(caller.client, args));
2136
+ const row = JSON.parse(text).epic;
2137
+ await receipt(caller, 'epic', row.id, row.name);
2138
+ return text;
2060
2139
  },
2061
2140
  },
2062
2141
  {
2063
- name: 'create_sprint',
2064
- levels: [1],
2065
- description: 'Create an ordered batch of work in a project. Dates are optional; use them only for a stated calendar constraint.',
2066
- input: {
2067
- project_id: z.string(),
2068
- name: z.string().min(1),
2069
- start_date: z.string().optional().describe('YYYY-MM-DD'),
2070
- end_date: z.string().optional().describe('YYYY-MM-DD'),
2071
- },
2142
+ name: 'create_sprint', levels: [1, 2],
2143
+ description: 'Create an sprint with optional description and dates. Read list_structure first to avoid duplicates.',
2144
+ input: { project_id: z.string().uuid(), name: z.string().min(1), description: z.string().optional(), start_date: z.string().optional(), end_date: z.string().optional() },
2072
2145
  handler: async (caller, args) => {
2073
- const { project_id, name, start_date, end_date } = args;
2074
- const sprint = await only(caller.client.from('sprints').insert({ project_id, name, start_date, end_date }).select('id, name'), 'create', `a sprint called ${name}`);
2075
- await receipt(caller, 'sprint', sprint.id, sprint.name);
2076
- return `Created sprint ${sprint.name}, id ${sprint.id}.`;
2146
+ const text = productResult(await createSprintHandler(caller.client, args));
2147
+ const row = JSON.parse(text).sprint;
2148
+ await receipt(caller, 'sprint', row.id, row.name);
2149
+ return text;
2077
2150
  },
2078
2151
  },
2152
+ {
2153
+ name: 'list_structure', levels: ALL,
2154
+ description: 'Read project epics and sprints with their descriptions and dates.',
2155
+ input: { project_id: z.string().uuid() },
2156
+ handler: async (caller, args) => productResult(await listStructureHandler(caller.client, { project_id: args.project_id })),
2157
+ },
2158
+ {
2159
+ name: 'update_structure', levels: [1, 2],
2160
+ description: 'Edit an epic or sprint name, description, dates, or archive state. Null clears a date. Epics use target_date; sprints use end_date.',
2161
+ input: { kind: z.enum(['epic', 'sprint']), id: z.string().uuid(), name: z.string().min(1).optional(), description: z.string().optional(), start_date: z.string().nullable().optional(), end_date: z.string().nullable().optional(), target_date: z.string().nullable().optional(), archived: z.boolean().optional() },
2162
+ handler: async (caller, args) => productResult(await updateStructureHandler(caller.client, args)),
2163
+ },
2164
+ {
2165
+ name: 'search_agent_cards', levels: ALL,
2166
+ description: 'Find your agent conversations by literal title or message text, or by work item. Includes archived cards unless specified. Follow next_offset for remaining results. Read-only.',
2167
+ input: { 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() },
2168
+ handler: async (caller, args) => productResult(await searchAgentCardsHandler(caller.client, args)),
2169
+ },
2170
+ {
2171
+ name: 'list_structure_artifacts', levels: ALL, description: 'Read active artifacts attached directly to an epic or sprint.',
2172
+ input: { kind: z.enum(['epic', 'sprint']), structure_id: z.string().uuid() },
2173
+ handler: async (caller, args) => productResult(await listStructureArtifactsHandler(caller.client, args.kind, args.structure_id)),
2174
+ },
2175
+ {
2176
+ name: 'get_structure_artifact', levels: ALL, description: 'Read a full epic or sprint artifact and its current revision.',
2177
+ input: { artifact_id: z.string().uuid() },
2178
+ handler: async (caller, args) => productResult(await getStructureArtifactHandler(caller.client, args.artifact_id)),
2179
+ },
2180
+ {
2181
+ name: 'create_structure_artifact', levels: ALL, description: 'Create a titled artifact on an epic or sprint. It appears on its management page.',
2182
+ input: { 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) },
2183
+ handler: async (caller, args) => { const text = productResult(await createStructureArtifactHandler(caller.client, caller.userId, args)); const result = JSON.parse(text); await receipt(caller, result.kind, result.structure.id, result.structure.name); return text; },
2184
+ },
2185
+ {
2186
+ name: 'update_structure_artifact', levels: ALL, description: 'Update an epic or sprint artifact using the revision you read. Concurrent edits refuse without overwriting. archived=true removes it; false restores it.',
2187
+ input: { 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() },
2188
+ handler: async (caller, args) => productResult(await updateStructureArtifactHandler(caller.client, args)),
2189
+ },
2079
2190
  {
2080
2191
  name: 'create_work_item',
2081
2192
  levels: [1],
@@ -2110,6 +2221,20 @@ const TOOLS = [
2110
2221
  return `Created work item ${item.name}, id ${item.id}.`;
2111
2222
  },
2112
2223
  },
2224
+ {
2225
+ name: 'edit_work_item_codebase',
2226
+ levels: [1, 2],
2227
+ description: 'Add or remove one SAVED work-item codebase target, preserving all other targets and attributes. Read get_work_item first for current targets and project codebase IDs. To select multiple codebases, add each; to replace a target, add its replacement before removing it. Duplicate additions are harmless. Re-read after edits or human changes. This edits the work item itself and provides an Open work item receipt; it does not attach to the conversation or execute files.',
2228
+ input: { work_item_id: z.string().uuid(), codebase_id: z.string().uuid(), action: z.enum(['add', 'remove']) },
2229
+ handler: async (caller, args) => {
2230
+ const a = args;
2231
+ const item = await only(caller.client.from('tasks')
2232
+ .select('id, name, project_id').eq('id', a.work_item_id), 'read', 'the work item');
2233
+ const targets = await editWorkItemCodebaseTarget(caller.client, item.id, item.project_id, a.codebase_id, a.action === 'add');
2234
+ await receiptOnce(caller, 'work_item', item.id, item.name);
2235
+ return JSON.stringify({ work_item_id: item.id, saved_codebase_targets: targets });
2236
+ },
2237
+ },
2113
2238
  // ── Do the work, and produce objects ─────────────────────────────────────
2114
2239
  //
2115
2240
  // ═══ EVERY LEVEL, AND THAT IS ux.md's FOURTH RULE. ═══ "Producing an object
@@ -2178,6 +2303,7 @@ const TOOLS = [
2178
2303
  + FIREWALL_WRITING_RULE,
2179
2304
  input: {
2180
2305
  work_item_id: z.string(),
2306
+ folder_name: z.string().max(80).nullable().optional().describe('Read list_artifact_folders first to reuse a folder.'),
2181
2307
  title: z.string().min(1),
2182
2308
  content: z.string().min(1),
2183
2309
  type: z.enum(['plan', 'spec', 'analysis', 'diagram', 'mock', 'wireframe', 'user_story']).optional(),
@@ -2187,6 +2313,7 @@ const TOOLS = [
2187
2313
  const a = args;
2188
2314
  const artifact = await only(caller.client.from('artifacts').insert({
2189
2315
  task_id: a.work_item_id,
2316
+ folder_name: a.folder_name ?? null,
2190
2317
  title: a.title,
2191
2318
  content: a.content,
2192
2319
  type: a.type ?? 'plan',
@@ -2697,7 +2824,7 @@ const TOOLS = [
2697
2824
  await whileRunning(client.rpc('panel3_request_completion', {
2698
2825
  p_run_id: runId, p_process_token: processToken, p_summary: report,
2699
2826
  }), runId, 'declare completion of');
2700
- return 'Completion recorded. Give your final answer and exit. Done waits until all processes have exited.';
2827
+ return 'Completion recorded. If the finished work needs an ending choice, call offer_ending; otherwise give your final answer and exit. Done waits until all processes have exited.';
2701
2828
  }
2702
2829
  await whileRunning(processToken === undefined
2703
2830
  ? client.from('panel3_runs').update({ report }).eq('id', runId)
@@ -2863,6 +2990,13 @@ const TOOLS = [
2863
2990
  },
2864
2991
  },
2865
2992
  // ── Dispatch and stop ────────────────────────────────────────────────────
2993
+ {
2994
+ name: 'list_codex_models',
2995
+ levels: [1, 2],
2996
+ description: 'Read the models and reasoning efforts currently offered by the installed Codex harness on this machine. Call before choosing a Codex child model yourself. This is discovery, not an allowlist for explicit user model IDs or aliases. If discovery fails, omit autonomous model/effort choices to use the installed defaults; never guess an identifier.',
2997
+ input: {},
2998
+ handler: async () => JSON.stringify(await listCodexModels()),
2999
+ },
2866
3000
  {
2867
3001
  name: 'dispatch',
2868
3002
  /* ═══ ABSENT AT LEVEL 3, WHICH IS HOW "DEPTH STOPS AT THREE" IS ENFORCED.
@@ -2896,7 +3030,7 @@ const TOOLS = [
2896
3030
  + 'context: what to find out or change, and in which part of the codebase.'),
2897
3031
  boundary: z.string().min(1).describe('What it must not touch, and where its work stops.'),
2898
3032
  work_item_id: z.string().optional().describe('The work item it is working, if there is one.'),
2899
- model: z.string().min(1).optional().describe('Exact model for this child. Copy the user\'s applicable value verbatim, including aliases; never expand or normalize it. Otherwise choose for the task. Omit for the harness or machine default, never parent inheritance.'),
3033
+ model: z.string().min(1).refine(value => !/^(?:codex|claude(?: code)?)(?:\s|$)/i.test(value.trim()), 'Use only the model ID, without the Claude or Codex harness label. Omit model to use the installed default.').optional().describe('Exact model for this child. Copy the user\'s applicable value verbatim, including aliases; never expand or normalize it. Otherwise choose for the task: for Claude, prefer supported unversioned aliases such as sonnet or opus; do not invent version-suffixed aliases. For Codex, first call list_codex_models and choose from its result. If uncertain, omit for the harness or machine default, never parent inheritance.'),
2900
3034
  effort: z.string().min(1).optional().describe('Exact effort for this child. Honor the user\'s applicable wish; otherwise choose independently. Passed unchanged to the harness.'),
2901
3035
  work_name: z.string().optional().describe(`A few words, ${WORK_NAME_WORDS} at most, naming the WORK this conversation is doing, such `
2902
3036
  + 'as "Fix the sign-out checklist bug". Pass it when the conversation has NO work item '
@@ -2926,6 +3060,9 @@ const TOOLS = [
2926
3060
  + 'the work, do not describe it.');
2927
3061
  }
2928
3062
  }
3063
+ const attached = await loadAttachments(caller.client, caller.cardId);
3064
+ const workItems = attached.filter(attachment => attachment.kind === 'work_item');
3065
+ const scopedWorkItem = work_item_id ?? (workItems.length === 1 ? workItems[0].ref_id : undefined);
2929
3066
  let codebase = null;
2930
3067
  if (codebase_id) {
2931
3068
  const projectId = await projectOfCard(caller);
@@ -2937,6 +3074,19 @@ const TOOLS = [
2937
3074
  if (!codebase) {
2938
3075
  throw new Error('That codebase is not registered on this project. Read the current project codebases and choose one of them.');
2939
3076
  }
3077
+ if (!scopedWorkItem && workItems.length > 1) {
3078
+ throw new Error('No agent was started. Identify the work item for this piece before choosing its codebase.');
3079
+ }
3080
+ if (scopedWorkItem) {
3081
+ await only(caller.client.from('tasks').select('id').eq('id', scopedWorkItem)
3082
+ .eq('project_id', projectId).is('archived_at', null).eq('is_idea', false), 'read', 'the live work item in this conversation\'s project');
3083
+ const targets = await listWorkItemCodebaseTargets(caller.client, scopedWorkItem);
3084
+ if (!targets.includes(codebase.gitRemoteUrl)) {
3085
+ throw new Error('No agent was started. This codebase is not a saved target of the work item. '
3086
+ + 'Read get_work_item for its current saved targets; if none are saved, ask the person to select one. '
3087
+ + 'Do not fall back to another project codebase or change targets to bypass this check.');
3088
+ }
3089
+ }
2940
3090
  }
2941
3091
  /* ONE LEVEL DOWN, AND THE SAME ARITHMETIC THE DATABASE DOES. This decides
2942
3092
  the words in the brief; `panel3_dispatch` decides the level on the row,
@@ -2951,7 +3101,6 @@ const TOOLS = [
2951
3101
  three hops from level 1 still knows which item it is working, without
2952
3102
  asking. See `workBrief`'s own doc for why the two are different things
2953
3103
  carried the same way. */
2954
- const attached = await loadAttachments(caller.client, caller.cardId);
2955
3104
  /* ═══ THE CONVERSATION IS NAMED HERE OR IT IS NEVER NAMED. ═══ Sending
2956
3105
  somebody resolves the working copy, and resolving it cuts the branch
2957
3106
  from whatever the conversation is called at that moment and stamps it
@@ -2966,7 +3115,7 @@ const TOOLS = [
2966
3115
  .select('id'), 'name', `card ${caller.cardId}`);
2967
3116
  }
2968
3117
  const attachments = attached.map(attachmentLine);
2969
- const { runId } = await caller.dispatch(caller.runId, workBrief(childLevel, responsibility, boundary, work_item_id, attachments, codebase === null ? undefined : {
3118
+ const { runId } = await caller.dispatch(caller.runId, workBrief(childLevel, responsibility, boundary, scopedWorkItem, attachments, codebase === null ? undefined : {
2970
3119
  id: codebase.id, name: codebase.name, identity: codebase.gitRemoteUrl,
2971
3120
  }), codebase, caller.processToken, { model, effort });
2972
3121
  /* ═══ WHERE ITS ANSWER GOES DEPENDS ON WHICH LEVEL THIS IS, AND THAT IS