vibe-coding-master 0.0.7 → 0.0.8
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 +47 -11
- package/dist/backend/adapters/filesystem.js +13 -0
- package/dist/backend/adapters/git-adapter.js +79 -1
- package/dist/backend/api/artifact-routes.js +13 -7
- package/dist/backend/api/message-routes.js +2 -0
- package/dist/backend/api/task-routes.js +14 -0
- package/dist/backend/server.js +2 -1
- package/dist/backend/services/command-dispatcher.js +4 -2
- package/dist/backend/services/harness-service.js +22 -4
- package/dist/backend/services/message-service.js +1 -1
- package/dist/backend/services/project-service.js +46 -9
- package/dist/backend/services/session-service.js +7 -5
- package/dist/backend/services/status-service.js +3 -1
- package/dist/backend/services/task-service.js +118 -4
- package/dist/backend/templates/harness/gitignore.js +9 -0
- package/dist-frontend/assets/index-CuiNNOzj.css +32 -0
- package/dist-frontend/assets/{index-Bp49_End.js → index-D59GuHCR.js} +18 -18
- package/dist-frontend/index.html +2 -2
- package/docs/cc-best-practices.md +16 -4
- package/docs/product-design.md +105 -14
- package/docs/v1-architecture-design.md +163 -29
- package/docs/v1-implementation-plan.md +131 -15
- package/package.json +1 -1
- package/dist-frontend/assets/index-BNASqKEK.css +0 -32
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ Each role runs as a real Claude Code process inside an embedded terminal. The GU
|
|
|
25
25
|
- `--dangerously-skip-permissions`
|
|
26
26
|
- PM-mediated role messaging through `vcmctl`.
|
|
27
27
|
- Manual and automatic orchestration modes.
|
|
28
|
-
- VCM harness installer for `CLAUDE.md
|
|
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.
|
|
30
30
|
- Durable task state, session state, raw terminal logs, handoff artifacts, and message history.
|
|
31
31
|
|
|
@@ -158,6 +158,29 @@ project-manager
|
|
|
158
158
|
|
|
159
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
160
|
|
|
161
|
+
## Task Worktree Management
|
|
162
|
+
|
|
163
|
+
VCM uses task-level worktree management:
|
|
164
|
+
|
|
165
|
+
```text
|
|
166
|
+
one task = one branch + one git worktree + one handoff directory + one role-session set
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The default when creating a task:
|
|
170
|
+
|
|
171
|
+
- task name: `<task>`
|
|
172
|
+
- branch: `feature/<task>`
|
|
173
|
+
- worktree path: `.ai/vcm/worktrees/<task>` inside the connected base repository
|
|
174
|
+
- role session cwd: that task worktree
|
|
175
|
+
|
|
176
|
+
VCM will not create worktrees per role. `project-manager`, `architect`, `coder`, and `reviewer` for the same task share the same task worktree.
|
|
177
|
+
|
|
178
|
+
VCM will not offer a separate `Create task worktree` button after a task exists, and a task should not be switched to another branch/worktree after creation.
|
|
179
|
+
|
|
180
|
+
Because worktrees live under `.ai/vcm/worktrees/`, the connected repository must ignore `.ai/vcm/`. Apply the VCM Harness before creating tasks so `.gitignore` contains the managed ignore block. The base repository must also be clean because the task branch/worktree is created from the connected repo's current `HEAD`.
|
|
181
|
+
|
|
182
|
+
When a task is complete, VCM provides a guarded cleanup action that removes the task worktree and VCM task metadata. Cleanup refuses to remove a dirty task worktree unless a force cleanup is requested through the API. Branch deletion stays separate and requires explicit confirmation.
|
|
183
|
+
|
|
161
184
|
## Sidebar UI
|
|
162
185
|
|
|
163
186
|
The left sidebar is intentionally compact and collapsible:
|
|
@@ -166,7 +189,7 @@ The left sidebar is intentionally compact and collapsible:
|
|
|
166
189
|
- `Repository`: connected path, branch, and working tree state. `Working tree: uncommitted changes` means `git status --porcelain` is not empty.
|
|
167
190
|
- `Workflow`: current soft gate and five workflow steps.
|
|
168
191
|
- `Settings`: `Messages`, `Events`, and the `Auto orchestration` on/off toggle.
|
|
169
|
-
- `VCM Harness`: status for `CLAUDE.md
|
|
192
|
+
- `VCM Harness`: status for `CLAUDE.md`, role agent files, and `.gitignore`.
|
|
170
193
|
- `New Task`: one `task name` input.
|
|
171
194
|
- `Tasks`: task list and task status.
|
|
172
195
|
|
|
@@ -203,6 +226,7 @@ VCM works best when the connected repository contains VCM collaboration rules as
|
|
|
203
226
|
|
|
204
227
|
```text
|
|
205
228
|
CLAUDE.md
|
|
229
|
+
.gitignore
|
|
206
230
|
.claude/agents/project-manager.md
|
|
207
231
|
.claude/agents/architect.md
|
|
208
232
|
.claude/agents/coder.md
|
|
@@ -217,6 +241,17 @@ VCM-managed rules live here.
|
|
|
217
241
|
<!-- VCM:END -->
|
|
218
242
|
```
|
|
219
243
|
|
|
244
|
+
For `.gitignore`, VCM uses a gitignore-native managed block:
|
|
245
|
+
|
|
246
|
+
```gitignore
|
|
247
|
+
# VCM:BEGIN version=1
|
|
248
|
+
.ai/vcm/
|
|
249
|
+
.vcm/
|
|
250
|
+
# VCM:END
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`.ai/vcm/` is the active VCM local control area. `.vcm/` is ignored only as a legacy safety rule so older local state cannot be accidentally committed during migration.
|
|
254
|
+
|
|
220
255
|
After applying harness changes, VCM reports the exact files changed and reminds the user to review and commit them before starting long-running work.
|
|
221
256
|
|
|
222
257
|
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.
|
|
@@ -247,8 +282,8 @@ vcmctl inbox
|
|
|
247
282
|
Durable message and handoff files:
|
|
248
283
|
|
|
249
284
|
```text
|
|
250
|
-
.vcm/messages/<task>.jsonl
|
|
251
|
-
.vcm/orchestration/<task>.json
|
|
285
|
+
.ai/vcm/messages/<task>.jsonl
|
|
286
|
+
.ai/vcm/orchestration/<task>.json
|
|
252
287
|
.ai/handoffs/<task>/messages/<message-id>.md
|
|
253
288
|
.ai/handoffs/<task>/role-commands/
|
|
254
289
|
.ai/handoffs/<task>/logs/
|
|
@@ -282,7 +317,7 @@ The backend state model still contains a `paused` field for compatibility with e
|
|
|
282
317
|
Each role session stores its Claude session id and transcript path under:
|
|
283
318
|
|
|
284
319
|
```text
|
|
285
|
-
.vcm/sessions/<task>.json
|
|
320
|
+
.ai/vcm/sessions/<task>.json
|
|
286
321
|
```
|
|
287
322
|
|
|
288
323
|
Session buttons behave as follows:
|
|
@@ -297,11 +332,12 @@ Session buttons behave as follows:
|
|
|
297
332
|
For a connected repository, VCM uses:
|
|
298
333
|
|
|
299
334
|
```text
|
|
300
|
-
.vcm/config.json
|
|
301
|
-
.vcm/tasks/<task>.json
|
|
302
|
-
.vcm/sessions/<task>.json
|
|
303
|
-
.vcm/messages/<task>.jsonl
|
|
304
|
-
.vcm/orchestration/<task>.json
|
|
335
|
+
.ai/vcm/config.json
|
|
336
|
+
.ai/vcm/tasks/<task>.json
|
|
337
|
+
.ai/vcm/sessions/<task>.json
|
|
338
|
+
.ai/vcm/messages/<task>.jsonl
|
|
339
|
+
.ai/vcm/orchestration/<task>.json
|
|
340
|
+
.ai/vcm/worktrees/<task>/
|
|
305
341
|
.ai/handoffs/<task>/architecture-plan.md
|
|
306
342
|
.ai/handoffs/<task>/implementation-log.md
|
|
307
343
|
.ai/handoffs/<task>/validation-log.md
|
|
@@ -344,7 +380,7 @@ npm run build
|
|
|
344
380
|
- VCM does not isolate roles with separate worktrees in V1.
|
|
345
381
|
- VCM does not translate Claude output from raw PTY output; translation reads Claude transcript JSONL files.
|
|
346
382
|
- VCM does not write translation output into handoff artifacts unless a user or role explicitly copies it there.
|
|
347
|
-
-
|
|
383
|
+
- Role file writes happen in the task worktree when a task has a worktree.
|
|
348
384
|
- The safest sandbox today is a container or VM boundary controlled by the user.
|
|
349
385
|
|
|
350
386
|
See also:
|
|
@@ -46,6 +46,19 @@ export function createNodeFileSystemAdapter() {
|
|
|
46
46
|
}
|
|
47
47
|
await this.writeText(targetPath, content);
|
|
48
48
|
return true;
|
|
49
|
+
},
|
|
50
|
+
async copyDir(sourcePath, targetPath) {
|
|
51
|
+
await fs.cp(sourcePath, targetPath, {
|
|
52
|
+
recursive: true,
|
|
53
|
+
force: false,
|
|
54
|
+
errorOnExist: false
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
async removePath(targetPath, options = {}) {
|
|
58
|
+
await fs.rm(targetPath, {
|
|
59
|
+
recursive: options.recursive ?? false,
|
|
60
|
+
force: options.force ?? false
|
|
61
|
+
});
|
|
49
62
|
}
|
|
50
63
|
};
|
|
51
64
|
}
|
|
@@ -22,6 +22,9 @@ export function createGitAdapter(runner) {
|
|
|
22
22
|
return result.stdout.trim() || "detached";
|
|
23
23
|
},
|
|
24
24
|
async isDirty(repoRoot) {
|
|
25
|
+
return (await this.getStatusPorcelain(repoRoot)).trim().length > 0;
|
|
26
|
+
},
|
|
27
|
+
async getStatusPorcelain(repoRoot) {
|
|
25
28
|
const result = await runGit(runner, repoRoot, ["status", "--porcelain"]);
|
|
26
29
|
if (result.exitCode !== 0) {
|
|
27
30
|
throw new VcmError({
|
|
@@ -31,7 +34,82 @@ export function createGitAdapter(runner) {
|
|
|
31
34
|
hint: result.stderr
|
|
32
35
|
});
|
|
33
36
|
}
|
|
34
|
-
return result.stdout
|
|
37
|
+
return result.stdout;
|
|
38
|
+
},
|
|
39
|
+
async isIgnored(repoRoot, repoRelativePath) {
|
|
40
|
+
const result = await runGit(runner, repoRoot, ["check-ignore", "-q", "--", repoRelativePath]);
|
|
41
|
+
if (result.exitCode === 0) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
if (result.exitCode === 1) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
throw new VcmError({
|
|
48
|
+
code: "GIT_ERROR",
|
|
49
|
+
message: `Unable to check whether Git ignores ${repoRelativePath}.`,
|
|
50
|
+
statusCode: 400,
|
|
51
|
+
hint: result.stderr
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
async branchExists(repoRoot, branch) {
|
|
55
|
+
const result = await runGit(runner, repoRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
56
|
+
if (result.exitCode === 0) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
if (result.exitCode === 1) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
throw new VcmError({
|
|
63
|
+
code: "GIT_ERROR",
|
|
64
|
+
message: `Unable to check Git branch: ${branch}`,
|
|
65
|
+
statusCode: 400,
|
|
66
|
+
hint: result.stderr
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
async createWorktree(input) {
|
|
70
|
+
const result = await runGit(runner, input.repoRoot, [
|
|
71
|
+
"worktree",
|
|
72
|
+
"add",
|
|
73
|
+
"-b",
|
|
74
|
+
input.branch,
|
|
75
|
+
input.worktreePath,
|
|
76
|
+
input.baseRef ?? "HEAD"
|
|
77
|
+
]);
|
|
78
|
+
if (result.exitCode !== 0) {
|
|
79
|
+
throw new VcmError({
|
|
80
|
+
code: "GIT_WORKTREE_CREATE_FAILED",
|
|
81
|
+
message: `Unable to create task worktree: ${input.worktreePath}`,
|
|
82
|
+
statusCode: 400,
|
|
83
|
+
hint: result.stderr
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
async removeWorktree(repoRoot, worktreePath, options = {}) {
|
|
88
|
+
const args = ["worktree", "remove"];
|
|
89
|
+
if (options.force) {
|
|
90
|
+
args.push("--force");
|
|
91
|
+
}
|
|
92
|
+
args.push(worktreePath);
|
|
93
|
+
const result = await runGit(runner, repoRoot, args);
|
|
94
|
+
if (result.exitCode !== 0) {
|
|
95
|
+
throw new VcmError({
|
|
96
|
+
code: "GIT_WORKTREE_REMOVE_FAILED",
|
|
97
|
+
message: `Unable to remove task worktree: ${worktreePath}`,
|
|
98
|
+
statusCode: 400,
|
|
99
|
+
hint: result.stderr
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
async deleteBranch(repoRoot, branch, options = {}) {
|
|
104
|
+
const result = await runGit(runner, repoRoot, ["branch", options.force ? "-D" : "-d", branch]);
|
|
105
|
+
if (result.exitCode !== 0) {
|
|
106
|
+
throw new VcmError({
|
|
107
|
+
code: "GIT_BRANCH_DELETE_FAILED",
|
|
108
|
+
message: `Unable to delete Git branch: ${branch}`,
|
|
109
|
+
statusCode: 400,
|
|
110
|
+
hint: result.stderr
|
|
111
|
+
});
|
|
112
|
+
}
|
|
35
113
|
}
|
|
36
114
|
};
|
|
37
115
|
}
|
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
import { isDispatchableRole, isRoleName } from "../../shared/constants.js";
|
|
2
2
|
import { VcmError } from "../errors.js";
|
|
3
|
+
import { getTaskRuntimeRepoRoot } from "../services/task-service.js";
|
|
3
4
|
export function registerArtifactRoutes(app, deps) {
|
|
4
5
|
app.get("/api/tasks/:taskSlug/artifacts", async (request) => {
|
|
5
6
|
const project = await requireCurrentProject(deps.projectService);
|
|
6
7
|
const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
|
|
8
|
+
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
7
9
|
return deps.artifactService.listArtifacts({
|
|
8
|
-
repoRoot:
|
|
10
|
+
repoRoot: taskRepoRoot,
|
|
9
11
|
handoffDir: task.handoffDir
|
|
10
12
|
});
|
|
11
13
|
});
|
|
12
14
|
app.get("/api/tasks/:taskSlug/artifacts/:artifactName", async (request) => {
|
|
13
15
|
const project = await requireCurrentProject(deps.projectService);
|
|
14
16
|
const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
|
|
15
|
-
const
|
|
17
|
+
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
18
|
+
const paths = deps.artifactService.getHandoffPaths(taskRepoRoot, task.handoffDir);
|
|
16
19
|
const artifactPath = artifactNameToPath(paths, request.params.artifactName);
|
|
17
20
|
return {
|
|
18
21
|
path: artifactPath,
|
|
19
22
|
content: await deps.artifactService.readArtifact({
|
|
20
|
-
repoRoot:
|
|
23
|
+
repoRoot: taskRepoRoot,
|
|
21
24
|
artifactPath
|
|
22
25
|
})
|
|
23
26
|
};
|
|
@@ -26,10 +29,11 @@ export function registerArtifactRoutes(app, deps) {
|
|
|
26
29
|
const project = await requireCurrentProject(deps.projectService);
|
|
27
30
|
const role = parseDispatchableRole(request.params.role);
|
|
28
31
|
const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
|
|
32
|
+
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
29
33
|
return {
|
|
30
34
|
role,
|
|
31
35
|
content: await deps.artifactService.readRoleCommand({
|
|
32
|
-
repoRoot:
|
|
36
|
+
repoRoot: taskRepoRoot,
|
|
33
37
|
handoffDir: task.handoffDir,
|
|
34
38
|
role
|
|
35
39
|
})
|
|
@@ -39,8 +43,9 @@ export function registerArtifactRoutes(app, deps) {
|
|
|
39
43
|
const project = await requireCurrentProject(deps.projectService);
|
|
40
44
|
const role = parseDispatchableRole(request.params.role);
|
|
41
45
|
const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
|
|
46
|
+
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
42
47
|
await deps.artifactService.saveRoleCommand({
|
|
43
|
-
repoRoot:
|
|
48
|
+
repoRoot: taskRepoRoot,
|
|
44
49
|
handoffDir: task.handoffDir,
|
|
45
50
|
role,
|
|
46
51
|
content: request.body.content
|
|
@@ -57,11 +62,12 @@ export function registerArtifactRoutes(app, deps) {
|
|
|
57
62
|
});
|
|
58
63
|
}
|
|
59
64
|
const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
|
|
60
|
-
const
|
|
65
|
+
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
66
|
+
const paths = deps.artifactService.getHandoffPaths(taskRepoRoot, task.handoffDir);
|
|
61
67
|
return {
|
|
62
68
|
role: request.params.role,
|
|
63
69
|
content: await deps.artifactService.readArtifact({
|
|
64
|
-
repoRoot:
|
|
70
|
+
repoRoot: taskRepoRoot,
|
|
65
71
|
artifactPath: paths.roleLogPaths[request.params.role]
|
|
66
72
|
})
|
|
67
73
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { VcmError } from "../errors.js";
|
|
2
|
+
import { getTaskRuntimeRepoRoot } from "../services/task-service.js";
|
|
2
3
|
export function registerMessageRoutes(app, deps) {
|
|
3
4
|
app.get("/api/tasks/:taskSlug/messages", async (request) => {
|
|
4
5
|
const context = await getRouteContext(deps, request.params.taskSlug);
|
|
@@ -72,6 +73,7 @@ async function getRouteContext(deps, taskSlug) {
|
|
|
72
73
|
const task = await deps.taskService.loadTask(project.repoRoot, taskSlug);
|
|
73
74
|
return {
|
|
74
75
|
repoRoot: project.repoRoot,
|
|
76
|
+
taskRepoRoot: getTaskRuntimeRepoRoot(task),
|
|
75
77
|
stateRoot: config.stateRoot,
|
|
76
78
|
handoffDir: task.handoffDir,
|
|
77
79
|
taskSlug
|
|
@@ -16,6 +16,20 @@ export function registerTaskRoutes(app, deps) {
|
|
|
16
16
|
const project = await requireCurrentProject(deps.projectService);
|
|
17
17
|
return deps.statusService.getTaskStatus(project.repoRoot, request.params.taskSlug);
|
|
18
18
|
});
|
|
19
|
+
app.post("/api/tasks/:taskSlug/cleanup", async (request) => {
|
|
20
|
+
const project = await requireCurrentProject(deps.projectService);
|
|
21
|
+
const sessions = await deps.sessionService.listRoleSessions(project.repoRoot, request.params.taskSlug);
|
|
22
|
+
const running = sessions.filter((session) => session.status === "running");
|
|
23
|
+
if (running.length > 0) {
|
|
24
|
+
throw new VcmError({
|
|
25
|
+
code: "TASK_SESSIONS_RUNNING",
|
|
26
|
+
message: "Stop all role sessions before cleaning up the task.",
|
|
27
|
+
statusCode: 409,
|
|
28
|
+
hint: running.map((session) => session.role).join(", ")
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return deps.taskService.cleanupTask(project.repoRoot, request.params.taskSlug, request.body ?? {});
|
|
32
|
+
});
|
|
19
33
|
}
|
|
20
34
|
async function requireCurrentProject(projectService) {
|
|
21
35
|
const project = await projectService.getCurrentProject();
|
package/dist/backend/server.js
CHANGED
|
@@ -53,7 +53,8 @@ export async function createServer(deps, options = {}) {
|
|
|
53
53
|
registerTaskRoutes(app, {
|
|
54
54
|
projectService: deps.projectService,
|
|
55
55
|
taskService: deps.taskService,
|
|
56
|
-
statusService: deps.statusService
|
|
56
|
+
statusService: deps.statusService,
|
|
57
|
+
sessionService: deps.sessionService
|
|
57
58
|
});
|
|
58
59
|
registerSessionRoutes(app, {
|
|
59
60
|
projectService: deps.projectService,
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { VcmError } from "../errors.js";
|
|
2
|
+
import { getTaskRuntimeRepoRoot } from "./task-service.js";
|
|
2
3
|
export function createCommandDispatcher(deps) {
|
|
3
4
|
const now = deps.now ?? (() => new Date().toISOString());
|
|
4
5
|
return {
|
|
5
6
|
async dispatchRoleCommand(input) {
|
|
6
7
|
const task = await deps.taskService.loadTask(input.repoRoot, input.taskSlug);
|
|
8
|
+
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
7
9
|
await deps.artifactService.readRoleCommand({
|
|
8
|
-
repoRoot:
|
|
10
|
+
repoRoot: taskRepoRoot,
|
|
9
11
|
handoffDir: task.handoffDir,
|
|
10
12
|
role: input.role
|
|
11
13
|
});
|
|
12
14
|
const commandPath = await deps.artifactService.resolveRoleCommandPath({
|
|
13
|
-
repoRoot:
|
|
15
|
+
repoRoot: taskRepoRoot,
|
|
14
16
|
handoffDir: task.handoffDir,
|
|
15
17
|
role: input.role
|
|
16
18
|
});
|
|
@@ -2,10 +2,12 @@ import path from "node:path";
|
|
|
2
2
|
import { renderArchitectHarnessRules } from "../templates/harness/architect-agent.js";
|
|
3
3
|
import { renderCoderHarnessRules } from "../templates/harness/coder-agent.js";
|
|
4
4
|
import { renderRootClaudeHarnessRules } from "../templates/harness/claude-root.js";
|
|
5
|
+
import { renderGitignoreHarnessRules } from "../templates/harness/gitignore.js";
|
|
5
6
|
import { renderProjectManagerHarnessRules } from "../templates/harness/project-manager-agent.js";
|
|
6
7
|
import { renderReviewerHarnessRules } from "../templates/harness/reviewer-agent.js";
|
|
7
8
|
export const VCM_HARNESS_VERSION = 1;
|
|
8
9
|
const MANAGED_BLOCK_PATTERN = /<!-- VCM:BEGIN(?:\s+version=(\d+))? -->[\s\S]*?<!-- VCM:END -->/m;
|
|
10
|
+
const HASH_MANAGED_BLOCK_PATTERN = /# VCM:BEGIN(?:\s+version=(\d+))?\n[\s\S]*?# VCM:END/m;
|
|
9
11
|
const HARNESS_FILES = [
|
|
10
12
|
{
|
|
11
13
|
kind: "root-claude",
|
|
@@ -13,6 +15,13 @@ const HARNESS_FILES = [
|
|
|
13
15
|
title: "CLAUDE.md",
|
|
14
16
|
renderRules: renderRootClaudeHarnessRules
|
|
15
17
|
},
|
|
18
|
+
{
|
|
19
|
+
kind: "gitignore",
|
|
20
|
+
path: ".gitignore",
|
|
21
|
+
title: ".gitignore",
|
|
22
|
+
commentStyle: "hash",
|
|
23
|
+
renderRules: renderGitignoreHarnessRules
|
|
24
|
+
},
|
|
16
25
|
{
|
|
17
26
|
kind: "agent-project-manager",
|
|
18
27
|
path: ".claude/agents/project-manager.md",
|
|
@@ -79,7 +88,8 @@ async function analyzeHarnessFiles(fs, repoRoot) {
|
|
|
79
88
|
}
|
|
80
89
|
async function analyzeHarnessFile(fs, repoRoot, definition) {
|
|
81
90
|
const absolutePath = resolveHarnessPath(repoRoot, definition.path);
|
|
82
|
-
const expectedBlock = renderManagedBlock(definition.renderRules());
|
|
91
|
+
const expectedBlock = renderManagedBlock(definition, definition.renderRules());
|
|
92
|
+
const managedBlockPattern = getManagedBlockPattern(definition);
|
|
83
93
|
const exists = await fs.pathExists(absolutePath);
|
|
84
94
|
if (!exists) {
|
|
85
95
|
return {
|
|
@@ -100,7 +110,7 @@ async function analyzeHarnessFile(fs, repoRoot, definition) {
|
|
|
100
110
|
};
|
|
101
111
|
}
|
|
102
112
|
const currentContent = await fs.readText(absolutePath);
|
|
103
|
-
const match = currentContent.match(
|
|
113
|
+
const match = currentContent.match(managedBlockPattern);
|
|
104
114
|
if (!match) {
|
|
105
115
|
return {
|
|
106
116
|
definition,
|
|
@@ -143,7 +153,7 @@ async function analyzeHarnessFile(fs, repoRoot, definition) {
|
|
|
143
153
|
},
|
|
144
154
|
nextContent: action === "ok"
|
|
145
155
|
? undefined
|
|
146
|
-
: currentContent.replace(
|
|
156
|
+
: currentContent.replace(managedBlockPattern, expectedBlock)
|
|
147
157
|
};
|
|
148
158
|
}
|
|
149
159
|
function renderHarnessStatus(analyses) {
|
|
@@ -161,9 +171,17 @@ function renderHarnessStatus(analyses) {
|
|
|
161
171
|
: []
|
|
162
172
|
};
|
|
163
173
|
}
|
|
164
|
-
function renderManagedBlock(rules) {
|
|
174
|
+
function renderManagedBlock(definition, rules) {
|
|
175
|
+
if (definition.commentStyle === "hash") {
|
|
176
|
+
return `# VCM:BEGIN version=${VCM_HARNESS_VERSION}\n${rules.trimEnd()}\n# VCM:END`;
|
|
177
|
+
}
|
|
165
178
|
return `<!-- VCM:BEGIN version=${VCM_HARNESS_VERSION} -->\n${rules.trimEnd()}\n<!-- VCM:END -->`;
|
|
166
179
|
}
|
|
180
|
+
function getManagedBlockPattern(definition) {
|
|
181
|
+
return definition.commentStyle === "hash"
|
|
182
|
+
? HASH_MANAGED_BLOCK_PATTERN
|
|
183
|
+
: MANAGED_BLOCK_PATTERN;
|
|
184
|
+
}
|
|
167
185
|
function renderNewHarnessFile(definition, block) {
|
|
168
186
|
const frontmatter = definition.frontmatter
|
|
169
187
|
? `${definition.frontmatter.trimEnd()}\n\n`
|
|
@@ -30,7 +30,7 @@ export function createMessageService(deps) {
|
|
|
30
30
|
status: "queued",
|
|
31
31
|
createdAt: timestamp
|
|
32
32
|
};
|
|
33
|
-
message.bodyPath = await writeMessageBody(deps.fs, input.repoRoot, input.handoffDir, message);
|
|
33
|
+
message.bodyPath = await writeMessageBody(deps.fs, input.taskRepoRoot ?? input.repoRoot, input.handoffDir, message);
|
|
34
34
|
const state = await this.getOrchestrationState(input);
|
|
35
35
|
const session = await deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, input.toRole);
|
|
36
36
|
if (!session || session.status !== "running") {
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { ROLE_NAMES } from "../../shared/constants.js";
|
|
3
3
|
import { VcmError } from "../errors.js";
|
|
4
|
+
const DEFAULT_HANDOFF_ROOT = ".ai/handoffs";
|
|
5
|
+
const DEFAULT_STATE_ROOT = ".ai/vcm";
|
|
6
|
+
const LEGACY_STATE_ROOT = ".vcm";
|
|
4
7
|
export function createProjectService(deps) {
|
|
5
8
|
let currentProject = null;
|
|
9
|
+
async function migrateLegacyStateRoot(repoRoot) {
|
|
10
|
+
if (!deps.fs.copyDir) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const legacyStateRoot = path.join(repoRoot, LEGACY_STATE_ROOT);
|
|
14
|
+
const currentStateRoot = path.join(repoRoot, DEFAULT_STATE_ROOT);
|
|
15
|
+
if (!(await deps.fs.pathExists(legacyStateRoot)) || await deps.fs.pathExists(currentStateRoot)) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
await deps.fs.copyDir(legacyStateRoot, currentStateRoot);
|
|
19
|
+
}
|
|
6
20
|
return {
|
|
7
21
|
async connectProject(input) {
|
|
8
22
|
const requestedPath = input.repoPath.trim();
|
|
@@ -26,10 +40,13 @@ export function createProjectService(deps) {
|
|
|
26
40
|
statusCode: 400
|
|
27
41
|
});
|
|
28
42
|
}
|
|
29
|
-
const config =
|
|
43
|
+
const config = await this.loadConfig(repoRoot);
|
|
30
44
|
await deps.fs.ensureDir(path.join(repoRoot, config.handoffRoot));
|
|
31
45
|
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "tasks"));
|
|
32
46
|
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "sessions"));
|
|
47
|
+
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "messages"));
|
|
48
|
+
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "orchestration"));
|
|
49
|
+
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "worktrees"));
|
|
33
50
|
await this.saveConfig(config, true);
|
|
34
51
|
const warnings = [];
|
|
35
52
|
let branch = "unknown";
|
|
@@ -70,20 +87,28 @@ export function createProjectService(deps) {
|
|
|
70
87
|
},
|
|
71
88
|
async loadConfig(repoRoot) {
|
|
72
89
|
const configPath = this.getConfigPath(repoRoot);
|
|
73
|
-
if (
|
|
74
|
-
return
|
|
90
|
+
if (await deps.fs.pathExists(configPath)) {
|
|
91
|
+
return normalizeProjectConfig(await deps.fs.readJson(configPath), repoRoot);
|
|
75
92
|
}
|
|
76
|
-
|
|
93
|
+
const legacyConfigPath = path.join(repoRoot, LEGACY_STATE_ROOT, "config.json");
|
|
94
|
+
if (await deps.fs.pathExists(legacyConfigPath)) {
|
|
95
|
+
const migratedConfig = normalizeProjectConfig(await deps.fs.readJson(legacyConfigPath), repoRoot);
|
|
96
|
+
await migrateLegacyStateRoot(repoRoot);
|
|
97
|
+
await this.saveConfig(migratedConfig, true);
|
|
98
|
+
return migratedConfig;
|
|
99
|
+
}
|
|
100
|
+
return buildDefaultProjectConfig(repoRoot);
|
|
77
101
|
},
|
|
78
102
|
async saveConfig(config, force = false) {
|
|
79
|
-
const
|
|
103
|
+
const normalizedConfig = normalizeProjectConfig(config, config.repoRoot);
|
|
104
|
+
const configPath = this.getConfigPath(normalizedConfig.repoRoot);
|
|
80
105
|
if (!force && await deps.fs.pathExists(configPath)) {
|
|
81
106
|
return;
|
|
82
107
|
}
|
|
83
|
-
await deps.fs.writeJsonAtomic(configPath,
|
|
108
|
+
await deps.fs.writeJsonAtomic(configPath, normalizedConfig);
|
|
84
109
|
},
|
|
85
110
|
getConfigPath(repoRoot) {
|
|
86
|
-
return path.join(repoRoot,
|
|
111
|
+
return path.join(repoRoot, DEFAULT_STATE_ROOT, "config.json");
|
|
87
112
|
}
|
|
88
113
|
};
|
|
89
114
|
}
|
|
@@ -101,9 +126,21 @@ export function buildDefaultProjectConfig(repoRoot) {
|
|
|
101
126
|
version: 1,
|
|
102
127
|
repoRoot,
|
|
103
128
|
defaultRoles: [...ROLE_NAMES],
|
|
104
|
-
handoffRoot:
|
|
105
|
-
stateRoot:
|
|
129
|
+
handoffRoot: DEFAULT_HANDOFF_ROOT,
|
|
130
|
+
stateRoot: DEFAULT_STATE_ROOT,
|
|
106
131
|
terminalBackend: "node-pty",
|
|
107
132
|
claudeCommand: process.env.VCM_CLAUDE_COMMAND || "claude"
|
|
108
133
|
};
|
|
109
134
|
}
|
|
135
|
+
function normalizeProjectConfig(input, repoRoot) {
|
|
136
|
+
const fallback = buildDefaultProjectConfig(repoRoot);
|
|
137
|
+
return {
|
|
138
|
+
version: 1,
|
|
139
|
+
repoRoot,
|
|
140
|
+
defaultRoles: input.defaultRoles?.length ? input.defaultRoles : fallback.defaultRoles,
|
|
141
|
+
handoffRoot: input.handoffRoot || fallback.handoffRoot,
|
|
142
|
+
stateRoot: DEFAULT_STATE_ROOT,
|
|
143
|
+
terminalBackend: "node-pty",
|
|
144
|
+
claudeCommand: input.claudeCommand || fallback.claudeCommand
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -4,6 +4,7 @@ import { ROLE_NAMES, isDispatchableRole } from "../../shared/constants.js";
|
|
|
4
4
|
import { VcmError } from "../errors.js";
|
|
5
5
|
import { resolveRepoPath } from "../adapters/filesystem.js";
|
|
6
6
|
import { claudeTranscriptPath } from "./claude-transcript-service.js";
|
|
7
|
+
import { getTaskRuntimeRepoRoot } from "./task-service.js";
|
|
7
8
|
export function createSessionService(deps) {
|
|
8
9
|
const now = deps.now ?? (() => new Date().toISOString());
|
|
9
10
|
async function launchRoleSession(repoRoot, taskSlug, role, input, launchMode) {
|
|
@@ -13,7 +14,8 @@ export function createSessionService(deps) {
|
|
|
13
14
|
}
|
|
14
15
|
const config = await deps.projectService.loadConfig(repoRoot);
|
|
15
16
|
const task = await deps.taskService.loadTask(repoRoot, taskSlug);
|
|
16
|
-
const
|
|
17
|
+
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
18
|
+
const paths = deps.artifactService.getHandoffPaths(taskRepoRoot, task.handoffDir);
|
|
17
19
|
const persisted = await loadPersistedRoleRecord(deps.fs, repoRoot, config.stateRoot, taskSlug, role);
|
|
18
20
|
const permissionMode = input.permissionMode ?? persisted?.permissionMode ?? "default";
|
|
19
21
|
const claudeSessionId = launchMode === "resume"
|
|
@@ -29,14 +31,14 @@ export function createSessionService(deps) {
|
|
|
29
31
|
}
|
|
30
32
|
const transcriptPath = launchMode === "resume" && persisted?.transcriptPath
|
|
31
33
|
? persisted.transcriptPath
|
|
32
|
-
: claudeTranscriptPath(
|
|
34
|
+
: claudeTranscriptPath(taskRepoRoot, claudeSessionId);
|
|
33
35
|
const startCommand = deps.claude.buildRoleStartCommand(role, config.claudeCommand, permissionMode, claudeSessionId, launchMode === "resume");
|
|
34
36
|
const runtimeSession = await deps.runtime.createSession({
|
|
35
37
|
taskSlug,
|
|
36
38
|
role,
|
|
37
39
|
command: startCommand.command,
|
|
38
40
|
args: startCommand.args,
|
|
39
|
-
cwd:
|
|
41
|
+
cwd: taskRepoRoot,
|
|
40
42
|
env: {
|
|
41
43
|
VCM_API_URL: deps.apiUrl,
|
|
42
44
|
VCM_CTL_COMMAND: deps.vcmctlCommand,
|
|
@@ -45,7 +47,7 @@ export function createSessionService(deps) {
|
|
|
45
47
|
},
|
|
46
48
|
cols: input.cols,
|
|
47
49
|
rows: input.rows,
|
|
48
|
-
logPath: resolveRepoPath(
|
|
50
|
+
logPath: resolveRepoPath(taskRepoRoot, paths.roleLogPaths[role])
|
|
49
51
|
});
|
|
50
52
|
const timestamp = now();
|
|
51
53
|
const record = {
|
|
@@ -57,7 +59,7 @@ export function createSessionService(deps) {
|
|
|
57
59
|
status: runtimeSession.status,
|
|
58
60
|
command: startCommand.display,
|
|
59
61
|
permissionMode,
|
|
60
|
-
cwd:
|
|
62
|
+
cwd: taskRepoRoot,
|
|
61
63
|
terminalBackend: "node-pty",
|
|
62
64
|
pid: runtimeSession.pid,
|
|
63
65
|
logPath: paths.roleLogPaths[role],
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { getTaskRuntimeRepoRoot } from "./task-service.js";
|
|
1
2
|
export function createStatusService(deps) {
|
|
2
3
|
return {
|
|
3
4
|
async getTaskStatus(repoRoot, taskSlug) {
|
|
4
5
|
const task = await deps.taskService.loadTask(repoRoot, taskSlug);
|
|
6
|
+
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
5
7
|
const artifacts = await deps.artifactService.listArtifacts({
|
|
6
|
-
repoRoot,
|
|
8
|
+
repoRoot: taskRepoRoot,
|
|
7
9
|
handoffDir: task.handoffDir
|
|
8
10
|
});
|
|
9
11
|
const sessions = await deps.sessionService.listRoleSessions(repoRoot, taskSlug);
|