vibe-coding-master 0.4.18 → 0.4.20

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.
@@ -201,6 +201,21 @@ export function createGitAdapter(runner) {
201
201
  hint: result.stderr
202
202
  });
203
203
  },
204
+ async mergeBranchFastForward(repoRoot, branch) {
205
+ const result = await runGit(runner, repoRoot, ["merge", "--ff-only", branch]);
206
+ if (result.exitCode !== 0) {
207
+ throw new VcmError({
208
+ code: "GIT_MERGE_FAILED",
209
+ message: `Unable to fast-forward merge branch: ${branch}`,
210
+ statusCode: 409,
211
+ hint: result.stderr || result.stdout || "Rebase the task branch onto the connected repository branch, then try again."
212
+ });
213
+ }
214
+ return {
215
+ stdout: result.stdout,
216
+ stderr: result.stderr
217
+ };
218
+ },
204
219
  async addPaths(repoRoot, paths) {
205
220
  if (paths.length === 0) {
206
221
  return;
@@ -38,6 +38,13 @@ export function registerHarnessRoutes(app, deps) {
38
38
  commitSha: request.query.commit
39
39
  });
40
40
  });
41
+ app.post("/api/projects/harness/repository-diff/merge-to-current-branch", async (request) => {
42
+ const { project, task } = await requireHarnessTaskContext(deps, request.body?.taskSlug);
43
+ return deps.harnessService.mergeRepositoryDiffToCurrentBranch(project.repoRoot, {
44
+ taskRepoRoot: task.worktreePath,
45
+ taskBranch: task.branch
46
+ });
47
+ });
41
48
  app.get("/api/projects/harness/bootstrap", async (request) => {
42
49
  const { project, task } = await requireHarnessTaskContext(deps, request.query.taskSlug);
43
50
  try {
@@ -119,6 +126,16 @@ export function registerHarnessRoutes(app, deps) {
119
126
  comment: typeof request.body?.comment === "string" ? request.body.comment : undefined
120
127
  });
121
128
  });
129
+ app.post("/api/projects/harness/task-retrospective", async (request) => {
130
+ const { project, task } = await requireHarnessTaskContext(deps, request.body?.taskSlug);
131
+ const trigger = request.body?.trigger === "auto" ? "auto" : "manual";
132
+ return deps.harnessFeedbackService.startTaskRetrospective(project.repoRoot, {
133
+ taskSlug: task.taskSlug,
134
+ taskRepoRoot: task.worktreePath,
135
+ handoffDir: task.handoffDir,
136
+ trigger
137
+ });
138
+ });
122
139
  }
