vibe-coding-master 0.3.28 → 0.3.29

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
@@ -11,7 +11,7 @@ VCM is designed for long-running coding work where one Claude Code conversation
11
11
 
12
12
  Each role runs as a real Claude Code process inside an embedded terminal. The GUI lets the user start, stop, resume, restart, switch, observe, and manually intervene in those sessions without juggling separate terminal windows.
13
13
 
14
- When Gate Review Gates are enabled for a task, or when a Gate Reviewer session already exists, the workspace can also show a fifth `Gate Reviewer` terminal role. It runs Claude Code as a project-scoped long-lived review session, receives short gate prompts that include the current task and worktree path, writes reports under the task worktree, and stays outside normal PM role routing.
14
+ When Gate Review Gates are enabled for a task, or when a Gate Reviewer session already exists, the workspace can also show a fifth `Gate Reviewer` terminal role. It is an optional VCM flow role powered by Claude Code. Its session is project-scoped and reusable across tasks, but each gate turn is bound to the current task/worktree, writes reports under the task worktree, and stays outside normal PM route-file dispatch.
15
15
 
16
16
  ## Current Capabilities
17
17
 
@@ -21,7 +21,7 @@ When Gate Review Gates are enabled for a task, or when a Gate Reviewer session a
21
21
  - Connected repository status for the base repo, including branch, upstream status, commit hash, dirty state, and fast-forward-only pull.
22
22
  - Embedded Claude Code terminals powered by `node-pty` and `xterm.js`.
23
23
  - One Claude Code session per role, with role tabs in the task header.
24
- - Optional Gate Reviewer terminal role when any Gate Review Gate is enabled.
24
+ - Optional Gate Reviewer VCM flow role when any Gate Review Gate is enabled.
25
25
  - Role session recovery through persisted Claude session ids and `claude --resume`.
26
26
  - Permission mode selection before start, resume, or restart:
27
27
  - `default`
@@ -350,14 +350,14 @@ Typical mobile flow:
350
350
  - `/pull-current`: runs the same fast-forward-only connected repository pull as the desktop `Pull` button.
351
351
  - `/tasks`: lists tasks for the selected project.
352
352
  - `/use-task <index-or-task-slug>`: selects the Gateway's current task context.
353
- - `/create-task <task-slug> [title]`: creates a worktree-backed task and starts the four role sessions using the saved launch template.
353
+ - `/create-task <task-slug> [title]`: creates a worktree-backed task and starts the four core role sessions using the saved launch template. If any Gate Review Gate is enabled, Gateway also resumes or starts Gate Reviewer from the saved template.
354
354
  - `/close-task`: starts a destructive close confirmation for the current task.
355
355
  - `/close-task confirm <task-slug>`: closes the task through VCM cleanup after exact slug confirmation.
356
356
  - `/translate on` and `/translate off`: changes Gateway translation for mobile messages.
357
357
 
358
358
  `/pull-current` only pulls the connected base repository. It does not pull task worktrees, stash local changes, merge divergent branches, or run arbitrary shell commands.
359
359
 
360
- `/create-task` uses the saved launch template from the desktop settings. The template controls permission mode, model, effort, and auto orchestration for the four core role sessions. Gateway translation uses the global Gateway translation setting, not the launch template.
360
+ `/create-task` uses the saved launch template from the desktop settings. The template controls permission mode, model, effort, and auto orchestration for the four core role sessions plus the optional Gate Reviewer. Gateway translation uses the global Gateway translation setting, not the launch template.
361
361
 
362
362
  `/close-task` is destructive. It stops VCM-managed role sessions and removes task-owned worktree/branch state according to the same cleanup behavior as the desktop `Close Task` action.
363
363
 
@@ -574,7 +574,7 @@ When it is on, VCM is in auto mode:
574
574
  - When the target role later reaches `Stop`, VCM scans again and may deliver the next pending route file.
575
575
  - If auto orchestration gets stuck after a manual copy/paste recovery, `Mark All Done` clears pending route files. It does not mutate message history.
576
576
 
