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 +18 -14
- package/dist/backend/api/message-routes.js +4 -4
- package/dist/backend/api/round-routes.js +4 -2
- package/dist/backend/services/claude-hook-service.js +1 -1
- package/dist/backend/services/message-service.js +71 -137
- package/dist/backend/services/round-service.js +14 -52
- package/dist/backend/templates/harness/project-manager-agent.js +1 -0
- package/dist-frontend/assets/index-D40qaonx.css +32 -0
- package/dist-frontend/assets/{index-CvtyKEfS.js → index-DK2F4LFT.js} +42 -41
- package/dist-frontend/index.html +2 -2
- package/docs/cc-best-practices.md +1 -0
- package/docs/product-design.md +23 -20
- package/docs/v1-architecture-design.md +23 -18
- package/docs/v1-implementation-plan.md +22 -19
- package/package.json +1 -1
- package/dist-frontend/assets/index-jEkUTnIY.css +0 -32
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
|
|
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
|
|
280
|
-
->
|
|
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
|
|
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,
|
|
313
|
-
- `Mark All Done`
|
|
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
|
|
323
|
-
-
|
|
324
|
-
-
|
|
325
|
-
-
|
|
326
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
21
|
+
pendingRouteCount: pendingRoutes.length
|
|
20
22
|
});
|
|
21
23
|
});
|
|
22
24
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
44
|
+
if (result.message) {
|
|
45
|
+
await appendMessageSnapshot(deps.fs, input, result.message);
|
|
46
|
+
}
|
|
76
47
|
if (result.delivered) {
|
|
77
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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(
|
|
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:
|
|
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 =
|
|
131
|
+
const message = findDeliveredMessageForPrompt(messages, input.role, messageId);
|
|
178
132
|
if (!message) {
|
|
179
133
|
return undefined;
|
|
180
134
|
}
|
|
181
|
-
const
|
|
135
|
+
const accepted = {
|
|
182
136
|
...message,
|
|
183
|
-
|
|
184
|
-
submittedAt: timestamp,
|
|
185
|
-
queuedBehindMessageId: undefined,
|
|
137
|
+
acceptedAt: timestamp,
|
|
186
138
|
failureReason: undefined
|
|
187
139
|
};
|
|
188
|
-
await appendMessageSnapshot(deps.fs, input,
|
|
189
|
-
await clearRouteFileIfStillMatchesMessage(deps.fs, input,
|
|
190
|
-
return
|
|
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
|
-
|
|
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:
|
|
218
|
-
messages
|
|
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
|
|
405
|
-
|
|
406
|
-
.
|
|
407
|
-
|
|
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
|
-
|
|
9
|
+
pendingRouteCount: input.pendingRouteCount,
|
|
13
10
|
roleStates,
|
|
14
11
|
updatedAt: now()
|
|
15
12
|
});
|
|
16
13
|
return {
|
|
17
14
|
...state,
|
|
18
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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:
|
|
51
|
-
activeRole:
|
|
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 =
|
|
57
|
-
|
|
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:
|
|
75
|
-
activeRole:
|
|
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.
|