vibe-coding-master 0.7.50 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/backend/adapters/filesystem.js +6 -0
  2. package/dist/backend/api/task-routes.js +20 -1
  3. package/dist/backend/server.js +4 -2
  4. package/dist/backend/services/auto-memory-service.js +111 -34
  5. package/dist/backend/services/claude-hook-service.js +5 -3
  6. package/dist/backend/services/gate-review-service.js +21 -2
  7. package/dist/backend/services/harness-feedback-service.js +53 -15
  8. package/dist/backend/services/message-service.js +10 -0
  9. package/dist/backend/services/runtime-recovery-service.js +9 -0
  10. package/dist/backend/services/translation-service.js +74 -3
  11. package/dist/backend/services/workflow-control-service.js +436 -75
  12. package/dist/backend/templates/handoff.js +14 -2
  13. package/dist/backend/templates/harness/architect-agent.js +6 -5
  14. package/dist/backend/templates/harness/architect-evidence-worker-agent.js +2 -0
  15. package/dist/backend/templates/harness/architect-validation-worker-agent.js +1 -1
  16. package/dist/backend/templates/harness/claude-root.js +3 -3
  17. package/dist/backend/templates/harness/coder-agent.js +1 -0
  18. package/dist/backend/templates/harness/harness-engineer-agent.js +14 -11
  19. package/dist/backend/templates/harness/memory-block.js +10 -0
  20. package/dist/backend/templates/harness/project-manager-agent.js +5 -1
  21. package/dist/backend/templates/harness/tester-agent.js +6 -0
  22. package/dist/backend/templates/harness/vcm-workflow-review-skill.js +17 -1
  23. package/dist/shared/validation/artifact-check.js +36 -3
  24. package/dist/shared/validation/artifact-contract.js +7 -0
  25. package/dist/shared/validation/artifact-registry.js +4 -1
  26. package/dist-frontend/assets/{index-DLsIPTvK.js → index-Dh7uVCmk.js} +1 -1
  27. package/dist-frontend/index.html +1 -1
  28. package/package.json +1 -1
  29. package/scripts/harness-tools/vcm-bash-guard +245 -21
@@ -4,11 +4,14 @@ import { resolveRepoPath } from "../adapters/filesystem.js";
4
4
  import { VcmError } from "../errors.js";
5
5
  import { WORKFLOW_EVIDENCE_ARTIFACTS, WORKFLOW_EVIDENCE_GATES, WORKFLOW_FLOWS } from "../../shared/types/workflow.js";
6
6
  import { checkMarkdownArtifact, readArtifactSectionContent } from "../../shared/validation/artifact-check.js";
7
+ import { ARCHITECT_DEBUG_NORMAL_PLAN_DISPOSITION } from "../../shared/validation/artifact-contract.js";
7
8
  import { renderWorkflowProgressTemplate } from "../templates/handoff.js";
8
- const HISTORY_HEADER = "| Sequence | Flow | Target Role | Evidence | Override Authorization | Confirmed At |";
9
- const HISTORY_SEPARATOR = "| --- | --- | --- | --- | --- | --- |";
9
+ const HISTORY_HEADER = "| Sequence | Flow | Target Role | Evidence | Override Authorization | Follow-Up Approval | Confirmed At |";
10
+ const HISTORY_SEPARATOR = "| --- | --- | --- | --- | --- | --- | --- |";
11
+ const LEGACY_HISTORY_HEADER = "| Sequence | Flow | Target Role | Evidence | Override Authorization | Confirmed At |";
12
+ const LEGACY_HISTORY_SEPARATOR = "| --- | --- | --- | --- | --- | --- |";
10
13
  const TARGET_ROLES = new Set(["architect", "coder", "tester"]);
11
- const FINAL_GATE_STATUSES = new Set(["disabled", "not_required", "skipped", "overridden"]);
14
+ const FRESH_EXCEPTION_GATE_STATUSES = new Set(["not_required", "skipped", "overridden"]);
12
15
  const MISSING_EVIDENCE_HASH = "<missing>";