577
- VCM Harness injects Claude Code `UserPromptSubmit` and `Stop` hooks into `.claude/settings.json`. Role tabs become `running` when Claude Code accepts a prompt and `idle` after `Stop`; VCM also marks a role `running` immediately after it writes a message to that embedded terminal. Gate Reviewer is a Claude Code role, but VCM triggers and settles gate turns directly around the gate prompt and report callback. The terminal process status is still tracked separately. Claude role rules require a role to end its turn after writing or updating a message route file, rather than polling, looping, or waiting for another role inside the same Claude Code turn.
577
+ VCM Harness injects Claude Code `UserPromptSubmit` and `Stop` hooks into `.claude/settings.json`. Role tabs become `running` when Claude Code accepts a prompt and `idle` after `Stop`; VCM also marks a role `running` immediately after it writes a message to that embedded terminal. Gate Reviewer uses the same Claude hook/Round/translation path as other VCM flow roles, while VCM still owns gate report polling and the PM callback. The terminal process status is tracked separately. Claude role rules require a role to end its turn after writing or updating a message route file, rather than polling, looping, or waiting for another role inside the same Claude Code turn.
578
578
 
579
579
  The implementation keeps only the active manual/auto orchestration mode. It does not expose pause/resume, stage/approve/reject, or a separate agent-facing message CLI.
580
580
 
@@ -1,4 +1,4 @@
1
- import { DISPATCHABLE_ROLES, isVcmRoleName } from "../../shared/constants.js";
1
+ import { CORE_VCM_ROLE_NAMES, DISPATCHABLE_ROLES } from "../../shared/constants.js";
2
2
  import { isOpenFileLimitError, VcmError } from "../errors.js";
3
3
  import { getTaskRuntimeRepoRoot } from "../services/task-service.js";
