codex-workflow-v2 2.0.0-alpha.3 → 2.0.0-alpha.6

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 (85) hide show
  1. package/README.md +104 -7
  2. package/dist/src/alpha6/adoption.d.ts +55 -0
  3. package/dist/src/alpha6/adoption.js +920 -0
  4. package/dist/src/alpha6/adoption.js.map +1 -0
  5. package/dist/src/alpha6/handoff.d.ts +39 -0
  6. package/dist/src/alpha6/handoff.js +975 -0
  7. package/dist/src/alpha6/handoff.js.map +1 -0
  8. package/dist/src/alpha6/journal.d.ts +30 -0
  9. package/dist/src/alpha6/journal.js +369 -0
  10. package/dist/src/alpha6/journal.js.map +1 -0
  11. package/dist/src/alpha6/milestone.d.ts +49 -0
  12. package/dist/src/alpha6/milestone.js +1049 -0
  13. package/dist/src/alpha6/milestone.js.map +1 -0
  14. package/dist/src/alpha6/plan-risk.d.ts +32 -0
  15. package/dist/src/alpha6/plan-risk.js +847 -0
  16. package/dist/src/alpha6/plan-risk.js.map +1 -0
  17. package/dist/src/alpha6/remediation.d.ts +20 -0
  18. package/dist/src/alpha6/remediation.js +748 -0
  19. package/dist/src/alpha6/remediation.js.map +1 -0
  20. package/dist/src/alpha6/review.d.ts +46 -0
  21. package/dist/src/alpha6/review.js +785 -0
  22. package/dist/src/alpha6/review.js.map +1 -0
  23. package/dist/src/alpha6/store-sidecars.d.ts +35 -0
  24. package/dist/src/alpha6/store-sidecars.js +281 -0
  25. package/dist/src/alpha6/store-sidecars.js.map +1 -0
  26. package/dist/src/cli.js +110 -23
  27. package/dist/src/cli.js.map +1 -1
  28. package/dist/src/contracts.d.ts +319 -1
  29. package/dist/src/delegation.d.ts +8 -0
  30. package/dist/src/delegation.js +155 -0
  31. package/dist/src/delegation.js.map +1 -0
  32. package/dist/src/git.js +2 -1
  33. package/dist/src/git.js.map +1 -1
  34. package/dist/src/index.d.ts +3 -0
  35. package/dist/src/index.js +1 -0
  36. package/dist/src/index.js.map +1 -1
  37. package/dist/src/reviewer.d.ts +6 -1
  38. package/dist/src/reviewer.js +145 -32
  39. package/dist/src/reviewer.js.map +1 -1
  40. package/dist/src/state/lock.d.ts +1 -0
  41. package/dist/src/state/lock.js +7 -1
  42. package/dist/src/state/lock.js.map +1 -1
  43. package/dist/src/state/store.d.ts +43 -1
  44. package/dist/src/state/store.js +140 -1
  45. package/dist/src/state/store.js.map +1 -1
  46. package/dist/src/ulid.d.ts +1 -1
  47. package/dist/src/ulid.js.map +1 -1
  48. package/dist/src/version.d.ts +1 -1
  49. package/dist/src/version.js +1 -1
  50. package/dist/src/workflow.d.ts +91 -13
  51. package/dist/src/workflow.js +1459 -76
  52. package/dist/src/workflow.js.map +1 -1
  53. package/docs/autonomy-guardrails.md +143 -0
  54. package/docs/decisions.md +30 -0
  55. package/docs/delegated-approval.md +189 -0
  56. package/docs/development-flow.md +53 -2
  57. package/docs/pdf/codex-workflow-v2-architecture-ru.pdf +426 -410
  58. package/docs/pdf/codex-workflow-v2-chat-only-guide-ru.pdf +778 -612
  59. package/docs/pdf/codex-workflow-v2-technical-reference-ru.pdf +526 -452
  60. package/docs/project-memory.md +7 -0
  61. package/docs/release.md +7 -0
  62. package/docs/updating-existing-project.md +141 -4
  63. package/docs/validation-report.md +47 -24
  64. package/package.json +1 -1
  65. package/plugins/codex-workflow-gateway/references/protocol.md +162 -5
  66. package/plugins/codex-workflow-gateway/skills/codex-workflow-gateway/SKILL.md +38 -3
  67. package/references/git-policy.md +5 -2
  68. package/references/state-machine.md +48 -0
  69. package/references/validation-and-review.md +28 -1
  70. package/roles/delivery-coordinator.md +11 -0
  71. package/roles/independent-reviewer.md +3 -0
  72. package/roles/technical-planner.md +7 -0
  73. package/roles/worker.md +4 -0
  74. package/schemas/adoption-posture-event.schema.json +129 -0
  75. package/schemas/authorization-event.schema.json +55 -0
  76. package/schemas/corrective-decision-event.schema.json +55 -0
  77. package/schemas/corrective-plan-audit.schema.json +20 -0
  78. package/schemas/delegation-grant.schema.json +69 -0
  79. package/schemas/milestone-scope-change-event.schema.json +68 -0
  80. package/schemas/milestone-transaction-journal.schema.json +95 -0
  81. package/schemas/plan-risk-audit-event.schema.json +90 -0
  82. package/schemas/remediation-event.schema.json +53 -0
  83. package/schemas/reviewer-attestation-event.schema.json +49 -0
  84. package/schemas/step-review-event.schema.json +74 -0
  85. package/schemas/task-handoff-event.schema.json +116 -0
@@ -1,15 +1,24 @@
1
- import { appendFileSync, readFileSync } from 'node:fs';
1
+ import { appendFileSync, existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import path from 'node:path';
4
4
  import { PROTOCOL_VERSION, STATE_SCHEMA_VERSION, } from './contracts.js';
5
5
  import { renderBrief, renderPlan, renderResult } from './artifacts.js';
6
+ import { assertDelegationCanAuthorize, delegationCanAuthorize, hashDelegationPolicy, prepareDelegationGrantGate, } from './delegation.js';
6
7
  import { assessDiscovery, mergeUnique } from './domain/discovery.js';
7
8
  import { pathAllowed, validatePlan } from './domain/validation.js';
8
9
  import { WorkflowError } from './errors.js';
9
10
  import { bindGraphEvidence, createGraphRefreshRequest, fallbackGraphBinding, inspectGraphBinding, validateGraphRequestCurrent, } from './graph.js';
11
+ import { appendTaskClaim, appendTaskHandback, appendTaskHandoff, assertTaskMutationAllowedByC1, readTaskC1Posture, } from './alpha6/handoff.js';
12
+ import { applyAdoptionPosture, assertAdoptionBaselinePreserved, buildAdoptionPreparation, initializeProjectRegistrationAdoption, readCurrentAdoptionPosture, } from './alpha6/adoption.js';
13
+ import { appendCurrentPlanRiskAudit, appendReboundPlanRiskAudit, readCurrentPlanRiskAudit, validatePlanRiskAuditCandidate, } from './alpha6/plan-risk.js';
14
+ import { appendCorrectiveDecisionEvent, appendRemediationEvent, assertCorrectiveAuditorIndependence, assertGuardedRemediationAttemptAllowed, findFailedGuardedStepRequiringCorrectiveDecision, readCorrectiveDecisionEvents, readRemediationEvents, } from './alpha6/remediation.js';
15
+ import { applyMilestoneScopeChangeTransaction, assertMilestoneMembershipIntegrity, assertMilestoneScopeChangeStatusAllowed, normalizeMilestonePlan, prepareMilestoneScopeChangeCandidate, readMilestoneForScopeChange, readMilestoneWithIntegrity, } from './alpha6/milestone.js';
16
+ import { buildCurrentStrictStepReviewCycle, ensurePendingStrictStepReview, isStepReviewRequired, recordStrictStepReview, } from './alpha6/review.js';
10
17
  import { applyKnowledgeSelections, hashKnowledgeMap, inspectKnowledgeMap, normalizeRelativePath, reconcileKnowledgeMap, scanProjectKnowledge, selectKnowledgeSources, } from './memory.js';
18
+ import { canonicalJsonStringify, sha256Hex } from './alpha6/store-sidecars.js';
11
19
  import { commitStep, mergeTaskBranch, runChecks, startLocalTaskBranch, syncBaseIntoTask, validateTaskHistory, } from './git.js';
12
20
  import { changedFiles, currentBranch, gitIsAncestor, headCommit, isClean, resolveRepositoryIdentity, runGit, } from './repository.js';
21
+ import { launchStrictStepReviewer } from './reviewer.js';
13
22
  import { WriterLockManager } from './state/lock.js';
14
23
  import { FileStateStore } from './state/store.js';
15
24
  import { createEntityId } from './ulid.js';
@@ -23,6 +32,18 @@ export class WorkflowService {
23
32
  }
24
33
  context(repository) {
25
34
  const identity = resolveRepositoryIdentity(repository);
35
+ const projectFile = path.join(this.store.projectRoot(identity.projectId), 'project.json');
36
+ const projectAlreadyRegistered = existsSync(projectFile);
37
+ if (!projectAlreadyRegistered) {
38
+ initializeProjectRegistrationAdoption(this.store, {
39
+ projectId: identity.projectId,
40
+ repositoryIdentity: identity,
41
+ packageVersion: PACKAGE_VERSION,
42
+ protocolVersion: PROTOCOL_VERSION,
43
+ stateSchemaVersion: STATE_SCHEMA_VERSION,
44
+ now: this.now(),
45
+ });
46
+ }
26
47
  this.store.registerProject(identity);
27
48
  return {
28
49
  identity,
@@ -30,6 +51,70 @@ export class WorkflowService {
30
51
  locks: new WriterLockManager(this.store.lockRoot(identity.projectId), this.now),
31
52
  };
32
53
  }
54
+ prepareDelegation(repository, input) {
55
+ const { identity } = this.context(repository);
56
+ const gate = prepareDelegationGrantGate(identity.projectId, input, this.now());
57
+ this.assertDelegationScopeExists(identity.projectId, gate.policy);
58
+ return gate;
59
+ }
60
+ grantDelegation(repository, input, confirmationCode) {
61
+ const { identity } = this.context(repository);
62
+ const gate = prepareDelegationGrantGate(identity.projectId, input, this.now());
63
+ this.assertDelegationScopeExists(identity.projectId, gate.policy);
64
+ if (confirmationCode.trim() !== gate.confirmationCode) {
65
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Delegation confirmation does not match the exact project and policy.', { projectId: identity.projectId, policyHash: gate.policyHash });
66
+ }
67
+ const duplicate = this.store.listDelegations(identity.projectId)
68
+ .find((grant) => grant.policyHash === gate.policyHash);
69
+ if (duplicate) {
70
+ throw new WorkflowError('TRANSITION_BLOCKED', 'This exact delegation policy was already issued.', {
71
+ grantId: duplicate.id,
72
+ status: duplicate.status,
73
+ });
74
+ }
75
+ const timestamp = this.now().toISOString();
76
+ return this.store.writeDelegation({
77
+ schemaVersion: STATE_SCHEMA_VERSION,
78
+ revision: 0,
79
+ id: createEntityId('DGR', this.now().getTime()),
80
+ projectId: identity.projectId,
81
+ createdAt: timestamp,
82
+ updatedAt: timestamp,
83
+ kind: 'delegation-grant',
84
+ status: 'active',
85
+ principal: gate.policy.principal,
86
+ delegate: gate.policy.delegate,
87
+ scope: gate.policy.scope,
88
+ transitions: gate.policy.transitions,
89
+ expiresAt: gate.policy.expiresAt,
90
+ policyHash: gate.policyHash,
91
+ revokedAt: null,
92
+ revokedBy: null,
93
+ revokeReason: null,
94
+ }, null);
95
+ }
96
+ revokeDelegation(repository, grantId, expectedRevision, actor, reason) {
97
+ const { identity } = this.context(repository);
98
+ const grant = this.store.readDelegation(identity.projectId, grantId);
99
+ assertExpectedRevision(grant, expectedRevision);
100
+ if (grant.status !== 'active') {
101
+ throw new WorkflowError('TRANSITION_BLOCKED', `Delegation ${grantId} is already ${grant.status}.`);
102
+ }
103
+ if (actor.trim() !== grant.principal) {
104
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Only the recorded principal may revoke a delegation.', {
105
+ principal: grant.principal,
106
+ });
107
+ }
108
+ if (!reason.trim())
109
+ throw new WorkflowError('INVALID_ARGUMENT', 'Delegation revocation requires a reason.');
110
+ return this.store.writeDelegation({
111
+ ...grant,
112
+ status: 'revoked',
113
+ revokedAt: this.now().toISOString(),
114
+ revokedBy: grant.principal,
115
+ revokeReason: reason.trim(),
116
+ }, expectedRevision);
117
+ }
33
118
  startDiscovery(repository, input) {
34
119
  if (!input.goal.trim())
35
120
  throw new WorkflowError('INVALID_ARGUMENT', 'Discovery requires a substantive goal.');
@@ -97,7 +182,7 @@ export class WorkflowService {
97
182
  if (!title.trim())
98
183
  throw new WorkflowError('INVALID_ARGUMENT', 'Task title is required.');
99
184
  if (milestoneId) {
100
- const milestone = this.store.readMilestone(identity.projectId, milestoneId);
185
+ const milestone = this.readMilestoneCurrent(identity.projectId, milestoneId);
101
186
  if (milestone.status === 'accepted' || milestone.status === 'cancelled') {
102
187
  throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestoneId} is ${milestone.status}.`);
103
188
  }
@@ -206,17 +291,29 @@ export class WorkflowService {
206
291
  }
207
292
  setMilestonePlan(repository, milestoneId, expectedRevision, plan) {
208
293
  const { identity } = this.context(repository);
209
- const milestone = this.store.readMilestone(identity.projectId, milestoneId);
294
+ const milestone = this.readMilestoneScopeChangeCurrent(identity.projectId, milestoneId);
210
295
  assertExpectedRevision(milestone, expectedRevision);
211
296
  if (!['planning', 'awaiting_execution_authorization', 'active', 'blocked'].includes(milestone.status)) {
212
297
  throw new WorkflowError('TRANSITION_BLOCKED', `Cannot update Milestone Plan while ${milestone.status}.`);
213
298
  }
299
+ if (milestone.status !== 'planning'
300
+ || milestone.planHash !== null
301
+ || milestone.membershipRevision !== 0
302
+ || milestone.taskIds.length > 0
303
+ || milestone.memberships.length > 0) {
304
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone Plan is no longer in the initial empty planning posture. Use explicit scope-change prepare/apply.', {
305
+ milestoneId,
306
+ revision: milestone.revision,
307
+ requiredAction: 'prepareMilestoneScopeChange -> applyMilestoneScopeChange',
308
+ });
309
+ }
214
310
  validateMilestonePlan(plan);
215
- const taskIds = plan.memberships.map((membership) => membership.taskId);
311
+ const normalized = normalizeMilestonePlan(plan);
312
+ const taskIds = normalized.taskIds;
216
313
  if (new Set(taskIds).size !== taskIds.length) {
217
314
  throw new WorkflowError('INVALID_ARGUMENT', 'Milestone memberships must have unique Task IDs.');
218
315
  }
219
- for (const membership of plan.memberships) {
316
+ for (const membership of normalized.memberships) {
220
317
  const task = this.store.readTask(identity.projectId, membership.taskId);
221
318
  if (task.milestoneId !== milestoneId) {
222
319
  throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} is not linked to Milestone ${milestoneId}.`);
@@ -231,22 +328,18 @@ export class WorkflowService {
231
328
  throw new WorkflowError('TRANSITION_BLOCKED', `Started Task ${task.id} cannot be removed from a Milestone.`);
232
329
  }
233
330
  }
234
- const root = this.store.milestoneRoot(identity.projectId, milestoneId);
235
- const normalized = {
236
- outcome: plan.outcome.trim(),
237
- successSignal: plan.successSignal.trim(),
238
- acceptance: [...plan.acceptance],
239
- memberships: plan.memberships.map((membership) => ({
240
- ...membership,
241
- reason: membership.reason.trim(),
242
- })),
243
- checks: [...plan.checks],
244
- };
245
- const stagedPlan = this.store.stageArtifact(root, 'plan.json', JSON.stringify(normalized, null, 2));
246
331
  const authorizations = milestone.authorizations.map((authorization) => authorization.kind === 'execution' && authorization.decision === 'approved'
247
332
  ? { ...authorization, decision: 'superseded' }
248
333
  : authorization);
249
- const saved = this.store.writeMilestone({
334
+ const root = this.store.milestoneRoot(identity.projectId, milestoneId);
335
+ const stagedPlan = this.store.stageArtifact(root, 'plan.json', JSON.stringify({
336
+ outcome: normalized.outcome,
337
+ successSignal: normalized.successSignal,
338
+ acceptance: normalized.acceptance,
339
+ memberships: normalized.memberships,
340
+ checks: normalized.checks,
341
+ }, null, 2));
342
+ const candidate = {
250
343
  ...milestone,
251
344
  status: 'awaiting_execution_authorization',
252
345
  outcome: normalized.outcome,
@@ -262,13 +355,55 @@ export class WorkflowService {
262
355
  evidenceHash: null,
263
356
  acceptedHead: null,
264
357
  cancellationReason: null,
265
- }, expectedRevision);
358
+ };
359
+ assertMilestoneMembershipIntegrity(candidate, (taskId) => this.store.readTask(identity.projectId, taskId), {}, () => this.store.listTasks(identity.projectId));
360
+ const saved = this.store.writeMilestone(candidate, expectedRevision);
266
361
  this.store.publishArtifact(stagedPlan);
267
362
  return saved;
268
363
  }
269
- authorizeMilestone(repository, milestoneId, expectedRevision, actor, reason = '') {
364
+ prepareMilestoneScopeChange(repository, milestoneId, expectedRevision, plan, actor) {
365
+ const { identity } = this.context(repository);
366
+ const milestone = this.readMilestoneScopeChangeCurrent(identity.projectId, milestoneId);
367
+ assertExpectedRevision(milestone, expectedRevision);
368
+ assertMilestoneScopeChangeStatusAllowed(milestone);
369
+ const prepared = this.prepareMilestoneScopeChangeCandidate(identity.projectId, milestone, plan, actor);
370
+ return {
371
+ milestoneId: prepared.milestoneId,
372
+ revision: prepared.expectedRevision,
373
+ actor: prepared.actor,
374
+ candidatePlanHash: prepared.candidatePlanHash,
375
+ candidateMembershipRevision: prepared.candidateMembershipRevision,
376
+ confirmationCodeBindingHash: prepared.confirmationCodeBindingHash,
377
+ confirmationCode: prepared.confirmationCode,
378
+ after: prepared.after,
379
+ };
380
+ }
381
+ applyMilestoneScopeChange(repository, milestoneId, expectedRevision, plan, actor, confirmationCode) {
270
382
  const { identity } = this.context(repository);
271
- const milestone = this.store.readMilestone(identity.projectId, milestoneId);
383
+ const milestone = this.readMilestoneScopeChangeCurrent(identity.projectId, milestoneId);
384
+ assertExpectedRevision(milestone, expectedRevision);
385
+ assertMilestoneScopeChangeStatusAllowed(milestone);
386
+ const prepared = this.prepareMilestoneScopeChangeCandidate(identity.projectId, milestone, plan, actor);
387
+ if (confirmationCode.trim() !== prepared.confirmationCode) {
388
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone scope-change confirmation does not match the current Milestone state and requested scope.', {
389
+ milestoneId,
390
+ revision: milestone.revision,
391
+ actor: prepared.actor,
392
+ confirmationCodeBindingHash: prepared.confirmationCodeBindingHash,
393
+ });
394
+ }
395
+ return applyMilestoneScopeChangeTransaction({
396
+ store: this.store,
397
+ milestone,
398
+ expectedRevision,
399
+ prepared,
400
+ now: this.now(),
401
+ readTask: (taskId) => this.store.readTask(identity.projectId, taskId),
402
+ });
403
+ }
404
+ authorizeMilestone(repository, milestoneId, expectedRevision, actor, reason = '', delegationGrantId = null) {
405
+ const { identity } = this.context(repository);
406
+ const milestone = this.readMilestoneCurrent(identity.projectId, milestoneId);
272
407
  assertExpectedRevision(milestone, expectedRevision);
273
408
  if (milestone.status !== 'awaiting_execution_authorization' || !milestone.planHash) {
274
409
  throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestoneId} is not awaiting authorization.`);
@@ -278,6 +413,7 @@ export class WorkflowService {
278
413
  if (planHash !== milestone.planHash) {
279
414
  throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone Plan changed after it was recorded.');
280
415
  }
416
+ const delegation = this.delegatedAuthorization(identity.projectId, delegationGrantId, 'milestone.execution_authorize', actor, milestone);
281
417
  const event = {
282
418
  kind: 'execution',
283
419
  decision: 'approved',
@@ -289,12 +425,14 @@ export class WorkflowService {
289
425
  evidenceHash: null,
290
426
  headCommit: runGit(identity.repositoryRoot, ['rev-parse', milestone.baseBranch]),
291
427
  reason,
428
+ authorizationMode: delegation ? 'delegated' : 'human',
429
+ ...(delegation ? { delegation } : {}),
292
430
  };
293
431
  return this.store.writeMilestone({ ...milestone, status: 'active', planHash, authorizations: [...milestone.authorizations, event] }, expectedRevision);
294
432
  }
295
433
  validateMilestone(repository, milestoneId, expectedRevision) {
296
434
  const { identity } = this.context(repository);
297
- const milestone = this.store.readMilestone(identity.projectId, milestoneId);
435
+ const milestone = this.readMilestoneCurrent(identity.projectId, milestoneId);
298
436
  assertExpectedRevision(milestone, expectedRevision);
299
437
  if (!['active', 'awaiting_final_acceptance'].includes(milestone.status)) {
300
438
  throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestoneId} cannot be validated while ${milestone.status}.`);
