vibe-coding-master 0.7.49 → 0.8.0

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 (31) hide show
  1. package/README.md +4 -2
  2. package/dist/backend/adapters/filesystem.js +6 -0
  3. package/dist/backend/api/task-routes.js +20 -1
  4. package/dist/backend/server.js +4 -2
  5. package/dist/backend/services/auto-memory-service.js +79 -28
  6. package/dist/backend/services/claude-hook-service.js +5 -3
  7. package/dist/backend/services/gate-review-service.js +21 -2
  8. package/dist/backend/services/harness-feedback-service.js +53 -15
  9. package/dist/backend/services/message-service.js +10 -0
  10. package/dist/backend/services/runtime-recovery-service.js +32 -7
  11. package/dist/backend/services/task-service.js +13 -0
  12. package/dist/backend/services/translation-service.js +74 -3
  13. package/dist/backend/services/workflow-control-service.js +436 -75
  14. package/dist/backend/templates/handoff.js +14 -2
  15. package/dist/backend/templates/harness/architect-agent.js +6 -6
  16. package/dist/backend/templates/harness/architect-evidence-worker-agent.js +2 -0
  17. package/dist/backend/templates/harness/architect-validation-worker-agent.js +1 -1
  18. package/dist/backend/templates/harness/claude-root.js +3 -3
  19. package/dist/backend/templates/harness/coder-agent.js +2 -1
  20. package/dist/backend/templates/harness/harness-engineer-agent.js +14 -11
  21. package/dist/backend/templates/harness/project-manager-agent.js +5 -1
  22. package/dist/backend/templates/harness/tester-agent.js +6 -0
  23. package/dist/backend/templates/harness/vcm-long-running-validation-skill.js +1 -4
  24. package/dist/backend/templates/harness/vcm-workflow-review-skill.js +17 -1
  25. package/dist/shared/validation/artifact-check.js +36 -3
  26. package/dist/shared/validation/artifact-contract.js +7 -0
  27. package/dist/shared/validation/artifact-registry.js +4 -1
  28. package/dist-frontend/assets/{index-DLsIPTvK.js → index-Dh7uVCmk.js} +1 -1
  29. package/dist-frontend/index.html +1 -1
  30. package/package.json +1 -1
  31. package/scripts/harness-tools/vcm-bash-guard +245 -21
package/README.md CHANGED
@@ -124,8 +124,9 @@ If you want VCM app state to survive container rebuilds, set:
124
124
  1. Start VCM with `vcm`.
125
125
  2. Open the GUI.
126
126
  3. In `Repository`, enter a local Git repository path and click `Connect`.
127
- 4. Create or select a task in the `Task` section. VCM creates a task branch and
128
- worktree immediately.
127
+ 4. Create or select a task in the `Task` section. Before creating a new task,
128
+ VCM fast-forward pulls the connected branch when it has an upstream, then
129
+ creates the task branch and worktree from the updated `HEAD`.
129
130
  5. In `VCM Harness`, initialize or update fixed harness files if VCM reports
130
131
  pending changes. Harness changes are written to the active task worktree.
131
132
  6. If bootstrap is incomplete, open Harness Studio and run bootstrap through
@@ -632,6 +633,7 @@ Make sure:
632
633
 
633
634
  - the repository is a Git repository
634
635
  - the connected base repo is clean
636
+ - the connected branch can be fast-forward pulled when it has an upstream
635
637
  - no other task is currently active for this project
636
638
  - the derived `feature/<task>` branch does not already exist
637
639
  - the derived `.claude/worktrees/<task>` directory does not already exist
@@ -41,6 +41,12 @@ export function createNodeFileSystemAdapter() {
41
41
  }
42
42
  });
43
43
  },
44
+ async fileVersion(targetPath) {
45
+ return runFileOperation(async () => {
46
+ const stat = await fs.stat(targetPath, { bigint: true });
47
+ return `${stat.mtimeNs}:${stat.size}`;
48
+ });
49
+ },
44
50
  async writeText(targetPath, content) {
45
51
  await runFileOperation(async () => {
46
52
  await fs.mkdir(path.dirname(targetPath), { recursive: true });
@@ -65,11 +65,17 @@ export function registerTaskRoutes(app, deps) {
65
65
  taskSlug
66
66
  }) ?? Promise.resolve(degradedWorkflowState(taskSlug))
67
67
  ]);
68
+ const displayedRoundState = await delayFlowPauseForTranslation(deps, {
69
+ repoRoot: project.repoRoot,
70
+ taskRepoRoot,
71
+ taskSlug,
72
+ roundState
73
+ });
68
74
  return {
69
75
  taskStatus,
70
76
  messages,
71
77
  orchestration,
72
- roundState,
78
+ roundState: displayedRoundState,
73
79
  workflowState,
74
80
  architectRestart: deps.architectRestartService.getState(project.repoRoot, taskSlug),
75
81
  roleStallWarning: deps.roleStallDetector.getWarning(project.repoRoot, taskSlug)
@@ -136,6 +142,19 @@ export function registerTaskRoutes(app, deps) {
136
142
  return deps.taskCloseService.closeTask(project.repoRoot, request.params.taskSlug);
137
143
  });
