vibe-coding-master 0.0.12 → 0.0.13

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
@@ -170,7 +170,7 @@ The `Create worktree and branch` option is selected by default when creating a t
170
170
 
171
171
  - task name: `<task>`
172
172
  - branch: `feature/<task>`
173
- - worktree path: `.ai/vcm/worktrees/<task>` inside the connected base repository
173
+ - worktree path: `.claude/worktrees/<task>` inside the connected base repository
174
174
  - role session cwd: that task worktree
175
175
 
176
176
  VCM will not create worktrees per role. `project-manager`, `architect`, `coder`, and `reviewer` for the same task share the same task worktree.
@@ -179,7 +179,7 @@ The user can turn this option off. In that mode, VCM creates the task metadata a
179
179
 
180
180
  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 mode after creation.
181
181
 
182
- 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`.
182
+ Because worktrees live under `.claude/worktrees/`, the connected repository must ignore both `.ai/vcm/` and `.claude/worktrees/`. 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`.
183
183
 
184
184
  When a task is complete, VCM provides a red `Close Task` action. Closing a task shows a destructive confirmation, stops VCM-managed running role sessions for that task, then deletes the task worktree, deletes the task branch by default, removes the base task index entry, and removes task runtime metadata. VCM does not preflight running sessions or uncommitted changes before closing. Tasks created without a worktree only remove VCM metadata because they do not own a separate branch/worktree.
185
185
 
@@ -190,7 +190,7 @@ The left sidebar is intentionally compact and collapsible:
190
190
  - `Repository Path`: path input on one row; `Recent` and `Connect` on the next row.
191
191
  - `Repository`: connected path, branch, and working tree state. `Working tree: uncommitted changes` means `git status --porcelain` is not empty.
192
192
  - `Workflow`: current soft gate and five workflow steps.
193
- - `Settings`: `Theme`, `Messages`, `Events`, and the `Auto orchestration` on/off toggle.
193
+ - `Settings`: `Theme`, `Round alert`, `Messages`, and `Events`.
194
194
  - `VCM Harness`: status for `CLAUDE.md`, role agent files, and `.gitignore`.
195
195
  - `New Task`: one `task name` input.
196
196
  - `Tasks`: task list and task status.
@@ -199,7 +199,7 @@ All sidebar sections are collapsed by default. When no task is selected, `Reposi
199
199
 
200
200
  ## Translation
201
201
 
202
- The `Translate` button in the role toolbar opens a translation panel beside the embedded terminal. The terminal and translation panel split the available width evenly.
202
+ The role toolbar has an `Auto orchestration` on/off button immediately to the left of the `Translate` button. The `Translate` button opens a translation panel beside the embedded terminal. The terminal and translation panel split the available width evenly.
203
203
 
204
204
  Translation settings are local and stored in:
205
205
 
@@ -211,6 +211,8 @@ The same file stores recent repository paths. The translation API key is stored
211
211
 
212
212
  The sidebar `Settings` section also stores the UI theme preference in this file. The default is `system`, which follows the OS/browser color-scheme preference; users can cycle between `System`, `Light`, and `Dark`.
213
213
 
214
+ The same sidebar also has a `Round alert` toggle. It is on by default and controls the in-app prompt plus short sound that fires when VCM detects that the current full conversation round is complete.
215
+
214
216
  Translation behavior:
215
217
 
216
218
  - Provider type is OpenAI-compatible chat completions.
@@ -255,10 +257,11 @@ For `.gitignore`, VCM uses a gitignore-native managed block:
255
257
  ```gitignore
256
258
  # VCM:BEGIN version=1
257
259
  .ai/vcm/
260
+ .claude/worktrees/
258
261
  # VCM:END
