vibe-coding-master 0.7.43 → 0.7.45

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 (54) hide show
  1. package/README.md +64 -39
  2. package/dist/backend/adapters/claude-adapter.js +2 -2
  3. package/dist/backend/adapters/codex-bridge-adapter.js +213 -0
  4. package/dist/backend/api/app-settings-routes.js +6 -6
  5. package/dist/backend/api/artifact-routes.js +3 -0
  6. package/dist/backend/api/harness-routes.js +16 -0
  7. package/dist/backend/api/task-routes.js +1 -0
  8. package/dist/backend/api/workflow-control-routes.js +12 -0
  9. package/dist/backend/cli/install-vcm-harness.js +21 -0
  10. package/dist/backend/server.js +11 -9
  11. package/dist/backend/services/app-settings-service.js +11 -11
  12. package/dist/backend/services/artifact-service.js +7 -30
  13. package/dist/backend/services/auto-memory-service.js +640 -12
  14. package/dist/backend/services/claude-hook-service.js +80 -2
  15. package/dist/backend/services/{ccr-integration-service.js → codex-bridge-integration-service.js} +71 -71
  16. package/dist/backend/services/gate-review-service.js +173 -74
  17. package/dist/backend/services/harness-feedback-service.js +76 -78
  18. package/dist/backend/services/harness-service.js +27 -3
  19. package/dist/backend/services/runtime-coordinator-service.js +2 -1
  20. package/dist/backend/services/session-service.js +16 -16
  21. package/dist/backend/services/status-service.js +1 -0
  22. package/dist/backend/services/translation-worker-service.js +19 -4
  23. package/dist/backend/services/usage-analytics-service.js +3 -3
  24. package/dist/backend/services/workflow-control-service.js +96 -12
  25. package/dist/backend/templates/handoff.js +44 -2
  26. package/dist/backend/templates/harness/architect-agent.js +13 -7
  27. package/dist/backend/templates/harness/architect-scaffold-worker-agent.js +1 -1
  28. package/dist/backend/templates/harness/check-scaffold-ledger.js +234 -10
  29. package/dist/backend/templates/harness/claude-root.js +3 -2
  30. package/dist/backend/templates/harness/coder-agent.js +8 -0
  31. package/dist/backend/templates/harness/gate-review.js +144 -49
  32. package/dist/backend/templates/harness/harness-engineer-agent.js +25 -10
  33. package/dist/backend/templates/harness/project-manager-agent.js +16 -10
  34. package/dist/backend/templates/harness/resolve-durable-doc-assignment.js +60 -0
  35. package/dist/backend/templates/harness/tester-agent.js +13 -0
  36. package/dist/backend/templates/harness/vcm-ask-user-skill.js +82 -0
  37. package/dist/backend/templates/harness/vcm-code-navigation-skill.js +7 -5
  38. package/dist/backend/templates/harness/vcm-task-state-skill.js +2 -2
  39. package/dist/backend/templates/harness/vcm-workflow-review-skill.js +1 -1
  40. package/dist/shared/types/session.js +28 -22
  41. package/dist/shared/types/workflow.js +1 -0
  42. package/dist/shared/validation/artifact-check.js +3 -3
  43. package/dist/shared/validation/artifact-contract.js +1 -1
  44. package/dist/shared/validation/artifact-registry.js +16 -0
  45. package/dist-frontend/assets/index-DDmygnV6.css +32 -0
  46. package/dist-frontend/assets/{index-VW9tYPP5.js → index-nAV6toi8.js} +47 -47
  47. package/dist-frontend/index.html +2 -2
  48. package/package.json +1 -1
  49. package/scripts/claude-plugins/vcm-lsp-bridge/.claude-plugin/plugin.json +21 -6
  50. package/scripts/{ccr-api-key-helper.mjs → codex-bridge-api-key-helper.mjs} +1 -1
  51. package/scripts/harness-tools/vcm-artifact +1 -2
  52. package/scripts/harness-tools/vcm-bash-guard +1 -1
  53. package/dist/backend/adapters/ccr-gateway-adapter.js +0 -172
  54. package/dist-frontend/assets/index-B0d4Z6ny.css +0 -32
@@ -122,7 +122,7 @@ export function createAutoMemoryService(deps) {
122
122
  listRuns(taskRepoRoot),
123
123
  listMemoryFiles(taskRepoRoot)
124
124
  ]);
