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 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,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
- .vcm/config.json
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
- - File writes still happen in the connected repository environment.
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.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,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 legacySettingsPath = deps.legacySettingsPath
7
- ?? path.join(homedir(), ".vibe-coding-master", "settings.json");
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: 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,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 = buildDefaultProjectConfig(repoRoot);
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 configPath = this.getConfigPath(repoRoot);
73
- if (!(await deps.fs.pathExists(configPath))) {
74
- return buildDefaultProjectConfig(repoRoot);
77
+ const appConfig = await deps.appSettings.loadProjectConfig(repoRoot);
78
+ if (appConfig) {
79
+ return normalizeProjectConfig(appConfig, repoRoot);
75
80
  }
76
- return deps.fs.readJson(configPath);
81
+ return buildDefaultProjectConfig(repoRoot);
77
82
  },
78
83
  async saveConfig(config, force = false) {
79
- const configPath = this.getConfigPath(config.repoRoot);
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.fs.writeJsonAtomic(configPath, config);
89
+ await deps.appSettings.saveProjectConfig(normalizedConfig);
84
90
  },
85
91
  getConfigPath(repoRoot) {
86
- return path.join(repoRoot, ".vcm", "config.json");
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: ".ai/handoffs",
105
- stateRoot: ".vcm",
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
+ }