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
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Each role runs as a real Claude Code process inside an embedded terminal. The GU
|
|
|
14
14
|
## Current V1 Capabilities
|
|
15
15
|
|
|
16
16
|
- GUI-first task workspace.
|
|
17
|
-
- Collapsible sidebar with repository connection,
|
|
17
|
+
- Collapsible sidebar with repository connection, settings, harness status, task creation, and task list.
|
|
18
18
|
- Recent repository path dropdown, stored locally with the five most recent paths.
|
|
19
19
|
- Embedded Claude Code terminals powered by `node-pty` and `xterm.js`.
|
|
20
20
|
- One Claude Code session per role, with role tabs in the task header.
|
|
@@ -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
|
|
@@ -156,8 +154,6 @@ project-manager
|
|
|
156
154
|
-> project-manager final acceptance, commit, and PR
|
|
157
155
|
```
|
|
158
156
|
|
|
159
|
-
The workflow status is shown in the sidebar `Workflow` section. It is a soft guide in V1: VCM highlights missing or incomplete handoff artifacts and suggests the next step, but it does not hard-block the user from manually starting or switching roles.
|
|
160
|
-
|
|
161
157
|
## Task Worktree Management
|
|
162
158
|
|
|
163
159
|
VCM uses task-level worktree management by default:
|
|
@@ -189,8 +185,7 @@ The left sidebar is intentionally compact and collapsible:
|
|
|
189
185
|
|
|
190
186
|
- `Repository Path`: path input on one row; `Recent` and `Connect` on the next row.
|
|
191
187
|
- `Repository`: connected path, branch, and working tree state. `Working tree: uncommitted changes` means `git status --porcelain` is not empty.
|
|
192
|
-
- `
|
|
193
|
-
- `Settings`: `Theme`, `Round alert`, `Messages`, and `Events`.
|
|
188
|
+
- `Settings`: `Theme`, `Round alert`, `Try alert`, `Messages`, and `Events`.
|
|
194
189
|
- `VCM Harness`: status for `CLAUDE.md`, role agent files, and `.gitignore`.
|
|
195
190
|
- `New Task`: one `task name` input.
|
|
196
191
|
- `Tasks`: task list and task status.
|
|
@@ -211,7 +206,7 @@ The same file stores recent repository paths. The translation API key is stored
|
|
|
211
206
|
|
|
212
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`.
|
|
213
208
|
|
|
214
|
-
The same sidebar also has a `Round alert` toggle. It is on by default and controls the in-app prompt plus short sound that fires when VCM detects that the current full conversation round is complete.
|
|
209
|
+
The same sidebar also has a `Round alert` toggle. It is on by default and controls the in-app prompt plus short sound 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.
|
|
215
210
|
|
|
216
211
|
Translation behavior:
|
|
217
212
|
|
|
@@ -263,31 +258,33 @@ For `.gitignore`, VCM uses a gitignore-native managed block:
|
|
|
263
258
|
|
|
264
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.
|
|
265
260
|
|
|
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.
|
|
262
|
+
|
|
266
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.
|
|
267
264
|
|
|
268
265
|
Role sessions learn VCM rules from `CLAUDE.md` and `.claude/agents/*.md`. VCM does not paste a long context block into the terminal at session start.
|
|
269
266
|
|
|
270
267
|
## Message Bus
|
|
271
268
|
|
|
272
|
-
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.
|
|
273
270
|
|
|
274
271
|
Role communication works like this:
|
|
275
272
|
|
|
276
273
|
```text
|
|
277
274
|
Claude Code role
|
|
278
|
-
->
|
|
279
|
-
->
|
|
280
|
-
->
|
|
281
|
-
->
|
|
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
|
|
282
280
|
```
|
|
283
281
|
|
|
284
|
-
Examples
|
|
282
|
+
Examples:
|
|
285
283
|
|
|
286
|
-
```
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
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
|
|
291
288
|
```
|
|
292
289
|
|
|
293
290
|
Runtime message and handoff files:
|
|
@@ -295,12 +292,12 @@ Runtime message and handoff files:
|
|
|
295
292
|
```text
|
|
296
293
|
.ai/vcm/messages/<task>.jsonl # under the task runtime repo
|
|
297
294
|
.ai/vcm/orchestration/<task>.json # under the task runtime repo
|
|
298
|
-
.ai/vcm/handoffs/messages/<
|
|
295
|
+
.ai/vcm/handoffs/messages/<from-role>-<to-role>.md
|
|
299
296
|
.ai/vcm/handoffs/role-commands/
|
|
300
297
|
.ai/vcm/handoffs/logs/
|
|
301
298
|
```
|
|
302
299
|
|
|
303
|
-
|
|
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.
|
|
304
301
|
|
|
305
302
|
## Orchestration Modes
|
|
306
303
|
|
|
@@ -308,33 +305,36 @@ VCM has a task-level `Auto orchestration` switch in the active role toolbar, imm
|
|
|
308
305
|
|
|
309
306
|
When it is off, VCM is in manual mode:
|
|
310
307
|
|
|
311
|
-
- Roles may
|
|
308
|
+
- Roles may write pending route files under `.ai/vcm/handoffs/messages/`.
|
|
312
309
|
- Messages appear in the `Messages` modal.
|
|
313
310
|
- The user can inspect them.
|
|
314
|
-
- The current GUI shows sequence, timestamp, status, body preview, path,
|
|
311
|
+
- The current GUI shows sequence, timestamp, status, body preview, path, `Copy`, and `Mark All Done`.
|
|
312
|
+
- `Mark All Done` marks open message records as `acknowledged` after the user manually copied or handled stuck queued/in-flight messages.
|
|
315
313
|
- The user decides what to do next by copying or manually acting on the message.
|
|
316
314
|
- VCM does not write to the target terminal or press Enter for the user.
|
|
317
315
|
|
|
318
316
|
When it is on, VCM is in auto mode:
|
|
319
317
|
|
|
320
318
|
- Backend policy still applies.
|
|
321
|
-
-
|
|
322
|
-
-
|
|
323
|
-
- If the target role session is running and has no active
|
|
324
|
-
-
|
|
325
|
-
-
|
|
326
|
-
-
|
|
327
|
-
-
|
|
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.
|
|
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.
|
|
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.
|
|
328
328
|
|
|
329
|
-
|
|
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.
|
|
330
330
|
|
|
331
|
-
The
|
|
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.
|
|
332
332
|
|
|
333
333
|
## Round Completion Alerts
|
|
334
334
|
|
|
335
|
-
VCM detects conversation completion from
|
|
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.
|
|
336
336
|
|
|
337
|
-
For role chains, VCM waits for the latest
|
|
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.
|
|
338
338
|
|
|
339
339
|
When `Round alert` is enabled, the frontend polls the task round state, deduplicates each completion id, shows a small `Round complete` prompt, and plays a short local sound.
|
|
340
340
|
|
|
@@ -370,6 +370,7 @@ For a connected repository, VCM uses:
|
|
|
370
370
|
<taskRepoRoot>/.ai/vcm/handoffs/validation-log.md
|
|
371
371
|
<taskRepoRoot>/.ai/vcm/handoffs/review-report.md
|
|
372
372
|
<taskRepoRoot>/.ai/vcm/handoffs/docs-sync-report.md
|
|
373
|
+
<taskRepoRoot>/.ai/vcm/handoffs/messages/<from-role>-<to-role>.md
|
|
373
374
|
<taskRepoRoot>/.ai/vcm/handoffs/role-commands/{architect,coder,reviewer}.md
|
|
374
375
|
<taskRepoRoot>/.ai/vcm/handoffs/logs/{project-manager,architect,coder,reviewer}.log
|
|
375
376
|
```
|
|
@@ -383,7 +384,6 @@ Because handoffs are scoped to `taskRepoRoot` without an extra task-name directo
|
|
|
383
384
|
The npm package publishes built output, not raw TypeScript entry files. `package.json` includes:
|
|
384
385
|
|
|
385
386
|
- `bin.vcm`: `dist/main.js`
|
|
386
|
-
- `bin.vcmctl`: `dist/cli/vcmctl.js`
|
|
387
387
|
- `files`: `dist`, `dist-frontend`, `docs`, `scripts`, `README.md`
|
|
388
388
|
- `prepack`: `npm run build && npm run verify:package`
|
|
389
389
|
|
|
@@ -5,32 +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
|
-
});
|
|
10
|
+
return deps.messageService.listPendingRouteFiles(context);
|
|
14
11
|
});
|
|
15
|
-
app.post("/api/tasks/:taskSlug/messages
|
|
12
|
+
app.post("/api/tasks/:taskSlug/messages/scan", async (request) => {
|
|
16
13
|
const context = await getRouteContext(deps, request.params.taskSlug);
|
|
17
|
-
return deps.messageService.
|
|
18
|
-
...context,
|
|
19
|
-
messageId: request.params.messageId
|
|
20
|
-
});
|
|
14
|
+
return deps.messageService.scanAndDispatchPendingRouteFiles(context);
|
|
21
15
|
});
|
|
22
|
-
app.post("/api/tasks/:taskSlug/messages
|
|
16
|
+
app.post("/api/tasks/:taskSlug/messages/mark-all-done", async (request) => {
|
|
23
17
|
const context = await getRouteContext(deps, request.params.taskSlug);
|
|
24
|
-
return deps.messageService.
|
|
18
|
+
return deps.messageService.markAllDone({
|
|
25
19
|
...context,
|
|
26
|
-
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
app.post("/api/tasks/:taskSlug/messages/:messageId/reject", async (request) => {
|
|
30
|
-
const context = await getRouteContext(deps, request.params.taskSlug);
|
|
31
|
-
return deps.messageService.rejectMessage({
|
|
32
|
-
...context,
|
|
33
|
-
messageId: request.params.messageId
|
|
20
|
+
clearRouteFiles: true
|
|
34
21
|
});
|
|
35
22
|
});
|
|
36
23
|
app.get("/api/tasks/:taskSlug/orchestration", async (request) => {
|
|
@@ -48,22 +35,7 @@ export function registerMessageRoutes(app, deps) {
|
|
|
48
35
|
}
|
|
49
36
|
return deps.messageService.updateOrchestrationState({
|
|
50
37
|
...context,
|
|
51
|
-
mode: request.body.mode
|
|
52
|
-
paused: request.body.paused
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
app.post("/api/tasks/:taskSlug/orchestration/pause", async (request) => {
|
|
56
|
-
const context = await getRouteContext(deps, request.params.taskSlug);
|
|
57
|
-
return deps.messageService.updateOrchestrationState({
|
|
58
|
-
...context,
|
|
59
|
-
paused: true
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
app.post("/api/tasks/:taskSlug/orchestration/resume", async (request) => {
|
|
63
|
-
const context = await getRouteContext(deps, request.params.taskSlug);
|
|
64
|
-
return deps.messageService.updateOrchestrationState({
|
|
65
|
-
...context,
|
|
66
|
-
paused: false
|
|
38
|
+
mode: request.body.mode
|
|
67
39
|
});
|
|
68
40
|
});
|
|
69
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";
|
|
@@ -7,6 +6,7 @@ import { createArtifactService } from "./services/artifact-service.js";
|
|
|
7
6
|
import { createClaudeAdapter } from "./adapters/claude-adapter.js";
|
|
8
7
|
import { createCommandRunner } from "./adapters/command-runner.js";
|
|
9
8
|
import { createCommandDispatcher } from "./services/command-dispatcher.js";
|
|
9
|
+
import { createClaudeHookService } from "./services/claude-hook-service.js";
|
|
10
10
|
import { createGitAdapter } from "./adapters/git-adapter.js";
|
|
11
11
|
import { createAppSettingsService } from "./services/app-settings-service.js";
|
|
12
12
|
import { createClaudeTranscriptService } from "./services/claude-transcript-service.js";
|
|
@@ -24,6 +24,7 @@ import { createTaskService } from "./services/task-service.js";
|
|
|
24
24
|
import { createTranslationService } from "./services/translation-service.js";
|
|
25
25
|
import { registerAppSettingsRoutes } from "./api/app-settings-routes.js";
|
|
26
26
|
import { registerArtifactRoutes } from "./api/artifact-routes.js";
|
|
27
|
+
import { registerClaudeHookRoutes } from "./api/claude-hook-routes.js";
|
|
27
28
|
import { registerHarnessRoutes } from "./api/harness-routes.js";
|
|
28
29
|
import { registerMessageRoutes } from "./api/message-routes.js";
|
|
29
30
|
import { registerProjectRoutes } from "./api/project-routes.js";
|
|
@@ -48,6 +49,7 @@ export async function createServer(deps, options = {}) {
|
|
|
48
49
|
});
|
|
49
50
|
});
|
|
50
51
|
registerAppSettingsRoutes(app, { appSettings: deps.appSettings });
|
|
52
|
+
registerClaudeHookRoutes(app, { claudeHookService: deps.claudeHookService });
|
|
51
53
|
registerProjectRoutes(app, { projectService: deps.projectService });
|
|
52
54
|
registerHarnessRoutes(app, {
|
|
53
55
|
projectService: deps.projectService,
|
|
@@ -137,8 +139,7 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
137
139
|
artifactService,
|
|
138
140
|
projectService,
|
|
139
141
|
taskService,
|
|
140
|
-
apiUrl: options.apiUrl
|
|
141
|
-
vcmctlCommand: options.vcmctlCommand ?? resolveVcmctlCommand()
|
|
142
|
+
apiUrl: options.apiUrl
|
|
142
143
|
});
|
|
143
144
|
const commandDispatcher = createCommandDispatcher({
|
|
144
145
|
runtime,
|
|
@@ -157,10 +158,14 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
157
158
|
sessionService,
|
|
158
159
|
taskService
|
|
159
160
|
});
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
const claudeHookService = createClaudeHookService({
|
|
162
|
+
projectService,
|
|
163
|
+
taskService,
|
|
164
|
+
sessionService,
|
|
165
|
+
messageService
|
|
163
166
|
});
|
|
167
|
+
const transcripts = createClaudeTranscriptService();
|
|
168
|
+
const roundService = createRoundService();
|
|
164
169
|
const translationService = createTranslationService({
|
|
165
170
|
runtime,
|
|
166
171
|
sessionRegistry: registry,
|
|
@@ -179,6 +184,7 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
179
184
|
artifactService,
|
|
180
185
|
harnessService,
|
|
181
186
|
commandDispatcher,
|
|
187
|
+
claudeHookService,
|
|
182
188
|
messageService,
|
|
183
189
|
roundService,
|
|
184
190
|
statusService,
|
|
@@ -189,26 +195,6 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
189
195
|
export function getDefaultStaticDir() {
|
|
190
196
|
return path.join(getAppRoot(), "dist-frontend");
|
|
191
197
|
}
|
|
192
|
-
function resolveVcmctlCommand() {
|
|
193
|
-
const appRoot = getAppRoot();
|
|
194
|
-
const currentModulePath = fileURLToPath(import.meta.url);
|
|
195
|
-
const sourceCli = path.join(appRoot, "src", "cli", "vcmctl.ts");
|
|
196
|
-
const tsxCli = path.join(appRoot, "node_modules", "tsx", "dist", "cli.mjs");
|
|
197
|
-
if (currentModulePath.includes(`${path.sep}src${path.sep}`) && existsSync(tsxCli) && existsSync(sourceCli)) {
|
|
198
|
-
return `${quoteShellArg(process.execPath)} ${quoteShellArg(tsxCli)} ${quoteShellArg(sourceCli)}`;
|
|
199
|
-
}
|
|
200
|
-
const distCli = path.join(appRoot, "dist", "cli", "vcmctl.js");
|
|
201
|
-
if (existsSync(distCli)) {
|
|
202
|
-
return `${quoteShellArg(process.execPath)} ${quoteShellArg(distCli)}`;
|
|
203
|
-
}
|
|
204
|
-
if (existsSync(tsxCli) && existsSync(sourceCli)) {
|
|
205
|
-
return `${quoteShellArg(process.execPath)} ${quoteShellArg(tsxCli)} ${quoteShellArg(sourceCli)}`;
|
|
206
|
-
}
|
|
207
|
-
return "vcmctl";
|
|
208
|
-
}
|
|
209
198
|
function getAppRoot() {
|
|
210
199
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
211
200
|
}
|
|
212
|
-
function quoteShellArg(value) {
|
|
213
|
-
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
214
|
-
}
|
|
@@ -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;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { isRoleName } from "../../shared/constants.js";
|
|
2
|
+
import { VcmError } from "../errors.js";
|
|
3
|
+
import { getTaskRuntimeRepoRoot } from "./task-service.js";
|
|
4
|
+
export function createClaudeHookService(deps) {
|
|
5
|
+
return {
|
|
6
|
+
async handleStopHook(input) {
|
|
7
|
+
if (!isRoleName(input.role)) {
|
|
8
|
+
throw new VcmError({
|
|
9
|
+
code: "HOOK_ROLE_INVALID",
|
|
10
|
+
message: `Unknown hook role: ${input.role}`,
|
|
11
|
+
statusCode: 400
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
const eventName = parseStopEvent(input.event.hook_event_name);
|
|
15
|
+
const project = await deps.projectService.getCurrentProject();
|
|
16
|
+
if (!project) {
|
|
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
|
+
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
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
ok: true,
|
|
45
|
+
eventName,
|
|
46
|
+
taskSlug: input.taskSlug,
|
|
47
|
+
role: input.role,
|
|
48
|
+
sessionUpdated: Boolean(session),
|
|
49
|
+
dispatchedCount: dispatched.filter((result) => result.delivered).length
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function parseStopEvent(value) {
|
|
55
|
+
if (value === undefined || value === "Stop") {
|
|
56
|
+
return "Stop";
|
|
57
|
+
}
|
|
58
|
+
throw new VcmError({
|
|
59
|
+
code: "HOOK_EVENT_UNSUPPORTED",
|
|
60
|
+
message: `Unsupported Claude Code hook event: ${String(value)}`,
|
|
61
|
+
statusCode: 400,
|
|
62
|
+
hint: "VCM accepts Stop hooks only."
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function stringOrUndefined(value) {
|
|
66
|
+
return typeof value === "string" ? value : undefined;
|
|
67
|
+
}
|
|
@@ -8,6 +8,9 @@ import { renderReviewerHarnessRules } from "../templates/harness/reviewer-agent.
|
|
|
8
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
|
+
const CLAUDE_SETTINGS_PATH = ".claude/settings.json";
|
|
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"];
|
|
11
14
|
const HARNESS_FILES = [
|
|
12
15
|
{
|
|
13
16
|
kind: "root-claude",
|
|
@@ -84,6 +87,7 @@ async function analyzeHarnessFiles(fs, repoRoot) {
|
|
|
84
87
|
for (const definition of HARNESS_FILES) {
|
|
85
88
|
analyses.push(await analyzeHarnessFile(fs, repoRoot, definition));
|
|
86
89
|
}
|
|
90
|
+
analyses.push(await analyzeClaudeSettingsFile(fs, repoRoot));
|
|
87
91
|
return analyses;
|
|
88
92
|
}
|
|
89
93
|
async function analyzeHarnessFile(fs, repoRoot, definition) {
|
|
@@ -188,6 +192,114 @@ function renderNewHarnessFile(definition, block) {
|
|
|
188
192
|
: "";
|
|
189
193
|
return `${frontmatter}# ${definition.title}\n\n${block}\n`;
|
|
190
194
|
}
|
|
195
|
+
async function analyzeClaudeSettingsFile(fs, repoRoot) {
|
|
196
|
+
const definition = {
|
|
197
|
+
kind: "claude-settings",
|
|
198
|
+
path: CLAUDE_SETTINGS_PATH,
|
|
199
|
+
title: "Claude Code Settings",
|
|
200
|
+
renderRules: () => ""
|
|
201
|
+
};
|
|
202
|
+
const absolutePath = resolveHarnessPath(repoRoot, CLAUDE_SETTINGS_PATH);
|
|
203
|
+
const exists = await fs.pathExists(absolutePath);
|
|
204
|
+
const current = exists
|
|
205
|
+
? parseJsonObject(await fs.readText(absolutePath))
|
|
206
|
+
: {};
|
|
207
|
+
const next = withVcmClaudeHooks(current);
|
|
208
|
+
const currentContent = exists
|
|
209
|
+
? `${JSON.stringify(current, null, 2)}\n`
|
|
210
|
+
: "";
|
|
211
|
+
const nextContent = `${JSON.stringify(next, null, 2)}\n`;
|
|
212
|
+
const action = exists
|
|
213
|
+
? currentContent === nextContent ? "ok" : "update"
|
|
214
|
+
: "create";
|
|
215
|
+
return {
|
|
216
|
+
definition,
|
|
217
|
+
status: {
|
|
218
|
+
kind: "claude-settings",
|
|
219
|
+
path: CLAUDE_SETTINGS_PATH,
|
|
220
|
+
exists,
|
|
221
|
+
hasManagedBlock: false,
|
|
222
|
+
action
|
|
223
|
+
},
|
|
224
|
+
plannedChange: action === "ok"
|
|
225
|
+
? undefined
|
|
226
|
+
: {
|
|
227
|
+
path: CLAUDE_SETTINGS_PATH,
|
|
228
|
+
action,
|
|
229
|
+
reason: exists
|
|
230
|
+
? "Claude Code hook settings do not contain the VCM Stop hook bridge."
|
|
231
|
+
: "Claude Code hook settings are missing; VCM will create them."
|
|
232
|
+
},
|
|
233
|
+
nextContent: action === "ok" ? undefined : nextContent
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function parseJsonObject(content) {
|
|
237
|
+
const parsed = JSON.parse(content);
|
|
238
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
239
|
+
return {};
|
|
240
|
+
}
|
|
241
|
+
return parsed;
|
|
242
|
+
}
|
|
243
|
+
function withVcmClaudeHooks(settings) {
|
|
244
|
+
const hooks = isPlainObject(settings.hooks)
|
|
245
|
+
? { ...settings.hooks }
|
|
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
|
+
}
|
|
259
|
+
for (const eventName of VCM_HOOK_EVENTS) {
|
|
260
|
+
const existingMatchers = Array.isArray(hooks[eventName])
|
|
261
|
+
? hooks[eventName]
|
|
262
|
+
: [];
|
|
263
|
+
hooks[eventName] = [
|
|
264
|
+
...existingMatchers.filter((entry) => !isVcmHookMatcher(entry)),
|
|
265
|
+
createVcmHookMatcher()
|
|
266
|
+
];
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
...settings,
|
|
270
|
+
hooks
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function createVcmHookMatcher() {
|
|
274
|
+
return {
|
|
275
|
+
hooks: [
|
|
276
|
+
{
|
|
277
|
+
type: "command",
|
|
278
|
+
command: VCM_HOOK_COMMAND,
|
|
279
|
+
timeout: 5
|
|
280
|
+
}
|
|
281
|
+
]
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function isVcmHookMatcher(value) {
|
|
285
|
+
if (!isPlainObject(value) || !Array.isArray(value.hooks)) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
return value.hooks.some((hook) => {
|
|
289
|
+
if (!isPlainObject(hook)) {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
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");
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
function isPlainObject(value) {
|
|
301
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
302
|
+
}
|
|
191
303
|
function renderAgentFrontmatter(name, description) {
|
|
192
304
|
return `---\nname: ${name}\ndescription: ${description}\ntools: Read, Grep, Glob, Bash, Edit, Write\n---`;
|
|
193
305
|
}
|