vibe-coding-master 0.0.7 → 0.0.9
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 +48 -11
- package/dist/backend/adapters/filesystem.js +6 -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/app-settings-service.js +90 -15
- 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 +28 -10
- 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 +6 -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 +110 -14
- package/docs/v1-architecture-design.md +168 -34
- package/docs/v1-implementation-plan.md +132 -22
- 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,16 @@ 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:END
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`.ai/vcm/` is the active VCM local control area for task state, session state, orchestration state, and task worktrees.
|
|
253
|
+
|
|
220
254
|
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
255
|
|
|
222
256
|
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 +281,8 @@ vcmctl inbox
|
|
|
247
281
|
Durable message and handoff files:
|
|
248
282
|
|
|
249
283
|
```text
|
|
250
|
-
.vcm/messages/<task>.jsonl
|
|
251
|
-
.vcm/orchestration/<task>.json
|
|
284
|
+
.ai/vcm/messages/<task>.jsonl
|
|
285
|
+
.ai/vcm/orchestration/<task>.json
|
|
252
286
|
.ai/handoffs/<task>/messages/<message-id>.md
|
|
253
287
|
.ai/handoffs/<task>/role-commands/
|
|
254
288
|
.ai/handoffs/<task>/logs/
|
|
@@ -282,7 +316,7 @@ The backend state model still contains a `paused` field for compatibility with e
|
|
|
282
316
|
Each role session stores its Claude session id and transcript path under:
|
|
283
317
|
|
|
284
318
|
```text
|
|
285
|
-
.vcm/sessions/<task>.json
|
|
319
|
+
.ai/vcm/sessions/<task>.json
|
|
286
320
|
```
|
|
287
321
|
|
|
288
322
|
Session buttons behave as follows:
|
|
@@ -297,11 +331,12 @@ Session buttons behave as follows:
|
|
|
297
331
|
For a connected repository, VCM uses:
|
|
298
332
|
|
|
299
333
|
```text
|
|
300
|
-
|
|
301
|
-
.vcm/tasks/<task>.json
|
|
302
|
-
.vcm/sessions/<task>.json
|
|
303
|
-
.vcm/messages/<task>.jsonl
|
|
304
|
-
.vcm/orchestration/<task>.json
|
|
334
|
+
~/.vcm/projects/<project-id>/config.json
|
|
335
|
+
.ai/vcm/tasks/<task>.json
|
|
336
|
+
.ai/vcm/sessions/<task>.json
|
|
337
|
+
.ai/vcm/messages/<task>.jsonl
|
|
338
|
+
.ai/vcm/orchestration/<task>.json
|
|
339
|
+
.ai/vcm/worktrees/<task>/
|
|
305
340
|
.ai/handoffs/<task>/architecture-plan.md
|
|
306
341
|
.ai/handoffs/<task>/implementation-log.md
|
|
307
342
|
.ai/handoffs/<task>/validation-log.md
|
|
@@ -311,6 +346,8 @@ For a connected repository, VCM uses:
|
|
|
311
346
|
.ai/handoffs/<task>/logs/{project-manager,architect,coder,reviewer}.log
|
|
312
347
|
```
|
|
313
348
|
|
|
349
|
+
The project config is stored under `~/.vcm` so it is durable local app state and is not hidden inside a Git-ignored repository directory. `.ai/vcm/` stays repository-local runtime state for tasks, sessions, messages, orchestration snapshots, and nested task worktrees.
|
|
350
|
+
|
|
314
351
|
## Packaging
|
|
315
352
|
|
|
316
353
|
The npm package publishes built output, not raw TypeScript entry files. `package.json` includes:
|
|
@@ -344,7 +381,7 @@ npm run build
|
|
|
344
381
|
- VCM does not isolate roles with separate worktrees in V1.
|
|
345
382
|
- VCM does not translate Claude output from raw PTY output; translation reads Claude transcript JSONL files.
|
|
346
383
|
- VCM does not write translation output into handoff artifacts unless a user or role explicitly copies it there.
|
|
347
|
-
-
|
|
384
|
+
- Role file writes happen in the task worktree when a task has a worktree.
|
|
348
385
|
- The safest sandbox today is a container or VM boundary controlled by the user.
|
|
349
386
|
|
|
350
387
|
See also:
|
|
@@ -46,6 +46,12 @@ export function createNodeFileSystemAdapter() {
|
|
|
46
46
|
}
|
|
47
47
|
await this.writeText(targetPath, content);
|
|
48
48
|
return true;
|
|
49
|
+
},
|
|
50
|
+
async removePath(targetPath, options = {}) {
|
|
51
|
+
await fs.rm(targetPath, {
|
|
52
|
+
recursive: options.recursive ?? false,
|
|
53
|
+
force: options.force ?? false
|
|
54
|
+
});
|
|
49
55
|
}
|
|
50
56
|
};
|
|
51
57
|
}
|
|
@@ -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,13 +1,13 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
const MAX_RECENT_REPOSITORIES = 5;
|
|
4
5
|
export function createAppSettingsService(deps) {
|
|
5
6
|
const settingsPath = deps.settingsPath ?? path.join(homedir(), ".vcm", "settings.json");
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
const legacyTranslationPath = deps.legacyTranslationPath
|
|
9
|
-
?? path.join(homedir(), ".vibe-coding-master", "translation.json");
|
|
7
|
+
const settingsRoot = path.dirname(settingsPath);
|
|
8
|
+
const projectIndexPath = path.join(settingsRoot, "projects", "index.json");
|
|
10
9
|
let cachedSettings = null;
|
|
10
|
+
let cachedProjectIndex = null;
|
|
11
11
|
async function loadSettings() {
|
|
12
12
|
if (cachedSettings) {
|
|
13
13
|
return cachedSettings;
|
|
@@ -17,20 +17,9 @@ export function createAppSettingsService(deps) {
|
|
|
17
17
|
if (await deps.fs.pathExists(settingsPath)) {
|
|
18
18
|
raw = await deps.fs.readJson(settingsPath);
|
|
19
19
|
}
|
|
20
|
-
else if (await deps.fs.pathExists(legacySettingsPath)) {
|
|
21
|
-
raw = await deps.fs.readJson(legacySettingsPath);
|
|
22
|
-
shouldSave = true;
|
|
23
|
-
}
|
|
24
20
|
else {
|
|
25
21
|
shouldSave = true;
|
|
26
22
|
}
|
|
27
|
-
if (!raw.translation && await deps.fs.pathExists(legacyTranslationPath)) {
|
|
28
|
-
raw = {
|
|
29
|
-
...raw,
|
|
30
|
-
translation: normalizeTranslationConfig(await deps.fs.readJson(legacyTranslationPath))
|
|
31
|
-
};
|
|
32
|
-
shouldSave = true;
|
|
33
|
-
}
|
|
34
23
|
cachedSettings = normalizeSettingsFile(raw);
|
|
35
24
|
if (shouldSave) {
|
|
36
25
|
await saveSettings(cachedSettings);
|
|
@@ -41,6 +30,26 @@ export function createAppSettingsService(deps) {
|
|
|
41
30
|
cachedSettings = settings;
|
|
42
31
|
await deps.fs.writeJsonAtomic(settingsPath, settings);
|
|
43
32
|
}
|
|
33
|
+
async function loadProjectIndex() {
|
|
34
|
+
if (cachedProjectIndex) {
|
|
35
|
+
return cachedProjectIndex;
|
|
36
|
+
}
|
|
37
|
+
if (await deps.fs.pathExists(projectIndexPath)) {
|
|
38
|
+
cachedProjectIndex = normalizeProjectIndexFile(await deps.fs.readJson(projectIndexPath));
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
cachedProjectIndex = { version: 1, projects: [] };
|
|
42
|
+
await saveProjectIndex(cachedProjectIndex);
|
|
43
|
+
}
|
|
44
|
+
return cachedProjectIndex;
|
|
45
|
+
}
|
|
46
|
+
async function saveProjectIndex(index) {
|
|
47
|
+
cachedProjectIndex = normalizeProjectIndexFile(index);
|
|
48
|
+
await deps.fs.writeJsonAtomic(projectIndexPath, cachedProjectIndex);
|
|
49
|
+
}
|
|
50
|
+
function getProjectConfigPath(repoRoot) {
|
|
51
|
+
return path.join(settingsRoot, "projects", getProjectId(repoRoot), "config.json");
|
|
52
|
+
}
|
|
44
53
|
return {
|
|
45
54
|
loadSettings,
|
|
46
55
|
async updateTranslationConfig(config) {
|
|
@@ -74,9 +83,75 @@ export function createAppSettingsService(deps) {
|
|
|
74
83
|
});
|
|
75
84
|
return recentRepositoryPaths;
|
|
76
85
|
},
|
|
86
|
+
loadProjectIndex,
|
|
87
|
+
async loadProjectConfig(repoRoot) {
|
|
88
|
+
const configPath = getProjectConfigPath(repoRoot);
|
|
89
|
+
if (!(await deps.fs.pathExists(configPath))) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
return deps.fs.readJson(configPath);
|
|
93
|
+
},
|
|
94
|
+
async saveProjectConfig(config) {
|
|
95
|
+
const configPath = getProjectConfigPath(config.repoRoot);
|
|
96
|
+
await deps.fs.writeJsonAtomic(configPath, config);
|
|
97
|
+
const projectId = getProjectId(config.repoRoot);
|
|
98
|
+
const current = await loadProjectIndex();
|
|
99
|
+
const projects = [
|
|
100
|
+
{
|
|
101
|
+
projectId,
|
|
102
|
+
repoRoot: config.repoRoot,
|
|
103
|
+
configPath,
|
|
104
|
+
lastOpenedAt: new Date().toISOString()
|
|
105
|
+
},
|
|
106
|
+
...current.projects.filter((entry) => entry.projectId !== projectId)
|
|
107
|
+
];
|
|
108
|
+
await saveProjectIndex({
|
|
109
|
+
version: 1,
|
|
110
|
+
projects
|
|
111
|
+
});
|
|
112
|
+
return config;
|
|
113
|
+
},
|
|
77
114
|
getSettingsPath() {
|
|
78
115
|
return settingsPath;
|
|
116
|
+
},
|
|
117
|
+
getProjectIndexPath() {
|
|
118
|
+
return projectIndexPath;
|
|
119
|
+
},
|
|
120
|
+
getProjectConfigPath
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function getProjectId(repoRoot) {
|
|
124
|
+
return createHash("sha256")
|
|
125
|
+
.update(path.resolve(repoRoot))
|
|
126
|
+
.digest("hex")
|
|
127
|
+
.slice(0, 16);
|
|
128
|
+
}
|
|
129
|
+
function normalizeProjectIndexFile(input) {
|
|
130
|
+
const rawProjects = Array.isArray(input.projects) ? input.projects : [];
|
|
131
|
+
const projects = [];
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
for (const value of rawProjects) {
|
|
134
|
+
if (!isObject(value)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const projectId = typeof value.projectId === "string" ? value.projectId.trim() : "";
|
|
138
|
+
const repoRoot = typeof value.repoRoot === "string" ? value.repoRoot.trim() : "";
|
|
139
|
+
const configPath = typeof value.configPath === "string" ? value.configPath.trim() : "";
|
|
140
|
+
const lastOpenedAt = typeof value.lastOpenedAt === "string" ? value.lastOpenedAt.trim() : "";
|
|
141
|
+
if (!projectId || !repoRoot || !configPath || seen.has(projectId)) {
|
|
142
|
+
continue;
|
|
79
143
|
}
|
|
144
|
+
seen.add(projectId);
|
|
145
|
+
projects.push({
|
|
146
|
+
projectId,
|
|
147
|
+
repoRoot,
|
|
148
|
+
configPath,
|
|
149
|
+
lastOpenedAt: lastOpenedAt || new Date(0).toISOString()
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
version: 1,
|
|
154
|
+
projects
|
|
80
155
|
};
|
|
81
156
|
}
|
|
82
157
|
function normalizeSettingsFile(input) {
|
|
@@ -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,6 +1,8 @@
|
|
|
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";
|
|
4
6
|
export function createProjectService(deps) {
|
|
5
7
|
let currentProject = null;
|
|
6
8
|
return {
|
|
@@ -26,10 +28,13 @@ export function createProjectService(deps) {
|
|
|
26
28
|
statusCode: 400
|
|
27
29
|
});
|
|
28
30
|
}
|
|
29
|
-
const config =
|
|
31
|
+
const config = await this.loadConfig(repoRoot);
|
|
30
32
|
await deps.fs.ensureDir(path.join(repoRoot, config.handoffRoot));
|
|
31
33
|
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "tasks"));
|
|
32
34
|
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "sessions"));
|
|
35
|
+
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "messages"));
|
|
36
|
+
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "orchestration"));
|
|
37
|
+
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "worktrees"));
|
|
33
38
|
await this.saveConfig(config, true);
|
|
34
39
|
const warnings = [];
|
|
35
40
|
let branch = "unknown";
|
|
@@ -69,21 +74,22 @@ export function createProjectService(deps) {
|
|
|
69
74
|
return deps.appSettings.getRecentRepositoryPaths();
|
|
70
75
|
},
|
|
71
76
|
async loadConfig(repoRoot) {
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
return
|
|
77
|
+
const appConfig = await deps.appSettings.loadProjectConfig(repoRoot);
|
|
78
|
+
if (appConfig) {
|
|
79
|
+
return normalizeProjectConfig(appConfig, repoRoot);
|
|
75
80
|
}
|
|
76
|
-
return
|
|
81
|
+
return buildDefaultProjectConfig(repoRoot);
|
|
77
82
|
},
|
|
78
83
|
async saveConfig(config, force = false) {
|
|
79
|
-
const
|
|
84
|
+
const normalizedConfig = normalizeProjectConfig(config, config.repoRoot);
|
|
85
|
+
const configPath = this.getConfigPath(normalizedConfig.repoRoot);
|
|
80
86
|
if (!force && await deps.fs.pathExists(configPath)) {
|
|
81
87
|
return;
|
|
82
88
|
}
|
|
83
|
-
await deps.
|
|
89
|
+
await deps.appSettings.saveProjectConfig(normalizedConfig);
|
|
84
90
|
},
|
|
85
91
|
getConfigPath(repoRoot) {
|
|
86
|
-
return
|
|
92
|
+
return deps.appSettings.getProjectConfigPath(repoRoot);
|
|
87
93
|
}
|
|
88
94
|
};
|
|
89
95
|
}
|
|
@@ -101,9 +107,21 @@ export function buildDefaultProjectConfig(repoRoot) {
|
|
|
101
107
|
version: 1,
|
|
102
108
|
repoRoot,
|
|
103
109
|
defaultRoles: [...ROLE_NAMES],
|
|
104
|
-
handoffRoot:
|
|
105
|
-
stateRoot:
|
|
110
|
+
handoffRoot: DEFAULT_HANDOFF_ROOT,
|
|
111
|
+
stateRoot: DEFAULT_STATE_ROOT,
|
|
106
112
|
terminalBackend: "node-pty",
|
|
107
113
|
claudeCommand: process.env.VCM_CLAUDE_COMMAND || "claude"
|
|
108
114
|
};
|
|
109
115
|
}
|
|
116
|
+
function normalizeProjectConfig(input, repoRoot) {
|
|
117
|
+
const fallback = buildDefaultProjectConfig(repoRoot);
|
|
118
|
+
return {
|
|
119
|
+
version: 1,
|
|
120
|
+
repoRoot,
|
|
121
|
+
defaultRoles: input.defaultRoles?.length ? input.defaultRoles : fallback.defaultRoles,
|
|
122
|
+
handoffRoot: input.handoffRoot || fallback.handoffRoot,
|
|
123
|
+
stateRoot: DEFAULT_STATE_ROOT,
|
|
124
|
+
terminalBackend: "node-pty",
|
|
125
|
+
claudeCommand: input.claudeCommand || fallback.claudeCommand
|
|
126
|
+
};
|
|
127
|
+
}
|