vibe-coding-master 0.7.39 → 0.7.40

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 (48) hide show
  1. package/README.md +11 -6
  2. package/dist/backend/adapters/claude-adapter.js +1 -6
  3. package/dist/backend/api/artifact-routes.js +72 -1
  4. package/dist/backend/api/runtime-state-routes.js +17 -5
  5. package/dist/backend/api/task-routes.js +6 -0
  6. package/dist/backend/api/workflow-control-routes.js +56 -0
  7. package/dist/backend/cli/install-vcm-harness.js +21 -31
  8. package/dist/backend/server.js +21 -5
  9. package/dist/backend/services/artifact-service.js +309 -5
  10. package/dist/backend/services/auto-memory-service.js +2 -2
  11. package/dist/backend/services/gate-review-service.js +81 -33
  12. package/dist/backend/services/harness-feedback-service.js +11 -4
  13. package/dist/backend/services/harness-service.js +17 -31
  14. package/dist/backend/services/message-service.js +105 -37
  15. package/dist/backend/services/status-service.js +6 -0
  16. package/dist/backend/services/workflow-control-service.js +854 -0
  17. package/dist/backend/templates/handoff.js +158 -3
  18. package/dist/backend/templates/harness/architect-agent.js +2 -2
  19. package/dist/backend/templates/harness/check-scaffold-ledger.js +105 -39
  20. package/dist/backend/templates/harness/claude-root.js +9 -1
  21. package/dist/backend/templates/harness/coder-agent.js +1 -2
  22. package/dist/backend/templates/harness/coder-worker-agent.js +1 -1
  23. package/dist/backend/templates/harness/gate-review.js +3 -4
  24. package/dist/backend/templates/harness/harness-engineer-agent.js +18 -1
  25. package/dist/backend/templates/harness/project-manager-agent.js +3 -2
  26. package/dist/backend/templates/harness/tester-agent.js +1 -0
  27. package/dist/backend/templates/harness/vcm-architecture-interview-skill.js +5 -0
  28. package/dist/backend/templates/harness/vcm-code-navigation-skill.js +1 -2
  29. package/dist/backend/templates/harness/vcm-final-acceptance-skill.js +2 -2
  30. package/dist/backend/templates/harness/vcm-long-running-validation-skill.js +4 -2
  31. package/dist/backend/templates/harness/vcm-propose-memory-skill.js +4 -2
  32. package/dist/backend/templates/harness/vcm-report-harness-issue-skill.js +2 -1
  33. package/dist/backend/templates/harness/vcm-route-message-skill.js +4 -7
  34. package/dist/backend/templates/harness/vcm-task-state-skill.js +2 -13
  35. package/dist/backend/templates/harness/vcm-workflow-review-skill.js +62 -0
  36. package/dist/backend/templates/message-envelope.js +4 -4
  37. package/dist/shared/types/workflow.js +7 -1
  38. package/dist/shared/validation/artifact-check.js +99 -95
  39. package/dist/shared/validation/artifact-contract.js +9 -0
  40. package/dist/shared/validation/artifact-registry.js +211 -0
  41. package/dist-frontend/assets/{index-DkM0mnQD.js → index-Bocc2DWF.js} +34 -34
  42. package/dist-frontend/assets/{index-CXSOe-NN.css → index-C_XHGNBD.css} +1 -1
  43. package/dist-frontend/index.html +2 -2
  44. package/package.json +1 -1
  45. package/scripts/harness-tools/run-long-check +38 -8
  46. package/scripts/harness-tools/vcm-artifact +148 -0
  47. package/scripts/harness-tools/vcm-bash-guard +42 -47
  48. package/scripts/harness-tools/watch-job +58 -4
