vibe-coding-master 0.0.16 → 0.0.17

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.
package/README.md CHANGED
@@ -194,7 +194,7 @@ All sidebar sections are collapsed by default. When no task is selected, `Reposi
194
194
 
195
195
  ## Translation
196
196
 
197
- The role toolbar has an `Auto orchestration` on/off button immediately to the left of the `Translate` button. The `Translate` button opens a translation panel beside the embedded terminal. The terminal and translation panel split the available width evenly.
197
+ The task header has a global `Translate` button next to `Close Task`. It opens a translation panel beside the embedded terminal for the role consoles and keeps the same on/off setting while switching roles. The terminal and translation panel split the available width evenly.
198
198
 
199
199
  Translation settings are local and stored in:
200
200
 
@@ -276,8 +276,10 @@ Claude Code role
276
276
  -> ends the Claude Code turn
277
277
  -> Stop hook calls VCM backend directly
278
278
  -> VCM scans pending route files
279
- -> VCM validates, snapshots, and dispatches pending messages as delivering
280
- -> UserPromptSubmit hook confirms accepted VCM messages as submitted
279
+ -> VCM validates and dispatches at most one pending route file
280
+ -> VCM records dispatchingAt and the GUI switches to the target role
281
+ -> VCM snapshots only actually delivered message history
282
+ -> UserPromptSubmit hook records acceptedAt and clears the matching route file
281
283
  ```
282
284
 
283
285
  Examples:
@@ -302,15 +304,16 @@ Each directed role route has exactly one message file. If a role changes its min
302
304
 
303
305
  ## Orchestration Modes
304
306
 
305
- VCM has a task-level `Auto orchestration` switch in the active role toolbar, immediately to the left of `Translate`.
307
+ VCM has a task-level `Auto orchestration` switch in the active role toolbar.
306
308
 
307
309
  When it is off, VCM is in manual mode:
308
310
 
309
311
  - Roles may write pending route files under `.ai/vcm/handoffs/messages/`.
310
312
  - Messages appear in the `Messages` modal.
311
313
  - The user can inspect them.
312
- - The current GUI shows sequence, timestamp, status, body preview, path, `Copy`, and `Mark All Done`.
313
- - `Mark All Done` marks open message records as `acknowledged` after the user manually copied or handled stuck queued/in-flight messages.
314
+ - The current GUI shows newest message history first with stable increasing sequence numbers, timestamp, body preview, path, `Copy`, `Mark All Done`, and `Delete All`.
315
+ - `Mark All Done` clears non-empty pending route files after the user manually copied or handled stuck handoff content.
316
+ - `Delete All` removes message history from the Messages modal without touching pending route files.
314
317
  - The user decides what to do next by copying or manually acting on the message.
315
318
  - VCM does not write to the target terminal or press Enter for the user.
316
319
 
@@ -319,14 +322,15 @@ When it is on, VCM is in auto mode:
319
322
  - Backend policy still applies.
320
323
  - Roles write pending route files under `.ai/vcm/handoffs/messages/`.
321
324
  - On Claude Code `Stop`, VCM scans pending route files.
322
- - If the target role session is running, idle, and has no active message, VCM writes a `[VCM MESSAGE]` envelope to the target terminal and submits it.
323
- - After successful terminal write, VCM snapshots the body as `delivering`.
324
- - Claude Code `UserPromptSubmit` confirms that the prompt was accepted; VCM then marks the message `submitted` and clears the source route file if it still contains the same message.
325
- - When the GUI observes a newly submitted auto message, it switches the active role tab to that message's target role so the user can watch the next step.
326
- - VCM enforces per-role turn-taking: each target role can have at most one in-flight `delivering` or `submitted` message.
325
+ - If the target role session is running and idle, VCM writes a `[VCM MESSAGE]` envelope to the target terminal and submits it.
326
+ - Just before terminal submission, VCM records `dispatchingAt`, waits briefly for the GUI to switch tabs, then writes to the embedded terminal.
327
+ - After successful terminal write, VCM snapshots the delivered body as message history.
328
+ - Claude Code `UserPromptSubmit` confirms that the prompt was accepted; VCM stores `acceptedAt` and clears the source route file if it still contains the same message.
329
+ - When the GUI observes a newly dispatching auto message, it switches the active role tab to that message's target role before the message is submitted.
330
+ - VCM enforces sequential turn-taking from hook state: a role that has accepted a prompt is busy until its `Stop` hook fires.
327
331
  - Additional pending files to a busy target role remain non-empty and are not written to that terminal.
