@ctrl-spc/cs 0.7.9 → 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.
@@ -748,6 +748,17 @@ async function setCardState(client, cardId, state) {
748
748
  .select('id'), 'set the state of', `card ${cardId}`);
749
749
  }
750
750
  // ---------------------------------------------------------------------------
751
+ /** Each start reloads only this run's durable choices, including null defaults. */
752
+ export async function settingsForRun(client, runId) {
753
+ const rows = await returned(client.from('panel3_runs').select('model, effort, harness').eq('id', runId), 'read', `model and effort for run ${runId}`);
754
+ const row = rows[0];
755
+ if (!row || (row.harness !== null && row.harness !== 'claude' && row.harness !== 'codex')
756
+ || (row.model !== null && typeof row.model !== 'string')
757
+ || (row.effort !== null && typeof row.effort !== 'string')) {
758
+ throw new Error('The run has no readable harness, model and effort selection.');
759
+ }
760
+ return { model: row.model, effort: row.effort, harness: row.harness ?? undefined };
761
+ }
751
762
  /**
752
763
  * One card, from the turns the take handed over to the answer on its thread.
753
764
  *
@@ -804,9 +815,11 @@ async function answerCard(client, tools, machineId, cardId, turns) {
804
815
  no rules" — that is a different project than the one the person is on. So it
805
816
  joins the three reads above inside this ending rather than beside it. */
806
817
  let rules;
818
+ let settings;
807
819
  try {
808
820
  brief = briefFor(turns[0].card_title, turns, await receiptsFor(client, cardId), await attachmentsFor(client, cardId), await codebasesForRun(client, runId));
809
821
  rules = await standingRulesFor(client, runId);
822
+ settings = await settingsForRun(client, runId);
810
823
  where = await workingDirectory(client, runId, LEVEL, false);
811
824
  }