123
140
  function degradedHarnessStatus(error) {
124
141
  return {
@@ -1,4 +1,4 @@
1
- import { CORE_VCM_ROLE_NAMES, DISPATCHABLE_ROLES } from "../../shared/constants.js";
1
+ import { DISPATCHABLE_ROLES, VCM_ROLE_NAMES } 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) {
@@ -41,7 +41,7 @@ export function registerTaskRoutes(app, deps) {
41
41
  async function stopRunningRoleSessions(deps, repoRoot, taskSlug) {
42
42
  const sessions = await deps.sessionService.listRoleSessions(repoRoot, taskSlug);
43
43
  for (const session of sessions) {
44
- if (session.status === "running" && CORE_VCM_ROLE_NAMES.some((role) => role === session.role)) {
44
+ if (session.status === "running" && VCM_ROLE_NAMES.some((role) => role === session.role)) {
45
45
  await deps.sessionService.stopRoleSession(repoRoot, taskSlug, session.role);
46
46
  }
47
47
  }
@@ -1,5 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { CORE_VCM_ROLE_DEFINITIONS, CORE_VCM_ROLE_NAMES, GATE_REVIEWER_ROLE_DEFINITION } from "../../shared/constants.js";
2
+ import { CORE_VCM_ROLE_DEFINITIONS, GATE_REVIEWER_ROLE_DEFINITION, VCM_ROLE_NAMES } 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";
@@ -425,9 +425,6 @@ export function createGatewayService(deps) {
425
425
  };
426
426
  const existing = await deps.sessionService.getRoleSession(project.repoRoot, task.taskSlug, definition.name);
427
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
428
  startedRoles.push(definition.name);
432
429
  continue;
433
430
  }
@@ -556,7 +553,7 @@ export function createGatewayService(deps) {
556
553
  async function stopRunningRoleSessions(repoRoot, taskSlug) {
557
554
  const sessions = await deps.sessionService.listRoleSessions(repoRoot, taskSlug);
558
555
  for (const session of sessions) {
559
- if (session.status === "running" && CORE_VCM_ROLE_NAMES.some((role) => role === session.role)) {
556
+ if (session.status === "running" && VCM_ROLE_NAMES.some((role) => role === session.role)) {
560
557
  await deps.sessionService.stopRoleSession(repoRoot, taskSlug, session.role);
561
558
  }
562
559
  }
@@ -230,7 +230,9 @@ function normalizePreferences(input) {
230
230
  return {
231
231
  themeMode: normalizeThemeMode(candidate.themeMode),
232
232
  flowPauseAlerts: rawFlowPauseAlerts !== false,
233
+ roleRetryEnabled: candidate.roleRetryEnabled !== false,
233
234
  permissionRequestMode: normalizePermissionRequestMode(candidate.permissionRequestMode),
235
+ autoTaskHarnessReviewEnabled: candidate.autoTaskHarnessReviewEnabled === true,
234
236
  translationEnabled: candidate.translationEnabled === true,
235
237
  translationAutoSendEnabled: candidate.translationAutoSendEnabled === true,
236
238
  translationTargetLanguage: normalizeTranslationTargetLanguage(candidate.translationTargetLanguage),
@@ -2,9 +2,22 @@ import { isGateReviewerRoleName, isHarnessEngineerToolRoleName, isTranslatorTool
2
2
  import { VcmError } from "../errors.js";
3
3
  import { submitTerminalInput } from "../runtime/terminal-submit.js";
4
4
  import { getTaskRuntimeRepoRoot } from "./task-service.js";
5
- const MAX_STOP_FAILURE_RECOVERY_ATTEMPTS = 2;
5
+ const MAX_ROLE_RETRY_ATTEMPTS = 20;
6
+ const ROLE_RETRY_BASE_DELAY_MS = 60_000;
7
+ const NON_RETRYABLE_STOP_FAILURE_ERRORS = new Set([
8
+ "authentication_failed",
9
+ "oauth_org_not_allowed",
10
+ "billing_error",
11
+ "invalid_request",
12
+ "model_not_found",
13
+ "max_output_tokens"
14
+ ]);
15
+ const DIAGNOSTIC_SNIPPET_MAX_LENGTH = 2000;
6
16
  export function createClaudeHookService(deps) {
7
- const stopFailureRecoveryAttempts = new Map();
17
+ const stopFailureRetryTimers = new Map();
18
+ const now = deps.now ?? (() => new Date().toISOString());
19
+ const retrySetTimeout = deps.retrySetTimeout ?? ((callback, delayMs) => globalThis.setTimeout(callback, delayMs));
20
+ const retryClearTimeout = deps.retryClearTimeout ?? ((timer) => globalThis.clearTimeout(timer));
8
21
  async function getHookContext(input) {
9
22
  if (!isVcmRoleName(input.role)) {
10
23
  throw new VcmError({
@@ -99,19 +112,8 @@ export function createClaudeHookService(deps) {
99
112
  };
100
113
  }
101
114
  async function resolveHookTaskSlug(repoRoot, input) {
102
- if (!isGateReviewerRoleName(input.role)) {
103
- return input.taskSlug;
104
- }
105
- const session = await deps.sessionService.getProjectGateReviewerSession(repoRoot);
106
- if (session?.activeTaskSlug) {
107
- return session.activeTaskSlug;
108
- }
109
- throw new VcmError({
110
- code: "GATE_REVIEWER_TASK_UNBOUND",
111
- message: "Gate Reviewer hook arrived without an active task binding.",
112
- statusCode: 409,
113
- hint: "Start or resume Gate Reviewer from the current task before submitting work."
114
- });
115
+ void repoRoot;
116
+ return input.taskSlug;
115
117
  }
116
118
  async function handleUserPromptSubmitHook(input) {
117
119
  const eventName = parseHookEvent(input.event.hook_event_name);
@@ -177,7 +179,7 @@ export function createClaudeHookService(deps) {
177
179
  throwUnsupportedEvent(eventName);
178
180
  }
179
181
  const context = await getHookContext(input);
180
- clearStopFailureRecovery(context.project.repoRoot, context.taskSlug, input.role);
182
+ await clearStopFailureRecoveryState(context, input.role);
181
183
  if (options.allowBlock && deps.jobGuard) {
182
184
  const verdict = await deps.jobGuard.evaluateStop({
183
185
  repoRoot: context.project.repoRoot,
@@ -215,15 +217,24 @@ export function createClaudeHookService(deps) {
215
217
  const pending = await deps.messageService.listPendingRouteFiles(routeDispatchInput);
216
218
  const hasCompletionEvidence = pending.some((routeFile) => routeFile.fromRole === input.role);
217
219
  if (hasCompletionEvidence) {
218
- clearStopFailureRecovery(context.project.repoRoot, context.taskSlug, input.role);
220
+ await clearStopFailureRecoveryState(context, input.role);
219
221
  return recordTurnEnd(input, context, eventName, {
220
222
  dispatchRouteFiles: !isGateReviewerRoleName(input.role),
221
223
  notifyGateway: false,
222
224
  settleGuard: true
223
225
  });
224
226
  }
225
- const recovered = await dispatchStopFailureRecovery(input, context);
226
- if (recovered) {
227
+ const failure = parseStopFailureDiagnostic(input.event);
228
+ if (!failure.retryable) {
229
+ await markStopFailureRecoveryFailed(input, context, failure, 0);
230
+ return recordTurnEnd(input, context, eventName, {
231
+ dispatchRouteFiles: false,
232
+ notifyGateway: false,
233
+ settleGuard: false
234
+ });
235
+ }
236
+ const retryScheduled = await scheduleStopFailureRetry(input, context, failure);
237
+ if (retryScheduled) {
227
238
  return {
228
239
  ok: true,
229
240
  eventName,
@@ -336,38 +347,147 @@ export function createClaudeHookService(deps) {
336
347
  ...(stoppedRole ? { stoppedRole } : {})
337
348
  };
338
349
  }
339
- async function dispatchStopFailureRecovery(input, context) {
340
- if (!deps.runtime) {
350
+ async function scheduleStopFailureRetry(input, context, failure) {
351
+ const preferences = await deps.appSettings.getPreferences();
352
+ if (!preferences.roleRetryEnabled || !deps.runtime) {
341
353
  return false;
342
354
  }
343
- const key = stopFailureRecoveryKey(context.project.repoRoot, context.taskSlug, input.role);
344
- const attempt = (stopFailureRecoveryAttempts.get(key) ?? 0) + 1;
345
- if (attempt > MAX_STOP_FAILURE_RECOVERY_ATTEMPTS) {
355
+ const stateInput = createRoundStateInput(context);
356
+ const currentRoundState = await deps.roundService.getSessionRoundState(stateInput);
357
+ const previousAttempt = currentRoundState.roleRecovery?.role === input.role &&
358
+ currentRoundState.roleRecovery.status !== "failed"
359
+ ? currentRoundState.roleRecovery.attempt
360
+ : 0;
361
+ const attempt = previousAttempt + 1;
362
+ const timestamp = now();
363
+ if (attempt > MAX_ROLE_RETRY_ATTEMPTS) {
364
+ clearStopFailureRetryTimer(context.project.repoRoot, context.taskSlug, input.role);
365
+ await markStopFailureRecoveryFailed(input, context, failure, MAX_ROLE_RETRY_ATTEMPTS, timestamp);
346
366
  return false;
347
367
  }
368
+ const nextRetryAt = new Date(Date.parse(timestamp) + attempt * ROLE_RETRY_BASE_DELAY_MS).toISOString();
369
+ await deps.roundService.setRoleRecovery({
370
+ ...stateInput,
371
+ recovery: {
372
+ role: input.role,
373
+ status: "waiting",
374
+ attempt,
375
+ maxAttempts: MAX_ROLE_RETRY_ATTEMPTS,
376
+ lastFailureAt: timestamp,
377
+ error: failure.error,
378
+ errorDetails: failure.errorDetails,
379
+ lastAssistantMessage: failure.lastAssistantMessage,
380
+ retryable: failure.retryable,
381
+ nextRetryAt
382
+ }
383
+ });
384
+ await deps.sessionService.markRoleActivityRunning(context.project.repoRoot, context.taskSlug, input.role);
385
+ scheduleStopFailureRetryTimer(input, context, attempt, nextRetryAt);
386
+ return true;
387
+ }
388
+ function scheduleStopFailureRetryTimer(input, context, attempt, nextRetryAt) {
389
+ const key = stopFailureRecoveryKey(context.project.repoRoot, context.taskSlug, input.role);
390
+ clearStopFailureRetryTimer(context.project.repoRoot, context.taskSlug, input.role);
391
+ const delayMs = Math.max(0, Date.parse(nextRetryAt) - Date.parse(now()));
392
+ const timer = retrySetTimeout(() => {
393
+ stopFailureRetryTimers.delete(key);
394
+ void runScheduledStopFailureRetry(input, context, attempt).catch(() => undefined);
395
+ }, delayMs);
396
+ stopFailureRetryTimers.set(key, timer);
397
+ }
398
+ async function runScheduledStopFailureRetry(input, context, attempt) {
399
+ const stateInput = createRoundStateInput(context);
400
+ const currentRoundState = await deps.roundService.getSessionRoundState(stateInput);
401
+ const recovery = currentRoundState.roleRecovery;
402
+ if (!recovery || recovery.role !== input.role || recovery.attempt !== attempt || recovery.status !== "waiting") {
403
+ return;
404
+ }
405
+ const timestamp = now();
348
406
  const session = await deps.sessionService.getRoleSession(context.project.repoRoot, context.taskSlug, input.role);
349
- if (!session || session.status !== "running") {
350
- return false;
407
+ if (!session || session.status !== "running" || !deps.runtime) {
408
+ await deps.roundService.setRoleRecovery({
409
+ ...stateInput,
410
+ recovery: {
411
+ ...recovery,
412
+ status: "failed",
413
+ failedAt: timestamp
414
+ }
415
+ });
416
+ await recordTurnEnd(input, context, "StopFailure", {
417
+ dispatchRouteFiles: false,
418
+ notifyGateway: false,
419
+ settleGuard: false
420
+ });
421
+ return;
351
422
  }
352
- stopFailureRecoveryAttempts.set(key, attempt);
353
- await submitTerminalInput(deps.runtime, session.id, renderStopFailureRecoveryPrompt(attempt));
423
+ await deps.roundService.setRoleRecovery({
424
+ ...stateInput,
425
+ recovery: {
426
+ ...recovery,
427
+ status: "retrying",
428
+ nextRetryAt: undefined,
429
+ lastRetryAt: timestamp
430
+ }
431
+ });
432
+ await submitTerminalInput(deps.runtime, session.id, renderStopFailureRecoveryPrompt());
354
433
  await deps.sessionService.markRoleActivityRunning(context.project.repoRoot, context.taskSlug, input.role);
355
- return true;
434
+ }
435
+ async function markStopFailureRecoveryFailed(input, context, failure, attempt, timestamp = now()) {
436
+ clearStopFailureRetryTimer(context.project.repoRoot, context.taskSlug, input.role);
437
+ await deps.roundService.setRoleRecovery({
438
+ ...createRoundStateInput(context),
439
+ recovery: {
440
+ role: input.role,
441
+ status: "failed",
442
+ attempt,
443
+ maxAttempts: MAX_ROLE_RETRY_ATTEMPTS,
444
+ lastFailureAt: timestamp,
445
+ error: failure.error,
446
+ errorDetails: failure.errorDetails,
447
+ lastAssistantMessage: failure.lastAssistantMessage,
448
+ retryable: failure.retryable,
449
+ failedAt: timestamp
450
+ }
451
+ });
356
452
  }
357
453
  function clearStopFailureRecovery(repoRoot, taskSlug, role) {
358
- stopFailureRecoveryAttempts.delete(stopFailureRecoveryKey(repoRoot, taskSlug, role));
454
+ clearStopFailureRetryTimer(repoRoot, taskSlug, role);
455
+ }
456
+ async function clearStopFailureRecoveryState(context, role) {
457
+ clearStopFailureRecovery(context.project.repoRoot, context.taskSlug, role);
458
+ await deps.roundService.clearRoleRecovery?.({
459
+ ...createRoundStateInput(context),
460
+ role
461
+ });
462
+ }
463
+ function clearStopFailureRetryTimer(repoRoot, taskSlug, role) {
464
+ const key = stopFailureRecoveryKey(repoRoot, taskSlug, role);
465
+ const timer = stopFailureRetryTimers.get(key);
466
+ if (timer === undefined) {
467
+ return;
468
+ }
469
+ retryClearTimeout(timer);
470
+ stopFailureRetryTimers.delete(key);
359
471
  }
360
472
  function stopFailureRecoveryKey(repoRoot, taskSlug, role) {
361
473
  return `${repoRoot}:${taskSlug}:${role}`;
362
474
  }
363
- function renderStopFailureRecoveryPrompt(attempt) {
475
+ function createRoundStateInput(context) {
476
+ return {
477
+ repoRoot: context.project.repoRoot,
478
+ stateRepoRoot: context.taskRepoRoot,
479
+ stateRoot: context.config.stateRoot,
480
+ taskSlug: context.taskSlug
481
+ };
482
+ }
483
+ function renderStopFailureRecoveryPrompt() {
364
484
  return [
365
485
  "[VCM Recovery]",
366
- "Your previous turn ended unexpectedly after context compaction or an API error.",
367
- "Continue the same assigned work from the current repository and VCM handoff state.",
368
- "Do not repeat completed edits, duplicate validation, or duplicate route messages.",
369
- "If the assigned work is already complete, write/send the expected VCM handoff now.",
370
- `Recovery attempt: ${attempt}/${MAX_STOP_FAILURE_RECOVERY_ATTEMPTS}.`
486
+ "Previous turn ended unexpectedly. Continue from current repo + VCM handoff state.",
487
+ "",
488
+ "Check whether your assigned work is already complete.",
489
+ "If complete, write the expected VCM completion artifact now.",
490
+ "Do not repeat completed edits, validation, or route messages."
371
491
  ].join("\n");
372
492
  }
373
493
  async function handlePermissionRequestHook(input) {
@@ -470,6 +590,38 @@ function throwUnsupportedEvent(eventName) {
470
590
  hint: "Use the matching VCM hook endpoint for this event."
471
591
  });
472
592
  }
593
+ function parseStopFailureDiagnostic(event) {
594
+ const error = (normalizeDiagnosticString(event.error, 200) ?? "unknown").toLowerCase();
595
+ const errorDetails = normalizeDiagnosticString(event.error_details, DIAGNOSTIC_SNIPPET_MAX_LENGTH);
596
+ const lastAssistantMessage = normalizeDiagnosticString(event.last_assistant_message, DIAGNOSTIC_SNIPPET_MAX_LENGTH);
597
+ return {
598
+ error,
599
+ errorDetails,
600
+ lastAssistantMessage,
601
+ retryable: !NON_RETRYABLE_STOP_FAILURE_ERRORS.has(error)
602
+ };
603
+ }
604
+ function normalizeDiagnosticString(value, maxLength) {
605
+ let text;
606
+ if (typeof value === "string") {
607
+ text = value;
608
+ }
609
+ else if (value !== undefined && value !== null) {
610
+ try {
611
+ text = JSON.stringify(value);
612
+ }
613
+ catch {
614
+ text = String(value);
615
+ }
616
+ }
617
+ const trimmed = text?.trim();
618
+ if (!trimmed) {
619
+ return undefined;
620
+ }
621
+ return trimmed.length > maxLength
622
+ ? `${trimmed.slice(0, maxLength)}...`
623
+ : trimmed;
624
+ }
473
625
  function stringOrUndefined(value) {
474
626
  return typeof value === "string" ? value : undefined;
475
627
  }
@@ -1,4 +1,6 @@
1
+ import { createHash } from "node:crypto";
1
2
  import path from "node:path";
3
+ import { checkMarkdownArtifact } from "../../shared/validation/artifact-check.js";
2
4
  import { resolveRepoPath, toRepoRelativePath } from "../adapters/filesystem.js";
3
5
  import { VcmError } from "../errors.js";
4
6
  import { submitTerminalInput } from "../runtime/terminal-submit.js";
@@ -6,6 +8,7 @@ const FEEDBACK_ROOT = ".ai/vcm/harness-feedback";
6
8
  const PENDING_DIR = `${FEEDBACK_ROOT}/pending`;
7
9
  const ACTIVE_DIR = `${FEEDBACK_ROOT}/active`;
8
10
  const COMPLETED_DIR = `${FEEDBACK_ROOT}/completed`;
11
+ const TASK_RETROSPECTIVE_DIR = `${FEEDBACK_ROOT}/task-retrospectives`;
9
12
  const STATE_PATH = `${FEEDBACK_ROOT}/state.json`;
10
13
  export function createHarnessFeedbackService(deps) {
11
14
  const now = deps.now ?? (() => new Date().toISOString());
@@ -13,6 +16,80 @@ export function createHarnessFeedbackService(deps) {
13
16
  await maybeDispatchNext(repoRoot, activeTaskSlug);
14
17
  return buildStateReport(repoRoot);
15
18
  }
19
+ async function startTaskRetrospective(repoRoot, input) {
20
+ const taskSlug = input.taskSlug.trim();
21
+ if (!taskSlug) {
22
+ throw new VcmError({
23
+ code: "HARNESS_TASK_REQUIRED",
24
+ message: "Select an active task before reviewing task harness.",
25
+ statusCode: 409
26
+ });
27
+ }
28
+ const state = await loadStoredState(repoRoot);
29
+ if (state) {
30
+ throw new VcmError({
31
+ code: "HARNESS_FEEDBACK_ACTIVE",
32
+ message: "Harness feedback is already active.",
33
+ statusCode: 409,
34
+ hint: "Review, approve, comment, or reject the current Harness feedback before starting Task Harness Retrospective."
35
+ });
36
+ }
37
+ const existingMarker = await loadTaskRetrospectiveMarker(repoRoot, taskSlug);
38
+ if (existingMarker) {
39
+ throw new VcmError({
40
+ code: "TASK_HARNESS_RETROSPECTIVE_EXISTS",
41
+ message: `Task Harness Retrospective has already been triggered for task: ${taskSlug}`,
42
+ statusCode: 409,
43
+ hint: "Review the existing Harness feedback item instead of starting another retrospective for the same task."
44
+ });
45
+ }
46
+ const finalAcceptancePath = path.posix.join(input.handoffDir, "final-acceptance.md");
47
+ const finalAcceptanceAbsolutePath = resolveRepoPath(input.taskRepoRoot, finalAcceptancePath);
48
+ const finalAcceptanceContent = await readAbsoluteOptionalText(finalAcceptanceAbsolutePath);
49
+ const finalAcceptanceCheck = checkMarkdownArtifact("final-acceptance", finalAcceptancePath, finalAcceptanceContent ?? null);
50
+ if (finalAcceptanceCheck.status !== "ok" || !finalAcceptanceContent) {
51
+ throw new VcmError({
52
+ code: "TASK_FINAL_ACCEPTANCE_NOT_READY",
53
+ message: "Task final acceptance is not complete yet.",
54
+ statusCode: 409,
55
+ hint: `${finalAcceptancePath} must pass the final-acceptance artifact check before Task Harness Retrospective can start.`
56
+ });
57
+ }
58
+ const session = await ensureIdleHarnessEngineer(repoRoot, taskSlug);
59
+ const timestamp = now();
60
+ const finalAcceptanceHash = `sha256:${sha256(finalAcceptanceContent)}`;
61
+ const id = sanitizeFeedbackId(`${timestamp}-task-retrospective-${taskSlug}`);
62
+ const feedbackPath = `${ACTIVE_DIR}/${id}/feedback.md`;
63
+ const analysisPath = `${ACTIVE_DIR}/${id}/analysis.md`;
64
+ const applyReportPath = `${ACTIVE_DIR}/${id}/apply-report.md`;
65
+ const active = {
66
+ id,
67
+ title: `Task Harness Retrospective: ${taskSlug}`,
68
+ path: feedbackPath,
69
+ source: "task-retrospective",
70
+ taskSlug,
71
+ summary: "Review the completed task workflow for reusable harness problems.",
72
+ trigger: input.trigger,
73
+ finalAcceptanceHash,
74
+ feedbackPath,
75
+ analysisPath,
76
+ applyReportPath,
77
+ startedAt: timestamp,
78
+ updatedAt: timestamp,
79
+ lastPromptAt: timestamp
80
+ };
81
+ const nextState = {
82
+ version: 1,
83
+ status: "analyzing",
84
+ active
85
+ };
86
+ await deps.fs.ensureDir(resolveRepoPath(repoRoot, path.posix.dirname(feedbackPath)));
87
+ await deps.fs.writeText(resolveRepoPath(repoRoot, feedbackPath), renderTaskRetrospectiveFeedback(active, finalAcceptancePath));
88
+ await persistStoredState(repoRoot, nextState);
89
+ await persistTaskRetrospectiveMarker(repoRoot, active, "analyzing");
90
+ await submitTerminalInput(deps.runtime, session.id, buildTaskRetrospectivePrompt(repoRoot, active));
91
+ return buildStateReport(repoRoot);
92
+ }
16
93
  async function decide(repoRoot, input) {
17
94
  const state = await loadStoredState(repoRoot);
18
95
  if (!state || state.status !== "awaiting_user_approval") {
@@ -48,6 +125,7 @@ export function createHarnessFeedbackService(deps) {
48
125
  }
49
126
  };
50
127
  await persistStoredState(repoRoot, nextState);
128
+ await persistTaskRetrospectiveMarker(repoRoot, nextState.active, "analyzing");
51
129
  await submitTerminalInput(deps.runtime, session.id, buildFeedbackCommentPrompt(repoRoot, nextState.active, input.comment ?? ""));
52
130
  return buildStateReport(repoRoot);
53
131
  }
@@ -63,6 +141,7 @@ export function createHarnessFeedbackService(deps) {
63
141
  }
64
142
  };
65
143
  await persistStoredState(repoRoot, nextState);
144
+ await persistTaskRetrospectiveMarker(repoRoot, nextState.active, "applying");
66
145
  await submitTerminalInput(deps.runtime, session.id, buildFeedbackApplyPrompt(repoRoot, nextState.active, input.comment ?? ""));
67
146
  return buildStateReport(repoRoot);
68
147
  }
@@ -76,14 +155,16 @@ export function createHarnessFeedbackService(deps) {
76
155
  }
77
156
  const timestamp = now();
78
157
  if (state.status === "analyzing") {
79
- await persistStoredState(repoRoot, {
158
+ const nextState = {
80
159
  ...state,
81
160
  status: "awaiting_user_approval",
82
161
  active: {
83
162
  ...state.active,
84
163
  updatedAt: timestamp
85
164
  }
86
- });
165
+ };
166
+ await persistStoredState(repoRoot, nextState);
167
+ await persistTaskRetrospectiveMarker(repoRoot, nextState.active, "awaiting_user_approval", timestamp);
87
168
  return;
88
169
  }
89
170
  if (state.status === "applying") {
@@ -132,6 +213,7 @@ export function createHarnessFeedbackService(deps) {
132
213
  const active = {
133
214
  ...next,
134
215
  feedbackPath: next.path,
216
+ source: next.source ?? "role-feedback",
135
217
  analysisPath,
136
218
  applyReportPath,
137
219
  startedAt: timestamp,
@@ -209,12 +291,15 @@ export function createHarnessFeedbackService(deps) {
209
291
  id: state.active.id,
210
292
  title: state.active.title,
211
293
  path: state.active.path,
294
+ source: state.active.source,
212
295
  reporterRole: state.active.reporterRole,
213
296
  taskSlug: state.active.taskSlug,
214
297
  summary: state.active.summary,
215
298
  status: state.status,
216
299
  startedAt: state.active.startedAt,
217
300
  updatedAt: state.active.updatedAt,
301
+ trigger: state.active.trigger,
302
+ finalAcceptanceHash: state.active.finalAcceptanceHash,
218
303
  feedbackContent,
219
304
  analysisPath: state.active.analysisPath,
220
305
  analysisContent,
@@ -249,6 +334,7 @@ export function createHarnessFeedbackService(deps) {
249
334
  id,
250
335
  title: compactLine(title),
251
336
  path: relativePath,
337
+ source: "role-feedback",
252
338
  reporterRole: metadata["reporter role"] ?? metadata.reporter,
253
339
  taskSlug: metadata["task slug"] ?? metadata.task,
254
340
  summary: metadata.summary
@@ -278,6 +364,16 @@ export function createHarnessFeedbackService(deps) {
278
364
  "</HARNESS_FEEDBACK>"
279
365
  ].join("\n");
280
366
  }
367
+ function buildTaskRetrospectivePrompt(repoRoot, active) {
368
+ return [
369
+ "[VCM Task Harness Retrospective]",
370
+ "",
371
+ "Review the completed task from the current active task worktree.",
372
+ "",
373
+ `Write the analysis to Result Path: ${resolveRepoPath(repoRoot, active.analysisPath)}`,
374
+ "End your turn after writing the result."
375
+ ].join("\n");
376
+ }
281
377
  function buildFeedbackCommentPrompt(repoRoot, active, comment) {
282
378
  return [
283
379
  "[VCM Harness Feedback Revision]",
@@ -345,6 +441,7 @@ export function createHarnessFeedbackService(deps) {
345
441
  comment,
346
442
  completedAt: now()
347
443
  });
444
+ await persistTaskRetrospectiveMarker(repoRoot, state.active, outcome === "rejected" ? "rejected" : "completed");
348
445
  await deps.fs.removePath?.(resolveRepoPath(repoRoot, state.active.feedbackPath), { force: true });
349
446
  await deps.fs.removePath?.(resolveRepoPath(repoRoot, path.posix.dirname(state.active.analysisPath)), { recursive: true, force: true });
350
447
  }
@@ -357,6 +454,7 @@ export function createHarnessFeedbackService(deps) {
357
454
  if (state?.version !== 1 || !state.active?.id || !state.status) {
358
455
  return undefined;
359
456
  }
457
+ state.active.source = state.active.source ?? "role-feedback";
360
458
  return state;
361
459
  }
362
460
  async function persistStoredState(repoRoot, state) {
@@ -372,8 +470,38 @@ export function createHarnessFeedbackService(deps) {
372
470
  }
373
471
  return deps.fs.readText(absolutePath);
374
472
  }
473
+ async function readAbsoluteOptionalText(absolutePath) {
474
+ if (!(await deps.fs.pathExists(absolutePath))) {
475
+ return undefined;
476
+ }
477
+ return deps.fs.readText(absolutePath);
478
+ }
479
+ async function loadTaskRetrospectiveMarker(repoRoot, taskSlug) {
480
+ const markerPath = resolveRepoPath(repoRoot, getTaskRetrospectiveMarkerPath(taskSlug));
481
+ if (!(await deps.fs.pathExists(markerPath))) {
482
+ return undefined;
483
+ }
484
+ return deps.fs.readJson(markerPath);
485
+ }
486
+ async function persistTaskRetrospectiveMarker(repoRoot, active, status, timestamp = now()) {
487
+ if (active.source !== "task-retrospective" || !active.taskSlug) {
488
+ return;
489
+ }
490
+ await deps.fs.writeJsonAtomic(resolveRepoPath(repoRoot, getTaskRetrospectiveMarkerPath(active.taskSlug)), {
491
+ version: 1,
492
+ taskSlug: active.taskSlug,
493
+ activeId: active.id,
494
+ trigger: active.trigger ?? "manual",
495
+ status,
496
+ finalAcceptanceHash: active.finalAcceptanceHash,
497
+ createdAt: active.startedAt,
498
+ updatedAt: timestamp,
499
+ ...(status === "completed" || status === "rejected" ? { completedAt: timestamp } : {})
500
+ });
501
+ }
375
502
  return {
376
503
  getState,
504
+ startTaskRetrospective,
377
505
  decide,
378
506
  recordHarnessEngineerHook,
379
507
  assertHarnessEngineerAvailable
@@ -397,10 +525,29 @@ function firstHeading(content) {
397
525
  function compactLine(value) {
398
526
  return value.replace(/\s+/g, " ").trim().slice(0, 160);
399
527
  }
528
+ function renderTaskRetrospectiveFeedback(active, finalAcceptancePath) {
529
+ return [
530
+ `# ${active.title}`,
531
+ "",
532
+ `Source: ${active.source}`,
533
+ `Task slug: ${active.taskSlug ?? ""}`,
534
+ `Trigger: ${active.trigger ?? "manual"}`,
535
+ `Final acceptance: ${finalAcceptancePath}`,
536
+ `Final acceptance hash: ${active.finalAcceptanceHash ?? ""}`,
537
+ "",
538
+ "Summary: Review the completed task workflow for reusable harness problems."
539
+ ].join("\n");
540
+ }
541
+ function getTaskRetrospectiveMarkerPath(taskSlug) {
542
+ return `${TASK_RETROSPECTIVE_DIR}/${sanitizeFeedbackId(taskSlug)}.json`;
543
+ }
400
544
  function sanitizeFeedbackId(value) {
401
545
  const sanitized = value.replace(/[^A-Za-z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
402
546
  return sanitized || "feedback";
403
547
  }
548
+ function sha256(content) {
549
+ return createHash("sha256").update(content).digest("hex");
550
+ }
404
551
  export function getHarnessFeedbackRelativePath(repoRoot, absolutePath) {
405
552
  return toRepoRelativePath(repoRoot, absolutePath);
406
553
  }