328
332
  - When the target role later reaches `Stop`, VCM scans again and may deliver the next pending route file.
329
- - If auto orchestration gets stuck after a manual copy/paste recovery, `Mark All Done` clears open `pending_approval`, `queued`, `delivering`, `submitted`, and failed message records to `acknowledged`, and clears pending route files.
333
+ - If auto orchestration gets stuck after a manual copy/paste recovery, `Mark All Done` clears pending route files. It does not mutate message history.
330
334
 
331
335
  VCM Harness injects Claude Code `UserPromptSubmit` and `Stop` hooks into `.claude/settings.json`. Role tabs become `running` when Claude Code accepts a prompt and `idle` after `Stop`; VCM also marks a role `running` immediately after it writes a message to that embedded terminal. The terminal process status is still tracked separately. The injected role rules require a role to end its turn after writing or updating a message route file, rather than polling, looping, or waiting for another role inside the same Claude Code turn.
332
336
 
@@ -334,9 +338,9 @@ The implementation keeps only the active manual/auto orchestration mode. It does
334
338
 
335
339
  ## Round Completion Alerts
336
340
 
337
- VCM detects conversation completion from hook-driven role activity state, not PTY silence. `UserPromptSubmit` marks a role `running`, and `Stop` marks that role `idle` with a stop timestamp.
341
+ VCM detects conversation completion from hook-driven role activity state, not PTY silence or message history. `UserPromptSubmit` marks a role `running`, and `Stop` marks that role `idle` with a stop timestamp.
338
342
 
339
- For role chains, VCM waits for the latest active message's target role to reach hook `Stop`. For example, if PM sends work to Coder and Coder sends a result back to PM, the round is not complete when Coder finishes; it is complete only after PM reaches `Stop` for the final response. If no VCM role message is involved, the latest direct role `Stop` can complete the round.
343
+ For role chains, VCM waits for the final role to reach hook `Stop`. For example, if PM sends work to Coder and Coder sends a result back to PM, the round is not complete when Coder finishes; it is complete only after PM reaches `Stop` for the final response. Pending route files block completion because more dispatch work is waiting; message history does not define completion.
340
344
 
341
345
  When `Round alert` is enabled, the frontend polls the task round state, deduplicates each completion id, shows a small `Round complete` prompt, and plays the local completion chime.
342
346
 
@@ -9,10 +9,6 @@ export function registerMessageRoutes(app, deps) {
9
9
  const context = await getRouteContext(deps, request.params.taskSlug);
10
10
  return deps.messageService.listPendingRouteFiles(context);
11
11
  });