138
144
  }
145
+ async function delayFlowPauseForTranslation(deps, input) {
146
+ if (!input.roundState.flowPause?.paused || !deps.translationService) {
147
+ return input.roundState;
148
+ }
149
+ try {
150
+ const pending = await deps.translationService.shouldDelayFlowPauseNotification(input);
151
+ return pending ? { ...input.roundState, flowPause: undefined } : input.roundState;
152
+ }
153
+ catch {
154
+ // Translation-state failures must release, rather than suppress, the pause alert.
155
+ return input.roundState;
156
+ }
157
+ }
139
158
  function requireWarningId(value) {
140
159
  if (typeof value === "string" && value.trim()) {
141
160
  return value.trim();
@@ -137,7 +137,8 @@ export async function createServer(deps, options = {}) {
137
137
  roundService: deps.roundService,
138
138
  taskWorkflowService: deps.taskWorkflowService,
139
139
  architectRestartService: deps.architectRestartService,
140
- roleStallDetector: deps.roleStallDetector
140
+ roleStallDetector: deps.roleStallDetector,
141
+ translationService: deps.translationService
141
142
  });
142
143
  registerSessionRoutes(app, {
143
144
  projectService: deps.projectService,
@@ -411,7 +412,8 @@ export function createDefaultServerDeps(options = {}) {
411
412
  taskService,
412
413
  translationWorkerService,
413
414
  architectRestartService,
414
- roleContextRestartService
415
+ roleContextRestartService,
416
+ workflowControlService
415
417
  });
416
418
  const claudeHookService = createClaudeHookService({
417
419
  projectService,
@@ -302,6 +302,7 @@ export function createAutoMemoryService(deps) {
302
302
  });
303
303
  }
304
304
  await writeRunMemoryHostSnapshot(taskRepoRoot, state.runId);
305
+ const existingEntries = collectExistingMemoryReviewEntries(beforeMemory);
305
306
  const timestamp = now();
306
307
  state.reviewPromptDispatchedAt = timestamp;
307
308
  state.retrospectiveReportPath = retrospectiveReportPath;
@@ -309,12 +310,19 @@ export function createAutoMemoryService(deps) {
309
310
  state.updatedAt = timestamp;
310
311
  await persistActiveState(taskRepoRoot, state);
311
312
  const runRoot = resolveRepoPath(taskRepoRoot, `${MEMORY_REVIEW_RUNS_ROOT}/${state.runId}`);
313
+ const existingEntriesPath = path.join(runRoot, "existing-entries.json");
314
+ await deps.fs.writeJsonAtomic(existingEntriesPath, {
315
+ version: 1,
316
+ runId: state.runId,
317
+ entries: existingEntries
318
+ });
312
319
  const planningCandidatePath = await findPlanningCandidateSnapshot(taskRepoRoot, state.runId);
313
320
  return {
314
321
  runId: state.runId,
315
322
  roleDraftsPath: path.join(runRoot, "drafts"),
316
323
  currentMemoryPath: path.join(runRoot, "before"),
317
324
  activeMemoryPaths: memoryPaths.map((memoryPath) => resolveRepoPath(taskRepoRoot, memoryPath)),
325
+ existingEntriesPath,
318
326
  reviewResultPath: path.join(runRoot, "review-result.json"),
319
327
  proposalCandidates,
320
328
  ...(planningCandidatePath
@@ -966,31 +974,35 @@ export function createAutoMemoryService(deps) {
966
974
  statusCode: 409
967
975
  });
968
976
  }
969
- for (const decision of result.decisions.filter((candidate) => candidate.source === "existing")) {
970
- const memoryPath = memoryTargetToPath(decision.target);
971
- if (!substantiveMemoryEntries(before[memoryPath]).includes(decision.entry)) {
977
+ const existingEntries = collectExistingMemoryReviewEntries(before);
978
+ const existingDecisions = result.decisions.filter((candidate) => candidate.source === "existing");
979
+ for (const decision of existingDecisions) {
980
+ const assigned = existingEntries.find((entry) => entry.itemId === decision.itemId);
981
+ if (!assigned) {
972
982
  throw new VcmError({
973
983
  code: "MEMORY_REVIEW_EXISTING_DECISION_UNKNOWN",
974
984
  message: `review-result.json contains an unknown existing-memory decision: ${decision.itemId}`,
975
985
  statusCode: 409
976
986
  });
977
987
  }
988
+ if (decision.target !== assigned.target || decision.entry !== assigned.entry) {
989
+ throw new VcmError({
990
+ code: "MEMORY_REVIEW_EXISTING_DECISION_MISMATCH",
991
+ message: `Existing-memory decision must copy the assigned target and entry exactly: ${decision.itemId}`,
992
+ statusCode: 409
993
+ });
994
+ }
978
995
  }
979
- for (const definition of MEMORY_FILE_DEFINITIONS) {
980
- const target = memoryPathToTarget(definition.path);
981
- for (const entry of substantiveMemoryEntries(before[definition.path])) {
982
- const matches = result.decisions.filter((decision) => decision.source === "existing"
983
- && decision.target === target
984
- && decision.entry === entry);
985
- if (matches.length !== 1) {
986
- throw new VcmError({
987
- code: "MEMORY_REVIEW_EXISTING_DECISION_MISSING",
988
- message: `review-result.json must contain exactly one decision for existing memory entry in ${definition.path}: ${entry}`,
989
- statusCode: 409
990
- });
991
- }
992
- validateExistingMemoryDecision(definition.path, entry, matches[0], after);
996
+ for (const assigned of existingEntries) {
997
+ const matches = existingDecisions.filter((decision) => decision.itemId === assigned.itemId);
998
+ if (matches.length !== 1) {
999
+ throw new VcmError({
1000
+ code: "MEMORY_REVIEW_EXISTING_DECISION_MISSING",
1001
+ message: `review-result.json must contain exactly one decision for existing memory item ${assigned.itemId} in ${assigned.memoryPath}.`,
1002
+ statusCode: 409
1003
+ });
993
1004
  }
1005
+ validateExistingMemoryDecision(assigned.memoryPath, assigned.entry, matches[0], after);
994
1006
  }
995
1007
  const moveDecisions = result.decisions.filter((decision) => decision.decision === "move-to-durable-doc");
996
1008
  if (moveDecisions.length !== result.durableDocAssignments.length) {
@@ -1002,7 +1014,6 @@ export function createAutoMemoryService(deps) {
1002
1014
  }
1003
1015
  const assignmentKeys = new Set();
1004
1016
  for (const assignment of result.durableDocAssignments) {
1005
- validateDurableDocAssignmentInput(assignment, after);
1006
1017
  const key = `${assignment.sourceMemoryPath}\n${assignment.sourceEntry}\n${assignment.targetPath}`;
1007
1018
  if (assignmentKeys.has(key)) {
1008
1019
  throw new VcmError({
@@ -1022,6 +1033,7 @@ export function createAutoMemoryService(deps) {
1022
1033
  statusCode: 409
1023
1034
  });
1024
1035
  }
1036
+ validateDurableDocAssignmentInput(assignment, matchingDecision.source, after);
1025
1037
  }
1026
1038
  return result;
1027
1039
  }
@@ -1034,7 +1046,7 @@ export function createAutoMemoryService(deps) {
1034
1046
  statusCode: 409
1035
1047
  });
1036
1048
  }
1037
- const afterEntries = new Set(substantiveMemoryEntries(after[memoryTargetToPath(decision.target)]));
1049
+ const afterContent = after[memoryTargetToPath(decision.target)];
1038
1050
  if (candidate.operation === "remove") {
1039
1051
  if (decision.decision !== "remove" && decision.decision !== "retain") {
1040
1052
  throw new VcmError({
@@ -1043,14 +1055,14 @@ export function createAutoMemoryService(deps) {
1043
1055
  statusCode: 409
1044
1056
  });
1045
1057
  }
1046
- if (decision.decision === "remove" && afterEntries.has(expectedEntry)) {
1058
+ if (decision.decision === "remove" && memoryContainsReviewContent(afterContent, expectedEntry)) {
1047
1059
  throw new VcmError({
1048
1060
  code: "MEMORY_REVIEW_PROPOSAL_REMOVAL_MISMATCH",
1049
1061
  message: `Accepted removal is still present for proposal ${candidate.id}.`,
1050
1062
  statusCode: 409
1051
1063
  });
1052
1064
  }
1053
- if (decision.decision === "retain" && !afterEntries.has(expectedEntry)) {
1065
+ if (decision.decision === "retain" && !memoryContainsReviewContent(afterContent, expectedEntry)) {
1054
1066
  throw new VcmError({
1055
1067
  code: "MEMORY_REVIEW_PROPOSAL_RETAIN_MISMATCH",
1056
1068
  message: `Rejected removal is missing from memory for proposal ${candidate.id}.`,
@@ -1067,7 +1079,7 @@ export function createAutoMemoryService(deps) {
1067
1079
  });
1068
1080
  }
1069
1081
  if ((decision.decision === "keep-in-memory" || decision.decision === "keep-memory-reference")
1070
- && !afterEntries.has(decision.finalContent)) {
1082
+ && !memoryContainsReviewContent(afterContent, decision.finalContent)) {
1071
1083
  throw new VcmError({
1072
1084
  code: "MEMORY_REVIEW_PROPOSAL_CONTENT_MISSING",
1073
1085
  message: `Accepted proposal content is missing from memory for ${candidate.id}.`,
@@ -1076,7 +1088,7 @@ export function createAutoMemoryService(deps) {
1076
1088
  }
1077
1089
  }
1078
1090
  function validateExistingMemoryDecision(memoryPath, entry, decision, after) {
1079
- const afterEntries = new Set(substantiveMemoryEntries(after[memoryPath]));
1091
+ const afterEntries = new Set(parseExistingMemoryEntries(after[memoryPath]));
1080
1092
  if (decision.decision === "retain" && !afterEntries.has(entry)) {
1081
1093
  throw new VcmError({
1082
1094
  code: "MEMORY_REVIEW_RETAIN_MISMATCH",
@@ -1101,7 +1113,7 @@ export function createAutoMemoryService(deps) {
1101
1113
  }
1102
1114
  }
1103
1115
  }
1104
- function validateDurableDocAssignmentInput(assignment, after) {
1116
+ function validateDurableDocAssignmentInput(assignment, source, after) {
1105
1117
  if (!MEMORY_FILE_DEFINITIONS.some((definition) => definition.path === assignment.sourceMemoryPath)) {
1106
1118
  throw new VcmError({
1107
1119
  code: "MEMORY_REVIEW_ASSIGNMENT_SOURCE_INVALID",
@@ -1109,7 +1121,10 @@ export function createAutoMemoryService(deps) {
1109
1121
  statusCode: 409
1110
1122
  });
1111
1123
  }
1112
- if (substantiveMemoryEntries(after[assignment.sourceMemoryPath]).includes(assignment.sourceEntry)) {
1124
+ const sourceStillPresent = source === "existing"
1125
+ ? parseExistingMemoryEntries(after[assignment.sourceMemoryPath]).includes(assignment.sourceEntry)
1126
+ : memoryContainsReviewContent(after[assignment.sourceMemoryPath], assignment.sourceEntry);
1127
+ if (sourceStillPresent) {
1113
1128
  throw new VcmError({
1114
1129
  code: "MEMORY_REVIEW_ASSIGNMENT_SOURCE_NOT_REMOVED",
1115
1130
  message: `Move-to-durable-doc must remove memory before assignment: ${assignment.sourceEntry}`,
@@ -1451,11 +1466,47 @@ function memoryTargetToPath(target) {
1451
1466
  }
1452
1467
  return definition.path;
1453
1468
  }
1454
- function substantiveMemoryEntries(content) {
1469
+ function collectExistingMemoryReviewEntries(memory) {
1470
+ return MEMORY_FILE_DEFINITIONS.flatMap((definition) => {
1471
+ const target = memoryPathToTarget(definition.path);
1472
+ return parseExistingMemoryEntries(memory[definition.path]).map((entry, index) => ({
1473
+ itemId: `existing:${target}:${index + 1}:${sha256(entry).slice(0, 12)}`,
1474
+ target,
1475
+ memoryPath: definition.path,
1476
+ entry
1477
+ }));
1478
+ });
1479
+ }
1480
+ function parseExistingMemoryEntries(content) {
1481
+ const normalized = content.replace(/\r\n?/g, "\n").trim();
1482
+ if (!normalized) {
1483
+ return [];
1484
+ }
1485
+ const lines = normalized.split("\n");
1486
+ const sectionStarts = lines
1487
+ .map((line, index) => (/^##(?:\s+|$)/.test(line) ? index : -1))
1488
+ .filter((index) => index >= 0);
1489
+ if (sectionStarts.length === 0) {
1490
+ return [normalized];
1491
+ }
1492
+ return sectionStarts.map((start, index) => {
1493
+ const end = sectionStarts[index + 1] ?? lines.length;
1494
+ return lines.slice(start, end).join("\n").trim();
1495
+ }).filter(Boolean);
1496
+ }
1497
+ function memoryContainsReviewContent(content, expected) {
1498
+ const normalizedExpected = expected.replace(/\r\n?/g, "\n").trim();
1499
+ if (!normalizedExpected) {
1500
+ return false;
1501
+ }
1502
+ if (normalizedExpected.includes("\n") || /^##(?:\s+|$)/.test(normalizedExpected)) {
1503
+ return parseExistingMemoryEntries(content).includes(normalizedExpected);
1504
+ }
1455
1505
  return content
1456
- .split(/\r?\n/)
1506
+ .replace(/\r\n?/g, "\n")
1507
+ .split("\n")
1457
1508
  .map((line) => line.trim())
1458
- .filter(Boolean);
1509
+ .includes(normalizedExpected);
1459
1510
  }
1460
1511
  function assertDurableDocPath(targetPath) {
1461
1512
  const normalized = normalizeProjectRelativePath(targetPath);
@@ -158,7 +158,8 @@ export function createClaudeHookService(deps) {
158
158
  ? await deps.harnessFeedbackService?.handleTaskRetrospectiveHook(context.project.repoRoot, {
159
159
  taskSlug: activeTask.taskSlug,
160
160
  eventName,
161
- memoryReviewStatus: memoryState?.status ?? "idle"
161
+ memoryReviewStatus: memoryState?.status ?? "idle",
162
+ memoryReviewError: memoryState?.active?.error
162
163
  })
163
164
  : false;
164
165
  if (memoryHandled || retrospectiveHandled) {
@@ -1097,7 +1098,8 @@ function asksUserQuestion(message) {
1097
1098
  .split(/\r?\n/)
1098
1099
  .map((line) => line.trim())
1099
1100
  .filter(Boolean);
1100
- return lines.some((line) => /[??]["')\]}】”’]*$/.test(line)
1101
+ return lines.some((line) => /[??]/.test(line)
1101
1102
  || /^(?:please\s+(?:answer|choose|confirm|decide|provide|select|tell)|(?:can|could|do|does|is|are|should|will|would)\s+you\b)/i.test(line)
1102
- || /^(?:请(?:回答|选择|确认|决定|提供|告知)|你(?:是否|能否|要不要|可否)|是否需要你|需要你(?:选择|确认|决定|提供|告知))/.test(line));
1103
+ || /\b(?:please\s+(?:reply|respond|choose|confirm|decide|provide|select|tell|give)|i\s+need\s+your\s+(?:decision|choice|confirmation|answer)|let\s+me\s+know|your\s+(?:decision|choice|confirmation|answer)\s+is\s+required|waiting\s+for\s+your)\b/i.test(line)
1104
+ || /(?:请(?:回答|回复|选择|确认|决定|提供|告知|给出|说明)|你(?:是否|能否|要不要|可否)|是否需要你|需要你(?:选择|确认|决定|提供|告知)|需要您的(?:选择|确认|决定|回复)|等待您的(?:选择|确认|决定|回复)|告诉我|告知我)/.test(line));
1103
1105
  }
@@ -836,6 +836,9 @@ export function createGateReviewService(deps) {
836
836
  async skipReviewGate(repoRoot, taskSlug, gate, input) {
837
837
  const context = await getContext(repoRoot, taskSlug);
838
838
  assertExceptionReason(input.reason);
839
+ const exceptionInputHash = gate === "code-diff"
840
+ ? undefined
841
+ : await tryComputeInputHash(deps, context.taskRepoRoot, gate);
839
842
  const index = await withGateStateLock(context, async () => {
840
843
  const timestamp = now();
841
844
  const current = await loadIndex(deps.fs, context, timestamp);
@@ -850,6 +853,7 @@ export function createGateReviewService(deps) {
850
853
  const next = applyGateState(current, gate, {
851
854
  status: "skipped",
852
855
  decision: undefined,
856
+ inputHash: exceptionInputHash ?? current.gates[gate].inputHash,
853
857
  exceptionReason: input.reason,
854
858
  error: undefined,
855
859
  completedAt: timestamp,
@@ -867,6 +871,9 @@ export function createGateReviewService(deps) {
867
871
  async overrideReviewGate(repoRoot, taskSlug, gate, input) {
868
872
  const context = await getContext(repoRoot, taskSlug);
869
873
  assertExceptionReason(input.reason);
874
+ const exceptionInputHash = gate === "code-diff"
875
+ ? undefined
876
+ : await tryComputeInputHash(deps, context.taskRepoRoot, gate);
870
877
  const index = await withGateStateLock(context, async () => {
871
878
  const timestamp = now();
872
879
  const current = await loadIndex(deps.fs, context, timestamp);
@@ -881,6 +888,7 @@ export function createGateReviewService(deps) {
881
888
  const next = applyGateState(current, gate, {
882
889
  status: "overridden",
883
890
  decision: "approve",
891
+ inputHash: exceptionInputHash ?? current.gates[gate].inputHash,
884
892
  exceptionReason: input.reason,
885
893
  error: undefined,
886
894
  completedAt: timestamp,
@@ -1319,8 +1327,11 @@ async function readCodeDiffEvidenceError(fs, taskRepoRoot, source) {
1319
1327
  }
1320
1328
  async function readCodeDiffValidationGateError(deps, context, index) {
1321
1329
  const validationGate = index.gates["validation-adequacy"];
1322
- if (validationGate.required && validationGate.status !== "skipped" && validationGate.status !== "overridden") {
1323
- if (validationGate.status !== "completed" || validationGate.decision !== "approve") {
1330
+ if (validationGate.required) {
1331
+ const passed = (validationGate.status === "completed" && validationGate.decision === "approve")
1332
+ || validationGate.status === "skipped"
1333
+ || validationGate.status === "overridden";
1334
+ if (!passed) {
1324
1335
  return "code-diff requires the validation-adequacy Gate to complete successfully for the current Tester evidence.";
1325
1336
  }
1326
1337
  const currentValidationHash = await computeInputHash(deps, context.taskRepoRoot, "validation-adequacy");
@@ -1330,6 +1341,14 @@ async function readCodeDiffValidationGateError(deps, context, index) {
1330
1341
  }
1331
1342
  return undefined;
1332
1343
  }
1344
+ async function tryComputeInputHash(deps, taskRepoRoot, gate) {
1345
+ try {
1346
+ return await computeInputHash(deps, taskRepoRoot, gate);
1347
+ }
1348
+ catch {
1349
+ return undefined;
1350
+ }
1351
+ }
1333
1352
  async function readArchitectureEvidenceError(fs, taskRepoRoot) {
1334
1353
  const relativePath = ".ai/vcm/handoffs/architecture-evidence.md";
1335
1354
  const absolutePath = resolveRepoPath(taskRepoRoot, relativePath);
@@ -11,15 +11,18 @@ const TASK_RETROSPECTIVE_DIR = `${FEEDBACK_ROOT}/task-retrospectives`;
11
11
  const LEGACY_STATE_PATH = `${FEEDBACK_ROOT}/state.json`;
12
12
  export function createHarnessFeedbackService(deps) {
13
13
  const now = deps.now ?? (() => new Date().toISOString());
14
- async function getState(repoRoot, _activeTaskSlug) {
14
+ async function getState(repoRoot, activeTaskSlug) {
15
15
  await cleanupLegacyState(repoRoot);
16
16
  const pending = await listPendingFeedback(repoRoot);
17
+ const marker = activeTaskSlug
18
+ ? await loadTaskRetrospectiveMarker(repoRoot, activeTaskSlug)
19
+ : undefined;
17
20
  return {
18
21
  version: 1,
19
22
  status: pending.length > 0 ? "queued" : "idle",
20
23
  queuedCount: pending.length,
21
24
  pending,
22
- warnings: []
25
+ warnings: marker?.status === "failed" && marker.error ? [marker.error] : []
23
26
  };
24
27
  }
25
28
  async function sendPendingFeedback(repoRoot, input) {
@@ -143,27 +146,41 @@ export function createHarnessFeedbackService(deps) {
143
146
  ]
144
147
  : ["Report is empty."];
145
148
  const reportReady = reportErrors.length === 0;
146
- if (!reportReady || (marker.memoryRunId && input.memoryReviewStatus === "failed")) {
149
+ if (!reportReady) {
147
150
  await persistTaskRetrospectiveMarker(repoRoot, {
148
151
  ...marker,
149
152
  status: "failed",
150
153
  failedAt: timestamp,
151
154
  updatedAt: timestamp,
152
- error: !reportReady
153
- ? `Harness Engineer did not write a valid Task Harness Retrospective report: ${reportErrors.join(" ")}`
154
- : "Task Harness Retrospective memory review failed."
155
+ error: `Harness Engineer did not write a valid Task Harness Retrospective report: ${reportErrors.join(" ")}`
155
156
  });
156
157
  return true;
157
158
  }
158
- if (marker.memoryRunId && input.memoryReviewStatus === "documenting") {
159
+ const processedMarker = await consumeAcceptedFeedback(repoRoot, marker);
160
+ if (!processedMarker) {
161
+ return true;
162
+ }
163
+ if (processedMarker.memoryRunId && input.memoryReviewStatus === "failed") {
159
164
  await persistTaskRetrospectiveMarker(repoRoot, {
160
- ...marker,
165
+ ...processedMarker,
166
+ status: "failed",
167
+ failedAt: timestamp,
168
+ updatedAt: timestamp,
169
+ error: input.memoryReviewError
170
+ ? `Task Harness Retrospective memory review failed: ${input.memoryReviewError}`
171
+ : "Task Harness Retrospective memory review failed. Open Harness Studio Memory to inspect the validation error."
172
+ });
173
+ return true;
174
+ }
175
+ if (processedMarker.memoryRunId && input.memoryReviewStatus === "documenting") {
176
+ await persistTaskRetrospectiveMarker(repoRoot, {
177
+ ...processedMarker,
161
178
  status: "waiting-docs",
162
179
  updatedAt: timestamp
163
180
  });
164
181
  return true;
165
182
  }
166
- await completeTaskRetrospective(repoRoot, marker);
183
+ await completeTaskRetrospective(repoRoot, processedMarker);
167
184
  return true;
168
185
  }
169
186
  async function completeWaitingTaskRetrospective(repoRoot, taskSlug, memoryStatus) {
@@ -189,6 +206,22 @@ export function createHarnessFeedbackService(deps) {
189
206
  return true;
190
207
  }
191
208
  async function completeTaskRetrospective(repoRoot, marker) {
209
+ const timestamp = now();
210
+ const processedMarker = await consumeAcceptedFeedback(repoRoot, marker);
211
+ if (!processedMarker) {
212
+ return;
213
+ }
214
+ await persistTaskRetrospectiveMarker(repoRoot, {
215
+ ...processedMarker,
216
+ status: "completed",
217
+ completedAt: timestamp,
218
+ updatedAt: timestamp
219
+ });
220
+ }
221
+ async function consumeAcceptedFeedback(repoRoot, marker) {
222
+ if (marker.feedbackProcessedAt) {
223
+ return marker;
224
+ }
192
225
  const timestamp = now();
193
226
  try {
194
227
  await removeProcessedFeedback(repoRoot, marker.pendingFeedbackPaths ?? []);
@@ -201,14 +234,16 @@ export function createHarnessFeedbackService(deps) {
201
234
  updatedAt: timestamp,
202
235
  error: `VCM could not remove processed Harness Feedback: ${errorMessage(error)}`
203
236
  });
204
- return;
237
+ return undefined;
205
238
  }
206
- await persistTaskRetrospectiveMarker(repoRoot, {
239
+ const processedMarker = {
207
240
  ...marker,
208
- status: "completed",
209
- completedAt: timestamp,
241
+ pendingFeedbackPaths: [],
242
+ feedbackProcessedAt: timestamp,
210
243
  updatedAt: timestamp
211
- });
244
+ };
245
+ await persistTaskRetrospectiveMarker(repoRoot, processedMarker);
246
+ return processedMarker;
212
247
  }
213
248
  async function assertHarnessEngineerAvailable(_repoRoot) {
214
249
  return undefined;
@@ -320,6 +355,7 @@ export function createHarnessFeedbackService(deps) {
320
355
  "Auto Memory Review:",
321
356
  `Role drafts: ${memoryReview.roleDraftsPath}`,
322
357
  `Current memory snapshot: ${memoryReview.currentMemoryPath}`,
358
+ `Existing memory entries: ${memoryReview.existingEntriesPath}`,
323
359
  "Active memory files:",
324
360
  ...memoryReview.activeMemoryPaths.map((memoryPath) => `- ${memoryPath}`),
325
361
  "Proposal candidates:",
@@ -338,7 +374,9 @@ export function createHarnessFeedbackService(deps) {
338
374
  "",
339
375
  "Review every memory candidate against final task evidence while performing this retrospective.",
340
376
  "The snapshot files contain only the matching pre-review <VCM-memory> block content.",
341
- "Before evaluating proposals, review every substantive entry in every current memory snapshot against current code, documentation, and final task evidence.",
377
+ "The existing memory entries file is the complete required decision set. It groups each ## section and its body as one semantic entry; legacy memory without ## sections is one entry.",
378
+ "Emit exactly one source=existing decision for every listed itemId. Copy its target and entry exactly; do not create decisions for headings, wrapped lines, or other text not listed there.",
379
+ "Review every listed existing entry against current code, documentation, and final task evidence before evaluating proposals.",
342
380
  "Evaluate each proposal independently. Keep only verified, durable, reusable project knowledge; do not keep task narrative, temporary state, unverified conclusions, or Harness rules in memory.",
343
381
  "For every existing entry and proposal, record why the decision is necessary, the impact if the knowledge is absent, the evidence checked, and whether a durable document is the correct source.",
344
382
  "When the decision is move-to-durable-doc, remove the entry from memory now and add one durableDocAssignment. Do not wait for the durable document update before removing memory.",
@@ -211,6 +211,7 @@ export function createMessageService(deps) {
211
211
  const messages = await readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
212
212
  let clearedCount = 0;
213
213
  if (input.clearRouteFiles) {
214
+ await deps.workflowControlService?.cancelPendingDispatch(toWorkflowContext(input));
214
215
  for (const routeFile of await listPendingRouteFiles(input)) {
215
216
  await deps.fs.writeText(resolveRepoPath(input.taskRepoRoot ?? input.repoRoot, routeFile.path), "");
216
217
  clearedCount += 1;
@@ -225,6 +226,15 @@ export function createMessageService(deps) {
225
226
  },
226
227
  async deleteMessageHistory(input) {
227
228
  return withTaskLock(taskLocks, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), async () => {
229
+ const workflowState = await deps.workflowControlService?.getState(toWorkflowContext(input));
230
+ if (workflowState?.pendingDispatch?.status === "dispatching") {
231
+ throw new VcmError({
232
+ code: "MESSAGE_HISTORY_DISPATCHING",
233
+ message: "Message history cannot be deleted while a PM workflow dispatch is awaiting target confirmation.",
234
+ statusCode: 409,
235
+ hint: "Wait for confirmation or use Mark All Done to cancel the pending route first."
236
+ });
237
+ }
228
238
  const messagesPath = getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug);
229
239
  const messages = await readLatestMessages(deps.fs, messagesPath);
230
240
  await writeMessageSnapshots(deps.fs, messagesPath, []);
@@ -33,8 +33,17 @@ export function createRuntimeRecoveryService(deps) {
33
33
  await recoverTaskSessions(taskRepoRoot, config.stateRoot, task.taskSlug, recoveredAt, context);
34
34
  const roundRecovered = await recoverRound(taskRepoRoot, config.stateRoot, task.taskSlug, recoveredAt, context);
35
35
  await recoverMessages(taskRepoRoot, config.stateRoot, task.taskSlug, recoveredAt, context);
36
+ const workflowRecovered = await deps.workflowControlService?.recoverTask({
37
+ taskRepoRoot,
38
+ stateRoot: config.stateRoot,
39
+ handoffDir: task.handoffDir,
40
+ taskSlug: task.taskSlug
41
+ });
42
+ if (workflowRecovered) {
43
+ context.changedPaths.add(path.join(config.stateRoot, "workflow-control.json"));
44
+ }
36
45
  await recoverGateReview(taskRepoRoot, recoveredAt, context);
37
- await cleanupCoderWorkers(taskRepoRoot, context);
46
+ await recoverCoderWorkers(taskRepoRoot, task.taskSlug, context);
38
47
  await deps.architectRestartService?.recoverTask(repoRoot, task.taskSlug);
39
48
  await deps.roleContextRestartService?.recoverTask(repoRoot, task.taskSlug);
40
49
  if ((roundRecovered || task.status === "running") && !hasLiveTaskSession(task.taskSlug)) {
@@ -238,16 +247,32 @@ export function createRuntimeRecoveryService(deps) {
238
247
  });
239
248
  context.changedPaths.add(relativePath);
240
249
  }
241
- async function cleanupCoderWorkers(taskRepoRoot, context) {
242
- const absolutePath = path.join(taskRepoRoot, CODER_WORKERS_RUNTIME_DIR);
243
- if (!(await deps.fs.pathExists(absolutePath))) {
250
+ async function recoverCoderWorkers(taskRepoRoot, taskSlug, context) {
251
+ if (hasLiveRoundRole(taskSlug, "coder") || !deps.fs.removePath) {
244
252
  return;
245
253
  }
246
- if (!deps.fs.removePath) {
254
+ const tasksPath = path.join(taskRepoRoot, CODER_WORKERS_RUNTIME_DIR, "tasks");
255
+ if (!(await deps.fs.pathExists(tasksPath))) {
247
256
  return;
248
257
  }
249
- await deps.fs.removePath(absolutePath, { recursive: true, force: true });
250
- context.changedPaths.add(CODER_WORKERS_RUNTIME_DIR);
258
+ for (const entry of await deps.fs.readDir(tasksPath)) {
259
+ if (!entry.endsWith(".json")) {
260
+ continue;
261
+ }
262
+ const absolutePath = path.join(tasksPath, entry);
263
+ let state;
264
+ try {
265
+ state = await deps.fs.readJson(absolutePath);
266
+ }
267
+ catch {
268
+ continue;
269
+ }
270
+ if (state.status !== "running" || state.handled === true) {
271
+ continue;
272
+ }
273
+ await deps.fs.removePath(absolutePath, { force: true });
274
+ context.changedPaths.add(path.posix.join(CODER_WORKERS_RUNTIME_DIR, "tasks", entry));
275
+ }
251
276
  }
252
277
  async function recoverHarnessBootstrap(repoRoot, _timestamp, context) {
253
278
  const absolutePath = path.join(repoRoot, BOOTSTRAP_SESSION_PATH);
@@ -53,6 +53,19 @@ export function createTaskService(deps) {
53
53
  hint: `Commit, stash, or discard these changes before creating a task worktree: ${baseVisibleChanges.slice(0, 12).join(", ")}`
54
54
  });
55
55
  }
56
+ const upstreamBranch = await deps.git.getUpstreamBranch(repoRoot);
57
+ if (upstreamBranch) {
58
+ await deps.git.pullFastForward(repoRoot);
59
+ const postPullVisibleChanges = await getBaseRepoVisibleChanges(deps.git, repoRoot);
60
+ if (postPullVisibleChanges.length > 0) {
61
+ throw new VcmError({
62
+ code: "BASE_REPO_DIRTY",
63
+ message: "The connected repository has Git-visible changes after pulling its upstream branch.",
64
+ statusCode: 409,
65
+ hint: `Commit, stash, or discard these changes before creating a task worktree: ${postPullVisibleChanges.slice(0, 12).join(", ")}`
66
+ });
67
+ }
68
+ }
56
69
  const timestamp = now();
57
70
  await deps.fs.ensureDir(path.dirname(worktreePath));
58
71
  await deps.git.createWorktree({