vibe-coding-master 0.3.0 → 0.3.1

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.
Files changed (35) hide show
  1. package/README.md +9 -6
  2. package/dist/backend/adapters/claude-adapter.js +4 -1
  3. package/dist/backend/api/codex-hook-routes.js +9 -0
  4. package/dist/backend/api/codex-review-routes.js +4 -0
  5. package/dist/backend/api/translation-routes.js +2 -2
  6. package/dist/backend/cli/install-vcm-harness.js +14 -1
  7. package/dist/backend/gateway/gateway-service.js +4 -3
  8. package/dist/backend/server.js +10 -0
  9. package/dist/backend/services/app-settings-service.js +12 -4
  10. package/dist/backend/services/artifact-service.js +2 -1
  11. package/dist/backend/services/claude-hook-service.js +3 -3
  12. package/dist/backend/services/codex-hook-service.js +87 -0
  13. package/dist/backend/services/codex-review-service.js +144 -59
  14. package/dist/backend/services/harness-service.js +28 -1
  15. package/dist/backend/services/message-service.js +8 -7
  16. package/dist/backend/services/project-service.js +2 -2
  17. package/dist/backend/services/round-service.js +29 -25
  18. package/dist/backend/services/session-service.js +141 -12
  19. package/dist/backend/templates/harness/codex-review.js +37 -6
  20. package/dist/shared/constants.js +15 -1
  21. package/dist/shared/types/app-settings.js +4 -3
  22. package/dist/shared/types/codex-hook.js +1 -0
  23. package/dist/shared/types/session.js +44 -0
  24. package/dist-frontend/assets/index-BavJjWQY.js +92 -0
  25. package/dist-frontend/assets/index-CR1EOe-w.css +32 -0
  26. package/dist-frontend/index.html +2 -2
  27. package/docs/ARCHITECTURE.md +1 -0
  28. package/docs/TESTING.md +82 -0
  29. package/docs/codex-review-gates.md +83 -45
  30. package/docs/gateway-design.md +5 -4
  31. package/docs/known-issues.md +1 -0
  32. package/docs/product-design.md +26 -10
  33. package/package.json +1 -1
  34. package/dist-frontend/assets/index-C9l94uxA.js +0 -92
  35. package/dist-frontend/assets/index-D6jWo6Jd.css +0 -32
@@ -4,12 +4,16 @@ import { CODEX_REVIEW_GATES } from "../../shared/types/codex-review.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";
7
+ import { renderCodexConfigHarnessRules } from "../templates/harness/codex-review.js";
7
8
  import { getTaskRuntimeRepoRoot } from "./task-service.js";
8
9
  const CODEX_DIR = ".ai/codex";
9
10
  const CODEX_CONFIG_PATH = ".ai/codex/config.toml";
10
11
  const CODEX_REVIEW_DIR = ".ai/vcm/codex-reviews";
11
12
  const REQUESTS_DIR = ".ai/vcm/codex-reviews/requests";
12
13
  const CODEX_REVIEW_VERSION = 1;
14
+ const CODEX_REVIEWER_ROLE = "codex-reviewer";
15
+ const DEFAULT_REPORT_POLL_INTERVAL_MS = 1000;
16
+ const DEFAULT_REPORT_TIMEOUT_MS = 30 * 60 * 1000;
13
17
  const activeRuns = new Set();