12
- app.post("/api/tasks/:taskSlug/messages/scan", async (request) => {
13
- const context = await getRouteContext(deps, request.params.taskSlug);
14
- return deps.messageService.scanAndDispatchPendingRouteFiles(context);
15
- });
16
12
  app.post("/api/tasks/:taskSlug/messages/mark-all-done", async (request) => {
17
13
  const context = await getRouteContext(deps, request.params.taskSlug);
18
14
  return deps.messageService.markAllDone({
@@ -20,6 +16,10 @@ export function registerMessageRoutes(app, deps) {
20
16
  clearRouteFiles: true
21
17
  });
22
18
  });
19
+ app.delete("/api/tasks/:taskSlug/messages/history", async (request) => {
20
+ const context = await getRouteContext(deps, request.params.taskSlug);
21
+ return deps.messageService.deleteMessageHistory(context);
22
+ });
23
23
  app.get("/api/tasks/:taskSlug/orchestration", async (request) => {
24
24
  const context = await getRouteContext(deps, request.params.taskSlug);
25
25
  return deps.messageService.getOrchestrationState(context);
@@ -7,16 +7,18 @@ export function registerRoundRoutes(app, deps) {
7
7
  const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
8
8
  const taskRepoRoot = getTaskRuntimeRepoRoot(task);
9
9
  const sessions = await deps.sessionService.listRoleSessions(project.repoRoot, request.params.taskSlug);
10
- const messages = await deps.messageService.listMessages({
10
+ const pendingRoutes = await deps.messageService.listPendingRouteFiles({
11
11
  repoRoot: project.repoRoot,
12
+ taskRepoRoot,
12
13
  stateRepoRoot: taskRepoRoot,
13
14
  stateRoot: config.stateRoot,
15
+ handoffDir: task.handoffDir,
14
16
  taskSlug: request.params.taskSlug
15
17
  });
16
18
  return deps.roundService.getTaskRoundState({
17
19
  taskSlug: request.params.taskSlug,
18
20
  sessions,
19
- messages
21
+ pendingRouteCount: pendingRoutes.length
20
22
  });
21
23
  });
22
24
  }
@@ -59,7 +59,7 @@ export function createClaudeHookService(deps) {
59
59
  role: input.role,
60
60
  sessionUpdated: Boolean(session),
61
61
  dispatchedCount: 0,
62
- submittedMessageId: submitted?.id
62
+ acceptedMessageId: submitted?.id
63
63
  };
64
64
  }
65
65
  async function handleStopHook(input) {
@@ -8,25 +8,11 @@ import { renderMessageEnvelope } from "../templates/message-envelope.js";
8
8
  const PM_ROLE = "project-manager";
9
9
  const PM_TO_ROLE_TYPES = new Set(["task", "question", "review-request", "revise", "cancel"]);
10
10
  const ROLE_TO_PM_TYPES = new Set(["result", "question", "blocked", "finding"]);
11
- const OPEN_MESSAGE_STATUSES = new Set([
12
- "pending_approval",
13
- "queued",
14
- "delivering",
15
- "submitted",
16
- "failed",
17
- "delivery_failed",
18
- "response_ready_missing_result"
19
- ]);
20
- const MUTABLE_ROUTE_MESSAGE_STATUSES = new Set([
21
- "pending_approval",
22
- "queued",
23
- "failed",
24
- "delivery_failed",
25
- "response_ready_missing_result"
26
- ]);
11
+ const DEFAULT_PRE_DISPATCH_SWITCH_DELAY_MS = 500;
27
12
  export function createMessageService(deps) {
28
13
  const now = deps.now ?? (() => new Date().toISOString());
29
14
  const id = deps.id ?? (() => `msg_${randomUUID()}`);
15
+ const preDispatchSwitchDelayMs = deps.preDispatchSwitchDelayMs ?? DEFAULT_PRE_DISPATCH_SWITCH_DELAY_MS;
30
16
  const taskLocks = new Map();
31
17
  async function getOrchestrationState(input) {
32
18
  const statePath = getOrchestrationStatePath(getStateRepoRoot(input), input.stateRoot, input.taskSlug);
@@ -49,32 +35,17 @@ export function createMessageService(deps) {
49
35
  const timestamp = now();
50
36
  const base = toRouteContext(input);
51
37
  const state = await getOrchestrationState(input);
52
- const messagesPath = getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug);
53
- let messages = await readLatestMessages(deps.fs, messagesPath);
54
- const updates = [];
55
- if (input.stoppedRole) {
56
- const acknowledged = acknowledgeActiveMessages(messages, input.stoppedRole, timestamp);
57
- for (const message of acknowledged) {
58
- await appendMessageSnapshot(deps.fs, input, message);
59
- await clearRouteFileIfStillMatchesMessage(deps.fs, input, message);
60
- }
61
- updates.push(...acknowledged);
62
- messages = applyMessageSnapshots(messages, acknowledged);
63
- }
64
38
  const pendingFiles = (await listRouteFiles(deps.fs, base)).filter((routeFile) => routeFile.pending);
65
39
  const candidates = selectDispatchCandidates(pendingFiles, input.stoppedRole);
66
40
  const results = [];
67
- const deliveredTargets = new Set();
68
41
  for (const routeFile of candidates) {
69
- if (deliveredTargets.has(routeFile.toRole)) {
70
- continue;
71
- }
72
- const result = await dispatchRouteFile(input, routeFile, messages, state, timestamp);
42
+ const result = await dispatchRouteFile(input, routeFile, state, timestamp);
73
43
  results.push(result);
74
- await appendMessageSnapshot(deps.fs, input, result.message);
75
- messages = applyMessageSnapshots(messages, [result.message]);
44
+ if (result.message) {
45
+ await appendMessageSnapshot(deps.fs, input, result.message);
46
+ }
76
47
  if (result.delivered) {
77
- deliveredTargets.add(routeFile.toRole);
48
+ break;
78
49
  }
79
50
  }
80
51
  return results;
@@ -84,73 +55,56 @@ export function createMessageService(deps) {
84
55
  const all = await listRouteFiles(deps.fs, context);
85
56
  return all.filter((routeFile) => routeFile.pending);
86
57
  }
87
- async function dispatchRouteFile(input, routeFile, existingMessages, state, timestamp) {
58
+ async function dispatchRouteFile(input, routeFile, state, timestamp) {
88
59
  validateMessagePolicy(routeFile.fromRole, routeFile.toRole, routeFile.type);
89
- const existingOpen = findOpenMessageForRoute(existingMessages, routeFile.path);
90
- const message = {
91
- ...(existingOpen ?? {
92
- id: id(),
93
- taskSlug: input.taskSlug,
94
- createdAt: timestamp
95
- }),
96
- fromRole: routeFile.fromRole,
97
- toRole: routeFile.toRole,
98
- type: routeFile.type,
99
- body: routeFile.body,
100
- artifactRefs: routeFile.artifactRefs,
101
- bodyPath: routeFile.path,
102
- routePath: routeFile.path,
103
- status: "queued",
104
- queuedBehindMessageId: undefined,
105
- failureReason: undefined
106
- };
107
60
  const session = await deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, routeFile.toRole);
108
61
  if (!session || session.status !== "running") {
109
62
  return {
110
- message: {
111
- ...message,
112
- status: "queued",
113
- failureReason: `${routeFile.toRole} session is not running.`
114
- },
115
63
  delivered: false,
116
64
  requiresUserApproval: false,
117
- clearedRouteFile: false
65
+ clearedRouteFile: false,
66
+ failureReason: `${routeFile.toRole} session is not running.`
118
67
  };
119
68
  }
120
69
  if (state.mode === "manual") {
121
70
  return {
122
- message: {
123
- ...message,
124
- status: "pending_approval"
125
- },
126
71
  delivered: false,
127
72
  requiresUserApproval: true,
128
73
  clearedRouteFile: false
129
74
  };
130
75
  }
131
- const activeMessage = findActiveMessageForRole(existingMessages, routeFile.toRole);
132
- if (activeMessage) {
76
+ if (session.activityStatus === "running") {
133
77
  return {
134
- message: {
135
- ...message,
136
- status: "queued",
137
- queuedBehindMessageId: activeMessage.id,
138
- failureReason: `${routeFile.toRole} is still handling VCM message ${activeMessage.id}.`
139
- },
140
78
  delivered: false,
141
79
  requiresUserApproval: false,
142
- clearedRouteFile: false
80
+ clearedRouteFile: false,
81
+ failureReason: `${routeFile.toRole} is still running.`
143
82
  };
144
83
  }
145
- const delivering = {
84
+ const message = {
85
+ id: id(),
86
+ taskSlug: input.taskSlug,
87
+ createdAt: timestamp,
88
+ fromRole: routeFile.fromRole,
89
+ toRole: routeFile.toRole,
90
+ type: routeFile.type,
91
+ body: routeFile.body,
92
+ artifactRefs: routeFile.artifactRefs,
93
+ bodyPath: routeFile.path,
94
+ routePath: routeFile.path,
95
+ dispatchingAt: timestamp,
96
+ failureReason: undefined
97
+ };
98
+ await appendMessageSnapshot(deps.fs, input, message);
99
+ await delay(preDispatchSwitchDelayMs);
100
+ const delivered = {
146
101
  ...message,
147
- status: "delivering",
148
102
  deliveredAt: timestamp
149
103
  };
150
- await submitTerminalInput(deps.runtime, session.id, renderMessageEnvelope(delivering));
104
+ await submitTerminalInput(deps.runtime, session.id, renderMessageEnvelope(delivered));
151
105
  await deps.sessionService.markRoleActivityRunning(input.repoRoot, input.taskSlug, routeFile.toRole);
152
106
  return {
153
- message: delivering,
107
+ message: delivered,
154
108
  delivered: true,
155
109
  requiresUserApproval: false,
156
110
  clearedRouteFile: false
@@ -174,48 +128,46 @@ export function createMessageService(deps) {
174
128
  if (!messageId) {
175
129
  return undefined;
176
130
  }
177
- const message = findDeliveringMessageForPrompt(messages, input.role, messageId);
131
+ const message = findDeliveredMessageForPrompt(messages, input.role, messageId);
178
132
  if (!message) {
179
133
  return undefined;
180
134
  }
181
- const submitted = {
135
+ const accepted = {
182
136
  ...message,
183
- status: "submitted",
184
- submittedAt: timestamp,
185
- queuedBehindMessageId: undefined,
137
+ acceptedAt: timestamp,
186
138
  failureReason: undefined
187
139
  };
188
- await appendMessageSnapshot(deps.fs, input, submitted);
189
- await clearRouteFileIfStillMatchesMessage(deps.fs, input, submitted);
190
- return submitted;
140
+ await appendMessageSnapshot(deps.fs, input, accepted);
141
+ await clearRouteFileIfStillMatchesMessage(deps.fs, input, accepted);
142
+ return accepted;
191
143
  });
192
144
  },
193
145
  async markAllDone(input) {
194
146
  return withTaskLock(taskLocks, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), async () => {
195
- const timestamp = now();
196
147
  const messages = await readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
197
- const updated = messages
198
- .filter((message) => OPEN_MESSAGE_STATUSES.has(message.status))
199
- .map((message) => ({
200
- ...message,
201
- status: "acknowledged",
202
- acknowledgedAt: timestamp,
203
- queuedBehindMessageId: undefined,
204
- failureReason: undefined
205
- }));
206
- for (const message of updated) {
207
- await appendMessageSnapshot(deps.fs, input, message);
208
- }
148
+ let clearedCount = 0;
209
149
  if (input.clearRouteFiles) {
210
150
  for (const routeFile of await listPendingRouteFiles(input)) {
211
151
  await deps.fs.writeText(resolveRepoPath(input.taskRepoRoot ?? input.repoRoot, routeFile.path), "");
152
+ clearedCount += 1;
212
153
  }
213
154
  }
214
- const latest = applyMessageSnapshots(messages, updated);
215
155
  return {
216
156
  taskSlug: input.taskSlug,
217
- updatedCount: updated.length,
218
- messages: latest
157
+ updatedCount: clearedCount,
158
+ messages
159
+ };
160
+ });
161
+ },
162
+ async deleteMessageHistory(input) {
163
+ return withTaskLock(taskLocks, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), async () => {
164
+ const messagesPath = getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug);
165
+ const messages = await readLatestMessages(deps.fs, messagesPath);
166
+ await writeMessageSnapshots(deps.fs, messagesPath, []);
167
+ return {
168
+ taskSlug: input.taskSlug,
169
+ deletedCount: messages.length,
170
+ messages: []
219
171
  };
220
172
  });
221
173
  },
@@ -401,31 +353,11 @@ async function withTaskLock(locks, key, run) {
401
353
  }
402
354
  }
403
355
  }
404
- function findOpenMessageForRoute(messages, routePath) {
405
- return [...messages]
406
- .reverse()
407
- .find((message) => (message.routePath ?? message.bodyPath) === routePath && MUTABLE_ROUTE_MESSAGE_STATUSES.has(message.status));
408
- }
409
- function findActiveMessageForRole(messages, role) {
410
- return messages.find((message) => message.toRole === role &&
411
- (message.status === "delivering" || message.status === "submitted"));
412
- }
413
- function acknowledgeActiveMessages(messages, role, timestamp) {
414
- return messages
415
- .filter((message) => message.toRole === role && (message.status === "delivering" || message.status === "submitted"))
416
- .map((message) => ({
417
- ...message,
418
- status: "acknowledged",
419
- submittedAt: message.submittedAt ?? message.deliveredAt ?? timestamp,
420
- acknowledgedAt: timestamp,
421
- failureReason: undefined
422
- }));
423
- }
424
- function findDeliveringMessageForPrompt(messages, role, messageId) {
425
- const delivering = messages.filter((message) => message.toRole === role &&
426
- message.status === "delivering" &&
427
- message.id === messageId);
428
- return delivering.sort((left, right) => getMessageDeliveryTime(left).localeCompare(getMessageDeliveryTime(right))).at(-1);
356
+ function findDeliveredMessageForPrompt(messages, role, messageId) {
357
+ const delivered = messages.filter((message) => message.toRole === role &&
358
+ message.id === messageId &&
359
+ message.deliveredAt);
360
+ return delivered.sort((left, right) => getMessageDeliveryTime(left).localeCompare(getMessageDeliveryTime(right))).at(-1);
429
361
  }
430
362
  function extractVcmMessageId(prompt) {
431
363
  return prompt?.match(/^\s*id:\s*(\S+)\s*$/m)?.[1];
@@ -455,16 +387,6 @@ function arraysEqual(left, right) {
455
387
  }
456
388
  return left.every((value, index) => value === right[index]);
457
389
  }
458
- function applyMessageSnapshots(messages, snapshots) {
459
- if (snapshots.length === 0) {
460
- return messages;
461
- }
462
- const latest = new Map(messages.map((message) => [message.id, message]));
463
- for (const snapshot of snapshots) {
464
- latest.set(snapshot.id, snapshot);
465
- }
466
- return [...latest.values()].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
467
- }
468
390
  async function readLatestMessages(fs, messagesPath) {
469
391
  if (!(await fs.pathExists(messagesPath))) {
470
392
  return [];
@@ -480,6 +402,12 @@ async function readLatestMessages(fs, messagesPath) {
480
402
  async function appendMessageSnapshot(fs, input, message) {
481
403
  await fs.appendText(getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), `${JSON.stringify(message)}\n`);
482
404
  }
405
+ async function writeMessageSnapshots(fs, messagesPath, messages) {
406
+ const content = messages.length > 0
407
+ ? `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`
408
+ : "";
409
+ await fs.writeText(messagesPath, content);
410
+ }
483
411
  function getMessagesPath(repoRoot, stateRoot, taskSlug) {
484
412
  return path.join(repoRoot, stateRoot, "messages", `${taskSlug}.jsonl`);
485
413
  }
@@ -489,3 +417,9 @@ function getOrchestrationStatePath(repoRoot, stateRoot, taskSlug) {
489
417
  function getStateRepoRoot(input) {
490
418
  return input.stateRepoRoot ?? input.repoRoot;
491
419
  }
420
+ function delay(ms) {
421
+ if (ms <= 0) {
422
+ return Promise.resolve();
423
+ }
424
+ return new Promise((resolve) => setTimeout(resolve, ms));
425
+ }
@@ -4,21 +4,15 @@ export function createRoundService(deps = {}) {
4
4
  return {
5
5
  getTaskRoundState(input) {
6
6
  const roleStates = ROLE_NAMES.map((role) => toRoleTurnState(role, input.sessions));
7
- const latestDelivered = getLatestDeliveredMessage(input.messages);
8
- const queuedMessageCount = input.messages.filter((message) => message.status === "queued").length;
9
- const pendingMessageCount = input.messages.filter((message) => message.status === "pending_approval").length;
10
7
  const state = evaluateTaskRoundState({
11
8
  taskSlug: input.taskSlug,
12
- messages: input.messages,
9
+ pendingRouteCount: input.pendingRouteCount,
13
10
  roleStates,
14
11
  updatedAt: now()
15
12
  });
16
13
  return {
17
14
  ...state,
18
- latestMessageId: latestDelivered?.id,
19
- latestMessageDeliveredAt: getMessageDeliveredAt(latestDelivered),
20
- queuedMessageCount,
21
- pendingMessageCount
15
+ pendingRouteCount: input.pendingRouteCount
22
16
  };
23
17
  },
24
18
  stopSession() { },
@@ -26,35 +20,22 @@ export function createRoundService(deps = {}) {
26
20
  };
27
21
  }
28
22
  export function evaluateTaskRoundState(input) {
29
- const latestDelivered = getLatestDeliveredMessage(input.messages);
30
- const hasQueuedOrPending = input.messages.some((message) => message.status === "queued" || message.status === "pending_approval");
31
- if (latestDelivered) {
32
- const deliveredAt = getMessageDeliveredAt(latestDelivered);
33
- const targetState = input.roleStates.find((roleState) => roleState.role === latestDelivered.toRole);
34
- const completedAt = targetState && deliveredAt
35
- ? getCompletedAtForDelivery(targetState, deliveredAt)
36
- : undefined;
37
- if (completedAt && !hasQueuedOrPending) {
38
- return {
39
- taskSlug: input.taskSlug,
40
- status: "completed",
41
- activeRole: latestDelivered.toRole,
42
- completionId: `${latestDelivered.id}:${completedAt}`,
43
- completedAt,
44
- roles: input.roleStates,
45
- updatedAt: input.updatedAt
46
- };
47
- }
23
+ const activeRole = input.roleStates.find((roleState) => roleState.status === "answering" ||
24
+ roleState.status === "using_tools" ||
25
+ roleState.status === "waiting_user" ||
26
+ roleState.status === "abnormal");
27
+ if (activeRole) {
48
28
  return {
49
29
  taskSlug: input.taskSlug,
50
- status: targetState?.status === "waiting_user" ? "waiting_user" : "active",
51
- activeRole: latestDelivered.toRole,
30
+ status: activeRole.status === "waiting_user" ? "waiting_user" : "active",
31
+ activeRole: activeRole.role,
52
32
  roles: input.roleStates,
53
33
  updatedAt: input.updatedAt
54
34
  };
55
35
  }
56
- const latestIdleRole = !hasQueuedOrPending ? getLatestIdleRole(input.roleStates) : undefined;
57
- if (latestIdleRole?.lastAnswerEndedAt) {
36
+ const latestIdleRole = getLatestIdleRole(input.roleStates);
37
+ const hasPendingRoutes = input.pendingRouteCount > 0;
38
+ if (latestIdleRole?.lastAnswerEndedAt && !hasPendingRoutes) {
58
39
  return {
59
40
  taskSlug: input.taskSlug,
60
41
  status: "completed",
@@ -65,14 +46,10 @@ export function evaluateTaskRoundState(input) {
65
46
  updatedAt: input.updatedAt
66
47
  };
67
48
  }
68
- const activeRole = input.roleStates.find((roleState) => roleState.status === "answering" ||
69
- roleState.status === "using_tools" ||
70
- roleState.status === "waiting_user" ||
71
- roleState.status === "abnormal");
72
49
  return {
73
50
  taskSlug: input.taskSlug,
74
- status: activeRole?.status === "waiting_user" ? "waiting_user" : activeRole ? "active" : "idle",
75
- activeRole: activeRole?.role,
51
+ status: hasPendingRoutes ? "active" : "idle",
52
+ activeRole: latestIdleRole?.role,
76
53
  roles: input.roleStates,
77
54
  updatedAt: input.updatedAt
78
55
  };
@@ -94,21 +71,6 @@ function toRoleTurnState(role, sessions) {
94
71
  : undefined
95
72
  };
96
73
  }
97
- function getLatestDeliveredMessage(messages) {
98
- return messages
99
- .filter((message) => message.status === "delivering" || message.status === "submitted")
100
- .sort((left, right) => getMessageDeliveredAt(left).localeCompare(getMessageDeliveredAt(right)))
101
- .at(-1);
102
- }
103
- function getMessageDeliveredAt(message) {
104
- return message?.submittedAt ?? message?.deliveredAt ?? message?.createdAt ?? "";
105
- }
106
- function getCompletedAtForDelivery(roleState, deliveredAt) {
107
- if (roleState.status !== "idle" || !roleState.lastAnswerEndedAt) {
108
- return undefined;
109
- }
110
- return roleState.lastAnswerEndedAt >= deliveredAt ? roleState.lastAnswerEndedAt : undefined;
111
- }
112
74
  function getLatestIdleRole(roleStates) {
113
75
  return roleStates
114
76
  .filter((roleState) => roleState.status === "idle" && roleState.lastAnswerEndedAt)
@@ -3,6 +3,7 @@ export function renderProjectManagerHarnessRules() {
3
3
 
4
4
  - You are the user-facing orchestration hub for the VCM task.
5
5
  - Clarify the user's request, classify task risk, and choose the role route.
6
+ - VCM orchestration is strictly sequential. Do not parallelize work across multiple roles or dispatch messages to multiple target roles at once.
6
7
  - Assign work by writing or updating .ai/vcm/handoffs/messages/project-manager-architect.md, project-manager-coder.md, or project-manager-reviewer.md.
7
8
  - Send role work as durable instructions with optional YAML frontmatter, for example type: task and artifact_refs: .ai/vcm/handoffs/architecture-plan.md.
8
9
  - Enforce per-role turn-taking: keep at most one in-flight message per target role.