4
4
  export function registerTaskRoutes(app, deps) {
@@ -40,7 +40,7 @@ export function registerTaskRoutes(app, deps) {
40
40
  async function stopRunningRoleSessions(deps, repoRoot, taskSlug) {
41
41
  const sessions = await deps.sessionService.listRoleSessions(repoRoot, taskSlug);
42
42
  for (const session of sessions) {
43
- if (session.status === "running" && isVcmRoleName(session.role)) {
43
+ if (session.status === "running" && CORE_VCM_ROLE_NAMES.some((role) => role === session.role)) {
44
44
  await deps.sessionService.stopRoleSession(repoRoot, taskSlug, session.role);
45
45
  }
46
46
  }
@@ -1,5 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { VCM_ROLE_DEFINITIONS } from "../../shared/constants.js";
2
+ import { CORE_VCM_ROLE_DEFINITIONS, CORE_VCM_ROLE_NAMES, GATE_REVIEWER_ROLE_DEFINITION } from "../../shared/constants.js";
3
3
  import { VcmError } from "../errors.js";
4
4
  import { submitTerminalInput } from "../runtime/terminal-submit.js";
5
5
  import { getTaskRuntimeRepoRoot } from "../services/task-service.js";
@@ -407,17 +407,36 @@ export function createGatewayService(deps) {
407
407
  taskSlug: task.taskSlug,
408
408
  mode: template.autoOrchestration ? "auto" : "manual"
409
409
  });
410
+ const gateReviewSettings = await deps.appSettings.getGateReviewSettings(project.repoRoot, task.taskSlug);
411
+ const roleDefinitions = [
412
+ ...CORE_VCM_ROLE_DEFINITIONS,
413
+ ...(gateReviewSettings.enabled ? [GATE_REVIEWER_ROLE_DEFINITION] : [])
414
+ ];
410
415
  const startedRoles = [];
411
- for (const definition of VCM_ROLE_DEFINITIONS) {
416
+ for (const definition of roleDefinitions) {
412
417
  const roleTemplate = template.roles[definition.name];
413
418
  try {
414
- await deps.sessionService.startRoleSession(project.repoRoot, task.taskSlug, definition.name, {
419
+ const sessionInput = {
415
420
  cols: 100,
416
421
  rows: 28,
417
422
  permissionMode: roleTemplate.permissionMode,
418
423
  model: roleTemplate.model,
419
424
  effort: roleTemplate.effort
420
- });
425
+ };
426
+ const existing = await deps.sessionService.getRoleSession(project.repoRoot, task.taskSlug, definition.name);
427
+ if (existing?.status === "running") {
428
+ if (definition.name === "gate-reviewer") {
429
+ await deps.sessionService.resumeRoleSession(project.repoRoot, task.taskSlug, definition.name, sessionInput);
430
+ }
431
+ startedRoles.push(definition.name);
432
+ continue;
433
+ }
434
+ if (existing?.claudeSessionId) {
435
+ await deps.sessionService.resumeRoleSession(project.repoRoot, task.taskSlug, definition.name, sessionInput);
436
+ }
437
+ else {
438
+ await deps.sessionService.startRoleSession(project.repoRoot, task.taskSlug, definition.name, sessionInput);
439
+ }
421
440
  startedRoles.push(definition.name);
422
441
  }
423
442
  catch (error) {
@@ -536,7 +555,7 @@ export function createGatewayService(deps) {
536
555
  async function stopRunningRoleSessions(repoRoot, taskSlug) {
537
556
  const sessions = await deps.sessionService.listRoleSessions(repoRoot, taskSlug);
538
557
  for (const session of sessions) {
539
- if (session.status === "running") {
558
+ if (session.status === "running" && CORE_VCM_ROLE_NAMES.some((role) => role === session.role)) {
540
559
  await deps.sessionService.stopRoleSession(repoRoot, taskSlug, session.role);
541
560
  }
542
561
  }
@@ -1,4 +1,4 @@
1
- import { isVcmRoleName } from "../../shared/constants.js";
1
+ import { isGateReviewerRoleName, isVcmRoleName } from "../../shared/constants.js";
2
2
  import { VcmError } from "../errors.js";
3
3
  import { submitTerminalInput } from "../runtime/terminal-submit.js";
4
4
  import { getTaskRuntimeRepoRoot } from "./task-service.js";
@@ -22,15 +22,32 @@ export function createClaudeHookService(deps) {
22
22
  });
23
23
  }
24
24
  const config = await deps.projectService.loadConfig(project.repoRoot);
25
- const task = await deps.taskService.loadTask(project.repoRoot, input.taskSlug);
25
+ const taskSlug = await resolveHookTaskSlug(project.repoRoot, input);
26
+ const task = await deps.taskService.loadTask(project.repoRoot, taskSlug);
26
27
  const taskRepoRoot = getTaskRuntimeRepoRoot(task);
27
28
  return {
28
29
  project,
29
30
  config,
30
31
  task,
32
+ taskSlug,
31
33
  taskRepoRoot
32
34
  };
33
35
  }
36
+ async function resolveHookTaskSlug(repoRoot, input) {
37
+ if (!isGateReviewerRoleName(input.role)) {
38
+ return input.taskSlug;
39
+ }
40
+ const session = await deps.sessionService.getProjectGateReviewerSession(repoRoot);
41
+ if (session?.activeTaskSlug) {
42
+ return session.activeTaskSlug;
43
+ }
44
+ throw new VcmError({
45
+ code: "GATE_REVIEWER_TASK_UNBOUND",
46
+ message: "Gate Reviewer hook arrived without an active task binding.",
47
+ statusCode: 409,
48
+ hint: "Start or resume Gate Reviewer from the current task before submitting work."
49
+ });
50
+ }
34
51
  async function handleUserPromptSubmitHook(input) {
35
52
  const eventName = parseHookEvent(input.event.hook_event_name);
36
53
  if (eventName !== "UserPromptSubmit") {
@@ -39,11 +56,11 @@ export function createClaudeHookService(deps) {
39
56
  const context = await getHookContext(input);
40
57
  deps.jobGuard?.notePromptSubmitted({
41
58
  repoRoot: context.project.repoRoot,
42
- taskSlug: input.taskSlug,
59
+ taskSlug: context.taskSlug,
43
60
  role: input.role
44
61
  });
45
62
  const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
46
- taskSlug: input.taskSlug,
63
+ taskSlug: context.taskSlug,
47
64
  role: input.role,
48
65
  eventName,
49
66
  claudeSessionId: stringOrUndefined(input.event.session_id),
@@ -54,7 +71,7 @@ export function createClaudeHookService(deps) {
54
71
  repoRoot: context.project.repoRoot,
55
72
  stateRepoRoot: context.taskRepoRoot,
56
73
  stateRoot: context.config.stateRoot,
57
- taskSlug: input.taskSlug,
74
+ taskSlug: context.taskSlug,
58
75
  role: input.role,
59
76
  eventName
60
77
  });
@@ -62,7 +79,7 @@ export function createClaudeHookService(deps) {
62
79
  await deps.translationService.recordConversationBoundary({
63
80
  repoRoot: context.project.repoRoot,
64
81
  taskRepoRoot: context.taskRepoRoot,
65
- taskSlug: input.taskSlug,
82
+ taskSlug: context.taskSlug,
66
83
  role: input.role,
67
84
  sessionId: session.id,
68
85
  boundaryKind: "start",
@@ -75,14 +92,14 @@ export function createClaudeHookService(deps) {
75
92
  stateRepoRoot: context.taskRepoRoot,
76
93
  stateRoot: context.config.stateRoot,
77
94
  handoffDir: context.task.handoffDir,
78
- taskSlug: input.taskSlug,
95
+ taskSlug: context.taskSlug,
79
96
  role: input.role,
80
97
  prompt: stringOrUndefined(input.event.prompt)
81
98
  });
82
99
  return {
83
100
  ok: true,
84
101
  eventName,
85
- taskSlug: input.taskSlug,
102
+ taskSlug: context.taskSlug,
86
103
  role: input.role,
87
104
  sessionUpdated: Boolean(session),
88
105
  dispatchedCount: 0,
@@ -95,11 +112,11 @@ export function createClaudeHookService(deps) {
95
112
  throwUnsupportedEvent(eventName);
96
113
  }
97
114
  const context = await getHookContext(input);
98
- clearStopFailureRecovery(context.project.repoRoot, input.taskSlug, input.role);
115
+ clearStopFailureRecovery(context.project.repoRoot, context.taskSlug, input.role);
99
116
  if (options.allowBlock && deps.jobGuard) {
100
117
  const verdict = await deps.jobGuard.evaluateStop({
101
118
  repoRoot: context.project.repoRoot,
102
- taskSlug: input.taskSlug,
119
+ taskSlug: context.taskSlug,
103
120
  role: input.role,
104
121
  taskRepoRoot: context.taskRepoRoot
105
122
  });
@@ -109,7 +126,7 @@ export function createClaudeHookService(deps) {
109
126
  return {
110
127
  ok: true,
111
128
  eventName,
112
- taskSlug: input.taskSlug,
129
+ taskSlug: context.taskSlug,
113
130
  role: input.role,
114
131
  sessionUpdated: false,
115
132
  dispatchedCount: 0,
@@ -118,7 +135,7 @@ export function createClaudeHookService(deps) {
118
135
  }
119
136
  }
120
137
  return recordTurnEnd(input, context, eventName, {
121
- dispatchRouteFiles: true,
138
+ dispatchRouteFiles: !isGateReviewerRoleName(input.role),
122
139
  notifyGateway: true,
123
140
  settleGuard: true
124
141
  });
@@ -133,9 +150,9 @@ export function createClaudeHookService(deps) {
133
150
  const pending = await deps.messageService.listPendingRouteFiles(routeDispatchInput);
134
151
  const hasCompletionEvidence = pending.some((routeFile) => routeFile.fromRole === input.role);
135
152
  if (hasCompletionEvidence) {
136
- clearStopFailureRecovery(context.project.repoRoot, input.taskSlug, input.role);
153
+ clearStopFailureRecovery(context.project.repoRoot, context.taskSlug, input.role);
137
154
  return recordTurnEnd(input, context, eventName, {
138
- dispatchRouteFiles: true,
155
+ dispatchRouteFiles: !isGateReviewerRoleName(input.role),
139
156
  notifyGateway: false,
140
157
  settleGuard: true
141
158
  });
@@ -145,7 +162,7 @@ export function createClaudeHookService(deps) {
145
162
  return {
146
163
  ok: true,
147
164
  eventName,
148
- taskSlug: input.taskSlug,
165
+ taskSlug: context.taskSlug,
149
166
  role: input.role,
150
167
  sessionUpdated: true,
151
168
  dispatchedCount: 0
@@ -164,7 +181,7 @@ export function createClaudeHookService(deps) {
164
181
  }
165
182
  const context = await getHookContext(input);
166
183
  const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
167
- taskSlug: input.taskSlug,
184
+ taskSlug: context.taskSlug,
168
185
  role: input.role,
169
186
  eventName,
170
187
  claudeSessionId: stringOrUndefined(input.event.session_id),
@@ -174,7 +191,7 @@ export function createClaudeHookService(deps) {
174
191
  return {
175
192
  ok: true,
176
193
  eventName,
177
- taskSlug: input.taskSlug,
194
+ taskSlug: context.taskSlug,
178
195
  role: input.role,
179
196
  sessionUpdated: Boolean(session),
180
197
  dispatchedCount: 0
@@ -184,7 +201,7 @@ export function createClaudeHookService(deps) {
184
201
  const scopedRouteDispatchInput = createRouteDispatchInput(input, context, input.role);
185
202
  const settleRouteDispatchInput = createRouteDispatchInput(input, context);
186
203
  const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
187
- taskSlug: input.taskSlug,
204
+ taskSlug: context.taskSlug,
188
205
  role: input.role,
189
206
  eventName,
190
207
  claudeSessionId: stringOrUndefined(input.event.session_id),
@@ -195,7 +212,7 @@ export function createClaudeHookService(deps) {
195
212
  repoRoot: context.project.repoRoot,
196
213
  stateRepoRoot: context.taskRepoRoot,
197
214
  stateRoot: context.config.stateRoot,
198
- taskSlug: input.taskSlug,
215
+ taskSlug: context.taskSlug,
199
216
  role: input.role,
200
217
  eventName,
201
218
  ...(options.settleGuard
@@ -217,7 +234,7 @@ export function createClaudeHookService(deps) {
217
234
  await deps.translationService.recordConversationBoundary({
218
235
  repoRoot: context.project.repoRoot,
219
236
  taskRepoRoot: context.taskRepoRoot,
220
- taskSlug: input.taskSlug,
237
+ taskSlug: context.taskSlug,
221
238
  role: input.role,
222
239
  sessionId: session.id,
223
240
  boundaryKind: "end",
@@ -227,7 +244,7 @@ export function createClaudeHookService(deps) {
227
244
  if (options.notifyGateway && session && input.role === "project-manager") {
228
245
  void deps.gatewayService?.handlePmStop({
229
246
  repoRoot: context.project.repoRoot,
230
- taskSlug: input.taskSlug,
247
+ taskSlug: context.taskSlug,
231
248
  session
232
249
  }).catch(() => undefined);
233
250
  }
@@ -237,7 +254,7 @@ export function createClaudeHookService(deps) {
237
254
  return {
238
255
  ok: true,
239
256
  eventName,
240
- taskSlug: input.taskSlug,
257
+ taskSlug: context.taskSlug,
241
258
  role: input.role,
242
259
  sessionUpdated: Boolean(session),
243
260
  dispatchedCount: dispatched.filter((result) => result.delivered).length
@@ -250,7 +267,7 @@ export function createClaudeHookService(deps) {
250
267
  stateRepoRoot: context.taskRepoRoot,
251
268
  stateRoot: context.config.stateRoot,
252
269
  handoffDir: context.task.handoffDir,
253
- taskSlug: input.taskSlug,
270
+ taskSlug: context.taskSlug,
254
271
  ...(stoppedRole ? { stoppedRole } : {})
255
272
  };
256
273
  }
@@ -258,18 +275,18 @@ export function createClaudeHookService(deps) {
258
275
  if (!deps.runtime) {
259
276
  return false;
260
277
  }
261
- const key = stopFailureRecoveryKey(context.project.repoRoot, input.taskSlug, input.role);
278
+ const key = stopFailureRecoveryKey(context.project.repoRoot, context.taskSlug, input.role);
262
279
  const attempt = (stopFailureRecoveryAttempts.get(key) ?? 0) + 1;
263
280
  if (attempt > MAX_STOP_FAILURE_RECOVERY_ATTEMPTS) {
264
281
  return false;
265
282
  }
266
- const session = await deps.sessionService.getRoleSession(context.project.repoRoot, input.taskSlug, input.role);
283
+ const session = await deps.sessionService.getRoleSession(context.project.repoRoot, context.taskSlug, input.role);
267
284
  if (!session || session.status !== "running") {
268
285
  return false;
269
286
  }
270
287
  stopFailureRecoveryAttempts.set(key, attempt);
271
288
  await submitTerminalInput(deps.runtime, session.id, renderStopFailureRecoveryPrompt(attempt));
272
- await deps.sessionService.markRoleActivityRunning(context.project.repoRoot, input.taskSlug, input.role);
289
+ await deps.sessionService.markRoleActivityRunning(context.project.repoRoot, context.taskSlug, input.role);
273
290
  return true;
274
291
  }
275
292
  function clearStopFailureRecovery(repoRoot, taskSlug, role) {
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { randomUUID } from "node:crypto";
3
- import { VCM_ROLE_NAMES, isVcmRoleName } from "../../shared/constants.js";
3
+ import { CORE_VCM_ROLE_NAMES } from "../../shared/constants.js";
4
4
  import { VcmError } from "../errors.js";
5
5
  import { resolveRepoPath } from "../adapters/filesystem.js";
6
6
  import { submitTerminalInput } from "../runtime/terminal-submit.js";
@@ -282,8 +282,8 @@ async function listRouteFiles(fs, input) {
282
282
  return routeFiles;
283
283
  }
284
284
  function parseRouteFileName(fileName) {
285
- for (const fromRole of VCM_ROLE_NAMES) {
286
- for (const toRole of VCM_ROLE_NAMES) {
285
+ for (const fromRole of CORE_VCM_ROLE_NAMES) {
286
+ for (const toRole of CORE_VCM_ROLE_NAMES) {
287
287
  if (fromRole === toRole) {
288
288
  continue;
289
289
  }
@@ -371,14 +371,14 @@ function selectDispatchCandidates(routeFiles, stoppedRole) {
371
371
  });
372
372
  }
373
373
  function validateMessagePolicy(fromRole, toRole, type) {
374
- if (!VCM_ROLE_NAMES.includes(toRole)) {
374
+ if (!CORE_VCM_ROLE_NAMES.some((role) => role === toRole)) {
375
375
  throw new VcmError({
376
376
  code: "MESSAGE_TARGET_UNKNOWN",
377
377
  message: `Unknown target role: ${toRole}`,
378
378
  statusCode: 400
379
379
  });
380
380
  }
381
- if (!isVcmRoleName(String(fromRole))) {
381
+ if (!CORE_VCM_ROLE_NAMES.some((role) => role === fromRole)) {
382
382
  throw new VcmError({
383
383
  code: "MESSAGE_SENDER_UNKNOWN",
384
384
  message: `Unknown sender role: ${fromRole}`,
@@ -425,7 +425,7 @@ function getMessageDeliveryTime(message) {
425
425
  }
426
426
  async function clearRouteFileIfStillMatchesMessage(fs, input, message) {
427
427
  const fromRole = message.fromRole;
428
- if (!message.routePath || !isVcmRoleName(fromRole)) {
428
+ if (!message.routePath || !isCoreRouteRole(fromRole) || !isCoreRouteRole(message.toRole)) {
429
429
  return;
430
430
  }
431
431
  const absolutePath = resolveRepoPath(input.taskRepoRoot ?? input.repoRoot, message.routePath);
@@ -440,6 +440,9 @@ async function clearRouteFileIfStillMatchesMessage(fs, input, message) {
440
440
  await fs.writeText(absolutePath, "");
441
441
  }
442
442
  }
443
+ function isCoreRouteRole(role) {
444
+ return CORE_VCM_ROLE_NAMES.some((candidate) => candidate === role);
445
+ }
443
446
  function arraysEqual(left, right) {
444
447
  if (left.length !== right.length) {
445
448
  return false;
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { VCM_ROLE_NAMES } from "../../shared/constants.js";
2
+ import { CORE_VCM_ROLE_NAMES } from "../../shared/constants.js";
3
3
  import { VcmError } from "../errors.js";
4
4
  const DEFAULT_HANDOFF_ROOT = ".ai/vcm/handoffs";
5
5
  const DEFAULT_STATE_ROOT = ".ai/vcm";
@@ -179,7 +179,7 @@ export function buildDefaultProjectConfig(repoRoot) {
179
179
  return {
180
180
  version: 1,
181
181
  repoRoot,
182
- defaultRoles: [...VCM_ROLE_NAMES],
182
+ defaultRoles: [...CORE_VCM_ROLE_NAMES],
183
183
  handoffRoot: DEFAULT_HANDOFF_ROOT,
184
184
  stateRoot: DEFAULT_STATE_ROOT,
185
185
  terminalBackend: "node-pty",
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import path from "node:path";
3
- import { VCM_ROLE_NAMES, isCodexRoleName, isDispatchableRole } from "../../shared/constants.js";
3
+ import { CORE_VCM_ROLE_NAMES, isCodexRoleName, 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";
@@ -101,10 +101,10 @@ export function createSessionService(deps) {
101
101
  await persistRoleSessionRecord(deps.fs, repoRoot, taskRepoRoot, config.stateRoot, record);
102
102
  return record;
103
103
  }
104
- async function launchProjectGateReviewerSession(repoRoot, input, launchMode) {
104
+ async function launchProjectGateReviewerSession(repoRoot, input, launchMode, activeTaskSlug) {
105
105
  const live = toRoleSessionRecordView(getRegisteredProjectGateReviewerSession(deps.registry, deps.runtime), deps.runtime);
106
106
  if (live && live.status === "running") {
107
- return live;
107
+ return bindProjectGateReviewerSession(repoRoot, live, activeTaskSlug);
108
108
  }
109
109
  const config = await deps.projectService.loadConfig(repoRoot);
110
110
  const persisted = await loadPersistedProjectGateReviewerSession(deps.fs, repoRoot);
@@ -125,6 +125,10 @@ export function createSessionService(deps) {
125
125
  const transcriptPath = launchMode === "resume" && persisted?.transcriptPath
126
126
  ? persisted.transcriptPath
127
127
  : claudeTranscriptPath(repoRoot, claudeSessionId);
128
+ const activeTask = activeTaskSlug
129
+ ? await deps.taskService.loadTask(repoRoot, activeTaskSlug)
130
+ : undefined;
131
+ const activeTaskRepoRoot = activeTask ? getTaskRuntimeRepoRoot(activeTask) : undefined;
128
132
  const startCommand = {
129
133
  ...deps.claude.buildRoleStartCommand(GATE_REVIEWER_ROLE, config.claudeCommand, permissionMode, claudeSessionId, launchMode === "resume", model, effort),
130
134
  cwd: repoRoot
@@ -137,8 +141,8 @@ export function createSessionService(deps) {
137
141
  cwd: startCommand.cwd,
138
142
  env: {
139
143
  VCM_API_URL: deps.apiUrl,
140
- VCM_TASK_REPO_ROOT: repoRoot,
141
- VCM_TASK_SLUG: PROJECT_GATE_REVIEWER_SCOPE,
144
+ VCM_TASK_REPO_ROOT: activeTaskRepoRoot ?? repoRoot,
145
+ VCM_TASK_SLUG: activeTaskSlug ?? PROJECT_GATE_REVIEWER_SCOPE,
142
146
  VCM_ROLE: GATE_REVIEWER_ROLE,
143
147
  VCM_SESSION_ID: claudeSessionId
144
148
  },
@@ -164,12 +168,31 @@ export function createSessionService(deps) {
164
168
  startedAt: runtimeSession.startedAt,
165
169
  updatedAt: timestamp,
166
170
  lastOutputAt: runtimeSession.lastOutputAt,
171
+ activeTaskSlug,
172
+ activeTaskRepoRoot,
167
173
  exitCode: runtimeSession.exitCode
168
174
  };
169
175
  deps.registry.upsert(record);
170
176
  await persistProjectGateReviewerSession(deps.fs, repoRoot, record);
171
177
  return record;
172
178
  }
179
+ async function bindProjectGateReviewerSession(repoRoot, record, activeTaskSlug) {
180
+ if (!activeTaskSlug) {
181
+ return record;
182
+ }
183
+ const task = await deps.taskService.loadTask(repoRoot, activeTaskSlug);
184
+ const activeTaskRepoRoot = getTaskRuntimeRepoRoot(task);
185
+ const updated = {
186
+ ...record,
187
+ taskSlug: PROJECT_GATE_REVIEWER_SCOPE,
188
+ activeTaskSlug,
189
+ activeTaskRepoRoot,
190
+ updatedAt: now()
191
+ };
192
+ deps.registry.upsert(updated);
193
+ await persistProjectGateReviewerSession(deps.fs, repoRoot, updated);
194
+ return updated;
195
+ }
173
196
  async function launchProjectTranslatorSession(repoRoot, input, launchMode) {
174
197
  const live = toRoleSessionRecordView(getRegisteredProjectTranslatorSession(deps.registry, deps.runtime), deps.runtime);
175
198
  if (live && live.status === "running") {
@@ -377,7 +400,7 @@ export function createSessionService(deps) {
377
400
  },
378
401
  startRoleSession(repoRoot, taskSlug, role, input = {}) {
379
402
  if (role === GATE_REVIEWER_ROLE) {
380
- return this.startProjectGateReviewerSession(repoRoot, input)
403
+ return launchProjectGateReviewerSession(repoRoot, input, "fresh", taskSlug)
381
404
  .then((session) => scopeProjectRoleSession(session, taskSlug));
382
405
  }
383
406
  if (role === CODEX_TRANSLATOR_ROLE) {
@@ -388,7 +411,7 @@ export function createSessionService(deps) {
388
411
  },
389
412
  resumeRoleSession(repoRoot, taskSlug, role, input = {}) {
390
413
  if (role === GATE_REVIEWER_ROLE) {
391
- return this.resumeProjectGateReviewerSession(repoRoot, input)
414
+ return launchProjectGateReviewerSession(repoRoot, input, "resume", taskSlug)
392
415
  .then((session) => scopeProjectRoleSession(session, taskSlug));
393
416
  }
394
417
  if (role === CODEX_TRANSLATOR_ROLE) {
@@ -430,7 +453,14 @@ export function createSessionService(deps) {
430
453
  },
431
454
  async restartRoleSession(repoRoot, taskSlug, role, input = {}) {
432
455
  if (role === GATE_REVIEWER_ROLE) {
433
- return scopeProjectRoleSession(await this.restartProjectGateReviewerSession(repoRoot, input), taskSlug);
456
+ const existing = await this.getProjectGateReviewerSession(repoRoot);
457
+ if (existing && deps.runtime.getSession(existing.id)) {
458
+ await deps.runtime.stop(existing.id);
459
+ }
460
+ if (existing) {
461
+ deps.registry.remove(existing.id);
462
+ }
463
+ return scopeProjectRoleSession(await launchProjectGateReviewerSession(repoRoot, input, "fresh", taskSlug), taskSlug);
434
464
  }
435
465
  if (role === CODEX_TRANSLATOR_ROLE) {
436
466
  void taskSlug;
@@ -448,7 +478,11 @@ export function createSessionService(deps) {
448
478
  },
449
479
  async getRoleSession(repoRoot, taskSlug, role) {
450
480
  if (role === GATE_REVIEWER_ROLE) {
451
- return scopeProjectRoleSession(await this.getProjectGateReviewerSession(repoRoot), taskSlug);
481
+ const session = await this.getProjectGateReviewerSession(repoRoot);
482
+ if (!session) {
483
+ return undefined;
484
+ }
485
+ return scopeProjectRoleSession(await bindProjectGateReviewerSession(repoRoot, session, taskSlug), taskSlug);
452
486
  }
453
487
  if (role === CODEX_TRANSLATOR_ROLE) {
454
488
  void taskSlug;
@@ -470,7 +504,7 @@ export function createSessionService(deps) {
470
504
  const task = await deps.taskService.loadTask(repoRoot, taskSlug);
471
505
  const taskRepoRoot = getTaskRuntimeRepoRoot(task);
472
506
  const persistedTaskSession = await loadPersistedTaskSessionRecord(deps.fs, taskRepoRoot, config.stateRoot, taskSlug);
473
- for (const role of VCM_ROLE_NAMES) {
507
+ for (const role of CORE_VCM_ROLE_NAMES) {
474
508
  const record = deps.registry.getByRole(taskSlug, role)
475
509
  ?? normalizePersistedRoleRecord(persistedTaskSession?.roles[role]?.record);
476
510
  const session = toRoleSessionRecordView(record, deps.runtime);
@@ -1,6 +1,6 @@
1
1
  export const DEFAULT_BACKEND_PORT = 4173;
2
2
  export const DEFAULT_FRONTEND_PORT = 5173;
3
- export const VCM_ROLE_DEFINITIONS = [
3
+ export const CORE_VCM_ROLE_DEFINITIONS = [
4
4
  {
5
5
  name: "project-manager",
6
6
  label: "Project Manager",
@@ -32,6 +32,10 @@ export const GATE_REVIEWER_ROLE_DEFINITION = {
32
32
  commandAgent: "gate-reviewer",
33
33
  dispatchable: false
34
34
  };
35
+ export const VCM_ROLE_DEFINITIONS = [
36
+ ...CORE_VCM_ROLE_DEFINITIONS,
37
+ GATE_REVIEWER_ROLE_DEFINITION
38
+ ];
35
39
  export const CODEX_TRANSLATOR_ROLE_DEFINITION = {
36
40
  name: "codex-translator",
37
41
  label: "Codex Translator",
@@ -43,9 +47,9 @@ export const CODEX_ROLE_DEFINITIONS = [
43
47
  ];
44
48
  export const ROLE_DEFINITIONS = [
45
49
  ...VCM_ROLE_DEFINITIONS,
46
- GATE_REVIEWER_ROLE_DEFINITION,
47
50
  ...CODEX_ROLE_DEFINITIONS
48
51
  ];
52
+ export const CORE_VCM_ROLE_NAMES = CORE_VCM_ROLE_DEFINITIONS.map((role) => role.name);
49
53
  export const VCM_ROLE_NAMES = VCM_ROLE_DEFINITIONS.map((role) => role.name);
50
54
  export const CODEX_ROLE_NAMES = CODEX_ROLE_DEFINITIONS.map((role) => role.name);
51
55
  export const ROLE_NAMES = ROLE_DEFINITIONS.map((role) => role.name);