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 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 `vcmctl`.
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
@@ -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. Those hooks call `vcmctl hook-event` through the session-provided `VCM_CTL_COMMAND`, so users do not need to configure hook commands manually.
261
+ VCM also JSON-merges `.claude/settings.json` to install a Claude Code `Stop` hook. The hook posts directly to the local VCM backend, so roles do not need a VCM CLI command to 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,25 @@ 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 API-driven. VCM does not watch files to trigger role messages.
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
- -> runs vcmctl send / vcmctl reply / vcmctl result
278
- -> vcmctl calls VCM backend API
279
- -> backend validates message policy and persists the message
280
- -> backend writes to the target embedded terminal when allowed
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
281
280
  ```
282
281
 
283
- Examples that roles can run inside their terminal:
282
+ Examples:
284
283
 
285
- ```bash
286
- vcmctl send --to coder --type task --body-file /tmp/message.md
287
- vcmctl reply --type blocked --body "Need clarification."
288
- vcmctl result --body-file /tmp/result.md --artifact .ai/vcm/handoffs/implementation-log.md
289
- vcmctl inbox
284
+ ```text
285
+ .ai/vcm/handoffs/messages/project-manager-coder.md
286
+ .ai/vcm/handoffs/messages/coder-project-manager.md
287
+ .ai/vcm/handoffs/messages/project-manager-reviewer.md
290
288
  ```
291
289
 
292
290
  Runtime message and handoff files:
@@ -294,12 +292,12 @@ Runtime message and handoff files:
294
292
  ```text
295
293
  .ai/vcm/messages/<task>.jsonl # under the task runtime repo
296
294
  .ai/vcm/orchestration/<task>.json # under the task runtime repo
297
- .ai/vcm/handoffs/messages/<message-id>.md
295
+ .ai/vcm/handoffs/messages/<from-role>-<to-role>.md
298
296
  .ai/vcm/handoffs/role-commands/
299
297
  .ai/vcm/handoffs/logs/
300
298
  ```
301
299
 
302
- The backend also keeps a compatibility role-command dispatch endpoint, but the primary workflow is PM-mediated `vcmctl` messaging.
300
+ 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
301
 
304
302
  ## Orchestration Modes
305
303
 
@@ -307,7 +305,7 @@ VCM has a task-level `Auto orchestration` switch in the active role toolbar, imm
307
305
 
308
306
  When it is off, VCM is in manual mode:
309
307
 
310
- - Roles may send messages through `vcmctl`.
308
+ - Roles may write pending route files under `.ai/vcm/handoffs/messages/`.
311
309
  - Messages appear in the `Messages` modal.
312
310
  - The user can inspect them.
313
311
  - The current GUI shows sequence, timestamp, status, body preview, path, `Copy`, and `Mark All Done`.
@@ -318,25 +316,23 @@ When it is off, VCM is in manual mode:
318
316
  When it is on, VCM is in auto mode:
319
317
 
320
318
  - Backend policy still applies.