259
262
  ```
260
263
 
261
- `.ai/vcm/` is the active VCM local control area. The base repo keeps the task index and nested worktrees; each task runtime repo keeps its own session, message, orchestration, and translation state.
264
+ `.ai/vcm/` is the active VCM local control area, and `.claude/worktrees/` is the Claude-compatible task worktree area. The base repo keeps the task index; each task runtime repo keeps its own session, message, orchestration, and translation state.
262
265
 
263
266
  After applying harness changes, VCM reports the exact files changed and reminds the user to review and commit them before starting long-running work.
264
267
 
@@ -301,7 +304,7 @@ The backend also keeps a compatibility role-command dispatch endpoint, but the p
301
304
 
302
305
  ## Orchestration Modes
303
306
 
304
- VCM has a task-level `Auto orchestration` switch in the sidebar `Settings` section.
307
+ VCM has a task-level `Auto orchestration` switch in the active role toolbar, immediately to the left of `Translate`.
305
308
 
306
309
  When it is off, VCM is in manual mode:
307
310
 
@@ -317,12 +320,24 @@ When it is on, VCM is in auto mode:
317
320
  - Backend policy still applies.
318
321
  - PM can send work to `architect`, `coder`, or `reviewer`.
319
322
  - Non-PM roles can reply only to `project-manager`.
320
- - If the target role session is running, VCM writes a `[VCM MESSAGE]` envelope to the target terminal and submits it.
323
+ - If the target role session is running and has no active delivered message, VCM writes a `[VCM MESSAGE]` envelope to the target terminal and submits it.
324
+ - When the GUI observes a newly delivered auto message, it switches the active role tab to that message's target role so the user can watch the next step.
325
+ - VCM enforces per-role turn-taking: each target role can have at most one in-flight delivered message.
326
+ - Additional messages to a busy target role are kept as `queued` and are not written to that terminal.
327
+ - When a role sends `vcmctl reply` or `vcmctl result`, VCM acknowledges that role's active message and delivers the next queued message for that role.
321
328
 
322
329
  The backend state model still contains a `paused` field for compatibility with existing API routes, but the current GUI exposes only a single on/off orchestration toggle.
323
330
 
324
331
  The backend still exposes stage/approve/reject compatibility APIs for automation and future UI work. They are not primary controls in the current Messages modal.
325
332
 
333
+ ## Round Completion Alerts
334
+
335
+ VCM detects conversation completion from Claude Code transcript JSONL events, not PTY silence. The backend watches each running role session and treats `stop_reason === "end_turn"` as a candidate end only after pending tool uses have finished and a short debounce passes.
336
+
337
+ For role chains, VCM waits for the latest delivered message's target role. For example, if PM sends work to Coder and Coder sends a result back to PM, the round is not complete when Coder finishes; it is complete only after PM finishes the final response. If no VCM role message is involved, the latest direct role answer can complete the round.
338
+
339
+ When `Round alert` is enabled, the frontend polls the task round state, deduplicates each completion id, shows a small `Round complete` prompt, and plays a short local sound.
340
+
326
341
  ## Resume Behavior
327
342
 
328
343
  Each role session stores its Claude session id and transcript path under:
@@ -345,7 +360,7 @@ For a connected repository, VCM uses:
345
360
  ```text
346
361
  ~/.vcm/projects/<project-id>/config.json
347
362
  <baseRepoRoot>/.ai/vcm/tasks/<task>.json
348
- <baseRepoRoot>/.ai/vcm/worktrees/<task>/
363
+ <baseRepoRoot>/.claude/worktrees/<task>/
349
364
  <taskRepoRoot>/.ai/vcm/sessions/<task>.json
350
365
  <taskRepoRoot>/.ai/vcm/messages/<task>.jsonl
351
366
  <taskRepoRoot>/.ai/vcm/orchestration/<task>.json
@@ -359,7 +374,7 @@ For a connected repository, VCM uses:
359
374
  <taskRepoRoot>/.ai/vcm/handoffs/logs/{project-manager,architect,coder,reviewer}.log