125
- if (active && !(await deps.appSettings.getPreferences()).autoMemoryEnabled) {
125
+ if (active && !preserveDisabledReview(active) && !(await deps.appSettings.getPreferences()).autoMemoryEnabled) {
126
126
  await discardActiveReview(taskRepoRoot, active);
127
127
  active = undefined;
128
128
  runs = await listRuns(taskRepoRoot);
@@ -140,7 +140,13 @@ export function createAutoMemoryService(deps) {
140
140
  const active = await loadActiveState(input.taskRepoRoot);
141
141
  const preferences = await deps.appSettings.getPreferences();
142
142
  if (!preferences.autoMemoryEnabled) {
143
- if (active) {
143
+ if (active?.status === "documenting") {
144
+ await dispatchCurrentDurableDocAssignment(input.baseRepoRoot, input.taskRepoRoot, active);
145
+ }
146
+ else if (active?.status === "reviewing" && active.reviewPromptDispatchedAt) {
147
+ return getState(input.baseRepoRoot, input.taskRepoRoot);
148
+ }
149
+ else if (active) {
144
150
  await discardActiveReview(input.taskRepoRoot, active);
145
151
  }
146
152
  return getState(input.baseRepoRoot, input.taskRepoRoot);
@@ -153,6 +159,10 @@ export function createAutoMemoryService(deps) {
153
159
  if (active?.status === "reviewing") {
154
160
  return getState(input.baseRepoRoot, input.taskRepoRoot);
155
161
  }
162
+ if (active?.status === "documenting") {
163
+ await dispatchCurrentDurableDocAssignment(input.baseRepoRoot, input.taskRepoRoot, active);
164
+ return getState(input.baseRepoRoot, input.taskRepoRoot);
165
+ }
156
166
  if (active || !input.roundReady || !input.requestTrigger) {
157
167
  return getState(input.baseRepoRoot, input.taskRepoRoot);
158
168
  }
@@ -187,7 +197,8 @@ export function createAutoMemoryService(deps) {
187
197
  trigger: input.requestTrigger,
188
198
  createdAt: timestamp,
189
199
  updatedAt: timestamp,
190
- drafts
200
+ drafts,
201
+ assignments: []
191
202
  };
192
203
  const before = await readMemorySet(input.taskRepoRoot);
193
204
  await writeRunMemorySet(input.taskRepoRoot, runId, "before", before);
@@ -304,6 +315,7 @@ export function createAutoMemoryService(deps) {
304
315
  roleDraftsPath: path.join(runRoot, "drafts"),
305
316
  currentMemoryPath: path.join(runRoot, "before"),
306
317
  activeMemoryPaths: memoryPaths.map((memoryPath) => resolveRepoPath(taskRepoRoot, memoryPath)),
318
+ reviewResultPath: path.join(runRoot, "review-result.json"),
307
319
  proposalCandidates,
308
320
  ...(planningCandidatePath
309
321
  ? { planningCandidatePath: resolveRepoPath(taskRepoRoot, planningCandidatePath) }
@@ -324,17 +336,30 @@ export function createAutoMemoryService(deps) {
324
336
  async function isRoleMemoryTurn(taskRepoRoot, role) {
325
337
  const state = await loadActiveState(taskRepoRoot);
326
338
  const draft = state?.status === "collecting" ? currentDraft(state) : undefined;
327
- return draft?.role === role && draft.status === "dispatched";
339
+ if (draft?.role === role && draft.status === "dispatched") {
340
+ return true;
341
+ }
342
+ const assignment = state?.status === "documenting" ? currentDurableDocAssignment(state) : undefined;
343
+ if (!assignment) {
344
+ return false;
345
+ }
346
+ if (assignment.status === "resolving-owner") {
347
+ return role === "project-manager";
348
+ }
349
+ return assignment.status === "running" && assignment.owner === role;
328
350
  }
329
351
  async function handleRoleHook(input) {
330
352
  const state = await loadActiveState(input.taskRepoRoot);
331
- if (!(await deps.appSettings.getPreferences()).autoMemoryEnabled) {
353
+ if (!(await deps.appSettings.getPreferences()).autoMemoryEnabled && !preserveDisabledReview(state)) {
332
354
  if (state) {
333
355
  await discardActiveReview(input.taskRepoRoot, state);
334
356
  return true;
335
357
  }
336
358
  return false;
337
359
  }
360
+ if (state?.status === "documenting") {
361
+ return handleDurableDocRoleHook(input, state);
362
+ }
338
363
  const draft = state?.status === "collecting" ? currentDraft(state) : undefined;
339
364
  if (!state || !draft || draft.role !== input.role) {
340
365
  return false;
@@ -375,7 +400,7 @@ export function createAutoMemoryService(deps) {
375
400
  }
376
401
  async function handleHarnessEngineerHook(input) {
377
402
  const state = await loadActiveState(input.taskRepoRoot);
378
- if (!(await deps.appSettings.getPreferences()).autoMemoryEnabled) {
403
+ if (!(await deps.appSettings.getPreferences()).autoMemoryEnabled && !preserveDisabledReview(state)) {
379
404
  if (state) {
380
405
  await discardActiveReview(input.taskRepoRoot, state);
381
406
  return true;
@@ -404,6 +429,9 @@ export function createAutoMemoryService(deps) {
404
429
  }
405
430
  try {
406
431
  await recordHarnessEngineerMemoryResult(input.taskRepoRoot, state);
432
+ if (state.assignments.length > 0) {
433
+ await dispatchCurrentDurableDocAssignment(input.baseRepoRoot, input.taskRepoRoot, state);
434
+ }
407
435
  }
408
436
  catch (error) {
409
437
  await failReview(input.taskRepoRoot, state, `Harness Engineer memory result could not be recorded: ${errorMessage(error)}`);
@@ -484,9 +512,49 @@ export function createAutoMemoryService(deps) {
484
512
  await clearActiveState(taskRepoRoot);
485
513
  return getState(baseRepoRoot, taskRepoRoot);
486
514
  }
515
+ async function retryDurableDocAssignment(baseRepoRoot, taskRepoRoot, assignmentId) {
516
+ const state = await loadActiveState(taskRepoRoot);
517
+ const assignment = state?.status === "documenting"
518
+ ? state.assignments.find((candidate) => candidate.id === assignmentId)
519
+ : undefined;
520
+ if (!state || !assignment || assignment.status !== "failed") {
521
+ throw new VcmError({
522
+ code: "DURABLE_DOC_ASSIGNMENT_NOT_FAILED",
523
+ message: `There is no failed durable-document assignment to retry: ${assignmentId}`,
524
+ statusCode: 409
525
+ });
526
+ }
527
+ assignment.status = assignment.owner ? "pending" : "waiting-owner";
528
+ assignment.updatedAt = now();
529
+ delete assignment.error;
530
+ delete assignment.requestedOwner;
531
+ delete assignment.dispatchedAt;
532
+ delete assignment.baseCommit;
533
+ delete assignment.reportHashBefore;
534
+ await persistMemoryAssignmentState(taskRepoRoot, state);
535
+ await dispatchCurrentDurableDocAssignment(baseRepoRoot, taskRepoRoot, state);
536
+ return getState(baseRepoRoot, taskRepoRoot);
537
+ }
538
+ async function resolveDurableDocAssignmentOwner(baseRepoRoot, taskRepoRoot, assignmentId, owner) {
539
+ const state = await loadActiveState(taskRepoRoot);
540
+ const assignment = state?.status === "documenting"
541
+ ? currentDurableDocAssignment(state)
542
+ : undefined;
543
+ if (!state || !assignment || assignment.id !== assignmentId || assignment.status !== "resolving-owner") {
544
+ throw new VcmError({
545
+ code: "DURABLE_DOC_OWNER_RESOLUTION_NOT_ACTIVE",
546
+ message: `Durable-document assignment is not waiting for PM owner resolution: ${assignmentId}`,
547
+ statusCode: 409
548
+ });
549
+ }
550
+ assignment.requestedOwner = owner;
551
+ assignment.updatedAt = now();
552
+ await persistMemoryAssignmentState(taskRepoRoot, state);
553
+ return getState(baseRepoRoot, taskRepoRoot);
554
+ }
487
555
  async function assertHarnessEngineerAvailable(taskRepoRoot) {
488
556
  const state = await loadActiveState(taskRepoRoot);
489
- if (state && !(await deps.appSettings.getPreferences()).autoMemoryEnabled) {
557
+ if (state && !preserveDisabledReview(state) && !(await deps.appSettings.getPreferences()).autoMemoryEnabled) {
490
558
  await discardActiveReview(taskRepoRoot, state);
491
559
  return;
492
560
  }
@@ -502,7 +570,7 @@ export function createAutoMemoryService(deps) {
502
570
  }
503
571
  async function assertNoActiveReview(taskRepoRoot) {
504
572
  const state = await loadActiveState(taskRepoRoot);
505
- if (state && !(await deps.appSettings.getPreferences()).autoMemoryEnabled) {
573
+ if (state && !preserveDisabledReview(state) && !(await deps.appSettings.getPreferences()).autoMemoryEnabled) {
506
574
  await discardActiveReview(taskRepoRoot, state);
507
575
  return;
508
576
  }
@@ -518,6 +586,166 @@ export function createAutoMemoryService(deps) {
518
586
  : "Wait for the active memory review to finish."
519
587
  });
520
588
  }
589
+ async function handleDurableDocRoleHook(input, state) {
590
+ const assignment = currentDurableDocAssignment(state);
591
+ if (!assignment) {
592
+ return false;
593
+ }
594
+ const expectedRole = assignment.status === "resolving-owner"
595
+ ? "project-manager"
596
+ : assignment.status === "running"
597
+ ? assignment.owner
598
+ : undefined;
599
+ if (expectedRole !== input.role) {
600
+ return false;
601
+ }
602
+ if (input.eventName === "UserPromptSubmit" || input.eventName === "PostCompact") {
603
+ return true;
604
+ }
605
+ if (input.eventName === "StopFailure") {
606
+ assignment.status = "failed";
607
+ assignment.error = `${input.role} durable-document assignment turn failed.`;
608
+ assignment.updatedAt = now();
609
+ await persistMemoryAssignmentState(input.taskRepoRoot, state);
610
+ return true;
611
+ }
612
+ if (input.eventName !== "Stop") {
613
+ return true;
614
+ }
615
+ if (assignment.status === "resolving-owner") {
616
+ if (!assignment.requestedOwner) {
617
+ return true;
618
+ }
619
+ assignment.owner = assignment.requestedOwner;
620
+ delete assignment.requestedOwner;
621
+ assignment.status = "pending";
622
+ assignment.updatedAt = now();
623
+ await persistMemoryAssignmentState(input.taskRepoRoot, state);
624
+ await dispatchCurrentDurableDocAssignment(input.baseRepoRoot, input.taskRepoRoot, state);
625
+ return true;
626
+ }
627
+ try {
628
+ assignment.commit = await validateDurableDocAssignmentCompletion(input.taskRepoRoot, assignment);
629
+ assignment.status = "completed";
630
+ assignment.completedAt = now();
631
+ assignment.updatedAt = assignment.completedAt;
632
+ delete assignment.error;
633
+ await persistMemoryAssignmentState(input.taskRepoRoot, state);
634
+ const next = currentDurableDocAssignment(state);
635
+ if (next) {
636
+ await dispatchCurrentDurableDocAssignment(input.baseRepoRoot, input.taskRepoRoot, state);
637
+ }
638
+ else {
639
+ await clearActiveState(input.taskRepoRoot);
640
+ }
641
+ }
642
+ catch (error) {
643
+ assignment.status = "failed";
644
+ assignment.error = `Durable-document assignment could not be completed: ${errorMessage(error)}`;
645
+ assignment.updatedAt = now();
646
+ await persistMemoryAssignmentState(input.taskRepoRoot, state);
647
+ }
648
+ return true;
649
+ }
650
+ async function dispatchCurrentDurableDocAssignment(baseRepoRoot, taskRepoRoot, state) {
651
+ const assignment = currentDurableDocAssignment(state);
652
+ if (!assignment || assignment.status === "failed") {
653
+ return;
654
+ }
655
+ if (assignment.status === "running" || assignment.status === "resolving-owner") {
656
+ const role = assignment.status === "resolving-owner" ? "project-manager" : assignment.owner;
657
+ if (!role) {
658
+ return;
659
+ }
660
+ const existing = await deps.sessionService.getRoleSession(baseRepoRoot, state.taskSlug, role);
661
+ if (existing?.activityStatus === "running" && deps.runtime.getSession(existing.id)) {
662
+ return;
663
+ }
664
+ assignment.status = assignment.owner ? "pending" : "waiting-owner";
665
+ assignment.updatedAt = now();
666
+ await persistMemoryAssignmentState(taskRepoRoot, state);
667
+ }
668
+ const role = assignment.owner ?? "project-manager";
669
+ try {
670
+ const session = await ensureWorkflowRoleSession(baseRepoRoot, state.taskSlug, role);
671
+ if (session.activityStatus === "running") {
672
+ return;
673
+ }
674
+ assignment.status = assignment.owner ? "running" : "resolving-owner";
675
+ assignment.baseCommit = await deps.git.getHeadCommit(taskRepoRoot);
676
+ assignment.reportHashBefore = await hashOptionalFile(taskRepoRoot, assignment.reportPath);
677
+ assignment.dispatchedAt = now();
678
+ assignment.updatedAt = assignment.dispatchedAt;
679
+ await persistMemoryAssignmentState(taskRepoRoot, state);
680
+ await submitTerminalInput(deps.runtime, session.id, assignment.owner
681
+ ? buildDurableDocAssignmentPrompt(taskRepoRoot, assignment)
682
+ : buildDurableDocOwnerPrompt(taskRepoRoot, assignment));
683
+ }
684
+ catch (error) {
685
+ assignment.status = "failed";
686
+ assignment.error = `Unable to dispatch durable-document assignment: ${errorMessage(error)}`;
687
+ assignment.updatedAt = now();
688
+ await persistMemoryAssignmentState(taskRepoRoot, state);
689
+ }
690
+ }
691
+ async function validateDurableDocAssignmentCompletion(taskRepoRoot, assignment) {
692
+ const reportAbsolutePath = resolveRepoPath(taskRepoRoot, assignment.reportPath);
693
+ if (!(await deps.fs.pathExists(reportAbsolutePath))) {
694
+ throw new Error(`required report is missing: ${assignment.reportPath}`);
695
+ }
696
+ const reportContent = await deps.fs.readText(reportAbsolutePath);
697
+ const reportHash = sha256(reportContent);
698
+ if (assignment.reportHashBefore && reportHash === assignment.reportHashBefore) {
699
+ throw new Error(`${assignment.reportPath} was not updated for assignment ${assignment.id}`);
700
+ }
701
+ const check = checkMarkdownArtifact("docs-update-report", assignment.reportPath, reportContent);
702
+ if (check.status !== "ok") {
703
+ const reasons = [
704
+ ...check.missingHeadings.map((heading) => `missing heading ${heading}`),
705
+ ...check.invalidFields
706
+ ];
707
+ throw new Error(`${assignment.reportPath} is invalid: ${reasons.join("; ") || check.status}`);
708
+ }
709
+ const assignmentId = readArtifactSectionValue(reportContent, "Assignment ID")?.trim();
710
+ if (assignmentId !== assignment.id) {
711
+ throw new Error(`Assignment ID must be exactly ${assignment.id}`);
712
+ }
713
+ const decision = readArtifactSectionValue(reportContent, "Decision")?.trim().toLowerCase();
714
+ if (decision !== "synced" && decision !== "unchanged") {
715
+ throw new Error("Decision must be synced or unchanged");
716
+ }
717
+ const currentHead = await deps.git.getHeadCommit(taskRepoRoot);
718
+ if (decision === "synced") {
719
+ if (!assignment.baseCommit || currentHead === assignment.baseCommit) {
720
+ throw new Error("documentation changes were not committed");
721
+ }
722
+ const changedPaths = await deps.git.getChangedPaths(taskRepoRoot, assignment.baseCommit, currentHead);
723
+ if (!changedPaths.includes(assignment.targetPath)) {
724
+ throw new Error(`documentation commit does not include ${assignment.targetPath}`);
725
+ }
726
+ const reportedCommit = readArtifactSectionValue(reportContent, "Commit")?.trim();
727
+ if (!reportedCommit || reportedCommit === "TBD" || !currentHead.startsWith(reportedCommit)) {
728
+ throw new Error(`Commit must identify the current documentation commit ${currentHead}`);
729
+ }
730
+ }
731
+ return currentHead;
732
+ }
733
+ async function hashOptionalFile(taskRepoRoot, relativePath) {
734
+ const absolutePath = resolveRepoPath(taskRepoRoot, relativePath);
735
+ return await deps.fs.pathExists(absolutePath)
736
+ ? sha256(await deps.fs.readText(absolutePath))
737
+ : undefined;
738
+ }
739
+ async function persistMemoryAssignmentState(taskRepoRoot, state) {
740
+ state.updatedAt = now();
741
+ await persistActiveState(taskRepoRoot, state);
742
+ const run = await readRun(taskRepoRoot, state.runId);
743
+ await persistRun(taskRepoRoot, {
744
+ ...run,
745
+ assignments: state.assignments,
746
+ updatedAt: state.updatedAt
747
+ });
748
+ }
521
749
  async function dispatchCurrentDraft(baseRepoRoot, taskRepoRoot, state) {
522
750
  const draft = currentDraft(state);
523
751
  if (!draft || draft.status !== "pending") {
@@ -641,8 +869,27 @@ export function createAutoMemoryService(deps) {
641
869
  hint: "Remove the unnecessary memory commits or leave memory unchanged without committing."
642
870
  });
643
871
  }
644
- await writeRunMemorySet(taskRepoRoot, state.runId, "after", after);
872
+ const reviewResult = await readAndValidateHarnessMemoryReviewResult(taskRepoRoot, state, before, after, currentHead, changedMemoryPaths);
645
873
  const timestamp = now();
874
+ const assignments = reviewResult.durableDocAssignments.map((assignment, index) => {
875
+ const owner = inferDurableDocAssignmentOwner(assignment.targetPath);
876
+ return {
877
+ id: `${state.runId}-doc-${index + 1}`,
878
+ runId: state.runId,
879
+ sourceMemoryPath: assignment.sourceMemoryPath,
880
+ sourceEntry: assignment.sourceEntry,
881
+ targetPath: normalizeProjectRelativePath(assignment.targetPath),
882
+ content: assignment.content,
883
+ reason: assignment.reason,
884
+ evidence: assignment.evidence,
885
+ ...(owner ? { owner } : {}),
886
+ status: owner ? "pending" : "waiting-owner",
887
+ reportPath: ".ai/vcm/handoffs/docs-update-report.md",
888
+ createdAt: timestamp,
889
+ updatedAt: timestamp
890
+ };
891
+ });
892
+ await writeRunMemorySet(taskRepoRoot, state.runId, "after", after);
646
893
  const diff = renderMemoryDiff(before, after);
647
894
  const run = await readRun(taskRepoRoot, state.runId);
648
895
  await persistRun(taskRepoRoot, {
@@ -651,10 +898,225 @@ export function createAutoMemoryService(deps) {
651
898
  updatedAt: timestamp,
652
899
  appliedAt: timestamp,
653
900
  afterHashes: hashMemorySet(after),
654
- diff
901
+ diff,
902
+ assignments
655
903
  });
656
904
  await deps.fs.writeText(resolveRepoPath(taskRepoRoot, `${MEMORY_REVIEW_RUNS_ROOT}/${state.runId}/applied.patch`), diff);
657
- await clearActiveState(taskRepoRoot);
905
+ if (assignments.length === 0) {
906
+ await clearActiveState(taskRepoRoot);
907
+ return;
908
+ }
909
+ state.status = "documenting";
910
+ state.assignments = assignments;
911
+ state.updatedAt = timestamp;
912
+ delete state.error;
913
+ await persistActiveState(taskRepoRoot, state);
914
+ }
915
+ async function readAndValidateHarnessMemoryReviewResult(taskRepoRoot, state, before, after, currentHead, changedMemoryPaths) {
916
+ const resultPath = resolveRepoPath(taskRepoRoot, `${MEMORY_REVIEW_RUNS_ROOT}/${state.runId}/review-result.json`);
917
+ if (!(await deps.fs.pathExists(resultPath))) {
918
+ throw new VcmError({
919
+ code: "MEMORY_REVIEW_RESULT_MISSING",
920
+ message: "Harness Engineer did not write review-result.json.",
921
+ statusCode: 409,
922
+ hint: `Write the validated memory decisions to ${resultPath} before ending the retrospective turn.`
923
+ });
924
+ }
925
+ const result = await deps.fs.readJson(resultPath);
926
+ const shapeError = validateMemoryReviewResultShape(result, state.runId);
927
+ if (shapeError) {
928
+ throw new VcmError({
929
+ code: "MEMORY_REVIEW_RESULT_INVALID",
930
+ message: `Harness Engineer review-result.json is invalid: ${shapeError}`,
931
+ statusCode: 409
932
+ });
933
+ }
934
+ if (changedMemoryPaths.length > 0 && result.memoryCommit !== currentHead) {
935
+ throw new VcmError({
936
+ code: "MEMORY_REVIEW_RESULT_COMMIT_MISMATCH",
937
+ message: `review-result.json memoryCommit must be the current memory commit: ${currentHead}`,
938
+ statusCode: 409
939
+ });
940
+ }
941
+ if (changedMemoryPaths.length === 0 && result.memoryCommit !== "none") {
942
+ throw new VcmError({
943
+ code: "MEMORY_REVIEW_RESULT_COMMIT_UNEXPECTED",
944
+ message: "review-result.json memoryCommit must be none when memory is unchanged.",
945
+ statusCode: 409
946
+ });
947
+ }
948
+ const candidates = await readReviewCandidates(taskRepoRoot, state);
949
+ const proposalDecisions = result.decisions.filter((decision) => decision.source === "proposal");
950
+ for (const candidate of candidates) {
951
+ const matches = proposalDecisions.filter((decision) => decision.itemId === candidate.id);
952
+ if (matches.length !== 1) {
953
+ throw new VcmError({
954
+ code: "MEMORY_REVIEW_PROPOSAL_DECISION_MISSING",
955
+ message: `review-result.json must contain exactly one decision for proposal ${candidate.id}.`,
956
+ statusCode: 409
957
+ });
958
+ }
959
+ validateProposalDecision(candidate, matches[0], after);
960
+ }
961
+ const unknownProposal = proposalDecisions.find((decision) => !candidates.some((candidate) => candidate.id === decision.itemId));
962
+ if (unknownProposal) {
963
+ throw new VcmError({
964
+ code: "MEMORY_REVIEW_PROPOSAL_DECISION_UNKNOWN",
965
+ message: `review-result.json contains an unknown proposal decision: ${unknownProposal.itemId}`,
966
+ statusCode: 409
967
+ });
968
+ }
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)) {
972
+ throw new VcmError({
973
+ code: "MEMORY_REVIEW_EXISTING_DECISION_UNKNOWN",
974
+ message: `review-result.json contains an unknown existing-memory decision: ${decision.itemId}`,
975
+ statusCode: 409
976
+ });
977
+ }
978
+ }
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);
993
+ }
994
+ }
995
+ const moveDecisions = result.decisions.filter((decision) => decision.decision === "move-to-durable-doc");
996
+ if (moveDecisions.length !== result.durableDocAssignments.length) {
997
+ throw new VcmError({
998
+ code: "MEMORY_REVIEW_ASSIGNMENT_COUNT_MISMATCH",
999
+ message: "Every move-to-durable-doc decision must have exactly one durableDocAssignment.",
1000
+ statusCode: 409
1001
+ });
1002
+ }
1003
+ const assignmentKeys = new Set();
1004
+ for (const assignment of result.durableDocAssignments) {
1005
+ validateDurableDocAssignmentInput(assignment, after);
1006
+ const key = `${assignment.sourceMemoryPath}\n${assignment.sourceEntry}\n${assignment.targetPath}`;
1007
+ if (assignmentKeys.has(key)) {
1008
+ throw new VcmError({
1009
+ code: "MEMORY_REVIEW_ASSIGNMENT_DUPLICATE",
1010
+ message: `Duplicate durableDocAssignment for ${assignment.targetPath}: ${assignment.sourceEntry}`,
1011
+ statusCode: 409
1012
+ });
1013
+ }
1014
+ assignmentKeys.add(key);
1015
+ const matchingDecision = moveDecisions.find((decision) => (memoryTargetToPath(decision.target) === assignment.sourceMemoryPath
1016
+ && decision.entry === assignment.sourceEntry
1017
+ && decision.durableDocPath === assignment.targetPath));
1018
+ if (!matchingDecision) {
1019
+ throw new VcmError({
1020
+ code: "MEMORY_REVIEW_ASSIGNMENT_DECISION_MISSING",
1021
+ message: `durableDocAssignment has no matching move decision: ${assignment.targetPath}`,
1022
+ statusCode: 409
1023
+ });
1024
+ }
1025
+ }
1026
+ return result;
1027
+ }
1028
+ function validateProposalDecision(candidate, decision, after) {
1029
+ const expectedEntry = candidate.content ?? candidate.existing ?? "";
1030
+ if (decision.entry !== expectedEntry || decision.target !== candidate.target) {
1031
+ throw new VcmError({
1032
+ code: "MEMORY_REVIEW_PROPOSAL_DECISION_MISMATCH",
1033
+ message: `Proposal decision does not match assigned candidate ${candidate.id}.`,
1034
+ statusCode: 409
1035
+ });
1036
+ }
1037
+ const afterEntries = new Set(substantiveMemoryEntries(after[memoryTargetToPath(decision.target)]));
1038
+ if (candidate.operation === "remove") {
1039
+ if (decision.decision !== "remove" && decision.decision !== "retain") {
1040
+ throw new VcmError({
1041
+ code: "MEMORY_REVIEW_PROPOSAL_DECISION_INVALID",
1042
+ message: `Remove proposal ${candidate.id} must use remove or retain.`,
1043
+ statusCode: 409
1044
+ });
1045
+ }
1046
+ if (decision.decision === "remove" && afterEntries.has(expectedEntry)) {
1047
+ throw new VcmError({
1048
+ code: "MEMORY_REVIEW_PROPOSAL_REMOVAL_MISMATCH",
1049
+ message: `Accepted removal is still present for proposal ${candidate.id}.`,
1050
+ statusCode: 409
1051
+ });
1052
+ }
1053
+ if (decision.decision === "retain" && !afterEntries.has(expectedEntry)) {
1054
+ throw new VcmError({
1055
+ code: "MEMORY_REVIEW_PROPOSAL_RETAIN_MISMATCH",
1056
+ message: `Rejected removal is missing from memory for proposal ${candidate.id}.`,
1057
+ statusCode: 409
1058
+ });
1059
+ }
1060
+ return;
1061
+ }
1062
+ if (!new Set(["keep-in-memory", "keep-memory-reference", "move-to-durable-doc", "reject"]).has(decision.decision)) {
1063
+ throw new VcmError({
1064
+ code: "MEMORY_REVIEW_PROPOSAL_DECISION_INVALID",
1065
+ message: `Add or update proposal ${candidate.id} uses an invalid decision: ${decision.decision}`,
1066
+ statusCode: 409
1067
+ });
1068
+ }
1069
+ if ((decision.decision === "keep-in-memory" || decision.decision === "keep-memory-reference")
1070
+ && !afterEntries.has(decision.finalContent)) {
1071
+ throw new VcmError({
1072
+ code: "MEMORY_REVIEW_PROPOSAL_CONTENT_MISSING",
1073
+ message: `Accepted proposal content is missing from memory for ${candidate.id}.`,
1074
+ statusCode: 409
1075
+ });
1076
+ }
1077
+ }
1078
+ function validateExistingMemoryDecision(memoryPath, entry, decision, after) {
1079
+ const afterEntries = new Set(substantiveMemoryEntries(after[memoryPath]));
1080
+ if (decision.decision === "retain" && !afterEntries.has(entry)) {
1081
+ throw new VcmError({
1082
+ code: "MEMORY_REVIEW_RETAIN_MISMATCH",
1083
+ message: `Retained memory entry is missing from ${memoryPath}: ${entry}`,
1084
+ statusCode: 409
1085
+ });
1086
+ }
1087
+ if ((decision.decision === "remove" || decision.decision === "move-to-durable-doc") && afterEntries.has(entry)) {
1088
+ throw new VcmError({
1089
+ code: "MEMORY_REVIEW_REMOVAL_MISMATCH",
1090
+ message: `Removed memory entry is still present in ${memoryPath}: ${entry}`,
1091
+ statusCode: 409
1092
+ });
1093
+ }
1094
+ if (decision.decision === "update") {
1095
+ if (!decision.finalContent || decision.finalContent === "none" || !afterEntries.has(decision.finalContent)) {
1096
+ throw new VcmError({
1097
+ code: "MEMORY_REVIEW_UPDATE_MISMATCH",
1098
+ message: `Updated memory content is missing from ${memoryPath}: ${decision.finalContent}`,
1099
+ statusCode: 409
1100
+ });
1101
+ }
1102
+ }
1103
+ }
1104
+ function validateDurableDocAssignmentInput(assignment, after) {
1105
+ if (!MEMORY_FILE_DEFINITIONS.some((definition) => definition.path === assignment.sourceMemoryPath)) {
1106
+ throw new VcmError({
1107
+ code: "MEMORY_REVIEW_ASSIGNMENT_SOURCE_INVALID",
1108
+ message: `Unknown memory source path: ${assignment.sourceMemoryPath}`,
1109
+ statusCode: 409
1110
+ });
1111
+ }
1112
+ if (substantiveMemoryEntries(after[assignment.sourceMemoryPath]).includes(assignment.sourceEntry)) {
1113
+ throw new VcmError({
1114
+ code: "MEMORY_REVIEW_ASSIGNMENT_SOURCE_NOT_REMOVED",
1115
+ message: `Move-to-durable-doc must remove memory before assignment: ${assignment.sourceEntry}`,
1116
+ statusCode: 409
1117
+ });
1118
+ }
1119
+ assertDurableDocPath(assignment.targetPath);
658
1120
  }
659
1121
  async function createAppliedRun(taskRepoRoot, taskSlug, source, before, after) {
660
1122
  assertCompleteMemorySet(after);
@@ -663,7 +1125,7 @@ export function createAutoMemoryService(deps) {
663
1125
  await writeRunMemorySet(taskRepoRoot, runId, "before", before);
664
1126
  await writeRunMemorySet(taskRepoRoot, runId, "after", after);
665
1127
  try {
666
- await applyAndCommitMemorySet(taskRepoRoot, before, after, "chore: update VCM memory");
1128
+ await applyAndCommitMemorySet(taskRepoRoot, before, after, "[VCM Harness] Update VCM memory");
667
1129
  const diff = renderMemoryDiff(before, after);
668
1130
  await persistRun(taskRepoRoot, {
669
1131
  version: 1,
@@ -729,6 +1191,7 @@ export function createAutoMemoryService(deps) {
729
1191
  finalAcceptanceHash: run.finalAcceptanceHash,
730
1192
  trigger: run.trigger,
731
1193
  diff: run.diff ?? "",
1194
+ assignments: run.assignments ?? [],
732
1195
  canRevert: run.status === "applied" && !run.revertedAt && Boolean(run.afterHashes),
733
1196
  error: run.error
734
1197
  });
@@ -780,6 +1243,7 @@ export function createAutoMemoryService(deps) {
780
1243
  const migrated = stored.drafts.some((draft) => draft.status === "running");
781
1244
  const state = {
782
1245
  ...stored,
1246
+ assignments: stored.assignments ?? [],
783
1247
  drafts: stored.drafts.map((draft) => ({
784
1248
  ...draft,
785
1249
  status: draft.status === "running" ? "dispatched" : draft.status
@@ -840,6 +1304,8 @@ export function createAutoMemoryService(deps) {
840
1304
  updateFile,
841
1305
  revertRun,
842
1306
  retryFailedReview,
1307
+ retryDurableDocAssignment,
1308
+ resolveDurableDocAssignmentOwner,
843
1309
  prepareTaskRetrospectiveReview,
844
1310
  cancelTaskRetrospectiveReview,
845
1311
  isRoleMemoryTurn,
@@ -863,6 +1329,13 @@ export async function assertMemoryBlocksInstalled(fs, taskRepoRoot) {
863
1329
  function currentDraft(state) {
864
1330
  return state.drafts.find((draft) => draft.status !== "completed");
865
1331
  }
1332
+ function preserveDisabledReview(state) {
1333
+ return state?.status === "documenting"
1334
+ || (state?.status === "reviewing" && Boolean(state.reviewPromptDispatchedAt));
1335
+ }
1336
+ function currentDurableDocAssignment(state) {
1337
+ return state.assignments.find((assignment) => assignment.status !== "completed");
1338
+ }
866
1339
  function toReviewCandidates(source, currentRole, items) {
867
1340
  return items.map((item) => ({
868
1341
  id: `${source}:${item.operation}:${item.ordinal}`,
@@ -883,6 +1356,7 @@ function toActiveReview(state) {
883
1356
  updatedAt: state.updatedAt,
884
1357
  currentRole: state.status === "collecting" ? currentDraft(state)?.role : undefined,
885
1358
  drafts: state.drafts,
1359
+ assignments: state.assignments,
886
1360
  trigger: state.trigger,
887
1361
  error: state.error
888
1362
  };
@@ -913,6 +1387,160 @@ function buildRoleDraftPrompt(taskRepoRoot, state, draft, planningCandidatePath)
913
1387
  "End the turn after VCM accepts the proposal."
914
1388
  ].join("\n");
915
1389
  }
1390
+ function buildDurableDocOwnerPrompt(taskRepoRoot, assignment) {
1391
+ return [
1392
+ "[VCM Durable Documentation Owner Resolution]",
1393
+ "",
1394
+ `Task worktree: ${taskRepoRoot}`,
1395
+ `Assignment ID: ${assignment.id}`,
1396
+ `Target document: ${assignment.targetPath}`,
1397
+ `Content to preserve: ${assignment.content}`,
1398
+ `Reason: ${assignment.reason}`,
1399
+ "",
1400
+ "Choose architect, coder, or tester as the document owner. If the correct owner cannot be determined, ask the user and wait for the answer.",
1401
+ `After deciding, run: .ai/tools/resolve-durable-doc-assignment --assignment ${assignment.id} --owner <architect|coder|tester>`,
1402
+ "End the turn after VCM accepts the owner."
1403
+ ].join("\n");
1404
+ }
1405
+ function buildDurableDocAssignmentPrompt(taskRepoRoot, assignment) {
1406
+ return [
1407
+ "[VCM Durable Documentation Assignment]",
1408
+ "",
1409
+ `Task worktree: ${taskRepoRoot}`,
1410
+ `Assignment ID: ${assignment.id}`,
1411
+ `Target document: ${assignment.targetPath}`,
1412
+ `Content to preserve: ${assignment.content}`,
1413
+ `Reason: ${assignment.reason}`,
1414
+ "Evidence:",
1415
+ ...assignment.evidence.map((item) => `- ${item}`),
1416
+ "",
1417
+ "Verify the content, update the target and any directly related durable documentation, run applicable documentation checks, and commit the documentation changes.",
1418
+ "Submit .ai/vcm/handoffs/docs-update-report.md through vcm-artifact. Set its Assignment ID to the exact ID above and record the final commit.",
1419
+ "End the turn after VCM accepts the report."
1420
+ ].join("\n");
1421
+ }
1422
+ function inferDurableDocAssignmentOwner(targetPath) {
1423
+ const normalized = normalizeProjectRelativePath(targetPath);
1424
+ if (normalized === "docs/TESTING.md") {
1425
+ return "tester";
1426
+ }
1427
+ if (normalized === "docs/ARCHITECTURE.md"
1428
+ || normalized === "docs/known-issues.md"
1429
+ || normalized.endsWith("/ARCHITECTURE.md")) {
1430
+ return "architect";
1431
+ }
1432
+ return undefined;
1433
+ }
1434
+ function memoryPathToTarget(memoryPath) {
1435
+ if (memoryPath === "CLAUDE.md") {
1436
+ return "shared";
1437
+ }
1438
+ const definition = MEMORY_FILE_DEFINITIONS.find((candidate) => candidate.path === memoryPath);
1439
+ if (!definition || !("role" in definition)) {
1440
+ throw new Error(`Unknown memory path: ${memoryPath}`);
1441
+ }
1442
+ return definition.role;
1443
+ }
1444
+ function memoryTargetToPath(target) {
1445
+ if (target === "shared") {
1446
+ return "CLAUDE.md";
1447
+ }
1448
+ const definition = MEMORY_FILE_DEFINITIONS.find((candidate) => "role" in candidate && candidate.role === target);
1449
+ if (!definition) {
1450
+ throw new Error(`Unknown memory target: ${target}`);
1451
+ }
1452
+ return definition.path;
1453
+ }
1454
+ function substantiveMemoryEntries(content) {
1455
+ return content
1456
+ .split(/\r?\n/)
1457
+ .map((line) => line.trim())
1458
+ .filter(Boolean);
1459
+ }
1460
+ function assertDurableDocPath(targetPath) {
1461
+ const normalized = normalizeProjectRelativePath(targetPath);
1462
+ if (!normalized
1463
+ || normalized.startsWith("../")
1464
+ || path.posix.isAbsolute(normalized)
1465
+ || normalized.startsWith(".ai/vcm/")
1466
+ || !normalized.endsWith(".md")) {
1467
+ throw new VcmError({
1468
+ code: "MEMORY_REVIEW_DURABLE_DOC_PATH_INVALID",
1469
+ message: `Durable-document target must be a project-relative Markdown path outside .ai/vcm: ${targetPath}`,
1470
+ statusCode: 409
1471
+ });
1472
+ }
1473
+ }
1474
+ function normalizeProjectRelativePath(filePath) {
1475
+ return filePath.trim().replaceAll("\\", "/").replace(/^\.\//, "");
1476
+ }
1477
+ function validateMemoryReviewResultShape(result, runId) {
1478
+ if (!result || typeof result !== "object") {
1479
+ return "root value must be an object";
1480
+ }
1481
+ if (result.version !== 1) {
1482
+ return "version must be 1";
1483
+ }
1484
+ if (result.runId !== runId) {
1485
+ return `runId must be ${runId}`;
1486
+ }
1487
+ if (typeof result.memoryCommit !== "string" || !result.memoryCommit.trim()) {
1488
+ return "memoryCommit must be a non-empty string";
1489
+ }
1490
+ if (!Array.isArray(result.decisions) || !Array.isArray(result.durableDocAssignments)) {
1491
+ return "decisions and durableDocAssignments must be arrays";
1492
+ }
1493
+ const allowedSources = new Set(["existing", "proposal"]);
1494
+ const allowedTargets = new Set(["shared", "project-manager", "architect", "coder", "tester", "reviewer", "harness-engineer"]);
1495
+ const allowedDecisions = new Set([
1496
+ "retain",
1497
+ "update",
1498
+ "remove",
1499
+ "move-to-durable-doc",
1500
+ "keep-in-memory",
1501
+ "keep-memory-reference",
1502
+ "reject"
1503
+ ]);
1504
+ for (const decision of result.decisions) {
1505
+ if (!decision
1506
+ || typeof decision.itemId !== "string"
1507
+ || !allowedSources.has(decision.source)
1508
+ || !allowedTargets.has(decision.target)
1509
+ || typeof decision.entry !== "string"
1510
+ || !decision.entry.trim()
1511
+ || !allowedDecisions.has(decision.decision)
1512
+ || typeof decision.reason !== "string"
1513
+ || !decision.reason.trim()
1514
+ || typeof decision.impactIfAbsent !== "string"
1515
+ || !decision.impactIfAbsent.trim()
1516
+ || !Array.isArray(decision.evidence)
1517
+ || decision.evidence.length === 0
1518
+ || decision.evidence.some((item) => typeof item !== "string" || !item.trim())
1519
+ || typeof decision.finalContent !== "string"
1520
+ || !decision.finalContent.trim()
1521
+ || typeof decision.durableDocPath !== "string"
1522
+ || !decision.durableDocPath.trim()) {
1523
+ return "every decision must use the complete review-result decision schema";
1524
+ }
1525
+ }
1526
+ for (const assignment of result.durableDocAssignments) {
1527
+ if (!assignment
1528
+ || typeof assignment.sourceMemoryPath !== "string"
1529
+ || typeof assignment.sourceEntry !== "string"
1530
+ || !assignment.sourceEntry.trim()
1531
+ || typeof assignment.targetPath !== "string"
1532
+ || typeof assignment.content !== "string"
1533
+ || !assignment.content.trim()
1534
+ || typeof assignment.reason !== "string"
1535
+ || !assignment.reason.trim()
1536
+ || !Array.isArray(assignment.evidence)
1537
+ || assignment.evidence.length === 0
1538
+ || assignment.evidence.some((item) => typeof item !== "string" || !item.trim())) {
1539
+ return "every durableDocAssignment must use the complete assignment schema";
1540
+ }
1541
+ }
1542
+ return undefined;
1543
+ }
916
1544
  function requireMemoryFileDefinition(filePath) {
917
1545
  const normalized = filePath.replaceAll("\\", "/").replace(/^\.\//, "");
918
1546
  const definition = MEMORY_FILE_DEFINITIONS.find((candidate) => candidate.path === normalized);