@@ -355,9 +493,9 @@ export class WorkflowService {
355
493
  this.store.publishArtifact(stagedResult);
356
494
  return saved;
357
495
  }
358
- acceptMilestone(repository, milestoneId, expectedRevision, actor, confirmationCode) {
496
+ acceptMilestone(repository, milestoneId, expectedRevision, actor, confirmationCode, delegationGrantId = null) {
359
497
  const { identity } = this.context(repository);
360
- const milestone = this.store.readMilestone(identity.projectId, milestoneId);
498
+ const milestone = this.readMilestoneCurrent(identity.projectId, milestoneId);
361
499
  assertExpectedRevision(milestone, expectedRevision);
362
500
  if (milestone.status !== 'awaiting_final_acceptance' || !milestone.resultHash || !milestone.evidenceHash) {
363
501
  throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestoneId} is not ready for acceptance.`);
@@ -384,6 +522,7 @@ export class WorkflowService {
384
522
  revision: milestone.revision,
385
523
  });
386
524
  }
525
+ const delegation = this.delegatedAuthorization(identity.projectId, delegationGrantId, 'milestone.final_accept', normalizedActor, milestone);
387
526
  const event = {
388
527
  kind: 'final_acceptance',
389
528
  decision: 'approved',
@@ -395,6 +534,8 @@ export class WorkflowService {
395
534
  evidenceHash,
396
535
  headCommit: currentHead,
397
536
  reason: `Confirmed with ${gate.confirmationCode}.`,
537
+ authorizationMode: delegation ? 'delegated' : 'human',
538
+ ...(delegation ? { delegation } : {}),
398
539
  };
399
540
  return this.store.writeMilestone({
400
541
  ...milestone,
@@ -409,23 +550,93 @@ export class WorkflowService {
409
550
  if (!reason.trim())
410
551
  throw new WorkflowError('INVALID_ARGUMENT', 'Milestone cancellation requires a reason.');
411
552
  const { identity } = this.context(repository);
412
- const milestone = this.store.readMilestone(identity.projectId, milestoneId);
553
+ const milestone = this.readMilestoneCurrent(identity.projectId, milestoneId);
413
554
  assertExpectedRevision(milestone, expectedRevision);
414
555
  if (['accepted', 'cancelled'].includes(milestone.status)) {
415
556
  throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestoneId} is ${milestone.status}.`);
416
557
  }
417
558
  return this.store.writeMilestone({ ...milestone, status: 'cancelled', cancellationReason: reason.trim() }, expectedRevision);
418
559
  }