13
16
  const DOCS_ONLY_ROLE_TRANSITIONS = [
14
17
  "docs-only/architect",
@@ -25,18 +28,50 @@ export function createWorkflowControlService(deps) {
25
28
  const locks = new Map();
26
29
  async function getState(input) {
27
30
  try {
28
- const progressWarning = await ensureProgressFile(deps.fs, input);
29
- if (!(await deps.fs.pathExists(statePath(input)))) {
30
- const state = emptyState(input.taskSlug, now());
31
- return progressWarning ? { ...state, warnings: [progressWarning] } : state;
31
+ await recoverCommitJournal(input);
32
+ const hasProgress = await deps.fs.pathExists(progressPath(input));
33
+ const hasState = await deps.fs.pathExists(statePath(input));
34
+ if (!hasProgress && !hasState) {
35
+ await writeAtomic(deps.fs, progressPath(input), renderWorkflowProgressTemplate(input.taskSlug));
36
+ return emptyState(input.taskSlug, now());
37
+ }
38
+ if (!hasProgress) {
39
+ const state = hasState
40
+ ? normalizeState(await deps.fs.readJson(statePath(input)), input.taskSlug, now())
41
+ : emptyState(input.taskSlug, now());
42
+ return {
43
+ ...state,
44
+ warnings: [...state.warnings, "Workflow Progress is missing while workflow runtime state still exists. Restore workflow-progress.md before continuing."]
45
+ };
46
+ }
47
+ const progress = parseWorkflowProgress(await deps.fs.readText(progressPath(input)), input.taskSlug);
48
+ if (!hasState) {
49
+ if (progress.revision === 0 && progress.status === "not-started" && progress.history.length === 0 && !progress.proposal) {
50
+ return emptyState(input.taskSlug, now());
51
+ }
52
+ if (!progress.proposal && progress.history.length > 0) {
53
+ const recovered = await reconstructStateFromProgress(input, progress);
54
+ await saveState(input, recovered);
55
+ return recovered;
56
+ }
57
+ return {
58
+ ...emptyState(input.taskSlug, now()),
59
+ warnings: ["Workflow runtime state is missing for an active Workflow Progress record. Restore workflow-control.json before continuing."]
60
+ };
32
61
  }
33
62
  const state = normalizeState(await deps.fs.readJson(statePath(input)), input.taskSlug, now());
34
- return progressWarning ? { ...state, warnings: [...state.warnings, progressWarning] } : state;
63
+ return {
64
+ ...state,
65
+ warnings: [...state.warnings, ...stateProgressConsistencyWarnings(state, progress)]
66
+ };
35
67
  }
36
68
  catch (error) {
69
+ const message = errorMessage(error);
37
70
  return {
38
71
  ...emptyState(input.taskSlug, now()),
39
- warnings: [`Workflow control state could not be read: ${errorMessage(error)}`]
72
+ warnings: [message.startsWith("Workflow Progress validation failed:")
73
+ ? `Workflow Progress is invalid and was preserved unchanged: ${message}`
74
+ : `Workflow control state could not be read: ${message}`]
40
75
  };
41
76
  }
42
77
  }
@@ -66,9 +101,23 @@ export function createWorkflowControlService(deps) {
66
101
  const baseHistoryHash = historyHash(current.history);
67
102
  const effectiveFlow = resolveEffectiveFlow(current.flow, candidate.proposal.requestedFlow);
68
103
  const verdict = await evaluateTransition(deps.fs, input, state, current, effectiveFlow, candidate.proposal.targetRole);
104
+ const followUpApprovalText = candidate.proposal.followUpApprovalText?.trim();
105
+ const followUpAllowed = Boolean(followUpApprovalText) && await canRouteUserApprovedFollowUp(deps.fs, input, state, current, effectiveFlow, candidate.proposal.targetRole, verdict.allowedTransitions);
69
106
  let overrideAuthorizationId;
70
107
  let userAuthorization;
71
- if (!verdict.allowed) {
108
+ let followUpApprovalId;
109
+ let userApprovedFollowUp;
110
+ if (followUpApprovalText) {
111
+ if (verdict.allowed) {
112
+ throw workflowError("WORKFLOW_FOLLOW_UP_APPROVAL_NOT_REQUIRED", "This workflow transition is already legal and must not consume a post-validation follow-up approval.", "Set User-Approved Follow-Up Approval Text to none.");
113
+ }
114
+ if (!followUpAllowed) {
115
+ throw workflowError("WORKFLOW_FOLLOW_UP_APPROVAL_INVALID", "User-approved follow-up work is legal only for a Tester dispatch after a fresh passing Test Report and successful validation-adequacy and code-diff Gates.", "Use the normal allowed transition, or ask the user only when optional Tester-owned work is proposed after validation is fully green.");
116
+ }
117
+ userApprovedFollowUp = createUserApprovedFollowUp(id(), current, candidate, effectiveFlow, baseHistoryHash, followUpApprovalText, now());
118
+ followUpApprovalId = userApprovedFollowUp.id;
119
+ }
120
+ else if (!verdict.allowed) {
72
121
  const authorizationText = candidate.proposal.authorizationText?.trim();
73
122
  const violatedRule = candidate.proposal.violatedRule?.trim();
74
123
  if (!authorizationText && !violatedRule) {
@@ -79,9 +128,6 @@ export function createWorkflowControlService(deps) {
79
128
  if (!authorizationText || violatedRule !== verdict.reason) {
80
129
  throw workflowError("WORKFLOW_USER_AUTHORIZATION_INVALID", `User authorization must include the exact authorization text and exact violated rule: ${verdict.reason}`, "Ask the user directly, then copy the user's authorization verbatim into Authorization Text and the rejection reason verbatim into Violated Rule.");
81
130
  }
82
- if (state.userAuthorizations.some((entry) => entry.authorizationText === authorizationText)) {
83
- throw workflowError("WORKFLOW_USER_AUTHORIZATION_REUSED", "This user authorization has already been recorded for another workflow transition.", "Ask the user for a new explicit authorization for this exact transition.");
84
- }
85
131
  userAuthorization = createUserAuthorization(id(), current, candidate, effectiveFlow, baseHistoryHash, verdict.reason, authorizationText, now());
86
132
  overrideAuthorizationId = userAuthorization.id;
87
133
  }
@@ -98,6 +144,7 @@ export function createWorkflowControlService(deps) {
98
144
  evidence: candidate.proposal.evidence,
99
145
  expectedRoutePath: expectedRoutePath(input.handoffDir, candidate.proposal.targetRole),
100
146
  overrideAuthorizationId,
147
+ followUpApprovalId,
101
148
  status: "pending",
102
149
  createdAt: timestamp,
103
150
  updatedAt: timestamp
@@ -107,13 +154,15 @@ export function createWorkflowControlService(deps) {
107
154
  proposal: candidate.proposal
108
155
  };
109
156
  const normalized = renderWorkflowProgress(accepted);
110
- await writeAtomic(deps.fs, progressPath(input), normalized);
111
- await saveState(input, {
157
+ await commitStateAndProgress(input, normalized, {
112
158
  ...state,
113
159
  pendingDispatch,
114
160
  userAuthorizations: userAuthorization
115
161
  ? [...state.userAuthorizations, userAuthorization]
116
162
  : state.userAuthorizations,
163
+ userApprovedFollowUps: userApprovedFollowUp
164
+ ? [...state.userApprovedFollowUps, userApprovedFollowUp]
165
+ : state.userApprovedFollowUps,
117
166
  updatedAt: timestamp
118
167
  });
119
168
  return { path: relativeProgressPath(input), content: normalized };
@@ -172,6 +221,25 @@ export function createWorkflowControlService(deps) {
172
221
  });
173
222
  });
174
223
  }
224
+ async function cancelPendingDispatch(input) {
225
+ await withLock(statePath(input), async () => {
226
+ const state = await getState(input);
227
+ const pending = state.pendingDispatch;
228
+ if (!pending)
229
+ return;
230
+ const progress = await readProgress(deps.fs, input);
231
+ await commitStateAndProgress(input, renderWorkflowProgress({
232
+ ...progress,
233
+ proposal: undefined
234
+ }), {
235
+ ...state,
236
+ pendingDispatch: null,
237
+ userAuthorizations: state.userAuthorizations.filter((entry) => entry.id !== pending.overrideAuthorizationId),
238
+ userApprovedFollowUps: state.userApprovedFollowUps.filter((entry) => entry.id !== pending.followUpApprovalId),
239
+ updatedAt: now()
240
+ });
241
+ });
242
+ }
175
243
  async function confirmDispatch(input, messageId) {
176
244
  await withLock(statePath(input), async () => {
177
245
  const state = await getState(input);
@@ -192,10 +260,11 @@ export function createWorkflowControlService(deps) {
192
260
  targetRole: pending.targetRole,
193
261
  evidence: pending.evidence,
194
262
  overrideAuthorizationId: pending.overrideAuthorizationId,
263
+ followUpApprovalId: pending.followUpApprovalId,
195
264
  confirmedAt: timestamp
196
265
  };
197
266
  const activeDispatch = await captureEvidenceBaseline(deps.fs, input, entry.sequence, entry.flow, entry.targetRole, timestamp);
198
- const flowRun = await advanceFlowRun(deps.fs, input, state, current, pending.effectiveFlow, Boolean(pending.overrideAuthorizationId), entry.sequence);
267
+ const flowRun = await advanceFlowRun(deps.fs, input, state, current, pending.effectiveFlow, entry.sequence);
199
268
  const completed = {
200
269
  ...current,
201
270
  flow: pending.effectiveFlow,
@@ -206,36 +275,69 @@ export function createWorkflowControlService(deps) {
206
275
  const userAuthorizations = state.userAuthorizations.map((entry) => entry.id === pending.overrideAuthorizationId
207
276
  ? { ...entry, status: "consumed", consumedAt: timestamp }
208
277
  : entry);
209
- await writeAtomic(deps.fs, progressPath(input), renderWorkflowProgress(completed));
210
- await saveState(input, {
278
+ const userApprovedFollowUps = state.userApprovedFollowUps.map((entry) => entry.id === pending.followUpApprovalId
279
+ ? { ...entry, status: "consumed", consumedAt: timestamp }
280
+ : entry);
281
+ await commitStateAndProgress(input, renderWorkflowProgress(completed), {
211
282
  ...state,
212
283
  pendingDispatch: null,
213
284
  activeDispatch,
214
285
  flowRun,
215
286
  userAuthorizations,
287
+ userApprovedFollowUps,
216
288
  updatedAt: timestamp
217
289
  });
218
290
  });
219
291
  }
292
+ async function recoverTask(input) {
293
+ return withLock(statePath(input), async () => {
294
+ await recoverCommitJournal(input);
295
+ const state = await getState(input);
296
+ if (state.warnings.length > 0 || state.pendingDispatch?.status !== "dispatching")
297
+ return false;
298
+ await saveState(input, {
299
+ ...state,
300
+ pendingDispatch: {
301
+ ...state.pendingDispatch,
302
+ status: "pending",
303
+ routeContentHash: undefined,
304
+ messageId: undefined,
305
+ updatedAt: now()
306
+ },
307
+ updatedAt: now()
308
+ });
309
+ return true;
310
+ });
311
+ }
220
312
  return {
221
313
  getState,
222
- getProgress: (input) => readProgress(deps.fs, input),
314
+ getProgress: async (input) => {
315
+ await getState(input);
316
+ return readProgress(deps.fs, input);
317
+ },
223
318
  requestUserInput,
224
319
  resolveUserInput,
225
320
  submitProgress,
226
321
  assertRouteAuthorized,
227
322
  claimDispatch,
228
323
  releaseDispatch,
229
- confirmDispatch
324
+ cancelPendingDispatch,
325
+ confirmDispatch,
326
+ recoverTask
230
327
  };
231
328
  async function requestUserInput(input, question) {
232
329
  return withLock(statePath(input), async () => {
233
330
  const state = await getState(input);
331
+ failOnStateWarnings(state);
332
+ if (state.pendingDispatch?.status === "dispatching") {
333
+ throw workflowError("WORKFLOW_DISPATCH_PENDING", "A role dispatch is already being submitted and cannot be canceled by a user question.", "Wait for the target UserPromptSubmit confirmation or recover the dispatch before asking the user.");
334
+ }
234
335
  const normalizedQuestion = question.trim();
235
336
  if (!normalizedQuestion) {
236
337
  throw workflowError("WORKFLOW_USER_QUESTION_REQUIRED", "A non-empty user question is required.");
237
338
  }
238
339
  const timestamp = now();
340
+ const pending = state.pendingDispatch;
239
341
  const next = {
240
342
  ...state,
241
343
  awaitingUser: {
@@ -243,15 +345,27 @@ export function createWorkflowControlService(deps) {
243
345
  requestedAt: timestamp
244
346
  },
245
347
  pendingDispatch: null,
348
+ userAuthorizations: state.userAuthorizations.filter((entry) => entry.id !== pending?.overrideAuthorizationId),
349
+ userApprovedFollowUps: state.userApprovedFollowUps.filter((entry) => entry.id !== pending?.followUpApprovalId),
246
350
  updatedAt: timestamp
247
351
  };
248
- await saveState(input, next);
352
+ if (pending) {
353
+ const progress = await readProgress(deps.fs, input);
354
+ await commitStateAndProgress(input, renderWorkflowProgress({
355
+ ...progress,
356
+ proposal: undefined
357
+ }), next);
358
+ }
359
+ else {
360
+ await saveState(input, next);
361
+ }
249
362
  return next;
250
363
  });
251
364
  }
252
365
  async function resolveUserInput(input) {
253
366
  return withLock(statePath(input), async () => {
254
367
  const state = await getState(input);
368
+ failOnStateWarnings(state);
255
369
  if (!state.awaitingUser)
256
370
  return state;
257
371
  const next = {
@@ -266,6 +380,58 @@ export function createWorkflowControlService(deps) {
266
380
  async function saveState(input, state) {
267
381
  await deps.fs.writeJsonAtomic(statePath(input), state);
268
382
  }
383
+ async function commitStateAndProgress(input, progressContent, state) {
384
+ const journal = {
385
+ version: 1,
386
+ taskSlug: input.taskSlug,
387
+ status: "pending",
388
+ progressContent,
389
+ state,
390
+ createdAt: now()
391
+ };
392
+ await deps.fs.writeJsonAtomic(transactionPath(input), journal);
393
+ await writeAtomic(deps.fs, progressPath(input), progressContent);
394
+ await saveState(input, state);
395
+ await deps.fs.writeJsonAtomic(transactionPath(input), { ...journal, status: "committed" });
396
+ await removeTransactionJournal(transactionPath(input));
397
+ }
398
+ async function recoverCommitJournal(input) {
399
+ const target = transactionPath(input);
400
+ if (!(await deps.fs.pathExists(target)))
401
+ return;
402
+ const journal = await deps.fs.readJson(target);
403
+ if (journal.version !== 1 || journal.taskSlug !== input.taskSlug) {
404
+ throw new Error("Workflow commit journal does not match the active task.");
405
+ }
406
+ if (journal.status === "pending") {
407
+ parseWorkflowProgress(journal.progressContent, input.taskSlug);
408
+ const state = normalizeState(journal.state, input.taskSlug, now());
409
+ failOnStateWarnings(state);
410
+ await writeAtomic(deps.fs, progressPath(input), journal.progressContent);
411
+ await saveState(input, state);
412
+ await deps.fs.writeJsonAtomic(target, { ...journal, status: "committed" });
413
+ }
414
+ await removeTransactionJournal(target);
415
+ }
416
+ async function removeTransactionJournal(target) {
417
+ try {
418
+ await deps.fs.removePath?.(target, { force: true });
419
+ }
420
+ catch {
421
+ // A committed journal is safe to replay or remove on the next state read.
422
+ }
423
+ }
424
+ async function reconstructStateFromProgress(input, progress) {
425
+ const timestamp = now();
426
+ const last = progress.history.at(-1);
427
+ const activeDispatch = await captureEvidenceBaseline(deps.fs, input, last.sequence, last.flow, last.targetRole, last.confirmedAt ?? timestamp);
428
+ return {
429
+ ...emptyState(input.taskSlug, timestamp),
430
+ activeDispatch,
431
+ flowRun: resolveFlowRun(null, progress),
432
+ updatedAt: timestamp
433
+ };
434
+ }
269
435
  async function withLock(key, operation) {
270
436
  const previous = locks.get(key) ?? Promise.resolve();
271
437
  const next = previous.catch(() => undefined).then(operation);
@@ -337,6 +503,24 @@ export function parseWorkflowProgress(content, expectedTaskSlug) {
337
503
  else if (authorizationText !== "none" || violatedRule !== "none") {
338
504
  errors.push("User Authorization fields must be none when no role dispatch is proposed.");
339
505
  }
506
+ const followUpSection = readArtifactSectionContent(content, "User-Approved Follow-Up");
507
+ const followUpApprovalText = followUpSection === undefined
508
+ ? "none"
509
+ : rawField(followUpSection, "Approval Text");
510
+ if (!followUpApprovalText) {
511
+ errors.push("User-Approved Follow-Up requires an Approval Text field.");
512
+ }
513
+ else if (proposal) {
514
+ if (followUpApprovalText !== "none") {
515
+ if (proposal.authorizationText || proposal.violatedRule) {
516
+ errors.push("User-Approved Follow-Up and User Authorization cannot be used in the same proposal.");
517
+ }
518
+ proposal.followUpApprovalText = followUpApprovalText;
519
+ }
520
+ }
521
+ else if (followUpApprovalText !== "none") {
522
+ errors.push("User-Approved Follow-Up Approval Text must be none when no role dispatch is proposed.");
523
+ }
340
524
  if (errors.length > 0)
341
525
  throw progressValidationError(errors);
342
526
  return {
@@ -354,7 +538,7 @@ export function renderWorkflowProgress(progress) {
354
538
  : [
355
539
  HISTORY_HEADER,
356
540
  HISTORY_SEPARATOR,
357
- ...progress.history.map((entry) => `| ${entry.sequence} | ${entry.flow} | ${entry.targetRole} | ${escapeCell(entry.evidence)} | ${entry.overrideAuthorizationId ?? "none"} | ${entry.confirmedAt ?? "none"} |`)
541
+ ...progress.history.map((entry) => `| ${entry.sequence} | ${entry.flow} | ${entry.targetRole} | ${escapeCell(entry.evidence)} | ${entry.overrideAuthorizationId ?? "none"} | ${entry.followUpApprovalId ?? "none"} | ${entry.confirmedAt ?? "none"} |`)
358
542
  ].join("\n");
359
543
  const proposal = progress.proposal;
360
544
  return `# Workflow Progress: ${progress.taskSlug}
@@ -377,6 +561,10 @@ Evidence: ${proposal?.evidence ?? "none"}
377
561
 
378
562
  Authorization Text: ${proposal?.authorizationText ?? "none"}
379
563
  Violated Rule: ${proposal?.violatedRule ?? "none"}
564
+
565
+ ## User-Approved Follow-Up
566
+
567
+ Approval Text: ${proposal?.followUpApprovalText ?? "none"}
380
568
  `;
381
569
  }
382
570
  async function evaluateTransition(fs, input, state, current, effectiveFlow, targetRole) {
@@ -392,6 +580,32 @@ async function evaluateTransition(fs, input, state, current, effectiveFlow, targ
392
580
  blockedHint: await describeBlockedCheckpoint(fs, input, state, current)
393
581
  };
394
582
  }
583
+ async function canRouteUserApprovedFollowUp(fs, input, state, current, effectiveFlow, targetRole, allowedTransitions) {
584
+ if (targetRole !== "tester"
585
+ || current.status !== "active"
586
+ || current.flow !== effectiveFlow
587
+ || (effectiveFlow !== "code-change"
588
+ && effectiveFlow !== "architect-debug"
589
+ && effectiveFlow !== "architecture-diagnosis"))
590
+ return false;
591
+ const active = state.activeDispatch;
592
+ if (!active || active.flow !== effectiveFlow || active.targetRole !== "tester")
593
+ return false;
594
+ const test = await artifactState(fs, input, "test-report.md", "test-report");
595
+ if (test.value !== "pass" || !evidenceIsFresh(state, effectiveFlow, "tester", "test-report.md", test.hash)) {
596
+ return false;
597
+ }
598
+ const validation = await gateState(fs, input, "validation-adequacy");
599
+ const codeDiff = await gateState(fs, input, "code-diff");
600
+ if (!gatePassedForDispatch(state, effectiveFlow, "tester", "validation-adequacy", validation)
601
+ || !gatePassedForDispatch(state, effectiveFlow, "tester", "code-diff", codeDiff))
602
+ return false;
603
+ const flowRun = resolveFlowRun(state.flowRun, current);
604
+ const expectedExit = flowRun.activeBranch === effectiveFlow
605
+ ? `${flowRun.rootFlow}/architect`
606
+ : `${effectiveFlow}/architect`;
607
+ return allowedTransitions.length === 1 && allowedTransitions[0] === expectedExit;
608
+ }
395
609
  async function getAllowedTransitions(fs, input, state, current) {
396
610
  if (current.status === "completed")
397
611
  return initialTransitions();
@@ -427,9 +641,12 @@ async function getAllowedTransitions(fs, input, state, current) {
427
641
  return [];
428
642
  return [];
429
643
  }
644
+ const coderDocsCorrection = await allowedActiveCoderDocsCorrection(fs, input, state, flow);
645
+ if (coderDocsCorrection)
646
+ return coderDocsCorrection;
430
647
  const segment = activeFlowSegment(current.history, flow, flowRun.startedAtSequence);
431
648
  if (flow === "code-change") {
432
- return allowedCodeChange(fs, input, state, segment, flowRun.resumedFromBranch !== undefined);
649
+ return allowedCodeChange(fs, input, state, segment, flowRun.resumedFromBranch);
433
650
  }
434
651
  if (flow === "architect-debug") {
435
652
  return allowedArchitectFix(fs, input, state, segment, "architect-debug", flowRun.resumedFromBranch !== undefined, flowRun.activeBranch ? flowRun.rootFlow : undefined);
@@ -448,8 +665,15 @@ function initialTransitions() {
448
665
  async function allowedCodeChange(fs, input, state, segment, resumedFromBranch) {
449
666
  const coderIndex = findLastIndex(segment, (entry) => entry.targetRole === "coder");
450
667
  const testerIndex = findLastIndex(segment, (entry) => entry.targetRole === "tester");
451
- if (resumedFromBranch && coderIndex < 0 && testerIndex < 0) {
452
- return allowedPostImplementationArchitect(fs, input, state, "code-change", segment);
668
+ if (resumedFromBranch && coderIndex < 0) {
669
+ if (testerIndex < 0) {
670
+ return allowedPostImplementationArchitect(fs, input, state, "code-change", segment);
671
+ }
672
+ return allowedAfterTester(fs, input, state, "code-change", resumedFromBranch, {
673
+ testerFailureFlow: "architect-debug",
674
+ implementationFailureFlow: "architect-debug",
675
+ successTarget: "code-change/architect"
676
+ });
453
677
  }
454
678
  if (coderIndex < 0) {
455
679
  const plan = await artifactState(fs, input, "architecture-plan.md", "architecture-plan");
@@ -477,7 +701,7 @@ async function allowedCodeChange(fs, input, state, segment, resumedFromBranch) {
477
701
  if (architectsAfterTester.length > 0) {
478
702
  return allowedPostImplementationArchitect(fs, input, state, "code-change", architectsAfterTester);
479
703
  }
480
- return allowedAfterTester(fs, input, state, "coder", {
704
+ return allowedAfterTester(fs, input, state, "code-change", "coder", {
481
705
  testerFailureFlow: "architect-debug",
482
706
  implementationFailureFlow: "architect-debug",
483
707
  successTarget: "code-change/architect"
@@ -514,9 +738,15 @@ async function allowedArchitectFix(fs, input, state, segment, source, resumedFro
514
738
  if (!evidenceIsFresh(state, source, "architect", artifactName, artifact.hash)) {
515
739
  return [`${source}/architect`];
516
740
  }
517
- if (source === "architect-debug" && artifact.disposition === "normal architecture plan required") {
741
+ if (source === "architect-debug" && artifact.disposition === ARCHITECT_DEBUG_NORMAL_PLAN_DISPOSITION) {
518
742
  return ["code-change/architect"];
519
743
  }
744
+ if (source === "architecture-diagnosis" && artifact.disposition === "analysis completed") {
745
+ return parentFlow ? [`${parentFlow}/architect`] : [];
746
+ }
747
+ if (artifact.disposition === "user clarification required") {
748
+ return [`${source}/architect`];
749
+ }
520
750
  return artifact.complete ? [`${source}/tester`] : [`${source}/architect`];
521
751
  }
522
752
  if (architectIndex > testerIndex) {
@@ -524,6 +754,10 @@ async function allowedArchitectFix(fs, input, state, segment, source, resumedFro
524
754
  const artifact = source === "architect-debug"
525
755
  ? await artifactState(fs, input, artifactName, "architect-debug")
526
756
  : await artifactState(fs, input, artifactName, "architecture-diagnosis");
757
+ if (evidenceIsFresh(state, source, "architect", artifactName, artifact.hash)
758
+ && artifact.disposition === "user clarification required") {
759
+ return [`${source}/architect`];
760
+ }
527
761
  if (evidenceIsFresh(state, source, "architect", artifactName, artifact.hash) && artifact.complete) {
528
762
  return [`${source}/tester`];
529
763
  }
@@ -533,7 +767,7 @@ async function allowedArchitectFix(fs, input, state, segment, source, resumedFro
533
767
  ? []
534
768
  : [`${source}/architect`];
535
769
  }
536
- return allowedAfterTester(fs, input, state, source, {
770
+ return allowedAfterTester(fs, input, state, source, source, {
537
771
  testerFailureFlow: source === "architect-debug" ? "architecture-diagnosis" : undefined,
538
772
  implementationFailureFlow: source,
539
773
  successTarget: parentFlow ? `${parentFlow}/architect` : `${source}/architect`
@@ -545,21 +779,46 @@ async function allowedPostImplementationArchitect(fs, input, state, flow, archit
545
779
  if (flow === "code-change" && architectEntries.length > 1 && acceptance.value === "needs-architect-follow-up") {
546
780
  return allowedArchitectureFollowup(fs, input, state, architectEntries[1]?.confirmedAt);
547
781
  }
548
- if (evidenceIsFresh(state, flow, "architect", "docs-sync-report.md", docs.hash)
549
- && (docs.value === "synced" || docs.value === "unchanged")) {
550
- if (acceptance.value === "needs-coder-follow-up" && flow === "code-change")
551
- return ["code-change/coder"];
552
- if (acceptance.value === "needs-docs-sync")
553
- return [`${flow}/architect`];
554
- if (acceptance.value === "needs-architect-follow-up")
555
- return [`${flow}/architect`];
556
- return [];
782
+ if (evidenceIsFresh(state, flow, "architect", "docs-sync-report.md", docs.hash) && docs.complete) {
783
+ if (docs.value === "synced" || docs.value === "unchanged") {
784
+ if (acceptance.value === "needs-coder-follow-up" && flow === "code-change")
785
+ return ["code-change/coder"];
786
+ if (acceptance.value === "needs-docs-sync")
787
+ return [`${flow}/architect`];
788
+ if (acceptance.value === "needs-architect-follow-up")
789
+ return [`${flow}/architect`];
790
+ return [];
791
+ }
792
+ if (docs.value === "blocked" && docs.correctionOwner && docs.correctionOwner !== "none") {
793
+ return [`${flow}/${docs.correctionOwner}`];
794
+ }
557
795
  }
558
796
  return [`${flow}/architect`];
559
797
  }
560
- async function allowedAfterTester(fs, input, state, codeSource, options) {
798
+ async function allowedActiveCoderDocsCorrection(fs, input, state, flow) {
799
+ const active = state.activeDispatch;
800
+ if (!active || active.flow !== flow || active.targetRole !== "coder")
801
+ return undefined;
802
+ const docs = await artifactState(fs, input, "docs-sync-report.md", "docs-sync-report");
803
+ if (!docs.complete
804
+ || docs.value !== "blocked"
805
+ || docs.correctionOwner !== "coder"
806
+ || active.artifactHashes["docs-sync-report.md"] !== docs.hash)
807
+ return undefined;
808
+ const coder = await artifactState(fs, input, "coder-completion.md", "coder-completion");
809
+ if (!evidenceIsFresh(state, flow, "coder", "coder-completion.md", coder.hash)
810
+ || coder.value === "incomplete")
811
+ return [`${flow}/coder`];
812
+ if (coder.value === "ready_for_review")
813
+ return [`${flow}/tester`];
814
+ if (flow === "code-change")
815
+ return ["architect-debug/architect"];
816
+ if (flow === "architect-debug")
817
+ return ["architecture-diagnosis/architect"];
818
+ return [];
819
+ }
820
+ async function allowedAfterTester(fs, input, state, currentFlow, codeSource, options) {
561
821
  const test = await artifactState(fs, input, "test-report.md", "test-report");
562
- const currentFlow = codeSource === "coder" ? "code-change" : codeSource;
563
822
  if (!evidenceIsFresh(state, currentFlow, "tester", "test-report.md", test.hash)) {
564
823
  return [`${currentFlow}/tester`];
565
824
  }
@@ -617,12 +876,18 @@ async function artifactState(fs, input, fileName, kind) {
617
876
  const disposition = kind === "architect-debug" || kind === "architecture-diagnosis"
618
877
  ? readArtifactSectionContent(content, "Final Disposition")?.trim().toLowerCase()
619
878
  : undefined;
879
+ const correctionOwner = kind === "docs-sync-report"
880
+ ? readArtifactSectionContent(content, "Correction Owner")?.trim().toLowerCase()
881
+ : undefined;
620
882
  return {
621
883
  complete: check.status === "ok",
622
- hash: contentHash(content),
884
+ hash: await artifactFingerprint(fs, absolute, content),
623
885
  value,
624
886
  infrastructure,
625
- disposition
887
+ disposition,
888
+ correctionOwner: correctionOwner === "none" || asTargetRole(correctionOwner)
889
+ ? correctionOwner
890
+ : undefined
626
891
  };
627
892
  }
628
893
  async function gateState(fs, input, gate) {
@@ -640,7 +905,9 @@ async function gateState(fs, input, gate) {
640
905
  async function captureEvidenceBaseline(fs, input, sequence, flow, targetRole, confirmedAt) {
641
906
  const artifactEntries = await Promise.all(WORKFLOW_EVIDENCE_ARTIFACTS.map(async (fileName) => {
642
907
  const target = resolveRepoPath(input.taskRepoRoot, path.posix.join(input.handoffDir, fileName));
643
- const hash = await fs.pathExists(target) ? contentHash(await fs.readText(target)) : MISSING_EVIDENCE_HASH;
908
+ const hash = await fs.pathExists(target)
909
+ ? await artifactFingerprint(fs, target, await fs.readText(target))
910
+ : MISSING_EVIDENCE_HASH;
644
911
  return [fileName, hash];
645
912
  }));
646
913
  const gateEntries = await Promise.all(WORKFLOW_EVIDENCE_GATES.map(async (gate) => [
@@ -656,8 +923,8 @@ async function captureEvidenceBaseline(fs, input, sequence, flow, targetRole, co
656
923
  confirmedAt
657
924
  };
658
925
  }
659
- async function advanceFlowRun(fs, input, state, current, effectiveFlow, usedOverride, nextSequence) {
660
- if (!current.flow || current.status === "completed" || (usedOverride && effectiveFlow !== current.flow)) {
926
+ async function advanceFlowRun(fs, input, state, current, effectiveFlow, nextSequence) {
927
+ if (!current.flow || current.status === "completed") {
661
928
  return newFlowRun(effectiveFlow, nextSequence);
662
929
  }
663
930
  const run = resolveFlowRun(state.flowRun, current);
@@ -677,7 +944,7 @@ async function advanceFlowRun(fs, input, state, current, effectiveFlow, usedOver
677
944
  if (run.activeBranch === current.flow && effectiveFlow === run.rootFlow) {
678
945
  if (current.flow === "architect-debug" && effectiveFlow === "code-change") {
679
946
  const debug = await artifactState(fs, input, "architect-debug.md", "architect-debug");
680
- if (debug.disposition === "normal architecture plan required") {
947
+ if (debug.disposition === ARCHITECT_DEBUG_NORMAL_PLAN_DISPOSITION) {
681
948
  return newFlowRun("code-change", nextSequence);
682
949
  }
683
950
  }
@@ -766,11 +1033,18 @@ function freshGateDecision(state, flow, targetRole, gate, record) {
766
1033
  function gatePassedForDispatch(state, flow, targetRole, gate, record) {
767
1034
  if (!record)
768
1035
  return false;
769
- if (FINAL_GATE_STATUSES.has(record.status))
1036
+ if (record.status === "disabled")
770
1037
  return true;
1038
+ if (FRESH_EXCEPTION_GATE_STATUSES.has(record.status)) {
1039
+ return gateRecordIsFresh(state, flow, targetRole, gate, record);
1040
+ }
771
1041
  return freshGateDecision(state, flow, targetRole, gate, record) === "approve"
772
1042
  && record.status === "completed";
773
1043
  }
1044
+ function gateRecordIsFresh(state, flow, targetRole, gate, record) {
1045
+ const baseline = matchingEvidenceBaseline(state, flow, targetRole);
1046
+ return !baseline || baseline.gateFingerprints[gate] !== gateFingerprint(record);
1047
+ }
774
1048
  function matchingEvidenceBaseline(state, flow, targetRole) {
775
1049
  const baseline = state.activeDispatch;
776
1050
  return baseline?.flow === flow && baseline.targetRole === targetRole ? baseline : undefined;
@@ -786,7 +1060,8 @@ function gateFingerprint(record) {
786
1060
  codeDiffSource: record.codeDiffSource,
787
1061
  codeDiffSources: record.codeDiffSources,
788
1062
  findings: record.findings,
789
- completedAt: record.completedAt
1063
+ completedAt: record.completedAt,
1064
+ updatedAt: record.updatedAt
790
1065
  }));
791
1066
  }
792
1067
  async function describeBlockedCheckpoint(fs, input, state, current) {
@@ -821,11 +1096,7 @@ async function validateCompletion(fs, input, state, candidate) {
821
1096
  throw workflowError("WORKFLOW_COMPLETION_INVALID", `${candidate.flow} is an active branch and must return to ${flowRun.rootFlow} before completion.`);
822
1097
  }
823
1098
  if (candidate.flow === "code-change" || candidate.flow === "architect-debug") {
824
- const acceptance = await artifactState(fs, input, "final-acceptance.md", "final-acceptance");
825
- if (!evidenceProducedAfterActiveDispatch(state, candidate.flow, "architect", "final-acceptance.md", acceptance.hash)
826
- || (acceptance.value !== "accepted" && acceptance.value !== "accepted-with-known-risks")) {
827
- throw workflowError("WORKFLOW_COMPLETION_INVALID", "Final Acceptance is not accepted for this complete delivery flow.");
828
- }
1099
+ await validateCompleteDeliveryEvidence(fs, input, state, candidate.flow);
829
1100
  return;
830
1101
  }
831
1102
  if (candidate.flow === "architecture-diagnosis") {
@@ -833,11 +1104,7 @@ async function validateCompletion(fs, input, state, candidate) {
833
1104
  if (diagnosis.disposition === "analysis completed"
834
1105
  && evidenceProducedAfterActiveDispatch(state, candidate.flow, "architect", "architecture-diagnosis.md", diagnosis.hash))
835
1106
  return;
836
- const acceptance = await artifactState(fs, input, "final-acceptance.md", "final-acceptance");
837
- if (!evidenceProducedAfterActiveDispatch(state, candidate.flow, "architect", "final-acceptance.md", acceptance.hash)
838
- || (acceptance.value !== "accepted" && acceptance.value !== "accepted-with-known-risks")) {
839
- throw workflowError("WORKFLOW_COMPLETION_INVALID", "Implemented Architecture Diagnosis requires accepted Final Acceptance evidence.");
840
- }
1107
+ await validateCompleteDeliveryEvidence(fs, input, state, candidate.flow);
841
1108
  return;
842
1109
  }
843
1110
  if (candidate.flow === "docs-only") {
@@ -862,6 +1129,35 @@ async function validateCompletion(fs, input, state, candidate) {
862
1129
  }
863
1130
  }
864
1131
  }
1132
+ async function validateCompleteDeliveryEvidence(fs, input, state, flow) {
1133
+ const baseline = state.activeDispatch;
1134
+ if (!baseline || baseline.flow !== flow) {
1135
+ throw workflowError("WORKFLOW_COMPLETION_INVALID", `The latest confirmed dispatch does not belong to the active ${flow} flow.`);
1136
+ }
1137
+ if (baseline.targetRole !== "architect") {
1138
+ throw workflowError("WORKFLOW_COMPLETION_INVALID", `The latest ${flow} dispatch is to ${baseline.targetRole}. Complete every required downstream validation and Gate step, then route Architect for docs sync before completing the flow.`);
1139
+ }
1140
+ const docs = await artifactState(fs, input, "docs-sync-report.md", "docs-sync-report");
1141
+ if (!docs.complete) {
1142
+ throw workflowError("WORKFLOW_COMPLETION_INVALID", "Docs Sync Report is missing or malformed for this complete delivery flow.");
1143
+ }
1144
+ if (baseline.artifactHashes["docs-sync-report.md"] === docs.hash) {
1145
+ throw workflowError("WORKFLOW_COMPLETION_INVALID", "Docs Sync Report was not produced after the final Architect dispatch.");
1146
+ }
1147
+ if (docs.value !== "synced" && docs.value !== "unchanged") {
1148
+ throw workflowError("WORKFLOW_COMPLETION_INVALID", `Docs Sync Report Decision must be synced or unchanged before completion; found ${docs.value ?? "missing"}.`);
1149
+ }
1150
+ const acceptance = await artifactState(fs, input, "final-acceptance.md", "final-acceptance");
1151
+ if (!acceptance.complete) {
1152
+ throw workflowError("WORKFLOW_COMPLETION_INVALID", "Final Acceptance is missing or malformed for this complete delivery flow.");
1153
+ }
1154
+ if (acceptance.value !== "accepted" && acceptance.value !== "accepted-with-known-risks") {
1155
+ throw workflowError("WORKFLOW_COMPLETION_INVALID", `Final Acceptance Decision must be accepted or accepted-with-known-risks; found ${acceptance.value ?? "missing"}.`);
1156
+ }
1157
+ if (baseline.artifactHashes["final-acceptance.md"] === acceptance.hash) {
1158
+ throw workflowError("WORKFLOW_COMPLETION_INVALID", "Final Acceptance was not produced after the final Architect dispatch.");
1159
+ }
1160
+ }
865
1161
  function evidenceProducedAfterActiveDispatch(state, flow, targetRole, artifact, currentHash) {
866
1162
  const baseline = matchingEvidenceBaseline(state, flow, targetRole);
867
1163
  return Boolean(baseline && baseline.artifactHashes[artifact] !== currentHash);
@@ -879,6 +1175,31 @@ function validateCandidateAgainstCurrent(current, candidate) {
879
1175
  if (errors.length > 0)
880
1176
  throw progressValidationError(errors);
881
1177
  }
1178
+ function stateProgressConsistencyWarnings(state, progress) {
1179
+ const warnings = [];
1180
+ const pending = state.pendingDispatch;
1181
+ if (!pending && progress.proposal) {
1182
+ warnings.push("Workflow Progress contains a dispatch proposal without a runtime approval.");
1183
+ }
1184
+ if (pending && (pending.revision !== progress.revision
1185
+ || pending.baseHistoryHash !== historyHash(progress.history)
1186
+ || progress.proposal?.requestedFlow !== pending.requestedFlow
1187
+ || progress.proposal?.targetRole !== pending.targetRole
1188
+ || progress.proposal?.evidence !== pending.evidence)) {
1189
+ warnings.push("Workflow runtime approval does not match workflow-progress.md.");
1190
+ }
1191
+ const last = progress.history.at(-1);
1192
+ if (last && (!state.activeDispatch
1193
+ || state.activeDispatch.sequence !== last.sequence
1194
+ || state.activeDispatch.flow !== last.flow
1195
+ || state.activeDispatch.targetRole !== last.targetRole)) {
1196
+ warnings.push("Workflow active dispatch does not match the latest Workflow Progress history entry.");
1197
+ }
1198
+ if (!last && state.activeDispatch) {
1199
+ warnings.push("Workflow active dispatch exists without a Workflow Progress history entry.");
1200
+ }
1201
+ return warnings;
1202
+ }
882
1203
  function createUserAuthorization(authorizationId, current, candidate, effectiveFlow, baseHistoryHash, violation, authorizationText, timestamp) {
883
1204
  return {
884
1205
  id: authorizationId,
@@ -896,31 +1217,33 @@ function createUserAuthorization(authorizationId, current, candidate, effectiveF
896
1217
  createdAt: timestamp
897
1218
  };
898
1219
  }
1220
+ function createUserApprovedFollowUp(approvalId, current, candidate, effectiveFlow, baseHistoryHash, approvalText, timestamp) {
1221
+ return {
1222
+ id: approvalId,
1223
+ status: "accepted",
1224
+ role: "project-manager",
1225
+ operation: "post-validation-follow-up",
1226
+ baseRevision: current.revision,
1227
+ baseHistoryHash,
1228
+ effectiveFlow: effectiveFlow,
1229
+ targetRole: "tester",
1230
+ evidence: candidate.proposal.evidence,
1231
+ approvalText,
1232
+ createdAt: timestamp
1233
+ };
1234
+ }
899
1235
  async function readProgress(fs, input) {
900
1236
  const target = progressPath(input);
901
1237
  if (!(await fs.pathExists(target)))
902
1238
  return parseWorkflowProgress(renderWorkflowProgressTemplate(input.taskSlug), input.taskSlug);
903
1239
  return parseWorkflowProgress(await fs.readText(target), input.taskSlug);
904
1240
  }
905
- async function ensureProgressFile(fs, input) {
906
- const target = progressPath(input);
907
- if (!(await fs.pathExists(target))) {
908
- await writeAtomic(fs, target, renderWorkflowProgressTemplate(input.taskSlug));
909
- return undefined;
910
- }
911
- try {
912
- parseWorkflowProgress(await fs.readText(target), input.taskSlug);
913
- return undefined;
914
- }
915
- catch (error) {
916
- return `Workflow Progress is invalid and was preserved unchanged: ${errorMessage(error)}`;
917
- }
918
- }
919
1241
  function parseHistory(value, errors) {
920
1242
  if (!value || value.trim() === "none")
921
1243
  return [];
922
1244
  const lines = value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
923
- if (lines[0] !== HISTORY_HEADER || lines[1] !== HISTORY_SEPARATOR) {
1245
+ const legacy = lines[0] === LEGACY_HISTORY_HEADER && lines[1] === LEGACY_HISTORY_SEPARATOR;
1246
+ if (!legacy && (lines[0] !== HISTORY_HEADER || lines[1] !== HISTORY_SEPARATOR)) {
924
1247
  errors.push("Dispatch History must use the exact VCM table header.");
925
1248
  return [];
926
1249
  }
@@ -929,8 +1252,9 @@ function parseHistory(value, errors) {
929
1252
  const cells = line.startsWith("|") && line.endsWith("|")
930
1253
  ? line.slice(1, -1).split("|").map((cell) => cell.trim())
931
1254
  : [];
932
- if (cells.length !== 6) {
933
- errors.push("Every Dispatch History row must contain exactly six columns.");
1255
+ const expectedColumns = legacy ? 6 : 7;
1256
+ if (cells.length !== expectedColumns) {
1257
+ errors.push(`Every Dispatch History row must contain exactly ${expectedColumns} columns.`);
934
1258
  continue;
935
1259
  }
936
1260
  const sequence = Number(cells[0]);
@@ -946,7 +1270,8 @@ function parseHistory(value, errors) {
946
1270
  targetRole,
947
1271
  evidence: cells[3],
948
1272
  overrideAuthorizationId: cells[4] === "none" ? undefined : cells[4],
949
- confirmedAt: cells[5] === "none" ? undefined : cells[5]
1273
+ followUpApprovalId: legacy || cells[5] === "none" ? undefined : cells[5],
1274
+ confirmedAt: cells[legacy ? 5 : 6] === "none" ? undefined : cells[legacy ? 5 : 6]
950
1275
  });
951
1276
  }
952
1277
  return entries;
@@ -964,6 +1289,11 @@ function normalizeState(value, taskSlug, timestamp) {
964
1289
  : value.userAuthorizations === undefined && Array.isArray(value.overrideRequests)
965
1290
  ? []
966
1291
  : undefined;
1292
+ const userApprovedFollowUps = value.userApprovedFollowUps === undefined
1293
+ ? []
1294
+ : Array.isArray(value.userApprovedFollowUps) && value.userApprovedFollowUps.every(isUserApprovedFollowUp)
1295
+ ? value.userApprovedFollowUps
1296
+ : undefined;
967
1297
  const activeDispatch = value.activeDispatch === undefined || value.activeDispatch === null
968
1298
  ? null
969
1299
  : normalizeDispatchEvidenceBaseline(value.activeDispatch);
@@ -977,6 +1307,7 @@ function normalizeState(value, taskSlug, timestamp) {
977
1307
  || activeDispatch === undefined
978
1308
  || flowRun === undefined
979
1309
  || userAuthorizations === undefined
1310
+ || userApprovedFollowUps === undefined
980
1311
  || awaitingUser === undefined) {
981
1312
  return { ...emptyState(taskSlug, timestamp), warnings: ["Workflow control state has an unsupported shape."] };
982
1313
  }
@@ -988,6 +1319,7 @@ function normalizeState(value, taskSlug, timestamp) {
988
1319
  activeDispatch,
989
1320
  flowRun,
990
1321
  userAuthorizations,
1322
+ userApprovedFollowUps,
991
1323
  warnings: [],
992
1324
  updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : timestamp
993
1325
  };
@@ -1001,6 +1333,7 @@ function emptyState(taskSlug, timestamp) {
1001
1333
  activeDispatch: null,
1002
1334
  flowRun: null,
1003
1335
  userAuthorizations: [],
1336
+ userApprovedFollowUps: [],
1004
1337
  warnings: [],
1005
1338
  updatedAt: timestamp
1006
1339
  };
@@ -1018,6 +1351,9 @@ function expectedRoutePath(handoffDir, role) {
1018
1351
  function statePath(input) {
1019
1352
  return path.join(input.taskRepoRoot, input.stateRoot, "workflow-control.json");
1020
1353
  }
1354
+ function transactionPath(input) {
1355
+ return path.join(input.taskRepoRoot, input.stateRoot, "workflow-control-transaction.json");
1356
+ }
1021
1357
  function relativeProgressPath(input) {
1022
1358
  return path.posix.join(input.handoffDir, "workflow-progress.md");
1023
1359
  }
@@ -1030,6 +1366,12 @@ function historyHash(history) {
1030
1366
  function contentHash(content) {
1031
1367
  return createHash("sha256").update(content).digest("hex");
1032
1368
  }
1369
+ async function artifactFingerprint(fs, target, content) {
1370
+ const contentDigest = contentHash(content);
1371
+ if (!fs.fileVersion)
1372
+ return contentDigest;
1373
+ return contentHash(`${contentDigest}:${await fs.fileVersion(target)}`);
1374
+ }
1033
1375
  function field(content, name) {
1034
1376
  return rawField(content, name)?.toLowerCase();
1035
1377
  }
@@ -1098,6 +1440,7 @@ function isPendingDispatch(value) {
1098
1440
  && typeof value.evidence === "string"
1099
1441
  && typeof value.expectedRoutePath === "string"
1100
1442
  && (value.overrideAuthorizationId === undefined || typeof value.overrideAuthorizationId === "string")
1443
+ && (value.followUpApprovalId === undefined || typeof value.followUpApprovalId === "string")
1101
1444
  && (value.status === "pending" || value.status === "dispatching")
1102
1445
  && (value.routeContentHash === undefined || typeof value.routeContentHash === "string")
1103
1446
  && (value.messageId === undefined || typeof value.messageId === "string")
@@ -1122,6 +1465,24 @@ function isUserAuthorization(value) {
1122
1465
  && typeof value.createdAt === "string"
1123
1466
  && (value.consumedAt === undefined || typeof value.consumedAt === "string");
1124
1467
  }
1468
+ function isUserApprovedFollowUp(value) {
1469
+ if (!isRecord(value))
1470
+ return false;
1471
+ return typeof value.id === "string"
1472
+ && (value.status === "accepted" || value.status === "consumed")
1473
+ && value.role === "project-manager"
1474
+ && value.operation === "post-validation-follow-up"
1475
+ && Number.isInteger(value.baseRevision)
1476
+ && typeof value.baseHistoryHash === "string"
1477
+ && (value.effectiveFlow === "code-change"
1478
+ || value.effectiveFlow === "architect-debug"
1479
+ || value.effectiveFlow === "architecture-diagnosis")
1480
+ && value.targetRole === "tester"
1481
+ && typeof value.evidence === "string"
1482
+ && typeof value.approvalText === "string"
1483
+ && typeof value.createdAt === "string"
1484
+ && (value.consumedAt === undefined || typeof value.consumedAt === "string");
1485
+ }
1125
1486
  function normalizeDispatchEvidenceBaseline(value) {
1126
1487
  if (!isRecord(value) || !isRecord(value.artifactHashes) || !isRecord(value.gateFingerprints))
1127
1488
  return undefined;