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
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ Each role runs as a real Claude Code process inside an embedded terminal. The GU
|
|
|
23
23
|
- `default`
|
|
24
24
|
- `bypassPermissions`
|
|
25
25
|
- `--dangerously-skip-permissions`
|
|
26
|
-
- PM-mediated role messaging through
|
|
26
|
+
- PM-mediated role messaging through VCM-dispatched route files.
|
|
27
27
|
- Manual and automatic orchestration modes.
|
|
28
28
|
- VCM harness installer for `CLAUDE.md`, `.claude/agents/*.md`, and the VCM-managed `.gitignore` block.
|
|
29
29
|
- Translation panel powered by an OpenAI-compatible low-cost model.
|
|
@@ -50,8 +50,6 @@ npm install -g vibe-coding-master
|
|
|
50
50
|
vcm
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
The package also installs `vcmctl`, which Claude Code role sessions use internally to send VCM messages.
|
|
54
|
-
|
|
55
53
|
From source:
|
|
56
54
|
|
|
57
55
|
```bash
|
|
@@ -208,7 +206,7 @@ The same file stores recent repository paths. The translation API key is stored
|
|
|
208
206
|
|
|
209
207
|
The sidebar `Settings` section also stores the UI theme preference in this file. The default is `system`, which follows the OS/browser color-scheme preference; users can cycle between `System`, `Light`, and `Dark`.
|
|
210
208
|
|
|
211
|
-
The same sidebar also has a `Round alert` toggle. It is on by default and controls the in-app prompt plus
|
|
209
|
+
The same sidebar also has a `Round alert` toggle. It is on by default and controls the in-app prompt plus a soft two-note completion chime that fires when VCM detects that the current full conversation round is complete. The `Try alert` button triggers the same local prompt and sound for testing.
|
|
212
210
|
|
|
213
211
|
Translation behavior:
|
|
214
212
|
|
|
@@ -260,7 +258,7 @@ For `.gitignore`, VCM uses a gitignore-native managed block:
|
|
|
260
258
|
|
|
261
259
|
`.ai/vcm/` is the active VCM local control area, and `.claude/worktrees/` is the Claude-compatible task worktree area. The base repo keeps the task index; each task runtime repo keeps its own session, message, orchestration, and translation state.
|
|
262
260
|
|
|
263
|
-
VCM also JSON-merges `.claude/settings.json` to install Claude Code `UserPromptSubmit` and `Stop` hooks.
|
|
261
|
+
VCM also JSON-merges `.claude/settings.json` to install Claude Code `UserPromptSubmit` and `Stop` hooks. The hooks post directly to the local VCM backend, so roles do not need a VCM CLI command to confirm delivery or report turn completion.
|
|
264
262
|
|
|
265
263
|
After applying harness changes, VCM reports the exact files changed and reminds the user to review and commit them before starting long-running work.
|
|
266
264
|
|
|
@@ -268,25 +266,26 @@ Role sessions learn VCM rules from `CLAUDE.md` and `.claude/agents/*.md`. VCM do
|
|
|
268
266
|
|
|
269
267
|
## Message Bus
|
|
270
268
|
|
|
271
|
-
The message bus is
|
|
269
|
+
The message bus is file-driven and dispatched by VCM after Claude Code turn completion. Roles do not call a VCM CLI to send messages.
|
|
272
270
|
|
|
273
271
|
Role communication works like this:
|
|
274
272
|
|
|
275
273
|
```text
|
|
276
274
|
Claude Code role
|
|
277
|
-
->
|
|
278
|
-
->
|
|
279
|
-
->
|
|
280
|
-
->
|
|
275
|
+
-> writes or updates .ai/vcm/handoffs/messages/<from-role>-<to-role>.md
|
|
276
|
+
-> ends the Claude Code turn
|
|
277
|
+
-> Stop hook calls VCM backend directly
|
|
278
|
+
-> VCM scans pending route files
|
|
279
|
+
-> VCM validates, snapshots, and dispatches pending messages as delivering
|
|
280
|
+
-> UserPromptSubmit hook confirms accepted VCM messages as submitted
|
|
281
281
|
```
|
|
282
282
|
|
|
283
|
-
Examples
|
|
283
|
+
Examples:
|
|
284
284
|
|
|
285
|
-
```
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
vcmctl inbox
|
|
285
|
+
```text
|
|
286
|
+
.ai/vcm/handoffs/messages/project-manager-coder.md
|
|
287
|
+
.ai/vcm/handoffs/messages/coder-project-manager.md
|
|
288
|
+
.ai/vcm/handoffs/messages/project-manager-reviewer.md
|
|
290
289
|
```
|
|
291
290
|
|
|
292
291
|
Runtime message and handoff files:
|
|
@@ -294,12 +293,12 @@ Runtime message and handoff files:
|
|
|
294
293
|
```text
|
|
295
294
|
.ai/vcm/messages/<task>.jsonl # under the task runtime repo
|
|
296
295
|
.ai/vcm/orchestration/<task>.json # under the task runtime repo
|
|
297
|
-
.ai/vcm/handoffs/messages/<
|
|
296
|
+
.ai/vcm/handoffs/messages/<from-role>-<to-role>.md
|
|
298
297
|
.ai/vcm/handoffs/role-commands/
|
|
299
298
|
.ai/vcm/handoffs/logs/
|
|
300
299
|
```
|
|
301
300
|
|
|
302
|
-
|
|
301
|
+
Each directed role route has exactly one message file. If a role changes its mind during one turn, it edits the same route file instead of creating another message. A blank file means no pending message; a non-empty file means pending work for VCM to submit.
|
|
303
302
|
|
|
304
303
|
## Orchestration Modes
|
|
305
304
|
|
|
@@ -307,7 +306,7 @@ VCM has a task-level `Auto orchestration` switch in the active role toolbar, imm
|
|
|
307
306
|
|
|
308
307
|
When it is off, VCM is in manual mode:
|
|
309
308
|
|
|
310
|
-
- Roles may
|
|
309
|
+
- Roles may write pending route files under `.ai/vcm/handoffs/messages/`.
|
|
311
310
|
- Messages appear in the `Messages` modal.
|
|
312
311
|
- The user can inspect them.
|
|
313
312
|
- The current GUI shows sequence, timestamp, status, body preview, path, `Copy`, and `Mark All Done`.
|
|
@@ -318,29 +317,28 @@ When it is off, VCM is in manual mode:
|
|
|
318
317
|
When it is on, VCM is in auto mode:
|
|
319
318
|
|
|
320
319
|
- Backend policy still applies.
|
|
321
|
-
-
|
|
322
|
-
-
|
|
323
|
-
- If the target role session is running and has no active message, VCM writes a `[VCM MESSAGE]` envelope to the target terminal and submits it
|
|
324
|
-
-
|
|
320
|
+
- Roles write pending route files under `.ai/vcm/handoffs/messages/`.
|
|
321
|
+
- On Claude Code `Stop`, VCM scans pending route files.
|
|
322
|
+
- If the target role session is running, idle, and has no active message, VCM writes a `[VCM MESSAGE]` envelope to the target terminal and submits it.
|
|
323
|
+
- After successful terminal write, VCM snapshots the body as `delivering`.
|
|
324
|
+
- Claude Code `UserPromptSubmit` confirms that the prompt was accepted; VCM then marks the message `submitted` and clears the source route file if it still contains the same message.
|
|
325
325
|
- When the GUI observes a newly submitted auto message, it switches the active role tab to that message's target role so the user can watch the next step.
|
|
326
|
-
- VCM enforces per-role turn-taking: each target role can have at most one in-flight `delivering
|
|
327
|
-
- Additional
|
|
328
|
-
- When
|
|
329
|
-
- If auto orchestration gets stuck after a manual copy/paste recovery, `Mark All Done` clears open `pending_approval`, `queued`, `
|
|
330
|
-
|
|
331
|
-
VCM Harness injects Claude Code `UserPromptSubmit` and `Stop` hooks into `.claude/settings.json`. Role tabs show `running` after `UserPromptSubmit` and `idle` after `Stop`; the terminal process status is still tracked separately. The injected role rules also require a role to end its turn after any successful `vcmctl send`, `vcmctl reply`, or `vcmctl result`, rather than running `vcmctl inbox`, looping, polling, or waiting for another role inside the same Claude Code turn.
|
|
326
|
+
- VCM enforces per-role turn-taking: each target role can have at most one in-flight `delivering` or `submitted` message.
|
|
327
|
+
- Additional pending files to a busy target role remain non-empty and are not written to that terminal.
|
|
328
|
+
- When the target role later reaches `Stop`, VCM scans again and may deliver the next pending route file.
|
|
329
|
+
- If auto orchestration gets stuck after a manual copy/paste recovery, `Mark All Done` clears open `pending_approval`, `queued`, `delivering`, `submitted`, and failed message records to `acknowledged`, and clears pending route files.
|
|
332
330
|
|
|
333
|
-
|
|
331
|
+
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.
|
|
334
332
|
|
|
335
|
-
The
|
|
333
|
+
The implementation keeps only the active manual/auto orchestration mode. It does not expose pause/resume, stage/approve/reject, or a separate agent-facing message CLI.
|
|
336
334
|
|
|
337
335
|
## Round Completion Alerts
|
|
338
336
|
|
|
339
|
-
VCM detects conversation completion from
|
|
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.
|
|
340
338
|
|
|
341
339
|
For role chains, VCM waits for the latest active message's target role to reach hook `Stop`. For example, if PM sends work to Coder and Coder sends a result back to PM, the round is not complete when Coder finishes; it is complete only after PM reaches `Stop` for the final response. If no VCM role message is involved, the latest direct role `Stop` can complete the round.
|
|
342
340
|
|
|
343
|
-
When `Round alert` is enabled, the frontend polls the task round state, deduplicates each completion id, shows a small `Round complete` prompt, and plays
|
|
341
|
+
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.
|
|
344
342
|
|
|
345
343
|
## Resume Behavior
|
|
346
344
|
|
|
@@ -374,6 +372,7 @@ For a connected repository, VCM uses:
|
|
|
374
372
|
<taskRepoRoot>/.ai/vcm/handoffs/validation-log.md
|
|
375
373
|
<taskRepoRoot>/.ai/vcm/handoffs/review-report.md
|
|
376
374
|
<taskRepoRoot>/.ai/vcm/handoffs/docs-sync-report.md
|
|
375
|
+
<taskRepoRoot>/.ai/vcm/handoffs/messages/<from-role>-<to-role>.md
|
|
377
376
|
<taskRepoRoot>/.ai/vcm/handoffs/role-commands/{architect,coder,reviewer}.md
|
|
378
377
|
<taskRepoRoot>/.ai/vcm/handoffs/logs/{project-manager,architect,coder,reviewer}.log
|
|
379
378
|
```
|
|
@@ -387,7 +386,6 @@ Because handoffs are scoped to `taskRepoRoot` without an extra task-name directo
|
|
|
387
386
|
The npm package publishes built output, not raw TypeScript entry files. `package.json` includes:
|
|
388
387
|
|
|
389
388
|
- `bin.vcm`: `dist/main.js`
|
|
390
|
-
- `bin.vcmctl`: `dist/cli/vcmctl.js`
|
|
391
389
|
- `files`: `dist`, `dist-frontend`, `docs`, `scripts`, `README.md`
|
|
392
390
|
- `prepack`: `npm run build && npm run verify:package`
|
|
393
391
|
|
|
@@ -2,4 +2,7 @@ export function registerClaudeHookRoutes(app, deps) {
|
|
|
2
2
|
app.post("/api/hooks/claude-code", async (request) => {
|
|
3
3
|
return deps.claudeHookService.handleHook(request.body);
|
|
4
4
|
});
|
|
5
|
+
app.post("/api/hooks/claude-code/stop", async (request) => {
|
|
6
|
+
return deps.claudeHookService.handleStopHook(request.body);
|
|
7
|
+
});
|
|
5
8
|
}
|
|
@@ -5,36 +5,19 @@ export function registerMessageRoutes(app, deps) {
|
|
|
5
5
|
const context = await getRouteContext(deps, request.params.taskSlug);
|
|
6
6
|
return deps.messageService.listMessages(context);
|
|
7
7
|
});
|
|
8
|
-
app.
|
|
8
|
+
app.get("/api/tasks/:taskSlug/messages/pending-routes", async (request) => {
|
|
9
9
|
const context = await getRouteContext(deps, request.params.taskSlug);
|
|
10
|
-
return deps.messageService.
|
|
11
|
-
...context,
|
|
12
|
-
...request.body
|
|
13
|
-
});
|
|
14
|
-
});
|
|
15
|
-
app.post("/api/tasks/:taskSlug/messages/mark-all-done", async (request) => {
|
|
16
|
-
const context = await getRouteContext(deps, request.params.taskSlug);
|
|
17
|
-
return deps.messageService.markAllDone(context);
|
|
10
|
+
return deps.messageService.listPendingRouteFiles(context);
|
|
18
11
|
});
|
|
19
|
-
app.post("/api/tasks/:taskSlug/messages
|
|
12
|
+
app.post("/api/tasks/:taskSlug/messages/scan", async (request) => {
|
|
20
13
|
const context = await getRouteContext(deps, request.params.taskSlug);
|
|
21
|
-
return deps.messageService.
|
|
22
|
-
...context,
|
|
23
|
-
messageId: request.params.messageId
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
app.post("/api/tasks/:taskSlug/messages/:messageId/approve", async (request) => {
|
|
27
|
-
const context = await getRouteContext(deps, request.params.taskSlug);
|
|
28
|
-
return deps.messageService.approveMessage({
|
|
29
|
-
...context,
|
|
30
|
-
messageId: request.params.messageId
|
|
31
|
-
});
|
|
14
|
+
return deps.messageService.scanAndDispatchPendingRouteFiles(context);
|
|
32
15
|
});
|
|
33
|
-
app.post("/api/tasks/:taskSlug/messages
|
|
16
|
+
app.post("/api/tasks/:taskSlug/messages/mark-all-done", async (request) => {
|
|
34
17
|
const context = await getRouteContext(deps, request.params.taskSlug);
|
|
35
|
-
return deps.messageService.
|
|
18
|
+
return deps.messageService.markAllDone({
|
|
36
19
|
...context,
|
|
37
|
-
|
|
20
|
+
clearRouteFiles: true
|
|
38
21
|
});
|
|
39
22
|
});
|
|
40
23
|
app.get("/api/tasks/:taskSlug/orchestration", async (request) => {
|
|
@@ -52,22 +35,7 @@ export function registerMessageRoutes(app, deps) {
|
|
|
52
35
|
}
|
|
53
36
|
return deps.messageService.updateOrchestrationState({
|
|
54
37
|
...context,
|
|
55
|
-
mode: request.body.mode
|
|
56
|
-
paused: request.body.paused
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
app.post("/api/tasks/:taskSlug/orchestration/pause", async (request) => {
|
|
60
|
-
const context = await getRouteContext(deps, request.params.taskSlug);
|
|
61
|
-
return deps.messageService.updateOrchestrationState({
|
|
62
|
-
...context,
|
|
63
|
-
paused: true
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
app.post("/api/tasks/:taskSlug/orchestration/resume", async (request) => {
|
|
67
|
-
const context = await getRouteContext(deps, request.params.taskSlug);
|
|
68
|
-
return deps.messageService.updateOrchestrationState({
|
|
69
|
-
...context,
|
|
70
|
-
paused: false
|
|
38
|
+
mode: request.body.mode
|
|
71
39
|
});
|
|
72
40
|
});
|
|
73
41
|
}
|
package/dist/backend/server.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
2
|
import { fileURLToPath } from "node:url";
|
|
4
3
|
import Fastify from "fastify";
|
|
5
4
|
import fastifyStatic from "@fastify/static";
|
|
@@ -140,8 +139,7 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
140
139
|
artifactService,
|
|
141
140
|
projectService,
|
|
142
141
|
taskService,
|
|
143
|
-
apiUrl: options.apiUrl
|
|
144
|
-
vcmctlCommand: options.vcmctlCommand ?? resolveVcmctlCommand()
|
|
142
|
+
apiUrl: options.apiUrl
|
|
145
143
|
});
|
|
146
144
|
const commandDispatcher = createCommandDispatcher({
|
|
147
145
|
runtime,
|
|
@@ -197,26 +195,6 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
197
195
|
export function getDefaultStaticDir() {
|
|
198
196
|
return path.join(getAppRoot(), "dist-frontend");
|
|
199
197
|
}
|
|
200
|
-
function resolveVcmctlCommand() {
|
|
201
|
-
const appRoot = getAppRoot();
|
|
202
|
-
const currentModulePath = fileURLToPath(import.meta.url);
|
|
203
|
-
const sourceCli = path.join(appRoot, "src", "cli", "vcmctl.ts");
|
|
204
|
-
const tsxCli = path.join(appRoot, "node_modules", "tsx", "dist", "cli.mjs");
|
|
205
|
-
if (currentModulePath.includes(`${path.sep}src${path.sep}`) && existsSync(tsxCli) && existsSync(sourceCli)) {
|
|
206
|
-
return `${quoteShellArg(process.execPath)} ${quoteShellArg(tsxCli)} ${quoteShellArg(sourceCli)}`;
|
|
207
|
-
}
|
|
208
|
-
const distCli = path.join(appRoot, "dist", "cli", "vcmctl.js");
|
|
209
|
-
if (existsSync(distCli)) {
|
|
210
|
-
return `${quoteShellArg(process.execPath)} ${quoteShellArg(distCli)}`;
|
|
211
|
-
}
|
|
212
|
-
if (existsSync(tsxCli) && existsSync(sourceCli)) {
|
|
213
|
-
return `${quoteShellArg(process.execPath)} ${quoteShellArg(tsxCli)} ${quoteShellArg(sourceCli)}`;
|
|
214
|
-
}
|
|
215
|
-
return "vcmctl";
|
|
216
|
-
}
|
|
217
198
|
function getAppRoot() {
|
|
218
199
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
219
200
|
}
|
|
220
|
-
function quoteShellArg(value) {
|
|
221
|
-
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
222
|
-
}
|
|
@@ -3,7 +3,7 @@ import { DISPATCHABLE_ROLES, ROLE_NAMES } from "../../shared/constants.js";
|
|
|
3
3
|
import { checkMarkdownArtifact } from "../../shared/validation/artifact-check.js";
|
|
4
4
|
import { VcmError } from "../errors.js";
|
|
5
5
|
import { resolveRepoPath } from "../adapters/filesystem.js";
|
|
6
|
-
import { renderArchitecturePlanTemplate, renderDocsSyncReportTemplate, renderImplementationLogTemplate, renderReviewReportTemplate, renderValidationLogTemplate } from "../templates/handoff.js";
|
|
6
|
+
import { renderArchitecturePlanTemplate, renderDocsSyncReportTemplate, renderImplementationLogTemplate, renderMessageRouteTemplate, renderReviewReportTemplate, renderValidationLogTemplate } from "../templates/handoff.js";
|
|
7
7
|
import { renderRoleCommandTemplate } from "../templates/role-command.js";
|
|
8
8
|
const ARTIFACT_PATH_KEYS = [
|
|
9
9
|
["architecture-plan", "architecturePlanPath"],
|
|
@@ -13,15 +13,25 @@ const ARTIFACT_PATH_KEYS = [
|
|
|
13
13
|
["docs-sync-report", "docsSyncReportPath"]
|
|
14
14
|
];
|
|
15
15
|
const ROLE_COMMAND_PLACEHOLDER_PATTERN = /(^|\n)\s*(TBD|status:\s*draft)\s*(\n|$)/i;
|
|
16
|
+
const DEFAULT_MESSAGE_ROUTES = [
|
|
17
|
+
["project-manager", "architect"],
|
|
18
|
+
["project-manager", "coder"],
|
|
19
|
+
["project-manager", "reviewer"],
|
|
20
|
+
["architect", "project-manager"],
|
|
21
|
+
["coder", "project-manager"],
|
|
22
|
+
["reviewer", "project-manager"]
|
|
23
|
+
];
|
|
16
24
|
export function createArtifactService(fs) {
|
|
17
25
|
return {
|
|
18
26
|
getHandoffPaths(_repoRoot, handoffDir) {
|
|
19
27
|
const roleCommandsDir = path.posix.join(handoffDir, "role-commands");
|
|
20
28
|
const logsDir = path.posix.join(handoffDir, "logs");
|
|
29
|
+
const messagesDir = path.posix.join(handoffDir, "messages");
|
|
21
30
|
return {
|
|
22
31
|
handoffDir,
|
|
23
32
|
roleCommandsDir,
|
|
24
33
|
logsDir,
|
|
34
|
+
messagesDir,
|
|
25
35
|
roleCommandPaths: {
|
|
26
36
|
architect: path.posix.join(roleCommandsDir, "architect.md"),
|
|
27
37
|
coder: path.posix.join(roleCommandsDir, "coder.md"),
|
|
@@ -33,6 +43,7 @@ export function createArtifactService(fs) {
|
|
|
33
43
|
coder: path.posix.join(logsDir, "coder.log"),
|
|
34
44
|
reviewer: path.posix.join(logsDir, "reviewer.log")
|
|
35
45
|
},
|
|
46
|
+
messageRoutePaths: getDefaultMessageRoutePaths(messagesDir),
|
|
36
47
|
architecturePlanPath: path.posix.join(handoffDir, "architecture-plan.md"),
|
|
37
48
|
implementationLogPath: path.posix.join(handoffDir, "implementation-log.md"),
|
|
38
49
|
validationLogPath: path.posix.join(handoffDir, "validation-log.md"),
|
|
@@ -40,11 +51,15 @@ export function createArtifactService(fs) {
|
|
|
40
51
|
docsSyncReportPath: path.posix.join(handoffDir, "docs-sync-report.md")
|
|
41
52
|
};
|
|
42
53
|
},
|
|
54
|
+
getMessageRoutePath(handoffDir, fromRole, toRole) {
|
|
55
|
+
return path.posix.join(handoffDir, "messages", `${fromRole}-${toRole}.md`);
|
|
56
|
+
},
|
|
43
57
|
async ensureHandoffStructure(input) {
|
|
44
58
|
const paths = this.getHandoffPaths(input.repoRoot, input.handoffDir);
|
|
45
59
|
await fs.ensureDir(resolveRepoPath(input.repoRoot, paths.handoffDir));
|
|
46
60
|
await fs.ensureDir(resolveRepoPath(input.repoRoot, paths.roleCommandsDir));
|
|
47
61
|
await fs.ensureDir(resolveRepoPath(input.repoRoot, paths.logsDir));
|
|
62
|
+
await fs.ensureDir(resolveRepoPath(input.repoRoot, paths.messagesDir));
|
|
48
63
|
return paths;
|
|
49
64
|
},
|
|
50
65
|
async createArtifactTemplates(input) {
|
|
@@ -57,7 +72,11 @@ export function createArtifactService(fs) {
|
|
|
57
72
|
[paths.implementationLogPath, renderImplementationLogTemplate(input.taskSlug)],
|
|
58
73
|
[paths.validationLogPath, renderValidationLogTemplate(input.taskSlug)],
|
|
59
74
|
[paths.reviewReportPath, renderReviewReportTemplate(input.taskSlug)],
|
|
60
|
-
[paths.docsSyncReportPath, renderDocsSyncReportTemplate(input.taskSlug)]
|
|
75
|
+
[paths.docsSyncReportPath, renderDocsSyncReportTemplate(input.taskSlug)],
|
|
76
|
+
...Object.values(paths.messageRoutePaths).map((messagePath) => [
|
|
77
|
+
messagePath,
|
|
78
|
+
renderMessageRouteTemplate()
|
|
79
|
+
])
|
|
61
80
|
];
|
|
62
81
|
const created = [];
|
|
63
82
|
for (const [artifactPath, content] of files) {
|
|
@@ -165,6 +184,12 @@ export function createArtifactService(fs) {
|
|
|
165
184
|
function getLegacyRoleCommandPath(roleCommandsDir, role) {
|
|
166
185
|
return path.posix.join(roleCommandsDir, `${role}-command.md`);
|
|
167
186
|
}
|
|
187
|
+
function getDefaultMessageRoutePaths(messagesDir) {
|
|
188
|
+
return Object.fromEntries(DEFAULT_MESSAGE_ROUTES.map(([fromRole, toRole]) => [
|
|
189
|
+
`${fromRole}-${toRole}`,
|
|
190
|
+
path.posix.join(messagesDir, `${fromRole}-${toRole}.md`)
|
|
191
|
+
]));
|
|
192
|
+
}
|
|
168
193
|
async function readTextOrNull(fs, absolutePath) {
|
|
169
194
|
if (!(await fs.pathExists(absolutePath))) {
|
|
170
195
|
return null;
|
|
@@ -2,59 +2,110 @@ import { isRoleName } from "../../shared/constants.js";
|
|
|
2
2
|
import { VcmError } from "../errors.js";
|
|
3
3
|
import { getTaskRuntimeRepoRoot } from "./task-service.js";
|
|
4
4
|
export function createClaudeHookService(deps) {
|
|
5
|
+
async function getHookContext(input) {
|
|
6
|
+
if (!isRoleName(input.role)) {
|
|
7
|
+
throw new VcmError({
|
|
8
|
+
code: "HOOK_ROLE_INVALID",
|
|
9
|
+
message: `Unknown hook role: ${input.role}`,
|
|
10
|
+
statusCode: 400
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
const project = await deps.projectService.getCurrentProject();
|
|
14
|
+
if (!project) {
|
|
15
|
+
throw new VcmError({
|
|
16
|
+
code: "PROJECT_NOT_CONNECTED",
|
|
17
|
+
message: "Connect a repository before accepting Claude Code hooks.",
|
|
18
|
+
statusCode: 409
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
const config = await deps.projectService.loadConfig(project.repoRoot);
|
|
22
|
+
const task = await deps.taskService.loadTask(project.repoRoot, input.taskSlug);
|
|
23
|
+
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
24
|
+
return {
|
|
25
|
+
project,
|
|
26
|
+
config,
|
|
27
|
+
task,
|
|
28
|
+
taskRepoRoot
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
async function handleUserPromptSubmitHook(input) {
|
|
32
|
+
const eventName = parseHookEvent(input.event.hook_event_name);
|
|
33
|
+
if (eventName !== "UserPromptSubmit") {
|
|
34
|
+
throwUnsupportedEvent(eventName);
|
|
35
|
+
}
|
|
36
|
+
const context = await getHookContext(input);
|
|
37
|
+
const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
|
|
38
|
+
taskSlug: input.taskSlug,
|
|
39
|
+
role: input.role,
|
|
40
|
+
eventName,
|
|
41
|
+
claudeSessionId: stringOrUndefined(input.event.session_id),
|
|
42
|
+
transcriptPath: stringOrUndefined(input.event.transcript_path),
|
|
43
|
+
cwd: stringOrUndefined(input.event.cwd)
|
|
44
|
+
});
|
|
45
|
+
const submitted = await deps.messageService.confirmPromptSubmitted({
|
|
46
|
+
repoRoot: context.project.repoRoot,
|
|
47
|
+
taskRepoRoot: context.taskRepoRoot,
|
|
48
|
+
stateRepoRoot: context.taskRepoRoot,
|
|
49
|
+
stateRoot: context.config.stateRoot,
|
|
50
|
+
handoffDir: context.task.handoffDir,
|
|
51
|
+
taskSlug: input.taskSlug,
|
|
52
|
+
role: input.role,
|
|
53
|
+
prompt: stringOrUndefined(input.event.prompt)
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
eventName,
|
|
58
|
+
taskSlug: input.taskSlug,
|
|
59
|
+
role: input.role,
|
|
60
|
+
sessionUpdated: Boolean(session),
|
|
61
|
+
dispatchedCount: 0,
|
|
62
|
+
submittedMessageId: submitted?.id
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async function handleStopHook(input) {
|
|
66
|
+
const eventName = parseHookEvent(input.event.hook_event_name ?? "Stop");
|
|
67
|
+
if (eventName !== "Stop") {
|
|
68
|
+
throwUnsupportedEvent(eventName);
|
|
69
|
+
}
|
|
70
|
+
const context = await getHookContext(input);
|
|
71
|
+
const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
|
|
72
|
+
taskSlug: input.taskSlug,
|
|
73
|
+
role: input.role,
|
|
74
|
+
eventName,
|
|
75
|
+
claudeSessionId: stringOrUndefined(input.event.session_id),
|
|
76
|
+
transcriptPath: stringOrUndefined(input.event.transcript_path),
|
|
77
|
+
cwd: stringOrUndefined(input.event.cwd)
|
|
78
|
+
});
|
|
79
|
+
const dispatched = await deps.messageService.scanAndDispatchPendingRouteFiles({
|
|
80
|
+
repoRoot: context.project.repoRoot,
|
|
81
|
+
taskRepoRoot: context.taskRepoRoot,
|
|
82
|
+
stateRepoRoot: context.taskRepoRoot,
|
|
83
|
+
stateRoot: context.config.stateRoot,
|
|
84
|
+
handoffDir: context.task.handoffDir,
|
|
85
|
+
taskSlug: input.taskSlug,
|
|
86
|
+
stoppedRole: input.role
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
eventName,
|
|
91
|
+
taskSlug: input.taskSlug,
|
|
92
|
+
role: input.role,
|
|
93
|
+
sessionUpdated: Boolean(session),
|
|
94
|
+
dispatchedCount: dispatched.filter((result) => result.delivered).length
|
|
95
|
+
};
|
|
96
|
+
}
|
|
5
97
|
return {
|
|
6
98
|
async handleHook(input) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
message: `Unknown hook role: ${input.role}`,
|
|
11
|
-
statusCode: 400
|
|
12
|
-
});
|
|
99
|
+
const eventName = parseHookEvent(input.event.hook_event_name);
|
|
100
|
+
if (eventName === "UserPromptSubmit") {
|
|
101
|
+
return handleUserPromptSubmitHook(input);
|
|
13
102
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
throw new VcmError({
|
|
18
|
-
code: "PROJECT_NOT_CONNECTED",
|
|
19
|
-
message: "Connect a repository before accepting Claude Code hooks.",
|
|
20
|
-
statusCode: 409
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
const session = await deps.sessionService.recordClaudeHookEvent(project.repoRoot, {
|
|
24
|
-
taskSlug: input.taskSlug,
|
|
25
|
-
role: input.role,
|
|
26
|
-
eventName,
|
|
27
|
-
claudeSessionId: stringOrUndefined(input.event.session_id),
|
|
28
|
-
transcriptPath: stringOrUndefined(input.event.transcript_path),
|
|
29
|
-
cwd: stringOrUndefined(input.event.cwd)
|
|
30
|
-
});
|
|
31
|
-
let submittedMessageId;
|
|
32
|
-
if (eventName === "UserPromptSubmit" && typeof input.event.prompt === "string") {
|
|
33
|
-
const config = await deps.projectService.loadConfig(project.repoRoot);
|
|
34
|
-
const task = await deps.taskService.loadTask(project.repoRoot, input.taskSlug);
|
|
35
|
-
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
36
|
-
const submitted = await deps.messageService.confirmPromptSubmitted({
|
|
37
|
-
repoRoot: project.repoRoot,
|
|
38
|
-
stateRepoRoot: taskRepoRoot,
|
|
39
|
-
stateRoot: config.stateRoot,
|
|
40
|
-
taskSlug: input.taskSlug,
|
|
41
|
-
role: input.role,
|
|
42
|
-
prompt: input.event.prompt
|
|
43
|
-
});
|
|
44
|
-
submittedMessageId = submitted?.id;
|
|
45
|
-
}
|
|
46
|
-
return {
|
|
47
|
-
ok: true,
|
|
48
|
-
eventName,
|
|
49
|
-
taskSlug: input.taskSlug,
|
|
50
|
-
role: input.role,
|
|
51
|
-
sessionUpdated: Boolean(session),
|
|
52
|
-
submittedMessageId
|
|
53
|
-
};
|
|
54
|
-
}
|
|
103
|
+
return handleStopHook(input);
|
|
104
|
+
},
|
|
105
|
+
handleStopHook
|
|
55
106
|
};
|
|
56
107
|
}
|
|
57
|
-
function
|
|
108
|
+
function parseHookEvent(value) {
|
|
58
109
|
if (value === "UserPromptSubmit" || value === "Stop") {
|
|
59
110
|
return value;
|
|
60
111
|
}
|
|
@@ -62,7 +113,15 @@ function parseHookEventName(value) {
|
|
|
62
113
|
code: "HOOK_EVENT_UNSUPPORTED",
|
|
63
114
|
message: `Unsupported Claude Code hook event: ${String(value)}`,
|
|
64
115
|
statusCode: 400,
|
|
65
|
-
hint: "VCM
|
|
116
|
+
hint: "VCM accepts UserPromptSubmit and Stop hooks only."
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function throwUnsupportedEvent(eventName) {
|
|
120
|
+
throw new VcmError({
|
|
121
|
+
code: "HOOK_EVENT_UNSUPPORTED",
|
|
122
|
+
message: `Unsupported Claude Code hook event: ${eventName}`,
|
|
123
|
+
statusCode: 400,
|
|
124
|
+
hint: "Use the matching VCM hook endpoint for this event."
|
|
66
125
|
});
|
|
67
126
|
}
|
|
68
127
|
function stringOrUndefined(value) {
|
|
@@ -9,7 +9,7 @@ export const VCM_HARNESS_VERSION = 1;
|
|
|
9
9
|
const MANAGED_BLOCK_PATTERN = /<!-- VCM:BEGIN(?:\s+version=(\d+))? -->[\s\S]*?<!-- VCM:END -->/m;
|
|
10
10
|
const HASH_MANAGED_BLOCK_PATTERN = /# VCM:BEGIN(?:\s+version=(\d+))?\n[\s\S]*?# VCM:END/m;
|
|
11
11
|
const CLAUDE_SETTINGS_PATH = ".claude/settings.json";
|
|
12
|
-
const VCM_HOOK_COMMAND =
|
|
12
|
+
const VCM_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,event}));});'"'"' | curl -fsS --max-time 2 -X POST "\${VCM_API_URL}/api/hooks/claude-code" -H "content-type: application/json" --data-binary @- >/dev/null || true'`;
|
|
13
13
|
const VCM_HOOK_EVENTS = ["UserPromptSubmit", "Stop"];
|
|
14
14
|
const HARNESS_FILES = [
|
|
15
15
|
{
|
|
@@ -244,6 +244,18 @@ function withVcmClaudeHooks(settings) {
|
|
|
244
244
|
const hooks = isPlainObject(settings.hooks)
|
|
245
245
|
? { ...settings.hooks }
|
|
246
246
|
: {};
|
|
247
|
+
for (const [eventName, value] of Object.entries(hooks)) {
|
|
248
|
+
if (!Array.isArray(value)) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const cleaned = value.filter((entry) => !isVcmHookMatcher(entry));
|
|
252
|
+
if (cleaned.length > 0) {
|
|
253
|
+
hooks[eventName] = cleaned;
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
delete hooks[eventName];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
247
259
|
for (const eventName of VCM_HOOK_EVENTS) {
|
|
248
260
|
const existingMatchers = Array.isArray(hooks[eventName])
|
|
249
261
|
? hooks[eventName]
|
|
@@ -278,6 +290,11 @@ function isVcmHookMatcher(value) {
|
|
|
278
290
|
return false;
|
|
279
291
|
}
|
|
280
292
|
return typeof hook.command === "string" && hook.command.includes("hook-event");
|
|
293
|
+
}) || value.hooks.some((hook) => {
|
|
294
|
+
if (!isPlainObject(hook)) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
return typeof hook.command === "string" && hook.command.includes("/api/hooks/claude-code");
|
|
281
298
|
});
|
|
282
299
|
}
|
|
283
300
|
function isPlainObject(value) {
|