vibe-coding-master 0.0.14 → 0.0.16
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 +32 -34
- package/dist/backend/api/claude-hook-routes.js +3 -0
- package/dist/backend/api/message-routes.js +8 -40
- package/dist/backend/server.js +1 -23
- package/dist/backend/services/artifact-service.js +27 -2
- package/dist/backend/services/claude-hook-service.js +108 -49
- package/dist/backend/services/harness-service.js +18 -1
- package/dist/backend/services/message-service.js +307 -210
- package/dist/backend/services/round-service.js +2 -2
- package/dist/backend/services/session-service.js +25 -9
- package/dist/backend/templates/handoff.js +3 -0
- package/dist/backend/templates/harness/architect-agent.js +4 -3
- package/dist/backend/templates/harness/claude-root.js +5 -4
- package/dist/backend/templates/harness/coder-agent.js +4 -3
- package/dist/backend/templates/harness/project-manager-agent.js +5 -5
- package/dist/backend/templates/harness/reviewer-agent.js +4 -3
- package/dist/backend/templates/message-envelope.js +7 -3
- package/dist-frontend/assets/{index-DVhkEVnA.js → index-CvtyKEfS.js} +44 -44
- package/dist-frontend/index.html +1 -1
- package/docs/cc-best-practices.md +10 -9
- package/docs/product-design.md +64 -35
- package/docs/v1-architecture-design.md +31 -29
- package/docs/v1-implementation-plan.md +71 -50
- package/package.json +2 -3
- package/scripts/verify-package.mjs +0 -3
- package/dist/cli/vcmctl.js +0 -171
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { ROLE_NAMES } from "../../shared/constants.js";
|
|
3
|
+
import { ROLE_NAMES, isRoleName } from "../../shared/constants.js";
|
|
4
4
|
import { VcmError } from "../errors.js";
|
|
5
5
|
import { resolveRepoPath } from "../adapters/filesystem.js";
|
|
6
6
|
import { submitTerminalInput } from "../runtime/terminal-submit.js";
|
|
7
|
-
import {
|
|
7
|
+
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
|
|
11
|
+
const OPEN_MESSAGE_STATUSES = new Set([
|
|
12
12
|
"pending_approval",
|
|
13
13
|
"queued",
|
|
14
|
-
"staged",
|
|
15
14
|
"delivering",
|
|
16
15
|
"submitted",
|
|
17
|
-
"
|
|
16
|
+
"failed",
|
|
17
|
+
"delivery_failed",
|
|
18
|
+
"response_ready_missing_result"
|
|
19
|
+
]);
|
|
20
|
+
const MUTABLE_ROUTE_MESSAGE_STATUSES = new Set([
|
|
21
|
+
"pending_approval",
|
|
22
|
+
"queued",
|
|
18
23
|
"failed",
|
|
19
24
|
"delivery_failed",
|
|
20
25
|
"response_ready_missing_result"
|
|
@@ -29,153 +34,168 @@ export function createMessageService(deps) {
|
|
|
29
34
|
return {
|
|
30
35
|
taskSlug: input.taskSlug,
|
|
31
36
|
mode: "manual",
|
|
32
|
-
paused: false,
|
|
33
37
|
updatedAt: now()
|
|
34
38
|
};
|
|
35
39
|
}
|
|
36
|
-
|
|
40
|
+
const state = await deps.fs.readJson(statePath);
|
|
41
|
+
return {
|
|
42
|
+
taskSlug: state.taskSlug,
|
|
43
|
+
mode: state.mode,
|
|
44
|
+
updatedAt: state.updatedAt
|
|
45
|
+
};
|
|
37
46
|
}
|
|
38
|
-
async function
|
|
47
|
+
async function scanLocked(input) {
|
|
39
48
|
await deps.taskService.loadTask(input.repoRoot, input.taskSlug);
|
|
40
|
-
validateMessagePolicy(input.fromRole, input.toRole, input.type);
|
|
41
49
|
const timestamp = now();
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
createdAt: timestamp
|
|
53
|
-
};
|
|
54
|
-
message.bodyPath = await writeMessageBody(deps.fs, input.taskRepoRoot ?? input.repoRoot, input.handoffDir, message);
|
|
55
|
-
let messages = await readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
|
|
56
|
-
if (isRoleActor(input.fromRole)) {
|
|
57
|
-
const acknowledged = acknowledgeActiveMessages(messages, input.fromRole, timestamp);
|
|
58
|
-
for (const acknowledgedMessage of acknowledged) {
|
|
59
|
-
await appendMessageSnapshot(deps.fs, input, acknowledgedMessage);
|
|
50
|
+
const base = toRouteContext(input);
|
|
51
|
+
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
60
|
}
|
|
61
|
+
updates.push(...acknowledged);
|
|
61
62
|
messages = applyMessageSnapshots(messages, acknowledged);
|
|
62
63
|
}
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
const pendingFiles = (await listRouteFiles(deps.fs, base)).filter((routeFile) => routeFile.pending);
|
|
65
|
+
const candidates = selectDispatchCandidates(pendingFiles, input.stoppedRole);
|
|
66
|
+
const results = [];
|
|
67
|
+
const deliveredTargets = new Set();
|
|
68
|
+
for (const routeFile of candidates) {
|
|
69
|
+
if (deliveredTargets.has(routeFile.toRole)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const result = await dispatchRouteFile(input, routeFile, messages, state, timestamp);
|
|
73
|
+
results.push(result);
|
|
74
|
+
await appendMessageSnapshot(deps.fs, input, result.message);
|
|
75
|
+
messages = applyMessageSnapshots(messages, [result.message]);
|
|
76
|
+
if (result.delivered) {
|
|
77
|
+
deliveredTargets.add(routeFile.toRole);
|
|
78
|
+
}
|
|
68
79
|
}
|
|
69
|
-
return
|
|
80
|
+
return results;
|
|
81
|
+
}
|
|
82
|
+
async function listPendingRouteFiles(input) {
|
|
83
|
+
const context = toRouteContext(input);
|
|
84
|
+
const all = await listRouteFiles(deps.fs, context);
|
|
85
|
+
return all.filter((routeFile) => routeFile.pending);
|
|
70
86
|
}
|
|
71
|
-
async function
|
|
72
|
-
|
|
87
|
+
async function dispatchRouteFile(input, routeFile, existingMessages, state, timestamp) {
|
|
88
|
+
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
|
+
const session = await deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, routeFile.toRole);
|
|
73
108
|
if (!session || session.status !== "running") {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
109
|
+
return {
|
|
110
|
+
message: {
|
|
111
|
+
...message,
|
|
112
|
+
status: "queued",
|
|
113
|
+
failureReason: `${routeFile.toRole} session is not running.`
|
|
114
|
+
},
|
|
115
|
+
delivered: false,
|
|
116
|
+
requiresUserApproval: false,
|
|
117
|
+
clearedRouteFile: false
|
|
78
118
|
};
|
|
79
|
-
return { message: queued, delivered: false, requiresUserApproval: false };
|
|
80
119
|
}
|
|
81
120
|
if (state.mode === "manual") {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
...message,
|
|
91
|
-
status: "queued",
|
|
92
|
-
failureReason: "Auto orchestration is paused."
|
|
121
|
+
return {
|
|
122
|
+
message: {
|
|
123
|
+
...message,
|
|
124
|
+
status: "pending_approval"
|
|
125
|
+
},
|
|
126
|
+
delivered: false,
|
|
127
|
+
requiresUserApproval: true,
|
|
128
|
+
clearedRouteFile: false
|
|
93
129
|
};
|
|
94
|
-
return { message: queued, delivered: false, requiresUserApproval: false };
|
|
95
130
|
}
|
|
96
|
-
const activeMessage = findActiveMessageForRole(existingMessages,
|
|
131
|
+
const activeMessage = findActiveMessageForRole(existingMessages, routeFile.toRole);
|
|
97
132
|
if (activeMessage) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
133
|
+
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
|
+
delivered: false,
|
|
141
|
+
requiresUserApproval: false,
|
|
142
|
+
clearedRouteFile: false
|
|
103
143
|
};
|
|
104
|
-
return { message: queued, delivered: false, requiresUserApproval: false };
|
|
105
144
|
}
|
|
106
|
-
const delivered = await deliverMessageToSession(session.id, message, timestamp);
|
|
107
|
-
return { message: delivered, delivered: true, requiresUserApproval: false };
|
|
108
|
-
}
|
|
109
|
-
async function deliverNextQueuedMessage(input, targetRole) {
|
|
110
|
-
const state = await getOrchestrationState(input);
|
|
111
|
-
if (state.mode !== "auto" || state.paused) {
|
|
112
|
-
return undefined;
|
|
113
|
-
}
|
|
114
|
-
const messages = await readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
|
|
115
|
-
if (findActiveMessageForRole(messages, targetRole)) {
|
|
116
|
-
return undefined;
|
|
117
|
-
}
|
|
118
|
-
const nextMessage = messages.find((message) => message.toRole === targetRole && message.status === "queued");
|
|
119
|
-
if (!nextMessage) {
|
|
120
|
-
return undefined;
|
|
121
|
-
}
|
|
122
|
-
const session = await deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, targetRole);
|
|
123
|
-
if (!session || session.status !== "running") {
|
|
124
|
-
return undefined;
|
|
125
|
-
}
|
|
126
|
-
const delivered = await deliverMessageToSession(session.id, nextMessage, now());
|
|
127
|
-
await appendMessageSnapshot(deps.fs, input, delivered);
|
|
128
|
-
return delivered;
|
|
129
|
-
}
|
|
130
|
-
async function deliverMessageToSession(sessionId, message, timestamp) {
|
|
131
145
|
const delivering = {
|
|
132
146
|
...message,
|
|
133
147
|
status: "delivering",
|
|
134
|
-
deliveredAt: timestamp
|
|
135
|
-
|
|
136
|
-
|
|
148
|
+
deliveredAt: timestamp
|
|
149
|
+
};
|
|
150
|
+
await submitTerminalInput(deps.runtime, session.id, renderMessageEnvelope(delivering));
|
|
151
|
+
await deps.sessionService.markRoleActivityRunning(input.repoRoot, input.taskSlug, routeFile.toRole);
|
|
152
|
+
return {
|
|
153
|
+
message: delivering,
|
|
154
|
+
delivered: true,
|
|
155
|
+
requiresUserApproval: false,
|
|
156
|
+
clearedRouteFile: false
|
|
137
157
|
};
|
|
138
|
-
await submitTerminalInput(deps.runtime, sessionId, renderMessageEnvelope(delivering));
|
|
139
|
-
return delivering;
|
|
140
158
|
}
|
|
141
159
|
return {
|
|
142
160
|
listMessages(input) {
|
|
143
161
|
return readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
|
|
144
162
|
},
|
|
145
|
-
|
|
146
|
-
return
|
|
163
|
+
listPendingRouteFiles(input) {
|
|
164
|
+
return listPendingRouteFiles(input);
|
|
165
|
+
},
|
|
166
|
+
async scanAndDispatchPendingRouteFiles(input) {
|
|
167
|
+
return withTaskLock(taskLocks, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), () => scanLocked(input));
|
|
147
168
|
},
|
|
148
169
|
async confirmPromptSubmitted(input) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return submitted;
|
|
170
|
+
return withTaskLock(taskLocks, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), async () => {
|
|
171
|
+
const timestamp = now();
|
|
172
|
+
const messages = await readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
|
|
173
|
+
const messageId = extractVcmMessageId(input.prompt);
|
|
174
|
+
if (!messageId) {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
const message = findDeliveringMessageForPrompt(messages, input.role, messageId);
|
|
178
|
+
if (!message) {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
const submitted = {
|
|
182
|
+
...message,
|
|
183
|
+
status: "submitted",
|
|
184
|
+
submittedAt: timestamp,
|
|
185
|
+
queuedBehindMessageId: undefined,
|
|
186
|
+
failureReason: undefined
|
|
187
|
+
};
|
|
188
|
+
await appendMessageSnapshot(deps.fs, input, submitted);
|
|
189
|
+
await clearRouteFileIfStillMatchesMessage(deps.fs, input, submitted);
|
|
190
|
+
return submitted;
|
|
191
|
+
});
|
|
172
192
|
},
|
|
173
193
|
async markAllDone(input) {
|
|
174
194
|
return withTaskLock(taskLocks, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), async () => {
|
|
175
195
|
const timestamp = now();
|
|
176
196
|
const messages = await readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
|
|
177
197
|
const updated = messages
|
|
178
|
-
.filter((message) =>
|
|
198
|
+
.filter((message) => OPEN_MESSAGE_STATUSES.has(message.status))
|
|
179
199
|
.map((message) => ({
|
|
180
200
|
...message,
|
|
181
201
|
status: "acknowledged",
|
|
@@ -186,6 +206,11 @@ export function createMessageService(deps) {
|
|
|
186
206
|
for (const message of updated) {
|
|
187
207
|
await appendMessageSnapshot(deps.fs, input, message);
|
|
188
208
|
}
|
|
209
|
+
if (input.clearRouteFiles) {
|
|
210
|
+
for (const routeFile of await listPendingRouteFiles(input)) {
|
|
211
|
+
await deps.fs.writeText(resolveRepoPath(input.taskRepoRoot ?? input.repoRoot, routeFile.path), "");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
189
214
|
const latest = applyMessageSnapshots(messages, updated);
|
|
190
215
|
return {
|
|
191
216
|
taskSlug: input.taskSlug,
|
|
@@ -194,40 +219,6 @@ export function createMessageService(deps) {
|
|
|
194
219
|
};
|
|
195
220
|
});
|
|
196
221
|
},
|
|
197
|
-
async stageMessage(input) {
|
|
198
|
-
const message = await getMessageOrThrow(deps.fs, input);
|
|
199
|
-
const session = await deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, message.toRole);
|
|
200
|
-
if (!session || session.status !== "running") {
|
|
201
|
-
throw new VcmError({
|
|
202
|
-
code: "MESSAGE_TARGET_NOT_RUNNING",
|
|
203
|
-
message: `${message.toRole} session is not running.`,
|
|
204
|
-
statusCode: 409,
|
|
205
|
-
hint: `Start the ${message.toRole} session before staging this message.`
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
const staged = {
|
|
209
|
-
...message,
|
|
210
|
-
status: "staged",
|
|
211
|
-
stagedAt: now(),
|
|
212
|
-
failureReason: undefined
|
|
213
|
-
};
|
|
214
|
-
deps.runtime.write(session.id, renderManualStagePrompt(staged));
|
|
215
|
-
await appendMessageSnapshot(deps.fs, input, staged);
|
|
216
|
-
return staged;
|
|
217
|
-
},
|
|
218
|
-
approveMessage(input) {
|
|
219
|
-
return this.stageMessage(input);
|
|
220
|
-
},
|
|
221
|
-
async rejectMessage(input) {
|
|
222
|
-
const message = await getMessageOrThrow(deps.fs, input);
|
|
223
|
-
const rejected = {
|
|
224
|
-
...message,
|
|
225
|
-
status: "rejected",
|
|
226
|
-
failureReason: undefined
|
|
227
|
-
};
|
|
228
|
-
await appendMessageSnapshot(deps.fs, input, rejected);
|
|
229
|
-
return rejected;
|
|
230
|
-
},
|
|
231
222
|
async getOrchestrationState(input) {
|
|
232
223
|
return getOrchestrationState(input);
|
|
233
224
|
},
|
|
@@ -236,7 +227,6 @@ export function createMessageService(deps) {
|
|
|
236
227
|
const next = {
|
|
237
228
|
...current,
|
|
238
229
|
mode: input.mode ?? current.mode,
|
|
239
|
-
paused: input.paused ?? current.paused,
|
|
240
230
|
updatedAt: now()
|
|
241
231
|
};
|
|
242
232
|
await deps.fs.writeJsonAtomic(getOrchestrationStatePath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), next);
|
|
@@ -244,6 +234,132 @@ export function createMessageService(deps) {
|
|
|
244
234
|
}
|
|
245
235
|
};
|
|
246
236
|
}
|
|
237
|
+
function toRouteContext(input) {
|
|
238
|
+
return {
|
|
239
|
+
repoRoot: input.repoRoot,
|
|
240
|
+
taskRepoRoot: input.taskRepoRoot,
|
|
241
|
+
handoffDir: input.handoffDir
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
async function listRouteFiles(fs, input) {
|
|
245
|
+
const repoRoot = input.taskRepoRoot ?? input.repoRoot;
|
|
246
|
+
const routeDir = path.posix.join(input.handoffDir, "messages");
|
|
247
|
+
const absoluteMessagesDir = resolveRepoPath(repoRoot, routeDir);
|
|
248
|
+
if (!(await fs.pathExists(absoluteMessagesDir))) {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
const entries = await fs.readDir(absoluteMessagesDir);
|
|
252
|
+
const routeFiles = [];
|
|
253
|
+
for (const entry of entries.filter((candidate) => candidate.endsWith(".md")).sort()) {
|
|
254
|
+
const route = parseRouteFileName(entry);
|
|
255
|
+
if (!route) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const relativePath = path.posix.join(routeDir, entry);
|
|
259
|
+
const content = await fs.readText(resolveRepoPath(repoRoot, relativePath));
|
|
260
|
+
const parsed = parseRouteFileContent(content, route.fromRole, route.toRole);
|
|
261
|
+
routeFiles.push({
|
|
262
|
+
path: relativePath,
|
|
263
|
+
fromRole: route.fromRole,
|
|
264
|
+
toRole: route.toRole,
|
|
265
|
+
type: parsed.type,
|
|
266
|
+
body: parsed.body,
|
|
267
|
+
artifactRefs: parsed.artifactRefs,
|
|
268
|
+
exists: true,
|
|
269
|
+
pending: parsed.body.trim().length > 0
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return routeFiles;
|
|
273
|
+
}
|
|
274
|
+
function parseRouteFileName(fileName) {
|
|
275
|
+
for (const fromRole of ROLE_NAMES) {
|
|
276
|
+
for (const toRole of ROLE_NAMES) {
|
|
277
|
+
if (fromRole === toRole) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (fileName === `${fromRole}-${toRole}.md`) {
|
|
281
|
+
return { fromRole, toRole };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
function parseRouteFileContent(content, fromRole, toRole) {
|
|
288
|
+
const { frontmatter, body } = splitFrontmatter(content);
|
|
289
|
+
const type = parseMessageType(frontmatter.type) ?? getDefaultMessageType(fromRole, toRole);
|
|
290
|
+
const artifactRefs = parseArtifactRefs(frontmatter);
|
|
291
|
+
return {
|
|
292
|
+
type,
|
|
293
|
+
body: body.trim(),
|
|
294
|
+
artifactRefs
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function splitFrontmatter(content) {
|
|
298
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
299
|
+
if (!match) {
|
|
300
|
+
return { frontmatter: {}, body: content };
|
|
301
|
+
}
|
|
302
|
+
const frontmatter = {};
|
|
303
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
304
|
+
const delimiter = line.indexOf(":");
|
|
305
|
+
if (delimiter <= 0) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const key = line.slice(0, delimiter).trim();
|
|
309
|
+
const value = line.slice(delimiter + 1).trim();
|
|
310
|
+
if (key) {
|
|
311
|
+
frontmatter[key] = value;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
frontmatter,
|
|
316
|
+
body: content.slice(match[0].length)
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function parseMessageType(value) {
|
|
320
|
+
const validTypes = [
|
|
321
|
+
"user-request",
|
|
322
|
+
"task",
|
|
323
|
+
"question",
|
|
324
|
+
"blocked",
|
|
325
|
+
"result",
|
|
326
|
+
"finding",
|
|
327
|
+
"review-request",
|
|
328
|
+
"revise",
|
|
329
|
+
"cancel"
|
|
330
|
+
];
|
|
331
|
+
return value && validTypes.includes(value)
|
|
332
|
+
? value
|
|
333
|
+
: undefined;
|
|
334
|
+
}
|
|
335
|
+
function parseArtifactRefs(frontmatter) {
|
|
336
|
+
const refs = frontmatter.artifact_refs ?? frontmatter.artifactRefs ?? frontmatter.related_artifact;
|
|
337
|
+
if (!refs) {
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
return refs
|
|
341
|
+
.split(",")
|
|
342
|
+
.map((entry) => entry.trim())
|
|
343
|
+
.filter(Boolean);
|
|
344
|
+
}
|
|
345
|
+
function getDefaultMessageType(fromRole, toRole) {
|
|
346
|
+
if (fromRole === PM_ROLE && toRole !== PM_ROLE) {
|
|
347
|
+
return "task";
|
|
348
|
+
}
|
|
349
|
+
if (fromRole !== PM_ROLE && toRole === PM_ROLE) {
|
|
350
|
+
return "result";
|
|
351
|
+
}
|
|
352
|
+
return "question";
|
|
353
|
+
}
|
|
354
|
+
function selectDispatchCandidates(routeFiles, stoppedRole) {
|
|
355
|
+
const candidates = stoppedRole
|
|
356
|
+
? routeFiles.filter((routeFile) => routeFile.fromRole === stoppedRole || routeFile.toRole === stoppedRole)
|
|
357
|
+
: routeFiles;
|
|
358
|
+
return [...candidates].sort((left, right) => {
|
|
359
|
+
const updated = (left.updatedAt ?? "").localeCompare(right.updatedAt ?? "");
|
|
360
|
+
return updated !== 0 ? updated : left.path.localeCompare(right.path);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
247
363
|
function validateMessagePolicy(fromRole, toRole, type) {
|
|
248
364
|
if (!ROLE_NAMES.includes(toRole)) {
|
|
249
365
|
throw new VcmError({
|
|
@@ -252,17 +368,7 @@ function validateMessagePolicy(fromRole, toRole, type) {
|
|
|
252
368
|
statusCode: 400
|
|
253
369
|
});
|
|
254
370
|
}
|
|
255
|
-
if (fromRole
|
|
256
|
-
if (toRole === PM_ROLE && type === "user-request") {
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
throw new VcmError({
|
|
260
|
-
code: "MESSAGE_POLICY_DENIED",
|
|
261
|
-
message: "User messages can only target project-manager as user-request.",
|
|
262
|
-
statusCode: 403
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
if (!ROLE_NAMES.includes(fromRole)) {
|
|
371
|
+
if (!isRoleName(String(fromRole))) {
|
|
266
372
|
throw new VcmError({
|
|
267
373
|
code: "MESSAGE_SENDER_UNKNOWN",
|
|
268
374
|
message: `Unknown sender role: ${fromRole}`,
|
|
@@ -279,7 +385,7 @@ function validateMessagePolicy(fromRole, toRole, type) {
|
|
|
279
385
|
code: "MESSAGE_POLICY_DENIED",
|
|
280
386
|
message: `${fromRole} cannot send ${type} messages to ${toRole}.`,
|
|
281
387
|
statusCode: 403,
|
|
282
|
-
hint: "Use project-manager as the orchestration hub."
|
|
388
|
+
hint: "Use project-manager as the orchestration hub unless this task explicitly allows a peer route."
|
|
283
389
|
});
|
|
284
390
|
}
|
|
285
391
|
async function withTaskLock(locks, key, run) {
|
|
@@ -295,31 +401,59 @@ async function withTaskLock(locks, key, run) {
|
|
|
295
401
|
}
|
|
296
402
|
}
|
|
297
403
|
}
|
|
298
|
-
function
|
|
299
|
-
return
|
|
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));
|
|
300
408
|
}
|
|
301
409
|
function findActiveMessageForRole(messages, role) {
|
|
302
|
-
return messages.find((message) => message.toRole === role &&
|
|
410
|
+
return messages.find((message) => message.toRole === role &&
|
|
411
|
+
(message.status === "delivering" || message.status === "submitted"));
|
|
303
412
|
}
|
|
304
413
|
function acknowledgeActiveMessages(messages, role, timestamp) {
|
|
305
414
|
return messages
|
|
306
|
-
.filter((message) => message.toRole === role &&
|
|
415
|
+
.filter((message) => message.toRole === role && (message.status === "delivering" || message.status === "submitted"))
|
|
307
416
|
.map((message) => ({
|
|
308
417
|
...message,
|
|
309
418
|
status: "acknowledged",
|
|
419
|
+
submittedAt: message.submittedAt ?? message.deliveredAt ?? timestamp,
|
|
310
420
|
acknowledgedAt: timestamp,
|
|
311
421
|
failureReason: undefined
|
|
312
422
|
}));
|
|
313
423
|
}
|
|
314
|
-
function
|
|
315
|
-
|
|
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);
|
|
316
429
|
}
|
|
317
|
-
function
|
|
318
|
-
return
|
|
430
|
+
function extractVcmMessageId(prompt) {
|
|
431
|
+
return prompt?.match(/^\s*id:\s*(\S+)\s*$/m)?.[1];
|
|
319
432
|
}
|
|
320
|
-
function
|
|
321
|
-
|
|
322
|
-
|
|
433
|
+
function getMessageDeliveryTime(message) {
|
|
434
|
+
return message.deliveredAt ?? message.createdAt;
|
|
435
|
+
}
|
|
436
|
+
async function clearRouteFileIfStillMatchesMessage(fs, input, message) {
|
|
437
|
+
if (!message.routePath || !isRoleName(String(message.fromRole))) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const absolutePath = resolveRepoPath(input.taskRepoRoot ?? input.repoRoot, message.routePath);
|
|
441
|
+
if (!(await fs.pathExists(absolutePath))) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const routeContent = await fs.readText(absolutePath);
|
|
445
|
+
const parsed = parseRouteFileContent(routeContent, message.fromRole, message.toRole);
|
|
446
|
+
if (parsed.body.trim() === message.body.trim() &&
|
|
447
|
+
parsed.type === message.type &&
|
|
448
|
+
arraysEqual(parsed.artifactRefs, message.artifactRefs)) {
|
|
449
|
+
await fs.writeText(absolutePath, "");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function arraysEqual(left, right) {
|
|
453
|
+
if (left.length !== right.length) {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
return left.every((value, index) => value === right[index]);
|
|
323
457
|
}
|
|
324
458
|
function applyMessageSnapshots(messages, snapshots) {
|
|
325
459
|
if (snapshots.length === 0) {
|
|
@@ -331,43 +465,6 @@ function applyMessageSnapshots(messages, snapshots) {
|
|
|
331
465
|
}
|
|
332
466
|
return [...latest.values()].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
333
467
|
}
|
|
334
|
-
async function writeMessageBody(fs, repoRoot, handoffDir, message) {
|
|
335
|
-
const bodyPath = path.posix.join(handoffDir, "messages", `${message.id}.md`);
|
|
336
|
-
await fs.writeText(resolveRepoPath(repoRoot, bodyPath), renderMessageBodyFile(message));
|
|
337
|
-
return bodyPath;
|
|
338
|
-
}
|
|
339
|
-
function renderMessageBodyFile(message) {
|
|
340
|
-
const artifactRefs = message.artifactRefs.length > 0
|
|
341
|
-
? message.artifactRefs.map((artifact) => `- ${artifact}`).join("\n")
|
|
342
|
-
: "- none";
|
|
343
|
-
return `# VCM Message ${message.id}
|
|
344
|
-
|
|
345
|
-
- Task: ${message.taskSlug}
|
|
346
|
-
- From: ${message.fromRole}
|
|
347
|
-
- To: ${message.toRole}
|
|
348
|
-
- Type: ${message.type}
|
|
349
|
-
|
|
350
|
-
## Body
|
|
351
|
-
|
|
352
|
-
${message.body}
|
|
353
|
-
|
|
354
|
-
## Artifact Refs
|
|
355
|
-
|
|
356
|
-
${artifactRefs}
|
|
357
|
-
`;
|
|
358
|
-
}
|
|
359
|
-
async function getMessageOrThrow(fs, input) {
|
|
360
|
-
const messages = await readLatestMessages(fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
|
|
361
|
-
const message = messages.find((candidate) => candidate.id === input.messageId);
|
|
362
|
-
if (!message) {
|
|
363
|
-
throw new VcmError({
|
|
364
|
-
code: "MESSAGE_MISSING",
|
|
365
|
-
message: `Message does not exist: ${input.messageId}`,
|
|
366
|
-
statusCode: 404
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
return message;
|
|
370
|
-
}
|
|
371
468
|
async function readLatestMessages(fs, messagesPath) {
|
|
372
469
|
if (!(await fs.pathExists(messagesPath))) {
|
|
373
470
|
return [];
|
|
@@ -96,12 +96,12 @@ function toRoleTurnState(role, sessions) {
|
|
|
96
96
|
}
|
|
97
97
|
function getLatestDeliveredMessage(messages) {
|
|
98
98
|
return messages
|
|
99
|
-
.filter((message) => message.status === "delivering" || message.status === "submitted"
|
|
99
|
+
.filter((message) => message.status === "delivering" || message.status === "submitted")
|
|
100
100
|
.sort((left, right) => getMessageDeliveredAt(left).localeCompare(getMessageDeliveredAt(right)))
|
|
101
101
|
.at(-1);
|
|
102
102
|
}
|
|
103
103
|
function getMessageDeliveredAt(message) {
|
|
104
|
-
return message?.submittedAt ?? message?.deliveredAt ?? message?.
|
|
104
|
+
return message?.submittedAt ?? message?.deliveredAt ?? message?.createdAt ?? "";
|
|
105
105
|
}
|
|
106
106
|
function getCompletedAtForDelivery(roleState, deliveredAt) {
|
|
107
107
|
if (roleState.status !== "idle" || !roleState.lastAnswerEndedAt) {
|