419
- setTaskPlan(repository, taskId, expectedRevision, plan) {
560
+ setTaskPlan(repository, taskId, expectedRevision, plan, correctiveAudit = null, planRiskAudit = null) {
420
561
  validatePlan(plan);
421
562
  const { identity } = this.context(repository);
422
563
  const knowledgeMap = this.assertKnowledgeMapBinding(identity.repositoryRoot, identity.projectId, plan.knowledgeMapRevision, plan.knowledgeMapHash);
423
564
  this.validateKnowledgeTargets(knowledgeMap, plan);
424
565
  const task = this.store.readTask(identity.projectId, taskId);
425
566
  assertExpectedRevision(task, expectedRevision);
426
- if (!['planning', 'awaiting_execution_authorization', 'needs_fix'].includes(task.status)) {
567
+ if (!['planning', 'awaiting_execution_authorization', 'needs_fix', 'blocked'].includes(task.status)) {
427
568
  throw new WorkflowError('TRANSITION_BLOCKED', `Cannot update Plan while Task is ${task.status}.`);
428
569
  }
570
+ const currentPlanCorrectiveDecisions = task.planHash
571
+ ? readCorrectiveDecisionEvents(this.store, task).filter((event) => event.planHash === task.planHash)
572
+ : [];
573
+ const currentReplanDecisions = currentPlanCorrectiveDecisions.filter((event) => event.decision === 'replan-required');
574
+ const currentContinueFixDecisions = currentPlanCorrectiveDecisions.filter((event) => event.decision === 'continue-fix');
575
+ const currentHardBlockingDecisions = currentPlanCorrectiveDecisions.filter((event) => event.decision === 'split-required' || event.decision === 'stop-escalate');
576
+ const currentReplanRequiredStepIds = [...new Set(currentReplanDecisions.map((event) => event.stepId))];
577
+ if (currentHardBlockingDecisions.length > 0) {
578
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} has current corrective decisions that prohibit normal plan updates.`, {
579
+ taskId: task.id,
580
+ requiredAction: 'Escalate or split the work according to the recorded corrective decision.',
581
+ decisions: currentHardBlockingDecisions.map((event) => ({
582
+ eventId: event.eventId,
583
+ stepId: event.stepId,
584
+ decision: event.decision,
585
+ })),
586
+ });
587
+ }
588
+ if (task.status === 'blocked' && currentReplanDecisions.length === 0) {
589
+ throw new WorkflowError('TRANSITION_BLOCKED', `Cannot update Plan while Task ${task.id} is blocked unless the current Plan already carries a replan-required corrective decision.`, {
590
+ taskId: task.id,
591
+ status: task.status,
592
+ requiredAction: 'Resolve the blocked strict review or record a replan-required corrective decision first.',
593
+ });
594
+ }
595
+ if (currentContinueFixDecisions.length > 0) {
596
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} already has a current continue-fix corrective decision; use the current Plan retry path instead of issuing another plan-set.`, {
597
+ taskId: task.id,
598
+ requiredAction: 'Run the guarded Step under the current Plan retry path, or escalate/cancel instead of issuing another plan-set at the same ordinal.',
599
+ decisions: currentContinueFixDecisions.map((event) => ({
600
+ eventId: event.eventId,
601
+ stepId: event.stepId,
602
+ triggeringAttemptCount: event.triggeringAttemptCount,
603
+ })),
604
+ });
605
+ }
606
+ const currentPlanRiskAudit = task.status === 'needs_fix'
607
+ ? readCurrentPlanRiskAudit(this.store, task)
608
+ : null;
609
+ const failedReviewAttempts = countFailedReviewAttempts(this.store.taskRoot(identity.projectId, taskId));
610
+ if (task.status === 'needs_fix' && task.review.status === 'failed' && failedReviewAttempts >= 2) {
611
+ validateCorrectivePlanAudit(task, correctiveAudit, failedReviewAttempts);
612
+ }
613
+ const guardedRemediationGate = task.status === 'needs_fix' && currentPlanRiskAudit
614
+ ? findFailedGuardedStepRequiringCorrectiveDecision(this.store, task, currentPlanRiskAudit)
615
+ : null;
616
+ if (guardedRemediationGate && currentReplanDecisions.length === 0 && !correctiveAudit) {
617
+ throw new WorkflowError('TRANSITION_BLOCKED', `Guarded Step ${guardedRemediationGate.stepId} requires a corrective decision before another remediation run can be authorized.`, {
618
+ taskId: task.id,
619
+ stepId: guardedRemediationGate.stepId,
620
+ attemptCount: guardedRemediationGate.attemptCount,
621
+ requiredSidecar: 'corrective-decisions.jsonl',
622
+ allowedDecision: 'continue-fix',
623
+ escalationDecisions: ['replan-required', 'split-required', 'stop-escalate'],
624
+ });
625
+ }
626
+ if (currentReplanDecisions.length > 0 && correctiveAudit) {
627
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} already has a current replan-required corrective decision; submit the replacement Plan without recording another decision in plan-set.`, {
628
+ taskId: task.id,
629
+ decision: correctiveAudit.decision,
630
+ requiredAction: 'Retry task plan-set without corrective-audit input; the recorded replan-required decision is the authorization for this recovery path.',
631
+ });
632
+ }
633
+ if (guardedRemediationGate && currentReplanDecisions.length === 0 && correctiveAudit?.decision !== 'continue-fix') {
634
+ throw new WorkflowError('TRANSITION_BLOCKED', `Guarded Step ${guardedRemediationGate.stepId} requires a separate corrective-decision record before execution may continue; plan-set only supports continue-fix.`, {
635
+ taskId: task.id,
636
+ stepId: guardedRemediationGate.stepId,
637
+ decision: correctiveAudit?.decision ?? null,
638
+ });
639
+ }
429
640
  for (const requirement of task.requirements) {
430
641
  if (!plan.requirements.includes(requirement)) {
431
642
  throw new WorkflowError('TRANSITION_BLOCKED', `Plan omits Task requirement ${requirement}.`);
@@ -454,7 +665,24 @@ export class WorkflowService {
454
665
  plannedSteps.every((step) => step.status === 'completed')) {
455
666
  throw new WorkflowError('TRANSITION_BLOCKED', 'A failed review requires at least one new or changed remediation Step.');
456
667
  }
457
- const saved = this.store.writeTask({
668
+ if (currentReplanRequiredStepIds.some((stepId) => !plannedSteps.some((step) => step.id === stepId))) {
669
+ throw new WorkflowError('TRANSITION_BLOCKED', `Replacement Plan under replan-required must retain every guarded Step targeted by the current corrective decision; removing or renaming one would reset remediation history.`, {
670
+ taskId: task.id,
671
+ requiredStepIds: currentReplanRequiredStepIds,
672
+ plannedStepIds: plannedSteps.map((step) => step.id),
673
+ });
674
+ }
675
+ if (guardedRemediationGate && currentReplanDecisions.length === 0 && !plannedSteps.some((step) => step.id === guardedRemediationGate.stepId)) {
676
+ throw new WorkflowError('TRANSITION_BLOCKED', `Continue-fix must retain guarded Step ${guardedRemediationGate.stepId}; removing or renaming it would reset the remediation breaker.`, {
677
+ taskId: task.id,
678
+ stepId: guardedRemediationGate.stepId,
679
+ });
680
+ }
681
+ if (correctiveAudit && guardedRemediationGate && currentReplanDecisions.length === 0 && currentPlanRiskAudit) {
682
+ assertCorrectiveAuditorIndependence(this.store, task, guardedRemediationGate.stepId, correctiveAudit.auditor);
683
+ appendCorrectiveDecisionEvent(this.store, task, guardedRemediationGate.stepId, correctiveAudit, currentPlanRiskAudit, stagedPlan.hash, this.now());
684
+ }
685
+ const candidate = {
458
686
  ...task,
459
687
  status: 'awaiting_execution_authorization',
460
688
  planHash: stagedPlan.hash,
@@ -474,8 +702,16 @@ export class WorkflowService {
474
702
  resultHash: null,
475
703
  evidenceHash: null,
476
704
  blockReason: null,
477
- }, expectedRevision);
705
+ };
706
+ if (planRiskAudit)
707
+ validatePlanRiskAuditCandidate(this.store, candidate, planRiskAudit);
708
+ const saved = this.store.writeTask(candidate, expectedRevision);
478
709
  this.store.publishArtifact(stagedPlan);
710
+ if (planRiskAudit)
711
+ appendCurrentPlanRiskAudit(this.store, saved, planRiskAudit, this.now());
712
+ if (correctiveAudit && task.status === 'needs_fix' && task.review.status === 'failed') {
713
+ appendCorrectivePlanAudit(this.store.taskRoot(identity.projectId, taskId), task, saved, failedReviewAttempts, correctiveAudit, this.now());
714
+ }
479
715
  return saved;
480
716
  }
481
717
  rebindTaskKnowledge(repository, taskId, expectedRevision) {
@@ -559,8 +795,62 @@ export class WorkflowService {
559
795
  this.store.publishArtifact(stagedPlan);
560
796
  return saved;
561
797
  }
562
- authorizeTask(repository, taskId, expectedRevision, actor, reason = '') {
798
+ refreshTaskContext(repository, taskId, expectedTaskRevision, expectedKnowledgeMapRevision, actor, delegationGrantId) {
799
+ if (!delegationGrantId) {
800
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Task context refresh combines approval transitions and therefore requires an explicit delegated approval grant.');
801
+ }
802
+ const { identity } = this.context(repository);
803
+ const task = this.store.readTask(identity.projectId, taskId);
804
+ const currentMap = this.store.readKnowledgeMap(identity.projectId);
805
+ assertExpectedRevision(task, expectedTaskRevision);
806
+ assertExpectedRevision(currentMap, expectedKnowledgeMapRevision);
807
+ if (!taskCanRebindKnowledge(task)) {
808
+ throw new WorkflowError('TRANSITION_BLOCKED', `Cannot refresh Task context while Task is ${task.status}.`);
809
+ }
810
+ const activeStep = task.steps.find((step) => step.status === 'in_progress');
811
+ if (activeStep) {
812
+ throw new WorkflowError('TRANSITION_BLOCKED', `Cannot refresh Task context while ${activeStep.id} is in progress.`);
813
+ }
814
+ const inspectedMap = inspectKnowledgeMap(currentMap, scanProjectKnowledge(identity.repositoryRoot, identity.projectId, this.now()));
815
+ const transitions = [];
816
+ const currentPlanRiskAudit = readCurrentPlanRiskAudit(this.store, task);
817
+ // Validate every delegated transition before the first write so a narrow grant cannot
818
+ // leave the composite operation half-applied.
819
+ this.delegatedAuthorization(identity.projectId, delegationGrantId, 'task.execution_authorize', actor, task);
820
+ let knowledgeMap = currentMap;
821
+ if (inspectedMap.status !== 'active' || !inspectedMap.approval) {
822
+ if (currentMap.status !== 'active' || !currentMap.approval || inspectedMap.status !== 'stale') {
823
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Automatic Task context refresh requires a previously approved map with content-only drift.', { currentStatus: currentMap.status, inspectedStatus: inspectedMap.status });
824
+ }
825
+ assertContentOnlyKnowledgeRefresh(currentMap, inspectedMap);
826
+ this.delegatedAuthorization(identity.projectId, delegationGrantId, 'project_memory.approve', actor, inspectedMap);
827
+ knowledgeMap = this.reconcileKnowledgeMap(repository, currentMap.revision);
828
+ transitions.push('project-memory reconcile');
829
+ knowledgeMap = this.approveKnowledgeMap(repository, knowledgeMap.revision, [], actor, delegationGrantId);
830
+ transitions.push('project-memory approve');
831
+ }
832
+ const knowledgeMapHash = hashKnowledgeMap(knowledgeMap);
833
+ if (task.knowledgeMapRevision === knowledgeMap.revision
834
+ && task.knowledgeMapHash === knowledgeMapHash) {
835
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Task context is already current; refresh is a no-op.');
836
+ }
837
+ let refreshedTask = this.rebindTaskKnowledge(repository, taskId, task.revision);
838
+ if (currentPlanRiskAudit) {
839
+ appendReboundPlanRiskAudit(this.store, task, refreshedTask, currentPlanRiskAudit, this.now());
840
+ }
841
+ transitions.push('task knowledge-rebind');
842
+ refreshedTask = this.authorizeTask(repository, taskId, refreshedTask.revision, actor, 'Delegated content-only context refresh.', delegationGrantId);
843
+ transitions.push('task execution authorize');
844
+ return {
845
+ mode: 'delegated-content-only',
846
+ transitions,
847
+ knowledgeMap,
848
+ task: refreshedTask,
849
+ };
850
+ }
851
+ authorizeTask(repository, taskId, expectedRevision, actor, reason = '', delegationGrantId = null) {
563
852
  const { identity } = this.context(repository);
853
+ this.readCurrentAdoptionPostureStrict(identity.projectId);
564
854
  const task = this.store.readTask(identity.projectId, taskId);
565
855
  assertExpectedRevision(task, expectedRevision);
566
856
  if (task.status !== 'awaiting_execution_authorization' || !task.planHash) {
@@ -570,6 +860,8 @@ export class WorkflowService {
570
860
  const root = this.store.taskRoot(identity.projectId, taskId);
571
861
  const briefHash = this.store.hashArtifact(root, 'brief.md');
572
862
  const planHash = this.store.hashArtifact(root, 'plan.md');
863
+ readCurrentPlanRiskAudit(this.store, task, { requireCurrentTaskRevision: true });
864
+ const delegation = this.delegatedAuthorization(identity.projectId, delegationGrantId, 'task.execution_authorize', actor, task);
573
865
  const event = {
574
866
  kind: 'execution',
575
867
  decision: 'approved',
@@ -581,18 +873,169 @@ export class WorkflowService {
581
873
  evidenceHash: null,
582
874
  headCommit: null,
583
875
  reason,
876
+ authorizationMode: delegation ? 'delegated' : 'human',
877
+ ...(delegation ? { delegation } : {}),
584
878
  };
585
879
  return this.store.writeTask({ ...task, status: 'ready', briefHash, planHash, authorizations: [...task.authorizations, event] }, expectedRevision);
586
880
  }
881
+ recordPlanRiskAudit(repository, taskId, expectedRevision, input) {
882
+ const { identity } = this.context(repository);
883
+ this.readCurrentAdoptionPostureStrict(identity.projectId);
884
+ const task = this.store.readTask(identity.projectId, taskId);
885
+ assertExpectedRevision(task, expectedRevision);
886
+ this.assertTaskKnowledgeBinding(identity.repositoryRoot, task);
887
+ return appendCurrentPlanRiskAudit(this.store, task, input, this.now());
888
+ }
889
+ showTaskHandoff(repository, taskId) {
890
+ const { identity } = this.context(repository);
891
+ const task = this.store.readTask(identity.projectId, taskId);
892
+ return {
893
+ task,
894
+ posture: readTaskC1Posture(this.store, task),
895
+ claimTokenAvailable: false,
896
+ };
897
+ }
898
+ handoffTask(repository, taskId, expectedRevision, actor, targetActor, reason, writerToken = null, grantId = null, expiresAt = null) {
899
+ const context = this.context(repository);
900
+ const task = this.store.readTask(context.identity.projectId, taskId);
901
+ assertExpectedRevision(task, expectedRevision);
902
+ const normalizedActor = actor.trim();
903
+ if (!normalizedActor) {
904
+ throw new WorkflowError('INVALID_ARGUMENT', 'Handoff actor is required.');
905
+ }
906
+ if (grantId)
907
+ this.assertHandoffGrant(context.identity.projectId, task, targetActor, grantId);
908
+ const milestone = task.milestoneId ? this.readMilestoneCurrent(task.projectId, task.milestoneId) : null;
909
+ const milestones = milestone ? this.listMilestonesCurrent(task.projectId) : [];
910
+ const milestoneDisplayNumber = milestone
911
+ ? milestones.findIndex((candidate) => candidate.id === milestone.id) + 1
912
+ : null;
913
+ const taskDisplayNumber = milestone
914
+ ? milestone.memberships.findIndex((membership) => membership.taskId === task.id) + 1
915
+ : null;
916
+ const currentLease = context.locks.inspect(taskId);
917
+ let lease;
918
+ let acquiredHere = false;
919
+ if (currentLease) {
920
+ if (currentLease.owner !== normalizedActor) {
921
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} active writer lease belongs to a different actor.`, {
922
+ taskId,
923
+ leaseOwner: currentLease.owner,
924
+ actor: normalizedActor,
925
+ });
926
+ }
927
+ if (writerToken === null) {
928
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} handoff requires the active writer token while a lease is present.`, {
929
+ taskId,
930
+ leaseOwner: currentLease.owner,
931
+ expiresAt: currentLease.expiresAt,
932
+ });
933
+ }
934
+ lease = context.locks.heartbeat(taskId, writerToken);
935
+ }
936
+ else {
937
+ if (writerToken !== null) {
938
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} has no active writer lease for the provided writer token.`, {
939
+ taskId,
940
+ });
941
+ }
942
+ acquiredHere = true;
943
+ lease = context.locks.acquire(taskId, normalizedActor);
944
+ }
945
+ const releaseLease = () => context.locks.release(taskId, lease.token);
946
+ try {
947
+ const prepared = appendTaskHandoff(this.store, task, {
948
+ actor: normalizedActor,
949
+ targetActor,
950
+ reason,
951
+ writerToken: lease.token,
952
+ milestoneDisplayNumber,
953
+ taskDisplayNumber,
954
+ expectedNext: String(nextForTask(task).action ?? 'task show'),
955
+ grantId,
956
+ expiresAt: expiresAt ?? new Date(this.now().getTime() + 24 * 60 * 60 * 1000).toISOString(),
957
+ }, this.now());
958
+ releaseLease();
959
+ return { task, event: prepared.event, bundle: prepared.bundle };
960
+ }
961
+ catch (error) {
962
+ if (acquiredHere)
963
+ releaseLease();
964
+ throw error;
965
+ }
966
+ }
967
+ claimTask(repository, taskId, expectedRevision, actor, claimToken, writerToken = null) {
968
+ const context = this.context(repository);
969
+ const task = this.store.readTask(context.identity.projectId, taskId);
970
+ assertExpectedRevision(task, expectedRevision);
971
+ const normalizedActor = actor.trim();
972
+ if (!normalizedActor) {
973
+ throw new WorkflowError('INVALID_ARGUMENT', 'Claim actor is required.');
974
+ }
975
+ const pendingHandoff = readTaskC1Posture(this.store, task);
976
+ if (pendingHandoff.state === 'pending' && pendingHandoff.latestEvent.grantId) {
977
+ this.assertHandoffGrant(context.identity.projectId, task, normalizedActor, pendingHandoff.latestEvent.grantId);
978
+ }
979
+ const existingLease = context.locks.inspect(taskId);
980
+ let lease;
981
+ let acquiredHere = false;
982
+ if (existingLease) {
983
+ if (existingLease.owner !== normalizedActor) {
984
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} existing writer lease belongs to a different actor.`, {
985
+ taskId,
986
+ leaseOwner: existingLease.owner,
987
+ actor: normalizedActor,
988
+ });
989
+ }
990
+ if (writerToken === null) {
991
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} claim requires the existing writer token.`, {
992
+ taskId,
993
+ leaseOwner: existingLease.owner,
994
+ });
995
+ }
996
+ lease = context.locks.heartbeat(taskId, writerToken);
997
+ }
998
+ else {
999
+ if (writerToken !== null) {
1000
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} has no active writer lease for the provided writer token.`, {
1001
+ taskId,
1002
+ });
1003
+ }
1004
+ acquiredHere = true;
1005
+ lease = context.locks.acquire(taskId, normalizedActor);
1006
+ }
1007
+ try {
1008
+ const event = appendTaskClaim(this.store, task, actor, claimToken, lease.token, this.now());
1009
+ return { task, event, lease };
1010
+ }
1011
+ catch (error) {
1012
+ if (acquiredHere)
1013
+ context.locks.release(taskId, lease.token);
1014
+ throw error;
1015
+ }
1016
+ }
1017
+ handbackTask(repository, taskId, expectedRevision, actor, reason, writerToken = null, limitations = [], recommendedNext = 'Coordinator reviews the terminal Task result and recorded evidence.') {
1018
+ const context = this.context(repository);
1019
+ const task = this.store.readTask(context.identity.projectId, taskId);
1020
+ assertExpectedRevision(task, expectedRevision);
1021
+ const lease = task.status === 'merged' || task.status === 'cancelled'
1022
+ ? context.locks.inspect(taskId)
1023
+ : this.proveWriterLeaseTokenForHandoff(context, taskId, writerToken);
1024
+ const event = appendTaskHandback(this.store, task, actor, reason, lease?.token ?? writerToken, limitations, recommendedNext, this.now());
1025
+ if (lease)
1026
+ context.locks.release(taskId, lease.token);
1027
+ return { task, event };
1028
+ }
587
1029
  startTask(repository, taskId, expectedRevision, workspaceOwner) {
588
1030
  const { identity } = this.context(repository);
589
1031
  const task = this.store.readTask(identity.projectId, taskId);
590
1032
  assertExpectedRevision(task, expectedRevision);
1033
+ this.requireExecutionAdoptionPosture(identity, taskId);
591
1034
  if (task.status !== 'ready')
592
1035
  throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} is ${task.status}.`);
593
1036
  this.assertExecutionAuthorizationFresh(task);
