@ctrl-spc/cli 1.3.4 → 1.3.6

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.
@@ -9,6 +9,7 @@ import { ask, select } from '../prompt.js';
9
9
  import { buildTopologyTargets, componentTargetId, discoverRepositoryTopology, planExplicitGrouping, readGroupingManifest, repositoryTargetId, } from '../topology.js';
10
10
  import { upsertProjectLink } from './link.js';
11
11
  import { registerMachineWorkspace } from './run.js';
12
+ import { installWakeAgent } from '../wake.js';
12
13
  function setGrouping(flags, grouping) {
13
14
  if (flags.grouping) {
14
15
  throw new Error(`Choose exactly one grouping option: --combine, --split, or --manifest <file>.`);
@@ -448,6 +449,9 @@ export async function init(argv = [], deps = {}) {
448
449
  return;
449
450
  }
450
451
  const client = await getClient();
452
+ if (installWakeAgent()) {
453
+ console.log('Remote start enabled — this computer checks in every 60s so you can start it from the web. Disable: ctrl-spc wake off');
454
+ }
451
455
  const { data: userData, error: userError } = await client.auth.getUser();
452
456
  if (userError || !userData.user) {
453
457
  console.error(`Could not resolve the signed-in user: ${userError?.message ?? 'no user returned'}`);
@@ -4,6 +4,7 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
4
4
  import { SUPABASE_URL, SUPABASE_KEY } from '../env.js';
5
5
  import { writeSession } from '../config.js';
6
6
  import { getClient } from '../supabase.js';
7
+ import { installWakeAgent } from '../wake.js';
7
8
  const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
8
9
  /** POST /callback bodies larger than this are rejected before JSON parsing. */
9
10
  const MAX_CALLBACK_BODY_BYTES = 64 * 1024;
@@ -16,6 +17,9 @@ export async function login() {
16
17
  const outcome = await runLoginServer();
17
18
  if (outcome.ok) {
18
19
  console.log(`Signed in as ${outcome.email}`);
20
+ if (installWakeAgent()) {
21
+ console.log('Remote start enabled — this computer checks in every 60s so you can start it from the web. Disable: ctrl-spc wake off');
22
+ }
19
23
  process.exitCode = 0;
20
24
  }
21
25
  else {
@@ -1,12 +1,41 @@
1
- import { clearSession } from '../config.js';
1
+ import { clearSession, getMachineIdentity } from '../config.js';
2
+ import { getClient } from '../supabase.js';
3
+ import { removeWakeAgent } from '../wake.js';
4
+ async function defaultNullHeartbeats() {
5
+ const client = await getClient();
6
+ const { error } = await client
7
+ .from('machines')
8
+ .update({ waker_seen_at: null, broker_seen_at: null })
9
+ .eq('id', getMachineIdentity().id);
10
+ if (error)
11
+ throw error;
12
+ }
2
13
  /**
3
14
  * `ctrl-spc logout`: deletes the stored session. Local-only — the refresh
4
15
  * token is discarded, not revoked server-side (Supabase revokes it on next
5
16
  * rotation attempt; fine for v1's single-session CLI).
6
17
  */
7
- export function logout() {
8
- if (clearSession()) {
9
- console.log('Logged out — stored session removed.');
18
+ export async function logout(deps = {}) {
19
+ try {
20
+ await (deps.nullHeartbeats ?? defaultNullHeartbeats)();
21
+ }
22
+ catch {
23
+ // Offline or already signed out. The freshness window removes stale UI.
24
+ }
25
+ let wakeRemovalFailed = false;
26
+ try {
27
+ ;
28
+ (deps.removeWake ?? removeWakeAgent)();
29
+ }
30
+ catch {
31
+ // Clearing the authenticated session is the security boundary. A stale
32
+ // plist without a session can only wake, fail authentication, and exit.
33
+ wakeRemovalFailed = true;
34
+ }
35
+ if ((deps.clear ?? clearSession)()) {
36
+ console.log(wakeRemovalFailed
37
+ ? 'Logged out — stored session removed. The background start check could not be removed, but it no longer has access to your account.'
38
+ : 'Logged out — stored session removed and remote start disabled.');
10
39
  }
11
40
  else {
12
41
  console.log('No stored session — already logged out.');
@@ -13,6 +13,7 @@ import { startMcpServer } from '../mcp.js';
13
13
  import { getMachineIdentity } from '../config.js';
14
14
  import { discoverRepositoryTopology } from '../topology.js';
15
15
  import { createAgentDispatchController } from '../dispatch.js';
16
+ import { installWakeAgent, stampMachineHeartbeat } from '../wake.js';
16
17
  function mcpUrl() {
17
18
  return `http://localhost:${MCP_PORT}/mcp`;
18
19
  }
@@ -758,12 +759,13 @@ export function createProjectPresenceReconciler(client, userId, profileName, mac
758
759
  * tool call can flip presence back to "working" mid-shutdown) and only then
759
760
  * untracks presence, before exiting.
760
761
  */
761
- function installShutdownHandler(presence, mcpServer, dispatch) {
762
+ function installShutdownHandler(presence, mcpServer, dispatch, onShutdown) {
762
763
  let shuttingDown = false;
763
764
  const shutdown = () => {
764
765
  if (shuttingDown)
765
766
  return;
766
767
  shuttingDown = true;
768
+ onShutdown?.();
767
769
  dispatch?.stop();
768
770
  console.log('\nShutting down — closing MCP server, untracking presence...');
769
771
  mcpServer
@@ -785,6 +787,8 @@ export async function run() {
785
787
  if (retireLegacyDaemon()) {
786
788
  console.log('Removed obsolete CTRL+SPC background launcher.');
787
789
  }
790
+ if (process.argv[2] !== '__broker')
791
+ installWakeAgent();
788
792
  const cwd = process.cwd();
789
793
  const client = await getClient();
790
794
  const { data: userData, error: userError } = await client.auth.getUser();
@@ -865,13 +869,21 @@ export async function run() {
865
869
  await presence.stop().catch(() => { });
866
870
  throw formatMcpStartupError(err, MCP_PORT);
867
871
  }
872
+ // A broker heartbeat means the local server is genuinely accepting work.
873
+ // Stamp only after startMcpServer succeeds; otherwise a port/startup failure
874
+ // would make the web app show this computer as online for three minutes.
875
+ await stampMachineHeartbeat(client, machine.id, true).catch(() => { });
876
+ const heartbeatTimer = setInterval(() => {
877
+ void stampMachineHeartbeat(client, machine.id, true).catch(() => { });
878
+ }, 60_000);
879
+ heartbeatTimer.unref();
868
880
  console.log(`MCP: ${url} (tools: list_tasks, get_task, begin_work, heartbeat_work, reserve_work_paths, ` +
869
881
  'release_work_paths, transfer_coordination, acknowledge_unavailable_checkout, record_context_exploration, ' +
870
882
  'set_task_role_slugs, ' +
871
883
  'ask_question, record_user_input, submit_workflow_stage, decide_workflow_review, ' +
872
- 'end_work, create_task, update_task, create_artifact, add_comment)');
884
+ 'end_work, create_task, update_task, create_artifact, update_artifact, add_comment)');
873
885
  console.log(`Projects: ${projects.length > 0 ? projects.map((project) => project.name).join(', ') : 'none initialized on this machine'}`);
874
886
  console.log('Presence: available');
875
887
  const dispatch = createAgentDispatchController(client, machine.id, registeredAgents);
876
- installShutdownHandler(presence, mcpServer, dispatch);
888
+ installShutdownHandler(presence, mcpServer, dispatch, () => clearInterval(heartbeatTimer));
877
889
  }
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { link } from './commands/link.js';
6
6
  import { fetchRepository } from './commands/fetch.js';
7
7
  import { run } from './commands/run.js';
8
8
  import { openCompanion, serveCompanion } from './companion.js';
9
+ import { installWakeAgent, removeWakeAgent, wakeCheck } from './wake.js';
9
10
  export const HELP_TEXT = `ctrl-spc — CTRL+SPC CLI
10
11
 
11
12
  Usage:
@@ -18,6 +19,8 @@ Usage:
18
19
  ctrl-spc fetch Clone and link one unavailable project repository
19
20
  [repository-id] [--project <project-id>] [--destination <path>] [--yes]
20
21
  ctrl-spc open Open the local connection manager
22
+ ctrl-spc wake Enable/disable starting this computer's agents from the web
23
+ on|off
21
24
  ctrl-spc Run the local agent process (presence + MCP server)
22
25
  ctrl-spc --help Show this help text
23
26
  `;
@@ -28,7 +31,7 @@ export async function main(argv = process.argv.slice(2)) {
28
31
  await login();
29
32
  return;
30
33
  case 'logout':
31
- logout();
34
+ await logout();
32
35
  return;
33
36
  case 'init':
34
37
  await init(argv.slice(1));
@@ -42,6 +45,25 @@ export async function main(argv = process.argv.slice(2)) {
42
45
  case 'open':
43
46
  await openCompanion();
44
47
  return;
48
+ case 'wake':
49
+ if (argv[1] === 'off') {
50
+ console.log(removeWakeAgent()
51
+ ? 'Remote start disabled on this computer.'
52
+ : 'Remote start was not enabled.');
53
+ }
54
+ else if (argv[1] === 'on') {
55
+ console.log(installWakeAgent()
56
+ ? 'Remote start enabled — this computer checks in every 60s. Disable: ctrl-spc wake off'
57
+ : 'Remote start requires macOS.');
58
+ }
59
+ else {
60
+ console.error('Usage: ctrl-spc wake on|off');
61
+ process.exitCode = 1;
62
+ }
63
+ return;
64
+ case '__wake-check':
65
+ await wakeCheck();
66
+ return;
45
67
  case '__broker':
46
68
  await run();
47
69
  return;
package/dist/mcp.js CHANGED
@@ -437,7 +437,15 @@ export async function beginAgentRunHandler(deps, session, args) {
437
437
  // field as already provided only for backwards-compatible embedded
438
438
  // clients/test doubles that predate this contract.
439
439
  const persistedInstruction = typeof task.description === 'string' ? task.description.trim() : null;
440
- session.run.noWorkflowIntent = persistedInstruction === '' && !args.prompt_instruction?.trim()
440
+ const acceptedIntentDecisions = (await must(deps.client
441
+ .from('decisions')
442
+ .select('id')
443
+ .eq('task_id', args.task_id)
444
+ .eq('category', 'intent')
445
+ .eq('state', 'decided')
446
+ .order('decided_at', { ascending: false }))) ?? [];
447
+ session.run.noWorkflowIntent = persistedInstruction === '' &&
448
+ !args.prompt_instruction?.trim() && acceptedIntentDecisions.length === 0
441
449
  ? 'missing'
442
450
  : 'provided';
443
451
  }
@@ -683,7 +691,7 @@ function explorationArtifactContent(args, orderedCoverage) {
683
691
  * mutation. The receipt is deliberately session-local while its evidence is a
684
692
  * durable analysis artifact tied to the exact task, run, and topology revision.
685
693
  */
686
- export async function recordContextExplorationHandler(deps, attribution, session, args) {
694
+ export async function recordContextExplorationHandler(deps, _attribution, session, args) {
687
695
  const active = joinedRunOrError(session, 'record_context_exploration');
688
696
  if ('content' in active)
689
697
  return active;
@@ -742,20 +750,23 @@ export async function recordContextExplorationHandler(deps, attribution, session
742
750
  return errorResult('Exploration coverage acknowledgement_id is required only for unavailable_acknowledged repositories.');
743
751
  }
744
752
  }
745
- const artifactResult = await createArtifactHandler(bindProject(deps, active.projectId), attribution, {
746
- task_id: active.taskId,
747
- type: 'analysis',
748
- format: 'md',
749
- content: explorationArtifactContent(args, orderedCoverage),
750
- coverage: orderedCoverage,
751
- }, active);
752
- if (artifactResult.isError)
753
- return artifactResult;
754
- const first = artifactResult.content[0];
755
- if (!first || first.type !== 'text')
756
- throw new Error('analysis artifact returned no text result');
757
- const payload = JSON.parse(first.text);
758
- const artifactId = typeof payload.artifact?.id === 'string' ? payload.artifact.id : null;
753
+ const content = explorationArtifactContent(args, orderedCoverage);
754
+ const existingContext = await must(deps.client
755
+ .from('artifacts')
756
+ .select('revision')
757
+ .eq('task_id', active.taskId)
758
+ .eq('system_kind', 'context')
759
+ .is('deleted_at', null)
760
+ .maybeSingle());
761
+ const artifact = await must(deps.client.rpc('record_context_exploration_artifact', {
762
+ p_run: active.runId,
763
+ p_claim_fencing_token: active.fencingToken,
764
+ p_topology_revision: active.topologyRevision,
765
+ p_expected_revision: existingContext ? Number(existingContext.revision) : null,
766
+ p_content: content,
767
+ p_coverage: orderedCoverage,
768
+ }));
769
+ const artifactId = typeof artifact?.id === 'string' ? artifact.id : null;
759
770
  if (!artifactId)
760
771
  throw new Error('analysis artifact returned no id');
761
772
  session.exploration = {
@@ -778,6 +789,10 @@ export async function recordContextExplorationHandler(deps, attribution, session
778
789
  });
779
790
  }
780
791
  catch (err) {
792
+ if (err instanceof Error && err.message === 'context_conflict') {
793
+ return errorResult('record_context_exploration found newer work-item context. Reload the work item, ' +
794
+ 'reconcile the latest context with these reports, and retry so no findings are lost.');
795
+ }
781
796
  return coordinationError('record_context_exploration', err);
782
797
  }
783
798
  }
@@ -793,92 +808,82 @@ function isPersistedAgentQuestion(row, taskId, agentRunId) {
793
808
  QUESTION_CATEGORIES.includes(match[1]) &&
794
809
  isRealQuestion(match[2]));
795
810
  }
796
- export async function askQuestionHandler(deps, attribution, args, agentRun) {
811
+ export async function askQuestionHandler(deps, _attribution, args, agentRun) {
797
812
  if (args.task_id !== agentRun.taskId) {
798
813
  return errorResult(`ask_question must stay on this agent run's task (${agentRun.taskId}).`);
799
814
  }
800
815
  if (!QUESTION_CATEGORIES.includes(args.category))
801
816
  return errorResult('ask_question requires a supported category.');
817
+ if (!args.context?.trim())
818
+ return errorResult('ask_question requires concise context for the decision.');
802
819
  if (!isRealQuestion(args.question)) {
803
820
  return errorResult('ask_question requires real question text containing a question mark.');
804
821
  }
805
822
  const question = args.question.trim();
806
- const result = await addCommentHandler(bindProject(deps, agentRun.projectId), attribution, { task_id: agentRun.taskId, body: `[question:${args.category}] ${question}` }, agentRun.runId);
807
- if (result.isError)
808
- return result;
809
- const first = result.content[0];
810
- if (!first || first.type !== 'text')
811
- return errorResult('ask_question returned no persisted comment.');
812
- const payload = JSON.parse(first.text);
813
- const commentId = typeof payload.comment?.id === 'string' ? payload.comment.id : null;
814
- let workflowQuestionId = null;
815
- if (agentRun.workflow?.enabled) {
816
- if (!commentId)
817
- return errorResult('ask_question returned no persisted comment id for the workflow question.');
818
- workflowQuestionId = await must(deps.client.rpc('open_workflow_question', {
823
+ let opened;
824
+ try {
825
+ opened = await must(deps.client.rpc('ask_agent_question', {
819
826
  p_run: agentRun.runId,
820
- p_comment: commentId,
821
827
  p_category: args.category,
828
+ p_context: args.context.trim(),
822
829
  p_question: question,
830
+ p_related_artifact: args.related_artifact_id ?? null,
823
831
  }));
832
+ }
833
+ catch (err) {
834
+ return coordinationError('ask_question', err);
835
+ }
836
+ if (!opened)
837
+ return errorResult('ask_question returned no persisted question.');
838
+ const comment = opened?.comment && typeof opened.comment === 'object'
839
+ ? opened.comment
840
+ : null;
841
+ const commentId = typeof comment?.id === 'string' ? comment.id : null;
842
+ if (!commentId)
843
+ return errorResult('ask_question returned no persisted comment id for the decision.');
844
+ const decisionId = typeof opened?.decision_id === 'string' ? opened.decision_id : null;
845
+ const workflowQuestionId = typeof opened?.workflow_question_id === 'string' ? opened.workflow_question_id : null;
846
+ if (!decisionId)
847
+ return errorResult('ask_question returned no persisted decision id.');
848
+ if (agentRun.workflow?.enabled) {
824
849
  agentRun.workflow = await loadWorkflowContext(deps.client, agentRun.taskId);
825
850
  }
826
851
  return textResult({
827
852
  question: {
828
853
  category: args.category,
829
854
  text: question,
855
+ context: args.context.trim(),
830
856
  comment_id: commentId,
857
+ decision_id: decisionId,
831
858
  workflow_question_id: workflowQuestionId,
859
+ related_artifact_id: args.related_artifact_id ?? null,
832
860
  },
833
- comment: payload.comment ?? null,
861
+ comment,
834
862
  ...(agentRun.workflow?.enabled ? { workflow: agentRun.workflow } : {}),
835
863
  });
836
864
  }
837
- export async function recordUserInputHandler(deps, attribution, session, args) {
865
+ export async function recordUserInputHandler(deps, _attribution, session, args) {
838
866
  const active = joinedRunOrError(session, 'record_user_input');
839
867
  if ('content' in active)
840
868
  return active;
841
- if (!active.workflow?.enabled)
842
- return errorResult('record_user_input requires a workflow task.');
843
869
  const capabilityError = workflowCapabilityError(active, 'record_user_input', 'record_user_input');
844
870
  if (capabilityError)
845
871
  return capabilityError;
846
- if (!Array.isArray(args.question_ids) || args.question_ids.length === 0) {
847
- return errorResult('record_user_input requires at least one workflow question id.');
872
+ const decisionIds = args.decision_ids ?? args.question_ids;
873
+ if (!Array.isArray(decisionIds) || decisionIds.length === 0) {
874
+ return errorResult('record_user_input requires at least one decision id returned by ask_question.');
848
875
  }
849
876
  if (!args.answer?.trim())
850
877
  return errorResult('record_user_input requires the user\'s exact answer.');
851
878
  try {
852
- const openQuestions = active.workflow.open_questions ?? [];
853
- const questionText = args.question_ids.map((id) => {
854
- const question = openQuestions.find((candidate) => candidate.id === id);
855
- return `- ${typeof question?.question === 'string' ? question.question : id}`;
856
- }).join('\n');
857
- const artifactResult = await createArtifactHandler(bindProject(deps, active.projectId), attribution, {
858
- task_id: active.taskId,
859
- type: 'analysis',
860
- format: 'md',
861
- content: `# User input\n\n## Questions\n\n${questionText}\n\n## User answer\n\n${args.answer.trim()}`,
862
- coverage: args.coverage,
863
- }, active);
864
- if (artifactResult.isError)
865
- return artifactResult;
866
- const first = artifactResult.content[0];
867
- if (!first || first.type !== 'text')
868
- throw new Error('answer artifact returned no text result');
869
- const payload = JSON.parse(first.text);
870
- const artifactId = typeof payload.artifact?.id === 'string' ? payload.artifact.id : null;
871
- if (!artifactId)
872
- throw new Error('answer artifact returned no id');
873
- active.workflow = normalizeWorkflowContext(await must(deps.client.rpc('record_workflow_answer', {
879
+ active.workflow = normalizeWorkflowContext(await must(deps.client.rpc('record_task_decisions', {
874
880
  p_run: active.runId,
875
- p_question_ids: args.question_ids,
876
- p_artifact: artifactId,
881
+ p_decision_ids: decisionIds,
882
+ p_answer: args.answer,
877
883
  })));
878
884
  return textResult({
879
885
  recorded: true,
880
- artifact_id: artifactId,
881
- question_ids: args.question_ids,
886
+ decision_ids: decisionIds,
882
887
  workflow: active.workflow,
883
888
  });
884
889
  }
@@ -934,27 +939,6 @@ export async function decideWorkflowReviewHandler(deps, session, args) {
934
939
  return errorResult('decide_workflow_review requires the user\'s exact approval or change-request message as evidence.');
935
940
  }
936
941
  try {
937
- const scopes = await activeRepositoryScopes(deps.client, active.projectId);
938
- const evidenceResult = await createArtifactHandler(bindProject(deps, active.projectId), attributionFromClientName(session.clientName), {
939
- task_id: active.taskId,
940
- type: 'analysis',
941
- format: 'md',
942
- content: `# Workflow review decision\n\n${args.user_message.trim()}`,
943
- coverage: scopes.map(scope => ({
944
- repository_scope_id: scope.repository_scope_id,
945
- disposition: 'context_only',
946
- reason: 'Conversation review evidence applies to this workflow task.',
947
- })),
948
- }, active);
949
- if (evidenceResult.isError)
950
- return evidenceResult;
951
- const evidenceBlock = evidenceResult.content[0];
952
- if (!evidenceBlock || evidenceBlock.type !== 'text')
953
- throw new Error('review evidence artifact returned no text result');
954
- const evidencePayload = JSON.parse(evidenceBlock.text);
955
- const evidenceArtifactId = typeof evidencePayload.artifact?.id === 'string' ? evidencePayload.artifact.id : null;
956
- if (!evidenceArtifactId)
957
- throw new Error('review evidence artifact returned no id');
958
942
  const context = normalizeWorkflowContext(await must(deps.client.rpc('decide_workflow_review', {
959
943
  p_task: args.task_id,
960
944
  p_stage_run: args.stage_run_id,
@@ -963,17 +947,15 @@ export async function decideWorkflowReviewHandler(deps, session, args) {
963
947
  p_source: 'agent_conversation',
964
948
  p_evidence: args.user_message.trim(),
965
949
  p_agent_run: active.runId,
966
- p_evidence_artifact: evidenceArtifactId,
950
+ p_evidence_artifact: null,
967
951
  p_dispatch_agent: false,
968
952
  })));
969
953
  if (session.run?.state === 'joined' && session.run.taskId === args.task_id) {
970
954
  session.run.workflow = context;
971
955
  }
972
- session.reviewEvidenceArtifactId = evidenceArtifactId;
973
956
  return textResult({
974
957
  recorded: true,
975
958
  decision: args.decision,
976
- evidence_artifact_id: evidenceArtifactId,
977
959
  workflow: context,
978
960
  instruction: context.state === 'completed'
979
961
  ? 'The user approved completion. The workflow and work item are Done.'
@@ -991,11 +973,9 @@ export async function endAgentRunHandler(deps, session, args = {}, options = {})
991
973
  try {
992
974
  if (options.requirePersistenceReceipt) {
993
975
  const exploration = session.exploration;
994
- const reviewEvidenceArtifactId = session.reviewEvidenceArtifactId ?? null;
995
976
  const receipt = args.persistence_receipt;
996
- if ((!exploration && !reviewEvidenceArtifactId) || !receipt) {
997
- return errorResult('end_work requires a persistence_receipt and this session\'s persisted context or review evidence artifact.');
998
- }
977
+ if (!receipt)
978
+ return errorResult('end_work requires a persistence_receipt for this session.');
999
979
  const outcome = receipt.outcome ?? 'completed';
1000
980
  if (exploration?.status === 'blocked' && !['blocked', 'aborted'].includes(outcome)) {
1001
981
  return errorResult('A subagents_unavailable session may end only with outcome blocked or aborted.');
@@ -1018,16 +998,15 @@ export async function endAgentRunHandler(deps, session, args = {}, options = {})
1018
998
  if (!Number.isInteger(receipt.final_task_revision) || receipt.final_task_revision < 1) {
1019
999
  return errorResult('end_work persistence_receipt requires a positive final_task_revision.');
1020
1000
  }
1021
- if (!Array.isArray(receipt.artifact_ids) || receipt.artifact_ids.length === 0) {
1022
- return errorResult('end_work persistence_receipt requires persisted artifact ids.');
1023
- }
1001
+ if (!Array.isArray(receipt.artifact_ids))
1002
+ return errorResult('end_work persistence_receipt requires artifact_ids.');
1024
1003
  const artifactIds = [...new Set(receipt.artifact_ids)];
1025
1004
  if (artifactIds.length !== receipt.artifact_ids.length) {
1026
1005
  return errorResult('end_work persistence_receipt artifact_ids must be unique.');
1027
1006
  }
1028
- const requiredEvidenceArtifactId = exploration?.artifactId ?? reviewEvidenceArtifactId;
1029
- if (!artifactIds.includes(requiredEvidenceArtifactId)) {
1030
- return errorResult('end_work persistence_receipt must include this session\'s required evidence artifact id.');
1007
+ const requiredContextArtifactId = exploration?.artifactId ?? null;
1008
+ if (requiredContextArtifactId && !artifactIds.includes(requiredContextArtifactId)) {
1009
+ return errorResult('end_work persistence_receipt must include this session\'s context artifact id.');
1031
1010
  }
1032
1011
  const task = await must(deps.client
1033
1012
  .from('tasks')
@@ -1040,25 +1019,29 @@ export async function endAgentRunHandler(deps, session, args = {}, options = {})
1040
1019
  }
1041
1020
  const artifactRows = (await must(deps.client
1042
1021
  .from('artifacts')
1043
- .select('id,task_id,agent_run_id,deleted_at')
1022
+ .select('id,task_id,agent_run_id,updated_by_agent_run_id,deleted_at')
1044
1023
  .eq('task_id', active.taskId)
1045
- .eq('agent_run_id', active.runId)
1046
1024
  .is('deleted_at', null)
1047
1025
  .order('created_at', { ascending: true })
1048
1026
  .order('id', { ascending: true }))) ?? [];
1049
1027
  const artifactsById = new Map(artifactRows.map((artifact) => [artifact.id, artifact]));
1050
- const persistedArtifactIds = artifactRows.map((artifact) => artifact.id);
1028
+ const persistedArtifactIds = artifactRows
1029
+ .filter((artifact) => (artifact.agent_run_id === active.runId || artifact.updated_by_agent_run_id === active.runId))
1030
+ .map((artifact) => artifact.id);
1031
+ if (requiredContextArtifactId && !persistedArtifactIds.includes(requiredContextArtifactId)) {
1032
+ persistedArtifactIds.push(requiredContextArtifactId);
1033
+ }
1051
1034
  const missingArtifactIds = persistedArtifactIds.filter((id) => !artifactIds.includes(id));
1052
- const foreignArtifactIds = artifactIds.filter((id) => !artifactsById.has(id));
1035
+ const persistedArtifactIdSet = new Set(persistedArtifactIds);
1036
+ const foreignArtifactIds = artifactIds.filter((id) => !persistedArtifactIdSet.has(id));
1053
1037
  if (missingArtifactIds.length > 0 || foreignArtifactIds.length > 0) {
1054
1038
  return errorResult('end_work persistence_receipt artifact_ids must exactly match every non-deleted artifact persisted by ' +
1055
1039
  `this task run. Missing: ${missingArtifactIds.join(', ') || 'none'}. ` +
1056
1040
  `Foreign or deleted: ${foreignArtifactIds.join(', ') || 'none'}.`);
1057
1041
  }
1058
- const evidenceArtifact = artifactsById.get(requiredEvidenceArtifactId);
1059
- if (!evidenceArtifact || evidenceArtifact.task_id !== active.taskId ||
1060
- evidenceArtifact.agent_run_id !== active.runId) {
1061
- return errorResult('end_work could not verify this session\'s required evidence artifact.');
1042
+ const contextArtifact = requiredContextArtifactId ? artifactsById.get(requiredContextArtifactId) : null;
1043
+ if (requiredContextArtifactId && (!contextArtifact || contextArtifact.task_id !== active.taskId)) {
1044
+ return errorResult('end_work could not verify this session\'s context artifact.');
1062
1045
  }
1063
1046
  }
1064
1047
  const ended = await must(deps.client.rpc('end_agent_run', {
@@ -1067,7 +1050,6 @@ export async function endAgentRunHandler(deps, session, args = {}, options = {})
1067
1050
  }));
1068
1051
  session.run = null;
1069
1052
  session.exploration = null;
1070
- session.reviewEvidenceArtifactId = null;
1071
1053
  return textResult({
1072
1054
  ended: Boolean(ended),
1073
1055
  agent_run_id: active.runId,
@@ -1172,7 +1154,7 @@ export async function getTaskHandler(deps, _attribution, args, machineId) {
1172
1154
  }
1173
1155
  const task = parseTaskRow(row);
1174
1156
  const topologyMachineId = machineId ?? deps.machineId;
1175
- const [rawComments, rawArtifacts, rawVersions, rawAgentRuns, rawTaskClaims, collaborationDocument, topology, workflow] = await Promise.all([
1157
+ const [rawComments, rawArtifacts, rawDecisions, rawVersions, rawAgentRuns, rawTaskClaims, collaborationDocument, topology, workflow] = await Promise.all([
1176
1158
  must(client
1177
1159
  .from('comments')
1178
1160
  .select(COMMENT_SELECT)
@@ -1186,6 +1168,12 @@ export async function getTaskHandler(deps, _attribution, args, machineId) {
1186
1168
  .is('deleted_at', null)
1187
1169
  .order('created_at', { ascending: true })
1188
1170
  .order('id', { ascending: true })),
1171
+ must(client
1172
+ .from('decisions')
1173
+ .select('*')
1174
+ .eq('task_id', row.id)
1175
+ .order('asked_at', { ascending: true })
1176
+ .order('id', { ascending: true })),
1189
1177
  must(client
1190
1178
  .from('task_versions')
1191
1179
  .select(TASK_VERSION_SELECT)
@@ -1222,12 +1210,19 @@ export async function getTaskHandler(deps, _attribution, args, machineId) {
1222
1210
  ]);
1223
1211
  const comments = sortRecords((rawComments ?? []).map(normalizeComment), 'created_at', 'id');
1224
1212
  const normalizedArtifacts = sortRecords((rawArtifacts ?? []).map(normalizeArtifact), 'created_at', 'id');
1213
+ const hiddenArtifactKinds = new Set([
1214
+ 'superseded_context',
1215
+ 'legacy_decision_evidence',
1216
+ 'legacy_review_evidence',
1217
+ ]);
1218
+ const visibleArtifacts = normalizedArtifacts.filter((artifact) => (artifact.id === highlightArtifactId || !hiddenArtifactKinds.has(String(artifact.system_kind ?? ''))));
1225
1219
  const artifacts = highlightArtifactId
1226
1220
  ? [
1227
- ...normalizedArtifacts.filter((artifact) => artifact.id === highlightArtifactId),
1228
- ...normalizedArtifacts.filter((artifact) => artifact.id !== highlightArtifactId),
1221
+ ...visibleArtifacts.filter((artifact) => artifact.id === highlightArtifactId),
1222
+ ...visibleArtifacts.filter((artifact) => artifact.id !== highlightArtifactId),
1229
1223
  ]
1230
- : normalizedArtifacts;
1224
+ : visibleArtifacts;
1225
+ const decisions = sortRecords(rawDecisions ?? [], 'asked_at', 'id');
1231
1226
  const taskVersions = sortRecords(rawVersions ?? [], 'seq', 'id');
1232
1227
  const now = Date.now();
1233
1228
  const leaseIsLive = (value) => typeof value === 'string' && Number.isFinite(new Date(value).getTime()) && new Date(value).getTime() > now;
@@ -1270,6 +1265,7 @@ export async function getTaskHandler(deps, _attribution, args, machineId) {
1270
1265
  dependent_edges: task.dependent_edges,
1271
1266
  comments,
1272
1267
  artifacts,
1268
+ decisions,
1273
1269
  task_versions: taskVersions,
1274
1270
  active_agent_runs: activeAgentRuns,
1275
1271
  active_task_claims: activeTaskClaims,
@@ -1408,6 +1404,9 @@ export async function createArtifactHandler(deps, attribution, args, agentRun) {
1408
1404
  return errorResult('create_artifact requires task_id.');
1409
1405
  if (!args.content)
1410
1406
  return errorResult('create_artifact requires content.');
1407
+ if (args.purpose_key?.trim().startsWith('context:')) {
1408
+ return errorResult('Context is a reserved work-item artifact. Use record_context_exploration instead.');
1409
+ }
1411
1410
  if (agentRun) {
1412
1411
  if (agentRun.state !== 'joined') {
1413
1412
  return errorResult('create_artifact is blocked while repositories are unavailable. Fetch or acknowledge them, then call begin_work again.');
@@ -1415,6 +1414,11 @@ export async function createArtifactHandler(deps, attribution, args, agentRun) {
1415
1414
  if (args.task_id !== agentRun.taskId) {
1416
1415
  return errorResult(`create_artifact task_id must match this agent run's task (${agentRun.taskId}).`);
1417
1416
  }
1417
+ if (!args.title?.trim())
1418
+ return errorResult('create_artifact requires a title for agent artifacts.');
1419
+ if (!args.purpose_key?.trim()) {
1420
+ return errorResult('create_artifact requires a stable purpose_key for agent artifacts.');
1421
+ }
1418
1422
  if (!Array.isArray(args.coverage) || args.coverage.length === 0) {
1419
1423
  return errorResult('create_artifact requires repository coverage for every active project scope.');
1420
1424
  }
@@ -1433,6 +1437,8 @@ export async function createArtifactHandler(deps, attribution, args, agentRun) {
1433
1437
  p_topology_revision: agentRun.topologyRevision,
1434
1438
  p_type: args.type,
1435
1439
  p_format: args.format ?? 'md',
1440
+ p_title: args.title.trim(),
1441
+ p_purpose_key: args.purpose_key.trim(),
1436
1442
  p_content: args.content,
1437
1443
  p_storage_path: null,
1438
1444
  p_coverage: args.coverage.map((coverage) => ({
@@ -1459,12 +1465,14 @@ export async function createArtifactHandler(deps, attribution, args, agentRun) {
1459
1465
  task_id: args.task_id,
1460
1466
  type: args.type,
1461
1467
  format: args.format ?? 'md',
1468
+ ...(args.title?.trim() ? { title: args.title.trim() } : {}),
1469
+ ...(args.purpose_key?.trim() ? { purpose_key: args.purpose_key.trim() } : {}),
1462
1470
  content: args.content,
1463
1471
  created_by: deps.userId,
1464
1472
  from_agent: null,
1465
1473
  agent_run_id: null,
1466
1474
  })
1467
- .select('id,task_id,type,format,content,storage_path,created_by,from_agent,created_at')
1475
+ .select('id,task_id,type,format,title,purpose_key,system_kind,content,storage_path,created_by,from_agent,created_at')
1468
1476
  .single());
1469
1477
  if (!row)
1470
1478
  throw new Error('Artifact insert returned no row.');
@@ -1476,6 +1484,63 @@ export async function createArtifactHandler(deps, attribution, args, agentRun) {
1476
1484
  return errorResult(`create_artifact failed: ${err.message}`);
1477
1485
  }
1478
1486
  }
1487
+ export async function updateArtifactHandler(deps, args, agentRun) {
1488
+ try {
1489
+ if (agentRun.state !== 'joined') {
1490
+ return errorResult('update_artifact is blocked while repositories are unavailable. Fetch or acknowledge them, then call begin_work again.');
1491
+ }
1492
+ if (!args.id)
1493
+ return errorResult('update_artifact requires an artifact id.');
1494
+ if (!Number.isInteger(args.expected_revision) || args.expected_revision < 1) {
1495
+ return errorResult('update_artifact requires the current positive artifact revision from get_task.');
1496
+ }
1497
+ if (!args.title?.trim())
1498
+ return errorResult('update_artifact requires a title.');
1499
+ if (!args.purpose_key?.trim())
1500
+ return errorResult('update_artifact requires a stable purpose_key.');
1501
+ if (args.purpose_key.trim().startsWith('context:')) {
1502
+ return errorResult('Context is a reserved work-item artifact. Use record_context_exploration instead.');
1503
+ }
1504
+ if (!args.content)
1505
+ return errorResult('update_artifact requires content.');
1506
+ if (!Array.isArray(args.coverage) || args.coverage.length === 0) {
1507
+ return errorResult('update_artifact requires repository coverage for every active project scope.');
1508
+ }
1509
+ for (const coverage of args.coverage) {
1510
+ if (!coverage.repository_scope_id || !coverage.reason?.trim()) {
1511
+ return errorResult('Every update_artifact coverage entry requires repository_scope_id and a non-empty reason.');
1512
+ }
1513
+ const unavailable = coverage.disposition === 'unavailable_acknowledged';
1514
+ if (unavailable !== Boolean(coverage.acknowledgement_id)) {
1515
+ return errorResult('update_artifact coverage acknowledgement_id is required only for unavailable_acknowledged repositories.');
1516
+ }
1517
+ }
1518
+ const artifact = await must(deps.client.rpc('update_agent_artifact', {
1519
+ p_run: agentRun.runId,
1520
+ p_claim_fencing_token: agentRun.fencingToken,
1521
+ p_topology_revision: agentRun.topologyRevision,
1522
+ p_artifact: args.id,
1523
+ p_expected_revision: args.expected_revision,
1524
+ p_type: args.type,
1525
+ p_format: args.format,
1526
+ p_title: args.title.trim(),
1527
+ p_purpose_key: args.purpose_key.trim(),
1528
+ p_content: args.content,
1529
+ p_coverage: args.coverage.map((coverage) => ({
1530
+ repository_scope_id: coverage.repository_scope_id,
1531
+ disposition: coverage.disposition,
1532
+ reason: coverage.reason.trim(),
1533
+ acknowledgement_id: coverage.acknowledgement_id ?? null,
1534
+ })),
1535
+ }));
1536
+ if (!artifact)
1537
+ throw new Error('update_agent_artifact returned no artifact');
1538
+ return textResult({ artifact, repository_coverage: args.coverage });
1539
+ }
1540
+ catch (err) {
1541
+ return coordinationError('update_artifact', err);
1542
+ }
1543
+ }
1479
1544
  export async function addCommentHandler(deps, attribution, args, agentRunId) {
1480
1545
  try {
1481
1546
  if (!args.task_id)
@@ -1648,7 +1713,7 @@ export async function startMcpServer(deps) {
1648
1713
  return errorResult(`${tool} is blocked because this No workflow item has no instructions. ` +
1649
1714
  (run.noWorkflowIntent === 'missing'
1650
1715
  ? 'Call ask_question with category intent to ask the user what should be done.'
1651
- : 'Persist the user\'s answer in the task description with update_task before continuing.'));
1716
+ : 'Wait for the user to answer the decision in the web app, or record their conversational answer with record_user_input.'));
1652
1717
  }
1653
1718
  function buildServer(session) {
1654
1719
  const server = new McpServer({ name: 'ctrl-spc', version: BROKER_VERSION });
@@ -1783,7 +1848,7 @@ export async function startMcpServer(deps) {
1783
1848
  reason: z.string().optional(),
1784
1849
  persistence_receipt: z.object({
1785
1850
  final_task_revision: z.number().int().positive(),
1786
- artifact_ids: z.array(z.string()).min(1),
1851
+ artifact_ids: z.array(z.string()),
1787
1852
  outcome: z.enum(['completed', 'blocked', 'aborted']).optional(),
1788
1853
  }),
1789
1854
  },
@@ -1836,29 +1901,22 @@ export async function startMcpServer(deps) {
1836
1901
  if (missing)
1837
1902
  return missing;
1838
1903
  const run = session.context.run;
1839
- if (!run.workflow?.enabled && run.noWorkflowIntent !== 'provided') {
1840
- if (run.noWorkflowIntent !== 'intent_question_asked') {
1841
- return requireNoWorkflowIntent(session, 'update_task');
1842
- }
1843
- if (!args.fields.description?.trim() || Object.keys(args.fields).some(field => field !== 'description')) {
1844
- return errorResult('After the No workflow intent question, update_task must first persist only the user\'s answer in the task description.');
1845
- }
1846
- }
1904
+ const intent = requireNoWorkflowIntent(session, 'update_task');
1905
+ if (intent)
1906
+ return intent;
1847
1907
  const workflowError = workflowCapabilityError(run, 'update_task', 'update_task');
1848
1908
  if (workflowError)
1849
1909
  return workflowError;
1850
- const result = await updateTaskHandler(bindProject(handlerDeps, run.projectId), attribution(), args, run);
1851
- if (!result.isError && !run.workflow?.enabled && run.noWorkflowIntent === 'intent_question_asked') {
1852
- run.noWorkflowIntent = 'provided';
1853
- }
1854
- return result;
1910
+ return updateTaskHandler(bindProject(handlerDeps, run.projectId), attribution(), args, run);
1855
1911
  });
1856
1912
  server.registerTool('create_artifact', {
1857
- description: 'Persist an analysis/plan/spec/diagram/mock/wireframe on a task after context exploration. Never write planning documents into a repository.',
1913
+ description: 'Create a titled analysis/plan/spec/diagram/mock/wireframe for a new stable purpose. Use update_artifact when that purpose already exists; a different purpose creates a different artifact.',
1858
1914
  inputSchema: {
1859
1915
  task_id: z.string(),
1860
1916
  type: z.enum(['analysis', 'plan', 'spec', 'diagram', 'mock', 'wireframe']),
1861
1917
  format: z.enum(['md', 'html', 'json', 'svg']).optional().describe('Defaults to md'),
1918
+ title: z.string().min(1),
1919
+ purpose_key: z.string().min(1).describe('Stable machine-readable identity for this artifact purpose'),
1862
1920
  content: z.string(),
1863
1921
  coverage: z.array(z.object({
1864
1922
  repository_scope_id: z.string(),
@@ -1880,12 +1938,44 @@ export async function startMcpServer(deps) {
1880
1938
  return workflowError;
1881
1939
  return createArtifactHandler(bindProject(handlerDeps, run.projectId), attribution(), args, run);
1882
1940
  });
1941
+ server.registerTool('update_artifact', {
1942
+ description: 'Update the existing artifact for the same purpose. Pass its latest revision; stale updates conflict instead of creating a duplicate or overwriting newer work.',
1943
+ inputSchema: {
1944
+ id: z.string(),
1945
+ expected_revision: z.number().int().positive(),
1946
+ type: z.enum(['analysis', 'plan', 'spec', 'diagram', 'mock', 'wireframe']),
1947
+ format: z.enum(['md', 'html', 'json', 'svg']),
1948
+ title: z.string().min(1),
1949
+ purpose_key: z.string().min(1),
1950
+ content: z.string(),
1951
+ coverage: z.array(z.object({
1952
+ repository_scope_id: z.string(),
1953
+ disposition: z.enum(['affected', 'reviewed_no_change', 'context_only', 'unavailable_acknowledged']),
1954
+ reason: z.string(),
1955
+ acknowledgement_id: z.string().nullable().optional(),
1956
+ })).min(1).describe('Exactly one disposition for every active repository scope returned by get_task'),
1957
+ },
1958
+ }, async (args) => {
1959
+ const missing = requireExploredSessionRun(session, 'update_artifact');
1960
+ if (missing)
1961
+ return missing;
1962
+ const intent = requireNoWorkflowIntent(session, 'update_artifact');
1963
+ if (intent)
1964
+ return intent;
1965
+ const run = session.context.run;
1966
+ const workflowError = workflowCapabilityError(run, 'create_artifact', 'update_artifact');
1967
+ if (workflowError)
1968
+ return workflowError;
1969
+ return updateArtifactHandler(bindProject(handlerDeps, run.projectId), args, run);
1970
+ });
1883
1971
  server.registerTool('ask_question', {
1884
1972
  description: 'Persist a categorized, actionable question after complete context exploration. Questions are comments on the current work item.',
1885
1973
  inputSchema: {
1886
1974
  task_id: z.string(),
1887
1975
  category: z.enum(['clarification', 'reproduction', 'intent', 'dependency', 'checkout', 'collision', 'split']),
1976
+ context: z.string().min(1).describe('Why this decision is needed and what it affects'),
1888
1977
  question: z.string(),
1978
+ related_artifact_id: z.string().nullable().optional(),
1889
1979
  },
1890
1980
  }, async (args) => {
1891
1981
  const missing = requireExploredSessionRun(session, 'ask_question');
@@ -1908,22 +1998,22 @@ export async function startMcpServer(deps) {
1908
1998
  return result;
1909
1999
  });
1910
2000
  server.registerTool('record_user_input', {
1911
- description: 'Persist the user\'s exact answer as an artifact and resolve the current workflow questions before continuing.',
2001
+ description: 'Persist the user\'s exact answer on first-class decision records returned by ask_question. This creates no answer or approval artifact.',
1912
2002
  inputSchema: {
1913
- question_ids: z.array(z.string()).min(1),
2003
+ decision_ids: z.array(z.string()).min(1).optional(),
2004
+ question_ids: z.array(z.string()).min(1).optional().describe('Compatibility alias for decision_ids'),
1914
2005
  answer: z.string(),
1915
- coverage: z.array(z.object({
1916
- repository_scope_id: z.string(),
1917
- disposition: z.enum(['affected', 'reviewed_no_change', 'context_only', 'unavailable_acknowledged']),
1918
- reason: z.string(),
1919
- acknowledgement_id: z.string().nullable().optional(),
1920
- })).min(1),
1921
2006
  },
1922
2007
  }, async (args) => {
1923
2008
  const missing = requireExploredSessionRun(session, 'record_user_input');
1924
2009
  if (missing)
1925
2010
  return missing;
1926
- return recordUserInputHandler(handlerDeps, attribution(), session.context, args);
2011
+ const result = await recordUserInputHandler(handlerDeps, attribution(), session.context, args);
2012
+ const run = session.context.run;
2013
+ if (!result.isError && run?.state === 'joined' && !run.workflow?.enabled && run.noWorkflowIntent === 'intent_question_asked') {
2014
+ run.noWorkflowIntent = 'provided';
2015
+ }
2016
+ return result;
1927
2017
  });
1928
2018
  server.registerTool('submit_workflow_stage', {
1929
2019
  description: 'Submit the current workflow stage. The database validates its stored requirements and either advances or waits for user review.',
@@ -1972,6 +2062,7 @@ export async function startMcpServer(deps) {
1972
2062
  return askQuestionHandler(handlerDeps, attribution(), {
1973
2063
  task_id: args.task_id,
1974
2064
  category: 'clarification',
2065
+ context: 'Legacy question recorded from add_comment.',
1975
2066
  question: args.body,
1976
2067
  }, run);
1977
2068
  });
package/dist/protocol.js CHANGED
@@ -87,7 +87,9 @@ is saved to the work item. I am applying the task's explicit intent gate now.”
87
87
  run acknowledgement ID. Before the tool succeeds, commentary may state process only:
88
88
  never disclose findings, conclusions, likely impact, or decisions. After persistence,
89
89
  terminal summaries may reference the persisted artifact/question but must add no
90
- unpersisted facts.
90
+ unpersisted facts. Context has a stable purpose for the current workflow stage (or work
91
+ item without a workflow): repeat exploration updates that artifact instead of creating
92
+ another card.
91
93
  6. **Follow the workflow; otherwise ask exactly what is needed.** For an assigned workflow,
92
94
  the stored current-stage instructions and capabilities decide the work; never skip ahead
93
95
  based on task wording. Without a workflow, after exploration is recorded, classify intent
@@ -108,11 +110,11 @@ is saved to the work item. I am applying the task's explicit intent gate now.”
108
110
  collision handoff. After \`ask_question\` succeeds, do no more work in that run: call
109
111
  \`end_work\` with reason \`pending_user_answer\`, a receipt outcome of \`blocked\`, the
110
112
  final task revision, and every live artifact ID; continue only in a later pasted run.
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.
113
+ 7. **Persist every accepted answer and durable finding.** \`ask_question\` creates a
114
+ first-class decision and returns its decision ID for the web app to display and answer.
115
+ When the user answers in the agent conversation instead, call \`record_user_input\` with
116
+ those decision IDs and the user's exact answer. Decisions
117
+ are not answer artifacts and are not copied into the task description.
116
118
  Never leave an accepted answer or finding only in terminal, chat, or a subagent report.
117
119
  8. **Reserve the exact write surface only when allowed.** Read-only workflow stages never
118
120
  reserve write paths or edit files. When the current stage allows repository writes, call
@@ -124,16 +126,20 @@ is saved to the work item. I am applying the task's explicit intent gate now.”
124
126
  9. **Never edit through a collision.** A collision reserves nothing. Narrow to disjoint
125
127
  paths, wait, ask for an explicit handoff, or use an isolated Git worktree. Never edit a
126
128
  conflicting path, overwrite unexpected local changes, or assume another agent stopped.
127
- 10. **Persist and submit every substantive non-question output.** Use \`create_artifact\` for every
129
+ 10. **Persist and submit every substantive non-question output.** Give each artifact a clear
130
+ title and stable \`purpose_key\`. Use \`create_artifact\` when that purpose does not exist and
131
+ \`update_artifact\` with the latest revision when the same purpose already exists; a
132
+ different purpose creates a different artifact. Persist every
128
133
  plan, specification, research/finding report, schema, diagram, mock, and wireframe before
129
134
  presenting it as complete. Use type \`analysis\` for research/findings, \`plan\` for plans,
130
135
  \`spec\` for specifications or proposed schemas, and the matching visual type. Never
131
136
  write planning documents into a repository. Every artifact has exactly one coverage disposition and non-empty
132
137
  reason for every active repository scope at the current topology revision. For workflow
133
138
  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.
139
+ database alone decides whether the next stage starts or a review gate opens. Workflow
140
+ approval is displayed on the submitted plan; it is not a decision artifact.
135
141
  11. **Keep the work item self-sufficient.** Questions are durable records created through
136
- \`ask_question\`; accepted answers and durable facts live in the description; substantive
142
+ \`ask_question\`; accepted answers live in first-class decisions; substantive
137
143
  outputs live in artifacts. No progress comments. Never leave useful
138
144
  context only in terminal, chat history, a subagent report, or a repository planning file.
139
145
  If work is too large, ask through \`ask_question\`; after approval use \`create_task\` to
@@ -148,10 +154,10 @@ is saved to the work item. I am applying the task's explicit intent gate now.”
148
154
  rejected. Ending releases only this session's work.`;
149
155
  export const PROTOCOL_SHORT = 'ctrl-spc protocol (compact — full text: MCP prompt `ctrl-spc-protocol`): ' +
150
156
  '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. ' +
157
+ 'No-workflow unclear feature: MUST ask_question, then end blocked; never infer from findings or call update_task/create_artifact/reserve_work_paths first. Improve, coordinate, prepare, support are not deliverables. ' +
152
158
  '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. ' +
153
159
  '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. ' +
154
160
  '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. ' +
155
161
  '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. ' +
162
+ '5) record_context_exploration revises one task context across stages/agents. ask_question creates decisions answered in web; chat answers use record_user_input and create no answer artifact. Same purpose: update_artifact; different purpose: create_artifact. Plan approval stays on the plan, not in Decisions. Cover every active scope. No progress comments or terminal-only findings. ' +
157
163
  '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}.';
@@ -2,7 +2,10 @@ import { execFileSync, spawn } from 'node:child_process';
2
2
  import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { configDir } from './config.js';
5
+ import { configDir, getMachineIdentity } from './config.js';
6
+ export function brokerMatchesRuntime(health, kind, machineId) {
7
+ return health?.runtime?.kind === kind && health.machine_id === machineId;
8
+ }
6
9
  const CURRENT_PACKAGE_PATH = fileURLToPath(new URL('../package.json', import.meta.url));
7
10
  const CURRENT_ENTRY_PATH = fileURLToPath(new URL('./index.js', import.meta.url));
8
11
  export const LOCAL_RUNTIME_PORT = Number(process.env.CTRL_SPC_LOCAL_PORT) || 4572;
@@ -209,8 +212,11 @@ export async function startRuntime(kind) {
209
212
  'Stop it from its terminal before switching connections.');
210
213
  }
211
214
  const currentHealth = await probeBroker(profile.port);
212
- if (currentHealth?.runtime?.kind === kind)
213
- return;
215
+ if (currentHealth?.runtime?.kind === kind) {
216
+ if (brokerMatchesRuntime(currentHealth, kind, getMachineIdentity().id))
217
+ return;
218
+ throw new Error('Another CTRL+SPC connection is already running for a different computer identity. Stop it and try again.');
219
+ }
214
220
  if (currentHealth) {
215
221
  throw new Error(`Port ${profile.port} is already used by an unlabeled process. Turn it off before continuing.`);
216
222
  }
@@ -242,10 +248,10 @@ export async function startRuntime(kind) {
242
248
  if (childExited)
243
249
  return true;
244
250
  const health = await probeBroker(profile.port);
245
- return health?.runtime?.kind === kind;
251
+ return brokerMatchesRuntime(health, kind, getMachineIdentity().id);
246
252
  }, 30_000, 250);
247
253
  const health = await probeBroker(profile.port);
248
- if (!ready || health?.runtime?.kind !== kind) {
254
+ if (!ready || !brokerMatchesRuntime(health, kind, getMachineIdentity().id)) {
249
255
  rmSync(pidPath(kind), { force: true });
250
256
  const log = existsSync(logPath(kind)) ? readFileSync(logPath(kind), 'utf8').trim().split('\n').slice(-4).join('\n') : '';
251
257
  throw new Error(`${profile.label} did not become ready.${log ? `\n${log}` : ''}`);
package/dist/skills.js CHANGED
@@ -48,13 +48,13 @@ Before the context artifact exists, never state or imply a requested deliverable
48
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.
49
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.
50
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.
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.
51
+ 5. Persist context before deciding — the parent consolidates every child report and MUST call \`record_context_exploration\` to create or update the work item's stable-purpose context artifact before deciding what to do, asking a task question, planning, reserving, or editing. Every agent and workflow stage revises the same task-level context artifact instead of creating another card. 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
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 findingsworkflow 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.
53
+ 7. Persist every accepted answer and finding\`ask_question\` creates first-class decisions and returns decision IDs for the web app to display and answer. When the user answers in the agent conversation instead, use \`record_user_input\` with those decision IDs and the exact user answer. Decisions create no answer artifact and are not copied into the task description. Do not use progress comments; do not leave findings only in the terminal.
54
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.
55
+ 9. Persist every substantive non-question output — give every artifact a clear title and stable \`purpose_key\`. Call \`create_artifact\` for a new purpose and \`update_artifact\` with the latest revision when the same purpose already exists; a different purpose creates a different artifact. 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. Plan approval is displayed on the submitted plan and is not a decision artifact. Never write planning documents into a repository.
56
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.
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.
57
+ 11. Keep the item self-sufficient — questions and accepted answers are persisted as first-class decisions through \`ask_question\` and \`record_user_input\`; 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.
58
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.
59
59
 
60
60
  Full protocol: MCP prompt \`ctrl-spc-protocol\`. Tools live at ${mcpUrl}.
package/dist/wake.js ADDED
@@ -0,0 +1,237 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { getClient } from './supabase.js';
7
+ import { getMachineIdentity } from './config.js';
8
+ import { currentCompanionKind, runtimeStatuses, startRuntime, } from './runtime-control.js';
9
+ const WAKE_LABEL = 'com.ctrl-spc.wake';
10
+ const WAKE_INTERVAL_SECONDS = 60;
11
+ const CURRENT_ENTRY_PATH = fileURLToPath(new URL('./index.js', import.meta.url));
12
+ export const WAKE_REQUEST_FRESH_SECONDS = 150;
13
+ const FAILED_CLEANUP_MINUTES = 10;
14
+ export function wakeAgentPlistPath() {
15
+ return join(homedir(), 'Library', 'LaunchAgents', `${WAKE_LABEL}.plist`);
16
+ }
17
+ function xmlEscape(value) {
18
+ return value
19
+ .replace(/&/g, '&amp;')
20
+ .replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;')
22
+ .replace(/"/g, '&quot;')
23
+ .replace(/'/g, '&apos;');
24
+ }
25
+ export function renderWakePlist(nodePath, entryPath, pathEnv) {
26
+ return `<?xml version="1.0" encoding="UTF-8"?>
27
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
28
+ <plist version="1.0">
29
+ <dict>
30
+ <key>Label</key><string>${WAKE_LABEL}</string>
31
+ <key>ProgramArguments</key>
32
+ <array>
33
+ <string>${xmlEscape(nodePath)}</string>
34
+ <string>${xmlEscape(entryPath)}</string>
35
+ <string>__wake-check</string>
36
+ </array>
37
+ <key>StartInterval</key><integer>${WAKE_INTERVAL_SECONDS}</integer>
38
+ <key>RunAtLoad</key><true/>
39
+ <key>ProcessType</key><string>Background</string>
40
+ <key>EnvironmentVariables</key>
41
+ <dict>
42
+ <key>PATH</key><string>${xmlEscape(pathEnv)}</string>
43
+ </dict>
44
+ </dict>
45
+ </plist>
46
+ `;
47
+ }
48
+ function defaultLaunchctl(args) {
49
+ execFileSync('launchctl', args, { stdio: 'ignore' });
50
+ }
51
+ function defaultWrite(path, contents) {
52
+ mkdirSync(dirname(path), { recursive: true });
53
+ writeFileSync(path, contents, { mode: 0o644 });
54
+ chmodSync(path, 0o644);
55
+ }
56
+ function defaultRead(path) {
57
+ try {
58
+ return readFileSync(path, 'utf8');
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ export function installWakeAgent(deps = {}) {
65
+ if (process.env.CTRL_SPC_NO_WAKE_AGENT === '1')
66
+ return false;
67
+ if (process.platform !== 'darwin' && !deps.plistPath)
68
+ return false;
69
+ const plistPath = deps.plistPath ?? wakeAgentPlistPath();
70
+ const contents = renderWakePlist(deps.nodePath ?? process.execPath, deps.entryPath ?? CURRENT_ENTRY_PATH, deps.pathEnv ?? process.env.PATH ?? '/usr/bin:/bin');
71
+ if ((deps.read ?? defaultRead)(plistPath) === contents)
72
+ return true;
73
+ (deps.write ?? defaultWrite)(plistPath, contents);
74
+ const uid = deps.uid ?? process.getuid?.();
75
+ if (uid === undefined)
76
+ return true;
77
+ const launchctl = deps.launchctl ?? defaultLaunchctl;
78
+ try {
79
+ launchctl(['bootout', `gui/${uid}`, plistPath]);
80
+ }
81
+ catch {
82
+ // The job was not loaded yet.
83
+ }
84
+ try {
85
+ launchctl(['bootstrap', `gui/${uid}`, plistPath]);
86
+ }
87
+ catch (error) {
88
+ // Do not leave a byte-identical plist behind after a failed bootstrap:
89
+ // installWakeAgent intentionally skips launchctl for identical files, so
90
+ // keeping it would make every later retry report success without loading
91
+ // the job.
92
+ try {
93
+ ;
94
+ (deps.remove ?? rmSync)(plistPath);
95
+ }
96
+ catch {
97
+ // Preserve the launchctl error, which is the actionable failure.
98
+ }
99
+ throw error;
100
+ }
101
+ return true;
102
+ }
103
+ export function removeWakeAgent(deps = {}) {
104
+ if (process.platform !== 'darwin' && !deps.plistPath)
105
+ return false;
106
+ const plistPath = deps.plistPath ?? wakeAgentPlistPath();
107
+ if (!(deps.exists ?? existsSync)(plistPath))
108
+ return false;
109
+ const uid = deps.uid ?? process.getuid?.();
110
+ if (uid !== undefined) {
111
+ try {
112
+ ;
113
+ (deps.launchctl ?? defaultLaunchctl)(['bootout', `gui/${uid}`, plistPath]);
114
+ }
115
+ catch {
116
+ // An unloaded job is already stopped; the file still needs removal.
117
+ }
118
+ }
119
+ ;
120
+ (deps.remove ?? rmSync)(plistPath);
121
+ return true;
122
+ }
123
+ export async function stampMachineHeartbeat(client, machineId, brokerRunning) {
124
+ const now = new Date().toISOString();
125
+ const patch = { waker_seen_at: now };
126
+ if (brokerRunning)
127
+ patch.broker_seen_at = now;
128
+ const { error } = await client.from('machines').update(patch).eq('id', machineId);
129
+ if (error)
130
+ throw error;
131
+ }
132
+ function assertNoError(error) {
133
+ if (error)
134
+ throw error;
135
+ }
136
+ function supabaseWakeDb(client, machineId) {
137
+ const table = () => client.from('machine_wake_requests');
138
+ const freshCutoff = () => new Date(Date.now() - WAKE_REQUEST_FRESH_SECONDS * 1000).toISOString();
139
+ return {
140
+ stampHeartbeat: (brokerRunning) => stampMachineHeartbeat(client, machineId, brokerRunning),
141
+ async claimFreshPending() {
142
+ const { data, error } = await table()
143
+ .select('id')
144
+ .eq('machine_id', machineId)
145
+ .eq('status', 'pending')
146
+ .gte('requested_at', freshCutoff())
147
+ .order('requested_at', { ascending: false })
148
+ .limit(1)
149
+ .maybeSingle();
150
+ assertNoError(error);
151
+ return data ?? null;
152
+ },
153
+ async failStalePending() {
154
+ const { error } = await table().update({
155
+ status: 'failed',
156
+ error: 'Request expired before this machine checked in.',
157
+ updated_at: new Date().toISOString(),
158
+ })
159
+ .eq('machine_id', machineId)
160
+ .eq('status', 'pending')
161
+ .lt('requested_at', freshCutoff());
162
+ assertNoError(error);
163
+ },
164
+ async resolvePending(id) {
165
+ const { error } = await table()
166
+ .delete()
167
+ .eq('id', id)
168
+ .eq('machine_id', machineId)
169
+ .eq('status', 'pending');
170
+ assertNoError(error);
171
+ },
172
+ async failRequest(id, message) {
173
+ const { error } = await table()
174
+ .update({ status: 'failed', error: message, updated_at: new Date().toISOString() })
175
+ .eq('id', id);
176
+ assertNoError(error);
177
+ },
178
+ async cleanupFailed() {
179
+ const cutoff = new Date(Date.now() - FAILED_CLEANUP_MINUTES * 60 * 1000).toISOString();
180
+ const { error } = await table()
181
+ .delete()
182
+ .eq('machine_id', machineId)
183
+ .eq('status', 'failed')
184
+ .lt('updated_at', cutoff);
185
+ assertNoError(error);
186
+ },
187
+ };
188
+ }
189
+ async function defaultGetDb() {
190
+ const client = await getClient();
191
+ return supabaseWakeDb(client, getMachineIdentity().id);
192
+ }
193
+ async function defaultBrokerRunning() {
194
+ const statuses = await runtimeStatuses();
195
+ const machineId = getMachineIdentity().id;
196
+ return statuses.some((status) => status.running && status.health?.machine_id === machineId);
197
+ }
198
+ export async function wakeCheck(deps = {}) {
199
+ try {
200
+ if (await (deps.brokerRunning ?? defaultBrokerRunning)())
201
+ return 'broker_running';
202
+ }
203
+ catch {
204
+ // A failed probe is treated as not running so the poller can recover it.
205
+ }
206
+ let db;
207
+ try {
208
+ db = await (deps.getDb ?? defaultGetDb)();
209
+ }
210
+ catch {
211
+ return 'no_session';
212
+ }
213
+ try {
214
+ await db.stampHeartbeat(false);
215
+ await db.failStalePending();
216
+ const pending = await db.claimFreshPending();
217
+ if (!pending) {
218
+ await db.cleanupFailed();
219
+ return 'idle';
220
+ }
221
+ try {
222
+ await (deps.start ?? startRuntime)((deps.runtimeKind ?? currentCompanionKind)());
223
+ await db.resolvePending(pending.id);
224
+ await db.stampHeartbeat(true);
225
+ await db.cleanupFailed();
226
+ return 'started';
227
+ }
228
+ catch (error) {
229
+ await db.failRequest(pending.id, error instanceof Error ? error.message : String(error));
230
+ await db.cleanupFailed();
231
+ return 'start_failed';
232
+ }
233
+ }
234
+ catch {
235
+ return 'idle';
236
+ }
237
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cli",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
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"