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 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` and `.claude/agents/*.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` and role agent files.
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
- - File writes still happen in the connected repository environment.
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.trim().length > 0;
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: project.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 paths = deps.artifactService.getHandoffPaths(project.repoRoot, task.handoffDir);
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: project.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: project.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: project.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 paths = deps.artifactService.getHandoffPaths(project.repoRoot, task.handoffDir);
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: project.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();
@@ -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: input.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: input.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(MANAGED_BLOCK_PATTERN);
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(MANAGED_BLOCK_PATTERN, expectedBlock)
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 = buildDefaultProjectConfig(repoRoot);
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 (!(await deps.fs.pathExists(configPath))) {
74
- return buildDefaultProjectConfig(repoRoot);
90
+ if (await deps.fs.pathExists(configPath)) {
91
+ return normalizeProjectConfig(await deps.fs.readJson(configPath), repoRoot);
75
92
  }
76
- return deps.fs.readJson(configPath);
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 configPath = this.getConfigPath(config.repoRoot);
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, config);
108
+ await deps.fs.writeJsonAtomic(configPath, normalizedConfig);
84
109
  },
85
110
  getConfigPath(repoRoot) {
86
- return path.join(repoRoot, ".vcm", "config.json");
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: ".ai/handoffs",
105
- stateRoot: ".vcm",
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 paths = deps.artifactService.getHandoffPaths(repoRoot, task.handoffDir);
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(repoRoot, claudeSessionId);
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: repoRoot,
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(repoRoot, paths.roleLogPaths[role])
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: repoRoot,
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);