@@ -0,0 +1,854 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import path from "node:path";
3
+ import { resolveRepoPath } from "../adapters/filesystem.js";
4
+ import { VcmError } from "../errors.js";
5
+ import { WORKFLOW_FLOWS } from "../../shared/types/workflow.js";
6
+ import { checkMarkdownArtifact, readArtifactSectionContent } from "../../shared/validation/artifact-check.js";
7
+ import { renderWorkflowProgressTemplate } from "../templates/handoff.js";
8
+ const HISTORY_HEADER = "| Sequence | Flow | Target Role | Evidence | Override Authorization | Confirmed At |";
9
+ const HISTORY_SEPARATOR = "| --- | --- | --- | --- | --- | --- |";
10
+ const TARGET_ROLES = new Set(["architect", "coder", "tester"]);
11
+ const FINAL_GATE_STATUSES = new Set(["disabled", "not_required", "skipped", "overridden"]);
12
+ export function createWorkflowControlService(deps) {
13
+ const now = deps.now ?? (() => new Date().toISOString());
14
+ const id = deps.id ?? (() => `wfovr_${randomUUID()}`);
15
+ const locks = new Map();
16
+ async function getState(input) {
17
+ try {
18
+ const progressWarning = await ensureProgressFile(deps.fs, input);
19
+ if (!(await deps.fs.pathExists(statePath(input)))) {
20
+ const state = emptyState(input.taskSlug, now());
21
+ return progressWarning ? { ...state, warnings: [progressWarning] } : state;
22
+ }
23
+ const state = normalizeState(await deps.fs.readJson(statePath(input)), input.taskSlug, now());
24
+ return progressWarning ? { ...state, warnings: [...state.warnings, progressWarning] } : state;
25
+ }
26
+ catch (error) {
27
+ return {
28
+ ...emptyState(input.taskSlug, now()),
29
+ warnings: [`Workflow control state could not be read: ${errorMessage(error)}`]
30
+ };
31
+ }
32
+ }
33
+ async function submitProgress(input, content) {
34
+ return withLock(statePath(input), async () => {
35
+ const state = await getState(input);
36
+ failOnStateWarnings(state);
37
+ if (state.pendingDispatch) {
38
+ throw workflowError("WORKFLOW_DISPATCH_PENDING", `A ${state.pendingDispatch.targetRole} dispatch is already ${state.pendingDispatch.status}.`, "Complete or recover the existing dispatch before proposing another workflow transition.");
39
+ }
40
+ const current = await readProgress(deps.fs, input);
41
+ const candidate = parseWorkflowProgress(content, input.taskSlug);
42
+ validateCandidateAgainstCurrent(current, candidate);
43
+ if (!candidate.proposal) {
44
+ if (candidate.status !== "completed") {
45
+ throw workflowError("WORKFLOW_PROPOSAL_REQUIRED", "Workflow Progress must propose one role dispatch or mark the active flow completed.", "Set Proposed Dispatch to a target role, or submit Status: completed after the flow's completion evidence exists.");
46
+ }
47
+ await validateCompletion(deps.fs, input, candidate);
48
+ const normalized = renderWorkflowProgress(candidate);
49
+ await writeAtomic(deps.fs, progressPath(input), normalized);
50
+ return { path: relativeProgressPath(input), content: normalized };
51
+ }
52
+ const baseHistoryHash = historyHash(current.history);
53
+ const effectiveFlow = resolveEffectiveFlow(current.flow, candidate.proposal.requestedFlow);
54
+ const verdict = await evaluateTransition(deps.fs, input, current, effectiveFlow, candidate.proposal.targetRole);
55
+ let overrideAuthorizationId;
56
+ if (!verdict.allowed) {
57
+ const authorizationId = candidate.proposal.authorizationId;
58
+ if (authorizationId === "request") {
59
+ const existingPending = state.overrideRequests.find((entry) => entry.status === "pending");
60
+ if (existingPending) {
61
+ throw workflowError("WORKFLOW_OVERRIDE_PENDING", `Workflow override ${existingPending.id} is already waiting for the user's decision.`, "Wait for the current user decision before requesting another workflow override.");
62
+ }
63
+ const quote = candidate.proposal.authorizationQuote?.trim();
64
+ const violatedRule = candidate.proposal.violatedRule?.trim();
65
+ if (!quote || !violatedRule || violatedRule !== verdict.reason) {
66
+ throw workflowError("WORKFLOW_OVERRIDE_REQUEST_INVALID", `Override request must include the proposed user authorization and the exact violated rule: ${verdict.reason}`, "Copy the rejection reason exactly into Violated Rule and record the user's proposed authorization text.");
67
+ }
68
+ const pending = createOverrideRequest(id(), current, candidate, effectiveFlow, baseHistoryHash, verdict.reason, quote, now());
69
+ const next = {
70
+ ...state,
71
+ overrideRequests: [...state.overrideRequests, pending],
72
+ updatedAt: now()
73
+ };
74
+ await saveState(input, next);
75
+ throw workflowError("WORKFLOW_OVERRIDE_PENDING", `Workflow override ${pending.id} requires direct user confirmation in VCM.`, "Wait for the user's decision. If approved, resubmit with the returned Authorization ID and exact authorization text.");
76
+ }
77
+ if (authorizationId && authorizationId !== "none") {
78
+ const override = state.overrideRequests.find((entry) => entry.id === authorizationId);
79
+ validateApprovedOverride(override, current, candidate, effectiveFlow, baseHistoryHash, verdict.reason);
80
+ overrideAuthorizationId = override.id;
81
+ }
82
+ else {
83
+ throw workflowError("WORKFLOW_TRANSITION_DENIED", verdict.reason, verdict.allowedTransitions.length > 0
84
+ ? `Allowed next dispatches: ${verdict.allowedTransitions.join(", ")}. Recheck the flow, or request an exact one-time user override.`
85
+ : "No role dispatch is legal at this checkpoint. Complete the required Gate, user decision, or PM-only step first.");
86
+ }
87
+ }
88
+ else if (candidate.proposal.authorizationId && candidate.proposal.authorizationId !== "none") {
89
+ throw workflowError("WORKFLOW_OVERRIDE_NOT_REQUIRED", "This workflow transition is legal and must not consume a user override.", "Set every User Override field to none.");
90
+ }
91
+ const timestamp = now();
92
+ const pendingDispatch = {
93
+ revision: candidate.revision,
94
+ baseHistoryHash,
95
+ effectiveFlow,
96
+ requestedFlow: candidate.proposal.requestedFlow,
97
+ targetRole: candidate.proposal.targetRole,
98
+ evidence: candidate.proposal.evidence,
99
+ expectedRoutePath: expectedRoutePath(input.handoffDir, candidate.proposal.targetRole),
100
+ overrideAuthorizationId,
101
+ status: "pending",
102
+ createdAt: timestamp,
103
+ updatedAt: timestamp
104
+ };
105
+ const accepted = {
106
+ ...candidate,
107
+ proposal: {
108
+ ...candidate.proposal,
109
+ authorizationId: overrideAuthorizationId,
110
+ authorizationQuote: overrideAuthorizationId
111
+ ? state.overrideRequests.find((entry) => entry.id === overrideAuthorizationId)?.authorizationText
112
+ : undefined,
113
+ violatedRule: overrideAuthorizationId ? verdict.reason : undefined
114
+ }
115
+ };
116
+ const normalized = renderWorkflowProgress(accepted);
117
+ await writeAtomic(deps.fs, progressPath(input), normalized);
118
+ await saveState(input, {
119
+ ...state,
120
+ pendingDispatch,
121
+ updatedAt: timestamp
122
+ });
123
+ return { path: relativeProgressPath(input), content: normalized };
124
+ });
125
+ }
126
+ async function assertRouteAuthorized(input) {
127
+ const state = await getState(input);
128
+ failOnStateWarnings(state);
129
+ const pending = state.pendingDispatch;
130
+ if (!pending || pending.status !== "pending") {
131
+ throw workflowError("WORKFLOW_ROUTE_NOT_APPROVED", "Project Manager has no pending workflow approval for this route.", "Submit a valid workflow-progress.md transition before the PM route message.");
132
+ }
133
+ if (pending.targetRole !== input.targetRole || pending.expectedRoutePath !== input.routePath) {
134
+ throw workflowError("WORKFLOW_ROUTE_MISMATCH", `The approved route is project-manager -> ${pending.targetRole}, not project-manager -> ${input.targetRole}.`, `Write only ${pending.expectedRoutePath}, or submit a new legal Workflow Progress transition.`);
135
+ }
136
+ const progress = await readProgress(deps.fs, input);
137
+ if (progress.revision !== pending.revision || historyHash(progress.history) !== pending.baseHistoryHash) {
138
+ throw workflowError("WORKFLOW_APPROVAL_STALE", "The pending workflow approval no longer matches workflow-progress.md.", "Resubmit the Workflow Progress transition.");
139
+ }
140
+ }
141
+ async function claimDispatch(input) {
142
+ await withLock(statePath(input), async () => {
143
+ await assertRouteAuthorized(input);
144
+ const state = await getState(input);
145
+ const pending = state.pendingDispatch;
146
+ await saveState(input, {
147
+ ...state,
148
+ pendingDispatch: {
149
+ ...pending,
150
+ status: "dispatching",
151
+ routeContentHash: input.routeContentHash,
152
+ messageId: input.messageId,
153
+ updatedAt: now()
154
+ },
155
+ updatedAt: now()
156
+ });
157
+ });
158
+ }
159
+ async function releaseDispatch(input, messageId) {
160
+ await withLock(statePath(input), async () => {
161
+ const state = await getState(input);
162
+ const pending = state.pendingDispatch;
163
+ if (!pending || pending.status !== "dispatching" || pending.messageId !== messageId)
164
+ return;
165
+ await saveState(input, {
166
+ ...state,
167
+ pendingDispatch: {
168
+ ...pending,
169
+ status: "pending",
170
+ routeContentHash: undefined,
171
+ messageId: undefined,
172
+ updatedAt: now()
173
+ },
174
+ updatedAt: now()
175
+ });
176
+ });
177
+ }
178
+ async function confirmDispatch(input, messageId) {
179
+ await withLock(statePath(input), async () => {
180
+ const state = await getState(input);
181
+ failOnStateWarnings(state);
182
+ const pending = state.pendingDispatch;
183
+ if (!pending || pending.status !== "dispatching" || pending.messageId !== messageId) {
184
+ throw workflowError("WORKFLOW_DISPATCH_CONFIRMATION_MISMATCH", `Message ${messageId} does not own the pending workflow dispatch.`, "Do not advance Workflow Progress from an unrelated UserPromptSubmit event.");
185
+ }
186
+ const current = await readProgress(deps.fs, input);
187
+ if (current.revision !== pending.revision || historyHash(current.history) !== pending.baseHistoryHash) {
188
+ throw workflowError("WORKFLOW_APPROVAL_STALE", "Workflow Progress changed before the approved dispatch was confirmed.", "Repair workflow-progress.md before continuing.");
189
+ }
190
+ const timestamp = now();
191
+ const entry = {
192
+ sequence: current.history.length + 1,
193
+ flow: pending.effectiveFlow,
194
+ targetRole: pending.targetRole,
195
+ evidence: pending.evidence,
196
+ overrideAuthorizationId: pending.overrideAuthorizationId,
197
+ confirmedAt: timestamp
198
+ };
199
+ const completed = {
200
+ ...current,
201
+ flow: pending.effectiveFlow,
202
+ status: "active",
203
+ history: [...current.history, entry],
204
+ proposal: undefined
205
+ };
206
+ const overrideRequests = state.overrideRequests.map((entry) => entry.id === pending.overrideAuthorizationId
207
+ ? { ...entry, status: "consumed", consumedAt: timestamp }
208
+ : entry);
209
+ await writeAtomic(deps.fs, progressPath(input), renderWorkflowProgress(completed));
210
+ await saveState(input, {
211
+ ...state,
212
+ pendingDispatch: null,
213
+ overrideRequests,
214
+ updatedAt: timestamp
215
+ });
216
+ });
217
+ }
218
+ async function approveOverride(input, overrideId, authorizationText) {
219
+ return decideOverride(input, overrideId, "approved", authorizationText);
220
+ }
221
+ async function rejectOverride(input, overrideId) {
222
+ return decideOverride(input, overrideId, "rejected");
223
+ }
224
+ async function decideOverride(input, overrideId, decision, authorizationText) {
225
+ return withLock(statePath(input), async () => {
226
+ const state = await getState(input);
227
+ failOnStateWarnings(state);
228
+ const existing = state.overrideRequests.find((entry) => entry.id === overrideId);
229
+ if (!existing || existing.status !== "pending") {
230
+ throw workflowError("WORKFLOW_OVERRIDE_NOT_PENDING", `Workflow override ${overrideId} is not pending.`, "Refresh the task state and act only on the current pending override.");
231
+ }
232
+ const normalizedAuthorization = authorizationText?.trim();
233
+ if (decision === "approved" && !normalizedAuthorization) {
234
+ throw workflowError("WORKFLOW_OVERRIDE_AUTHORIZATION_REQUIRED", "Direct user authorization text is required.", "Describe the exact one-time workflow exception being authorized.");
235
+ }
236
+ const timestamp = now();
237
+ const next = {
238
+ ...state,
239
+ overrideRequests: state.overrideRequests.map((entry) => entry.id === overrideId
240
+ ? {
241
+ ...entry,
242
+ status: decision,
243
+ authorizationText: decision === "approved" ? normalizedAuthorization : undefined,
244
+ decidedAt: timestamp
245
+ }
246
+ : entry),
247
+ updatedAt: timestamp
248
+ };
249
+ await saveState(input, next);
250
+ return next;
251
+ });
252
+ }
253
+ return {
254
+ getState,
255
+ submitProgress,
256
+ assertRouteAuthorized,
257
+ claimDispatch,
258
+ releaseDispatch,
259
+ confirmDispatch,
260
+ approveOverride,
261
+ rejectOverride
262
+ };
263
+ async function saveState(input, state) {
264
+ await deps.fs.writeJsonAtomic(statePath(input), state);
265
+ }
266
+ async function withLock(key, operation) {
267
+ const previous = locks.get(key) ?? Promise.resolve();
268
+ const next = previous.catch(() => undefined).then(operation);
269
+ locks.set(key, next);
270
+ try {
271
+ return await next;
272
+ }
273
+ finally {
274
+ if (locks.get(key) === next)
275
+ locks.delete(key);
276
+ }
277
+ }
278
+ }
279
+ export function parseWorkflowProgress(content, expectedTaskSlug) {
280
+ const title = /^# Workflow Progress:\s*(\S.+?)\s*$/m.exec(content)?.[1]?.trim();
281
+ const revision = integerField(content, "Revision");
282
+ const flowValue = field(content, "Flow");
283
+ const status = field(content, "Status");
284
+ const errors = [];
285
+ if (!title)
286
+ errors.push("Title must be '# Workflow Progress: <task-slug>'.");
287
+ if (expectedTaskSlug && title !== expectedTaskSlug)
288
+ errors.push(`Workflow Progress task must be ${expectedTaskSlug}.`);
289
+ if (revision === undefined || revision < 0)
290
+ errors.push("Revision must be a non-negative integer.");
291
+ const flow = flowValue === "none" ? undefined : asFlow(flowValue);
292
+ if (flowValue !== "none" && !flow)
293
+ errors.push(`Flow must be none|${WORKFLOW_FLOWS.join("|")}.`);
294
+ if (status !== "not-started" && status !== "active" && status !== "completed") {
295
+ errors.push("Status must be not-started|active|completed.");
296
+ }
297
+ const history = parseHistory(readArtifactSectionContent(content, "Dispatch History"), errors);
298
+ const proposalSection = readArtifactSectionContent(content, "Proposed Dispatch") ?? "";
299
+ const requestedFlowValue = field(proposalSection, "Requested Flow");
300
+ const targetRoleValue = field(proposalSection, "Target Role");
301
+ const evidence = rawField(proposalSection, "Evidence");
302
+ let proposal;
303
+ if (targetRoleValue !== "none") {
304
+ const targetRole = asTargetRole(targetRoleValue);
305
+ const requestedFlow = requestedFlowValue === "none" ? undefined : asFlow(requestedFlowValue);
306
+ if (!targetRole)
307
+ errors.push("Target Role must be none|architect|coder|tester.");
308
+ if (requestedFlowValue !== "none" && !requestedFlow)
309
+ errors.push(`Requested Flow must be none|${WORKFLOW_FLOWS.join("|")}.`);
310
+ if (!evidence || evidence === "none")
311
+ errors.push("Evidence must identify the real artifact or user request for the proposed dispatch.");
312
+ if (targetRole)
313
+ proposal = { targetRole, requestedFlow, evidence: evidence ?? "" };
314
+ }
315
+ else if (requestedFlowValue !== "none" || evidence !== "none") {
316
+ errors.push("A completed/no-dispatch proposal must use Requested Flow, Target Role, and Evidence value none.");
317
+ }
318
+ const overrideSection = readArtifactSectionContent(content, "User Override") ?? "";
319
+ const authorizationId = rawField(overrideSection, "Authorization ID");
320
+ const authorizationQuote = rawField(overrideSection, "Authorization Quote");
321
+ const violatedRule = rawField(overrideSection, "Violated Rule");
322
+ if (!authorizationId || !authorizationQuote || !violatedRule) {
323
+ errors.push("User Override requires Authorization ID, Authorization Quote, and Violated Rule fields.");
324
+ }
325
+ else if (proposal) {
326
+ const allNone = authorizationId === "none" && authorizationQuote === "none" && violatedRule === "none";
327
+ const allSet = authorizationId !== "none" && authorizationQuote !== "none" && violatedRule !== "none";
328
+ if (!allNone && !allSet)
329
+ errors.push("User Override fields must all be none or all contain the exact override data.");
330
+ if (allSet) {
331
+ proposal.authorizationId = authorizationId;
332
+ proposal.authorizationQuote = authorizationQuote;
333
+ proposal.violatedRule = violatedRule;
334
+ }
335
+ }
336
+ else if (authorizationId !== "none" || authorizationQuote !== "none" || violatedRule !== "none") {
337
+ errors.push("User Override fields must be none when no role dispatch is proposed.");
338
+ }
339
+ if (errors.length > 0)
340
+ throw progressValidationError(errors);
341
+ return {
342
+ taskSlug: title,
343
+ revision: revision,
344
+ flow,
345
+ status: status,
346
+ history,
347
+ proposal
348
+ };
349
+ }
350
+ export function renderWorkflowProgress(progress) {
351
+ const history = progress.history.length === 0
352
+ ? "none"
353
+ : [
354
+ HISTORY_HEADER,
355
+ HISTORY_SEPARATOR,
356
+ ...progress.history.map((entry) => `| ${entry.sequence} | ${entry.flow} | ${entry.targetRole} | ${escapeCell(entry.evidence)} | ${entry.overrideAuthorizationId ?? "none"} | ${entry.confirmedAt ?? "none"} |`)
357
+ ].join("\n");
358
+ const proposal = progress.proposal;
359
+ return `# Workflow Progress: ${progress.taskSlug}
360
+
361
+ Revision: ${progress.revision}
362
+ Flow: ${progress.flow ?? "none"}
363
+ Status: ${progress.status}
364
+
365
+ ## Dispatch History
366
+
367
+ ${history}
368
+
369
+ ## Proposed Dispatch
370
+
371
+ Requested Flow: ${proposal?.requestedFlow ?? "none"}
372
+ Target Role: ${proposal?.targetRole ?? "none"}
373
+ Evidence: ${proposal?.evidence ?? "none"}
374
+
375
+ ## User Override
376
+
377
+ Authorization ID: ${proposal?.authorizationId ?? "none"}
378
+ Authorization Quote: ${proposal?.authorizationQuote ?? "none"}
379
+ Violated Rule: ${proposal?.violatedRule ?? "none"}
380
+ `;
381
+ }
382
+ async function evaluateTransition(fs, input, current, effectiveFlow, targetRole) {
383
+ const allowedTransitions = await getAllowedTransitions(fs, input, current);
384
+ const signature = `${effectiveFlow}/${targetRole}`;
385
+ if (allowedTransitions.includes(signature))
386
+ return { allowed: true, reason: "allowed", allowedTransitions };
387
+ return {
388
+ allowed: false,
389
+ reason: `Transition ${signature} is not legal after the confirmed Workflow Progress history.`,
390
+ allowedTransitions
391
+ };
392
+ }
393
+ async function getAllowedTransitions(fs, input, current) {
394
+ if (current.status === "completed")
395
+ return [];
396
+ if (!current.flow || current.history.length === 0) {
397
+ return [
398
+ "code-change/architect",
399
+ "architect-debug/architect",
400
+ "architecture-diagnosis/architect",
401
+ "docs-only/architect",
402
+ "validation-only/tester"
403
+ ];
404
+ }
405
+ const flow = current.flow;
406
+ if (flow === "docs-only") {
407
+ const docs = await artifactState(fs, input, "docs-sync-report.md", "docs-sync-report");
408
+ return docs.value === "synced" || docs.value === "unchanged"
409
+ ? []
410
+ : ["docs-only/architect", "code-change/architect", "validation-only/tester"];
411
+ }
412
+ if (flow === "validation-only") {
413
+ const test = await artifactState(fs, input, "test-report.md", "test-report");
414
+ if (test.infrastructure === "production-change-required")
415
+ return ["code-change/architect"];
416
+ if (test.value === "incomplete" || test.infrastructure === "repair-required")
417
+ return ["validation-only/tester"];
418
+ const validationGate = await gateState(fs, input, "validation-adequacy");
419
+ if (validationGate?.decision === "request_changes")
420
+ return ["validation-only/tester"];
421
+ if (!gatePassed(validationGate))
422
+ return [];
423
+ return [];
424
+ }
425
+ const segment = current.history.slice(findLastIndex(current.history, (entry) => entry.flow !== flow) + 1);
426
+ if (flow === "code-change")
427
+ return allowedCodeChange(fs, input, current.history);
428
+ if (flow === "architect-debug")
429
+ return allowedArchitectFix(fs, input, current, segment, "architect-debug");
430
+ return allowedArchitectFix(fs, input, current, segment, "architecture-diagnosis");
431
+ }
432
+ async function allowedCodeChange(fs, input, segment) {
433
+ const coderIndex = findLastIndex(segment, (entry) => entry.targetRole === "coder");
434
+ const testerIndex = findLastIndex(segment, (entry) => entry.targetRole === "tester");
435
+ if (coderIndex < 0) {
436
+ const plan = await artifactState(fs, input, "architecture-plan.md", "architecture-plan");
437
+ const gate = await gateState(fs, input, "architecture-plan");
438
+ if (plan.value !== "complete")
439
+ return ["code-change/architect"];
440
+ if (gate?.decision === "request_changes")
441
+ return ["code-change/architect"];
442
+ return gatePassed(gate) ? ["code-change/coder"] : [];
443
+ }
444
+ if (testerIndex < coderIndex) {
445
+ const coder = await artifactState(fs, input, "coder-completion.md", "coder-completion");
446
+ if (coder.value === "failed")
447
+ return ["architect-debug/architect"];
448
+ return coder.value === "ready_for_review" ? ["code-change/tester"] : ["code-change/coder"];
449
+ }
450
+ const architectsAfterTester = segment.filter((entry, index) => index > testerIndex && entry.targetRole === "architect");
451
+ if (architectsAfterTester.length > 0) {
452
+ const docs = await artifactState(fs, input, "docs-sync-report.md", "docs-sync-report");
453
+ if (docs.value === "synced" || docs.value === "unchanged") {
454
+ const acceptance = await artifactState(fs, input, "final-acceptance.md", "final-acceptance");
455
+ if (acceptance.value === "needs-coder-follow-up")
456
+ return ["code-change/coder"];
457
+ if (acceptance.value === "needs-docs-sync")
458
+ return ["code-change/architect"];
459
+ if (acceptance.value === "needs-architect-follow-up") {
460
+ if (architectsAfterTester.length === 1)
461
+ return ["code-change/architect"];
462
+ return allowedArchitectureFollowup(fs, input, architectsAfterTester[1]?.confirmedAt);
463
+ }
464
+ return [];
465
+ }
466
+ return ["code-change/architect"];
467
+ }
468
+ return allowedAfterTester(fs, input, "coder", {
469
+ testerFailureFlow: "architect-debug",
470
+ implementationFailureFlow: "architect-debug",
471
+ successTarget: "code-change/architect"
472
+ });
473
+ }
474
+ async function allowedArchitectureFollowup(fs, input, followupDispatchedAt) {
475
+ const plan = await artifactState(fs, input, "architecture-plan.md", "architecture-plan");
476
+ if (plan.value !== "complete")
477
+ return ["code-change/architect"];
478
+ const gate = await gateState(fs, input, "architecture-plan");
479
+ if (!gate || !followupDispatchedAt || gate.updatedAt <= followupDispatchedAt)
480
+ return [];
481
+ if (gate.decision === "request_changes")
482
+ return ["code-change/architect"];
483
+ return gatePassed(gate) ? ["code-change/coder"] : [];
484
+ }
485
+ async function allowedArchitectFix(fs, input, current, segment, source) {
486
+ const architectIndex = findLastIndex(segment, (entry) => entry.targetRole === "architect");
487
+ const testerIndex = findLastIndex(segment, (entry) => entry.targetRole === "tester");
488
+ if (architectIndex < 0)
489
+ return [`${source}/architect`];
490
+ if (testerIndex < 0) {
491
+ const artifact = source === "architect-debug"
492
+ ? await artifactState(fs, input, "architect-debug.md", "architect-debug")
493
+ : await artifactState(fs, input, "architecture-diagnosis.md", "architecture-diagnosis");
494
+ if (source === "architect-debug" && artifact.disposition === "normal architecture plan required") {
495
+ return ["code-change/architect"];
496
+ }
497
+ return artifact.complete ? [`${source}/tester`] : [`${source}/architect`];
498
+ }
499
+ if (architectIndex > testerIndex) {
500
+ const codeDiff = await gateState(fs, input, "code-diff");
501
+ if (codeDiff?.decision === "request_changes")
502
+ return [`${source}/tester`];
503
+ const docs = await artifactState(fs, input, "docs-sync-report.md", "docs-sync-report");
504
+ return docs.value === "synced" || docs.value === "unchanged" ? [] : [`${source}/architect`];
505
+ }
506
+ const parentCodeChange = current.history.slice(0, -segment.length).some((entry) => entry.flow === "code-change");
507
+ return allowedAfterTester(fs, input, source, {
508
+ testerFailureFlow: source === "architect-debug" ? "architecture-diagnosis" : undefined,
509
+ implementationFailureFlow: source,
510
+ successTarget: parentCodeChange ? "code-change/architect" : `${source}/architect`
511
+ });
512
+ }
513
+ async function allowedAfterTester(fs, input, codeSource, options) {
514
+ const test = await artifactState(fs, input, "test-report.md", "test-report");
515
+ const currentFlow = codeSource === "coder" ? "code-change" : codeSource;
516
+ if (test.value === "incomplete" || test.infrastructure === "repair-required")
517
+ return [`${currentFlow}/tester`];
518
+ if (test.value === "fail" && test.infrastructure !== "repair-required") {
519
+ return options.testerFailureFlow ? [`${options.testerFailureFlow}/architect`] : [];
520
+ }
521
+ const validation = await gateState(fs, input, "validation-adequacy");
522
+ if (validation?.decision === "request_changes")
523
+ return [`${currentFlow}/tester`];
524
+ if (!gatePassed(validation))
525
+ return [];
526
+ const codeDiff = await gateState(fs, input, "code-diff");
527
+ if (codeDiff?.decision === "request_changes") {
528
+ const scopes = new Set(codeDiff.findings?.map((finding) => finding.scope).filter(Boolean));
529
+ return scopes.size === 1 && scopes.has("test-only")
530
+ ? [`${currentFlow}/tester`]
531
+ : options.implementationFailureFlow ? [`${options.implementationFailureFlow}/architect`] : [];
532
+ }
533
+ if (!gatePassed(codeDiff))
534
+ return [];
535
+ const gateCodeSource = codeSource === "architecture-diagnosis" ? "architect-diagnosis" : codeSource;
536
+ const reviewedSources = codeDiff?.codeDiffSources ?? (codeDiff?.codeDiffSource ? [codeDiff.codeDiffSource] : []);
537
+ if (codeDiff?.status === "completed" && !reviewedSources.includes(gateCodeSource))
538
+ return [];
539
+ return [options.successTarget];
540
+ }
541
+ async function artifactState(fs, input, fileName, kind) {
542
+ const relative = path.posix.join(input.handoffDir, fileName);
543
+ const absolute = resolveRepoPath(input.taskRepoRoot, relative);
544
+ if (!(await fs.pathExists(absolute)))
545
+ return { complete: false };
546
+ const content = await fs.readText(absolute);
547
+ const check = checkMarkdownArtifact(kind, relative, content, { mode: "final" });
548
+ const inline = (name) => new RegExp(`^${name}:\\s*(.+?)\\s*$`, "mi").exec(content)?.[1]?.trim().toLowerCase();
549
+ let value;
550
+ if (kind === "architecture-plan")
551
+ value = inline("Planning Result");
552
+ if (kind === "coder-completion")
553
+ value = inline("Decision");
554
+ if (kind === "architect-debug")
555
+ value = inline("Status") ?? readArtifactSectionContent(content, "Final Disposition")?.trim().toLowerCase();
556
+ if (kind === "architecture-diagnosis")
557
+ value = readArtifactSectionContent(content, "Final Disposition")?.trim().toLowerCase();
558
+ if (kind === "test-report")
559
+ value = inline("Test Result");
560
+ if (kind === "docs-sync-report" || kind === "final-acceptance")
561
+ value = readArtifactSectionContent(content, "Decision")?.trim().toLowerCase();
562
+ const infrastructure = kind === "test-report"
563
+ ? /^Status:\s*(.+?)\s*$/mi.exec(readArtifactSectionContent(content, "Test Infrastructure") ?? "")?.[1]?.trim().toLowerCase()
564
+ : undefined;
565
+ const disposition = kind === "architect-debug" || kind === "architecture-diagnosis"
566
+ ? readArtifactSectionContent(content, "Final Disposition")?.trim().toLowerCase()
567
+ : undefined;
568
+ return { complete: check.status === "ok", value, infrastructure, disposition };
569
+ }
570
+ async function gateState(fs, input, gate) {
571
+ const target = resolveRepoPath(input.taskRepoRoot, path.posix.join(".ai/vcm/gate-reviews", "index.json"));
572
+ if (!(await fs.pathExists(target)))
573
+ return undefined;
574
+ try {
575
+ const index = await fs.readJson(target);
576
+ return index.gates?.[gate];
577
+ }
578
+ catch {
579
+ return undefined;
580
+ }
581
+ }
582
+ function gatePassed(record) {
583
+ return Boolean(record && (FINAL_GATE_STATUSES.has(record.status)
584
+ || (record.status === "completed" && record.decision === "approve")));
585
+ }
586
+ async function validateCompletion(fs, input, candidate) {
587
+ if (!candidate.flow || candidate.history.length === 0) {
588
+ throw workflowError("WORKFLOW_COMPLETION_INVALID", "An unstarted workflow cannot be completed.");
589
+ }
590
+ if (candidate.flow === "code-change" || candidate.flow === "architect-debug") {
591
+ const acceptance = await artifactState(fs, input, "final-acceptance.md", "final-acceptance");
592
+ if (acceptance.value !== "accepted" && acceptance.value !== "accepted-with-known-risks") {
593
+ throw workflowError("WORKFLOW_COMPLETION_INVALID", "Final Acceptance is not accepted for this complete delivery flow.");
594
+ }
595
+ return;
596
+ }
597
+ if (candidate.flow === "architecture-diagnosis") {
598
+ const diagnosis = await artifactState(fs, input, "architecture-diagnosis.md", "architecture-diagnosis");
599
+ if (diagnosis.disposition === "analysis completed")
600
+ return;
601
+ const acceptance = await artifactState(fs, input, "final-acceptance.md", "final-acceptance");
602
+ if (acceptance.value !== "accepted" && acceptance.value !== "accepted-with-known-risks") {
603
+ throw workflowError("WORKFLOW_COMPLETION_INVALID", "Implemented Architecture Diagnosis requires accepted Final Acceptance evidence.");
604
+ }
605
+ return;
606
+ }
607
+ if (candidate.flow === "docs-only") {
608
+ const docs = await artifactState(fs, input, "docs-sync-report.md", "docs-sync-report");
609
+ if (docs.value !== "synced" && docs.value !== "unchanged") {
610
+ throw workflowError("WORKFLOW_COMPLETION_INVALID", "Docs-only completion requires a synced or unchanged Docs Sync Report.");
611
+ }
612
+ return;
613
+ }
614
+ if (candidate.flow === "validation-only") {
615
+ const test = await artifactState(fs, input, "test-report.md", "test-report");
616
+ const gate = await gateState(fs, input, "validation-adequacy");
617
+ if ((test.value !== "pass" && test.value !== "fail") || !gatePassed(gate)) {
618
+ throw workflowError("WORKFLOW_COMPLETION_INVALID", "Validation-only completion requires a terminal Test Report and a passed Validation Adequacy Gate.");
619
+ }
620
+ }
621
+ }
622
+ function validateCandidateAgainstCurrent(current, candidate) {
623
+ const errors = [];
624
+ if (candidate.revision !== current.revision + 1)
625
+ errors.push(`Revision must be ${current.revision + 1}.`);
626
+ if (candidate.flow !== current.flow)
627
+ errors.push(`Flow must preserve the confirmed value ${current.flow ?? "none"}; use Requested Flow for a start or switch.`);
628
+ if (candidate.status !== current.status && candidate.status !== "completed")
629
+ errors.push(`Status must remain ${current.status} unless completing the flow.`);
630
+ if (JSON.stringify(candidate.history) !== JSON.stringify(current.history))
631
+ errors.push("Dispatch History is append-only and must be copied without changes.");
632
+ if (errors.length > 0)
633
+ throw progressValidationError(errors);
634
+ }
635
+ function validateApprovedOverride(override, current, candidate, effectiveFlow, baseHistoryHash, violation) {
636
+ const proposal = candidate.proposal;
637
+ if (!override || override.status !== "approved")
638
+ throw workflowError("WORKFLOW_OVERRIDE_INVALID", "The supplied workflow override is not approved.");
639
+ if (override.baseRevision !== current.revision
640
+ || override.baseHistoryHash !== baseHistoryHash
641
+ || override.effectiveFlow !== effectiveFlow
642
+ || override.requestedFlow !== proposal.requestedFlow
643
+ || override.targetRole !== proposal.targetRole
644
+ || override.evidence !== proposal.evidence
645
+ || override.violatedRule !== violation
646
+ || override.authorizationText !== proposal.authorizationQuote
647
+ || proposal.violatedRule !== violation) {
648
+ throw workflowError("WORKFLOW_OVERRIDE_MISMATCH", "The approved override does not match this exact workflow transition.");
649
+ }
650
+ }
651
+ function createOverrideRequest(overrideId, current, candidate, effectiveFlow, baseHistoryHash, violation, quote, timestamp) {
652
+ return {
653
+ id: overrideId,
654
+ status: "pending",
655
+ baseRevision: current.revision,
656
+ baseHistoryHash,
657
+ requestedFlow: candidate.proposal.requestedFlow,
658
+ effectiveFlow,
659
+ targetRole: candidate.proposal.targetRole,
660
+ evidence: candidate.proposal.evidence,
661
+ violatedRule: violation,
662
+ proposedAuthorizationQuote: quote,
663
+ createdAt: timestamp
664
+ };
665
+ }
666
+ async function readProgress(fs, input) {
667
+ const target = progressPath(input);
668
+ if (!(await fs.pathExists(target)))
669
+ return parseWorkflowProgress(renderWorkflowProgressTemplate(input.taskSlug), input.taskSlug);
670
+ return parseWorkflowProgress(await fs.readText(target), input.taskSlug);
671
+ }
672
+ async function ensureProgressFile(fs, input) {
673
+ const target = progressPath(input);
674
+ if (!(await fs.pathExists(target))) {
675
+ await writeAtomic(fs, target, renderWorkflowProgressTemplate(input.taskSlug));
676
+ return undefined;
677
+ }
678
+ try {
679
+ parseWorkflowProgress(await fs.readText(target), input.taskSlug);
680
+ return undefined;
681
+ }
682
+ catch (error) {
683
+ return `Workflow Progress is invalid and was preserved unchanged: ${errorMessage(error)}`;
684
+ }
685
+ }
686
+ function parseHistory(value, errors) {
687
+ if (!value || value.trim() === "none")
688
+ return [];
689
+ const lines = value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
690
+ if (lines[0] !== HISTORY_HEADER || lines[1] !== HISTORY_SEPARATOR) {
691
+ errors.push("Dispatch History must use the exact VCM table header.");
692
+ return [];
693
+ }
694
+ const entries = [];
695
+ for (const line of lines.slice(2)) {
696
+ const cells = line.startsWith("|") && line.endsWith("|")
697
+ ? line.slice(1, -1).split("|").map((cell) => cell.trim())
698
+ : [];
699
+ if (cells.length !== 6) {
700
+ errors.push("Every Dispatch History row must contain exactly six columns.");
701
+ continue;
702
+ }
703
+ const sequence = Number(cells[0]);
704
+ const flow = asFlow(cells[1]);
705
+ const targetRole = asTargetRole(cells[2]);
706
+ if (!Number.isInteger(sequence) || sequence !== entries.length + 1 || !flow || !targetRole || !cells[3]) {
707
+ errors.push("Dispatch History rows require consecutive sequence, valid flow, target role, and evidence.");
708
+ continue;
709
+ }
710
+ entries.push({
711
+ sequence,
712
+ flow,
713
+ targetRole,
714
+ evidence: cells[3],
715
+ overrideAuthorizationId: cells[4] === "none" ? undefined : cells[4],
716
+ confirmedAt: cells[5] === "none" ? undefined : cells[5]
717
+ });
718
+ }
719
+ return entries;
720
+ }
721
+ function normalizeState(value, taskSlug, timestamp) {
722
+ if (!isRecord(value) || value.version !== 1 || value.taskSlug !== taskSlug) {
723
+ return { ...emptyState(taskSlug, timestamp), warnings: ["Workflow control state has an unsupported shape."] };
724
+ }
725
+ const pendingDispatch = value.pendingDispatch === null
726
+ ? null
727
+ : isPendingDispatch(value.pendingDispatch) ? value.pendingDispatch : undefined;
728
+ const overrideRequests = Array.isArray(value.overrideRequests)
729
+ && value.overrideRequests.every(isOverrideRequest)
730
+ ? value.overrideRequests
731
+ : undefined;
732
+ if (pendingDispatch === undefined || overrideRequests === undefined) {
733
+ return { ...emptyState(taskSlug, timestamp), warnings: ["Workflow control state has an unsupported shape."] };
734
+ }
735
+ return {
736
+ version: 1,
737
+ taskSlug,
738
+ pendingDispatch,
739
+ overrideRequests,
740
+ warnings: [],
741
+ updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : timestamp
742
+ };
743
+ }
744
+ function emptyState(taskSlug, timestamp) {
745
+ return { version: 1, taskSlug, pendingDispatch: null, overrideRequests: [], warnings: [], updatedAt: timestamp };
746
+ }
747
+ function resolveEffectiveFlow(current, requested) {
748
+ if (requested)
749
+ return requested;
750
+ if (current)
751
+ return current;
752
+ throw workflowError("WORKFLOW_FLOW_REQUIRED", "Requested Flow is required for the first role dispatch.");
753
+ }
754
+ function expectedRoutePath(handoffDir, role) {
755
+ return path.posix.join(handoffDir, "messages", `project-manager-${role}.md`);
756
+ }
757
+ function statePath(input) {
758
+ return path.join(input.taskRepoRoot, input.stateRoot, "workflow-control.json");
759
+ }
760
+ function relativeProgressPath(input) {
761
+ return path.posix.join(input.handoffDir, "workflow-progress.md");
762
+ }
763
+ function progressPath(input) {
764
+ return resolveRepoPath(input.taskRepoRoot, relativeProgressPath(input));
765
+ }
766
+ function historyHash(history) {
767
+ return createHash("sha256").update(JSON.stringify(history)).digest("hex");
768
+ }
769
+ function field(content, name) {
770
+ return rawField(content, name)?.toLowerCase();
771
+ }
772
+ function rawField(content, name) {
773
+ return new RegExp(`^${escapeRegExp(name)}:\\s*(.*?)\\s*$`, "mi").exec(content)?.[1]?.trim();
774
+ }
775
+ function integerField(content, name) {
776
+ const value = rawField(content, name);
777
+ return value && /^\d+$/.test(value) ? Number(value) : undefined;
778
+ }
779
+ function asFlow(value) {
780
+ return WORKFLOW_FLOWS.includes(value) ? value : undefined;
781
+ }
782
+ function asTargetRole(value) {
783
+ return TARGET_ROLES.has(value) ? value : undefined;
784
+ }
785
+ function escapeCell(value) {
786
+ return value.replaceAll("|", "&#124;").replace(/\s+/g, " ").trim();
787
+ }
788
+ function escapeRegExp(value) {
789
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
790
+ }
791
+ function failOnStateWarnings(state) {
792
+ if (state.warnings.length > 0)
793
+ throw workflowError("WORKFLOW_STATE_INVALID", state.warnings.join(" "));
794
+ }
795
+ function progressValidationError(errors) {
796
+ return workflowError("WORKFLOW_PROGRESS_INVALID", `Workflow Progress validation failed:\n${errors.map((error) => `- ${error}`).join("\n")}`);
797
+ }
798
+ function workflowError(code, message, hint) {
799
+ return new VcmError({ code, message, hint, statusCode: code.includes("PENDING") ? 409 : 422 });
800
+ }
801
+ async function writeAtomic(fs, target, content) {
802
+ if (fs.writeTextAtomic)
803
+ await fs.writeTextAtomic(target, content);
804
+ else
805
+ await fs.writeText(target, content);
806
+ }
807
+ function isRecord(value) {
808
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
809
+ }
810
+ function isPendingDispatch(value) {
811
+ if (!isRecord(value))
812
+ return false;
813
+ return Number.isInteger(value.revision)
814
+ && typeof value.baseHistoryHash === "string"
815
+ && Boolean(asFlow(typeof value.effectiveFlow === "string" ? value.effectiveFlow : undefined))
816
+ && (value.requestedFlow === undefined || Boolean(asFlow(typeof value.requestedFlow === "string" ? value.requestedFlow : undefined)))
817
+ && Boolean(asTargetRole(typeof value.targetRole === "string" ? value.targetRole : undefined))
818
+ && typeof value.evidence === "string"
819
+ && typeof value.expectedRoutePath === "string"
820
+ && (value.overrideAuthorizationId === undefined || typeof value.overrideAuthorizationId === "string")
821
+ && (value.status === "pending" || value.status === "dispatching")
822
+ && (value.routeContentHash === undefined || typeof value.routeContentHash === "string")
823
+ && (value.messageId === undefined || typeof value.messageId === "string")
824
+ && typeof value.createdAt === "string"
825
+ && typeof value.updatedAt === "string";
826
+ }
827
+ function isOverrideRequest(value) {
828
+ if (!isRecord(value))
829
+ return false;
830
+ return typeof value.id === "string"
831
+ && ["pending", "approved", "rejected", "consumed"].includes(String(value.status))
832
+ && Number.isInteger(value.baseRevision)
833
+ && typeof value.baseHistoryHash === "string"
834
+ && (value.requestedFlow === undefined || Boolean(asFlow(typeof value.requestedFlow === "string" ? value.requestedFlow : undefined)))
835
+ && Boolean(asFlow(typeof value.effectiveFlow === "string" ? value.effectiveFlow : undefined))
836
+ && Boolean(asTargetRole(typeof value.targetRole === "string" ? value.targetRole : undefined))
837
+ && typeof value.evidence === "string"
838
+ && typeof value.violatedRule === "string"
839
+ && typeof value.proposedAuthorizationQuote === "string"
840
+ && (value.authorizationText === undefined || typeof value.authorizationText === "string")
841
+ && typeof value.createdAt === "string"
842
+ && (value.decidedAt === undefined || typeof value.decidedAt === "string")
843
+ && (value.consumedAt === undefined || typeof value.consumedAt === "string");
844
+ }
845
+ function findLastIndex(values, predicate) {
846
+ for (let index = values.length - 1; index >= 0; index -= 1) {
847
+ if (predicate(values[index]))
848
+ return index;
849
+ }
850
+ return -1;
851
+ }
852
+ function errorMessage(error) {
853
+ return error instanceof Error ? error.message : String(error);
854
+ }