594
1037
  if (task.milestoneId) {
595
- const milestone = this.store.readMilestone(identity.projectId, task.milestoneId);
1038
+ const milestone = this.readMilestoneCurrent(identity.projectId, task.milestoneId);
596
1039
  const membership = milestone.memberships.find((candidate) => candidate.taskId === taskId);
597
1040
  if (milestone.status !== 'active' || membership?.disposition !== 'required') {
598
1041
  throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone-linked Task requires an active, authorized membership.', {
@@ -601,6 +1044,15 @@ export class WorkflowService {
601
1044
  disposition: membership?.disposition ?? null,
602
1045
  });
603
1046
  }
1047
+ const handoffPosture = readTaskC1Posture(this.store, task);
1048
+ if (handoffPosture.state !== 'claimed') {
1049
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone-linked Task start requires a prepared and claimed C1 handoff bundle.', {
1050
+ taskId: task.id,
1051
+ milestoneId: milestone.id,
1052
+ c1Posture: handoffPosture.state,
1053
+ requiredAction: handoffPosture.state === 'pending' ? 'Claim the prepared handoff.' : 'Prepare and claim a C1 handoff.',
1054
+ });
1055
+ }
604
1056
  }
605
1057
  if (!isClean(identity.repositoryRoot)) {
606
1058
  throw new WorkflowError('GIT_PRECONDITION_FAILED', 'Task start requires a clean checkout.', {
@@ -626,20 +1078,48 @@ export class WorkflowService {
626
1078
  }
627
1079
  return this.store.writeTask({ ...task, baseCommit, taskBranch, workspaceOwner, blockReason: null }, expectedRevision);
628
1080
  }
629
- runStep(repository, taskId, stepId, expectedRevision, owner, writerToken) {
1081
+ runStep(repository, taskId, stepId, expectedRevision, actor, writerToken) {
630
1082
  const context = this.context(repository);
631
1083
  const task = this.store.readTask(context.identity.projectId, taskId);
632
1084
  assertExpectedRevision(task, expectedRevision);
1085
+ this.requireExecutionAdoptionPosture(context.identity, taskId);
633
1086
  if (!['ready', 'in_progress', 'needs_fix'].includes(task.status)) {
634
1087
  throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} cannot run a Step while ${task.status}.`);
635
1088
  }
636
1089
  this.assertExecutionAuthorizationFresh(task);
637
1090
  const knowledgeMap = this.assertTaskKnowledgeBinding(context.identity.repositoryRoot, task);
1091
+ const currentPlanBlockingDecisions = readCorrectiveDecisionEvents(this.store, task)
1092
+ .filter((event) => event.planHash === task.planHash && event.decision !== 'continue-fix');
1093
+ if (currentPlanBlockingDecisions.length > 0) {
1094
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} cannot run any Step while the current Plan has active blocking corrective decisions.`, {
1095
+ taskId,
1096
+ decisions: currentPlanBlockingDecisions.map((event) => ({
1097
+ eventId: event.eventId,
1098
+ stepId: event.stepId,
1099
+ decision: event.decision,
1100
+ planHash: event.planHash,
1101
+ triggeringAttemptCount: event.triggeringAttemptCount,
1102
+ })),
1103
+ });
1104
+ }
638
1105
  assertTaskBranch(context.identity.repositoryRoot, task);
639
1106
  const step = requireStep(task, stepId);
1107
+ const remediationHistoryForStep = readRemediationEvents(this.store, task).filter((event) => event.stepId === stepId);
640
1108
  if (!['planned', 'failed'].includes(step.status)) {
641
1109
  throw new WorkflowError('TRANSITION_BLOCKED', `${stepId} cannot start from ${step.status}.`);
642
1110
  }
1111
+ const planRiskAudit = readCurrentPlanRiskAudit(this.store, task);
1112
+ if (remediationHistoryForStep.length > 0 && (!planRiskAudit || !isStepReviewRequired(task, stepId, planRiskAudit.reviewRequiredStepIds))) {
1113
+ throw new WorkflowError('TRANSITION_BLOCKED', `Step ${stepId} has remediation history but is no longer marked for guarded review by the current Plan Risk Audit; execution is blocked until the audit and Step posture are reconciled.`, {
1114
+ taskId,
1115
+ stepId,
1116
+ remediationAttemptCount: remediationHistoryForStep.length,
1117
+ currentAuditPresent: Boolean(planRiskAudit),
1118
+ });
1119
+ }
1120
+ if (planRiskAudit && isStepReviewRequired(task, stepId, planRiskAudit.reviewRequiredStepIds)) {
1121
+ assertGuardedRemediationAttemptAllowed(this.store, task, stepId, planRiskAudit);
1122
+ }
643
1123
  const dirty = changedFiles(context.identity.repositoryRoot);
644
1124
  if (step.status === 'planned' && dirty.length > 0) {
645
1125
  throw new WorkflowError('GIT_PRECONDITION_FAILED', `${stepId} must start from a clean checkout.`, {
@@ -658,18 +1138,41 @@ export class WorkflowService {
658
1138
  incompleteDependencies,
659
1139
  });
660
1140
  }
1141
+ const handoffPosture = readTaskC1Posture(this.store, task);
1142
+ if (handoffPosture.state === 'pending') {
1143
+ this.assertTaskMutationAllowedByHandoff(task, actor, null);
1144
+ }
661
1145
  const existing = context.locks.inspect(taskId);
662
1146
  let acquiredHere = false;
663
- const lease = existing
664
- ? writerToken
665
- ? context.locks.heartbeat(taskId, writerToken)
1147
+ let lease;
1148
+ if (handoffPosture.state === 'claimed') {
1149
+ if (!existing) {
1150
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} claimed C1 posture requires an active writer lease before task run.`, {
1151
+ taskId,
1152
+ claimant: handoffPosture.claimant,
1153
+ });
1154
+ }
1155
+ if (!writerToken) {
1156
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} claimed C1 posture requires the bound writer token for task run.`, {
1157
+ taskId,
1158
+ claimant: handoffPosture.claimant,
1159
+ });
1160
+ }
1161
+ lease = context.locks.heartbeat(taskId, writerToken);
1162
+ this.assertTaskMutationAllowedByHandoff(task, actor, lease.token);
1163
+ }
1164
+ else {
1165
+ lease = existing
1166
+ ? writerToken
1167
+ ? context.locks.heartbeat(taskId, writerToken)
1168
+ : (() => {
1169
+ throw new WorkflowError('LOCKED', `${taskId} already has a writer.`, { owner: existing.owner });
1170
+ })()
666
1171
  : (() => {
667
- throw new WorkflowError('LOCKED', `${taskId} already has a writer.`, { owner: existing.owner });
668
- })()
669
- : (() => {
670
- acquiredHere = true;
671
- return context.locks.acquire(taskId, owner.trim() || 'worker');
672
- })();
1172
+ acquiredHere = true;
1173
+ return context.locks.acquire(taskId, actor.trim() || 'worker');
1174
+ })();
1175
+ }
673
1176
  const steps = task.steps.map((candidate) => candidate.id === stepId ? { ...candidate, status: 'in_progress' } : candidate);
674
1177
  let saved;
