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