@ctrl-spc/cs 0.7.6 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -550,6 +550,23 @@ export function levelOnePrompt(cardTitle, messages, produced, attachments = [],
550
550
  * Every child it starts, and every grandchild THEY start, would be working
551
551
  * from nothing but a responsibility sentence somebody else wrote down.
552
552
  */
553
+ export const OWNER_COMPLETION_RULES = [
554
+ 'ASSIGNMENT STATUS — THIS OVERRIDES OLDER ENDING INSTRUCTIONS IN YOUR BRIEF.',
555
+ 'A reply or process exit does not finish the assignment. Keep working through all remaining',
556
+ 'required steps and checks. Status questions never cancel work already in progress.',
557
+ 'When you yield to workers, write_report with what remains and exit; you resume with results.',
558
+ 'When no worker is active and work remains, continue it yourself or dispatch it. If you exit',
559
+ 'without declaring completion, the product starts you again to carry on.',
560
+ 'Only when the latest user assignment is fulfilled, call write_report with work_complete=true',
561
+ 'and a report explaining what fulfilled it. Check all required steps and verification first.',
562
+ 'Then give the final answer and exit. Do not declare completion for an interim status answer.',
563
+ 'A failed or skipped required step is unfinished work, even if all workers have returned.',
564
+ 'Resolve it or ask for the real required intervention; never convert it into a completion caveat.',
565
+ 'If a required user action prevents progress, use ask_question and stop. It must name the actual',
566
+ 'approval, credential or manual action needed. Never ask just because no agent is running.',
567
+ 'A handover explicitly requested by the person may finish that request without finishing a',
568
+ 'feature. Say exactly what was handed over and do not claim the unfinished feature is done.',
569
+ ].join('\n');
553
570
  export function workBrief(level, responsibility, boundary, workItemId, attachments = [], codebase) {
554
571
  /* ═══ ONLY WHEN A WORKFLOW IS ACTUALLY ON THE CARD. ═══ An owner is not
555
572
  instructed about a process that is not there, which is the rule
@@ -562,6 +579,7 @@ export function workBrief(level, responsibility, boundary, workItemId, attachmen
562
579
  ? [
563
580
  'You see one thing a person asked for through to done. Keep the whole picture and stay',
564
581
  'available to the person while the work moves.',
582
+ OWNER_COMPLETION_RULES,
565
583
  '',
566
584
  /* ═══ THE FIRST THING, EVERY TIME, AND IT IS NOT THE REPLY. ═══ Nothing
567
585
  an owner writes reaches the person until its process exits, and a turn
@@ -836,7 +854,7 @@ export const landingOutcomeContext = (landing) => {
836
854
  'They chose neither of the two answers, so nothing was merged and the work is still on '
837
855
  + `${landing.branch ?? 'its branch'}.`,
838
856
  'Read what they actually wrote and answer it. If they still want the work put back, offer the',
839
- 'ending again with `offer_ending` rather than replying: a reply finishes the card, and an',
857
+ 'ending again with `offer_ending` rather than declaring completion: an',
840
858
  'offer leaves it with them.',
841
859
  ];
842
860
  }
@@ -915,6 +933,7 @@ landing = null) {
915
933
  });
916
934
  return [
917
935
  'YOU OWN THIS CTRL+SPC CONVERSATION.',
936
+ OWNER_COMPLETION_RULES,
918
937
  'You are this conversation\'s one owner, started for one serialized activation. Speak directly to the',
919
938
  'person as CTRL+SPC. The launcher is finished and cannot answer or receive an escalation.',
920
939
  '',
@@ -133,7 +133,7 @@
133
133
  // `import type` loses its v3 header in the published `dist/`.
134
134
  import { out, returned, signedInClient } from './client.js';
135
135
  import { readableWriteError } from '../firewall.js';
136
- import { answerPrompt, escalationPrompt, levelOnePrompt, ownerActivationPrompt, readBackPrompt, landingOutcomeContext, presentedArtifactAnswerContext, resumePrompt, retryPrompt, standingRules, whatWasAttached, workingRules, } from './prompt.js';
136
+ import { answerPrompt, escalationPrompt, levelOnePrompt, ownerActivationPrompt, readBackPrompt, landingOutcomeContext, presentedArtifactAnswerContext, resumePrompt, retryPrompt, standingRules, whatWasAttached, workingRules, OWNER_COMPLETION_RULES, } from './prompt.js';
137
137
  import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, loadOutputNames, outputOf, recordBaseProtection, standingRulesFor, withAskContent, } from './show.js';
138
138
  import { forgetSecrets, redactSecrets } from './secrets.js';
139
139
  import { sayListening, stopListening } from './presence.js';
@@ -1950,8 +1950,17 @@ export function ownerContinuationPrompt(brief, report, events, children, deliver
1950
1950
  const messages = events.filter((event) => event.kind === 'user' && event.needsReply);
1951
1951
  return [
1952
1952
  'CONTINUE THE CTRL+SPC CONVERSATION YOU ALREADY OWN.',
1953
+ OWNER_COMPLETION_RULES,
1953
1954
  'Use only the new facts below as the new turn. Keep coordinating the whole card and speak as CTRL+SPC.',
1954
1955
  '',
1956
+ ...(messages.length === 0 && delivered === null ? [
1957
+ 'WHY YOU WERE STARTED AGAIN',
1958
+ 'The assignment is still open. Your previous final reply did not record completion.',
1959
+ 'Do not repeat a completion reply by itself. If the assignment is fulfilled, call',
1960
+ 'write_report with work_complete=true NOW, then give the final answer. Otherwise do the',
1961
+ 'remaining work or resolve the new worker results below. No new user message is needed.',
1962
+ '',
1963
+ ] : []),
1955
1964
  'NEW USER MESSAGES',
1956
1965
  ...(messages.length === 0
1957
1966
  ? ['None.']
@@ -2425,15 +2434,17 @@ export async function reconcileOwnerSessions(client, machineId, _machineHarness,
2425
2434
  async function killStopped(client, machineId) {
2426
2435
  const stopped = await returned(client
2427
2436
  .from('panel3_runs')
2428
- .select('id, card_id, pid, started_at, resumed_at')
2437
+ .select('id, card_id, pid, state, started_at, resumed_at')
2429
2438
  .eq('machine_id', machineId)
2430
- .in('state', ENDED_BY_THE_PERSON)
2439
+ .in('state', [...ENDED_BY_THE_PERSON, 'finished', 'failed'])
2431
2440
  .not('pid', 'is', null), 'read', 'the runs on this machine that are not coming back');
2432
2441
  if (stopped.length === 0)
2433
2442
  return;
2434
2443
  const booted = bootedAt();
2435
2444
  for (const run of stopped) {
2436
2445
  if (runProcessIsAlive(run, booted)) {
2446
+ if (!ENDED_BY_THE_PERSON.includes(run.state))
2447
+ continue;
2437
2448
  try {
2438
2449
  process.kill(run.pid);
2439
2450
  out(`stopped run ${run.id} card ${run.card_id} killed pid ${run.pid}`);
@@ -2455,11 +2466,11 @@ async function killStopped(client, machineId) {
2455
2466
  await returned(client
2456
2467
  .from('panel3_runs')
2457
2468
  .update({ pid: null })
2458
- // STILL NOT COMING BACK. Nothing starts either of these states, so
2459
- // this cannot lose a race, and saying so in the statement is what
2460
- // keeps that true if something one day does.
2469
+ // A finished owner may activate again between this read and write.
2470
+ // Only acknowledge the exact process whose exit was observed.
2461
2471
  .eq('id', run.id)
2462
- .in('state', ENDED_BY_THE_PERSON)
2472
+ .eq('pid', run.pid)
2473
+ .in('state', [...ENDED_BY_THE_PERSON, 'finished', 'failed'])
2463
2474
  .select('id'), 'clear the process id of', `run ${run.id}`);
2464
2475
  }
2465
2476
  catch (error) {
@@ -2562,7 +2573,9 @@ async function recoverStranded(client, tools, machineId, mine, hold) {
2562
2573
  await writeAnswer(client, run.id, run.card_id, null, run.process_token ?? undefined);
2563
2574
  }
2564
2575
  else {
2565
- const resumed = await activateOwner(client, tools, machineId, run.id, run.process_token, run.pid);
2576
+ // This process belonged to a previous daemon. Use the fenced recovery
2577
+ // claim, not a failed-attempt retry whose budget may already be spent.
2578
+ const resumed = await activateOwner(client, tools, machineId, run.id, run.process_token, null);
2566
2579
  if (resumed)
2567
2580
  hold(run.id, resumed.settled);
2568
2581
  }
@@ -2914,7 +2927,7 @@ export async function sweepFinishedWorktrees(client) {
2914
2927
  const live = new Set((await returned(client.from('panel3_runs')
2915
2928
  .select('card_id')
2916
2929
  .in('card_id', cards)
2917
- .is('ended_at', null), 'read', 'which of those cards still have an agent going')).map((row) => row.card_id));
2930
+ .or('ended_at.is.null,pid.not.is.null'), 'read', 'which of those cards still have an agent going')).map((row) => row.card_id));
2918
2931
  const touched = new Set();
2919
2932
  for (const one of held) {
2920
2933
  const cardIds = cardOfFolder.get(one.folder) ?? [];
@@ -112,6 +112,10 @@
112
112
  // The runtime import comes FIRST, deliberately: `tsc` elides a type-only import
113
113
  // and takes the leading comment with it, so a file whose first statement is
114
114
  // `import type` loses its v3 header in the published `dist/`.
115
+ import { presentDesign } from '../design-review.js';
116
+ import { canonicalArgsHash, getDocumentHandler, proposeProjectContextHandler, placeWorkItemHandler, resolveFeedbackHandler, PROJECT_DOCUMENT_TYPES, errorMessage } from '../product-tools.js';
117
+ import { workflowToolForName, workflowToolAlwaysAllowed } from '../workflow-tool-mentions.js';
118
+ import { captureUrlScreenshot } from '../browser.js';
115
119
  import { createServer as createHttpServer } from 'node:http';
116
120
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
117
121
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
@@ -126,7 +130,7 @@ import { workBrief } from './prompt.js';
126
130
  here is the same fact without a second round trip. */
127
131
  import { harness } from './spawn.js';
128
132
  import { listCodebases } from '../codebases.js';
129
- import { buildWorkflow, duplicateWorkflow, editWorkflow, readWorkflow, rewordStage } from '../workflows.js';
133
+ import { buildWorkflow, duplicateWorkflow, editWorkflow, readWorkflow, rewordStage, workflowToolPermission, validateWorkflowToolTarget, WORKFLOW_TOOL_TEACHING } from '../workflows.js';
130
134
  /* ═══ 38-panel3-steps: THE FIFTH NEUTRAL MODULE, AND A DECISION LIKE THE OTHERS.
131
135
  ═══ Stages and steps are the work item's own record, shared by both
132
136
  generations and read by the web's Steps section. `steps.ts` owns the rows so
@@ -738,7 +742,173 @@ async function stageForWriting(caller, stageId, consequence) {
738
742
  await carriedWorkItem(caller, stage.workItemId, consequence);
739
743
  return stage;
740
744
  }
745
+ function productResult(result) {
746
+ const text = result.content.filter((item) => Boolean(item && typeof item === 'object' && 'text' in item)).map(item => item.text).join('\n');
747
+ if (result.isError)
748
+ throw new Error(text);
749
+ return text;
750
+ }
741
751
  const TOOLS = [
752
+ {
753
+ name: 'get_document', levels: ALL,
754
+ description: 'Read a project context document or instruction in full by id. Read-only.',
755
+ input: { id: z.string().uuid() },
756
+ handler: async (caller, args) => productResult(await getDocumentHandler(caller.client, args)),
757
+ },
758
+ {
759
+ name: 'add_comment', levels: ALL,
760
+ description: 'Leave a durable note or handover on a work item. ' + FIREWALL_WRITING_RULE,
761
+ input: { work_item_id: z.string().uuid(), body: z.string().min(1) },
762
+ handler: async (caller, args) => {
763
+ const { work_item_id, body } = args;
764
+ await carriedWorkItem(caller, work_item_id, 'a comment cannot be added to it');
765
+ const comment = await only(caller.client.from('comments')
766
+ .insert({ task_id: work_item_id, body, author_id: caller.userId }).select('id'), 'add', 'the comment');
767
+ return `Added comment ${comment.id}.`;
768
+ },
769
+ },
770
+ {
771
+ name: 'create_product_idea', levels: [1],
772
+ description: 'Capture a potential improvement without committing to implementation. First read existing work items, including Product Ideas, and the product to avoid duplicates. Only a human can promote an idea. ' + FIREWALL_WRITING_RULE,
773
+ input: { project_id: z.string().uuid(), title: z.string().min(1), description: z.string().optional() },
774
+ handler: async (caller, args) => {
775
+ const a = args;
776
+ const id = await returned(caller.client.rpc('create_product_idea', {
777
+ p_project: a.project_id, p_name: a.title, p_description: a.description ?? '', p_owner: caller.userId,
778
+ }), 'create', 'the product idea');
779
+ if (!id)
780
+ throw new Error('The product idea was not created.');
781
+ await receipt(caller, 'work_item', id, a.title);
782
+ return `Created Product Idea ${a.title}, id ${id}. No work has started. Only a human can promote it.`;
783
+ },
784
+ },
785
+ {
786
+ name: 'place_work_item', levels: [1],
787
+ description: 'Move a work item into an epic or sprint in its project. Null removes placement. Its status stays the same.',
788
+ input: { work_item_id: z.string().uuid(), epic_id: z.string().uuid().nullable().optional(),
789
+ sprint_id: z.string().uuid().nullable().optional(), before_work_item_id: z.string().uuid().optional() },
790
+ handler: async (caller, args) => productResult(await placeWorkItemHandler(caller.client, args)),
791
+ },
792
+ {
793
+ name: 'reorder_backlog', levels: [1],
794
+ description: 'Move one backlog work item before another, or to the bottom. Read the current order first and explain the reason.',
795
+ input: { project_id: z.string().uuid(), task_id: z.string().uuid(), before_task_id: z.string().uuid().optional(), reason: z.string().min(1) },
796
+ handler: async (caller, args) => {
797
+ const a = args;
798
+ const items = await rows(caller.client.from('tasks').select('id, revision')
799
+ .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');
800
+ const moving = items.find(item => item.id === a.task_id);
801
+ if (!moving || (a.before_task_id && !items.some(item => item.id === a.before_task_id)))
802
+ throw new Error('Both items must be live backlog work in this project. Nothing was moved.');
803
+ if (a.task_id === a.before_task_id)
804
+ throw new Error('An item cannot be placed before itself. Nothing was moved.');
805
+ await returned(caller.client.rpc('save_task_if_current', { p_task_id: a.task_id,
806
+ p_expected_revision: moving.revision, p_changes: {}, p_tag_ids: null, p_reorder: true,
807
+ p_before_task_id: a.before_task_id ?? null }), 'reorder', 'the backlog');
808
+ return `Reordered the backlog: ${a.reason}`;
809
+ },
810
+ },
811
+ {
812
+ name: 'propose_project_context', levels: [2, 3],
813
+ description: 'Submit repository guidance for human review in Project settings. Pending proposals for this scan are replaced; accepted context is never changed. Name the scanned codebase and use repository-relative source paths. ' + FIREWALL_WRITING_RULE,
814
+ input: { work_item_id: z.string().uuid(), codebase: z.string().min(1), proposals: z.array(z.object({
815
+ title: z.string().min(1), type: z.enum(PROJECT_DOCUMENT_TYPES), content: z.string().min(1),
816
+ source_path: z.string().min(1), reason: z.string().min(1), scope: z.enum(['project', 'codebase']),
817
+ })) },
818
+ handler: async (caller, args) => {
819
+ const { work_item_id, ...a } = args;
820
+ await carriedWorkItem(caller, work_item_id, 'project context cannot be proposed for it');
821
+ return productResult(await proposeProjectContextHandler(caller.client, null, { ...a, task_id: work_item_id }));
822
+ },
823
+ },
824
+ {
825
+ name: 'record_context_exploration', levels: ALL,
826
+ description: 'Save explored context and research in the work item’s Findings artifact. Repeated calls update the same artifact. ' + FIREWALL_WRITING_RULE,
827
+ input: { work_item_id: z.string().uuid(), content: z.string().min(1) },
828
+ handler: async (caller, args) => {
829
+ const { work_item_id, content } = args;
830
+ await carriedWorkItem(caller, work_item_id, 'findings cannot be recorded on it');
831
+ const existing = await rows(caller.client.from('artifacts').select('id')
832
+ .eq('task_id', work_item_id).eq('purpose_key', 'workflow_findings').is('deleted_at', null), 'read', 'existing findings');
833
+ if (existing.length > 1)
834
+ throw new Error('More than one Findings artifact exists. Choose the artifact to edit explicitly.');
835
+ const artifact = existing[0]
836
+ ? await only(caller.client.from('artifacts').update({ content, updated_by: caller.userId })
837
+ .eq('id', existing[0].id).select('id'), 'update', 'the findings')
838
+ : await only(caller.client.from('artifacts').insert({ task_id: work_item_id, title: 'Findings',
839
+ type: 'analysis', format: 'md', content, purpose_key: 'workflow_findings', created_by: caller.userId }).select('id'), 'record', 'the findings');
840
+ await receiptOnce(caller, 'artifact', artifact.id, 'Findings');
841
+ return `Recorded Findings artifact ${artifact.id}.`;
842
+ },
843
+ },
844
+ {
845
+ name: 'resolve_feedback', levels: ALL,
846
+ description: 'Mark specified feedback rounds addressed after revising their artifact. Supply the new revision. This does not approve the artifact.',
847
+ input: { work_item_id: z.string().uuid(), feedback_ids: z.array(z.string().uuid()).min(1), revision: z.number().int().positive() },
848
+ handler: async (caller, args) => {
849
+ const a = args;
850
+ await carriedWorkItem(caller, a.work_item_id, 'its feedback cannot be resolved');
851
+ await validateWorkflowToolTarget(caller.client, a.work_item_id, 'resolve_feedback', a);
852
+ return productResult(await resolveFeedbackHandler(caller.client, { taskId: a.work_item_id }, a));
853
+ },
854
+ },
855
+ ...['present_mocks', 'present_wireframes'].map((name) => ({
856
+ name, levels: [2],
857
+ description: 'Create a self-contained HTML design and open its artifact review. Use html, with scripts inline for interactive mocks. Only the active conversation owner may present for review. Revise the same artifact for changes; do not present it again. Stop after opening review. ' + FIREWALL_WRITING_RULE,
858
+ input: { work_item_id: z.string().uuid(), title: z.string().min(1), html: z.string().min(1),
859
+ ...(name === 'present_wireframes' ? { kind: z.enum(['ui', 'architecture']) } : {}) },
860
+ handler: async (caller, args) => {
861
+ if (!caller.isOwner || !caller.processToken)
862
+ throw new Error('Escalate this design presentation to the current conversation owner. Nothing was written.');
863
+ const a = args;
864
+ await carriedWorkItem(caller, a.work_item_id, 'a design cannot be presented for it');
865
+ const mock = name === 'present_mocks';
866
+ const result = await presentDesign(caller.client, caller.userId, a.work_item_id, a, {
867
+ tool: name, unit: mock ? 'mock' : 'diagram', htmlSpec: 'self-contained HTML',
868
+ artifactType: mock ? 'mock' : a.kind === 'architecture' ? 'diagram' : 'wireframe',
869
+ artifactNoun: mock ? 'mock' : 'wireframe',
870
+ }, async (kind, id) => {
871
+ if (kind === 'artifact')
872
+ await receiptOnce(caller, 'artifact', id, a.title);
873
+ }, async (review) => {
874
+ await asked(caller, { work_item_id: a.work_item_id, related_artifact_id: review.artifactId,
875
+ category: 'wireframe_review', context: review.title, question: review.question,
876
+ answer_mode: 'single_select', options: review.options }, true);
877
+ const decision = await only(caller.client.from('decisions').select('id')
878
+ .eq('related_artifact_id', review.artifactId).eq('category', 'wireframe_review'), 'read', 'the design review');
879
+ return decision.id;
880
+ });
881
+ const text = productResult(result);
882
+ return text + '\nStop now. The conversation resumes when the user answers. Do not poll.';
883
+ },
884
+ })),
885
+ {
886
+ name: 'propose_scope_change', levels: [2],
887
+ description: 'Present a proposed change to approved scope, with the user request, classification, and quoted evidence, as an artifact for review. Only the conversation owner may open its approval. Stop; permission to propose does not approve expanded work. ' + FIREWALL_WRITING_RULE,
888
+ input: { work_item_id: z.string().uuid(), request: z.string().min(1),
889
+ classification: z.enum(['completion_gap', 'clarification', 'expansion', 'replacement']),
890
+ evidence: z.string().min(1), measured_against_version: z.number().int().positive() },
891
+ handler: async (caller, args) => {
892
+ if (!caller.isOwner || !caller.processToken)
893
+ throw new Error('Escalate the scope proposal to the current conversation owner. Nothing was written.');
894
+ const a = args;
895
+ await carriedWorkItem(caller, a.work_item_id, 'a scope change cannot be proposed for it');
896
+ const artifact = await only(caller.client.from('artifacts').insert({ task_id: a.work_item_id,
897
+ title: 'Scope change proposal', type: 'spec', format: 'md', created_by: caller.userId,
898
+ content: `# Proposed scope change\n\n${a.request}\n\nClassification: ${a.classification}\n\nCompared with approved scope version ${a.measured_against_version}:\n\n${a.evidence}\n\nProposed only. Do not implement until the user approves.`,
899
+ }).select('id'), 'create', 'the scope change proposal');
900
+ await receipt(caller, 'artifact', artifact.id, 'Scope change proposal');
901
+ try {
902
+ return await asked(caller, { work_item_id: a.work_item_id, related_artifact_id: artifact.id,
903
+ category: 'scope_change', context: 'Review the proposed change before any expanded work begins.',
904
+ question: 'Approve this scope change, or request changes?', answer_mode: 'single_select',
905
+ options: ['approve', 'request changes'] }, true);
906
+ }
907
+ catch (error) {
908
+ throw new Error(`Proposal artifact ${artifact.id} was created, but review did not open: ${errorMessage(error)}. Do not create it again. Use ask_question to request approval of this artifact; do not implement it.`);
909
+ }
910
+ },
911
+ },
742
912
  // ── Read the record ──────────────────────────────────────────────────────
743
913
  {
744
914
  name: 'list_projects',
@@ -789,6 +959,9 @@ const TOOLS = [
789
959
  .eq('id', work_item_id), 'read', `work item ${work_item_id}`);
790
960
  const artifacts = await rows(client.from('artifacts').select('id, type, title, created_at').eq('task_id', work_item_id)
791
961
  .is('deleted_at', null).order('created_at'), 'read', `the artifacts on work item ${work_item_id}`);
962
+ const feedback = await rows(client.from('artifact_feedback').select('*').eq('task_id', work_item_id).order('created_at'), 'read', 'the artifact feedback');
963
+ 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');
964
+ 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');
792
965
  return [
793
966
  `${item.name}`,
794
967
  `id ${item.id}`,
@@ -801,6 +974,7 @@ const TOOLS = [
801
974
  'DESCRIPTION',
802
975
  item.description.trim() === '' ? '(empty)' : item.description,
803
976
  '',
977
+ 'DECISIONS', JSON.stringify(decisions), 'FEEDBACK', JSON.stringify(feedback), 'COMMENTS', JSON.stringify(comments),
804
978
  `ARTIFACTS ${artifacts.length}`,
805
979
  listed(artifacts.map((a) => line(a.id, a.type, a.title ?? '(untitled)')), 'none'),
806
980
  ].join('\n');
@@ -1879,12 +2053,12 @@ const TOOLS = [
1879
2053
  {
1880
2054
  name: 'create_sprint',
1881
2055
  levels: [1],
1882
- description: 'Create a sprint in a project. Both dates are required and the start must not be after the end.',
2056
+ description: 'Create an ordered batch of work in a project. Dates are optional; use them only for a stated calendar constraint.',
1883
2057
  input: {
1884
2058
  project_id: z.string(),
1885
2059
  name: z.string().min(1),
1886
- start_date: z.string().describe('YYYY-MM-DD'),
1887
- end_date: z.string().describe('YYYY-MM-DD'),
2060
+ start_date: z.string().optional().describe('YYYY-MM-DD'),
2061
+ end_date: z.string().optional().describe('YYYY-MM-DD'),
1888
2062
  },
1889
2063
  handler: async (caller, args) => {
1890
2064
  const { project_id, name, start_date, end_date } = args;
@@ -2332,6 +2506,26 @@ const TOOLS = [
2332
2506
  // because a conversation is one person's. An artifact belongs to a work item
2333
2507
  // the whole org can see, so it goes where every other artifact goes. Two
2334
2508
  // visibility models, two buckets, both deliberate.
2509
+ {
2510
+ name: 'attach_screenshot',
2511
+ levels: [2, 3],
2512
+ description: 'Capture a web page and keep the screenshot as an artifact on this work item. Pass url; the tool takes the picture. ' + WORKFLOW_TOOL_TEACHING,
2513
+ input: {
2514
+ url: z.string().url(), work_item_id: z.string().uuid(), title: z.string().min(1).max(200),
2515
+ platform: z.literal('web'), target: z.string().min(1).max(200),
2516
+ },
2517
+ handler: async (caller, args) => {
2518
+ const a = args;
2519
+ await carriedWorkItem(caller, a.work_item_id, 'a screenshot cannot be attached to it');
2520
+ const capture = await captureUrlScreenshot(a.url);
2521
+ try {
2522
+ return await toolNamed('attach_image_artifact').handler(caller, { ...a, path: capture.path });
2523
+ }
2524
+ finally {
2525
+ await capture.cleanup();
2526
+ }
2527
+ },
2528
+ },
2335
2529
  {
2336
2530
  name: 'attach_image_artifact',
2337
2531
  levels: [2, 3],
@@ -2481,11 +2675,21 @@ const TOOLS = [
2481
2675
  description: 'Write down what is true NOW: what you have done, what you decided and why, and what is still '
2482
2676
  + 'open. It REPLACES your last report rather than adding to it, and it is what you are handed if '
2483
2677
  + 'you are respawned, so write it as the thing you would want to read to carry on. Write it as '
2484
- + 'you go, not only at the end. '
2678
+ + 'you go, not only at the end. The conversation owner MUST set work_complete=true only when '
2679
+ + 'the latest user assignment is fulfilled, including its required steps and checks. A status '
2680
+ + 'reply is not completion. This records intent; Done waits for all agents to exit. '
2485
2681
  + FIREWALL_WRITING_RULE,
2486
- input: { report: z.string().min(1) },
2487
- handler: async ({ client, runId, processToken }, args) => {
2488
- const { report } = args;
2682
+ input: { report: z.string().min(1), work_complete: z.boolean().optional() },
2683
+ handler: async ({ client, runId, processToken, isOwner }, args) => {
2684
+ const { report, work_complete } = args;
2685
+ if (work_complete) {
2686
+ if (!isOwner || processToken === undefined)
2687
+ throw new Error('Only the conversation owner can declare the assignment complete.');
2688
+ await whileRunning(client.rpc('panel3_request_completion', {
2689
+ p_run_id: runId, p_process_token: processToken, p_summary: report,
2690
+ }), runId, 'declare completion of');
2691
+ return 'Completion recorded. Give your final answer and exit. Done waits until all processes have exited.';
2692
+ }
2489
2693
  await whileRunning(processToken === undefined
2490
2694
  ? client.from('panel3_runs').update({ report }).eq('id', runId)
2491
2695
  .in('state', STILL_WRITING).is('ended_at', null).select('id')
@@ -2590,7 +2794,11 @@ const TOOLS = [
2590
2794
  + 'or sends back for changes. Only the current Level 2 conversation owner may name it, and '
2591
2795
  + 'its current process token is required. Leave it out for every ordinary question.'),
2592
2796
  },
2593
- handler: async (caller, args) => asked(caller, args, true),
2797
+ handler: async (caller, args) => {
2798
+ if (String(args.category).startsWith('workflow_tool_'))
2799
+ throw new Error('That category is reserved for tool permissions. The tool opens its own approval question; do not create one yourself.');
2800
+ return asked(caller, args, true);
2801
+ },
2594
2802
  },
2595
2803
  {
2596
2804
  name: 'escalate',
@@ -2895,9 +3103,60 @@ async function receiptOnce(caller, kind, refId, label) {
2895
3103
  * the same reason `cs show` exists: a rule nobody can print is a rule nobody
2896
3104
  * can check. */
2897
3105
  export function toolNamesForLevel(level, isOwner = false) {
2898
- return TOOLS.filter((t) => t.levels.includes(level) && !(isOwner && t.name === 'escalate'))
3106
+ return TOOLS.filter((t) => toolAvailable(t, level, isOwner))
2899
3107
  .map((t) => t.name);
2900
3108
  }
3109
+ /** The stage owns the setting; the existing question path owns permission.
3110
+ * An exact-call fingerprint prevents approval of one edit authorizing another. */
3111
+ async function invokeWorkflowTool(caller, tool, args) {
3112
+ const canonical = workflowToolForName(tool.name);
3113
+ const a = args;
3114
+ const { workflow_tool_instance: instance, workflow_work_item_id: explicitSource, ...inputs } = a;
3115
+ const invoke = () => tool.handler(caller, inputs);
3116
+ if (!canonical || workflowToolAlwaysAllowed(canonical))
3117
+ return invoke();
3118
+ const attached = await loadAttachments(caller.client, caller.cardId);
3119
+ const ids = attached.filter(item => item.kind === 'work_item').map(item => item.ref_id);
3120
+ let workItemId = explicitSource;
3121
+ if (!workItemId && ids.length === 1)
3122
+ workItemId = ids[0];
3123
+ if (!workItemId && ids.includes(String(a.work_item_id ?? a.task_id)))
3124
+ workItemId = String(a.work_item_id ?? a.task_id);
3125
+ if (!workItemId && tool.name === 'create_step') {
3126
+ const stage = await stageById(caller.client, String(a.stage_id));
3127
+ workItemId = stage?.workItemId ?? undefined;
3128
+ }
3129
+ if (!workItemId && ids.length > 1)
3130
+ throw new Error('Name workflow_work_item_id from the card attachments so the correct stage controls this action.');
3131
+ if (workItemId)
3132
+ await carriedWorkItem(caller, workItemId, 'its workflow permissions cannot be used', attached);
3133
+ const permission = await workflowToolPermission(caller.client, workItemId ?? null, canonical, instance);
3134
+ if (!tool.levels.includes(caller.level) && !permission)
3135
+ throw new Error('This planning action requires an explicit tool mention in the current workflow stage. Nothing was written.');
3136
+ if (permission)
3137
+ await validateWorkflowToolTarget(caller.client, workItemId, canonical, a);
3138
+ if (!permission || permission.approval === 'not-required')
3139
+ return invoke();
3140
+ const fingerprint = canonicalArgsHash({ tool: tool.name, workItemId, instance: permission.id, args: inputs });
3141
+ const category = `workflow_tool_${fingerprint}`;
3142
+ const questions = await withAskContent(caller.client, await rows(caller.client.from('panel3_asks').select(`pending_run_id, answered_at, ${ASK_CONTENT_COLUMNS}`).eq('card_id', caller.cardId), 'read', 'workflow tool approvals'));
3143
+ const previous = questions.find(question => question.category === category && question.decision_id !== null);
3144
+ if (previous?.answered_at) {
3145
+ if (previous.selected_options?.length === 1 && previous.selected_options[0] === 'approve')
3146
+ return invoke();
3147
+ throw new Error('The user did not approve this workflow tool action. Nothing was written.');
3148
+ }
3149
+ if (previous)
3150
+ throw new Error('This workflow tool action is waiting for user approval. Stop until the user answers.');
3151
+ if (caller.level === 3)
3152
+ throw new Error('This workflow tool action requires user approval. Escalate the exact action to the conversation owner and stop; the owner must perform the approved call.');
3153
+ await asked(caller, {
3154
+ question: `May I ${tool.name.replaceAll('_', ' ')}${a.title ? `: ${String(a.title).slice(0, 180)}` : ''}?`,
3155
+ category, context: 'This use of the tool requires approval in the workflow stage.',
3156
+ answer_mode: 'single_select', options: ['approve', 'deny'], work_item_id: workItemId,
3157
+ }, true);
3158
+ throw new Error('This workflow tool action is waiting for user approval. Nothing was written. Stop until the user answers, then retry the same call with the same arguments.');
3159
+ }
2901
3160
  /**
2902
3161
  * One tool's handler, by name. Exported for the same reason `toolNamesForLevel`
2903
3162
  * is: a test that only calls `workBrief` with a hand-built array proves the
@@ -2917,6 +3176,11 @@ export function toolShape(name) {
2917
3176
  const { description, input } = toolNamed(name);
2918
3177
  return { description, input };
2919
3178
  }
3179
+ function toolAvailable(tool, level, isOwner) {
3180
+ if (isOwner && tool.name === 'escalate')
3181
+ return false;
3182
+ return tool.levels.includes(level) || (level === 2 && isOwner && workflowToolForName(tool.name) !== undefined);
3183
+ }
2920
3184
  function toolNamed(name) {
2921
3185
  const tool = TOOLS.find((t) => t.name === name);
2922
3186
  if (!tool)
@@ -2938,9 +3202,14 @@ function buildServer(caller) {
2938
3202
  + 'hold rather than one that is missing.',
2939
3203
  });
2940
3204
  for (const tool of TOOLS) {
2941
- if (!tool.levels.includes(caller.level) || (caller.isOwner && tool.name === 'escalate'))
3205
+ if (!toolAvailable(tool, caller.level, caller.isOwner))
2942
3206
  continue;
2943
- server.registerTool(tool.name, { description: tool.description, inputSchema: tool.input }, (async (args) => {
3207
+ server.registerTool(tool.name, { description: tool.description + ' ' + WORKFLOW_TOOL_TEACHING, inputSchema: { ...tool.input,
3208
+ ...(workflowToolForName(tool.name) ? {
3209
+ workflow_tool_instance: z.string().uuid().optional(),
3210
+ workflow_work_item_id: z.string().uuid().optional().describe('The attached work item whose workflow stage instructs this action. Needed when the card carries multiple work items.'),
3211
+ } : {}),
3212
+ } }, (async (args) => {
2944
3213
  try {
2945
3214
  /* ═══ NOTHING THIS RUN READ GOES BACK OUT THROUGH A TOOL. ═══ Every
2946
3215
  argument of every tool, redacted before the handler sees it: see
@@ -2949,7 +3218,7 @@ function buildServer(caller) {
2949
3218
  on; `writeAnswer` in run.ts is the other. It is identity for the
2950
3219
  runs that read no credential, which is nearly all of them. */
2951
3220
  const safe = redactArgs(secretScope(caller), args);
2952
- return { content: [{ type: 'text', text: await tool.handler(caller, safe) }] };
3221
+ return { content: [{ type: 'text', text: await invokeWorkflowTool(caller, tool, safe) }] };
2953
3222
  }
2954
3223
  catch (error) {
2955
3224
  /* ═══ A FAILURE REACHES THE AGENT AS A FAILURE. ═══ Constraint 7, at