14
18
  const SOURCE_ARTIFACTS = {
15
19
  "architecture-plan": [
@@ -29,6 +33,8 @@ const SOURCE_ARTIFACTS = {
29
33
  const VALID_SEVERITIES = new Set(["critical", "high", "medium", "low"]);
30
34
  export function createCodexReviewService(deps) {
31
35
  const now = deps.now ?? (() => new Date().toISOString());
36
+ const reportPollIntervalMs = deps.reportPollIntervalMs ?? DEFAULT_REPORT_POLL_INTERVAL_MS;
37
+ const reportTimeoutMs = deps.reportTimeoutMs ?? DEFAULT_REPORT_TIMEOUT_MS;
32
38
  async function getContext(repoRoot, taskSlug) {
33
39
  const projectConfig = await deps.projectService.loadConfig(repoRoot);
34
40
  const task = await deps.taskService.loadTask(repoRoot, taskSlug);
@@ -153,9 +159,7 @@ export function createCodexReviewService(deps) {
153
159
  const codexDir = resolveRepoPath(context.taskRepoRoot, CODEX_DIR);
154
160
  const reviewDir = resolveRepoPath(context.taskRepoRoot, CODEX_REVIEW_DIR);
155
161
  const prompt = await buildCodexPrompt(deps.fs, context.taskRepoRoot, gate, requestId);
156
- const outputMessagePath = path.join(reviewDir, "logs", `${requestId}.last-message.txt`);
157
162
  await deps.fs.ensureDir(reviewDir);
158
- await deps.fs.ensureDir(path.dirname(outputMessagePath));
159
163
  if (!(await deps.fs.pathExists(codexDir))) {
160
164
  throw new VcmError({
161
165
  code: "CODEX_REVIEW_CONFIG_MISSING",
@@ -164,32 +168,13 @@ export function createCodexReviewService(deps) {
164
168
  hint: "Apply the VCM harness before requesting Codex review gates."
165
169
  });
166
170
  }
167
- const args = buildCodexExecArgs({
168
- codexDir,
169
- reviewDir,
170
- prompt,
171
- outputMessagePath,
172
- config: context.config
173
- });
174
- const result = await deps.runner.run(context.config.command, args, {
175
- cwd: context.taskRepoRoot,
176
- env: {
177
- ...process.env,
178
- VCM_TASK_REPO_ROOT: context.taskRepoRoot,
179
- VCM_TASK_SLUG: context.taskSlug,
180
- VCM_CODEX_REVIEW_GATE: gate,
181
- VCM_CODEX_REVIEW_REQUEST_ID: requestId
182
- }
171
+ const session = await ensureCodexReviewerSession(context);
172
+ await deps.sessionService.markRoleActivityRunning(context.repoRoot, context.taskSlug, CODEX_REVIEWER_ROLE);
173
+ await submitTerminalInput(deps.runtime, session.id, prompt);
174
+ const parsed = await waitForGateReport(deps.fs, context.taskRepoRoot, gate, requestId, now(), {
175
+ intervalMs: reportPollIntervalMs,
176
+ timeoutMs: reportTimeoutMs
183
177
  });
184
- if (result.exitCode !== 0) {
185
- throw new VcmError({
186
- code: "CODEX_REVIEW_FAILED",
187
- message: `Codex CLI exited with ${result.exitCode}.`,
188
- statusCode: 500,
189
- hint: trimCommandOutput(`${result.stderr}\n${result.stdout}`)
190
- });
191
- }
192
- const parsed = await parseGateReport(deps.fs, context.taskRepoRoot, gate, requestId, now());
193
178
  const completedAt = now();
194
179
  await updateGateRecord(context, gate, {
195
180
  status: "completed",
@@ -230,6 +215,29 @@ export function createCodexReviewService(deps) {
230
215
  activeRuns.delete(runKey);
231
216
  }
232
217
  }
218
+ async function ensureCodexReviewerSession(context) {
219
+ const existing = await deps.sessionService.getRoleSession(context.repoRoot, context.taskSlug, CODEX_REVIEWER_ROLE);
220
+ if (existing?.status === "running" && deps.runtime.getSession(existing.id)) {
221
+ return existing;
222
+ }
223
+ if (existing?.claudeSessionId) {
224
+ try {
225
+ return await deps.sessionService.resumeRoleSession(context.repoRoot, context.taskSlug, CODEX_REVIEWER_ROLE, {
226
+ cols: 100,
227
+ rows: 28,
228
+ model: "default"
229
+ });
230
+ }
231
+ catch {
232
+ // Fall through to a fresh Codex Reviewer terminal if the saved session cannot be resumed.
233
+ }
234
+ }
235
+ return deps.sessionService.startRoleSession(context.repoRoot, context.taskSlug, CODEX_REVIEWER_ROLE, {
236
+ cols: 100,
237
+ rows: 28,
238
+ model: "default"
239
+ });
240
+ }
233
241
  async function updateGateRecord(context, gate, patch, options = {}) {
234
242
  const index = await loadIndex(deps.fs, context, now());
235
243
  const next = applyGateState(index, gate, patch, now(), options.clearActiveGate);
@@ -276,6 +284,29 @@ export function createCodexReviewService(deps) {
276
284
  const context = await getContext(repoRoot, taskSlug);
277
285
  return loadIndex(deps.fs, context, now());
278
286
  },
287
+ async updateSettings(repoRoot, taskSlug, input) {
288
+ const context = await getContext(repoRoot, taskSlug);
289
+ const requiredGates = new Set(context.config.enabled ? context.config.requiredGates : []);
290
+ for (const [gate, enabled] of Object.entries(input?.gates ?? {})) {
291
+ if (!isCodexReviewGate(gate)) {
292
+ continue;
293
+ }
294
+ if (enabled) {
295
+ requiredGates.add(gate);
296
+ }
297
+ else {
298
+ requiredGates.delete(gate);
299
+ }
300
+ }
301
+ await writeRuntimeConfigSettings(deps.fs, context.taskRepoRoot, [...requiredGates]);
302
+ const nextContext = await getContext(repoRoot, taskSlug);
303
+ const index = await loadIndex(deps.fs, nextContext, now());
304
+ await saveIndex(deps.fs, nextContext.taskRepoRoot, {
305
+ ...index,
306
+ updatedAt: now()
307
+ });
308
+ return loadIndex(deps.fs, nextContext, now());
309
+ },
279
310
  requestReviewGate(repoRoot, taskSlug, gate) {
280
311
  return requestReviewGateInternal(repoRoot, taskSlug, gate);
281
312
  },
@@ -356,12 +387,60 @@ async function loadRuntimeConfig(fs, taskRepoRoot) {
356
387
  const parsedGates = parseTomlStringArray(section, "required_gates").filter(isCodexReviewGate);
357
388
  return {
358
389
  enabled,
359
- requiredGates: parsedGates.length > 0 ? parsedGates : [...CODEX_REVIEW_GATES],
390
+ requiredGates: parsedGates.length > 0 ? parsedGates : enabled ? [...CODEX_REVIEW_GATES] : [],
360
391
  model: parseTomlString(content, "model"),
361
392
  modelReasoningEffort: parseTomlString(content, "model_reasoning_effort"),
362
393
  command: parseTomlString(section, "command") ?? "codex"
363
394
  };
364
395
  }
396
+ async function writeRuntimeConfigSettings(fs, taskRepoRoot, requiredGates) {
397
+ const configPath = resolveRepoPath(taskRepoRoot, CODEX_CONFIG_PATH);
398
+ const current = await fs.pathExists(configPath)
399
+ ? await fs.readText(configPath)
400
+ : renderCodexConfigHarnessRules();
401
+ const section = renderCodexReviewSettingsSection(requiredGates);
402
+ const next = replaceTomlSection(current, "vcm.codex_review", section);
403
+ await fs.writeText(configPath, next);
404
+ }
405
+ function renderCodexReviewSettingsSection(requiredGates) {
406
+ if (requiredGates.length === 0) {
407
+ return [
408
+ "[vcm.codex_review]",
409
+ "enabled = false",
410
+ "required_gates = []"
411
+ ].join("\n");
412
+ }
413
+ const lines = [
414
+ "[vcm.codex_review]",
415
+ "enabled = true",
416
+ "required_gates = [",
417
+ ...CODEX_REVIEW_GATES
418
+ .filter((gate) => requiredGates.includes(gate))
419
+ .map((gate) => ` "${gate}",`),
420
+ "]"
421
+ ];
422
+ return lines.join("\n");
423
+ }
424
+ function replaceTomlSection(content, sectionName, nextSection) {
425
+ const lines = content.replace(/\s+$/g, "").split(/\r?\n/);
426
+ const header = `[${sectionName}]`;
427
+ const start = lines.findIndex((line) => line.trim() === header);
428
+ if (start < 0) {
429
+ return `${lines.join("\n").trimEnd()}\n\n${nextSection}\n`;
430
+ }
431
+ let end = lines.length;
432
+ for (let index = start + 1; index < lines.length; index += 1) {
433
+ if (/^\s*\[[^\]]+\]\s*$/.test(lines[index])) {
434
+ end = index;
435
+ break;
436
+ }
437
+ }
438
+ return [
439
+ ...lines.slice(0, start),
440
+ nextSection,
441
+ ...lines.slice(end)
442
+ ].join("\n").trimEnd() + "\n";
443
+ }
365
444
  async function loadIndex(fs, context, timestamp) {
366
445
  const indexPath = getIndexPath(context.taskRepoRoot);
367
446
  const raw = await readJsonOrNull(fs, indexPath);
@@ -444,6 +523,8 @@ async function computeInputHash(deps, taskRepoRoot, gate) {
444
523
  "CLAUDE.md",
445
524
  ".ai/codex/AGENTS.md",
446
525
  ".ai/codex/config.toml",
526
+ ".ai/codex/.codex/config.toml",
527
+ ".ai/codex/.codex/hooks.json",
447
528
  promptPathForGate(gate)
448
529
  ];
449
530
  for (const relativePath of [...common, ...SOURCE_ARTIFACTS[gate]]) {
@@ -497,31 +578,28 @@ Decision: approve|request_changes
497
578
 
498
579
  Write only that report file. Do not edit any other file.`;
499
580
  }
500
- function buildCodexExecArgs(input) {
501
- const args = [
502
- "exec",
503
- "--cd",
504
- input.codexDir,
505
- "--add-dir",
506
- input.reviewDir,
507
- "--sandbox",
508
- "workspace-write",
509
- "--ask-for-approval",
510
- "never",
511
- "--skip-git-repo-check",
512
- "--color",
513
- "never",
514
- "--output-last-message",
515
- input.outputMessagePath
516
- ];
517
- if (input.config.model) {
518
- args.push("--model", input.config.model);
519
- }
520
- if (input.config.modelReasoningEffort) {
521
- args.push("--config", `model_reasoning_effort="${input.config.modelReasoningEffort}"`);
581
+ async function waitForGateReport(fs, taskRepoRoot, gate, requestId, timestamp, options) {
582
+ const startedAt = Date.now();
583
+ let lastError;
584
+ while (Date.now() - startedAt <= options.timeoutMs) {
585
+ try {
586
+ return await parseGateReport(fs, taskRepoRoot, gate, requestId, timestamp);
587
+ }
588
+ catch (error) {
589
+ lastError = error;
590
+ if (!isPendingReportError(error)) {
591
+ throw error;
592
+ }
593
+ }
594
+ await delay(options.intervalMs);
522
595
  }
523
- args.push(input.prompt);
524
- return args;
596
+ const detail = errorMessage(lastError);
597
+ throw new VcmError({
598
+ code: "CODEX_REVIEW_REPORT_TIMEOUT",
599
+ message: `Codex Reviewer did not produce a valid ${gate} report within ${Math.round(options.timeoutMs / 1000)}s.`,
600
+ statusCode: 504,
601
+ hint: detail
602
+ });
525
603
  }
526
604
  async function parseGateReport(fs, taskRepoRoot, gate, requestId, timestamp) {
527
605
  const reportPath = reportPathForGate(gate);
@@ -744,13 +822,6 @@ function renderProjectManagerCallback(input) {
744
822
  ];
745
823
  return lines.join("\n");
746
824
  }
747
- function trimCommandOutput(output) {
748
- const trimmed = output.trim();
749
- if (!trimmed) {
750
- return undefined;
751
- }
752
- return trimmed.length > 4000 ? `${trimmed.slice(0, 4000)}...` : trimmed;
753
- }
754
825
  function errorMessage(error) {
755
826
  if (error instanceof VcmError) {
756
827
  return error.hint ? `${error.message} ${error.hint}` : error.message;
@@ -760,6 +831,20 @@ function errorMessage(error) {
760
831
  }
761
832
  return "Unknown Codex review error.";
762
833
  }
834
+ function isPendingReportError(error) {
835
+ return error instanceof VcmError && [
836
+ "CODEX_REVIEW_DECISION_MISSING",
837
+ "CODEX_REVIEW_REPORT_GATE_MISMATCH",
838
+ "CODEX_REVIEW_REPORT_MISSING",
839
+ "CODEX_REVIEW_REPORT_STALE"
840
+ ].includes(error.code);
841
+ }
842
+ function delay(ms) {
843
+ if (ms <= 0) {
844
+ return Promise.resolve();
845
+ }
846
+ return new Promise((resolve) => setTimeout(resolve, ms));
847
+ }
763
848
  function escapeRegex(value) {
764
849
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
765
850
  }
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { renderArchitectHarnessRules } from "../templates/harness/architect-agent.js";
6
6
  import { renderCoderHarnessRules } from "../templates/harness/coder-agent.js";
7
- import { renderCodexAgentsHarnessRules, renderCodexArchitecturePlanPrompt, renderCodexConfigHarnessRules, renderCodexFinalDiffPrompt, renderCodexReviewResultSchema, renderCodexValidationAdequacyPrompt, renderRequestCodexReviewTool, renderVcmCodexReviewGateSkillRules } from "../templates/harness/codex-review.js";
7
+ import { renderCodexAgentsHarnessRules, renderCodexArchitecturePlanPrompt, renderCodexCliConfigHarnessRules, renderCodexConfigHarnessRules, renderCodexFinalDiffPrompt, renderCodexHooksHarnessRules, renderCodexReviewResultSchema, renderCodexValidationAdequacyPrompt, renderRequestCodexReviewTool, renderVcmCodexReviewGateSkillRules } from "../templates/harness/codex-review.js";
8
8
  import { renderRootClaudeHarnessRules } from "../templates/harness/claude-root.js";
9
9
  import { renderGitignoreHarnessRules } from "../templates/harness/gitignore.js";
10
10
  import { renderProjectManagerHarnessRules } from "../templates/harness/project-manager-agent.js";
@@ -110,6 +110,20 @@ const HARNESS_FILES = [
110
110
  ownership: "raw-file",
111
111
  renderRules: renderCodexConfigHarnessRules
112
112
  },
113
+ {
114
+ kind: "codex-cli-config",
115
+ path: ".ai/codex/.codex/config.toml",
116
+ title: "VCM Codex CLI Config",
117
+ ownership: "raw-file",
118
+ renderRules: renderCodexCliConfigHarnessRules
119
+ },
120
+ {
121
+ kind: "codex-hooks",
122
+ path: ".ai/codex/.codex/hooks.json",
123
+ title: "VCM Codex Hooks",
124
+ ownership: "raw-file",
125
+ renderRules: renderCodexHooksHarnessRules
126
+ },
113
127
  {
114
128
  kind: "codex-prompt-architecture-plan",
115
129
  path: ".ai/codex/prompts/architecture-plan-gate.md",
@@ -393,8 +407,21 @@ function renderHarnessStatus(analyses) {
393
407
  const plannedChanges = analyses
394
408
  .map((analysis) => analysis.plannedChange)
395
409
  .filter((change) => Boolean(change));
410
+ // Derive `initialized`: the VCM harness is considered installed when at least one
411
+ // VCM-exclusive marker is present (per analysis):
412
+ // - status.hasManagedBlock === true (a managed block already lives in the file), OR
413
+ // - definition.ownership is "whole-file" | "raw-file" AND status.exists === true
414
+ // (a VCM-owned file lives at a VCM-exclusive path).
415
+ // A pre-existing claude-settings (default "managed-block" ownership, no managed block)
416
+ // or a non-VCM CLAUDE.md/.gitignore (action "insert", hasManagedBlock === false) is
417
+ // intentionally NOT counted as initialized.
418
+ const initialized = analyses.some((analysis) => analysis.status.hasManagedBlock ||
419
+ ((analysis.definition.ownership === "whole-file" ||
420
+ analysis.definition.ownership === "raw-file") &&
421
+ analysis.status.exists));
396
422
  return {
397
423
  version: VCM_HARNESS_VERSION,
424
+ initialized,
398
425
  files,
399
426
  needsApply: plannedChanges.length > 0,
400
427
  plannedChanges,
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { randomUUID } from "node:crypto";
3
- import { ROLE_NAMES, isRoleName } from "../../shared/constants.js";
3
+ import { VCM_ROLE_NAMES, isVcmRoleName } 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 ROLE_NAMES) {
286
- for (const toRole of ROLE_NAMES) {
285
+ for (const fromRole of VCM_ROLE_NAMES) {
286
+ for (const toRole of 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 (!ROLE_NAMES.includes(toRole)) {
374
+ if (!VCM_ROLE_NAMES.includes(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 (!isRoleName(String(fromRole))) {
381
+ if (!isVcmRoleName(String(fromRole))) {
382
382
  throw new VcmError({
383
383
  code: "MESSAGE_SENDER_UNKNOWN",
384
384
  message: `Unknown sender role: ${fromRole}`,
@@ -424,7 +424,8 @@ function getMessageDeliveryTime(message) {
424
424
  return message.deliveredAt ?? message.createdAt;
425
425
  }
426
426
  async function clearRouteFileIfStillMatchesMessage(fs, input, message) {
427
- if (!message.routePath || !isRoleName(String(message.fromRole))) {
427
+ const fromRole = message.fromRole;
428
+ if (!message.routePath || !isVcmRoleName(fromRole)) {
428
429
  return;
429
430
  }
430
431
  const absolutePath = resolveRepoPath(input.taskRepoRoot ?? input.repoRoot, message.routePath);
@@ -432,7 +433,7 @@ async function clearRouteFileIfStillMatchesMessage(fs, input, message) {
432
433
  return;
433
434
  }
434
435
  const routeContent = await fs.readText(absolutePath);
435
- const parsed = parseRouteFileContent(routeContent, message.fromRole, message.toRole);
436
+ const parsed = parseRouteFileContent(routeContent, fromRole, message.toRole);
436
437
  if (parsed.body.trim() === message.body.trim() &&
437
438
  parsed.type === message.type &&
438
439
  arraysEqual(parsed.artifactRefs, message.artifactRefs)) {
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { ROLE_NAMES } from "../../shared/constants.js";
2
+ import { 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";
@@ -182,7 +182,7 @@ export function buildDefaultProjectConfig(repoRoot) {
182
182
  return {
183
183
  version: 1,
184
184
  repoRoot,
185
- defaultRoles: [...ROLE_NAMES],
185
+ defaultRoles: [...VCM_ROLE_NAMES],
186
186
  handoffRoot: DEFAULT_HANDOFF_ROOT,
187
187
  stateRoot: DEFAULT_STATE_ROOT,
188
188
  terminalBackend: "node-pty",
@@ -134,6 +134,32 @@ export function createRoundService(deps) {
134
134
  }, delayMs);
135
135
  settleTimers.set(getRoundStatePath(input), timer);
136
136
  }
137
+ async function recordRoleTurnEvent(input) {
138
+ return withTaskLock(input, async () => {
139
+ const timestamp = now();
140
+ const settled = await settleIfNeeded(input, await load(input), timestamp);
141
+ const current = settled.currentRound;
142
+ const shouldStartNewRound = input.eventName === "UserPromptSubmit" && (!current || current.status === "stopped");
143
+ const next = applyRoundHookEvent({
144
+ state: settled,
145
+ taskSlug: input.taskSlug,
146
+ role: input.role,
147
+ eventName: input.eventName,
148
+ timestamp,
149
+ roundId: shouldStartNewRound ? id() : current?.id ?? "",
150
+ settleMs
151
+ });
152
+ await save(input, next);
153
+ if (input.eventName === "UserPromptSubmit") {
154
+ clearSettleTimer(input);
155
+ await updateSessionStatus(input, "running");
156
+ }
157
+ else if (next.currentRound) {
158
+ scheduleSettleTimer(input, next.currentRound, timestamp, input.settleGuard);
159
+ }
160
+ return toSessionRoundState(next, timestamp);
161
+ });
162
+ }
137
163
  async function withTaskLock(input, run) {
138
164
  const key = getRoundStatePath(input);
139
165
  const previous = taskLocks.get(key) ?? Promise.resolve();
@@ -155,31 +181,9 @@ export function createRoundService(deps) {
155
181
  return toSessionRoundState(await load(input), timestamp);
156
182
  });
157
183
  },
158
- async recordClaudeHookEvent(input) {
159
- return withTaskLock(input, async () => {
160
- const timestamp = now();
161
- const settled = await settleIfNeeded(input, await load(input), timestamp);
162
- const current = settled.currentRound;
163
- const shouldStartNewRound = input.eventName === "UserPromptSubmit" && (!current || current.status === "stopped");
164
- const next = applyRoundHookEvent({
165
- state: settled,
166
- taskSlug: input.taskSlug,
167
- role: input.role,
168
- eventName: input.eventName,
169
- timestamp,
170
- roundId: shouldStartNewRound ? id() : current?.id ?? "",
171
- settleMs
172
- });
173
- await save(input, next);
174
- if (input.eventName === "UserPromptSubmit") {
175
- clearSettleTimer(input);
176
- await updateSessionStatus(input, "running");
177
- }
178
- else if (next.currentRound) {
179
- scheduleSettleTimer(input, next.currentRound, timestamp, input.settleGuard);
180
- }
181
- return toSessionRoundState(next, timestamp);
182
- });
184
+ recordRoleTurnEvent,
185
+ recordClaudeHookEvent(input) {
186
+ return recordRoleTurnEvent(input);
183
187
  },
184
188
  stopSession() { },
185
189
  stopTask(taskSlug) {