675
1178
  try {
@@ -687,7 +1190,7 @@ export class WorkflowService {
687
1190
  protocolVersion: PROTOCOL_VERSION,
688
1191
  packageVersion: PACKAGE_VERSION,
689
1192
  role: 'worker',
690
- objective: step.objective,
1193
+ objective: `${step.objective} Leave all Step changes uncommitted; Workflow core creates the atomic commit through task step-complete.`,
691
1194
  inputs: [
692
1195
  path.join(this.store.taskRoot(task.projectId, task.id), 'brief.md'),
693
1196
  path.join(this.store.taskRoot(task.projectId, task.id), 'plan.md'),
@@ -695,7 +1198,10 @@ export class WorkflowService {
695
1198
  allowedReads: [identityRelative(context.identity.repositoryRoot, '.')],
696
1199
  allowedWrites: step.allowedWrites,
697
1200
  commands: step.checks,
698
- forbiddenScope: step.forbiddenScope,
1201
+ forbiddenScope: [
1202
+ ...step.forbiddenScope,
1203
+ 'Manual Git staging, commits, amendments, resets, rebases, or other history mutations. Workflow core owns the Step commit.',
1204
+ ],
699
1205
  resultSchema: {
700
1206
  type: 'object',
701
1207
  required: ['summary', 'changedFiles', 'checks'],
@@ -709,6 +1215,7 @@ export class WorkflowService {
709
1215
  'Required change falls outside allowedWrites.',
710
1216
  'Implementation requires changing Brief, Plan, or acceptance.',
711
1217
  'A configured check cannot be run or interpreted safely.',
1218
+ 'Do not run git add or git commit. Return uncommitted changes to the coordinator, which must invoke task step-complete.',
712
1219
  ],
713
1220
  contextBudget: 'medium',
714
1221
  taskId,
@@ -727,6 +1234,14 @@ export class WorkflowService {
727
1234
  ? this.requireActiveKnowledgeMap(identity.repositoryRoot, identity.projectId)
728
1235
  : this.assertTaskKnowledgeBinding(identity.repositoryRoot, task);
729
1236
  const root = this.store.taskRoot(task.projectId, task.id);
1237
+ const planRiskAudit = task.planHash && !['planning', 'merged', 'cancelled'].includes(task.status)
1238
+ ? readCurrentPlanRiskAudit(this.store, task)
1239
+ : null;
1240
+ const planRiskReviewScope = planRiskAudit
1241
+ ? planRiskAudit.reviewRequiredStepIds.length > 0
1242
+ ? ` Current Plan Risk Audit marks ${planRiskAudit.reviewRequiredStepIds.join(', ')} for strict review.`
1243
+ : ' Current Plan Risk Audit marks no Steps for strict review.'
1244
+ : '';
730
1245
  const common = {
731
1246
  protocolVersion: PROTOCOL_VERSION,
732
1247
  packageVersion: PACKAGE_VERSION,
@@ -748,17 +1263,25 @@ export class WorkflowService {
748
1263
  if (role === 'technical-planner') {
749
1264
  return {
750
1265
  ...common,
751
- objective: `Produce an execution-ready Plan for ${task.title}.`,
1266
+ objective: `Produce an execution-ready Plan for ${task.title}. Perform an explicit risk review before plan-set: migrations, concurrency, restart/replay, crash windows, and external-provider behavior require failure-specific checks in the original Plan.${planRiskReviewScope}`,
752
1267
  resultSchema: {
753
1268
  type: 'object',
754
1269
  required: ['objective', 'requirements', 'acceptance', 'risks', 'steps'],
755
1270
  },
1271
+ stopConditions: [
1272
+ ...common.stopConditions,
1273
+ 'A migration is planned without a populated forward-upgrade check.',
1274
+ 'Concurrency or cursor/state ownership is planned without deterministic race checks.',
1275
+ 'Restart, replay, or crash recovery is claimed without restart/crash-window evidence.',
1276
+ 'An external provider is used without failure-injection and hidden-default verification.',
1277
+ 'The safe risk slice crosses persistence, recovery orchestration, and executable composition and should be split.',
1278
+ ],
756
1279
  };
757
1280
  }
758
1281
  if (role === 'independent-reviewer') {
759
1282
  return {
760
1283
  ...common,
761
- objective: `Review ${task.title} against Brief, Plan, diff, checks, and evidence without modifying files.`,
1284
+ objective: `Review ${task.title} against Brief, Plan, diff, checks, and evidence without modifying files.${planRiskReviewScope}`,
762
1285
  inputs: [...common.inputs, ...(task.evidenceHash ? [path.join(root, 'evidence.json')] : [])],
763
1286
  resultSchema: {
764
1287
  type: 'object',
@@ -768,28 +1291,65 @@ export class WorkflowService {
768
1291
  ...common.stopConditions,
769
1292
  'The checkout is dirty or the evidence does not identify the reviewed commit.',
770
1293
  'Read-only reviewer isolation cannot be established; return unverified.',
1294
+ 'High-risk behavior lacks populated-upgrade, race, restart/replay, crash-window, or provider-failure evidence required by the Plan.',
771
1295
  ],
772
1296
  };
773
1297
  }
774
1298
  return {
775
1299
  ...common,
776
- objective: `Coordinate the next valid state transition for ${task.title}.`,
1300
+ objective: `Coordinate the next valid state transition for ${task.title}.${planRiskReviewScope}`,
777
1301
  resultSchema: { type: 'object', required: ['recommendedAction', 'reason'] },
778
1302
  };
779
1303
  }
780
- completeStep(repository, taskId, stepId, expectedRevision, writerToken, notes) {
1304
+ completeStep(repository, taskId, stepId, expectedRevision, writerToken, notes, actor) {
781
1305
  const context = this.context(repository);
782
- context.locks.heartbeat(taskId, writerToken);
1306
+ const lease = context.locks.heartbeat(taskId, writerToken);
1307
+ this.readCurrentAdoptionPostureStrict(context.identity.projectId);
783
1308
  const task = this.store.readTask(context.identity.projectId, taskId);
784
1309
  assertExpectedRevision(task, expectedRevision);
1310
+ this.assertTaskMutationAllowedByHandoff(task, actor ?? null, lease.token);
785
1311
  assertTaskBranch(context.identity.repositoryRoot, task);
786
1312
  const step = requireStep(task, stepId);
787
1313
  if (task.status !== 'in_progress' || step.status !== 'in_progress') {
788
1314
  throw new WorkflowError('TRANSITION_BLOCKED', `${stepId} is not in progress.`);
789
1315
  }
1316
+ const planRiskAudit = readCurrentPlanRiskAudit(this.store, task);
1317
+ const reviewRequired = planRiskAudit
1318
+ ? isStepReviewRequired(task, stepId, planRiskAudit.reviewRequiredStepIds)
1319
+ : false;
1320
+ if (reviewRequired && step.evidence) {
1321
+ validateTaskHistory(context.identity.repositoryRoot, task);
1322
+ if (!isClean(context.identity.repositoryRoot)) {
1323
+ throw new WorkflowError('GIT_PRECONDITION_FAILED', 'Strict Step Review recovery requires a clean Task checkout.', {
1324
+ changedFiles: changedFiles(context.identity.repositoryRoot),
1325
+ });
1326
+ }
1327
+ const currentHead = headCommit(context.identity.repositoryRoot);
1328
+ if (currentHead !== step.evidence.commitSha) {
1329
+ throw new WorkflowError('GIT_PRECONDITION_FAILED', 'Strict Step Review recovery requires HEAD to remain at the recorded completion commit.', {
1330
+ stepId,
1331
+ expectedCommit: step.evidence.commitSha,
1332
+ actualCommit: currentHead,
1333
+ });
1334
+ }
1335
+ const currentCycle = buildCurrentStrictStepReviewCycle(this.store, task, stepId);
1336
+ if (!currentCycle.pendingEvent && !currentCycle.latestMatchingEvent) {
1337
+ ensurePendingStrictStepReview(this.store, task, stepId, this.now());
1338
+ }
1339
+ return task;
1340
+ }
1341
+ validateTaskHistory(context.identity.repositoryRoot, task);
790
1342
  const checks = runChecks(context.identity.repositoryRoot, step.checks);
791
1343
  const failed = checks.filter((check) => !check.passed);
792
1344
  if (failed.length > 0) {
1345
+ if (reviewRequired && planRiskAudit) {
1346
+ appendRemediationEvent(this.store, task, stepId, 'checks-failed', {
1347
+ completionCommit: headCommit(context.identity.repositoryRoot),
1348
+ checksHash: sha256Hex(canonicalJsonStringify(checks)),
1349
+ reviewEventHash: null,
1350
+ reviewerAttestationHash: null,
1351
+ }, planRiskAudit, this.now());
1352
+ }
793
1353
  const steps = task.steps.map((candidate) => candidate.id === stepId ? { ...candidate, status: 'failed' } : candidate);
794
1354
  return this.store.writeTask({ ...task, status: 'needs_fix', steps, blockReason: `${stepId} checks failed.` }, expectedRevision);
795
1355
  }
@@ -801,6 +1361,18 @@ export class WorkflowService {
801
1361
  checks,
802
1362
  notes,
803
1363
  };
1364
+ if (reviewRequired) {
1365
+ const saved = this.store.writeTask({
1366
+ ...task,
1367
+ status: 'in_progress',
1368
+ steps: task.steps.map((candidate) => candidate.id === stepId
1369
+ ? { ...candidate, status: 'in_progress', evidence }
1370
+ : candidate),
1371
+ blockReason: null,
1372
+ }, expectedRevision);
1373
+ ensurePendingStrictStepReview(this.store, saved, stepId, this.now());
1374
+ return saved;
1375
+ }
804
1376
  const completedIds = new Set([
805
1377
  ...task.steps.filter((candidate) => candidate.status === 'completed').map((candidate) => candidate.id),
806
1378
  stepId,
@@ -815,11 +1387,111 @@ export class WorkflowService {
815
1387
  });
816
1388
  return this.store.writeTask({ ...task, status: 'in_progress', steps, blockReason: null }, expectedRevision);
817
1389
  }
818
- submitTask(repository, taskId, expectedRevision, writerToken) {
1390
+ reviewStep(repository, taskId, stepId, expectedRevision, runner, actor, writerToken = null) {
819
1391
  const context = this.context(repository);
820
- context.locks.heartbeat(taskId, writerToken);
1392
+ this.readCurrentAdoptionPostureStrict(context.identity.projectId);
821
1393
  const task = this.store.readTask(context.identity.projectId, taskId);
822
1394
  assertExpectedRevision(task, expectedRevision);
1395
+ this.proveOptionalWriterLeaseToken(context, taskId, writerToken);
1396
+ this.assertTaskMutationAllowedByHandoff(task, actor ?? null, writerToken);
1397
+ if (task.status !== 'in_progress' && task.status !== 'blocked') {
1398
+ throw new WorkflowError('TRANSITION_BLOCKED', `Strict Step Review cannot run while task ${taskId} is ${task.status}.`);
1399
+ }
1400
+ assertTaskBranch(context.identity.repositoryRoot, task);
1401
+ this.assertTaskKnowledgeBinding(context.identity.repositoryRoot, task);
1402
+ const planRiskAudit = readCurrentPlanRiskAudit(this.store, task);
1403
+ if (!planRiskAudit || !isStepReviewRequired(task, stepId, planRiskAudit.reviewRequiredStepIds)) {
1404
+ throw new WorkflowError('TRANSITION_BLOCKED', `Step ${stepId} is not marked for strict review by the current Plan Risk Audit.`, {
1405
+ taskId: task.id,
1406
+ stepId,
1407
+ });
1408
+ }
1409
+ const step = requireStep(task, stepId);
1410
+ if (step.status !== 'in_progress' || !step.evidence) {
1411
+ throw new WorkflowError('TRANSITION_BLOCKED', `Strict Step Review requires ${stepId} to remain in progress with canonical evidence.`, {
1412
+ taskId: task.id,
1413
+ stepId,
1414
+ stepStatus: step.status,
1415
+ });
1416
+ }
1417
+ validateTaskHistory(context.identity.repositoryRoot, task);
1418
+ if (!isClean(context.identity.repositoryRoot)) {
1419
+ throw new WorkflowError('GIT_PRECONDITION_FAILED', 'Strict Step Review requires a clean Task checkout.', {
1420
+ changedFiles: changedFiles(context.identity.repositoryRoot),
1421
+ });
1422
+ }
1423
+ const currentHead = headCommit(context.identity.repositoryRoot);
1424
+ if (currentHead !== step.evidence.commitSha) {
1425
+ throw new WorkflowError('GIT_PRECONDITION_FAILED', 'Strict Step Review requires HEAD to remain at the recorded completion commit.', {
1426
+ taskId: task.id,
1427
+ stepId,
1428
+ expectedCommit: step.evidence.commitSha,
1429
+ actualCommit: currentHead,
1430
+ });
1431
+ }
1432
+ const currentCycle = buildCurrentStrictStepReviewCycle(this.store, task, stepId);
1433
+ if (task.status === 'blocked') {
1434
+ if (currentCycle.resolution !== 'unverified') {
1435
+ throw new WorkflowError('TRANSITION_BLOCKED', `Strict Step Review retry requires ${stepId} to be blocked by an unverified prior cycle.`, {
1436
+ taskId: task.id,
1437
+ stepId,
1438
+ resolution: currentCycle.resolution,
1439
+ });
1440
+ }
1441
+ assertGuardedRemediationAttemptAllowed(this.store, task, stepId, planRiskAudit);
1442
+ }
1443
+ else if (currentCycle.resolution !== 'pending') {
1444
+ if (currentCycle.resolution === 'failed' || currentCycle.resolution === 'unverified') {
1445
+ if (!currentCycle.reviewRecordedEvent || !currentCycle.reviewerAttestationEvent) {
1446
+ throw new WorkflowError('STATE_CORRUPT', 'Strict Step Review recovery requires exact review and attestation sidecars.', {
1447
+ taskId: task.id,
1448
+ stepId,
1449
+ resolution: currentCycle.resolution,
1450
+ });
1451
+ }
1452
+ appendRemediationEvent(this.store, task, stepId, currentCycle.resolution === 'failed' ? 'review-failed' : 'review-unverified', {
1453
+ completionCommit: currentCycle.reviewRecordedEvent.completionCommit,
1454
+ checksHash: currentCycle.reviewRecordedEvent.checksHash,
1455
+ reviewEventHash: currentCycle.reviewRecordedEvent.eventHash,
1456
+ reviewerAttestationHash: currentCycle.reviewerAttestationEvent.eventHash,
1457
+ }, planRiskAudit, this.now());
1458
+ }
1459
+ return this.store.writeTask(applyStrictStepReviewResolution(task, stepId, currentCycle.resolution), expectedRevision);
1460
+ }
1461
+ ensurePendingStrictStepReview(this.store, task, stepId, this.now());
1462
+ const envelope = this.taskContext(repository, taskId, 'independent-reviewer');
1463
+ const launch = launchStrictStepReviewer(context.identity.repositoryRoot, task, this.store.taskRoot(task.projectId, task.id), envelope, stepId, step.evidence, runner);
1464
+ const recorded = recordStrictStepReview(this.store, task, stepId, {
1465
+ review: launch.review,
1466
+ reviewerOutputHash: launch.reviewerOutputHash,
1467
+ reviewedCommit: launch.reviewedCommit,
1468
+ isolationResult: launch.verifiedIsolation ? 'verified' : 'unverified',
1469
+ }, this.now());
1470
+ if (recorded.resolution === 'failed' || recorded.resolution === 'unverified') {
1471
+ const reviewRecordedEvent = recorded.reviewRecordedEvent;
1472
+ const reviewerAttestationEvent = recorded.reviewerAttestationEvent;
1473
+ if (!reviewRecordedEvent || !reviewerAttestationEvent) {
1474
+ throw new WorkflowError('STATE_CORRUPT', 'Strict Step Review remediation recording requires review and attestation sidecars.', {
1475
+ taskId: task.id,
1476
+ stepId,
1477
+ resolution: recorded.resolution,
1478
+ });
1479
+ }
1480
+ appendRemediationEvent(this.store, task, stepId, recorded.resolution === 'failed' ? 'review-failed' : 'review-unverified', {
1481
+ completionCommit: reviewRecordedEvent.completionCommit,
1482
+ checksHash: reviewRecordedEvent.checksHash,
1483
+ reviewEventHash: reviewRecordedEvent.eventHash,
1484
+ reviewerAttestationHash: reviewerAttestationEvent.eventHash,
1485
+ }, planRiskAudit, this.now());
1486
+ }
1487
+ return this.store.writeTask(applyStrictStepReviewResolution(task, stepId, recorded.resolution, launch.review.summary), expectedRevision);
1488
+ }
1489
+ submitTask(repository, taskId, expectedRevision, writerToken, actor) {
1490
+ const context = this.context(repository);
1491
+ const lease = context.locks.heartbeat(taskId, writerToken);
1492
+ const task = this.store.readTask(context.identity.projectId, taskId);
1493
+ assertExpectedRevision(task, expectedRevision);
1494
+ this.assertTaskMutationAllowedByHandoff(task, actor ?? null, lease.token);
823
1495
  assertTaskBranch(context.identity.repositoryRoot, task);
824
1496
  if (!['in_progress', 'ready'].includes(task.status)) {
825
1497
  throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} cannot be submitted while ${task.status}.`);
@@ -846,10 +1518,13 @@ export class WorkflowService {
846
1518
  this.store.publishArtifact(stagedEvidence);
847
1519
  return saved;
848
1520
  }
849
- recordReview(repository, taskId, expectedRevision, review) {
1521
+ recordReview(repository, taskId, expectedRevision, review, actor, writerToken = null) {
850
1522
  const { identity } = this.context(repository);
1523
+ this.readCurrentAdoptionPostureStrict(identity.projectId);
851
1524
  const task = this.store.readTask(identity.projectId, taskId);
852
1525
  assertExpectedRevision(task, expectedRevision);
1526
+ this.proveOptionalWriterLeaseToken(this.context(repository), taskId, writerToken);
1527
+ this.assertTaskMutationAllowedByHandoff(task, actor ?? null, writerToken);
853
1528
  const retryingUnverified = task.status === 'blocked' && task.review.status === 'unverified';
854
1529
  if (task.status !== 'validating' && !retryingUnverified) {
855
1530
  throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} is not ready for review.`);
@@ -873,7 +1548,11 @@ export class WorkflowService {
873
1548
  if (recorded.findings.length > 0) {
874
1549
  const file = path.join(this.store.taskRoot(task.projectId, task.id), 'findings.jsonl');
875
1550
  for (const finding of recorded.findings) {
876
- appendFileSync(file, `${JSON.stringify({ ...finding, recordedAt: recorded.recordedAt })}\n`, {
1551
+ appendFileSync(file, `${JSON.stringify({
1552
+ ...finding,
1553
+ reviewer: recorded.reviewer,
1554
+ recordedAt: recorded.recordedAt,
1555
+ })}\n`, {
877
1556
  encoding: 'utf8',
878
1557
  mode: 0o600,
879
1558
  });
@@ -881,6 +1560,59 @@ export class WorkflowService {
881
1560
  }
882
1561
  return saved;
883
1562
  }
1563
+ recordStepCorrectiveDecision(repository, taskId, stepId, expectedRevision, correctiveAudit, actor, writerToken = null) {
1564
+ const { identity } = this.context(repository);
1565
+ this.readCurrentAdoptionPostureStrict(identity.projectId);
1566
+ const task = this.store.readTask(identity.projectId, taskId);
1567
+ assertExpectedRevision(task, expectedRevision);
1568
+ this.proveOptionalWriterLeaseToken(this.context(repository), taskId, writerToken);
1569
+ this.assertTaskMutationAllowedByHandoff(task, actor ?? null, writerToken);
1570
+ if (task.status !== 'needs_fix' && task.status !== 'blocked') {
1571
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} cannot record a corrective decision while ${task.status}.`);
1572
+ }
1573
+ const planRiskAudit = readCurrentPlanRiskAudit(this.store, task);
1574
+ if (!planRiskAudit) {
1575
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} has no current Plan Risk Audit for corrective decision recording.`, {
1576
+ taskId,
1577
+ stepId,
1578
+ });
1579
+ }
1580
+ if (!task.planHash) {
1581
+ throw new WorkflowError('STATE_CORRUPT', `Task ${taskId} has no current Plan hash for corrective decision recording.`, {
1582
+ taskId,
1583
+ stepId,
1584
+ });
1585
+ }
1586
+ const step = requireStep(task, stepId);
1587
+ if (!isStepReviewRequired(task, stepId, planRiskAudit.reviewRequiredStepIds)) {
1588
+ throw new WorkflowError('TRANSITION_BLOCKED', `Step ${stepId} is not guarded by the current Plan Risk Audit and cannot receive a corrective decision.`, {
1589
+ taskId,
1590
+ stepId,
1591
+ });
1592
+ }
1593
+ if (task.status === 'needs_fix') {
1594
+ if (step.status !== 'failed') {
1595
+ throw new WorkflowError('TRANSITION_BLOCKED', `Corrective decision for ${stepId} requires a currently failed guarded Step while task ${taskId} is needs_fix.`, {
1596
+ taskId,
1597
+ stepId,
1598
+ stepStatus: step.status,
1599
+ });
1600
+ }
1601
+ }
1602
+ else {
1603
+ const currentCycle = buildCurrentStrictStepReviewCycle(this.store, task, stepId);
1604
+ if (step.status !== 'in_progress' || !step.evidence || currentCycle.resolution !== 'unverified') {
1605
+ throw new WorkflowError('TRANSITION_BLOCKED', `Corrective decision for ${stepId} requires a blocked unverified guarded Step with current evidence.`, {
1606
+ taskId,
1607
+ stepId,
1608
+ stepStatus: step.status,
1609
+ resolution: currentCycle.resolution,
1610
+ });
1611
+ }
1612
+ }
1613
+ assertCorrectiveAuditorIndependence(this.store, task, stepId, correctiveAudit.auditor);
1614
+ return appendCorrectiveDecisionEvent(this.store, task, stepId, correctiveAudit, planRiskAudit, task.planHash, this.now());
1615
+ }
884
1616
  setTaskResult(repository, taskId, expectedRevision, summary, limitations) {
885
1617
  const { identity } = this.context(repository);
886
1618
  const task = this.store.readTask(identity.projectId, taskId);
@@ -895,7 +1627,7 @@ export class WorkflowService {
895
1627
  this.store.publishArtifact(stagedResult);
896
1628
  return saved;
897
1629
  }
898
- acceptTask(repository, taskId, expectedRevision, actor) {
1630
+ acceptTask(repository, taskId, expectedRevision, actor, delegationGrantId = null) {
899
1631
  const { identity } = this.context(repository);
900
1632
  const task = this.store.readTask(identity.projectId, taskId);
901
1633
  assertExpectedRevision(task, expectedRevision);
@@ -910,6 +1642,7 @@ export class WorkflowService {
910
1642
  this.assertReviewFresh(identity.repositoryRoot, task);
911
1643
  const resultHash = this.store.hashArtifact(root, 'result.md');
912
1644
  const evidenceHash = this.store.hashArtifact(root, 'evidence.json');
1645
+ const delegation = this.delegatedAuthorization(identity.projectId, delegationGrantId, 'task.final_accept', actor, task);
913
1646
  const event = {
914
1647
  kind: 'final_acceptance',
915
1648
  decision: 'approved',
@@ -921,6 +1654,8 @@ export class WorkflowService {
921
1654
  evidenceHash,
922
1655
  headCommit: headCommit(identity.repositoryRoot),
923
1656
  reason: '',
1657
+ authorizationMode: delegation ? 'delegated' : 'human',
1658
+ ...(delegation ? { delegation } : {}),
924
1659
  };
925
1660
  return this.store.writeTask({
926
1661
  ...task,
@@ -982,10 +1717,12 @@ export class WorkflowService {
982
1717
  this.store.publishArtifact(stagedEvidence);
983
1718
  return saved;
984
1719
  }
985
- mergeTask(repository, taskId, expectedRevision, writerToken) {
1720
+ mergeTask(repository, taskId, expectedRevision, writerToken, actor) {
986
1721
  const context = this.context(repository);
987
1722
  const task = this.store.readTask(context.identity.projectId, taskId);
988
1723
  assertExpectedRevision(task, expectedRevision);
1724
+ const lease = context.locks.heartbeat(taskId, writerToken);
1725
+ this.assertTaskMutationAllowedByHandoff(task, actor ?? null, lease.token);
989
1726
  if (task.status !== 'merging' || task.workspaceOwner !== 'local' || !task.taskBranch || !task.baseCommit) {
990
1727
  throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} is not ready for a local merge.`);
991
1728
  }
@@ -1002,10 +1739,12 @@ export class WorkflowService {
1002
1739
  context.locks.release(taskId, writerToken);
1003
1740
  return saved;
1004
1741
  }
1005
- confirmExternalMerge(repository, taskId, expectedRevision, writerToken) {
1742
+ confirmExternalMerge(repository, taskId, expectedRevision, writerToken, actor) {
1006
1743
  const context = this.context(repository);
1007
1744
  const task = this.store.readTask(context.identity.projectId, taskId);
1008
1745
  assertExpectedRevision(task, expectedRevision);
1746
+ const lease = context.locks.heartbeat(taskId, writerToken);
1747
+ this.assertTaskMutationAllowedByHandoff(task, actor ?? null, lease.token);
1009
1748
  if (task.status !== 'ready_to_merge' || task.workspaceOwner !== 'external') {
1010
1749
  throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} is not awaiting external merge confirmation.`);
1011
1750
  }
@@ -1019,11 +1758,91 @@ export class WorkflowService {
1019
1758
  }
1020
1759
  status(repository) {
1021
1760
  const { identity } = this.context(repository);
1761
+ const tasks = this.store.listTasks(identity.projectId);
1762
+ const rawMilestones = this.store.listMilestones(identity.projectId);
1763
+ const adoption = this.inspectAdoptionPreparation(identity, tasks, rawMilestones);
1022
1764
  return {
1023
1765
  projectId: identity.projectId,
1024
1766
  discoveries: this.store.listDiscoveries(identity.projectId),
1025
- tasks: this.store.listTasks(identity.projectId),
1026
- milestones: this.store.listMilestones(identity.projectId),
1767
+ tasks,
1768
+ milestones: this.listMilestonesCurrent(identity.projectId, adoption.currentPosture),
1769
+ delegations: this.store.listDelegations(identity.projectId),
1770
+ adoption,
1771
+ };
1772
+ }
1773
+ recoverMilestoneTransactions(repository) {
1774
+ const { identity } = this.context(repository);
1775
+ const milestones = this.listMilestonesCurrent(identity.projectId, readCurrentAdoptionPosture(this.store, identity.projectId));
1776
+ return {
1777
+ projectId: identity.projectId,
1778
+ recoveredMilestoneIds: milestones.map((milestone) => milestone.id),
1779
+ };
1780
+ }
1781
+ prepareAdoption(repository) {
1782
+ const { identity } = this.context(repository);
1783
+ return this.inspectAdoptionPreparation(identity, this.store.listTasks(identity.projectId), this.store.listMilestones(identity.projectId));
1784
+ }
1785
+ applyAdoption(repository, actor, confirmationCode) {
1786
+ const context = this.context(repository);
1787
+ const tasks = context.store.listTasks(context.identity.projectId);
1788
+ const milestones = context.store.listMilestones(context.identity.projectId);
1789
+ return applyAdoptionPosture(this.store, {
1790
+ projectId: context.identity.projectId,
1791
+ repositoryIdentity: context.identity,
1792
+ packageVersion: PACKAGE_VERSION,
1793
+ protocolVersion: PROTOCOL_VERSION,
1794
+ stateSchemaVersion: STATE_SCHEMA_VERSION,
1795
+ tasks,
1796
+ milestones,
1797
+ sidecarInventory: this.collectAdoptionSidecarInventory(context.identity.projectId, tasks, milestones),
1798
+ blockers: this.computeAdoptionBoundaryBlockers(context.identity, tasks, context.locks.list()),
1799
+ now: this.now(),
1800
+ }, actor, confirmationCode);
1801
+ }
1802
+ updatePreflight(repository) {
1803
+ const context = this.context(repository);
1804
+ const tasks = context.store.listTasks(context.identity.projectId);
1805
+ const milestones = context.store.listMilestones(context.identity.projectId);
1806
+ const dirty = changedFiles(context.identity.repositoryRoot);
1807
+ const runningSteps = tasks.flatMap((task) => task.steps
1808
+ .filter((step) => step.status === 'in_progress')
1809
+ .map((step) => ({ taskId: task.id, stepId: step.id })));
1810
+ const leases = context.locks.list();
1811
+ const now = this.now().getTime();
1812
+ const activeLeases = leases.filter((lease) => Date.parse(lease.expiresAt) > now);
1813
+ const staleLeases = leases.filter((lease) => Date.parse(lease.expiresAt) <= now);
1814
+ const blockers = [
1815
+ ...(dirty.length > 0 ? ['Repository checkout is not clean.'] : []),
1816
+ ...(runningSteps.length > 0 ? ['At least one Worker Step is in progress.'] : []),
1817
+ ...(activeLeases.length > 0 ? ['At least one writer lease is active.'] : []),
1818
+ ...(staleLeases.length > 0 ? ['At least one stale writer lease requires explicit repair.'] : []),
1819
+ ];
1820
+ const adoption = buildAdoptionPreparation({
1821
+ projectId: context.identity.projectId,
1822
+ repositoryIdentity: context.identity,
1823
+ packageVersion: PACKAGE_VERSION,
1824
+ protocolVersion: PROTOCOL_VERSION,
1825
+ stateSchemaVersion: STATE_SCHEMA_VERSION,
1826
+ tasks,
1827
+ milestones,
1828
+ sidecarInventory: this.collectAdoptionSidecarInventory(context.identity.projectId, tasks, milestones),
1829
+ blockers,
1830
+ now: this.now(),
1831
+ }, readCurrentAdoptionPosture(this.store, context.identity.projectId));
1832
+ return {
1833
+ safe: blockers.length === 0,
1834
+ packageVersion: PACKAGE_VERSION,
1835
+ protocolVersion: PROTOCOL_VERSION,
1836
+ stateSchemaVersion: STATE_SCHEMA_VERSION,
1837
+ projectId: context.identity.projectId,
1838
+ repositoryRoot: context.identity.repositoryRoot,
1839
+ clean: dirty.length === 0,
1840
+ changedFiles: dirty,
1841
+ runningSteps,
1842
+ activeLeases,
1843
+ staleLeases,
1844
+ blockers,
1845
+ adoption,
1027
1846
  };
1028
1847
  }
1029
1848
  scanKnowledgeMap(repository) {
@@ -1049,12 +1868,21 @@ export class WorkflowService {
1049
1868
  const scanned = scanProjectKnowledge(identity.repositoryRoot, identity.projectId, this.now());
1050
1869
  return inspectKnowledgeMap(current, scanned);
1051
1870
  }
1052
- approveKnowledgeMap(repository, expectedRevision, selections, actor) {
1871
+ approveKnowledgeMap(repository, expectedRevision, selections, actor, delegationGrantId = null) {
1053
1872
  const { identity } = this.context(repository);
1054
1873
  const current = this.store.readKnowledgeMap(identity.projectId);
1055
1874
  assertExpectedRevision(current, expectedRevision);
1056
1875
  const inspected = inspectKnowledgeMap(current, scanProjectKnowledge(identity.repositoryRoot, identity.projectId, this.now()));
1876
+ const delegation = this.delegatedAuthorization(identity.projectId, delegationGrantId, 'project_memory.approve', actor, inspected);
1057
1877
  const approved = applyKnowledgeSelections(inspected, selections, actor, this.now());
1878
+ if (!approved.approval) {
1879
+ throw new WorkflowError('STATE_CORRUPT', 'Knowledge Map approval was not recorded.');
1880
+ }
1881
+ approved.approval = {
1882
+ ...approved.approval,
1883
+ authorizationMode: delegation ? 'delegated' : 'human',
1884
+ ...(delegation ? { delegation } : {}),
1885
+ };
1058
1886
  return this.store.writeKnowledgeMap(approved, expectedRevision);
1059
1887
  }
1060
1888
  reconcileKnowledgeMap(repository, expectedRevision) {
@@ -1090,29 +1918,73 @@ export class WorkflowService {
1090
1918
  }
1091
1919
  next(repository) {
1092
1920
  const status = this.status(repository);
1921
+ const adoptionInfo = {
1922
+ status: status.adoption.status,
1923
+ safe: status.adoption.safe,
1924
+ blockers: status.adoption.blockers,
1925
+ baselineDigestHash: status.adoption.baselineDigestHash,
1926
+ sidecarBaselineHash: status.adoption.sidecarBaselineHash,
1927
+ confirmationCodeBindingHash: status.adoption.confirmationCodeBindingHash,
1928
+ confirmationCode: status.adoption.confirmationCode,
1929
+ currentEventType: status.adoption.currentPosture?.eventType ?? null,
1930
+ };
1931
+ if (status.adoption.status !== 'applied') {
1932
+ return {
1933
+ scope: 'project',
1934
+ projectId: status.projectId,
1935
+ status: status.adoption.status,
1936
+ action: status.adoption.safe ? 'state adoption-apply' : 'restore safe boundary, then state adoption-prepare',
1937
+ adoption: adoptionInfo,
1938
+ };
1939
+ }
1940
+ const withAdoption = (value) => ({ ...value, adoption: adoptionInfo });
1093
1941
  const repositoryRoot = resolveRepositoryIdentity(repository).repositoryRoot;
1094
1942
  const milestonesById = new Map(status.milestones.map((milestone) => [milestone.id, milestone]));
1095
1943
  const activeTask = status.tasks.find((task) => taskIsActionableFromRepositoryNext(task, milestonesById));
1096
1944
  if (activeTask) {
1097
1945
  if (!taskCanRebindKnowledge(activeTask) && activeTask.status !== 'planning') {
1098
- return nextForTask(activeTask);
1946
+ const planRiskAudit = activeTask.planHash
1947
+ ? readCurrentPlanRiskAudit(this.store, activeTask)
1948
+ : null;
1949
+ return withAdoption(this.nextForTaskWithDelegation(status.projectId, activeTask, planRiskAudit));
1099
1950
  }
1100
1951
  const knowledgeMap = this.knowledgeMapStatus(repository);
1101
1952
  if (knowledgeMap.status !== 'active' || !knowledgeMap.approval) {
1102
- return {
1953
+ const action = knowledgeMap.status === 'stale' ? 'project-memory reconcile' : 'project-memory approve';
1954
+ const delegatedApprovalOptions = action === 'project-memory approve'
1955
+ ? this.delegatedApprovalOptions(status.projectId, 'project_memory.approve', knowledgeMap)
1956
+ : [];
1957
+ const storedKnowledgeMap = this.store.readKnowledgeMap(status.projectId);
1958
+ const contextRefreshOptions = action === 'project-memory reconcile'
1959
+ && storedKnowledgeMap.status === 'active'
1960
+ && storedKnowledgeMap.approval
1961
+ && isContentOnlyKnowledgeRefresh(storedKnowledgeMap, knowledgeMap)
1962
+ ? this.delegatedContextRefreshOptions(status.projectId, activeTask, knowledgeMap)
1963
+ : [];
1964
+ return withAdoption({
1103
1965
  scope: 'project-memory',
1104
1966
  status: knowledgeMap.status,
1105
- action: knowledgeMap.status === 'stale' ? 'project-memory reconcile' : 'project-memory approve',
1967
+ action,
1106
1968
  revision: knowledgeMap.revision,
1107
1969
  taskId: activeTask.id,
1108
- };
1970
+ ...(delegatedApprovalOptions.length > 0 ? { delegatedApprovalOptions } : {}),
1971
+ ...(contextRefreshOptions.length > 0
1972
+ ? {
1973
+ contextRefresh: {
1974
+ action: 'task context-refresh',
1975
+ mode: 'delegated-content-only',
1976
+ options: contextRefreshOptions,
1977
+ },
1978
+ }
1979
+ : {}),
1980
+ });
1109
1981
  }
1110
1982
  const knowledgeMapHash = hashKnowledgeMap(knowledgeMap);
1111
1983
  if (taskCanRebindKnowledge(activeTask) &&
1112
1984
  activeTask.planHash &&
1113
1985
  (activeTask.knowledgeMapRevision !== knowledgeMap.revision ||
1114
1986
  activeTask.knowledgeMapHash !== knowledgeMapHash)) {
1115
- return {
1987
+ return withAdoption({
1116
1988
  scope: 'task',
1117
1989
  id: activeTask.id,
1118
1990
  status: activeTask.status,
@@ -1120,9 +1992,12 @@ export class WorkflowService {
1120
1992
  revision: activeTask.revision,
1121
1993
  knowledgeMapRevision: knowledgeMap.revision,
1122
1994
  knowledgeMapHash,
1123
- };
1995
+ });
1124
1996
  }
1125
- return nextForTask(activeTask);
1997
+ const planRiskAudit = activeTask.planHash && activeTask.status !== 'planning'
1998
+ ? readCurrentPlanRiskAudit(this.store, activeTask)
1999
+ : null;
2000
+ return withAdoption(this.nextForTaskWithDelegation(status.projectId, activeTask, planRiskAudit));
1126
2001
  }
1127
2002
  const activeMilestone = status.milestones.find((milestone) => !['accepted', 'cancelled'].includes(milestone.status));
1128
2003
  if (activeMilestone) {
@@ -1140,43 +2015,241 @@ export class WorkflowService {
1140
2015
  evidence.headCommit !== currentHead ||
1141
2016
  resultHash !== activeMilestone.resultHash ||
1142
2017
  evidenceHash !== activeMilestone.evidenceHash) {
1143
- return {
2018
+ return withAdoption({
1144
2019
  scope: 'milestone',
1145
2020
  id: activeMilestone.id,
1146
2021
  status: activeMilestone.status,
1147
2022
  action: 'restore clean base, then milestone validate',
1148
2023
  revision: activeMilestone.revision,
1149
2024
  reason: 'final validation evidence is stale',
1150
- };
2025
+ });
1151
2026
  }
1152
- return {
2027
+ const delegatedApprovalOptions = this.delegatedApprovalOptions(status.projectId, 'milestone.final_accept', activeMilestone);
2028
+ return withAdoption({
1153
2029
  scope: 'milestone',
1154
2030
  id: activeMilestone.id,
1155
2031
  status: activeMilestone.status,
1156
- action: 'await explicit human final acceptance',
2032
+ action: delegatedApprovalOptions.length > 0
2033
+ ? 'milestone accept using delegated approval or await explicit human final acceptance'
2034
+ : 'await explicit human final acceptance',
1157
2035
  revision: activeMilestone.revision,
1158
2036
  requiredHumanGate: milestoneFinalAcceptanceGate(activeMilestone, evidence.headCommit),
1159
- };
2037
+ completionPolicy: {
2038
+ resultEvidenceAndHeadMustMatch: true,
2039
+ finalAcceptanceTransition: 'milestone.final_accept',
2040
+ delegatedActorMustRemainDelegate: true,
2041
+ },
2042
+ ...(delegatedApprovalOptions.length > 0 ? { delegatedApprovalOptions } : {}),
2043
+ });
1160
2044
  }
1161
- return {
2045
+ const delegatedApprovalOptions = activeMilestone.status === 'awaiting_execution_authorization'
2046
+ ? this.delegatedApprovalOptions(status.projectId, 'milestone.execution_authorize', activeMilestone)
2047
+ : [];
2048
+ return withAdoption({
1162
2049
  scope: 'milestone',
1163
2050
  id: activeMilestone.id,
1164
2051
  status: activeMilestone.status,
1165
2052
  action: nextForMilestone(activeMilestone),
1166
2053
  revision: activeMilestone.revision,
1167
- };
2054
+ ...(activeMilestone.status === 'active'
2055
+ ? {
2056
+ completionPolicy: {
2057
+ requiredTasksMustBeMerged: true,
2058
+ validationAction: 'milestone validate',
2059
+ finalAcceptanceTransition: 'milestone.final_accept',
2060
+ delegatedActorMustRemainDelegate: true,
2061
+ requiredTasks: activeMilestone.memberships
2062
+ .filter((membership) => membership.disposition === 'required')
2063
+ .map((membership) => {
2064
+ const task = status.tasks.find((candidate) => candidate.id === membership.taskId);
2065
+ return { taskId: membership.taskId, status: task?.status ?? 'missing' };
2066
+ }),
2067
+ },
2068
+ }
2069
+ : {}),
2070
+ ...(delegatedApprovalOptions.length > 0 ? { delegatedApprovalOptions } : {}),
2071
+ });
1168
2072
  }
1169
2073
  const activeDiscovery = status.discoveries.find((discovery) => !['materialized', 'abandoned'].includes(discovery.status));
1170
2074
  if (activeDiscovery) {
1171
- return {
2075
+ return withAdoption({
1172
2076
  scope: 'discovery',
1173
2077
  id: activeDiscovery.id,
1174
2078
  status: activeDiscovery.status,
1175
2079
  action: activeDiscovery.status === 'ready_to_materialize' ? 'discovery materialize' : 'discovery update',
1176
2080
  readiness: assessDiscovery(activeDiscovery),
2081
+ });
2082
+ }
2083
+ return withAdoption({ scope: 'repository', status: 'idle', action: 'discovery start' });
2084
+ }
2085
+ delegatedAuthorization(projectId, grantId, transition, actor, target) {
2086
+ if (!grantId) {
2087
+ if (actor.trim().startsWith('agent:')) {
2088
+ throw new WorkflowError('TRANSITION_BLOCKED', 'An agent actor requires an explicit delegated approval grant.');
2089
+ }
2090
+ return null;
2091
+ }
2092
+ const grant = this.store.readDelegation(projectId, grantId);
2093
+ return assertDelegationCanAuthorize(grant, transition, actor, target, this.now());
2094
+ }
2095
+ assertHandoffGrant(projectId, task, targetActor, grantId) {
2096
+ const grant = this.store.readDelegation(projectId, grantId);
2097
+ const policyHash = hashDelegationPolicy(projectId, {
2098
+ principal: grant.principal,
2099
+ delegate: grant.delegate,
2100
+ scope: grant.scope,
2101
+ transitions: grant.transitions,
2102
+ expiresAt: grant.expiresAt,
2103
+ });
2104
+ const scopeMatches = grant.scope.kind === 'project'
2105
+ || (grant.scope.kind === 'task' && grant.scope.id === task.id)
2106
+ || (grant.scope.kind === 'milestone' && grant.scope.id === task.milestoneId);
2107
+ if (grant.status !== 'active'
2108
+ || grant.policyHash !== policyHash
2109
+ || Date.parse(grant.expiresAt) <= this.now().getTime()
2110
+ || grant.delegate !== targetActor.trim()
2111
+ || !scopeMatches) {
2112
+ throw new WorkflowError('TRANSITION_BLOCKED', `Delegation ${grantId} cannot be bound to this C1 handoff.`, {
2113
+ grantId,
2114
+ taskId: task.id,
2115
+ targetActor: targetActor.trim(),
2116
+ grantStatus: grant.status,
2117
+ expectedPolicyHash: policyHash,
2118
+ actualPolicyHash: grant.policyHash,
2119
+ grantDelegate: grant.delegate,
2120
+ grantScope: grant.scope,
2121
+ grantExpiresAt: grant.expiresAt,
2122
+ });
2123
+ }
2124
+ }
2125
+ nextForTaskWithDelegation(projectId, task, planRiskAudit = null) {
2126
+ const transition = task.status === 'awaiting_execution_authorization'
2127
+ ? 'task.execution_authorize'
2128
+ : task.status === 'awaiting_final_acceptance'
2129
+ ? 'task.final_accept'
2130
+ : null;
2131
+ const delegatedApprovalOptions = transition
2132
+ ? this.delegatedApprovalOptions(projectId, transition, task)
2133
+ : [];
2134
+ const failedReviewAttempts = countFailedReviewAttempts(this.store.taskRoot(projectId, task.id));
2135
+ const next = nextForTask(task);
2136
+ const handoffPosture = readTaskC1Posture(this.store, task);
2137
+ const strictStepReviewStep = planRiskAudit
2138
+ ? task.steps.find((step) => {
2139
+ if (step.status !== 'in_progress' || !step.evidence || !planRiskAudit.reviewRequiredStepIds.includes(step.id)) {
2140
+ return false;
2141
+ }
2142
+ if (task.status !== 'blocked')
2143
+ return true;
2144
+ return buildCurrentStrictStepReviewCycle(this.store, task, step.id).resolution === 'unverified';
2145
+ }) ?? null
2146
+ : null;
2147
+ const handoffOverlay = handoffPosture.state === 'pending'
2148
+ ? {
2149
+ action: 'task claim',
2150
+ blockedAction: next.action,
2151
+ c1Handoff: {
2152
+ state: 'pending',
2153
+ targetActor: handoffPosture.targetActor,
2154
+ eventId: handoffPosture.latestEvent.eventId,
2155
+ },
2156
+ }
2157
+ : handoffPosture.state === 'claimed'
2158
+ ? {
2159
+ c1Handoff: {
2160
+ state: 'claimed',
2161
+ claimant: handoffPosture.claimant,
2162
+ writerLeaseTokenBound: handoffPosture.writerLeaseTokenHash !== null,
2163
+ requiredActor: handoffPosture.claimant,
2164
+ },
2165
+ }
2166
+ : {};
2167
+ const result = {
2168
+ ...next,
2169
+ ...handoffOverlay,
2170
+ ...(strictStepReviewStep
2171
+ ? {
2172
+ action: 'task step-review',
2173
+ stepId: strictStepReviewStep.id,
2174
+ commitPolicy: {
2175
+ owner: 'workflow-core',
2176
+ manualCommitAllowed: false,
2177
+ completionAction: 'task step-review',
2178
+ },
2179
+ strictStepReview: {
2180
+ reviewRequired: true,
2181
+ completionCommit: strictStepReviewStep.evidence.commitSha,
2182
+ },
2183
+ }
2184
+ : {}),
2185
+ ...(planRiskAudit
2186
+ ? {
2187
+ planRiskAudit: {
2188
+ decision: planRiskAudit.audit.decision,
2189
+ scope: planRiskAudit.scope,
2190
+ postureEventType: planRiskAudit.postureEventType,
2191
+ reviewRequiredStepIds: planRiskAudit.reviewRequiredStepIds,
2192
+ },
2193
+ }
2194
+ : {}),
2195
+ ...(task.status === 'needs_fix' && task.review.status === 'failed' && failedReviewAttempts >= 2
2196
+ ? {
2197
+ action: 'task plan-set with independent corrective audit',
2198
+ correctivePlanGate: {
2199
+ failedReviewAttempts,
2200
+ auditFileRequired: true,
2201
+ auditorMustDifferFromFailedReviewer: true,
2202
+ allowedDecision: 'continue-fix',
2203
+ escalationDecisions: ['replan-required', 'split-required', 'stop-escalate'],
2204
+ },
2205
+ }
2206
+ : {}),
2207
+ ...(delegatedApprovalOptions.length > 0 ? { delegatedApprovalOptions } : {}),
2208
+ };
2209
+ if (handoffPosture.state === 'pending') {
2210
+ return {
2211
+ ...result,
2212
+ action: 'task claim',
2213
+ blockedAction: next.action,
1177
2214
  };
1178
2215
  }
1179
- return { scope: 'repository', status: 'idle', action: 'discovery start' };
2216
+ return result;
2217
+ }
2218
+ delegatedApprovalOptions(projectId, transition, target) {
2219
+ return this.store.listDelegations(projectId)
2220
+ .filter((grant) => delegationCanAuthorize(grant, transition, target, this.now()))
2221
+ .map((grant) => ({
2222
+ grantId: grant.id,
2223
+ principal: grant.principal,
2224
+ delegate: grant.delegate,
2225
+ transition,
2226
+ scope: grant.scope,
2227
+ expiresAt: grant.expiresAt,
2228
+ policyHash: grant.policyHash,
2229
+ }));
2230
+ }
2231
+ delegatedContextRefreshOptions(projectId, task, knowledgeMap) {
2232
+ return this.store.listDelegations(projectId)
2233
+ .filter((grant) => delegationCanAuthorize(grant, 'project_memory.approve', knowledgeMap, this.now()))
2234
+ .filter((grant) => delegationCanAuthorize(grant, 'task.execution_authorize', task, this.now()))
2235
+ .map((grant) => ({
2236
+ grantId: grant.id,
2237
+ principal: grant.principal,
2238
+ delegate: grant.delegate,
2239
+ transition: 'task.execution_authorize',
2240
+ transitions: ['project_memory.approve', 'task.execution_authorize'],
2241
+ scope: grant.scope,
2242
+ expiresAt: grant.expiresAt,
2243
+ policyHash: grant.policyHash,
2244
+ }));
2245
+ }
2246
+ assertDelegationScopeExists(projectId, policy) {
2247
+ if (policy.scope.kind === 'task') {
2248
+ this.store.readTask(projectId, policy.scope.id);
2249
+ return;
2250
+ }
2251
+ if (policy.scope.kind === 'milestone')
2252
+ this.store.readMilestone(projectId, policy.scope.id);
1180
2253
  }
1181
2254
  assertExecutionAuthorizationFresh(task) {
1182
2255
  const root = this.store.taskRoot(task.projectId, task.id);
@@ -1207,6 +2280,75 @@ export class WorkflowService {
1207
2280
  throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone authorization is stale.');
1208
2281
  }
1209
2282
  }
2283
+ readMilestoneCurrent(projectId, milestoneId) {
2284
+ return readMilestoneWithIntegrity(this.store, projectId, milestoneId, (taskId) => this.store.readTask(projectId, taskId), () => this.store.listTasks(projectId));
2285
+ }
2286
+ readMilestoneScopeChangeCurrent(projectId, milestoneId) {
2287
+ return readMilestoneForScopeChange(this.store, projectId, milestoneId, (taskId) => this.store.readTask(projectId, taskId), () => this.store.listTasks(projectId));
2288
+ }
2289
+ listMilestonesCurrent(projectId, posture = this.readCurrentAdoptionPostureStrict(projectId)) {
2290
+ const milestones = this.store.listMilestones(projectId);
2291
+ if (!posture)
2292
+ return milestones;
2293
+ const grandfatheredMilestones = new Set(posture.terminalMilestones.map((milestone) => `${milestone.milestoneId}:${milestone.revision}:${milestone.status}`));
2294
+ return milestones.map((milestone) => (grandfatheredMilestones.has(`${milestone.id}:${milestone.revision}:${milestone.status}`)
2295
+ ? milestone
2296
+ : readMilestoneWithIntegrity(this.store, projectId, milestone.id, (taskId) => this.store.readTask(projectId, taskId), () => this.store.listTasks(projectId))));
2297
+ }
2298
+ prepareMilestoneScopeChangeCandidate(projectId, milestone, plan, actor) {
2299
+ validateMilestonePlan(plan);
2300
+ return prepareMilestoneScopeChangeCandidate({
2301
+ milestone,
2302
+ plan,
2303
+ readTask: (taskId) => this.store.readTask(projectId, taskId),
2304
+ actor,
2305
+ });
2306
+ }
2307
+ proveWriterLeaseTokenForHandoff(context, taskId, writerToken) {
2308
+ const currentLease = context.locks.inspect(taskId);
2309
+ if (!currentLease) {
2310
+ if (writerToken !== null) {
2311
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} has no active writer lease for the provided writer token.`, {
2312
+ taskId,
2313
+ });
2314
+ }
2315
+ return null;
2316
+ }
2317
+ if (writerToken === null) {
2318
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} requires the active writer token while a lease is present.`, {
2319
+ taskId,
2320
+ leaseOwner: currentLease.owner,
2321
+ expiresAt: currentLease.expiresAt,
2322
+ });
2323
+ }
2324
+ return context.locks.heartbeat(taskId, writerToken);
2325
+ }
2326
+ proveOptionalWriterLeaseToken(context, taskId, writerToken) {
2327
+ const currentLease = context.locks.inspect(taskId);
2328
+ if (!currentLease) {
2329
+ if (writerToken !== null) {
2330
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} has no active writer lease for the provided writer token.`, {
2331
+ taskId,
2332
+ });
2333
+ }
2334
+ return null;
2335
+ }
2336
+ if (writerToken === null)
2337
+ return null;
2338
+ return context.locks.heartbeat(taskId, writerToken);
2339
+ }
2340
+ assertTaskMutationAllowedByHandoff(task, actor, writerToken) {
2341
+ const posture = readTaskC1Posture(this.store, task);
2342
+ if (posture.state === 'none')
2343
+ return;
2344
+ if (!actor || !actor.trim()) {
2345
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} has active C1 coordination and requires an explicit actor for mutation.`, {
2346
+ taskId: task.id,
2347
+ posture: posture.state,
2348
+ });
2349
+ }
2350
+ assertTaskMutationAllowedByC1(this.store, task, { actor: actor.trim(), writerToken });
2351
+ }
1210
2352
  readGraphBindingOrNull(projectId, graphKind) {
1211
2353
  try {
1212
2354
  return this.store.readGraphBinding(projectId, graphKind);
@@ -1229,6 +2371,71 @@ export class WorkflowService {
1229
2371
  }
1230
2372
  return inspected;
1231
2373
  }
2374
+ inspectAdoptionPreparation(identity, tasks, milestones) {
2375
+ const locks = new WriterLockManager(this.store.lockRoot(identity.projectId), this.now);
2376
+ const currentPosture = readCurrentAdoptionPosture(this.store, identity.projectId);
2377
+ if (currentPosture) {
2378
+ assertAdoptionBaselinePreserved(currentPosture, tasks, milestones);
2379
+ }
2380
+ return buildAdoptionPreparation({
2381
+ projectId: identity.projectId,
2382
+ repositoryIdentity: identity,
2383
+ packageVersion: PACKAGE_VERSION,
2384
+ protocolVersion: PROTOCOL_VERSION,
2385
+ stateSchemaVersion: STATE_SCHEMA_VERSION,
2386
+ tasks,
2387
+ milestones,
2388
+ sidecarInventory: this.collectAdoptionSidecarInventory(identity.projectId, tasks, milestones),
2389
+ blockers: this.computeAdoptionBoundaryBlockers(identity, tasks, locks.list()),
2390
+ now: this.now(),
2391
+ }, currentPosture);
2392
+ }
2393
+ computeAdoptionBoundaryBlockers(identity, tasks, leases) {
2394
+ const dirty = changedFiles(identity.repositoryRoot);
2395
+ const runningSteps = tasks.flatMap((task) => task.steps
2396
+ .filter((step) => step.status === 'in_progress')
2397
+ .map((step) => ({ taskId: task.id, stepId: step.id })));
2398
+ const now = this.now().getTime();
2399
+ const activeLeases = leases.filter((lease) => Date.parse(lease.expiresAt) > now);
2400
+ const staleLeases = leases.filter((lease) => Date.parse(lease.expiresAt) <= now);
2401
+ return [
2402
+ ...(dirty.length > 0 ? ['Repository checkout is not clean.'] : []),
2403
+ ...(runningSteps.length > 0 ? ['At least one Worker Step is in progress.'] : []),
2404
+ ...(activeLeases.length > 0 ? ['At least one writer lease is active.'] : []),
2405
+ ...(staleLeases.length > 0 ? ['At least one stale writer lease requires explicit repair.'] : []),
2406
+ ];
2407
+ }
2408
+ collectAdoptionSidecarInventory(projectId, tasks, milestones) {
2409
+ const inventory = [];
2410
+ const collect = (scope, ownerId, root) => {
2411
+ if (!existsSync(root))
2412
+ return;
2413
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
2414
+ if (entry.isSymbolicLink()) {
2415
+ throw new WorkflowError('STATE_CORRUPT', `Adoption sidecar inventory rejects symlink entries: ${path.join(root, entry.name)}`);
2416
+ }
2417
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
2418
+ continue;
2419
+ const filePath = path.join(root, entry.name);
2420
+ const stat = lstatSync(filePath);
2421
+ if (!stat.isFile()) {
2422
+ throw new WorkflowError('STATE_CORRUPT', `Adoption sidecar inventory requires regular files only: ${filePath}`);
2423
+ }
2424
+ inventory.push({
2425
+ scope,
2426
+ ownerId,
2427
+ sidecarName: entry.name,
2428
+ contentHash: createHash('sha256').update(readFileSync(filePath)).digest('hex'),
2429
+ });
2430
+ }
2431
+ };
2432
+ collect('project', projectId, this.store.projectRoot(projectId));
2433
+ for (const task of tasks)
2434
+ collect('task', task.id, this.store.taskRoot(projectId, task.id));
2435
+ for (const milestone of milestones)
2436
+ collect('milestone', milestone.id, this.store.milestoneRoot(projectId, milestone.id));
2437
+ return inventory;
2438
+ }
1232
2439
  assertKnowledgeMapBinding(repositoryRoot, projectId, revision, mapHash) {
1233
2440
  const map = this.requireActiveKnowledgeMap(repositoryRoot, projectId);
1234
2441
  const currentHash = hashKnowledgeMap(map);
@@ -1242,6 +2449,29 @@ export class WorkflowService {
1242
2449
  }
1243
2450
  return map;
1244
2451
  }
2452
+ readCurrentAdoptionPostureStrict(projectId) {
2453
+ const posture = readCurrentAdoptionPosture(this.store, projectId);
2454
+ if (!posture)
2455
+ return null;
2456
+ assertAdoptionBaselinePreserved(posture, this.store.listTasks(projectId), this.store.listMilestones(projectId));
2457
+ return posture;
2458
+ }
2459
+ requireExecutionAdoptionPosture(identity, taskId) {
2460
+ const tasks = this.store.listTasks(identity.projectId);
2461
+ const milestones = this.store.listMilestones(identity.projectId);
2462
+ const posture = this.readCurrentAdoptionPostureStrict(identity.projectId);
2463
+ if (posture)
2464
+ return posture;
2465
+ const adoption = this.inspectAdoptionPreparation(identity, tasks, milestones);
2466
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Alpha.6 adoption posture is required before execution can proceed.', {
2467
+ taskId,
2468
+ adoptionStatus: adoption.status,
2469
+ requiredAction: adoption.safe ? 'state adoption-apply' : 'restore safe boundary, then state adoption-prepare',
2470
+ blockers: adoption.blockers,
2471
+ confirmationCode: adoption.confirmationCode,
2472
+ confirmationCodeBindingHash: adoption.confirmationCodeBindingHash,
2473
+ });
2474
+ }
1245
2475
  assertTaskKnowledgeBinding(repositoryRoot, task) {
1246
2476
  if (task.knowledgeMapRevision === null || !task.knowledgeMapHash) {
1247
2477
  throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} has no Project Knowledge Map binding.`);
@@ -1313,6 +2543,62 @@ function dependenciesComplete(step, completed) {
1313
2543
  const completedIds = new Set(completed);
1314
2544
  return step.dependencies.every((dependency) => completedIds.has(dependency));
1315
2545
  }
2546
+ function applyStrictStepReviewResolution(task, stepId, resolution, summary) {
2547
+ if (resolution === 'pending') {
2548
+ throw new WorkflowError('STATE_CORRUPT', `Strict Step Review resolution is still pending for ${stepId}.`, {
2549
+ taskId: task.id,
2550
+ stepId,
2551
+ });
2552
+ }
2553
+ const step = requireStep(task, stepId);
2554
+ if (!step.evidence) {
2555
+ throw new WorkflowError('STATE_CORRUPT', `Strict Step Review resolution requires canonical evidence for ${stepId}.`, {
2556
+ taskId: task.id,
2557
+ stepId,
2558
+ resolution,
2559
+ });
2560
+ }
2561
+ if (resolution === 'passed_verified') {
2562
+ const completedIds = new Set([
2563
+ ...task.steps.filter((candidate) => candidate.status === 'completed').map((candidate) => candidate.id),
2564
+ stepId,
2565
+ ]);
2566
+ return {
2567
+ ...task,
2568
+ status: 'in_progress',
2569
+ steps: task.steps.map((candidate) => {
2570
+ if (candidate.id === stepId)
2571
+ return { ...candidate, status: 'completed', evidence: step.evidence };
2572
+ if (candidate.status === 'blocked' && dependenciesComplete(candidate, completedIds)) {
2573
+ return { ...candidate, status: 'planned' };
2574
+ }
2575
+ return candidate;
2576
+ }),
2577
+ blockReason: null,
2578
+ };
2579
+ }
2580
+ if (resolution === 'failed') {
2581
+ return {
2582
+ ...task,
2583
+ status: 'needs_fix',
2584
+ steps: task.steps.map((candidate) => candidate.id === stepId
2585
+ ? { ...candidate, status: 'failed', evidence: null }
2586
+ : candidate),
2587
+ blockReason: summary?.trim() || `Strict Step Review failed for ${stepId}.`,
2588
+ systemCommits: task.systemCommits.includes(step.evidence.commitSha)
2589
+ ? task.systemCommits
2590
+ : [...task.systemCommits, step.evidence.commitSha],
2591
+ };
2592
+ }
2593
+ return {
2594
+ ...task,
2595
+ status: 'blocked',
2596
+ steps: task.steps.map((candidate) => candidate.id === stepId
2597
+ ? { ...candidate, status: 'in_progress', evidence: step.evidence }
2598
+ : candidate),
2599
+ blockReason: summary?.trim() || `Strict Step Review isolation could not be verified for ${stepId}.`,
2600
+ };
2601
+ }
1316
2602
  function identityRelative(repositoryRoot, value) {
1317
2603
  return path.resolve(repositoryRoot, value);
1318
2604
  }
@@ -1366,6 +2652,83 @@ function validateReviewInput(review) {
1366
2652
  throw new WorkflowError('INVALID_ARGUMENT', 'A failed review requires at least one finding.');
1367
2653
  }
1368
2654
  }
2655
+ function countFailedReviewAttempts(taskRoot) {
2656
+ const file = path.join(taskRoot, 'findings.jsonl');
2657
+ if (!existsSync(file))
2658
+ return 0;
2659
+ const attempts = new Set();
2660
+ for (const line of readFileSync(file, 'utf8').split('\n').filter(Boolean)) {
2661
+ try {
2662
+ const finding = JSON.parse(line);
2663
+ if (typeof finding.recordedAt === 'string' && finding.recordedAt) {
2664
+ attempts.add(`${finding.recordedAt}\0${typeof finding.reviewer === 'string' ? finding.reviewer : ''}`);
2665
+ }
2666
+ }
2667
+ catch {
2668
+ throw new WorkflowError('STATE_CORRUPT', `Invalid review finding log: ${file}`);
2669
+ }
2670
+ }
2671
+ return attempts.size;
2672
+ }
2673
+ function validateCorrectivePlanAudit(task, audit, failedReviewAttempts) {
2674
+ if (!audit) {
2675
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} has ${failedReviewAttempts} failed independent reviews. A separate corrective Plan audit is required before another plan-set.`, {
2676
+ failedReviewAttempts,
2677
+ requiredAction: 'Run a separate read-only Auditor and pass its evidence through --corrective-audit-file.',
2678
+ });
2679
+ }
2680
+ if (!audit.auditor.trim() || !audit.summary.trim()) {
2681
+ throw new WorkflowError('INVALID_ARGUMENT', 'Corrective Plan audit requires auditor and summary.');
2682
+ }
2683
+ if (audit.auditor.trim() === task.review.reviewer.trim()) {
2684
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Corrective Plan audit must be performed by an Auditor distinct from the failed-review author.');
2685
+ }
2686
+ const latestFindingIds = new Set(task.review.findings.map((finding) => finding.id));
2687
+ const reviewedFindingIds = new Set(audit.reviewedFindingIds);
2688
+ if (reviewedFindingIds.size !== audit.reviewedFindingIds.length || audit.reviewedFindingIds.some((id) => !id.trim())) {
2689
+ throw new WorkflowError('INVALID_ARGUMENT', 'Corrective Plan audit finding IDs must be non-empty and unique.');
2690
+ }
2691
+ const missing = [...latestFindingIds].filter((findingId) => !reviewedFindingIds.has(findingId));
2692
+ if (missing.length > 0) {
2693
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Corrective Plan audit does not cover every latest finding.', {
2694
+ missingFindingIds: missing,
2695
+ });
2696
+ }
2697
+ if (audit.decision !== 'continue-fix') {
2698
+ throw new WorkflowError('TRANSITION_BLOCKED', `Corrective Auditor decision ${audit.decision} requires coordinator/user redirection before implementation continues.`, { decision: audit.decision, summary: audit.summary });
2699
+ }
2700
+ }
2701
+ function appendCorrectivePlanAudit(taskRoot, previous, saved, failedReviewAttempts, audit, now) {
2702
+ appendFileSync(path.join(taskRoot, 'corrective-plan-audits.jsonl'), `${JSON.stringify({
2703
+ recordedAt: now.toISOString(),
2704
+ taskId: previous.id,
2705
+ taskRevision: previous.revision,
2706
+ failedReviewAttempts,
2707
+ previousPlanHash: previous.planHash,
2708
+ correctivePlanHash: saved.planHash,
2709
+ ...audit,
2710
+ })}\n`, { encoding: 'utf8', mode: 0o600 });
2711
+ }
2712
+ function assertContentOnlyKnowledgeRefresh(approved, inspected) {
2713
+ if (!isContentOnlyKnowledgeRefresh(approved, inspected)) {
2714
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Automatic Task context refresh is limited to content-hash drift. Knowledge sources, categories, scopes, authorities, gaps, or conflicts changed.', { requiredAction: 'Run project-memory reconcile, inspect the classification diff, and approve it explicitly.' });
2715
+ }
2716
+ }
2717
+ function isContentOnlyKnowledgeRefresh(approved, inspected) {
2718
+ const classification = (map) => JSON.stringify({
2719
+ entries: map.entries.map((entry) => ({
2720
+ id: entry.id,
2721
+ category: entry.category,
2722
+ path: entry.path,
2723
+ scope: entry.scope,
2724
+ authority: entry.authority,
2725
+ discoveredBy: entry.discoveredBy,
2726
+ })),
2727
+ gaps: map.gaps,
2728
+ conflicts: map.conflicts,
2729
+ });
2730
+ return classification(approved) === classification(inspected);
2731
+ }
1369
2732
  function validateMilestonePlan(plan) {
1370
2733
  if (!plan.outcome.trim())
1371
2734
  throw new WorkflowError('INVALID_ARGUMENT', 'Milestone outcome is required.');
@@ -1461,11 +2824,14 @@ function nextForMilestone(milestone) {
1461
2824
  }
1462
2825
  function nextForTask(task) {
1463
2826
  const stepsComplete = task.steps.every((step) => step.status === 'completed' || step.status === 'skipped');
2827
+ const activeStep = task.steps.find((step) => step.status === 'in_progress');
2828
+ const failedStep = task.steps.find((step) => step.status === 'failed');
2829
+ const runnableStep = task.steps.find((step) => step.status === 'planned' && step.dependencies.every((dependency) => ['completed', 'skipped'].includes(requireStep(task, dependency).status)));
1464
2830
  const actionByStatus = {
1465
2831
  planning: 'task plan-set',
1466
2832
  awaiting_execution_authorization: 'task authorize',
1467
2833
  ready: task.workspaceOwner ? (stepsComplete ? 'task submit' : 'task run') : 'task start',
1468
- in_progress: stepsComplete ? 'task submit' : 'task run or task submit',
2834
+ in_progress: activeStep ? 'task step-complete' : stepsComplete ? 'task submit' : 'task run',
1469
2835
  validating: task.review.status === 'passed' ? 'task result-set' : 'task review-record',
1470
2836
  needs_fix: task.steps.some((step) => step.status === 'failed') ? 'task run' : 'task plan-set',
1471
2837
  awaiting_final_acceptance: 'task accept',
@@ -1475,7 +2841,24 @@ function nextForTask(task) {
1475
2841
  blocked: task.review.status === 'unverified' ? 'task review-record' : 'doctor',
1476
2842
  cancelled: 'none',
1477
2843
  };
1478
- return { scope: 'task', id: task.id, status: task.status, action: actionByStatus[task.status], revision: task.revision };
2844
+ const selectedStep = activeStep ?? failedStep ?? runnableStep;
2845
+ return {
2846
+ scope: 'task',
2847
+ id: task.id,
2848
+ status: task.status,
2849
+ action: actionByStatus[task.status],
2850
+ revision: task.revision,
2851
+ ...(selectedStep ? { stepId: selectedStep.id } : {}),
2852
+ ...(activeStep
2853
+ ? {
2854
+ commitPolicy: {
2855
+ owner: 'workflow-core',
2856
+ manualCommitAllowed: false,
2857
+ completionAction: 'task step-complete',
2858
+ },
2859
+ }
2860
+ : {}),
2861
+ };
1479
2862
  }
1480
2863
  function knowledgeSummary(map) {
1481
2864
  return {