321
- - PM can send work to `architect`, `coder`, or `reviewer`.
322
- - Non-PM roles can reply only to `project-manager`.
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 as `delivering`.
324
- - Claude Code `UserPromptSubmit` confirms the prompt really entered the target role session; VCM then marks the message `submitted`.
319
+ - Roles write pending route files under `.ai/vcm/handoffs/messages/`.
320
+ - On Claude Code `Stop`, VCM scans pending route files.
321
+ - 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.
322
+ - After successful terminal submission, VCM snapshots the body in message history and clears the source route file.
325
323
  - 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`, `submitted`, `delivered`, or `staged` message.
327
- - Additional messages to a busy target role are kept as `queued` and are not written to that terminal.
328
- - When a role sends `vcmctl reply` or `vcmctl result`, VCM acknowledges that role's active message and delivers the next queued message for that role.
329
- - If auto orchestration gets stuck after a manual copy/paste recovery, `Mark All Done` clears open `pending_approval`, `queued`, `staged`, `delivering`, `submitted`, `delivered`, and failed message records to `acknowledged`.
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.
324
+ - VCM enforces per-role turn-taking: each target role can have at most one in-flight `submitted` message.
325
+ - Additional pending files to a busy target role remain non-empty and are not written to that terminal.
326
+ - When the target role later reaches `Stop`, VCM scans again and may deliver the next pending route file.
327
+ - If auto orchestration gets stuck after a manual copy/paste recovery, `Mark All Done` clears open `pending_approval`, `queued`, `submitted`, and failed message records to `acknowledged`, and clears pending route files.
332
328
 
333
- The backend state model still contains a `paused` field for compatibility with existing API routes, but the current GUI exposes only a single on/off orchestration toggle.
329
+ VCM Harness injects a Claude Code `Stop` hook into `.claude/settings.json`. Role tabs become `running` when VCM submits user input or a message to that embedded terminal, and `idle` after `Stop`; 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
330
 
335
- The backend still exposes stage/approve/reject compatibility APIs for automation and future UI work. They are not primary controls in the current Messages modal.
331
+ 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
332
 
337
333
  ## Round Completion Alerts
338
334
 
339
- VCM detects conversation completion from the hook-driven role activity state, not PTY silence. `UserPromptSubmit` marks a role `running`, and `Stop` marks that role `idle` with a stop timestamp.
335
+ VCM detects conversation completion from hook-driven role activity state, not PTY silence. VCM terminal submission marks a role `running`, and `Stop` marks that role `idle` with a stop timestamp.
340
336
 
341
337
  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
338
 
@@ -374,6 +370,7 @@ For a connected repository, VCM uses:
374
370
  <taskRepoRoot>/.ai/vcm/handoffs/validation-log.md
375
371
  <taskRepoRoot>/.ai/vcm/handoffs/review-report.md
376
372
  <taskRepoRoot>/.ai/vcm/handoffs/docs-sync-report.md
373
+ <taskRepoRoot>/.ai/vcm/handoffs/messages/<from-role>-<to-role>.md
377
374
  <taskRepoRoot>/.ai/vcm/handoffs/role-commands/{architect,coder,reviewer}.md
378
375
  <taskRepoRoot>/.ai/vcm/handoffs/logs/{project-manager,architect,coder,reviewer}.log
379
376
  ```
@@ -387,7 +384,6 @@ Because handoffs are scoped to `taskRepoRoot` without an extra task-name directo
387
384
  The npm package publishes built output, not raw TypeScript entry files. `package.json` includes:
388
385
 
389
386
  - `bin.vcm`: `dist/main.js`
390
- - `bin.vcmctl`: `dist/cli/vcmctl.js`
391
387
  - `files`: `dist`, `dist-frontend`, `docs`, `scripts`, `README.md`
392
388
  - `prepack`: `npm run build && npm run verify:package`
393
389
 