360
375
  ```
361
376
 
362
- The project config is stored under `~/.vcm` so it is durable local app state and is not hidden inside a Git-ignored repository directory. For worktree-backed tasks, `taskRepoRoot` is `<baseRepoRoot>/.ai/vcm/worktrees/<task>`; for inline tasks, `taskRepoRoot` is the connected base repo.
377
+ The project config is stored under `~/.vcm` so it is durable local app state and is not hidden inside a Git-ignored repository directory. For worktree-backed tasks, `taskRepoRoot` is `<baseRepoRoot>/.claude/worktrees/<task>`; for inline tasks, `taskRepoRoot` is the connected base repo.
363
378
 
364
379
  Because handoffs are scoped to `taskRepoRoot` without an extra task-name directory, VCM allows only one active inline task per connected repository. Use the default worktree mode for parallel tasks.
365
380
 
@@ -0,0 +1,33 @@
1
+ import { VcmError } from "../errors.js";
2
+ import { getTaskRuntimeRepoRoot } from "../services/task-service.js";
3
+ export function registerRoundRoutes(app, deps) {
4
+ app.get("/api/tasks/:taskSlug/round", async (request) => {
5
+ const project = await requireCurrentProject(deps.projectService);
6
+ const config = await deps.projectService.loadConfig(project.repoRoot);
7
+ const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
8
+ const taskRepoRoot = getTaskRuntimeRepoRoot(task);
9
+ const sessions = await deps.sessionService.listRoleSessions(project.repoRoot, request.params.taskSlug);
10
+ const messages = await deps.messageService.listMessages({
11
+ repoRoot: project.repoRoot,
12
+ stateRepoRoot: taskRepoRoot,
13
+ stateRoot: config.stateRoot,
14
+ taskSlug: request.params.taskSlug
15
+ });
16
+ return deps.roundService.getTaskRoundState({
17
+ taskSlug: request.params.taskSlug,
18
+ sessions,
19
+ messages
20
+ });
21
+ });
22
+ }
23
+ async function requireCurrentProject(projectService) {
24
+ const project = await projectService.getCurrentProject();
25
+ if (!project) {
26
+ throw new VcmError({
27
+ code: "PROJECT_NOT_CONNECTED",
28
+ message: "Connect a repository first.",
29
+ statusCode: 409
30
+ });
31
+ }
32
+ return project;
33
+ }
@@ -15,6 +15,7 @@ export function registerSessionRoutes(app, deps) {
15
15
  const role = parseRole(request.params.role);
16
16
  const session = await deps.sessionService.stopRoleSession(project.repoRoot, request.params.taskSlug, role);
17
17
  await deps.translationService.stopSession(session.id);
18
+ deps.roundService.stopSession(session.id);
18
19
  return session;
19
20
  });
20
21
  app.post("/api/tasks/:taskSlug/sessions/:role/restart", async (request) => {
@@ -23,6 +24,7 @@ export function registerSessionRoutes(app, deps) {
23
24
  const existing = await deps.sessionService.getRoleSession(project.repoRoot, request.params.taskSlug, role);
24
25
  if (existing) {
25
26
  await deps.translationService.stopSession(existing.id, { clearCache: true });
27
+ deps.roundService.stopSession(existing.id);
26
28
  }
27
29
  return deps.sessionService.restartRoleSession(project.repoRoot, request.params.taskSlug, role, request.body);
28
30
  });
@@ -22,6 +22,7 @@ export function registerTaskRoutes(app, deps) {
22
22
  const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
23
23
  await stopRunningRoleSessions(deps, project.repoRoot, request.params.taskSlug);
24
24
  await deps.translationService.stopTask(getTaskRuntimeRepoRoot(task), request.params.taskSlug, { clearCache: true });
25
+ deps.roundService.stopTask(request.params.taskSlug);
25
26
  return deps.taskService.cleanupTask(project.repoRoot, request.params.taskSlug, request.body ?? {});
26
27
  });
27
28
  }
@@ -18,6 +18,7 @@ import { createProjectService } from "./services/project-service.js";
18
18
  import { createSessionRegistry } from "./runtime/session-registry.js";
19
19
  import { createSessionService } from "./services/session-service.js";
20
20
  import { createMessageService } from "./services/message-service.js";
21
+ import { createRoundService } from "./services/round-service.js";
21
22
  import { createStatusService } from "./services/status-service.js";
22
23
  import { createTaskService } from "./services/task-service.js";
23
24
  import { createTranslationService } from "./services/translation-service.js";
@@ -26,6 +27,7 @@ import { registerArtifactRoutes } from "./api/artifact-routes.js";
26
27
  import { registerHarnessRoutes } from "./api/harness-routes.js";
27
28
  import { registerMessageRoutes } from "./api/message-routes.js";
28
29
  import { registerProjectRoutes } from "./api/project-routes.js";
30
+ import { registerRoundRoutes } from "./api/round-routes.js";
29
31
  import { registerSessionRoutes } from "./api/session-routes.js";
30
32
  import { registerTaskRoutes } from "./api/task-routes.js";
31
33
  import { registerTranslationRoutes } from "./api/translation-routes.js";
@@ -56,13 +58,15 @@ export async function createServer(deps, options = {}) {
56
58
  taskService: deps.taskService,
57
59
  sessionService: deps.sessionService,
58
60
  statusService: deps.statusService,
59
- translationService: deps.translationService
61
+ translationService: deps.translationService,
62
+ roundService: deps.roundService
60
63
  });
61
64
  registerSessionRoutes(app, {
62
65
  projectService: deps.projectService,
63
66
  sessionService: deps.sessionService,
64
67
  commandDispatcher: deps.commandDispatcher,
65
- translationService: deps.translationService
68
+ translationService: deps.translationService,
69
+ roundService: deps.roundService
66
70
  });
67
71
  registerArtifactRoutes(app, {
68
72
  projectService: deps.projectService,
@@ -74,6 +78,13 @@ export async function createServer(deps, options = {}) {
74
78
  taskService: deps.taskService,
75
79
  messageService: deps.messageService
76
80
  });
81
+ registerRoundRoutes(app, {
82
+ projectService: deps.projectService,
83
+ taskService: deps.taskService,
84
+ sessionService: deps.sessionService,
85
+ messageService: deps.messageService,
86
+ roundService: deps.roundService
87
+ });
77
88
  registerTranslationRoutes(app, {
78
89
  projectService: deps.projectService,
79
90
  taskService: deps.taskService,
@@ -146,10 +157,14 @@ export function createDefaultServerDeps(options = {}) {
146
157
  sessionService,
147
158
  taskService
148
159
  });
160
+ const transcripts = createClaudeTranscriptService();
161
+ const roundService = createRoundService({
162
+ transcripts
163
+ });
149
164
  const translationService = createTranslationService({
150
165
  runtime,
151
166
  sessionRegistry: registry,
152
- transcripts: createClaudeTranscriptService(),
167
+ transcripts,
153
168
  sessionService,
154
169
  fs,
155
170
  projectService,
@@ -165,6 +180,7 @@ export function createDefaultServerDeps(options = {}) {
165
180
  harnessService,
166
181
  commandDispatcher,
167
182
  messageService,
183
+ roundService,
168
184
  statusService,
169
185
  translationService,
170
186
  runtime
@@ -180,7 +180,8 @@ function normalizeSettingsFile(input) {
180
180
  function normalizePreferences(input) {
181
181
  const candidate = isObject(input) ? input : {};
182
182
  return {
183
- themeMode: normalizeThemeMode(candidate.themeMode)
183
+ themeMode: normalizeThemeMode(candidate.themeMode),
184
+ roundCompletionAlerts: candidate.roundCompletionAlerts !== false
184
185
  };
185
186
  }
186
187
  function normalizeThemeMode(input) {
@@ -11,55 +11,128 @@ const ROLE_TO_PM_TYPES = new Set(["result", "question", "blocked", "finding"]);
11
11
  export function createMessageService(deps) {
12
12
  const now = deps.now ?? (() => new Date().toISOString());
13
13
  const id = deps.id ?? (() => `msg_${randomUUID()}`);
14
- return {
15
- listMessages(input) {
16
- return readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
17
- },
18
- async sendMessage(input) {
19
- await deps.taskService.loadTask(input.repoRoot, input.taskSlug);
20
- validateMessagePolicy(input.fromRole, input.toRole, input.type);
21
- const timestamp = now();
22
- const message = {
23
- id: id(),
14
+ const taskLocks = new Map();
15
+ async function getOrchestrationState(input) {
16
+ const statePath = getOrchestrationStatePath(getStateRepoRoot(input), input.stateRoot, input.taskSlug);
17
+ if (!(await deps.fs.pathExists(statePath))) {
18
+ return {
24
19
  taskSlug: input.taskSlug,
25
- fromRole: input.fromRole,
26
- toRole: input.toRole,
27
- type: input.type,
28
- body: input.body,
29
- artifactRefs: input.artifactRefs ?? [],
30
- parentMessageId: input.parentMessageId,
31
- status: "queued",
32
- createdAt: timestamp
20
+ mode: "manual",
21
+ paused: false,
22
+ updatedAt: now()
33
23
  };
34
- message.bodyPath = await writeMessageBody(deps.fs, input.taskRepoRoot ?? input.repoRoot, input.handoffDir, message);
35
- const state = await this.getOrchestrationState(input);
36
- const session = await deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, input.toRole);
37
- if (!session || session.status !== "running") {
38
- message.status = "queued";
39
- message.failureReason = `${input.toRole} session is not running.`;
40
- await appendMessageSnapshot(deps.fs, input, message);
41
- return { message, delivered: false, requiresUserApproval: false };
42
- }
43
- if (state.mode === "manual") {
44
- message.status = "pending_approval";
45
- await appendMessageSnapshot(deps.fs, input, message);
46
- return { message, delivered: false, requiresUserApproval: true };
47
- }
48
- if (state.paused) {
49
- message.status = "queued";
50
- message.failureReason = "Auto orchestration is paused.";
51
- await appendMessageSnapshot(deps.fs, input, message);
52
- return { message, delivered: false, requiresUserApproval: false };
24
+ }
25
+ return deps.fs.readJson(statePath);
26
+ }
27
+ async function sendMessageLocked(input) {
28
+ await deps.taskService.loadTask(input.repoRoot, input.taskSlug);
29
+ validateMessagePolicy(input.fromRole, input.toRole, input.type);
30
+ const timestamp = now();
31
+ const message = {
32
+ id: id(),
33
+ taskSlug: input.taskSlug,
34
+ fromRole: input.fromRole,
35
+ toRole: input.toRole,
36
+ type: input.type,
37
+ body: input.body,
38
+ artifactRefs: input.artifactRefs ?? [],
39
+ parentMessageId: input.parentMessageId,
40
+ status: "queued",
41
+ createdAt: timestamp
42
+ };
43
+ message.bodyPath = await writeMessageBody(deps.fs, input.taskRepoRoot ?? input.repoRoot, input.handoffDir, message);
44
+ let messages = await readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
45
+ if (isRoleActor(input.fromRole)) {
46
+ const acknowledged = acknowledgeActiveMessages(messages, input.fromRole, timestamp);
47
+ for (const acknowledgedMessage of acknowledged) {
48
+ await appendMessageSnapshot(deps.fs, input, acknowledgedMessage);
53
49
  }
54
- const delivered = {
50
+ messages = applyMessageSnapshots(messages, acknowledged);
51
+ }
52
+ const state = await getOrchestrationState(input);
53
+ const result = await routeOutboundMessage(input, message, messages, state, timestamp);
54
+ await appendMessageSnapshot(deps.fs, input, result.message);
55
+ if (isRoleActor(input.fromRole)) {
56
+ await deliverNextQueuedMessage(input, input.fromRole);
57
+ }
58
+ return result;
59
+ }
60
+ async function routeOutboundMessage(input, message, existingMessages, state, timestamp) {
61
+ const session = await deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, input.toRole);
62
+ if (!session || session.status !== "running") {
63
+ const queued = {
55
64
  ...message,
56
- status: "delivered",
57
- deliveredAt: timestamp,
58
- failureReason: undefined
65
+ status: "queued",
66
+ failureReason: `${input.toRole} session is not running.`
59
67
  };
60
- await submitTerminalInput(deps.runtime, session.id, renderMessageEnvelope(delivered));
61
- await appendMessageSnapshot(deps.fs, input, delivered);
62
- return { message: delivered, delivered: true, requiresUserApproval: false };
68
+ return { message: queued, delivered: false, requiresUserApproval: false };
69
+ }
70
+ if (state.mode === "manual") {
71
+ const pending = {
72
+ ...message,
73
+ status: "pending_approval"
74
+ };
75
+ return { message: pending, delivered: false, requiresUserApproval: true };
76
+ }
77
+ if (state.paused) {
78
+ const queued = {
79
+ ...message,
80
+ status: "queued",
81
+ failureReason: "Auto orchestration is paused."
82
+ };
83
+ return { message: queued, delivered: false, requiresUserApproval: false };
84
+ }
85
+ const activeMessage = findActiveMessageForRole(existingMessages, input.toRole);
86
+ if (activeMessage) {
87
+ const queued = {
88
+ ...message,
89
+ status: "queued",
90
+ queuedBehindMessageId: activeMessage.id,
91
+ failureReason: `${input.toRole} is still handling VCM message ${activeMessage.id}.`
92
+ };
93
+ return { message: queued, delivered: false, requiresUserApproval: false };
94
+ }
95
+ const delivered = await deliverMessageToSession(session.id, message, timestamp);
96
+ return { message: delivered, delivered: true, requiresUserApproval: false };
97
+ }
98
+ async function deliverNextQueuedMessage(input, targetRole) {
99
+ const state = await getOrchestrationState(input);
100
+ if (state.mode !== "auto" || state.paused) {
101
+ return undefined;
102
+ }
103
+ const messages = await readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
104
+ if (findActiveMessageForRole(messages, targetRole)) {
105
+ return undefined;
106
+ }
107
+ const nextMessage = messages.find((message) => message.toRole === targetRole && message.status === "queued");
108
+ if (!nextMessage) {
109
+ return undefined;
110
+ }
111
+ const session = await deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, targetRole);
112
+ if (!session || session.status !== "running") {
113
+ return undefined;
114
+ }
115
+ const delivered = await deliverMessageToSession(session.id, nextMessage, now());
116
+ await appendMessageSnapshot(deps.fs, input, delivered);
117
+ return delivered;
118
+ }
119
+ async function deliverMessageToSession(sessionId, message, timestamp) {
120
+ const delivered = {
121
+ ...message,
122
+ status: "delivered",
123
+ deliveredAt: timestamp,
124
+ queuedBehindMessageId: undefined,
125
+ failureReason: undefined
126
+ };
127
+ await submitTerminalInput(deps.runtime, sessionId, renderMessageEnvelope(delivered));
128
+ return delivered;
129
+ }
130
+ return {
131
+ listMessages(input) {
132
+ return readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
133
+ },
134
+ async sendMessage(input) {
135
+ return withTaskLock(taskLocks, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), () => sendMessageLocked(input));
63
136
  },
64
137
  async stageMessage(input) {
65
138
  const message = await getMessageOrThrow(deps.fs, input);
@@ -96,19 +169,10 @@ export function createMessageService(deps) {
96
169
  return rejected;
97
170
  },
98
171
  async getOrchestrationState(input) {
99
- const statePath = getOrchestrationStatePath(getStateRepoRoot(input), input.stateRoot, input.taskSlug);
100
- if (!(await deps.fs.pathExists(statePath))) {
101
- return {
102
- taskSlug: input.taskSlug,
103
- mode: "manual",
104
- paused: false,
105
- updatedAt: now()
106
- };
107
- }
108
- return deps.fs.readJson(statePath);
172
+ return getOrchestrationState(input);
109
173
  },
110
174
  async updateOrchestrationState(input) {
111
- const current = await this.getOrchestrationState(input);
175
+ const current = await getOrchestrationState(input);
112
176
  const next = {
113
177
  ...current,
114
178
  mode: input.mode ?? current.mode,
@@ -158,6 +222,45 @@ function validateMessagePolicy(fromRole, toRole, type) {
158
222
  hint: "Use project-manager as the orchestration hub."
159
223
  });
160
224
  }
225
+ async function withTaskLock(locks, key, run) {
226
+ const previous = locks.get(key) ?? Promise.resolve();
227
+ const next = previous.catch(() => undefined).then(run);
228
+ locks.set(key, next);
229
+ try {
230
+ return await next;
231
+ }
232
+ finally {
233
+ if (locks.get(key) === next) {
234
+ locks.delete(key);
235
+ }
236
+ }
237
+ }
238
+ function isRoleActor(actor) {
239
+ return ROLE_NAMES.includes(actor);
240
+ }
241
+ function findActiveMessageForRole(messages, role) {
242
+ return messages.find((message) => message.toRole === role && message.status === "delivered");
243
+ }
244
+ function acknowledgeActiveMessages(messages, role, timestamp) {
245
+ return messages
246
+ .filter((message) => message.toRole === role && message.status === "delivered")
247
+ .map((message) => ({
248
+ ...message,
249
+ status: "acknowledged",
250
+ acknowledgedAt: timestamp,
251
+ failureReason: undefined
252
+ }));
253
+ }
254
+ function applyMessageSnapshots(messages, snapshots) {
255
+ if (snapshots.length === 0) {
256
+ return messages;
257
+ }
258
+ const latest = new Map(messages.map((message) => [message.id, message]));
259
+ for (const snapshot of snapshots) {
260
+ latest.set(snapshot.id, snapshot);
261
+ }
262
+ return [...latest.values()].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
263
+ }
161
264
  async function writeMessageBody(fs, repoRoot, handoffDir, message) {
162
265
  const bodyPath = path.posix.join(handoffDir, "messages", `${message.id}.md`);
163
266
  await fs.writeText(resolveRepoPath(repoRoot, bodyPath), renderMessageBodyFile(message));
@@ -31,7 +31,7 @@ export function createProjectService(deps) {
31
31
  const config = await this.loadConfig(repoRoot);
32
32
  await deps.fs.ensureDir(path.join(repoRoot, config.handoffRoot));
33
33
  await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "tasks"));
34
- await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "worktrees"));
34
+ await deps.fs.ensureDir(path.join(repoRoot, ".claude", "worktrees"));
35
35
  await this.saveConfig(config, true);
36
36
  const warnings = [];
37
37
  let branch = "unknown";