812
825
  catch (error) {
@@ -823,7 +836,7 @@ async function answerCard(client, tools, machineId, cardId, turns) {
823
836
  /* AN EMPTY DIRECTORY OF THE USER'S OWN, because level 1 has no code tool to
824
837
  use a real one with, and the daemon's inherited cwd under a launchd login
825
838
  item is the filesystem root. */
826
- const started = startAgent(withStandingRules(rules, brief, where.block), LEVEL, tools.urlFor(runId), where.cwd);
839
+ const started = startAgent(withStandingRules(rules, brief, where.block), LEVEL, tools.urlFor(runId), where.cwd, undefined, settings);
827
840
  out(`run ${runId} level ${LEVEL} ${started.pid ? `pid ${started.pid}` : 'no process'}`);
828
841
  try {
829
842
  /* THE BRIEF, NOT WHAT THE PROCESS WAS HANDED. The rules are current at the
@@ -1162,14 +1175,16 @@ async function workingDirectory(client, runId, level, isOwner, knownCodebase) {
1162
1175
  * Then the row, then the process, then the pid — constraint 8, in the only order
1163
1176
  * that satisfies it.
1164
1177
  */
1165
- async function startChild(client, tools, machineId, parentRunId, brief, codebase, parentProcessToken) {
1178
+ async function startChild(client, tools, machineId, parentRunId, brief, codebase, parentProcessToken, choice = {}) {
1166
1179
  const { data, error } = await client.rpc('panel3_dispatch', {
1167
1180
  p_parent_run_id: parentRunId,
1168
1181
  p_brief: brief,
1169
1182
  p_machine_id: machineId,
1170
1183
  p_codebase_id: codebase?.id ?? null,
1171
1184
  p_codebase_label: codebase?.name ?? null,
1172
- ...(parentProcessToken === undefined ? {} : { p_process_token: parentProcessToken }),
1185
+ p_process_token: parentProcessToken ?? null,
1186
+ p_model: choice.model ?? null,
1187
+ p_effort: choice.effort ?? null,
1173
1188
  });
1174
1189
  if (error)
1175
1190
  throw new Error(`could not start an agent under run ${parentRunId}: ${readableWriteError(error.message)}`);
@@ -1200,12 +1215,14 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
1200
1215
  exists (`panel3_dispatch` wrote it above) and it carries this child's
1201
1216
  codebase, which is what decides which codebase-scoped rules it is under. */
1202
1217
  let rules;
1218
+ let settings;
1203
1219
  try {
1204
1220
  where = await workingDirectory(client, row.run_id, level, level === 2, codebase);
1205
1221
  prompt = level === 2
1206
1222
  ? await initialOwnerPrompt(client, row.run_card_id, parentRunId, brief)
1207
1223
  : brief;
1208
1224
  rules = await standingRulesFor(client, row.run_id);
1225
+ settings = await settingsForRun(client, row.run_id);
1209
1226
  /* Inside this site's own try, for `standingRulesFor`'s reason: a failure to
1210
1227
  assemble what the agent needs ends the run the way this path already ends
1211
1228
  runs, rather than starting a process that is missing it. */
@@ -1218,7 +1235,7 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
1218
1235
  }
1219
1236
  const processToken = row.process_token ?? undefined;
1220
1237
  const isOwner = level === 2;
1221
- const started = startAgent(withStandingRules(rules, prompt, where.block, [], pictures), level, tools.urlFor(row.run_id, processToken), where.cwd, isOwner ? { ownerId: row.run_id } : undefined);
1238
+ const started = startAgent(withStandingRules(rules, prompt, where.block, [], pictures), level, tools.urlFor(row.run_id, processToken), where.cwd, isOwner ? { ownerId: row.run_id } : undefined, settings);
1222
1239
  if (started.pid === null) {
1223
1240
  /* THERE IS A ROW AND THERE IS NO PROCESS, which is the one shape the record
1224
1241
  must never be left in quietly. The answer is already settled — nothing ran
@@ -1688,9 +1705,11 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1688
1705
  is a `returned()` message naming tables and columns, which carries no local
1689
1706
  path and is therefore shareable. */
1690
1707
  let rules;
1708
+ let settings;
1691
1709
  let pictures;
1692
1710
  try {
1693
1711
  rules = await standingRulesFor(client, runId);
1712
+ settings = await settingsForRun(client, runId);
1694
1713
  /* ═══ WRITTEN AGAIN ON EVERY START, LIKE THE RULES. ═══ A resumed process is
1695
1714
  a NEW process with a new copy of the working directory, so the files a
1696
1715
  previous one was handed are not there any more, and the stored brief this
@@ -1709,7 +1728,7 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1709
1728
  still running. See `retryPrompt`. */
1710
1729
  withStandingRules(rules, afterPid === null
1711
1730
  ? resumePrompt(claimed.run_brief, claimed.run_report, children)
1712
- : retryPrompt(claimed.run_brief, claimed.run_report, children), where.block, [], pictures), level, tools.urlFor(runId), where.cwd);
1731
+ : retryPrompt(claimed.run_brief, claimed.run_report, children), where.block, [], pictures), level, tools.urlFor(runId), where.cwd, undefined, settings);
1713
1732
  if (started.pid === null) {
1714
1733
  /* THE CLAIM HAPPENED AND NO PROCESS DID, which is the one shape the record
1715
1734
  must never be left in quietly. Same handling as a dispatch that could not
@@ -1816,9 +1835,11 @@ async function startRearmed(client, tools, machineId, row) {
1816
1835
  same way: the re-arm's claim has already happened, so a failure here ends
1817
1836
  the run with its reason rather than leaving it claimed with no process. */
1818
1837
  let rules;
1838
+ let settings;
1819
1839
  let pictures;
1820
1840
  try {
1821
1841
  rules = await standingRulesFor(client, row.run_id);
1842
+ settings = await settingsForRun(client, row.run_id);
1822
1843
  pictures = await picturesOnDisk(client, row.run_card_id, where, level);
1823
1844
  }
1824
1845
  catch (error) {
@@ -1826,7 +1847,7 @@ async function startRearmed(client, tools, machineId, row) {
1826
1847
  await endRun(client, level, row.run_id, row.run_card_id, why);
1827
1848
  throw new Error(`NO AGENT IS RUNNING: ${why}`);
1828
1849
  }
1829
- const started = startAgent(withStandingRules(rules, prompt, where.block, [], pictures), level, tools.urlFor(row.run_id), where.cwd);
1850
+ const started = startAgent(withStandingRules(rules, prompt, where.block, [], pictures), level, tools.urlFor(row.run_id), where.cwd, undefined, settings);
1830
1851
  if (started.pid === null) {
1831
1852
  const answer = await started.answered;
1832
1853
  const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
@@ -2064,6 +2085,7 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
2064
2085
  says what the product DID with the person's answer, and the landing needs
2065
2086
  the card's copy. Nothing in the prompt depended on it before. */
2066
2087
  let rules;
2088
+ let settings;
2067
2089
  /* THE CARD'S COPY IS MADE IN THE SAME WINDOW AND UNDER THE SAME ENDING, for
2068
2090
  the reason the check above gives: it writes, so it happens after the claim,
2069
2091
  and a failure to make it is an activation that ends rather than one that
@@ -2087,6 +2109,7 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
2087
2109
  try {
2088
2110
  where = await ownerDirectory(client, candidate);
2089
2111
  rules = await standingRulesFor(client, runId);
2112
+ settings = await settingsForRun(client, runId);
2090
2113
  attached = whatWasAttached((await attachmentsFor(client, claimed.run_card_id))
2091
2114
  .filter((line) => !line.startsWith('codebase ')));
2092
2115
  /* ═══ THE OWNER'S OWN ACTIVATION, WHICH IS WHERE MOST PICTURES ARRIVE. ═══
@@ -2119,7 +2142,7 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
2119
2142
  are not the same event and `prompt.ts` exists to stop an agent being
2120
2143
  told an untrue reason for its own restart. */
2121
2144
  afterPid !== null, landing);
2122
- const started = startAgent(withStandingRules(rules, prompt, where.block, attached, pictures), 2, tools.urlFor(runId, claimed.process_token), where.cwd, { ownerId: runId, ...(resumeSessionId ? { resumeSessionId } : {}) });
2145
+ const started = startAgent(withStandingRules(rules, prompt, where.block, attached, pictures), 2, tools.urlFor(runId, claimed.process_token), where.cwd, { ownerId: runId, ...(resumeSessionId ? { resumeSessionId } : {}) }, settings);
2123
2146
  if (started.pid === null) {
2124
2147
  const answer = await started.answered;
2125
2148
  const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
@@ -3100,8 +3123,8 @@ export async function run(args, injected) {
3100
3123
  `tools` is referenced inside the callback it is being given, which is safe
3101
3124
  for the plain reason that the callback can only run once a request has
3102
3125
  arrived at a server that by then exists. */
3103
- const tools = await startToolsServer(current(), async (parentRunId, brief, codebase, processToken) => {
3104
- const child = await startChild(current(), tools, machineId, parentRunId, brief, codebase, processToken);
3126
+ const tools = await startToolsServer(current(), async (parentRunId, brief, codebase, processToken, choice) => {
3127
+ const child = await startChild(current(), tools, machineId, parentRunId, brief, codebase, processToken, choice);
3105
3128
  hold(child.runId, child.settled);
3106
3129
  return { runId: child.runId };
3107
3130
  });
@@ -83,7 +83,7 @@ const read = (query, what) => returned(query, 'read', what);
83
83
  const CARD_COLUMNS = 'id, project_id, title, state, created_at, archived_at';
84
84
  const TURN_COLUMNS = 'id, card_id, role, body, created_at, addressed_at, run_id';
85
85
  const RUN_COLUMNS = 'id, card_id, codebase_id, branch, parent_run_id, level, state, brief, report, failed_because, '
86
- + 'activity, machine_id, pid, started_at, resumed_at, read_back_at, ended_at';
86
+ + 'activity, machine_id, pid, started_at, resumed_at, read_back_at, ended_at, model, effort';
87
87
  const ASK_COLUMNS = `id, card_id, run_id, pending_run_id, answered_at, delivered_at, created_at, ${ASK_CONTENT_COLUMNS}`;
88
88
  const OUTPUT_COLUMNS = 'id, card_id, run_id, kind, ref_id, label, created_at';
89
89
  const ATTACHMENT_COLUMNS = 'id, card_id, kind, ref_id, label, created_at';
@@ -1125,6 +1125,10 @@ async function showRun(client, run) {
1125
1125
  out(`RUN ${run.id}`);
1126
1126
  out(` card ${run.card_id}${card ? ` ${card.title}` : ' (card not readable)'}`);
1127
1127
  out(` level ${run.level}`);
1128
+ if (run.model != null)
1129
+ out(` model ${run.model}`);
1130
+ if (run.effort != null)
1131
+ out(` effort ${run.effort}`);
1128
1132
  out(` state ${run.state}${isLive(run) ? ' (live)' : ''}`);
1129
1133
  out(` parent ${run.parent_run_id ?? 'none, dispatched by the daemon'}`);
1130
1134
  out(` machine ${run.machine_id}${run.pid ? ` pid ${run.pid}` : ' no pid recorded'}`);
@@ -124,6 +124,7 @@ import { randomUUID } from 'node:crypto';
124
124
  import { agentPath } from '../agents.js';
125
125
  import { ensureCodexRunHome, ensurePanel3CodexOwnerHome, removeCodexRunHome, } from '../codex-home.js';
126
126
  import { windowsSafeSpawn } from '../win-shell.js';
127
+ import { modelChoiceRules } from './prompt.js';
127
128
  const AGENT_VAR = 'CTRL_SPC_V3_AGENT';
128
129
  /**
129
130
  * The harness named on this machine, or the reason the name is not one.
@@ -169,9 +170,9 @@ const allowedTools = (level) => [`mcp__${SERVER}__*`, ...(level === 1 ? [] : COD
169
170
  * checked without starting a process — which is how the allowlist is proved,
170
171
  * and how it stays provable after this task.
171
172
  */
172
- export function agentArgs(level, toolsUrl, agent = harness(), platform = process.platform, ownerSession) {
173
+ export function agentArgs(level, toolsUrl, agent = harness(), platform = process.platform, ownerSession, choice = {}) {
173
174
  if (agent === 'codex')
174
- return codexArgs(level, toolsUrl, platform, ownerSession);
175
+ return codexArgs(level, toolsUrl, platform, ownerSession, choice);
175
176
  const session = ownerSession
176
177
  ? ownerSession.resumeSessionId
177
178
  ? ['--resume', ownerSession.resumeSessionId]
@@ -187,6 +188,8 @@ export function agentArgs(level, toolsUrl, agent = harness(), platform = process
187
188
  '--tools', builtIns(level),
188
189
  '--allowedTools', allowedTools(level),
189
190
  ...session,
191
+ ...(choice.model == null ? [] : ['--model', choice.model]),
192
+ ...(choice.effort == null ? [] : ['--effort', choice.effort]),
190
193
  /* STATED WHERE THERE IS SOMETHING TO STATE. `acceptEdits` grants file edits
191
194
  to a headless process with nobody at a prompt to approve them, and it is
192
195
  passed only to the levels that have a file tool to use it with: at level 1
@@ -221,7 +224,7 @@ export function agentArgs(level, toolsUrl, agent = harness(), platform = process
221
224
  * `--json` makes the answer an `agent_message` item read out of the stream
222
225
  * rather than whatever prose happened to reach stdout (see `codexAnswer`).
223
226
  */
224
- function codexArgs(level, toolsUrl, platform, ownerSession) {
227
+ function codexArgs(level, toolsUrl, platform, ownerSession, choice = {}) {
225
228
  const resume = ownerSession?.resumeSessionId;
226
229
  return [
227
230
  'exec',
@@ -229,6 +232,8 @@ function codexArgs(level, toolsUrl, platform, ownerSession) {
229
232
  // The prompt arrives on stdin, exactly as it does for claude: `codex exec`
230
233
  // reads it from there when no prompt argument is given.
231
234
  '--json',
235
+ ...(choice.model == null ? [] : ['-m', choice.model]),
236
+ ...(choice.effort == null ? [] : ['-c', `model_reasoning_effort=${JSON.stringify(choice.effort)}`]),
232
237
  // The level 1 scratch directory is not a repository, and neither need a
233
238
  // working copy be.
234
239
  '--skip-git-repo-check',
@@ -241,13 +246,13 @@ function codexArgs(level, toolsUrl, platform, ownerSession) {
241
246
  proven per-run home below. */
242
247
  ...(platform === 'win32' ? [
243
248
  '--ignore-user-config',
244
- '-c', 'model="gpt-5.5"',
245
249
  '-c', 'features.apps=false',
246
250
  // The same closing `codex-home.ts` writes into the per-run config, and
247
251
  // for the same measured reason: `features.multi_agent=false` parses and
248
252
  // leaves the tool reachable.
249
253
  '-c', 'agents.enabled=false',
250
254
  '-c', `mcp_servers.${SERVER}.url=${JSON.stringify(toolsUrl)}`,
255
+ '-c', `mcp_servers.${SERVER}.required=true`,
251
256
  '-c', `mcp_servers.${SERVER}.default_tools_approval_mode="approve"`,
252
257
  '-c', 'windows.sandbox="unelevated"',
253
258
  '-c', 'windows.sandbox_private_desktop=false',
@@ -291,7 +296,7 @@ function runKey(toolsUrl) {
291
296
  * is the forbidden state ux.md is about, so the failure wins wherever both are
292
297
  * present.
293
298
  */
294
- export function codexAnswer(stdout) {
299
+ export function codexAnswer(stdout, exitCode = 0, stderr = '') {
295
300
  let text = null;
296
301
  let failure = null;
297
302
  for (const line of stdout.split('\n')) {
@@ -319,6 +324,9 @@ export function codexAnswer(stdout) {
319
324
  if (failure !== null) {
320
325
  return { ok: false, reason: `codex could not finish the turn${failure ? `: ${failure}` : ''}` };
321
326
  }
327
+ if (exitCode !== 0) {
328
+ return { ok: false, reason: `codex exited ${exitCode}${tail(stderr) || tail(stdout)}` };
329
+ }
322
330
  if (text === null || text.trim() === '') {
323
331
  return { ok: false, reason: 'codex exited 0 without saying anything to the person' };
324
332
  }
@@ -377,7 +385,7 @@ function tail(text, chars = 500) {
377
385
  * process still alive" after the daemon that started it has been killed. So the
378
386
  * caller gets the pid immediately, writes it, and then waits.
379
387
  */
380
- export function startAgent(prompt, level, toolsUrl, cwd, ownerSession) {
388
+ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings = {}) {
381
389
  const failed = (reason) => ({
382
390
  pid: null,
383
391
  session: Promise.resolve(null),
@@ -385,7 +393,7 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession) {
385
393
  });
386
394
  let agent;
387
395
  try {
388
- agent = harness();
396
+ agent = settings.harness ?? harness();
389
397
  }
390
398
  catch (err) {
391
399
  // A machine configured for a harness this build has never heard of. See
@@ -395,7 +403,10 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession) {
395
403
  const launchedOwnerSession = ownerSession && !ownerSession.resumeSessionId
396
404
  ? { ...ownerSession, freshSessionId: randomUUID() }
397
405
  : ownerSession;
398
- const ARGS = agentArgs(level, toolsUrl, agent, process.platform, launchedOwnerSession);
406
+ // A local default applies only to an omitted choice; explicit run values always win.
407
+ const choice = { ...settings, model: settings.model ?? process.env[`CTRL_SPC_V3_${agent.toUpperCase()}_DEFAULT_MODEL`] };
408
+ const ARGS = agentArgs(level, toolsUrl, agent, process.platform, launchedOwnerSession, choice);
409
+ prompt = `${modelChoiceRules(agent)}\n\n${prompt}`;
399
410
  const bin = agentPath(agent);
400
411
  if (!bin) {
401
412
  return failed(`${agent} is not installed on this machine`);
@@ -531,6 +542,11 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession) {
531
542
  respawn behind it is refused. Naming the signal is the whole fix. */
532
543
  finish({ ok: false, reason: `${agent} was ended by ${signal}${tail(stderr) || tail(stdout)}` });
533
544
  }
545
+ else if (agent === 'codex') {
546
+ // Startup failures can put the real error in JSON stdout and only
547
+ // 'Reading prompt from stdin' on stderr, including on non-zero exits.
548
+ finish(codexAnswer(stdout, code ?? 1, stderr));
549
+ }
534
550
  else if (code !== 0) {
535
551
  /* STDOUT WHEN STDERR IS EMPTY, because `claude -p` prints its own
536
552
  failure on stdout and exits non-zero having written nothing to
@@ -541,11 +557,6 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession) {
541
557
  else if (stdout.trim() === '') {
542
558
  finish({ ok: false, reason: `${agent} exited 0 and said nothing${tail(stderr)}` });
543
559
  }
544
- else if (agent === 'codex') {
545
- /* The stream, not the buffer. See `codexAnswer`: stdout here is
546
- protocol, and a failed turn that exited 0 is still a failure. */
547
- finish(codexAnswer(stdout));
548
- }
549
560
  else {
550
561
  const text = stdout.trim();
551
562
  finish({
@@ -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
  *
@@ -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.',
@@ -964,7 +996,7 @@ const TOOLS = [
964
996
  .from('tasks')
965
997
  .select('id, name, description, status, due_date, project_id, created_at, epics(name), sprints(name)')
966
998
  .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)
999
+ const artifacts = await rows(client.from('artifacts').select('id, type, title, created_at, folder_name').eq('task_id', work_item_id)
968
1000
  .is('deleted_at', null).order('created_at'), 'read', `the artifacts on work item ${work_item_id}`);
969
1001
  const feedback = await rows(client.from('artifact_feedback').select('*').eq('task_id', work_item_id).order('created_at'), 'read', 'the artifact feedback');
970
1002
  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');
@@ -983,7 +1015,7 @@ const TOOLS = [
983
1015
  '',
984
1016
  'DECISIONS', JSON.stringify(decisions), 'FEEDBACK', JSON.stringify(feedback), 'COMMENTS', JSON.stringify(comments),
985
1017
  `ARTIFACTS ${artifacts.length}`,
986
- listed(artifacts.map((a) => line(a.id, a.type, a.title ?? '(untitled)')), 'none'),
1018
+ listed(artifacts.map((a) => line(a.id, a.type, a.title ?? '(untitled)', `folder: ${a.folder_name ?? 'Unfiled'}`)), 'none'),
987
1019
  ].join('\n');
988
1020
  },
989
1021
  },
@@ -1681,9 +1713,9 @@ const TOOLS = [
1681
1713
  input: { work_item_id: z.string() },
1682
1714
  handler: async ({ client }, args) => {
1683
1715
  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)
1716
+ const artifacts = await rows(client.from('artifacts').select('id, type, format, title, created_at, folder_name').eq('task_id', work_item_id)
1685
1717
  .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.');
1718
+ 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
1719
  },
1688
1720
  },
1689
1721
  {
@@ -1694,7 +1726,7 @@ const TOOLS = [
1694
1726
  handler: async ({ client }, args) => {
1695
1727
  const { artifact_id } = args;
1696
1728
  const artifact = await only(client.from('artifacts')
1697
- .select('id, task_id, type, format, title, content, storage_path, revision, created_at')
1729
+ .select('id, task_id, type, format, title, content, storage_path, revision, created_at, folder_name')
1698
1730
  .eq('id', artifact_id), 'read', `artifact ${artifact_id}`);
1699
1731
  return [
1700
1732
  `${artifact.title ?? '(untitled)'}`,
@@ -1702,6 +1734,7 @@ const TOOLS = [
1702
1734
  `work item ${artifact.task_id}`,
1703
1735
  `type ${artifact.type} (${artifact.format})`,
1704
1736
  `revision ${artifact.revision}`,
1737
+ `folder ${artifact.folder_name ?? 'Unfiled'}`,
1705
1738
  '',
1706
1739
  /* A stored file and an empty body are different facts and are said
1707
1740
  differently. Returning '' for a PNG would read as an artifact with
@@ -1833,11 +1866,11 @@ const TOOLS = [
1833
1866
  const agents = await rows(client.from('panel3_runs')
1834
1867
  // `resumed_at` is read because "how long it has been going" is about
1835
1868
  // the attempt that is running now. See `elapsed`.
1836
- .select('id, level, parent_run_id, state, activity, started_at, resumed_at, ended_at')
1869
+ .select('id, level, parent_run_id, state, activity, started_at, resumed_at, ended_at, model, effort')
1837
1870
  .eq('card_id', cardId).order('started_at'), 'read', 'the agents on this card');
1838
1871
  return listed(agents.map((a) => {
1839
1872
  const live = a.state === 'running' && !a.ended_at;
1840
- return line(a.id, `L${a.level}`, a.parent_run_id ? `parent ${a.parent_run_id}` : 'no parent, dispatched by the daemon', live ? 'still running' : a.state, a.activity ?? null, live
1873
+ return line(a.id, `L${a.level}`, a.parent_run_id ? `parent ${a.parent_run_id}` : 'no parent, dispatched by the daemon', live ? 'still running' : a.state, a.activity ?? null, a.model == null ? null : `model ${a.model}`, a.effort == null ? null : `effort ${a.effort}`, live
1841
1874
  ? `going ${elapsed(a.started_at, null, a.resumed_at)}`
1842
1875
  : `went ${elapsed(a.started_at, a.ended_at, a.resumed_at)}`);
1843
1876
  }), 'Nothing has run on this card.');
@@ -1851,9 +1884,11 @@ const TOOLS = [
1851
1884
  + 'in your prompt; this is here for when you need to re-read them mid-run.',
1852
1885
  input: {},
1853
1886
  handler: async ({ client, runId }) => {
1854
- const run = await only(client.from('panel3_runs').select('level, brief, report, activity').eq('id', runId), 'read', 'your own run');
1887
+ const run = await only(client.from('panel3_runs').select('level, brief, report, activity, model, effort').eq('id', runId), 'read', 'your own run');
1855
1888
  return [
1856
1889
  `You are a level ${run.level} agent.`,
1890
+ ...(run.model == null ? [] : [`MODEL ${run.model}`]),
1891
+ ...(run.effort == null ? [] : [`EFFORT ${run.effort}`]),
1857
1892
  '',
1858
1893
  'BRIEF, written when you were dispatched and never changed',
1859
1894
  run.brief,
@@ -1878,9 +1913,9 @@ const TOOLS = [
1878
1913
  + 'dispatch the same work twice.',
1879
1914
  input: {},
1880
1915
  handler: async ({ client, runId }) => {
1881
- const children = await rows(client.from('panel3_runs').select('id, level, state, activity, started_at, ended_at')
1916
+ const children = await rows(client.from('panel3_runs').select('id, level, state, activity, started_at, ended_at, model, effort')
1882
1917
  .eq('parent_run_id', runId).order('started_at'), 'read', 'the runs you dispatched');
1883
- return listed(children.map((c) => line(c.id, `L${c.level}`, c.state === 'running' && !c.ended_at ? 'still running' : c.state, c.activity ?? null)), 'You have dispatched nothing.');
1918
+ return listed(children.map((c) => line(c.id, `L${c.level}`, c.state === 'running' && !c.ended_at ? 'still running' : c.state, c.activity ?? null, c.model == null ? null : `model ${c.model}`, c.effort == null ? null : `effort ${c.effort}`)), 'You have dispatched nothing.');
1884
1919
  },
1885
1920
  },
1886
1921
  {
@@ -2046,34 +2081,65 @@ const TOOLS = [
2046
2081
  },
2047
2082
  },
2048
2083
  {
2049
- name: 'create_epic',
2050
- levels: [1],
2051
- description: 'Create an epic in a project. An epic groups work items that belong to one body of work.',
2052
- input: { project_id: z.string(), name: z.string().min(1) },
2084
+ name: 'create_epic', levels: [1, 2],
2085
+ description: 'Create an epic with optional description and dates. Read list_structure first to avoid duplicates.',
2086
+ 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() },
2053
2087
  handler: async (caller, args) => {
2054
- const { project_id, name } = args;
2055
- const epic = await only(caller.client.from('epics').insert({ project_id, name }).select('id, name'), 'create', `an epic called ${name}`);
2056
- await receipt(caller, 'epic', epic.id, epic.name);
2057
- return `Created epic ${epic.name}, id ${epic.id}.`;
2088
+ const text = productResult(await createEpicHandler(caller.client, args));
2089
+ const row = JSON.parse(text).epic;
2090
+ await receipt(caller, 'epic', row.id, row.name);
2091
+ return text;
2058
2092
  },
2059
2093
  },
2060
2094
  {
2061
- name: 'create_sprint',
2062
- levels: [1],
2063
- description: 'Create an ordered batch of work in a project. Dates are optional; use them only for a stated calendar constraint.',
2064
- input: {
2065
- project_id: z.string(),
2066
- name: z.string().min(1),
2067
- start_date: z.string().optional().describe('YYYY-MM-DD'),
2068
- end_date: z.string().optional().describe('YYYY-MM-DD'),
2069
- },
2095
+ name: 'create_sprint', levels: [1, 2],
2096
+ description: 'Create an sprint with optional description and dates. Read list_structure first to avoid duplicates.',
2097
+ 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() },
2070
2098
  handler: async (caller, args) => {
2071
- const { project_id, name, start_date, end_date } = args;
2072
- const sprint = await only(caller.client.from('sprints').insert({ project_id, name, start_date, end_date }).select('id, name'), 'create', `a sprint called ${name}`);
2073
- await receipt(caller, 'sprint', sprint.id, sprint.name);
2074
- return `Created sprint ${sprint.name}, id ${sprint.id}.`;
2099
+ const text = productResult(await createSprintHandler(caller.client, args));
2100
+ const row = JSON.parse(text).sprint;
2101
+ await receipt(caller, 'sprint', row.id, row.name);
2102
+ return text;
2075
2103
  },
2076
2104
  },
2105
+ {
2106
+ name: 'list_structure', levels: ALL,
2107
+ description: 'Read project epics and sprints with their descriptions and dates.',
2108
+ input: { project_id: z.string().uuid() },
2109
+ handler: async (caller, args) => productResult(await listStructureHandler(caller.client, { project_id: args.project_id })),
2110
+ },
2111
+ {
2112
+ name: 'update_structure', levels: [1, 2],
2113
+ description: 'Edit an epic or sprint name, description, dates, or archive state. Null clears a date. Epics use target_date; sprints use end_date.',
2114
+ 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() },
2115
+ handler: async (caller, args) => productResult(await updateStructureHandler(caller.client, args)),
2116
+ },
2117
+ {
2118
+ name: 'search_agent_cards', levels: ALL,
2119
+ 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.',
2120
+ 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() },
2121
+ handler: async (caller, args) => productResult(await searchAgentCardsHandler(caller.client, args)),
2122
+ },
2123
+ {
2124
+ name: 'list_structure_artifacts', levels: ALL, description: 'Read active artifacts attached directly to an epic or sprint.',
2125
+ input: { kind: z.enum(['epic', 'sprint']), structure_id: z.string().uuid() },
2126
+ handler: async (caller, args) => productResult(await listStructureArtifactsHandler(caller.client, args.kind, args.structure_id)),
2127
+ },
2128
+ {
2129
+ name: 'get_structure_artifact', levels: ALL, description: 'Read a full epic or sprint artifact and its current revision.',
2130
+ input: { artifact_id: z.string().uuid() },
2131
+ handler: async (caller, args) => productResult(await getStructureArtifactHandler(caller.client, args.artifact_id)),
2132
+ },
2133
+ {
2134
+ name: 'create_structure_artifact', levels: ALL, description: 'Create a titled artifact on an epic or sprint. It appears on its management page.',
2135
+ 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) },
2136
+ 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; },
2137
+ },
2138
+ {
2139
+ 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.',
2140
+ 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() },
2141
+ handler: async (caller, args) => productResult(await updateStructureArtifactHandler(caller.client, args)),
2142
+ },
2077
2143
  {
2078
2144
  name: 'create_work_item',
2079
2145
  levels: [1],
@@ -2176,6 +2242,7 @@ const TOOLS = [
2176
2242
  + FIREWALL_WRITING_RULE,
2177
2243
  input: {
2178
2244
  work_item_id: z.string(),
2245
+ folder_name: z.string().max(80).nullable().optional().describe('Read list_artifact_folders first to reuse a folder.'),
2179
2246
  title: z.string().min(1),
2180
2247
  content: z.string().min(1),
2181
2248
  type: z.enum(['plan', 'spec', 'analysis', 'diagram', 'mock', 'wireframe', 'user_story']).optional(),
@@ -2185,6 +2252,7 @@ const TOOLS = [
2185
2252
  const a = args;
2186
2253
  const artifact = await only(caller.client.from('artifacts').insert({
2187
2254
  task_id: a.work_item_id,
2255
+ folder_name: a.folder_name ?? null,
2188
2256
  title: a.title,
2189
2257
  content: a.content,
2190
2258
  type: a.type ?? 'plan',
@@ -2894,6 +2962,8 @@ const TOOLS = [
2894
2962
  + 'context: what to find out or change, and in which part of the codebase.'),
2895
2963
  boundary: z.string().min(1).describe('What it must not touch, and where its work stops.'),
2896
2964
  work_item_id: z.string().optional().describe('The work item it is working, if there is one.'),
2965
+ 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.'),
2966
+ 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.'),
2897
2967
  work_name: z.string().optional().describe(`A few words, ${WORK_NAME_WORDS} at most, naming the WORK this conversation is doing, such `
2898
2968
  + 'as "Fix the sign-out checklist bug". Pass it when the conversation has NO work item '
2899
2969
  + 'attached: the conversation is called this from now on, and the branch the work goes on is '
@@ -2901,7 +2971,7 @@ const TOOLS = [
2901
2971
  + 'attached, because that item is already the name.'),
2902
2972
  },
2903
2973
  handler: async (caller, args) => {
2904
- const { codebase_id, responsibility, boundary, work_item_id, work_name } = args;
2974
+ const { codebase_id, responsibility, boundary, work_item_id, work_name, model, effort } = args;
2905
2975
  if (caller.level === 2 && !codebase_id) {
2906
2976
  throw new Error('A worker must be attached to a registered project codebase.');
2907
2977
  }
@@ -2964,7 +3034,7 @@ const TOOLS = [
2964
3034
  const attachments = attached.map(attachmentLine);
2965
3035
  const { runId } = await caller.dispatch(caller.runId, workBrief(childLevel, responsibility, boundary, work_item_id, attachments, codebase === null ? undefined : {
2966
3036
  id: codebase.id, name: codebase.name, identity: codebase.gitRemoteUrl,
2967
- }), codebase, caller.processToken);
3037
+ }), codebase, caller.processToken, { model, effort });
2968
3038
  /* ═══ WHERE ITS ANSWER GOES DEPENDS ON WHICH LEVEL THIS IS, AND THAT IS
2969
3039
  KNOWN HERE RATHER THAN GUESSED. ═══ The description above cannot say it,
2970
3040
  because it is registered once for both levels that hold the tool; this