@@ -1,5 +1,5 @@
1
1
  export function registerClaudeHookRoutes(app, deps) {
2
- app.post("/api/hooks/claude-code", async (request) => {
3
- return deps.claudeHookService.handleHook(request.body);
2
+ app.post("/api/hooks/claude-code/stop", async (request) => {
3
+ return deps.claudeHookService.handleStopHook(request.body);
4
4
  });
5
5
  }
@@ -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.post("/api/tasks/:taskSlug/messages", async (request) => {
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.sendMessage({
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/:messageId/stage", async (request) => {
12
+ app.post("/api/tasks/:taskSlug/messages/scan", async (request) => {
20
13
  const context = await getRouteContext(deps, request.params.taskSlug);
21
- return deps.messageService.stageMessage({
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/:messageId/reject", async (request) => {
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.rejectMessage({
18
+ return deps.messageService.markAllDone({
36
19
  ...context,
37
- messageId: request.params.messageId
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
  }
@@ -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;
@@ -3,7 +3,7 @@ import { VcmError } from "../errors.js";
3
3
  import { getTaskRuntimeRepoRoot } from "./task-service.js";
4
4
  export function createClaudeHookService(deps) {
5
5
  return {
6
- async handleHook(input) {
6
+ async handleStopHook(input) {
7
7
  if (!isRoleName(input.role)) {
8
8
  throw new VcmError({
9
9
  code: "HOOK_ROLE_INVALID",
@@ -11,7 +11,7 @@ export function createClaudeHookService(deps) {
11
11
  statusCode: 400
12
12
  });
13
13
  }
14
- const eventName = parseHookEventName(input.event.hook_event_name);
14
+ const eventName = parseStopEvent(input.event.hook_event_name);
15
15
  const project = await deps.projectService.getCurrentProject();
16
16
  if (!project) {
17
17
  throw new VcmError({
@@ -28,41 +28,38 @@ export function createClaudeHookService(deps) {
28
28
  transcriptPath: stringOrUndefined(input.event.transcript_path),
29
29
  cwd: stringOrUndefined(input.event.cwd)
30
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
- }
31
+ const config = await deps.projectService.loadConfig(project.repoRoot);
32
+ const task = await deps.taskService.loadTask(project.repoRoot, input.taskSlug);
33
+ const taskRepoRoot = getTaskRuntimeRepoRoot(task);
34
+ const dispatched = await deps.messageService.scanAndDispatchPendingRouteFiles({
35
+ repoRoot: project.repoRoot,
36
+ taskRepoRoot,
37
+ stateRepoRoot: taskRepoRoot,
38
+ stateRoot: config.stateRoot,
39
+ handoffDir: task.handoffDir,
40
+ taskSlug: input.taskSlug,
41
+ stoppedRole: input.role
42
+ });
46
43
  return {
47
44
  ok: true,
48
45
  eventName,
49
46
  taskSlug: input.taskSlug,
50
47
  role: input.role,
51
48
  sessionUpdated: Boolean(session),
52
- submittedMessageId
49
+ dispatchedCount: dispatched.filter((result) => result.delivered).length
53
50
  };
54
51
  }
55
52
  };
56
53
  }
57
- function parseHookEventName(value) {
58
- if (value === "UserPromptSubmit" || value === "Stop") {
59
- return value;
54
+ function parseStopEvent(value) {
55
+ if (value === undefined || value === "Stop") {
56
+ return "Stop";
60
57
  }
61
58
  throw new VcmError({
62
59
  code: "HOOK_EVENT_UNSUPPORTED",
63
60
  message: `Unsupported Claude Code hook event: ${String(value)}`,
64
61
  statusCode: 400,
65
- hint: "VCM currently accepts UserPromptSubmit and Stop hooks only."
62
+ hint: "VCM accepts Stop hooks only."
66
63
  });
67
64
  }
68
65
  function stringOrUndefined(value) {
@@ -9,8 +9,8 @@ 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 = "sh -c 'if [ -n \"${VCM_CTL_COMMAND:-}\" ]; then eval \"$VCM_CTL_COMMAND hook-event\"; else vcmctl hook-event; fi'";
13
- const VCM_HOOK_EVENTS = ["UserPromptSubmit", "Stop"];
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/stop" -H "content-type: application/json" --data-binary @- >/dev/null || true'`;
13
+ const VCM_HOOK_EVENTS = ["Stop"];
14
14
  const HARNESS_FILES = [
15
15
  {
16
16
  kind: "root-claude",
@@ -227,7 +227,7 @@ async function analyzeClaudeSettingsFile(fs, repoRoot) {
227
227
  path: CLAUDE_SETTINGS_PATH,
228
228
  action,
229
229
  reason: exists
230
- ? "Claude Code hook settings do not contain the VCM UserPromptSubmit/Stop hook bridge."
230
+ ? "Claude Code hook settings do not contain the VCM Stop hook bridge."
231
231
  : "Claude Code hook settings are missing; VCM will create them."
232
232
  },
233
233
  nextContent: action === "ok" ? undefined : nextContent
@@ -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/stop");
281
298
  });
282
299
  }
283
300
  function isPlainObject(value) {