vibe-coding-master 0.0.14 → 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 +28 -32
- package/dist/backend/api/claude-hook-routes.js +2 -2
- 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 +19 -22
- package/dist/backend/services/harness-service.js +20 -3
- package/dist/backend/services/message-service.js +254 -216
- package/dist/backend/services/round-service.js +2 -2
- package/dist/backend/services/session-service.js +23 -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-QhzvzTMZ.js} +41 -41
- package/dist-frontend/index.html +1 -1
- package/docs/cc-best-practices.md +10 -9
- package/docs/product-design.md +61 -33
- package/docs/v1-architecture-design.md +29 -29
- package/docs/v1-implementation-plan.md +71 -51
- package/package.json +2 -3
- package/scripts/verify-package.mjs +0 -3
- package/dist/cli/vcmctl.js +0 -171
|
@@ -1,20 +1,24 @@
|
|
|
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
|
-
"delivering",
|
|
16
14
|
"submitted",
|
|
17
|
-
"
|
|
15
|
+
"failed",
|
|
16
|
+
"delivery_failed",
|
|
17
|
+
"response_ready_missing_result"
|
|
18
|
+
]);
|
|
19
|
+
const MUTABLE_ROUTE_MESSAGE_STATUSES = new Set([
|
|
20
|
+
"pending_approval",
|
|
21
|
+
"queued",
|
|
18
22
|
"failed",
|
|
19
23
|
"delivery_failed",
|
|
20
24
|
"response_ready_missing_result"
|
|
@@ -29,153 +33,145 @@ export function createMessageService(deps) {
|
|
|
29
33
|
return {
|
|
30
34
|
taskSlug: input.taskSlug,
|
|
31
35
|
mode: "manual",
|
|
32
|
-
paused: false,
|
|
33
36
|
updatedAt: now()
|
|
34
37
|
};
|
|
35
38
|
}
|
|
36
|
-
|
|
39
|
+
const state = await deps.fs.readJson(statePath);
|
|
40
|
+
return {
|
|
41
|
+
taskSlug: state.taskSlug,
|
|
42
|
+
mode: state.mode,
|
|
43
|
+
updatedAt: state.updatedAt
|
|
44
|
+
};
|
|
37
45
|
}
|
|
38
|
-
async function
|
|
46
|
+
async function scanLocked(input) {
|
|
39
47
|
await deps.taskService.loadTask(input.repoRoot, input.taskSlug);
|
|
40
|
-
validateMessagePolicy(input.fromRole, input.toRole, input.type);
|
|
41
48
|
const timestamp = now();
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
status: "queued",
|
|
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);
|
|
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);
|
|
60
58
|
}
|
|
59
|
+
updates.push(...acknowledged);
|
|
61
60
|
messages = applyMessageSnapshots(messages, acknowledged);
|
|
62
61
|
}
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
}
|
|
68
77
|
}
|
|
69
|
-
return
|
|
78
|
+
return results;
|
|
79
|
+
}
|
|
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);
|
|
70
84
|
}
|
|
71
|
-
async function
|
|
72
|
-
|
|
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);
|
|
73
106
|
if (!session || session.status !== "running") {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
78
116
|
};
|
|
79
|
-
return { message: queued, delivered: false, requiresUserApproval: false };
|
|
80
117
|
}
|
|
81
118
|
if (state.mode === "manual") {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
...message,
|
|
91
|
-
status: "queued",
|
|
92
|
-
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
|
|
93
127
|
};
|
|
94
|
-
return { message: queued, delivered: false, requiresUserApproval: false };
|
|
95
128
|
}
|
|
96
|
-
const activeMessage = findActiveMessageForRole(existingMessages,
|
|
129
|
+
const activeMessage = findActiveMessageForRole(existingMessages, routeFile.toRole);
|
|
97
130
|
if (activeMessage) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
103
141
|
};
|
|
104
|
-
return { message: queued, delivered: false, requiresUserApproval: false };
|
|
105
|
-
}
|
|
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
142
|
}
|
|
122
|
-
const
|
|
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
|
-
const delivering = {
|
|
143
|
+
const submitted = {
|
|
132
144
|
...message,
|
|
133
|
-
status: "
|
|
145
|
+
status: "submitted",
|
|
134
146
|
deliveredAt: timestamp,
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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);
|
|
147
165
|
},
|
|
148
|
-
async
|
|
149
|
-
|
|
150
|
-
if (!messageId) {
|
|
151
|
-
return undefined;
|
|
152
|
-
}
|
|
153
|
-
const messages = await readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
|
|
154
|
-
const message = messages.find((candidate) => candidate.id === messageId && candidate.toRole === input.role);
|
|
155
|
-
if (!message) {
|
|
156
|
-
return undefined;
|
|
157
|
-
}
|
|
158
|
-
if (message.status === "submitted" || message.status === "acknowledged") {
|
|
159
|
-
return message;
|
|
160
|
-
}
|
|
161
|
-
if (!isSubmittableMessageStatus(message.status)) {
|
|
162
|
-
return undefined;
|
|
163
|
-
}
|
|
164
|
-
const submitted = {
|
|
165
|
-
...message,
|
|
166
|
-
status: "submitted",
|
|
167
|
-
submittedAt: now(),
|
|
168
|
-
failureReason: undefined
|
|
169
|
-
};
|
|
170
|
-
await appendMessageSnapshot(deps.fs, input, submitted);
|
|
171
|
-
return submitted;
|
|
166
|
+
async scanAndDispatchPendingRouteFiles(input) {
|
|
167
|
+
return withTaskLock(taskLocks, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), () => scanLocked(input));
|
|
172
168
|
},
|
|
173
169
|
async markAllDone(input) {
|
|
174
170
|
return withTaskLock(taskLocks, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), async () => {
|
|
175
171
|
const timestamp = now();
|
|
176
172
|
const messages = await readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
|
|
177
173
|
const updated = messages
|
|
178
|
-
.filter((message) =>
|
|
174
|
+
.filter((message) => OPEN_MESSAGE_STATUSES.has(message.status))
|
|
179
175
|
.map((message) => ({
|
|
180
176
|
...message,
|
|
181
177
|
status: "acknowledged",
|
|
@@ -186,6 +182,11 @@ export function createMessageService(deps) {
|
|
|
186
182
|
for (const message of updated) {
|
|
187
183
|
await appendMessageSnapshot(deps.fs, input, message);
|
|
188
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
|
+
}
|
|
189
190
|
const latest = applyMessageSnapshots(messages, updated);
|
|
190
191
|
return {
|
|
191
192
|
taskSlug: input.taskSlug,
|
|
@@ -194,40 +195,6 @@ export function createMessageService(deps) {
|
|
|
194
195
|
};
|
|
195
196
|
});
|
|
196
197
|
},
|
|
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
198
|
async getOrchestrationState(input) {
|
|
232
199
|
return getOrchestrationState(input);
|
|
233
200
|
},
|
|
@@ -236,7 +203,6 @@ export function createMessageService(deps) {
|
|
|
236
203
|
const next = {
|
|
237
204
|
...current,
|
|
238
205
|
mode: input.mode ?? current.mode,
|
|
239
|
-
paused: input.paused ?? current.paused,
|
|
240
206
|
updatedAt: now()
|
|
241
207
|
};
|
|
242
208
|
await deps.fs.writeJsonAtomic(getOrchestrationStatePath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), next);
|
|
@@ -244,6 +210,132 @@ export function createMessageService(deps) {
|
|
|
244
210
|
}
|
|
245
211
|
};
|
|
246
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
|
+
}
|
|
247
339
|
function validateMessagePolicy(fromRole, toRole, type) {
|
|
248
340
|
if (!ROLE_NAMES.includes(toRole)) {
|
|
249
341
|
throw new VcmError({
|
|
@@ -252,17 +344,7 @@ function validateMessagePolicy(fromRole, toRole, type) {
|
|
|
252
344
|
statusCode: 400
|
|
253
345
|
});
|
|
254
346
|
}
|
|
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)) {
|
|
347
|
+
if (!isRoleName(String(fromRole))) {
|
|
266
348
|
throw new VcmError({
|
|
267
349
|
code: "MESSAGE_SENDER_UNKNOWN",
|
|
268
350
|
message: `Unknown sender role: ${fromRole}`,
|
|
@@ -279,7 +361,7 @@ function validateMessagePolicy(fromRole, toRole, type) {
|
|
|
279
361
|
code: "MESSAGE_POLICY_DENIED",
|
|
280
362
|
message: `${fromRole} cannot send ${type} messages to ${toRole}.`,
|
|
281
363
|
statusCode: 403,
|
|
282
|
-
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."
|
|
283
365
|
});
|
|
284
366
|
}
|
|
285
367
|
async function withTaskLock(locks, key, run) {
|
|
@@ -295,15 +377,18 @@ async function withTaskLock(locks, key, run) {
|
|
|
295
377
|
}
|
|
296
378
|
}
|
|
297
379
|
}
|
|
298
|
-
function
|
|
299
|
-
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));
|
|
300
384
|
}
|
|
301
385
|
function findActiveMessageForRole(messages, role) {
|
|
302
|
-
return messages.find((message) => message.toRole === role &&
|
|
386
|
+
return messages.find((message) => message.toRole === role &&
|
|
387
|
+
message.status === "submitted");
|
|
303
388
|
}
|
|
304
389
|
function acknowledgeActiveMessages(messages, role, timestamp) {
|
|
305
390
|
return messages
|
|
306
|
-
.filter((message) => message.toRole === role &&
|
|
391
|
+
.filter((message) => message.toRole === role && message.status === "submitted")
|
|
307
392
|
.map((message) => ({
|
|
308
393
|
...message,
|
|
309
394
|
status: "acknowledged",
|
|
@@ -311,16 +396,6 @@ function acknowledgeActiveMessages(messages, role, timestamp) {
|
|
|
311
396
|
failureReason: undefined
|
|
312
397
|
}));
|
|
313
398
|
}
|
|
314
|
-
function isActiveMessageStatus(status) {
|
|
315
|
-
return status === "delivering" || status === "submitted" || status === "delivered" || status === "staged";
|
|
316
|
-
}
|
|
317
|
-
function isSubmittableMessageStatus(status) {
|
|
318
|
-
return status === "delivering" || status === "delivered" || status === "staged" || status === "pending_approval";
|
|
319
|
-
}
|
|
320
|
-
function extractSubmittedMessageId(prompt) {
|
|
321
|
-
const match = prompt.match(/\bmsg_[A-Za-z0-9_-]+\b/);
|
|
322
|
-
return match?.[0];
|
|
323
|
-
}
|
|
324
399
|
function applyMessageSnapshots(messages, snapshots) {
|
|
325
400
|
if (snapshots.length === 0) {
|
|
326
401
|
return messages;
|
|
@@ -331,43 +406,6 @@ function applyMessageSnapshots(messages, snapshots) {
|
|
|
331
406
|
}
|
|
332
407
|
return [...latest.values()].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
333
408
|
}
|
|
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
409
|
async function readLatestMessages(fs, messagesPath) {
|
|
372
410
|
if (!(await fs.pathExists(messagesPath))) {
|
|
373
411
|
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 === "
|
|
99
|
+
.filter((message) => 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) {
|
|
@@ -41,9 +41,9 @@ export function createSessionService(deps) {
|
|
|
41
41
|
cwd: taskRepoRoot,
|
|
42
42
|
env: {
|
|
43
43
|
VCM_API_URL: deps.apiUrl,
|
|
44
|
-
VCM_CTL_COMMAND: deps.vcmctlCommand,
|
|
45
44
|
VCM_TASK_SLUG: taskSlug,
|
|
46
|
-
VCM_ROLE: role
|
|
45
|
+
VCM_ROLE: role,
|
|
46
|
+
VCM_SESSION_ID: claudeSessionId
|
|
47
47
|
},
|
|
48
48
|
cols: input.cols,
|
|
49
49
|
rows: input.rows,
|
|
@@ -165,14 +165,9 @@ export function createSessionService(deps) {
|
|
|
165
165
|
const timestamp = now();
|
|
166
166
|
const updated = {
|
|
167
167
|
...current,
|
|
168
|
-
activityStatus:
|
|
168
|
+
activityStatus: "idle",
|
|
169
169
|
lastHookEventAt: timestamp,
|
|
170
|
-
|
|
171
|
-
? timestamp
|
|
172
|
-
: current.lastPromptSubmittedAt,
|
|
173
|
-
lastStopAt: input.eventName === "Stop"
|
|
174
|
-
? timestamp
|
|
175
|
-
: current.lastStopAt,
|
|
170
|
+
lastStopAt: timestamp,
|
|
176
171
|
updatedAt: timestamp
|
|
177
172
|
};
|
|
178
173
|
deps.registry.upsert(updated);
|
|
@@ -180,6 +175,25 @@ export function createSessionService(deps) {
|
|
|
180
175
|
const task = await deps.taskService.loadTask(repoRoot, input.taskSlug);
|
|
181
176
|
await persistTaskSession(deps.fs, getTaskRuntimeRepoRoot(task), config.stateRoot, updated);
|
|
182
177
|
return updated;
|
|
178
|
+
},
|
|
179
|
+
async markRoleActivityRunning(repoRoot, taskSlug, role) {
|
|
180
|
+
const current = await this.getRoleSession(repoRoot, taskSlug, role);
|
|
181
|
+
if (!current) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
const timestamp = now();
|
|
185
|
+
const updated = {
|
|
186
|
+
...current,
|
|
187
|
+
activityStatus: "running",
|
|
188
|
+
lastPromptSubmittedAt: timestamp,
|
|
189
|
+
lastHookEventAt: timestamp,
|
|
190
|
+
updatedAt: timestamp
|
|
191
|
+
};
|
|
192
|
+
deps.registry.upsert(updated);
|
|
193
|
+
const config = await deps.projectService.loadConfig(repoRoot);
|
|
194
|
+
const task = await deps.taskService.loadTask(repoRoot, taskSlug);
|
|
195
|
+
await persistTaskSession(deps.fs, getTaskRuntimeRepoRoot(task), config.stateRoot, updated);
|
|
196
|
+
return updated;
|
|
183
197
|
}
|
|
184
198
|
};
|
|
185
199
|
}
|