@ctrl-spc/cli 1.2.2 → 1.3.0

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.
@@ -12,6 +12,7 @@ import { MCP_PORT } from '../env.js';
12
12
  import { startMcpServer } from '../mcp.js';
13
13
  import { getMachineIdentity } from '../config.js';
14
14
  import { discoverRepositoryTopology } from '../topology.js';
15
+ import { createAgentDispatchController } from '../dispatch.js';
15
16
  function mcpUrl() {
16
17
  return `http://localhost:${MCP_PORT}/mcp`;
17
18
  }
@@ -611,12 +612,13 @@ export function createProjectPresenceReconciler(client, userId, profileName, mac
611
612
  * tool call can flip presence back to "working" mid-shutdown) and only then
612
613
  * untracks presence, before exiting.
613
614
  */
614
- function installShutdownHandler(presence, mcpServer) {
615
+ function installShutdownHandler(presence, mcpServer, dispatch) {
615
616
  let shuttingDown = false;
616
617
  const shutdown = () => {
617
618
  if (shuttingDown)
618
619
  return;
619
620
  shuttingDown = true;
621
+ dispatch?.stop();
620
622
  console.log('\nShutting down — closing MCP server, untracking presence...');
621
623
  mcpServer
622
624
  .close()
@@ -712,8 +714,10 @@ export async function run() {
712
714
  }
713
715
  console.log(`MCP: ${url} (tools: list_tasks, get_task, begin_work, heartbeat_work, reserve_work_paths, ` +
714
716
  'release_work_paths, transfer_coordination, acknowledge_unavailable_checkout, record_context_exploration, ' +
715
- 'ask_question, end_work, create_task, update_task, create_artifact, add_comment)');
717
+ 'ask_question, record_user_input, submit_workflow_stage, decide_workflow_review, ' +
718
+ 'end_work, create_task, update_task, create_artifact, add_comment)');
716
719
  console.log(`Projects: ${projects.length > 0 ? projects.map((project) => project.name).join(', ') : 'none initialized on this machine'}`);
717
720
  console.log('Presence: available');
718
- installShutdownHandler(presence, mcpServer);
721
+ const dispatch = createAgentDispatchController(client, machine.id, registeredAgents);
722
+ installShutdownHandler(presence, mcpServer, dispatch);
719
723
  }
@@ -0,0 +1,104 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { resolveAgentCommand } from './agents.js';
3
+ function shellQuote(value) {
4
+ return `'${value.replaceAll("'", "'\\''")}'`;
5
+ }
6
+ function appleScriptString(value) {
7
+ return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
8
+ }
9
+ export function agentTerminalCommand(request, executable) {
10
+ const prompt = shellQuote(request.prompt);
11
+ const binary = shellQuote(executable);
12
+ const fresh = `${binary} ${prompt}`;
13
+ const resume = request.agent_kind === 'claude'
14
+ ? `${binary} --continue ${prompt}`
15
+ : `${binary} resume --last ${prompt}`;
16
+ const agentCommand = request.mode === 'resume' ? `( ${resume} || ${fresh} )` : fresh;
17
+ return `cd ${shellQuote(request.workspace_root)} && ${agentCommand}`;
18
+ }
19
+ /** Opens a visible agent conversation. Resume is best-effort and falls back
20
+ * to a fresh task conversation in the same project checkout. */
21
+ export async function launchAgentDispatch(request) {
22
+ if (process.platform !== 'darwin') {
23
+ throw new Error('Automatic agent launch currently requires macOS Terminal.');
24
+ }
25
+ const executable = resolveAgentCommand(request.agent_kind);
26
+ if (!executable)
27
+ throw new Error(`${request.agent_kind} is not installed on this machine.`);
28
+ const command = agentTerminalCommand(request, executable);
29
+ const script = `tell application "Terminal" to activate\ntell application "Terminal" to do script ${appleScriptString(command)}`;
30
+ await new Promise((resolve, reject) => {
31
+ const child = spawn('/usr/bin/osascript', ['-e', script], {
32
+ stdio: ['ignore', 'ignore', 'pipe'],
33
+ });
34
+ let stderr = '';
35
+ child.stderr.on('data', (chunk) => { stderr += String(chunk); });
36
+ child.once('error', reject);
37
+ child.once('exit', (code) => {
38
+ if (code === 0)
39
+ resolve();
40
+ else
41
+ reject(new Error(stderr.trim() || `Terminal launch exited with code ${code ?? 'unknown'}.`));
42
+ });
43
+ });
44
+ }
45
+ export function createAgentDispatchController(client, machineId, agents, deps = {}) {
46
+ const claim = deps.claim ?? (async () => {
47
+ const { data, error } = await client.rpc('claim_agent_dispatch_request', {
48
+ p_machine: machineId,
49
+ p_agents: agents,
50
+ });
51
+ if (error)
52
+ throw error;
53
+ return data;
54
+ });
55
+ const complete = deps.complete ?? (async (id, state, errorMessage) => {
56
+ const { error } = await client.rpc('complete_agent_dispatch_request', {
57
+ p_request: id,
58
+ p_state: state,
59
+ p_error: errorMessage ?? null,
60
+ });
61
+ if (error)
62
+ throw error;
63
+ });
64
+ const launch = deps.launch ?? launchAgentDispatch;
65
+ let stopped = false;
66
+ let polling = false;
67
+ let timer;
68
+ const poll = async () => {
69
+ if (stopped || polling)
70
+ return;
71
+ polling = true;
72
+ try {
73
+ const request = await claim();
74
+ if (!request)
75
+ return;
76
+ try {
77
+ await launch(request);
78
+ await complete(request.id, 'launched');
79
+ }
80
+ catch (err) {
81
+ await complete(request.id, 'failed', err instanceof Error ? err.message : String(err));
82
+ }
83
+ }
84
+ finally {
85
+ polling = false;
86
+ }
87
+ };
88
+ timer = setInterval(() => {
89
+ void poll().catch((err) => console.warn(`Warning: agent dispatch poll failed: ${err.message}`));
90
+ }, deps.intervalMs ?? 2500);
91
+ timer.unref();
92
+ if (deps.startImmediately !== false) {
93
+ void poll().catch((err) => console.warn(`Warning: agent dispatch poll failed: ${err.message}`));
94
+ }
95
+ return {
96
+ poll,
97
+ stop() {
98
+ stopped = true;
99
+ if (timer)
100
+ clearInterval(timer);
101
+ timer = undefined;
102
+ },
103
+ };
104
+ }
package/dist/mcp.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
2
3
  import { createServer as createHttpServer } from 'node:http';
3
4
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
5
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
@@ -81,6 +82,25 @@ function requiredProjectId(deps, action) {
81
82
  throw new Error(`${action} requires a project-bound agent run`);
82
83
  return deps.projectId;
83
84
  }
85
+ function normalizeWorkflowContext(value) {
86
+ if (!value || typeof value !== 'object' || Array.isArray(value))
87
+ return { enabled: false };
88
+ const context = value;
89
+ return context.enabled === true ? context : { enabled: false };
90
+ }
91
+ async function loadWorkflowContext(client, taskId) {
92
+ const value = await must(client.rpc('get_task_workflow_context', { p_task: taskId }));
93
+ return normalizeWorkflowContext(value);
94
+ }
95
+ function workflowCapabilityError(run, capability, action) {
96
+ const workflow = run.workflow;
97
+ if (!workflow?.enabled)
98
+ return null;
99
+ if (workflow.current_stage?.capabilities?.includes(capability))
100
+ return null;
101
+ return errorResult(`${action} is not allowed during workflow stage "${workflow.current_stage?.name ?? 'unknown'}". ` +
102
+ 'Follow the stored stage instructions returned by get_task.');
103
+ }
84
104
  /**
85
105
  * One future-proof task read model. `*` keeps every direct task column without
86
106
  * requiring a CLI release when an additive migration lands. Every relationship
@@ -362,8 +382,8 @@ export async function beginAgentRunHandler(deps, session, args) {
362
382
  'Call end_work before beginning a different task in the same agent session.');
363
383
  }
364
384
  const task = deps.projectId
365
- ? await must(deps.client.from('tasks').select('id,project_id').eq('id', args.task_id).eq('project_id', deps.projectId).maybeSingle())
366
- : await must(deps.client.from('tasks').select('id,project_id').eq('id', args.task_id).maybeSingle());
385
+ ? await must(deps.client.from('tasks').select('id,project_id,description').eq('id', args.task_id).eq('project_id', deps.projectId).maybeSingle())
386
+ : await must(deps.client.from('tasks').select('id,project_id,description').eq('id', args.task_id).maybeSingle());
367
387
  if (!task)
368
388
  return errorResult(`No accessible task found for id "${args.task_id}".`);
369
389
  const taskProjectId = task.project_id ?? deps.projectId;
@@ -433,7 +453,29 @@ export async function beginAgentRunHandler(deps, session, args) {
433
453
  previousExploration.topologyRevision === Number(result.topology_revision)
434
454
  ? previousExploration
435
455
  : null;
456
+ try {
457
+ session.run.workflow = normalizeWorkflowContext(await must(deps.client.rpc('begin_workflow_stage', { p_run: result.run_id })));
458
+ if (!session.run.workflow.enabled) {
459
+ // A real task row always has a string description. Treat an omitted
460
+ // field as already provided only for backwards-compatible embedded
461
+ // clients/test doubles that predate this contract.
462
+ const persistedInstruction = typeof task.description === 'string' ? task.description.trim() : null;
463
+ session.run.noWorkflowIntent = persistedInstruction === '' && !args.prompt_instruction?.trim()
464
+ ? 'missing'
465
+ : 'provided';
466
+ }
467
+ }
468
+ catch (workflowError) {
469
+ await deps.client.rpc('end_agent_run', {
470
+ p_run: result.run_id,
471
+ p_reason: 'workflow stage unavailable',
472
+ });
473
+ session.run = null;
474
+ session.exploration = null;
475
+ throw workflowError;
476
+ }
436
477
  const collaborator = result.role === 'collaborator';
478
+ const workflow = session.run.workflow;
437
479
  return textResult({
438
480
  coordination: {
439
481
  status: collaborator ? 'joined_as_collaborator' : 'coordinating',
@@ -445,6 +487,16 @@ export async function beginAgentRunHandler(deps, session, args) {
445
487
  : 'You are coordinating this task. Inspect every repository, then call reserve_work_paths before editing. Collaborators may join with disjoint reservations.',
446
488
  },
447
489
  agent_run: session.run,
490
+ workflow,
491
+ ...(workflow?.enabled ? {
492
+ workflow_instruction: workflow.current_stage?.state === 'awaiting_review'
493
+ ? `Current stage: ${workflow.current_stage?.name ?? 'unknown'} is awaiting review. ` +
494
+ 'Only record the user\'s explicit approval or change request with decide_workflow_review; do not redo or advance the stage first.'
495
+ : `Current stage: ${workflow.current_stage?.name ?? 'unknown'}. ` +
496
+ `${workflow.current_stage?.instructions ?? 'Follow the stored workflow instructions.'}`,
497
+ } : session.run.noWorkflowIntent === 'missing' ? {
498
+ no_workflow_instruction: 'This No workflow item has no instructions. After context exploration, ask the user what should be done with ask_question(category: intent). Do not plan, create another artifact, reserve paths, or edit first.',
499
+ } : {}),
448
500
  });
449
501
  }
450
502
  catch (err) {
@@ -484,6 +536,11 @@ export async function reserveWorkPathsHandler(deps, session, args) {
484
536
  if (!Array.isArray(args.reservations) || args.reservations.length === 0) {
485
537
  return errorResult('reserve_work_paths requires at least one repository/path reservation.');
486
538
  }
539
+ if (args.reservations.some((reservation) => (reservation.mode ?? 'write') === 'write')) {
540
+ const workflowError = workflowCapabilityError(active, 'reserve_write', 'reserve_work_paths');
541
+ if (workflowError)
542
+ return workflowError;
543
+ }
487
544
  const missingCheckout = args.reservations.find((reservation) => (reservation.mode ?? 'write') === 'write' && !reservation.repository_checkout_id);
488
545
  if (missingCheckout) {
489
546
  return errorResult('Every write reservation requires repository_checkout_id from get_task topology.checkouts. ' +
@@ -776,11 +833,180 @@ export async function askQuestionHandler(deps, attribution, args, agentRun) {
776
833
  if (!first || first.type !== 'text')
777
834
  return errorResult('ask_question returned no persisted comment.');
778
835
  const payload = JSON.parse(first.text);
836
+ const commentId = typeof payload.comment?.id === 'string' ? payload.comment.id : null;
837
+ let workflowQuestionId = null;
838
+ if (agentRun.workflow?.enabled) {
839
+ if (!commentId)
840
+ return errorResult('ask_question returned no persisted comment id for the workflow question.');
841
+ workflowQuestionId = await must(deps.client.rpc('open_workflow_question', {
842
+ p_run: agentRun.runId,
843
+ p_comment: commentId,
844
+ p_category: args.category,
845
+ p_question: question,
846
+ }));
847
+ agentRun.workflow = await loadWorkflowContext(deps.client, agentRun.taskId);
848
+ }
779
849
  return textResult({
780
- question: { category: args.category, text: question, comment_id: payload.comment?.id ?? null },
850
+ question: {
851
+ category: args.category,
852
+ text: question,
853
+ comment_id: commentId,
854
+ workflow_question_id: workflowQuestionId,
855
+ },
781
856
  comment: payload.comment ?? null,
857
+ ...(agentRun.workflow?.enabled ? { workflow: agentRun.workflow } : {}),
782
858
  });
783
859
  }
860
+ export async function recordUserInputHandler(deps, attribution, session, args) {
861
+ const active = joinedRunOrError(session, 'record_user_input');
862
+ if ('content' in active)
863
+ return active;
864
+ if (!active.workflow?.enabled)
865
+ return errorResult('record_user_input requires a workflow task.');
866
+ const capabilityError = workflowCapabilityError(active, 'record_user_input', 'record_user_input');
867
+ if (capabilityError)
868
+ return capabilityError;
869
+ if (!Array.isArray(args.question_ids) || args.question_ids.length === 0) {
870
+ return errorResult('record_user_input requires at least one workflow question id.');
871
+ }
872
+ if (!args.answer?.trim())
873
+ return errorResult('record_user_input requires the user\'s exact answer.');
874
+ try {
875
+ const openQuestions = active.workflow.open_questions ?? [];
876
+ const questionText = args.question_ids.map((id) => {
877
+ const question = openQuestions.find((candidate) => candidate.id === id);
878
+ return `- ${typeof question?.question === 'string' ? question.question : id}`;
879
+ }).join('\n');
880
+ const artifactResult = await createArtifactHandler(bindProject(deps, active.projectId), attribution, {
881
+ task_id: active.taskId,
882
+ type: 'analysis',
883
+ format: 'md',
884
+ content: `# User input\n\n## Questions\n\n${questionText}\n\n## User answer\n\n${args.answer.trim()}`,
885
+ coverage: args.coverage,
886
+ }, active);
887
+ if (artifactResult.isError)
888
+ return artifactResult;
889
+ const first = artifactResult.content[0];
890
+ if (!first || first.type !== 'text')
891
+ throw new Error('answer artifact returned no text result');
892
+ const payload = JSON.parse(first.text);
893
+ const artifactId = typeof payload.artifact?.id === 'string' ? payload.artifact.id : null;
894
+ if (!artifactId)
895
+ throw new Error('answer artifact returned no id');
896
+ active.workflow = normalizeWorkflowContext(await must(deps.client.rpc('record_workflow_answer', {
897
+ p_run: active.runId,
898
+ p_question_ids: args.question_ids,
899
+ p_artifact: artifactId,
900
+ })));
901
+ return textResult({
902
+ recorded: true,
903
+ artifact_id: artifactId,
904
+ question_ids: args.question_ids,
905
+ workflow: active.workflow,
906
+ });
907
+ }
908
+ catch (err) {
909
+ return coordinationError('record_user_input', err);
910
+ }
911
+ }
912
+ export async function submitWorkflowStageHandler(deps, session, args) {
913
+ const active = joinedRunOrError(session, 'submit_workflow_stage');
914
+ if ('content' in active)
915
+ return active;
916
+ if (!active.workflow?.enabled)
917
+ return errorResult('submit_workflow_stage requires a workflow task.');
918
+ const capabilityError = workflowCapabilityError(active, 'submit_stage', 'submit_workflow_stage');
919
+ if (capabilityError)
920
+ return capabilityError;
921
+ if (!Number.isInteger(args.expected_workflow_revision) || args.expected_workflow_revision < 1) {
922
+ return errorResult('submit_workflow_stage requires the current positive workflow revision from get_task.');
923
+ }
924
+ if (!Array.isArray(args.artifacts))
925
+ return errorResult('submit_workflow_stage requires an artifacts array.');
926
+ try {
927
+ active.workflow = normalizeWorkflowContext(await must(deps.client.rpc('submit_workflow_stage', {
928
+ p_run: active.runId,
929
+ p_expected_workflow_revision: args.expected_workflow_revision,
930
+ p_artifacts: args.artifacts,
931
+ })));
932
+ const waiting = active.workflow.state === 'awaiting_user';
933
+ return textResult({
934
+ submitted: true,
935
+ workflow: active.workflow,
936
+ instruction: waiting
937
+ ? 'This stage is awaiting explicit user review. Do not begin the next stage. End this agent run after persisting its receipt.'
938
+ : `Continue with workflow stage "${active.workflow.current_stage?.name ?? 'next'}" and follow its stored instructions.`,
939
+ });
940
+ }
941
+ catch (err) {
942
+ return coordinationError('submit_workflow_stage', err);
943
+ }
944
+ }
945
+ export async function decideWorkflowReviewHandler(deps, session, args) {
946
+ const active = joinedRunOrError(session, 'decide_workflow_review');
947
+ if ('content' in active)
948
+ return active;
949
+ if (active.taskId !== args.task_id) {
950
+ return errorResult(`decide_workflow_review must stay on this agent run's task (${active.taskId}).`);
951
+ }
952
+ if (!active.workflow?.enabled || active.workflow.current_stage?.stage_run_id !== args.stage_run_id ||
953
+ active.workflow.current_stage?.state !== 'awaiting_review') {
954
+ return errorResult('decide_workflow_review requires this live agent run to be bound to the current review stage.');
955
+ }
956
+ if (!args.user_message?.trim()) {
957
+ return errorResult('decide_workflow_review requires the user\'s exact approval or change-request message as evidence.');
958
+ }
959
+ try {
960
+ const scopes = await activeRepositoryScopes(deps.client, active.projectId);
961
+ const evidenceResult = await createArtifactHandler(bindProject(deps, active.projectId), attributionFromClientName(session.clientName), {
962
+ task_id: active.taskId,
963
+ type: 'analysis',
964
+ format: 'md',
965
+ content: `# Workflow review decision\n\n${args.user_message.trim()}`,
966
+ coverage: scopes.map(scope => ({
967
+ repository_scope_id: scope.repository_scope_id,
968
+ disposition: 'context_only',
969
+ reason: 'Conversation review evidence applies to this workflow task.',
970
+ })),
971
+ }, active);
972
+ if (evidenceResult.isError)
973
+ return evidenceResult;
974
+ const evidenceBlock = evidenceResult.content[0];
975
+ if (!evidenceBlock || evidenceBlock.type !== 'text')
976
+ throw new Error('review evidence artifact returned no text result');
977
+ const evidencePayload = JSON.parse(evidenceBlock.text);
978
+ const evidenceArtifactId = typeof evidencePayload.artifact?.id === 'string' ? evidencePayload.artifact.id : null;
979
+ if (!evidenceArtifactId)
980
+ throw new Error('review evidence artifact returned no id');
981
+ const context = normalizeWorkflowContext(await must(deps.client.rpc('decide_workflow_review', {
982
+ p_task: args.task_id,
983
+ p_stage_run: args.stage_run_id,
984
+ p_expected_workflow_revision: args.expected_workflow_revision,
985
+ p_decision: args.decision,
986
+ p_source: 'agent_conversation',
987
+ p_evidence: args.user_message.trim(),
988
+ p_agent_run: active.runId,
989
+ p_evidence_artifact: evidenceArtifactId,
990
+ p_dispatch_agent: false,
991
+ })));
992
+ if (session.run?.state === 'joined' && session.run.taskId === args.task_id) {
993
+ session.run.workflow = context;
994
+ }
995
+ session.reviewEvidenceArtifactId = evidenceArtifactId;
996
+ return textResult({
997
+ recorded: true,
998
+ decision: args.decision,
999
+ evidence_artifact_id: evidenceArtifactId,
1000
+ workflow: context,
1001
+ instruction: context.state === 'completed'
1002
+ ? 'The user approved completion. The workflow and work item are Done.'
1003
+ : `The workflow remains on stage "${context.current_stage?.name ?? 'unknown'}". Call begin_work before continuing agent work.`,
1004
+ });
1005
+ }
1006
+ catch (err) {
1007
+ return coordinationError('decide_workflow_review', err);
1008
+ }
1009
+ }
784
1010
  export async function endAgentRunHandler(deps, session, args = {}, options = {}) {
785
1011
  const active = runOrError(session, 'end_work');
786
1012
  if ('content' in active)
@@ -788,18 +1014,19 @@ export async function endAgentRunHandler(deps, session, args = {}, options = {})
788
1014
  try {
789
1015
  if (options.requirePersistenceReceipt) {
790
1016
  const exploration = session.exploration;
1017
+ const reviewEvidenceArtifactId = session.reviewEvidenceArtifactId ?? null;
791
1018
  const receipt = args.persistence_receipt;
792
- if (!exploration || !receipt) {
793
- return errorResult('end_work requires a persistence_receipt and this session\'s persisted context exploration artifact.');
1019
+ if ((!exploration && !reviewEvidenceArtifactId) || !receipt) {
1020
+ return errorResult('end_work requires a persistence_receipt and this session\'s persisted context or review evidence artifact.');
794
1021
  }
795
1022
  const outcome = receipt.outcome ?? 'completed';
796
- if (exploration.status === 'blocked' && !['blocked', 'aborted'].includes(outcome)) {
1023
+ if (exploration?.status === 'blocked' && !['blocked', 'aborted'].includes(outcome)) {
797
1024
  return errorResult('A subagents_unavailable session may end only with outcome blocked or aborted.');
798
1025
  }
799
- if (exploration.status === 'explored' && !['completed', 'blocked'].includes(outcome)) {
1026
+ if (exploration?.status === 'explored' && !['completed', 'blocked'].includes(outcome)) {
800
1027
  return errorResult('An explored session may end as completed, or blocked after persisting a question for the user.');
801
1028
  }
802
- if (exploration.status === 'explored' && outcome === 'blocked') {
1029
+ if (exploration?.status === 'explored' && outcome === 'blocked') {
803
1030
  const questionRows = (await must(deps.client
804
1031
  .from('comments')
805
1032
  .select('id,task_id,agent_run_id,body,created_at')
@@ -821,8 +1048,9 @@ export async function endAgentRunHandler(deps, session, args = {}, options = {})
821
1048
  if (artifactIds.length !== receipt.artifact_ids.length) {
822
1049
  return errorResult('end_work persistence_receipt artifact_ids must be unique.');
823
1050
  }
824
- if (!artifactIds.includes(exploration.artifactId)) {
825
- return errorResult('end_work persistence_receipt must include the context exploration artifact id.');
1051
+ const requiredEvidenceArtifactId = exploration?.artifactId ?? reviewEvidenceArtifactId;
1052
+ if (!artifactIds.includes(requiredEvidenceArtifactId)) {
1053
+ return errorResult('end_work persistence_receipt must include this session\'s required evidence artifact id.');
826
1054
  }
827
1055
  const task = await must(deps.client
828
1056
  .from('tasks')
@@ -850,10 +1078,10 @@ export async function endAgentRunHandler(deps, session, args = {}, options = {})
850
1078
  `this task run. Missing: ${missingArtifactIds.join(', ') || 'none'}. ` +
851
1079
  `Foreign or deleted: ${foreignArtifactIds.join(', ') || 'none'}.`);
852
1080
  }
853
- const explorationArtifact = artifactsById.get(exploration.artifactId);
854
- if (!explorationArtifact || explorationArtifact.task_id !== exploration.taskId ||
855
- explorationArtifact.agent_run_id !== exploration.agentRunId) {
856
- return errorResult('end_work could not verify this session\'s persisted context exploration artifact.');
1081
+ const evidenceArtifact = artifactsById.get(requiredEvidenceArtifactId);
1082
+ if (!evidenceArtifact || evidenceArtifact.task_id !== active.taskId ||
1083
+ evidenceArtifact.agent_run_id !== active.runId) {
1084
+ return errorResult('end_work could not verify this session\'s required evidence artifact.');
857
1085
  }
858
1086
  }
859
1087
  const ended = await must(deps.client.rpc('end_agent_run', {
@@ -862,6 +1090,7 @@ export async function endAgentRunHandler(deps, session, args = {}, options = {})
862
1090
  }));
863
1091
  session.run = null;
864
1092
  session.exploration = null;
1093
+ session.reviewEvidenceArtifactId = null;
865
1094
  return textResult({
866
1095
  ended: Boolean(ended),
867
1096
  agent_run_id: active.runId,
@@ -939,7 +1168,7 @@ export async function getTaskHandler(deps, _attribution, args, machineId) {
939
1168
  }
940
1169
  const task = parseTaskRow(row);
941
1170
  const topologyMachineId = machineId ?? deps.machineId;
942
- const [rawComments, rawArtifacts, rawVersions, rawAgentRuns, rawTaskClaims, collaborationDocument, topology] = await Promise.all([
1171
+ const [rawComments, rawArtifacts, rawVersions, rawAgentRuns, rawTaskClaims, collaborationDocument, topology, workflow] = await Promise.all([
943
1172
  must(client
944
1173
  .from('comments')
945
1174
  .select(COMMENT_SELECT)
@@ -985,6 +1214,7 @@ export async function getTaskHandler(deps, _attribution, args, machineId) {
985
1214
  topologyMachineId
986
1215
  ? loadAgentProjectTopology(client, row.project_id, deps.userId, topologyMachineId)
987
1216
  : Promise.resolve(undefined),
1217
+ loadWorkflowContext(client, row.id),
988
1218
  ]);
989
1219
  const comments = sortRecords((rawComments ?? []).map(normalizeComment), 'created_at', 'id');
990
1220
  const normalizedArtifacts = sortRecords((rawArtifacts ?? []).map(normalizeArtifact), 'created_at', 'id');
@@ -1042,6 +1272,7 @@ export async function getTaskHandler(deps, _attribution, args, machineId) {
1042
1272
  active_work_reservations: activeWorkReservations,
1043
1273
  collaborative_description: collaborativeDescription,
1044
1274
  ...(topology ? { topology } : {}),
1275
+ workflow,
1045
1276
  highlighted_artifact_id: highlightArtifactId,
1046
1277
  protocol: PROTOCOL_SHORT,
1047
1278
  });
@@ -1057,20 +1288,16 @@ export async function createTaskHandler(deps, _attribution, args) {
1057
1288
  const { client, userId } = deps;
1058
1289
  const projectId = requiredProjectId(deps, 'create_task');
1059
1290
  const tags = await resolveOrCreateTags(client, projectId, args.tags ?? []);
1060
- const inserted = await must(client
1061
- .from('tasks')
1062
- .insert({
1063
- project_id: projectId,
1064
- name: args.name,
1065
- description: args.description ?? '',
1066
- owner_id: userId,
1067
- status: 'backlog',
1068
- })
1069
- .select('id')
1070
- .single());
1071
- if (!inserted)
1072
- throw new Error('Task insert returned no row.');
1073
- const taskId = inserted.id;
1291
+ const taskId = await must(client.rpc('create_task_with_workflow', {
1292
+ p_project: projectId,
1293
+ p_name: args.name,
1294
+ p_description: args.description ?? '',
1295
+ p_owner: userId,
1296
+ p_use_workflow: true,
1297
+ p_workflow_version: null,
1298
+ }));
1299
+ if (!taskId)
1300
+ throw new Error('Task insert returned no id.');
1074
1301
  if (tags.length > 0) {
1075
1302
  await must(client.from('task_tags').insert(tags.map((t) => ({ task_id: taskId, tag_id: t.id }))));
1076
1303
  }
@@ -1097,6 +1324,14 @@ export async function updateTaskHandler(deps, _attribution, args, agentRun) {
1097
1324
  if (agentRun && (id !== agentRun.taskId || projectId !== agentRun.projectId)) {
1098
1325
  return errorResult(`update_task must stay on this agent run's task ${agentRun.taskId} in project ${agentRun.projectId}.`);
1099
1326
  }
1327
+ if (agentRun?.workflow?.enabled) {
1328
+ const capabilityError = workflowCapabilityError(agentRun, 'update_task', 'update_task');
1329
+ if (capabilityError)
1330
+ return capabilityError;
1331
+ if (fields.status !== undefined) {
1332
+ return errorResult('Workflow task status is controlled by workflow transitions and final user approval.');
1333
+ }
1334
+ }
1100
1335
  if (!Number.isInteger(expected_revision) || expected_revision < 1) {
1101
1336
  return errorResult('update_task requires expected_revision from the most recent get_task response.');
1102
1337
  }
@@ -1283,7 +1518,7 @@ export function protocolPromptHandler() {
1283
1518
  // ---------------------------------------------------------------------------
1284
1519
  const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
1285
1520
  const HEARTBEAT_INTERVAL_MS = 30 * 1000;
1286
- export const BROKER_VERSION = '1.2.1';
1521
+ export const BROKER_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
1287
1522
  async function readJsonBody(req) {
1288
1523
  const chunks = [];
1289
1524
  for await (const chunk of req)
@@ -1398,6 +1633,15 @@ export async function startMcpServer(deps) {
1398
1633
  }
1399
1634
  return null;
1400
1635
  }
1636
+ function requireNoWorkflowIntent(session, tool) {
1637
+ const run = session.context.run;
1638
+ if (!run || run.state !== 'joined' || run.workflow?.enabled || run.noWorkflowIntent === 'provided')
1639
+ return null;
1640
+ return errorResult(`${tool} is blocked because this No workflow item has no instructions. ` +
1641
+ (run.noWorkflowIntent === 'missing'
1642
+ ? 'Call ask_question with category intent to ask the user what should be done.'
1643
+ : 'Persist the user\'s answer in the task description with update_task before continuing.'));
1644
+ }
1401
1645
  function buildServer(session) {
1402
1646
  const server = new McpServer({ name: 'ctrl-spc', version: BROKER_VERSION });
1403
1647
  function attribution() {
@@ -1425,7 +1669,10 @@ export async function startMcpServer(deps) {
1425
1669
  });
1426
1670
  server.registerTool('begin_work', {
1427
1671
  description: 'Explicitly begin work on a task. The first live agent coordinates; later agents join as collaborators. No agent may edit repository paths until reserve_work_paths succeeds.',
1428
- inputSchema: { task_id: z.string() },
1672
+ inputSchema: {
1673
+ task_id: z.string(),
1674
+ prompt_instruction: z.string().optional().describe('Exact work instruction supplied alongside the copied command. Omit when the paste contains only the command.'),
1675
+ },
1429
1676
  }, async (args) => {
1430
1677
  syncClientIdentity();
1431
1678
  const result = await beginAgentRunHandler(handlerDeps, session.context, args);
@@ -1453,6 +1700,9 @@ export async function startMcpServer(deps) {
1453
1700
  const missing = requireExploredSessionRun(session, 'reserve_work_paths');
1454
1701
  if (missing)
1455
1702
  return missing;
1703
+ const intent = requireNoWorkflowIntent(session, 'reserve_work_paths');
1704
+ if (intent)
1705
+ return intent;
1456
1706
  return reserveWorkPathsHandler(handlerDeps, session.context, args);
1457
1707
  });
1458
1708
  server.registerTool('record_context_exploration', {
@@ -1482,6 +1732,10 @@ export async function startMcpServer(deps) {
1482
1732
  const missing = requireActiveSessionRun(session, 'record_context_exploration');
1483
1733
  if (missing)
1484
1734
  return missing;
1735
+ const run = session.context.run;
1736
+ const workflowError = workflowCapabilityError(run, 'record_context', 'record_context_exploration');
1737
+ if (workflowError)
1738
+ return workflowError;
1485
1739
  return recordContextExplorationHandler(handlerDeps, attribution(), session.context, args);
1486
1740
  });
1487
1741
  server.registerTool('release_work_paths', {
@@ -1534,7 +1788,13 @@ export async function startMcpServer(deps) {
1534
1788
  const missing = requireExploredSessionRun(session, 'create_task');
1535
1789
  if (missing)
1536
1790
  return missing;
1791
+ const intent = requireNoWorkflowIntent(session, 'create_task');
1792
+ if (intent)
1793
+ return intent;
1537
1794
  const run = session.context.run;
1795
+ const workflowError = workflowCapabilityError(run, 'create_task', 'create_task');
1796
+ if (workflowError)
1797
+ return workflowError;
1538
1798
  return createTaskHandler(bindProject(handlerDeps, run.projectId), attribution(), args);
1539
1799
  });
1540
1800
  server.registerTool('update_task', {
@@ -1558,7 +1818,22 @@ export async function startMcpServer(deps) {
1558
1818
  if (missing)
1559
1819
  return missing;
1560
1820
  const run = session.context.run;
1561
- return updateTaskHandler(bindProject(handlerDeps, run.projectId), attribution(), args, run);
1821
+ if (!run.workflow?.enabled && run.noWorkflowIntent !== 'provided') {
1822
+ if (run.noWorkflowIntent !== 'intent_question_asked') {
1823
+ return requireNoWorkflowIntent(session, 'update_task');
1824
+ }
1825
+ if (!args.fields.description?.trim() || Object.keys(args.fields).some(field => field !== 'description')) {
1826
+ return errorResult('After the No workflow intent question, update_task must first persist only the user\'s answer in the task description.');
1827
+ }
1828
+ }
1829
+ const workflowError = workflowCapabilityError(run, 'update_task', 'update_task');
1830
+ if (workflowError)
1831
+ return workflowError;
1832
+ const result = await updateTaskHandler(bindProject(handlerDeps, run.projectId), attribution(), args, run);
1833
+ if (!result.isError && !run.workflow?.enabled && run.noWorkflowIntent === 'intent_question_asked') {
1834
+ run.noWorkflowIntent = 'provided';
1835
+ }
1836
+ return result;
1562
1837
  });
1563
1838
  server.registerTool('create_artifact', {
1564
1839
  description: 'Persist an analysis/plan/spec/diagram/mock/wireframe on a task after context exploration. Never write planning documents into a repository.',
@@ -1578,7 +1853,13 @@ export async function startMcpServer(deps) {
1578
1853
  const missing = requireExploredSessionRun(session, 'create_artifact');
1579
1854
  if (missing)
1580
1855
  return missing;
1856
+ const intent = requireNoWorkflowIntent(session, 'create_artifact');
1857
+ if (intent)
1858
+ return intent;
1581
1859
  const run = session.context.run;
1860
+ const workflowError = workflowCapabilityError(run, 'create_artifact', 'create_artifact');
1861
+ if (workflowError)
1862
+ return workflowError;
1582
1863
  return createArtifactHandler(bindProject(handlerDeps, run.projectId), attribution(), args, run);
1583
1864
  });
1584
1865
  server.registerTool('ask_question', {
@@ -1593,7 +1874,65 @@ export async function startMcpServer(deps) {
1593
1874
  if (missing)
1594
1875
  return missing;
1595
1876
  const run = session.context.run;
1596
- return askQuestionHandler(handlerDeps, attribution(), args, run);
1877
+ if (!run.workflow?.enabled && run.noWorkflowIntent === 'missing' && args.category !== 'intent') {
1878
+ return errorResult('This No workflow item has no instructions. The first question must use category intent and ask what should be done.');
1879
+ }
1880
+ if (!run.workflow?.enabled && run.noWorkflowIntent === 'intent_question_asked') {
1881
+ return requireNoWorkflowIntent(session, 'ask_question');
1882
+ }
1883
+ const workflowError = workflowCapabilityError(run, 'ask_question', 'ask_question');
1884
+ if (workflowError)
1885
+ return workflowError;
1886
+ const result = await askQuestionHandler(handlerDeps, attribution(), args, run);
1887
+ if (!result.isError && !run.workflow?.enabled && run.noWorkflowIntent === 'missing') {
1888
+ run.noWorkflowIntent = 'intent_question_asked';
1889
+ }
1890
+ return result;
1891
+ });
1892
+ server.registerTool('record_user_input', {
1893
+ description: 'Persist the user\'s exact answer as an artifact and resolve the current workflow questions before continuing.',
1894
+ inputSchema: {
1895
+ question_ids: z.array(z.string()).min(1),
1896
+ answer: z.string(),
1897
+ coverage: z.array(z.object({
1898
+ repository_scope_id: z.string(),
1899
+ disposition: z.enum(['affected', 'reviewed_no_change', 'context_only', 'unavailable_acknowledged']),
1900
+ reason: z.string(),
1901
+ acknowledgement_id: z.string().nullable().optional(),
1902
+ })).min(1),
1903
+ },
1904
+ }, async (args) => {
1905
+ const missing = requireExploredSessionRun(session, 'record_user_input');
1906
+ if (missing)
1907
+ return missing;
1908
+ return recordUserInputHandler(handlerDeps, attribution(), session.context, args);
1909
+ });
1910
+ server.registerTool('submit_workflow_stage', {
1911
+ description: 'Submit the current workflow stage. The database validates its stored requirements and either advances or waits for user review.',
1912
+ inputSchema: {
1913
+ expected_workflow_revision: z.number().int().positive(),
1914
+ artifacts: z.array(z.object({ artifact_id: z.string(), role: z.string() })),
1915
+ },
1916
+ }, async (args) => {
1917
+ const missing = requireExploredSessionRun(session, 'submit_workflow_stage');
1918
+ if (missing)
1919
+ return missing;
1920
+ return submitWorkflowStageHandler(handlerDeps, session.context, args);
1921
+ });
1922
+ server.registerTool('decide_workflow_review', {
1923
+ description: 'Record the user\'s explicit approval or request for changes from this conversation. Uses the same workflow gate as the web app.',
1924
+ inputSchema: {
1925
+ task_id: z.string(),
1926
+ stage_run_id: z.string(),
1927
+ expected_workflow_revision: z.number().int().positive(),
1928
+ decision: z.enum(['approved', 'changes_requested']),
1929
+ user_message: z.string(),
1930
+ },
1931
+ }, async (args) => {
1932
+ const missing = requireActiveSessionRun(session, 'decide_workflow_review');
1933
+ if (missing)
1934
+ return missing;
1935
+ return decideWorkflowReviewHandler(handlerDeps, session.context, args);
1597
1936
  });
1598
1937
  server.registerTool('add_comment', {
1599
1938
  description: 'Legacy question-only alias. Agent progress narration is not persisted; use ask_question for categorized questions.',
@@ -1603,6 +1942,9 @@ export async function startMcpServer(deps) {
1603
1942
  if (missing)
1604
1943
  return missing;
1605
1944
  const run = session.context.run;
1945
+ const intent = requireNoWorkflowIntent(session, 'add_comment');
1946
+ if (intent)
1947
+ return intent;
1606
1948
  if (args.task_id !== run.taskId) {
1607
1949
  return errorResult(`add_comment must stay on this agent run's task (${run.taskId}).`);
1608
1950
  }
package/dist/protocol.js CHANGED
@@ -13,7 +13,16 @@ export const PROTOCOL_FULL = `## 5a. Agent working protocol
13
13
 
14
14
  Delivered redundantly (installed skill + MCP), phrased imperatively for the agent:
15
15
 
16
- **HARD STOP unclear novel feature.** After \`record_context_exploration\` succeeds,
16
+ **Workflow authority.** The \`workflow\` object returned by \`get_task\` is the durable
17
+ source of truth. When \`workflow.enabled\` is true, follow only its current stage,
18
+ stored instructions, capabilities, requirements, and gate. Never infer or skip a
19
+ stage. Call \`submit_workflow_stage\` with the stage's persisted artifacts when its
20
+ requirements are satisfied. A stage awaiting review never advances until the user
21
+ approves through the web app or explicitly in conversation. Requested changes stay
22
+ in the same stage. Only final user approval moves the item to Done.
23
+
24
+ **HARD STOP — no-workflow unclear novel feature.** This rule applies only when
25
+ \`workflow.enabled\` is false. After \`record_context_exploration\` succeeds,
17
26
  if the task does not explicitly request a build or name a deliverable, the immediate
18
27
  next tool call is \`ask_question\` with category \`intent\` and the exact question:
19
28
  “What should I produce for this feature? Select one or more: build, plan, spec,
@@ -36,8 +45,11 @@ is saved to the work item. I am applying the task's explicit intent gate now.”
36
45
  collaboration, claims, or reservations. The ID is the only context in the paste;
37
46
  everything current lives in ctrl-spc.
38
47
  2. **Begin explicitly.** Read every active repository scope, checkout, topology revision,
39
- unmet dependency, and collaborator, then call \`begin_work\`. The first live run
40
- becomes coordinator and \`begin_work\` moves a Backlog item to In Progress. Later runs
48
+ unmet dependency, and collaborator, then call \`begin_work\`. If the user supplied an
49
+ exact instruction alongside the copied command, pass it as \`prompt_instruction\`; never
50
+ invent one. The first live run
51
+ becomes coordinator and \`begin_work\` moves a Backlog item to In Progress. Workflow
52
+ tasks remain In Progress through every active stage and review gate. Later runs
41
53
  join as collaborators without repeating that transition. Only the current coordinator
42
54
  may make later task-status changes, and no agent marks an item Done autonomously.
43
55
  3. **Repair missing context.** If \`begin_work\` reports unavailable checkouts, warn the
@@ -76,13 +88,15 @@ is saved to the work item. I am applying the task's explicit intent gate now.”
76
88
  never disclose findings, conclusions, likely impact, or decisions. After persistence,
77
89
  terminal summaries may reference the persisted artifact/question but must add no
78
90
  unpersisted facts.
79
- 6. **Infer explicit work; otherwise ask exactly what is needed.** After exploration is
80
- recorded, classify intent from the work item's explicit wording and accepted answers,
91
+ 6. **Follow the workflow; otherwise ask exactly what is needed.** For an assigned workflow,
92
+ the stored current-stage instructions and capabilities decide the work; never skip ahead
93
+ based on task wording. Without a workflow, after exploration is recorded, classify intent
94
+ from the work item's explicit wording and accepted answers,
81
95
  never from the exploration findings. Findings do not turn an unclear request into an
82
96
  authorized deliverable. Perform the action only when the item explicitly names it; do
83
97
  not ask for confirmation. Otherwise use \`ask_question\`. For a bug, ask for reproduction steps,
84
98
  expected behavior, or actual behavior only when each is missing and not discoverable.
85
- For a novel feature with no explicit build action or deliverable, apply the HARD STOP
99
+ For a no-workflow novel feature with no explicit build action or deliverable, apply the HARD STOP
86
100
  above; the immediate next call is the exact build/plan/spec/diagram/mock/wireframe
87
101
  \`ask_question\`.
88
102
  Generic goals such as “improve,” “coordinate,” “prepare,” or “support” do not authorize
@@ -94,11 +108,15 @@ is saved to the work item. I am applying the task's explicit intent gate now.”
94
108
  collision handoff. After \`ask_question\` succeeds, do no more work in that run: call
95
109
  \`end_work\` with reason \`pending_user_answer\`, a receipt outcome of \`blocked\`, the
96
110
  final task revision, and every live artifact ID; continue only in a later pasted run.
97
- 7. **Persist every accepted answer and durable finding.** Immediately call \`update_task\`
98
- to add accepted decisions, constraints, acceptance criteria, and durable findings to the
99
- description. Use the latest \`get_task\` revision; refetch and reconcile on conflict.
111
+ 7. **Persist every accepted answer and durable finding.** For workflow questions, call
112
+ \`record_user_input\` with the open question IDs and the user's exact answer; it creates
113
+ the required answer artifact before the stage continues. For no-workflow tasks, use
114
+ revision-safe \`update_task\` for accepted decisions and constraints. Immediately call \`update_task\`
115
+ after accepting a no-workflow answer so a later agent can recover it.
100
116
  Never leave an accepted answer or finding only in terminal, chat, or a subagent report.
101
- 8. **Reserve the exact write surface.** Before editing, call \`reserve_work_paths\` with
117
+ 8. **Reserve the exact write surface only when allowed.** Read-only workflow stages never
118
+ reserve write paths or edit files. When the current stage allows repository writes, call
119
+ \`reserve_work_paths\` with
102
120
  the topology revision and, for every intended edit, the exact
103
121
  \`repository_scope_id\`, selected \`repository_checkout_id\`, repo-relative path, and
104
122
  write mode. An empty path reserves the entire scope. Do not edit until the full atomic
@@ -106,12 +124,14 @@ is saved to the work item. I am applying the task's explicit intent gate now.”
106
124
  9. **Never edit through a collision.** A collision reserves nothing. Narrow to disjoint
107
125
  paths, wait, ask for an explicit handoff, or use an isolated Git worktree. Never edit a
108
126
  conflicting path, overwrite unexpected local changes, or assume another agent stopped.
109
- 10. **Persist every substantive non-question output.** Use \`create_artifact\` for every
127
+ 10. **Persist and submit every substantive non-question output.** Use \`create_artifact\` for every
110
128
  plan, specification, research/finding report, schema, diagram, mock, and wireframe before
111
129
  presenting it as complete. Use type \`analysis\` for research/findings, \`plan\` for plans,
112
130
  \`spec\` for specifications or proposed schemas, and the matching visual type. Never
113
131
  write planning documents into a repository. Every artifact has exactly one coverage disposition and non-empty
114
- reason for every active repository scope at the current topology revision.
132
+ reason for every active repository scope at the current topology revision. For workflow
133
+ tasks, pass those artifact IDs and requirement roles to \`submit_workflow_stage\`; the
134
+ database alone decides whether the next stage starts or a review gate opens.
115
135
  11. **Keep the work item self-sufficient.** Questions are durable records created through
116
136
  \`ask_question\`; accepted answers and durable facts live in the description; substantive
117
137
  outputs live in artifacts. No progress comments. Never leave useful
@@ -127,10 +147,11 @@ is saved to the work item. I am applying the task's explicit intent gate now.”
127
147
  abandoning the run. Missing, stale, foreign, duplicate, or incomplete receipts are
128
148
  rejected. Ending releases only this session's work.`;
129
149
  export const PROTOCOL_SHORT = 'ctrl-spc protocol (compact — full text: MCP prompt `ctrl-spc-protocol`): ' +
130
- 'HARD STOP unclear novel feature: immediately after record_context_exploration, ask_question category intent exactly “What should I produce for this feature? Select one or more: build, plan, spec, diagram, mock, wireframe.” Then end_work blocked pending_user_answer. No intervening get_task/update_task/create_artifact/reservation/tool; read-only sandbox is not a missing checkout. ' +
131
- '1) get_task is read-only; begin_work (run coordinator); resolve missing checkouts via fetch/link or acknowledge_unavailable_checkout. ' +
150
+ 'Workflow is authoritative: follow only get_task.workflow.current_stage instructions, capabilities, requirements, and gate. submit_workflow_stage cannot skip stages; requested changes stay in-stage; only final user approval sets Done. ' +
151
+ 'For no-workflow unclear features only, MUST use ask_question and end blocked before doing unrequested work; never infer from findings. Generic goals improve, coordinate, prepare, and support are not deliverables. ' +
152
+ '1) get_task is read-only; begin_work (run coordinator) with exact prompt_instruction when supplied; resolve missing checkouts via fetch/link or acknowledge_unavailable_checkout. ' +
132
153
  '2) Every item needs read-only subagents covering scope IDs once. Codex spawn_agent MUST use fork_turns:none; its self-contained prompt has exact IDs/paths/focus, no copied command. Child calls no ctrl-spc tools and writes nothing. If unavailable, record blocked; stop. ' +
133
154
  '3) Parent must record_context_exploration before deciding, asking, planning, reserving, or editing. Before success narrate process only—no findings/conclusions/impact/decisions. Later summaries add no unpersisted facts. ' +
134
- '4) Intent only from task/accepted answers, never findings; improve/coordinate/prepare/support are not deliverables. Unclear novel feature: next mutation after context MUST be ask_question for build/plan/spec/diagram/mock/wireframe. No update_task/create_artifact/reservation/decision/edit before answer; end blocked pending_user_answer. Bugs ask only missing repro/expected/actual. ' +
135
- '5) Put every accepted answer and durable finding in the description via revision-safe update_task; use create_artifact for every substantive non-question output with coverage for every active scope. No progress comments or terminal-only findings. ' +
136
- '6) reserve_work_paths exact checkout+paths; never edit collisions. release_work_paths, then end_work with persistence_receipt={final_task_revision,artifact_ids,outcome}.';
155
+ '4) Workflow stage data decides intent. Without a workflow, intent comes only from task, prompt, and accepted answers—never from findings. ' +
156
+ '5) Workflow answers use record_user_input artifacts; no-workflow answers use revision-safe update_task. Persist every substantive output with create_artifact and every active scope covered. No progress comments or terminal-only findings. ' +
157
+ '6) Call reserve_work_paths only when the stage allows writes and target the exact checkout; never edit collisions. release_work_paths, then end_work with persistence_receipt={final_task_revision,artifact_ids,outcome}.';
package/dist/skills.js CHANGED
@@ -38,21 +38,23 @@ export function codexLegacySkillPath() {
38
38
  function protocolBody(mcpUrl) {
39
39
  return `Resolve a CTRL+SPC work item or artifact pasted as \`/ctrl-spc work <id>\` or \`/ctrl-spc artifact <id>\`, then follow the ctrl-spc working protocol:
40
40
 
41
- HARD STOPunclear novel feature: after \`record_context_exploration\` succeeds, if the task does not explicitly request a build or name a deliverable, the next tool call MUST be \`ask_question\` with category \`intent\` and this exact question: “What should I produce for this feature? Select one or more: build, plan, spec, diagram, mock, wireframe.” Then call \`end_work\` with reason \`pending_user_answer\` and outcome \`blocked\`. In that run, never call \`get_task\` again, \`update_task\`, \`create_artifact\`, \`reserve_work_paths\`, or any other tool between the context artifact and \`ask_question\`. The context artifact is the only allowed artifact. Do not copy findings into the task description before the user answers. A read-only execution sandbox is not a missing checkout and must not change this intent question.
41
+ WORKFLOW AUTHORITYwhen \`get_task.workflow.enabled\` is true, follow only its current stage, stored instructions, capabilities, requirements, and gate. Never infer or skip a stage. Persist each required artifact, then call \`submit_workflow_stage\`. Review gates advance only after explicit user approval in the web app or conversation; requested changes stay in the same stage. Only final approval sets Done.
42
+
43
+ HARD STOP — unclear novel feature (No workflow only): when \`workflow.enabled\` is false, after \`record_context_exploration\` succeeds, if the task does not explicitly request a build or name a deliverable, the next tool call MUST be \`ask_question\` with category \`intent\` and this exact question: “What should I produce for this feature? Select one or more: build, plan, spec, diagram, mock, wireframe.” Then call \`end_work\` with reason \`pending_user_answer\` and outcome \`blocked\`. In that run, never call \`get_task\` again, \`update_task\`, \`create_artifact\`, \`reserve_work_paths\`, or any other tool between the context artifact and \`ask_question\`. The context artifact is the only allowed artifact. Do not copy findings into the task description before the user answers. A read-only execution sandbox is not a missing checkout and must not change this intent question. The user may select one or more: build, plan, spec, diagram, mock, wireframe.
42
44
 
43
45
  Before the context artifact exists, never state or imply a requested deliverable and never mention repository counts, contents, tests, findings, or likely impact. If progress commentary is required, use only: “The required CTRL+SPC context review is in progress; no decision or repository change has been made.” After the context artifact and before the intent question, either say nothing or use only: “The required context review is saved to the work item. I am applying the task's explicit intent gate now.”
44
46
 
45
47
  1. Resolve without side effects — call \`get_task\` before anything else. It is read-only: it does not change status, presence, claims, or reservations. The ID is the only context in the paste; everything current lives in ctrl-spc.
46
- 2. Begin explicitly — call \`begin_work\` after reading the topology. The first live run is coordinator and \`begin_work\` moves Backlog to In Progress; later runs join as collaborators. Only the coordinator changes status. Never mark an item \`done\` autonomously.
48
+ 2. Begin explicitly — call \`begin_work\` after reading the topology. If the user supplied an exact instruction alongside the copied command, pass it as \`prompt_instruction\`; never invent one. The first live run is coordinator and \`begin_work\` moves Backlog to In Progress; later runs join as collaborators. Only the coordinator changes status. Never mark an item \`done\` autonomously.
47
49
  3. Repair missing context — if \`begin_work\` reports an unavailable checkout, warn the user and offer to fetch or link it. Never fetch without approval. If the user explicitly continues without it, call \`acknowledge_unavailable_checkout\`, then call \`begin_work\` again after every missing scope is fetched or acknowledged.
48
50
  4. Delegate exploration — every pasted work item MUST use at least one read-only subagent. After \`begin_work\` joins, partition every available repository scope exactly once across bounded subagent invocations. Every child prompt must explicitly list its assigned \`repository_scope_ids\`; across the batch each available ID appears exactly once. When using Codex \`spawn_agent\`, MUST pass \`fork_turns: "none"\`; never pass \`all\` or a recent-turn count. Give each child a self-contained assignment containing only its exact scope IDs, checkout paths, inspection focus, read-only restrictions, and required report shape. Never include or inherit the parent \`/ctrl-spc work\` command. The child must not call any ctrl-spc tool—including \`get_task\` or \`begin_work\`—or pick up the work item; it only inspects assigned paths and returns its report. If zero scopes are available after explicit acknowledgements, one child inspects the task/artifact context and reports those unavailable scopes without pretending they were reviewed. Children inspect only: no file mutation, path reservation, ctrl-spc call, \`tee\`, shell redirection, temp/capture file, install, formatter, cache-generating command, or any command that can write. Inspection-first is sufficient; do not decide intent before reports are persisted. Each report uses \`{ agent, focus, repository_scope_ids, inspected_paths, findings, likely_impact, unresolved_questions }\`. If no subagent is available, do not explore directly: call \`record_context_exploration\` with \`blocked_reason = subagents_unavailable\`, tell the user, and stop.
49
- 5. Persist context before deciding — the parent consolidates every child report and MUST call \`record_context_exploration\` to create the work item's analysis artifact before deciding what to do, asking a task question, planning, reserving, or editing. Pass the structured reports plus a separate coverage array; include every active scope exactly once, and give unavailable scopes their acknowledgement. Before the tool succeeds, commentary may state process only: never disclose findings, conclusions, likely impact, or decisions. After persistence, terminal summaries may reference the persisted artifact/question but must add no unpersisted facts.
50
- 6. Decide or ask deterministically — after exploration is recorded, classify intent only from the work item's explicit wording and accepted answers, never from findings. Findings do not authorize a deliverable. Infer and perform the action only when the item explicitly names it. Otherwise persist only the necessary question with \`ask_question\`. For a bug, ask for reproduction steps, expected behavior, or actual behavior only when each is missing and not discoverable. For a novel feature with no explicit build action or deliverable, apply the HARD STOP above: the mandatory next mutating call after \`record_context_exploration\` is \`ask_question\`; it must say “select one or more: build, plan, spec, diagram, mock, wireframe.” Generic goals such as “improve,” “coordinate,” “prepare,” or “support” are not deliverables. In this state do not call \`update_task\`, \`create_artifact\`, \`reserve_work_paths\`, or \`create_task\`; do not decide, edit, or complete. The context analysis is the only allowed artifact before the answer. Ask before overriding an unmet dependency, splitting work, fetching, or accepting a collision handoff. After \`ask_question\` succeeds, do no more work in that run: call \`end_work\` with reason \`pending_user_answer\` and outcome \`blocked\`, then wait for a later pasted run.
51
- 7. Persist answers and findings — after every accepted answer, call \`update_task\` to add the decision, constraint, acceptance criterion, and durable findings to the description. Pass the latest revision and refetch/reconcile on conflict. Do not use progress comments; do not leave findings only in the terminal.
52
- 8. Reserve before writing — call \`reserve_work_paths\` with the topology revision, exact checkout ID, and every repository-relative path you intend to edit. Do not edit until the full atomic request succeeds. On collision, narrow to disjoint paths, wait, ask for an explicit handoff, or use an isolated Git worktree. Never edit a conflicting path.
53
- 9. Persist every output — every substantive non-question output must be a ctrl-spc artifact before it is presented as complete. Use \`create_artifact\` type \`analysis\` for research/findings, \`plan\` for plans, \`spec\` for specifications or proposed schemas, and the matching type for diagrams, mocks, and wireframes. Never write planning documents into a repository. Every artifact includes exactly one coverage disposition and non-empty reason for every active repository scope.
51
+ 5. Persist context before deciding — the parent consolidates every child report and MUST call \`record_context_exploration\` to create the work item's analysis artifact before deciding what to do, asking a task question, planning, reserving, or editing. Pass the structured reports plus a separate coverage array; include every active repository scope exactly once, and give unavailable scopes their acknowledgement. Before the tool succeeds, commentary may state process only: never disclose findings, conclusions, likely impact, or decisions. After persistence, terminal summaries may reference the persisted artifact/question but must add no unpersisted facts.
52
+ 6. Decide or ask deterministically — assigned workflow stage data decides the work. Without a workflow, classify intent only from the work item's explicit wording and accepted answers, never from findings. Findings do not authorize a deliverable. Infer and perform the action only when the item explicitly names it. Otherwise persist only the necessary question with \`ask_question\`. For a bug, ask for reproduction steps, expected behavior, or actual behavior only when each is missing and not discoverable. For a novel feature with no explicit build action or deliverable, apply the HARD STOP above: the mandatory next mutating call after \`record_context_exploration\` is \`ask_question\`; it must say “select one or more: build, plan, spec, diagram, mock, wireframe.” Generic goals such as “improve,” “coordinate,” “prepare,” or “support” are not deliverables. In this state do not call \`update_task\`, \`create_artifact\`, \`reserve_work_paths\`, or \`create_task\`; do not decide, edit, or complete. The context analysis is the only allowed artifact before the answer. Ask before overriding an unmet dependency, splitting work, fetching, or accepting a collision handoff. After \`ask_question\` succeeds, do no more work in that run: call \`end_work\` with reason \`pending_user_answer\` and outcome \`blocked\`, then wait for a later pasted run.
53
+ 7. Persist answers and findings — workflow answers MUST use \`record_user_input\` with open question IDs and the exact user answer so an artifact is saved. For No workflow, after every accepted answer, call revision-safe \`update_task\` to add the decision, constraint, acceptance criterion, and durable findings to the description. Do not use progress comments; do not leave findings only in the terminal.
54
+ 8. Reserve before writing — read-only stages never reserve writes or edit files. When the current stage allows writes, call \`reserve_work_paths\` for the exact checkout and paths; never edit through a collision. Never edit a conflicting path; narrow the reservation or use an isolated Git worktree.
55
+ 9. Persist every output — call \`create_artifact\` for every substantive non-question output. Every artifact must have exactly one coverage disposition for every active repository scope. For workflow tasks, submit required artifact IDs and roles through \`submit_workflow_stage\`; the database decides whether to advance or wait for review. Never write planning documents into a repository.
54
56
  10. Split only with approval — when work is too large, use \`ask_question\`; on approval create dependency-linked items with \`create_task\`, a shared feature tag, and matching board order.
55
- 11. Keep the item self-sufficient — questions are persisted through \`ask_question\`; accepted answers and durable facts live in the description; substantive outputs live in artifacts. \`add_comment\` is not for routine progress. A fresh agent must need no terminal, chat, or subagent history.
57
+ 11. Keep the item self-sufficient — questions are persisted through \`ask_question\`; every accepted answer and durable fact lives in the description; substantive outputs live in artifacts. Do not use progress comments; \`add_comment\` is not for routine progress. A fresh agent must need no terminal, chat, or subagent history.
56
58
  12. End with proof — release finished reservations with \`release_work_paths\`, then call \`end_work\` with \`persistence_receipt = { final_task_revision, artifact_ids, outcome }\`. \`artifact_ids\` must exactly match every live artifact produced by this run and include the context-exploration artifact. Use outcome \`completed\` only for finished work, \`blocked\` for a persisted question or unavailable subagents, and \`aborted\` only when intentionally abandoning the run. A missing, stale, foreign, duplicate, or incomplete receipt is rejected; ending releases only this session's work.
57
59
 
58
60
  Full protocol: MCP prompt \`ctrl-spc-protocol\`. Tools live at ${mcpUrl}.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cli",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "CTRL+SPC CLI — per-machine agent for browser login, project linking, presence, and the local MCP server.",
5
5
  "engines": {
6
6
  "node": ">=22"