codex-workflow-v2 2.0.0-alpha.4 → 2.0.0-alpha.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +114 -10
  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 +95 -19
  27. package/dist/src/cli.js.map +1 -1
  28. package/dist/src/contracts.d.ts +259 -0
  29. package/dist/src/git.js +2 -1
  30. package/dist/src/git.js.map +1 -1
  31. package/dist/src/index.d.ts +2 -0
  32. package/dist/src/reviewer.d.ts +6 -1
  33. package/dist/src/reviewer.js +145 -32
  34. package/dist/src/reviewer.js.map +1 -1
  35. package/dist/src/state/lock.d.ts +1 -0
  36. package/dist/src/state/lock.js +7 -1
  37. package/dist/src/state/lock.js.map +1 -1
  38. package/dist/src/state/store.d.ts +39 -1
  39. package/dist/src/state/store.js +127 -1
  40. package/dist/src/state/store.js.map +1 -1
  41. package/dist/src/version.d.ts +1 -1
  42. package/dist/src/version.js +1 -1
  43. package/dist/src/version.js.map +1 -1
  44. package/dist/src/workflow.d.ts +123 -9
  45. package/dist/src/workflow.js +1497 -76
  46. package/dist/src/workflow.js.map +1 -1
  47. package/docs/autonomy-guardrails.md +143 -0
  48. package/docs/decisions.md +27 -0
  49. package/docs/delegated-approval.md +30 -5
  50. package/docs/development-flow.md +40 -1
  51. package/docs/pdf/codex-workflow-v2-architecture-ru.pdf +155 -136
  52. package/docs/pdf/codex-workflow-v2-chat-only-guide-ru.pdf +236 -223
  53. package/docs/pdf/codex-workflow-v2-technical-reference-ru.pdf +225 -206
  54. package/docs/project-memory.md +7 -0
  55. package/docs/release.md +7 -0
  56. package/docs/updating-existing-project.md +75 -5
  57. package/docs/validation-report.md +56 -34
  58. package/package.json +2 -2
  59. package/plugins/codex-workflow-gateway/references/protocol.md +155 -6
  60. package/plugins/codex-workflow-gateway/skills/codex-workflow-gateway/SKILL.md +23 -1
  61. package/references/git-policy.md +5 -2
  62. package/references/state-machine.md +36 -0
  63. package/references/validation-and-review.md +28 -1
  64. package/roles/delivery-coordinator.md +11 -0
  65. package/roles/independent-reviewer.md +3 -0
  66. package/roles/technical-planner.md +7 -0
  67. package/roles/worker.md +4 -0
  68. package/schemas/adoption-posture-event.schema.json +129 -0
  69. package/schemas/corrective-decision-event.schema.json +55 -0
  70. package/schemas/corrective-plan-audit.schema.json +20 -0
  71. package/schemas/milestone-scope-change-event.schema.json +68 -0
  72. package/schemas/milestone-transaction-journal.schema.json +95 -0
  73. package/schemas/plan-risk-audit-event.schema.json +90 -0
  74. package/schemas/remediation-event.schema.json +53 -0
  75. package/schemas/reviewer-attestation-event.schema.json +49 -0
  76. package/schemas/step-review-event.schema.json +74 -0
  77. package/schemas/task-handoff-event.schema.json +116 -0
@@ -1,16 +1,25 @@
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, prepareDelegationGrantGate, } from './delegation.js';
6
+ import { assertDelegationCanAuthorize, delegationCanAuthorize, hashDelegationPolicy, prepareDelegationGrantGate, } from './delegation.js';
7
7
  import { assessDiscovery, mergeUnique } from './domain/discovery.js';
8
8
  import { pathAllowed, validatePlan } from './domain/validation.js';
9
9
  import { WorkflowError } from './errors.js';
10
+ import { inspectLocalDependency } from './diagnostics.js';
10
11
  import { bindGraphEvidence, createGraphRefreshRequest, fallbackGraphBinding, inspectGraphBinding, validateGraphRequestCurrent, } from './graph.js';
12
+ import { appendTaskClaim, appendTaskHandback, appendTaskHandoff, assertTaskMutationAllowedByC1, readTaskC1Posture, } from './alpha6/handoff.js';
13
+ import { applyAdoptionPosture, assertAdoptionBaselinePreserved, buildAdoptionPreparation, initializeProjectRegistrationAdoption, readCurrentAdoptionPosture, } from './alpha6/adoption.js';
14
+ import { appendCurrentPlanRiskAudit, appendReboundPlanRiskAudit, readCurrentPlanRiskAudit, validatePlanRiskAuditCandidate, } from './alpha6/plan-risk.js';
15
+ import { appendCorrectiveDecisionEvent, appendRemediationEvent, assertCorrectiveAuditorIndependence, assertGuardedRemediationAttemptAllowed, findFailedGuardedStepRequiringCorrectiveDecision, readCorrectiveDecisionEvents, readRemediationEvents, } from './alpha6/remediation.js';
16
+ import { applyMilestoneScopeChangeTransaction, assertMilestoneMembershipIntegrity, assertMilestoneScopeChangeStatusAllowed, normalizeMilestonePlan, prepareMilestoneScopeChangeCandidate, readMilestoneForScopeChange, readMilestoneWithIntegrity, } from './alpha6/milestone.js';
17
+ import { buildCurrentStrictStepReviewCycle, ensurePendingStrictStepReview, isStepReviewRequired, recordStrictStepReview, } from './alpha6/review.js';
11
18
  import { applyKnowledgeSelections, hashKnowledgeMap, inspectKnowledgeMap, normalizeRelativePath, reconcileKnowledgeMap, scanProjectKnowledge, selectKnowledgeSources, } from './memory.js';
19
+ import { canonicalJsonStringify, sha256Hex } from './alpha6/store-sidecars.js';
12
20
  import { commitStep, mergeTaskBranch, runChecks, startLocalTaskBranch, syncBaseIntoTask, validateTaskHistory, } from './git.js';
13
21
  import { changedFiles, currentBranch, gitIsAncestor, headCommit, isClean, resolveRepositoryIdentity, runGit, } from './repository.js';
22
+ import { launchStrictStepReviewer } from './reviewer.js';
14
23
  import { WriterLockManager } from './state/lock.js';
15
24
  import { FileStateStore } from './state/store.js';
16
25
  import { createEntityId } from './ulid.js';
@@ -24,6 +33,18 @@ export class WorkflowService {
24
33
  }
25
34
  context(repository) {
26
35
  const identity = resolveRepositoryIdentity(repository);
36
+ const projectFile = path.join(this.store.projectRoot(identity.projectId), 'project.json');
37
+ const projectAlreadyRegistered = existsSync(projectFile);
38
+ if (!projectAlreadyRegistered) {
39
+ initializeProjectRegistrationAdoption(this.store, {
40
+ projectId: identity.projectId,
41
+ repositoryIdentity: identity,
42
+ packageVersion: PACKAGE_VERSION,
43
+ protocolVersion: PROTOCOL_VERSION,
44
+ stateSchemaVersion: STATE_SCHEMA_VERSION,
45
+ now: this.now(),
46
+ });
47
+ }
27
48
  this.store.registerProject(identity);
28
49
  return {
29
50
  identity,
@@ -162,7 +183,7 @@ export class WorkflowService {
162
183
  if (!title.trim())
163
184
  throw new WorkflowError('INVALID_ARGUMENT', 'Task title is required.');
164
185
  if (milestoneId) {
165
- const milestone = this.store.readMilestone(identity.projectId, milestoneId);
186
+ const milestone = this.readMilestoneCurrent(identity.projectId, milestoneId);
166
187
  if (milestone.status === 'accepted' || milestone.status === 'cancelled') {
167
188
  throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestoneId} is ${milestone.status}.`);
168
189
  }
@@ -271,17 +292,29 @@ export class WorkflowService {
271
292
  }
272
293
  setMilestonePlan(repository, milestoneId, expectedRevision, plan) {
273
294
  const { identity } = this.context(repository);
274
- const milestone = this.store.readMilestone(identity.projectId, milestoneId);
295
+ const milestone = this.readMilestoneScopeChangeCurrent(identity.projectId, milestoneId);
275
296
  assertExpectedRevision(milestone, expectedRevision);
276
297
  if (!['planning', 'awaiting_execution_authorization', 'active', 'blocked'].includes(milestone.status)) {
277
298
  throw new WorkflowError('TRANSITION_BLOCKED', `Cannot update Milestone Plan while ${milestone.status}.`);
278
299
  }
300
+ if (milestone.status !== 'planning'
301
+ || milestone.planHash !== null
302
+ || milestone.membershipRevision !== 0
303
+ || milestone.taskIds.length > 0
304
+ || milestone.memberships.length > 0) {
305
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone Plan is no longer in the initial empty planning posture. Use explicit scope-change prepare/apply.', {
306
+ milestoneId,
307
+ revision: milestone.revision,
308
+ requiredAction: 'prepareMilestoneScopeChange -> applyMilestoneScopeChange',
309
+ });
310
+ }
279
311
  validateMilestonePlan(plan);
280
- const taskIds = plan.memberships.map((membership) => membership.taskId);
312
+ const normalized = normalizeMilestonePlan(plan);
313
+ const taskIds = normalized.taskIds;
281
314
  if (new Set(taskIds).size !== taskIds.length) {
282
315
  throw new WorkflowError('INVALID_ARGUMENT', 'Milestone memberships must have unique Task IDs.');
283
316
  }
284
- for (const membership of plan.memberships) {
317
+ for (const membership of normalized.memberships) {
285
318
  const task = this.store.readTask(identity.projectId, membership.taskId);
286
319
  if (task.milestoneId !== milestoneId) {
287
320
  throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} is not linked to Milestone ${milestoneId}.`);
@@ -296,22 +329,18 @@ export class WorkflowService {
296
329
  throw new WorkflowError('TRANSITION_BLOCKED', `Started Task ${task.id} cannot be removed from a Milestone.`);
297
330
  }
298
331
  }
299
- const root = this.store.milestoneRoot(identity.projectId, milestoneId);
300
- const normalized = {
301
- outcome: plan.outcome.trim(),
302
- successSignal: plan.successSignal.trim(),
303
- acceptance: [...plan.acceptance],
304
- memberships: plan.memberships.map((membership) => ({
305
- ...membership,
306
- reason: membership.reason.trim(),
307
- })),
308
- checks: [...plan.checks],
309
- };
310
- const stagedPlan = this.store.stageArtifact(root, 'plan.json', JSON.stringify(normalized, null, 2));
311
332
  const authorizations = milestone.authorizations.map((authorization) => authorization.kind === 'execution' && authorization.decision === 'approved'
312
333
  ? { ...authorization, decision: 'superseded' }
313
334
  : authorization);
314
- const saved = this.store.writeMilestone({
335
+ const root = this.store.milestoneRoot(identity.projectId, milestoneId);
336
+ const stagedPlan = this.store.stageArtifact(root, 'plan.json', JSON.stringify({
337
+ outcome: normalized.outcome,
338
+ successSignal: normalized.successSignal,
339
+ acceptance: normalized.acceptance,
340
+ memberships: normalized.memberships,
341
+ checks: normalized.checks,
342
+ }, null, 2));
343
+ const candidate = {
315
344
  ...milestone,
316
345
  status: 'awaiting_execution_authorization',
317
346
  outcome: normalized.outcome,
@@ -327,13 +356,55 @@ export class WorkflowService {
327
356
  evidenceHash: null,
328
357
  acceptedHead: null,
329
358
  cancellationReason: null,
330
- }, expectedRevision);
359
+ };
360
+ assertMilestoneMembershipIntegrity(candidate, (taskId) => this.store.readTask(identity.projectId, taskId), {}, () => this.store.listTasks(identity.projectId));
361
+ const saved = this.store.writeMilestone(candidate, expectedRevision);
331
362
  this.store.publishArtifact(stagedPlan);
332
363
  return saved;
333
364
  }
365
+ prepareMilestoneScopeChange(repository, milestoneId, expectedRevision, plan, actor) {
366
+ const { identity } = this.context(repository);
367
+ const milestone = this.readMilestoneScopeChangeCurrent(identity.projectId, milestoneId);
368
+ assertExpectedRevision(milestone, expectedRevision);
369
+ assertMilestoneScopeChangeStatusAllowed(milestone);
370
+ const prepared = this.prepareMilestoneScopeChangeCandidate(identity.projectId, milestone, plan, actor);
371
+ return {
372
+ milestoneId: prepared.milestoneId,
373
+ revision: prepared.expectedRevision,
374
+ actor: prepared.actor,
375
+ candidatePlanHash: prepared.candidatePlanHash,
376
+ candidateMembershipRevision: prepared.candidateMembershipRevision,
377
+ confirmationCodeBindingHash: prepared.confirmationCodeBindingHash,
378
+ confirmationCode: prepared.confirmationCode,
379
+ after: prepared.after,
380
+ };
381
+ }
382
+ applyMilestoneScopeChange(repository, milestoneId, expectedRevision, plan, actor, confirmationCode) {
383
+ const { identity } = this.context(repository);
384
+ const milestone = this.readMilestoneScopeChangeCurrent(identity.projectId, milestoneId);
385
+ assertExpectedRevision(milestone, expectedRevision);
386
+ assertMilestoneScopeChangeStatusAllowed(milestone);
387
+ const prepared = this.prepareMilestoneScopeChangeCandidate(identity.projectId, milestone, plan, actor);
388
+ if (confirmationCode.trim() !== prepared.confirmationCode) {
389
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone scope-change confirmation does not match the current Milestone state and requested scope.', {
390
+ milestoneId,
391
+ revision: milestone.revision,
392
+ actor: prepared.actor,
393
+ confirmationCodeBindingHash: prepared.confirmationCodeBindingHash,
394
+ });
395
+ }
396
+ return applyMilestoneScopeChangeTransaction({
397
+ store: this.store,
398
+ milestone,
399
+ expectedRevision,
400
+ prepared,
401
+ now: this.now(),
402
+ readTask: (taskId) => this.store.readTask(identity.projectId, taskId),
403
+ });
404
+ }
334
405
  authorizeMilestone(repository, milestoneId, expectedRevision, actor, reason = '', delegationGrantId = null) {
335
406
  const { identity } = this.context(repository);
336
- const milestone = this.store.readMilestone(identity.projectId, milestoneId);
407
+ const milestone = this.readMilestoneCurrent(identity.projectId, milestoneId);
337
408
  assertExpectedRevision(milestone, expectedRevision);
338
409
  if (milestone.status !== 'awaiting_execution_authorization' || !milestone.planHash) {
339
410
  throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestoneId} is not awaiting authorization.`);
@@ -362,7 +433,7 @@ export class WorkflowService {
362
433
  }
363
434
  validateMilestone(repository, milestoneId, expectedRevision) {
364
435
  const { identity } = this.context(repository);
365
- const milestone = this.store.readMilestone(identity.projectId, milestoneId);
436
+ const milestone = this.readMilestoneCurrent(identity.projectId, milestoneId);
366
437
  assertExpectedRevision(milestone, expectedRevision);
367
438
  if (!['active', 'awaiting_final_acceptance'].includes(milestone.status)) {
368
439
  throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestoneId} cannot be validated while ${milestone.status}.`);
@@ -425,7 +496,7 @@ export class WorkflowService {
425
496
  }
426
497
  acceptMilestone(repository, milestoneId, expectedRevision, actor, confirmationCode, delegationGrantId = null) {
427
498
  const { identity } = this.context(repository);
428
- const milestone = this.store.readMilestone(identity.projectId, milestoneId);
499
+ const milestone = this.readMilestoneCurrent(identity.projectId, milestoneId);
429
500
  assertExpectedRevision(milestone, expectedRevision);
430
501
  if (milestone.status !== 'awaiting_final_acceptance' || !milestone.resultHash || !milestone.evidenceHash) {
431
502
  throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestoneId} is not ready for acceptance.`);
@@ -480,23 +551,93 @@ export class WorkflowService {
480
551
  if (!reason.trim())
481
552
  throw new WorkflowError('INVALID_ARGUMENT', 'Milestone cancellation requires a reason.');
482
553
  const { identity } = this.context(repository);
483
- const milestone = this.store.readMilestone(identity.projectId, milestoneId);
554
+ const milestone = this.readMilestoneCurrent(identity.projectId, milestoneId);
484
555
  assertExpectedRevision(milestone, expectedRevision);
485
556
  if (['accepted', 'cancelled'].includes(milestone.status)) {
486
557
  throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestoneId} is ${milestone.status}.`);
487
558
  }
488
559
  return this.store.writeMilestone({ ...milestone, status: 'cancelled', cancellationReason: reason.trim() }, expectedRevision);
489
560
  }
490
- setTaskPlan(repository, taskId, expectedRevision, plan) {
561
+ setTaskPlan(repository, taskId, expectedRevision, plan, correctiveAudit = null, planRiskAudit = null) {
491
562
  validatePlan(plan);
492
563
  const { identity } = this.context(repository);
493
564
  const knowledgeMap = this.assertKnowledgeMapBinding(identity.repositoryRoot, identity.projectId, plan.knowledgeMapRevision, plan.knowledgeMapHash);
494
565
  this.validateKnowledgeTargets(knowledgeMap, plan);
495
566
  const task = this.store.readTask(identity.projectId, taskId);
496
567
  assertExpectedRevision(task, expectedRevision);
497
- if (!['planning', 'awaiting_execution_authorization', 'needs_fix'].includes(task.status)) {
568
+ if (!['planning', 'awaiting_execution_authorization', 'needs_fix', 'blocked'].includes(task.status)) {
498
569
  throw new WorkflowError('TRANSITION_BLOCKED', `Cannot update Plan while Task is ${task.status}.`);
499
570
  }
571
+ const currentPlanCorrectiveDecisions = task.planHash
572
+ ? readCorrectiveDecisionEvents(this.store, task).filter((event) => event.planHash === task.planHash)
573
+ : [];
574
+ const currentReplanDecisions = currentPlanCorrectiveDecisions.filter((event) => event.decision === 'replan-required');
575
+ const currentContinueFixDecisions = currentPlanCorrectiveDecisions.filter((event) => event.decision === 'continue-fix');
576
+ const currentHardBlockingDecisions = currentPlanCorrectiveDecisions.filter((event) => event.decision === 'split-required' || event.decision === 'stop-escalate');
577
+ const currentReplanRequiredStepIds = [...new Set(currentReplanDecisions.map((event) => event.stepId))];
578
+ if (currentHardBlockingDecisions.length > 0) {
579
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} has current corrective decisions that prohibit normal plan updates.`, {
580
+ taskId: task.id,
581
+ requiredAction: 'Escalate or split the work according to the recorded corrective decision.',
582
+ decisions: currentHardBlockingDecisions.map((event) => ({
583
+ eventId: event.eventId,
584
+ stepId: event.stepId,
585
+ decision: event.decision,
586
+ })),
587
+ });
588
+ }
589
+ if (task.status === 'blocked' && currentReplanDecisions.length === 0) {
590
+ 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.`, {
591
+ taskId: task.id,
592
+ status: task.status,
593
+ requiredAction: 'Resolve the blocked strict review or record a replan-required corrective decision first.',
594
+ });
595
+ }
596
+ if (currentContinueFixDecisions.length > 0) {
597
+ 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.`, {
598
+ taskId: task.id,
599
+ requiredAction: 'Run the guarded Step under the current Plan retry path, or escalate/cancel instead of issuing another plan-set at the same ordinal.',
600
+ decisions: currentContinueFixDecisions.map((event) => ({
601
+ eventId: event.eventId,
602
+ stepId: event.stepId,
603
+ triggeringAttemptCount: event.triggeringAttemptCount,
604
+ })),
605
+ });
606
+ }
607
+ const currentPlanRiskAudit = task.status === 'needs_fix'
608
+ ? readCurrentPlanRiskAudit(this.store, task)
609
+ : null;
610
+ const failedReviewAttempts = countFailedReviewAttempts(this.store.taskRoot(identity.projectId, taskId));
611
+ if (task.status === 'needs_fix' && task.review.status === 'failed' && failedReviewAttempts >= 2) {
612
+ validateCorrectivePlanAudit(task, correctiveAudit, failedReviewAttempts);
613
+ }
614
+ const guardedRemediationGate = task.status === 'needs_fix' && currentPlanRiskAudit
615
+ ? findFailedGuardedStepRequiringCorrectiveDecision(this.store, task, currentPlanRiskAudit)
616
+ : null;
617
+ if (guardedRemediationGate && currentReplanDecisions.length === 0 && !correctiveAudit) {
618
+ throw new WorkflowError('TRANSITION_BLOCKED', `Guarded Step ${guardedRemediationGate.stepId} requires a corrective decision before another remediation run can be authorized.`, {
619
+ taskId: task.id,
620
+ stepId: guardedRemediationGate.stepId,
621
+ attemptCount: guardedRemediationGate.attemptCount,
622
+ requiredSidecar: 'corrective-decisions.jsonl',
623
+ allowedDecision: 'continue-fix',
624
+ escalationDecisions: ['replan-required', 'split-required', 'stop-escalate'],
625
+ });
626
+ }
627
+ if (currentReplanDecisions.length > 0 && correctiveAudit) {
628
+ 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.`, {
629
+ taskId: task.id,
630
+ decision: correctiveAudit.decision,
631
+ requiredAction: 'Retry task plan-set without corrective-audit input; the recorded replan-required decision is the authorization for this recovery path.',
632
+ });
633
+ }
634
+ if (guardedRemediationGate && currentReplanDecisions.length === 0 && correctiveAudit?.decision !== 'continue-fix') {
635
+ 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.`, {
636
+ taskId: task.id,
637
+ stepId: guardedRemediationGate.stepId,
638
+ decision: correctiveAudit?.decision ?? null,
639
+ });
640
+ }
500
641
  for (const requirement of task.requirements) {
501
642
  if (!plan.requirements.includes(requirement)) {
502
643
  throw new WorkflowError('TRANSITION_BLOCKED', `Plan omits Task requirement ${requirement}.`);
@@ -525,7 +666,24 @@ export class WorkflowService {
525
666
  plannedSteps.every((step) => step.status === 'completed')) {
526
667
  throw new WorkflowError('TRANSITION_BLOCKED', 'A failed review requires at least one new or changed remediation Step.');
527
668
  }
528
- const saved = this.store.writeTask({
669
+ if (currentReplanRequiredStepIds.some((stepId) => !plannedSteps.some((step) => step.id === stepId))) {
670
+ 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.`, {
671
+ taskId: task.id,
672
+ requiredStepIds: currentReplanRequiredStepIds,
673
+ plannedStepIds: plannedSteps.map((step) => step.id),
674
+ });
675
+ }
676
+ if (guardedRemediationGate && currentReplanDecisions.length === 0 && !plannedSteps.some((step) => step.id === guardedRemediationGate.stepId)) {
677
+ throw new WorkflowError('TRANSITION_BLOCKED', `Continue-fix must retain guarded Step ${guardedRemediationGate.stepId}; removing or renaming it would reset the remediation breaker.`, {
678
+ taskId: task.id,
679
+ stepId: guardedRemediationGate.stepId,
680
+ });
681
+ }
682
+ if (correctiveAudit && guardedRemediationGate && currentReplanDecisions.length === 0 && currentPlanRiskAudit) {
683
+ assertCorrectiveAuditorIndependence(this.store, task, guardedRemediationGate.stepId, correctiveAudit.auditor);
684
+ appendCorrectiveDecisionEvent(this.store, task, guardedRemediationGate.stepId, correctiveAudit, currentPlanRiskAudit, stagedPlan.hash, this.now());
685
+ }
686
+ const candidate = {
529
687
  ...task,
530
688
  status: 'awaiting_execution_authorization',
531
689
  planHash: stagedPlan.hash,
@@ -545,8 +703,16 @@ export class WorkflowService {
545
703
  resultHash: null,
546
704
  evidenceHash: null,
547
705
  blockReason: null,
548
- }, expectedRevision);
706
+ };
707
+ if (planRiskAudit)
708
+ validatePlanRiskAuditCandidate(this.store, candidate, planRiskAudit);
709
+ const saved = this.store.writeTask(candidate, expectedRevision);
549
710
  this.store.publishArtifact(stagedPlan);
711
+ if (planRiskAudit)
712
+ appendCurrentPlanRiskAudit(this.store, saved, planRiskAudit, this.now());
713
+ if (correctiveAudit && task.status === 'needs_fix' && task.review.status === 'failed') {
714
+ appendCorrectivePlanAudit(this.store.taskRoot(identity.projectId, taskId), task, saved, failedReviewAttempts, correctiveAudit, this.now());
715
+ }
550
716
  return saved;
551
717
  }
552
718
  rebindTaskKnowledge(repository, taskId, expectedRevision) {
@@ -630,8 +796,62 @@ export class WorkflowService {
630
796
  this.store.publishArtifact(stagedPlan);
631
797
  return saved;
632
798
  }
799
+ refreshTaskContext(repository, taskId, expectedTaskRevision, expectedKnowledgeMapRevision, actor, delegationGrantId) {
800
+ if (!delegationGrantId) {
801
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Task context refresh combines approval transitions and therefore requires an explicit delegated approval grant.');
802
+ }
803
+ const { identity } = this.context(repository);
804
+ const task = this.store.readTask(identity.projectId, taskId);
805
+ const currentMap = this.store.readKnowledgeMap(identity.projectId);
806
+ assertExpectedRevision(task, expectedTaskRevision);
807
+ assertExpectedRevision(currentMap, expectedKnowledgeMapRevision);
808
+ if (!taskCanRebindKnowledge(task)) {
809
+ throw new WorkflowError('TRANSITION_BLOCKED', `Cannot refresh Task context while Task is ${task.status}.`);
810
+ }
811
+ const activeStep = task.steps.find((step) => step.status === 'in_progress');
812
+ if (activeStep) {
813
+ throw new WorkflowError('TRANSITION_BLOCKED', `Cannot refresh Task context while ${activeStep.id} is in progress.`);
814
+ }
815
+ const inspectedMap = inspectKnowledgeMap(currentMap, scanProjectKnowledge(identity.repositoryRoot, identity.projectId, this.now()));
816
+ const transitions = [];
817
+ const currentPlanRiskAudit = readCurrentPlanRiskAudit(this.store, task);
818
+ // Validate every delegated transition before the first write so a narrow grant cannot
819
+ // leave the composite operation half-applied.
820
+ this.delegatedAuthorization(identity.projectId, delegationGrantId, 'task.execution_authorize', actor, task);
821
+ let knowledgeMap = currentMap;
822
+ if (inspectedMap.status !== 'active' || !inspectedMap.approval) {
823
+ if (currentMap.status !== 'active' || !currentMap.approval || inspectedMap.status !== 'stale') {
824
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Automatic Task context refresh requires a previously approved map with content-only drift.', { currentStatus: currentMap.status, inspectedStatus: inspectedMap.status });
825
+ }
826
+ assertContentOnlyKnowledgeRefresh(currentMap, inspectedMap);
827
+ this.delegatedAuthorization(identity.projectId, delegationGrantId, 'project_memory.approve', actor, inspectedMap);
828
+ knowledgeMap = this.reconcileKnowledgeMap(repository, currentMap.revision);
829
+ transitions.push('project-memory reconcile');
830
+ knowledgeMap = this.approveKnowledgeMap(repository, knowledgeMap.revision, [], actor, delegationGrantId);
831
+ transitions.push('project-memory approve');
832
+ }
833
+ const knowledgeMapHash = hashKnowledgeMap(knowledgeMap);
834
+ if (task.knowledgeMapRevision === knowledgeMap.revision
835
+ && task.knowledgeMapHash === knowledgeMapHash) {
836
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Task context is already current; refresh is a no-op.');
837
+ }
838
+ let refreshedTask = this.rebindTaskKnowledge(repository, taskId, task.revision);
839
+ if (currentPlanRiskAudit) {
840
+ appendReboundPlanRiskAudit(this.store, task, refreshedTask, currentPlanRiskAudit, this.now());
841
+ }
842
+ transitions.push('task knowledge-rebind');
843
+ refreshedTask = this.authorizeTask(repository, taskId, refreshedTask.revision, actor, 'Delegated content-only context refresh.', delegationGrantId);
844
+ transitions.push('task execution authorize');
845
+ return {
846
+ mode: 'delegated-content-only',
847
+ transitions,
848
+ knowledgeMap,
849
+ task: refreshedTask,
850
+ };
851
+ }
633
852
  authorizeTask(repository, taskId, expectedRevision, actor, reason = '', delegationGrantId = null) {
634
853
  const { identity } = this.context(repository);
854
+ this.readCurrentAdoptionPostureStrict(identity.projectId);
635
855
  const task = this.store.readTask(identity.projectId, taskId);
636
856
  assertExpectedRevision(task, expectedRevision);
637
857
  if (task.status !== 'awaiting_execution_authorization' || !task.planHash) {
@@ -641,6 +861,7 @@ export class WorkflowService {
641
861
  const root = this.store.taskRoot(identity.projectId, taskId);
642
862
  const briefHash = this.store.hashArtifact(root, 'brief.md');
643
863
  const planHash = this.store.hashArtifact(root, 'plan.md');
864
+ readCurrentPlanRiskAudit(this.store, task, { requireCurrentTaskRevision: true });
644
865
  const delegation = this.delegatedAuthorization(identity.projectId, delegationGrantId, 'task.execution_authorize', actor, task);
645
866
  const event = {
646
867
  kind: 'execution',
@@ -658,15 +879,164 @@ export class WorkflowService {
658
879
  };
659
880
  return this.store.writeTask({ ...task, status: 'ready', briefHash, planHash, authorizations: [...task.authorizations, event] }, expectedRevision);
660
881
  }
882
+ recordPlanRiskAudit(repository, taskId, expectedRevision, input) {
883
+ const { identity } = this.context(repository);
884
+ this.readCurrentAdoptionPostureStrict(identity.projectId);
885
+ const task = this.store.readTask(identity.projectId, taskId);
886
+ assertExpectedRevision(task, expectedRevision);
887
+ this.assertTaskKnowledgeBinding(identity.repositoryRoot, task);
888
+ return appendCurrentPlanRiskAudit(this.store, task, input, this.now());
889
+ }
890
+ showTaskHandoff(repository, taskId) {
891
+ const { identity } = this.context(repository);
892
+ const task = this.store.readTask(identity.projectId, taskId);
893
+ return {
894
+ task,
895
+ posture: readTaskC1Posture(this.store, task),
896
+ claimTokenAvailable: false,
897
+ };
898
+ }
899
+ handoffTask(repository, taskId, expectedRevision, actor, targetActor, reason, writerToken = null, grantId = null, expiresAt = null) {
900
+ const context = this.context(repository);
901
+ const task = this.store.readTask(context.identity.projectId, taskId);
902
+ assertExpectedRevision(task, expectedRevision);
903
+ const normalizedActor = actor.trim();
904
+ if (!normalizedActor) {
905
+ throw new WorkflowError('INVALID_ARGUMENT', 'Handoff actor is required.');
906
+ }
907
+ if (grantId)
908
+ this.assertHandoffGrant(context.identity.projectId, task, targetActor, grantId);
909
+ const milestone = task.milestoneId ? this.readMilestoneCurrent(task.projectId, task.milestoneId) : null;
910
+ const milestones = milestone ? this.listMilestonesCurrent(task.projectId) : [];
911
+ const milestoneDisplayNumber = milestone
912
+ ? milestones.findIndex((candidate) => candidate.id === milestone.id) + 1
913
+ : null;
914
+ const taskDisplayNumber = milestone
915
+ ? milestone.memberships.findIndex((membership) => membership.taskId === task.id) + 1
916
+ : null;
917
+ const currentLease = context.locks.inspect(taskId);
918
+ let lease;
919
+ let acquiredHere = false;
920
+ if (currentLease) {
921
+ if (currentLease.owner !== normalizedActor) {
922
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} active writer lease belongs to a different actor.`, {
923
+ taskId,
924
+ leaseOwner: currentLease.owner,
925
+ actor: normalizedActor,
926
+ });
927
+ }
928
+ if (writerToken === null) {
929
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} handoff requires the active writer token while a lease is present.`, {
930
+ taskId,
931
+ leaseOwner: currentLease.owner,
932
+ expiresAt: currentLease.expiresAt,
933
+ });
934
+ }
935
+ lease = context.locks.heartbeat(taskId, writerToken);
936
+ }
937
+ else {
938
+ if (writerToken !== null) {
939
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} has no active writer lease for the provided writer token.`, {
940
+ taskId,
941
+ });
942
+ }
943
+ acquiredHere = true;
944
+ lease = context.locks.acquire(taskId, normalizedActor);
945
+ }
946
+ const releaseLease = () => context.locks.release(taskId, lease.token);
947
+ try {
948
+ const prepared = appendTaskHandoff(this.store, task, {
949
+ actor: normalizedActor,
950
+ targetActor,
951
+ reason,
952
+ writerToken: lease.token,
953
+ milestoneDisplayNumber,
954
+ taskDisplayNumber,
955
+ expectedNext: String(nextForTask(task).action ?? 'task show'),
956
+ grantId,
957
+ expiresAt: expiresAt ?? new Date(this.now().getTime() + 24 * 60 * 60 * 1000).toISOString(),
958
+ }, this.now());
959
+ releaseLease();
960
+ return { task, event: prepared.event, bundle: prepared.bundle };
961
+ }
962
+ catch (error) {
963
+ if (acquiredHere)
964
+ releaseLease();
965
+ throw error;
966
+ }
967
+ }
968
+ claimTask(repository, taskId, expectedRevision, actor, claimToken, writerToken = null) {
969
+ const context = this.context(repository);
970
+ const task = this.store.readTask(context.identity.projectId, taskId);
971
+ assertExpectedRevision(task, expectedRevision);
972
+ const normalizedActor = actor.trim();
973
+ if (!normalizedActor) {
974
+ throw new WorkflowError('INVALID_ARGUMENT', 'Claim actor is required.');
975
+ }
976
+ const pendingHandoff = readTaskC1Posture(this.store, task);
977
+ if (pendingHandoff.state === 'pending' && pendingHandoff.latestEvent.grantId) {
978
+ this.assertHandoffGrant(context.identity.projectId, task, normalizedActor, pendingHandoff.latestEvent.grantId);
979
+ }
980
+ const existingLease = context.locks.inspect(taskId);
981
+ let lease;
982
+ let acquiredHere = false;
983
+ if (existingLease) {
984
+ if (existingLease.owner !== normalizedActor) {
985
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} existing writer lease belongs to a different actor.`, {
986
+ taskId,
987
+ leaseOwner: existingLease.owner,
988
+ actor: normalizedActor,
989
+ });
990
+ }
991
+ if (writerToken === null) {
992
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} claim requires the existing writer token.`, {
993
+ taskId,
994
+ leaseOwner: existingLease.owner,
995
+ });
996
+ }
997
+ lease = context.locks.heartbeat(taskId, writerToken);
998
+ }
999
+ else {
1000
+ if (writerToken !== null) {
1001
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} has no active writer lease for the provided writer token.`, {
1002
+ taskId,
1003
+ });
1004
+ }
1005
+ acquiredHere = true;
1006
+ lease = context.locks.acquire(taskId, normalizedActor);
1007
+ }
1008
+ try {
1009
+ const event = appendTaskClaim(this.store, task, actor, claimToken, lease.token, this.now());
1010
+ return { task, event, lease };
1011
+ }
1012
+ catch (error) {
1013
+ if (acquiredHere)
1014
+ context.locks.release(taskId, lease.token);
1015
+ throw error;
1016
+ }
1017
+ }
1018
+ handbackTask(repository, taskId, expectedRevision, actor, reason, writerToken = null, limitations = [], recommendedNext = 'Coordinator reviews the terminal Task result and recorded evidence.') {
1019
+ const context = this.context(repository);
1020
+ const task = this.store.readTask(context.identity.projectId, taskId);
1021
+ assertExpectedRevision(task, expectedRevision);
1022
+ const lease = task.status === 'merged' || task.status === 'cancelled'
1023
+ ? context.locks.inspect(taskId)
1024
+ : this.proveWriterLeaseTokenForHandoff(context, taskId, writerToken);
1025
+ const event = appendTaskHandback(this.store, task, actor, reason, lease?.token ?? writerToken, limitations, recommendedNext, this.now());
1026
+ if (lease)
1027
+ context.locks.release(taskId, lease.token);
1028
+ return { task, event };
1029
+ }
661
1030
  startTask(repository, taskId, expectedRevision, workspaceOwner) {
662
1031
  const { identity } = this.context(repository);
663
1032
  const task = this.store.readTask(identity.projectId, taskId);
664
1033
  assertExpectedRevision(task, expectedRevision);
1034
+ this.requireExecutionAdoptionPosture(identity, taskId);
665
1035
  if (task.status !== 'ready')
666
1036
  throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} is ${task.status}.`);
667
1037
  this.assertExecutionAuthorizationFresh(task);
668
1038
  if (task.milestoneId) {
669
- const milestone = this.store.readMilestone(identity.projectId, task.milestoneId);
1039
+ const milestone = this.readMilestoneCurrent(identity.projectId, task.milestoneId);
670
1040
  const membership = milestone.memberships.find((candidate) => candidate.taskId === taskId);
671
1041
  if (milestone.status !== 'active' || membership?.disposition !== 'required') {
672
1042
  throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone-linked Task requires an active, authorized membership.', {
@@ -675,6 +1045,15 @@ export class WorkflowService {
675
1045
  disposition: membership?.disposition ?? null,
676
1046
  });
677
1047
  }
1048
+ const handoffPosture = readTaskC1Posture(this.store, task);
1049
+ if (handoffPosture.state !== 'claimed') {
1050
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone-linked Task start requires a prepared and claimed C1 handoff bundle.', {
1051
+ taskId: task.id,
1052
+ milestoneId: milestone.id,
1053
+ c1Posture: handoffPosture.state,
1054
+ requiredAction: handoffPosture.state === 'pending' ? 'Claim the prepared handoff.' : 'Prepare and claim a C1 handoff.',
1055
+ });
1056
+ }
678
1057
  }
679
1058
  if (!isClean(identity.repositoryRoot)) {
680
1059
  throw new WorkflowError('GIT_PRECONDITION_FAILED', 'Task start requires a clean checkout.', {
@@ -700,20 +1079,48 @@ export class WorkflowService {
700
1079
  }
701
1080
  return this.store.writeTask({ ...task, baseCommit, taskBranch, workspaceOwner, blockReason: null }, expectedRevision);
702
1081
  }
703
- runStep(repository, taskId, stepId, expectedRevision, owner, writerToken) {
1082
+ runStep(repository, taskId, stepId, expectedRevision, actor, writerToken) {
704
1083
  const context = this.context(repository);
705
1084
  const task = this.store.readTask(context.identity.projectId, taskId);
706
1085
  assertExpectedRevision(task, expectedRevision);
1086
+ this.requireExecutionAdoptionPosture(context.identity, taskId);
707
1087
  if (!['ready', 'in_progress', 'needs_fix'].includes(task.status)) {
708
1088
  throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} cannot run a Step while ${task.status}.`);
709
1089
  }
710
1090
  this.assertExecutionAuthorizationFresh(task);
711
1091
  const knowledgeMap = this.assertTaskKnowledgeBinding(context.identity.repositoryRoot, task);
1092
+ const currentPlanBlockingDecisions = readCorrectiveDecisionEvents(this.store, task)
1093
+ .filter((event) => event.planHash === task.planHash && event.decision !== 'continue-fix');
1094
+ if (currentPlanBlockingDecisions.length > 0) {
1095
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} cannot run any Step while the current Plan has active blocking corrective decisions.`, {
1096
+ taskId,
1097
+ decisions: currentPlanBlockingDecisions.map((event) => ({
1098
+ eventId: event.eventId,
1099
+ stepId: event.stepId,
1100
+ decision: event.decision,
1101
+ planHash: event.planHash,
1102
+ triggeringAttemptCount: event.triggeringAttemptCount,
1103
+ })),
1104
+ });
1105
+ }
712
1106
  assertTaskBranch(context.identity.repositoryRoot, task);
713
1107
  const step = requireStep(task, stepId);
1108
+ const remediationHistoryForStep = readRemediationEvents(this.store, task).filter((event) => event.stepId === stepId);
714
1109
  if (!['planned', 'failed'].includes(step.status)) {
715
1110
  throw new WorkflowError('TRANSITION_BLOCKED', `${stepId} cannot start from ${step.status}.`);
716
1111
  }
1112
+ const planRiskAudit = readCurrentPlanRiskAudit(this.store, task);
1113
+ if (remediationHistoryForStep.length > 0 && (!planRiskAudit || !isStepReviewRequired(task, stepId, planRiskAudit.reviewRequiredStepIds))) {
1114
+ 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.`, {
1115
+ taskId,
1116
+ stepId,
1117
+ remediationAttemptCount: remediationHistoryForStep.length,
1118
+ currentAuditPresent: Boolean(planRiskAudit),
1119
+ });
1120
+ }
1121
+ if (planRiskAudit && isStepReviewRequired(task, stepId, planRiskAudit.reviewRequiredStepIds)) {
1122
+ assertGuardedRemediationAttemptAllowed(this.store, task, stepId, planRiskAudit);
1123
+ }
717
1124
  const dirty = changedFiles(context.identity.repositoryRoot);
718
1125
  if (step.status === 'planned' && dirty.length > 0) {
719
1126
  throw new WorkflowError('GIT_PRECONDITION_FAILED', `${stepId} must start from a clean checkout.`, {
@@ -732,18 +1139,41 @@ export class WorkflowService {
732
1139
  incompleteDependencies,
733
1140
  });
734
1141
  }
1142
+ const handoffPosture = readTaskC1Posture(this.store, task);
1143
+ if (handoffPosture.state === 'pending') {
1144
+ this.assertTaskMutationAllowedByHandoff(task, actor, null);
1145
+ }
735
1146
  const existing = context.locks.inspect(taskId);
736
1147
  let acquiredHere = false;
737
- const lease = existing
738
- ? writerToken
739
- ? context.locks.heartbeat(taskId, writerToken)
1148
+ let lease;
1149
+ if (handoffPosture.state === 'claimed') {
1150
+ if (!existing) {
1151
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} claimed C1 posture requires an active writer lease before task run.`, {
1152
+ taskId,
1153
+ claimant: handoffPosture.claimant,
1154
+ });
1155
+ }
1156
+ if (!writerToken) {
1157
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} claimed C1 posture requires the bound writer token for task run.`, {
1158
+ taskId,
1159
+ claimant: handoffPosture.claimant,
1160
+ });
1161
+ }
1162
+ lease = context.locks.heartbeat(taskId, writerToken);
1163
+ this.assertTaskMutationAllowedByHandoff(task, actor, lease.token);
1164
+ }
1165
+ else {
1166
+ lease = existing
1167
+ ? writerToken
1168
+ ? context.locks.heartbeat(taskId, writerToken)
1169
+ : (() => {
1170
+ throw new WorkflowError('LOCKED', `${taskId} already has a writer.`, { owner: existing.owner });
1171
+ })()
740
1172
  : (() => {
741
- throw new WorkflowError('LOCKED', `${taskId} already has a writer.`, { owner: existing.owner });
742
- })()
743
- : (() => {
744
- acquiredHere = true;
745
- return context.locks.acquire(taskId, owner.trim() || 'worker');
746
- })();
1173
+ acquiredHere = true;
1174
+ return context.locks.acquire(taskId, actor.trim() || 'worker');
1175
+ })();
1176
+ }
747
1177
  const steps = task.steps.map((candidate) => candidate.id === stepId ? { ...candidate, status: 'in_progress' } : candidate);
748
1178
  let saved;
749
1179
  try {
@@ -761,7 +1191,7 @@ export class WorkflowService {
761
1191
  protocolVersion: PROTOCOL_VERSION,
762
1192
  packageVersion: PACKAGE_VERSION,
763
1193
  role: 'worker',
764
- objective: step.objective,
1194
+ objective: `${step.objective} Leave all Step changes uncommitted; Workflow core creates the atomic commit through task step-complete.`,
765
1195
  inputs: [
766
1196
  path.join(this.store.taskRoot(task.projectId, task.id), 'brief.md'),
767
1197
  path.join(this.store.taskRoot(task.projectId, task.id), 'plan.md'),
@@ -769,7 +1199,10 @@ export class WorkflowService {
769
1199
  allowedReads: [identityRelative(context.identity.repositoryRoot, '.')],
770
1200
  allowedWrites: step.allowedWrites,
771
1201
  commands: step.checks,
772
- forbiddenScope: step.forbiddenScope,
1202
+ forbiddenScope: [
1203
+ ...step.forbiddenScope,
1204
+ 'Manual Git staging, commits, amendments, resets, rebases, or other history mutations. Workflow core owns the Step commit.',
1205
+ ],
773
1206
  resultSchema: {
774
1207
  type: 'object',
775
1208
  required: ['summary', 'changedFiles', 'checks'],
@@ -783,6 +1216,7 @@ export class WorkflowService {
783
1216
  'Required change falls outside allowedWrites.',
784
1217
  'Implementation requires changing Brief, Plan, or acceptance.',
785
1218
  'A configured check cannot be run or interpreted safely.',
1219
+ 'Do not run git add or git commit. Return uncommitted changes to the coordinator, which must invoke task step-complete.',
786
1220
  ],
787
1221
  contextBudget: 'medium',
788
1222
  taskId,
@@ -794,13 +1228,26 @@ export class WorkflowService {
794
1228
  },
795
1229
  };
796
1230
  }
797
- taskContext(repository, taskId, role) {
1231
+ taskContext(repository, taskId, role, allowHistoricalStrictReview = false) {
798
1232
  const { identity } = this.context(repository);
799
1233
  const task = this.store.readTask(identity.projectId, taskId);
1234
+ const historicalStrictReview = allowHistoricalStrictReview
1235
+ && role === 'independent-reviewer'
1236
+ && task.steps.some((step) => step.status === 'in_progress' && step.evidence !== null);
800
1237
  const knowledgeMap = task.knowledgeMapRevision === null
801
1238
  ? this.requireActiveKnowledgeMap(identity.repositoryRoot, identity.projectId)
802
- : this.assertTaskKnowledgeBinding(identity.repositoryRoot, task);
1239
+ : historicalStrictReview
1240
+ ? this.strictReviewKnowledgeMap(identity.repositoryRoot, task)
1241
+ : this.assertTaskKnowledgeBinding(identity.repositoryRoot, task);
803
1242
  const root = this.store.taskRoot(task.projectId, task.id);
1243
+ const planRiskAudit = task.planHash && !['planning', 'merged', 'cancelled'].includes(task.status)
1244
+ ? readCurrentPlanRiskAudit(this.store, task)
1245
+ : null;
1246
+ const planRiskReviewScope = planRiskAudit
1247
+ ? planRiskAudit.reviewRequiredStepIds.length > 0
1248
+ ? ` Current Plan Risk Audit marks ${planRiskAudit.reviewRequiredStepIds.join(', ')} for strict review.`
1249
+ : ' Current Plan Risk Audit marks no Steps for strict review.'
1250
+ : '';
804
1251
  const common = {
805
1252
  protocolVersion: PROTOCOL_VERSION,
806
1253
  packageVersion: PACKAGE_VERSION,
@@ -822,17 +1269,25 @@ export class WorkflowService {
822
1269
  if (role === 'technical-planner') {
823
1270
  return {
824
1271
  ...common,
825
- objective: `Produce an execution-ready Plan for ${task.title}.`,
1272
+ 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}`,
826
1273
  resultSchema: {
827
1274
  type: 'object',
828
1275
  required: ['objective', 'requirements', 'acceptance', 'risks', 'steps'],
829
1276
  },
1277
+ stopConditions: [
1278
+ ...common.stopConditions,
1279
+ 'A migration is planned without a populated forward-upgrade check.',
1280
+ 'Concurrency or cursor/state ownership is planned without deterministic race checks.',
1281
+ 'Restart, replay, or crash recovery is claimed without restart/crash-window evidence.',
1282
+ 'An external provider is used without failure-injection and hidden-default verification.',
1283
+ 'The safe risk slice crosses persistence, recovery orchestration, and executable composition and should be split.',
1284
+ ],
830
1285
  };
831
1286
  }
832
1287
  if (role === 'independent-reviewer') {
833
1288
  return {
834
1289
  ...common,
835
- objective: `Review ${task.title} against Brief, Plan, diff, checks, and evidence without modifying files.`,
1290
+ objective: `Review ${task.title} against Brief, Plan, diff, checks, and evidence without modifying files.${planRiskReviewScope}`,
836
1291
  inputs: [...common.inputs, ...(task.evidenceHash ? [path.join(root, 'evidence.json')] : [])],
837
1292
  resultSchema: {
838
1293
  type: 'object',
@@ -842,28 +1297,65 @@ export class WorkflowService {
842
1297
  ...common.stopConditions,
843
1298
  'The checkout is dirty or the evidence does not identify the reviewed commit.',
844
1299
  'Read-only reviewer isolation cannot be established; return unverified.',
1300
+ 'High-risk behavior lacks populated-upgrade, race, restart/replay, crash-window, or provider-failure evidence required by the Plan.',
845
1301
  ],
846
1302
  };
847
1303
  }
848
1304
  return {
849
1305
  ...common,
850
- objective: `Coordinate the next valid state transition for ${task.title}.`,
1306
+ objective: `Coordinate the next valid state transition for ${task.title}.${planRiskReviewScope}`,
851
1307
  resultSchema: { type: 'object', required: ['recommendedAction', 'reason'] },
852
1308
  };
853
1309
  }
854
- completeStep(repository, taskId, stepId, expectedRevision, writerToken, notes) {
1310
+ completeStep(repository, taskId, stepId, expectedRevision, writerToken, notes, actor) {
855
1311
  const context = this.context(repository);
856
- context.locks.heartbeat(taskId, writerToken);
1312
+ const lease = context.locks.heartbeat(taskId, writerToken);
1313
+ this.readCurrentAdoptionPostureStrict(context.identity.projectId);
857
1314
  const task = this.store.readTask(context.identity.projectId, taskId);
858
1315
  assertExpectedRevision(task, expectedRevision);
1316
+ this.assertTaskMutationAllowedByHandoff(task, actor ?? null, lease.token);
859
1317
  assertTaskBranch(context.identity.repositoryRoot, task);
860
1318
  const step = requireStep(task, stepId);
861
1319
  if (task.status !== 'in_progress' || step.status !== 'in_progress') {
862
1320
  throw new WorkflowError('TRANSITION_BLOCKED', `${stepId} is not in progress.`);
863
1321
  }
1322
+ const planRiskAudit = readCurrentPlanRiskAudit(this.store, task);
1323
+ const reviewRequired = planRiskAudit
1324
+ ? isStepReviewRequired(task, stepId, planRiskAudit.reviewRequiredStepIds)
1325
+ : false;
1326
+ if (reviewRequired && step.evidence) {
1327
+ validateTaskHistory(context.identity.repositoryRoot, task);
1328
+ if (!isClean(context.identity.repositoryRoot)) {
1329
+ throw new WorkflowError('GIT_PRECONDITION_FAILED', 'Strict Step Review recovery requires a clean Task checkout.', {
1330
+ changedFiles: changedFiles(context.identity.repositoryRoot),
1331
+ });
1332
+ }
1333
+ const currentHead = headCommit(context.identity.repositoryRoot);
1334
+ if (currentHead !== step.evidence.commitSha) {
1335
+ throw new WorkflowError('GIT_PRECONDITION_FAILED', 'Strict Step Review recovery requires HEAD to remain at the recorded completion commit.', {
1336
+ stepId,
1337
+ expectedCommit: step.evidence.commitSha,
1338
+ actualCommit: currentHead,
1339
+ });
1340
+ }
1341
+ const currentCycle = buildCurrentStrictStepReviewCycle(this.store, task, stepId);
1342
+ if (!currentCycle.pendingEvent && !currentCycle.latestMatchingEvent) {
1343
+ ensurePendingStrictStepReview(this.store, task, stepId, this.now());
1344
+ }
1345
+ return task;
1346
+ }
1347
+ validateTaskHistory(context.identity.repositoryRoot, task);
864
1348
  const checks = runChecks(context.identity.repositoryRoot, step.checks);
865
1349
  const failed = checks.filter((check) => !check.passed);
866
1350
  if (failed.length > 0) {
1351
+ if (reviewRequired && planRiskAudit) {
1352
+ appendRemediationEvent(this.store, task, stepId, 'checks-failed', {
1353
+ completionCommit: headCommit(context.identity.repositoryRoot),
1354
+ checksHash: sha256Hex(canonicalJsonStringify(checks)),
1355
+ reviewEventHash: null,
1356
+ reviewerAttestationHash: null,
1357
+ }, planRiskAudit, this.now());
1358
+ }
867
1359
  const steps = task.steps.map((candidate) => candidate.id === stepId ? { ...candidate, status: 'failed' } : candidate);
868
1360
  return this.store.writeTask({ ...task, status: 'needs_fix', steps, blockReason: `${stepId} checks failed.` }, expectedRevision);
869
1361
  }
@@ -875,6 +1367,18 @@ export class WorkflowService {
875
1367
  checks,
876
1368
  notes,
877
1369
  };
1370
+ if (reviewRequired) {
1371
+ const saved = this.store.writeTask({
1372
+ ...task,
1373
+ status: 'in_progress',
1374
+ steps: task.steps.map((candidate) => candidate.id === stepId
1375
+ ? { ...candidate, status: 'in_progress', evidence }
1376
+ : candidate),
1377
+ blockReason: null,
1378
+ }, expectedRevision);
1379
+ ensurePendingStrictStepReview(this.store, saved, stepId, this.now());
1380
+ return saved;
1381
+ }
878
1382
  const completedIds = new Set([
879
1383
  ...task.steps.filter((candidate) => candidate.status === 'completed').map((candidate) => candidate.id),
880
1384
  stepId,
@@ -889,11 +1393,266 @@ export class WorkflowService {
889
1393
  });
890
1394
  return this.store.writeTask({ ...task, status: 'in_progress', steps, blockReason: null }, expectedRevision);
891
1395
  }
892
- submitTask(repository, taskId, expectedRevision, writerToken) {
1396
+ alpha6C1StrictReviewRescuePreflight(repository) {
1397
+ const context = this.context(repository);
1398
+ const dependency = inspectLocalDependency(context.identity.repositoryRoot);
1399
+ const tasks = context.store.listTasks(context.identity.projectId);
1400
+ const dirty = changedFiles(context.identity.repositoryRoot);
1401
+ const currentHead = headCommit(context.identity.repositoryRoot);
1402
+ const now = this.now().getTime();
1403
+ const candidates = [];
1404
+ const malformedCandidates = [];
1405
+ for (const task of tasks) {
1406
+ if (task.status !== 'in_progress')
1407
+ continue;
1408
+ const planRiskAudit = task.planHash ? readCurrentPlanRiskAudit(this.store, task) : null;
1409
+ if (!planRiskAudit)
1410
+ continue;
1411
+ for (const step of task.steps) {
1412
+ if (step.status !== 'in_progress' || !step.evidence)
1413
+ continue;
1414
+ if (!isStepReviewRequired(task, step.id, planRiskAudit.reviewRequiredStepIds))
1415
+ continue;
1416
+ try {
1417
+ const cycle = buildCurrentStrictStepReviewCycle(this.store, task, step.id);
1418
+ if (cycle.resolution !== 'pending')
1419
+ continue;
1420
+ validateTaskHistory(context.identity.repositoryRoot, task);
1421
+ const storedKnowledgeMap = this.store.readKnowledgeMap(context.identity.projectId);
1422
+ const knowledgeMap = inspectKnowledgeMap(storedKnowledgeMap, scanProjectKnowledge(context.identity.repositoryRoot, context.identity.projectId, this.now()));
1423
+ const knowledgeBindingStale = knowledgeMap.status !== 'active'
1424
+ || task.knowledgeMapRevision !== knowledgeMap.revision
1425
+ || task.knowledgeMapHash !== hashKnowledgeMap(knowledgeMap);
1426
+ const posture = readTaskC1Posture(this.store, task);
1427
+ const lease = context.locks.inspect(task.id);
1428
+ const claimant = posture.state === 'claimed' ? posture.claimant : null;
1429
+ const tokenBound = posture.state === 'claimed'
1430
+ && lease !== null
1431
+ && posture.writerLeaseTokenHash !== null
1432
+ && posture.writerLeaseTokenHash === sha256Hex(lease.token);
1433
+ candidates.push({
1434
+ taskId: task.id,
1435
+ taskRevision: task.revision,
1436
+ stepId: step.id,
1437
+ completionCommit: step.evidence.commitSha,
1438
+ currentHead,
1439
+ knowledgeBindingStale,
1440
+ claimant,
1441
+ handoffExpiresAt: posture.state === 'claimed' ? posture.latestEvent.expiresAt : null,
1442
+ lease: lease
1443
+ ? {
1444
+ entityId: lease.entityId,
1445
+ owner: lease.owner,
1446
+ pid: lease.pid,
1447
+ hostname: lease.hostname,
1448
+ acquiredAt: lease.acquiredAt,
1449
+ heartbeatAt: lease.heartbeatAt,
1450
+ expiresAt: lease.expiresAt,
1451
+ stale: Date.parse(lease.expiresAt) <= now,
1452
+ tokenBound,
1453
+ }
1454
+ : null,
1455
+ });
1456
+ }
1457
+ catch (error) {
1458
+ malformedCandidates.push(error instanceof Error ? error.message : String(error));
1459
+ }
1460
+ }
1461
+ }
1462
+ const candidate = candidates.length === 1 ? candidates[0] : null;
1463
+ const unrelatedLeases = context.locks.list().filter((lease) => lease.entityId !== candidate?.taskId);
1464
+ const blockers = [
1465
+ ...(dependency.declared !== '2.0.0-alpha.6'
1466
+ ? ['Project package.json must declare codex-workflow-v2 exactly at 2.0.0-alpha.6.']
1467
+ : []),
1468
+ ...(dirty.length > 0 ? ['Repository checkout is not clean.'] : []),
1469
+ ...(candidates.length !== 1
1470
+ ? [`Expected exactly one pending alpha.6 strict review; found ${candidates.length}.`]
1471
+ : []),
1472
+ ...(candidate && candidate.currentHead !== candidate.completionCommit
1473
+ ? ['HEAD does not match the pending strict review completion commit.']
1474
+ : []),
1475
+ ...(candidate && !candidate.knowledgeBindingStale
1476
+ ? ['The pending strict review is not blocked by a stale Project Knowledge Map.']
1477
+ : []),
1478
+ ...(candidate && candidate.claimant === null
1479
+ ? ['The rescue Task does not have an active claimed C1 posture.']
1480
+ : []),
1481
+ ...(candidate && candidate.lease === null
1482
+ ? ['The claimed C1 rescue Task has no bound writer lease.']
1483
+ : []),
1484
+ ...(candidate?.lease && candidate.claimant !== candidate.lease.owner
1485
+ ? ['The writer lease owner does not match the active C1 claimant.']
1486
+ : []),
1487
+ ...(candidate?.lease && !candidate.lease.tokenBound
1488
+ ? ['The writer lease token does not match the active C1 binding.']
1489
+ : []),
1490
+ ...(unrelatedLeases.length > 0
1491
+ ? ['A writer lease exists outside the exact rescue Task.']
1492
+ : []),
1493
+ ...malformedCandidates.map((message) => `Pending strict review state is invalid: ${message}`),
1494
+ ];
1495
+ const eligible = blockers.length === 0 && candidate !== null && candidate.claimant !== null;
1496
+ return {
1497
+ eligible,
1498
+ mutationFree: true,
1499
+ runnerPackageVersion: PACKAGE_VERSION,
1500
+ requiredProjectPackageVersion: '2.0.0-alpha.6',
1501
+ declaredProjectPackageVersion: dependency.declared,
1502
+ projectId: context.identity.projectId,
1503
+ repositoryRoot: context.identity.repositoryRoot,
1504
+ clean: dirty.length === 0,
1505
+ candidate,
1506
+ blockers,
1507
+ requiredAction: eligible && candidate && candidate.claimant
1508
+ ? {
1509
+ action: 'update rescue-review',
1510
+ taskId: candidate.taskId,
1511
+ stepId: candidate.stepId,
1512
+ expectedRevision: candidate.taskRevision,
1513
+ actor: candidate.claimant,
1514
+ requiresBoundWriterToken: true,
1515
+ }
1516
+ : null,
1517
+ };
1518
+ }
1519
+ alpha6C1StrictReviewRescue(repository, taskId, stepId, expectedRevision, actor, writerToken, runner) {
1520
+ const preflight = this.alpha6C1StrictReviewRescuePreflight(repository);
1521
+ if (!preflight.eligible || !preflight.candidate || !preflight.requiredAction) {
1522
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Alpha.6 C1 strict-review rescue preflight is not eligible.', {
1523
+ blockers: preflight.blockers,
1524
+ });
1525
+ }
1526
+ const candidate = preflight.candidate;
1527
+ if (candidate.taskId !== taskId
1528
+ || candidate.stepId !== stepId
1529
+ || candidate.taskRevision !== expectedRevision) {
1530
+ throw new WorkflowError('STATE_CONFLICT', 'Rescue command does not match the exact preflight candidate.', {
1531
+ expected: preflight.requiredAction,
1532
+ actual: { taskId, stepId, expectedRevision, actor },
1533
+ });
1534
+ }
1535
+ if (candidate.claimant !== actor.trim()) {
1536
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Rescue actor does not match the active C1 claimant.', {
1537
+ expectedActor: candidate.claimant,
1538
+ actualActor: actor.trim(),
1539
+ });
1540
+ }
1541
+ const lease = this.context(repository).locks.inspect(taskId);
1542
+ if (!lease || lease.token !== writerToken) {
1543
+ throw new WorkflowError('LOCK_TOKEN_INVALID', `Writer token does not own ${taskId}`);
1544
+ }
1545
+ return this.reviewStepWithKnowledgePolicy(repository, taskId, stepId, expectedRevision, runner, actor, writerToken, true);
1546
+ }
1547
+ reviewStep(repository, taskId, stepId, expectedRevision, runner, actor, writerToken = null) {
1548
+ return this.reviewStepWithKnowledgePolicy(repository, taskId, stepId, expectedRevision, runner, actor, writerToken, false);
1549
+ }
1550
+ reviewStepWithKnowledgePolicy(repository, taskId, stepId, expectedRevision, runner, actor, writerToken, allowStaleKnowledgeBinding) {
893
1551
  const context = this.context(repository);
894
- context.locks.heartbeat(taskId, writerToken);
1552
+ this.readCurrentAdoptionPostureStrict(context.identity.projectId);
895
1553
  const task = this.store.readTask(context.identity.projectId, taskId);
896
1554
  assertExpectedRevision(task, expectedRevision);
1555
+ this.proveOptionalWriterLeaseToken(context, taskId, writerToken);
1556
+ this.assertTaskMutationAllowedByHandoff(task, actor ?? null, writerToken);
1557
+ if (task.status !== 'in_progress' && task.status !== 'blocked') {
1558
+ throw new WorkflowError('TRANSITION_BLOCKED', `Strict Step Review cannot run while task ${taskId} is ${task.status}.`);
1559
+ }
1560
+ assertTaskBranch(context.identity.repositoryRoot, task);
1561
+ if (!allowStaleKnowledgeBinding)
1562
+ this.assertTaskKnowledgeBinding(context.identity.repositoryRoot, task);
1563
+ const planRiskAudit = readCurrentPlanRiskAudit(this.store, task);
1564
+ if (!planRiskAudit || !isStepReviewRequired(task, stepId, planRiskAudit.reviewRequiredStepIds)) {
1565
+ throw new WorkflowError('TRANSITION_BLOCKED', `Step ${stepId} is not marked for strict review by the current Plan Risk Audit.`, {
1566
+ taskId: task.id,
1567
+ stepId,
1568
+ });
1569
+ }
1570
+ const step = requireStep(task, stepId);
1571
+ if (step.status !== 'in_progress' || !step.evidence) {
1572
+ throw new WorkflowError('TRANSITION_BLOCKED', `Strict Step Review requires ${stepId} to remain in progress with canonical evidence.`, {
1573
+ taskId: task.id,
1574
+ stepId,
1575
+ stepStatus: step.status,
1576
+ });
1577
+ }
1578
+ validateTaskHistory(context.identity.repositoryRoot, task);
1579
+ if (!isClean(context.identity.repositoryRoot)) {
1580
+ throw new WorkflowError('GIT_PRECONDITION_FAILED', 'Strict Step Review requires a clean Task checkout.', {
1581
+ changedFiles: changedFiles(context.identity.repositoryRoot),
1582
+ });
1583
+ }
1584
+ const currentHead = headCommit(context.identity.repositoryRoot);
1585
+ if (currentHead !== step.evidence.commitSha) {
1586
+ throw new WorkflowError('GIT_PRECONDITION_FAILED', 'Strict Step Review requires HEAD to remain at the recorded completion commit.', {
1587
+ taskId: task.id,
1588
+ stepId,
1589
+ expectedCommit: step.evidence.commitSha,
1590
+ actualCommit: currentHead,
1591
+ });
1592
+ }
1593
+ const currentCycle = buildCurrentStrictStepReviewCycle(this.store, task, stepId);
1594
+ if (task.status === 'blocked') {
1595
+ if (currentCycle.resolution !== 'unverified') {
1596
+ throw new WorkflowError('TRANSITION_BLOCKED', `Strict Step Review retry requires ${stepId} to be blocked by an unverified prior cycle.`, {
1597
+ taskId: task.id,
1598
+ stepId,
1599
+ resolution: currentCycle.resolution,
1600
+ });
1601
+ }
1602
+ assertGuardedRemediationAttemptAllowed(this.store, task, stepId, planRiskAudit);
1603
+ }
1604
+ else if (currentCycle.resolution !== 'pending') {
1605
+ if (currentCycle.resolution === 'failed' || currentCycle.resolution === 'unverified') {
1606
+ if (!currentCycle.reviewRecordedEvent || !currentCycle.reviewerAttestationEvent) {
1607
+ throw new WorkflowError('STATE_CORRUPT', 'Strict Step Review recovery requires exact review and attestation sidecars.', {
1608
+ taskId: task.id,
1609
+ stepId,
1610
+ resolution: currentCycle.resolution,
1611
+ });
1612
+ }
1613
+ appendRemediationEvent(this.store, task, stepId, currentCycle.resolution === 'failed' ? 'review-failed' : 'review-unverified', {
1614
+ completionCommit: currentCycle.reviewRecordedEvent.completionCommit,
1615
+ checksHash: currentCycle.reviewRecordedEvent.checksHash,
1616
+ reviewEventHash: currentCycle.reviewRecordedEvent.eventHash,
1617
+ reviewerAttestationHash: currentCycle.reviewerAttestationEvent.eventHash,
1618
+ }, planRiskAudit, this.now());
1619
+ }
1620
+ return this.store.writeTask(applyStrictStepReviewResolution(task, stepId, currentCycle.resolution), expectedRevision);
1621
+ }
1622
+ ensurePendingStrictStepReview(this.store, task, stepId, this.now());
1623
+ const envelope = this.taskContext(repository, taskId, 'independent-reviewer', allowStaleKnowledgeBinding);
1624
+ const launch = launchStrictStepReviewer(context.identity.repositoryRoot, task, this.store.taskRoot(task.projectId, task.id), envelope, stepId, step.evidence, runner);
1625
+ const recorded = recordStrictStepReview(this.store, task, stepId, {
1626
+ review: launch.review,
1627
+ reviewerOutputHash: launch.reviewerOutputHash,
1628
+ reviewedCommit: launch.reviewedCommit,
1629
+ isolationResult: launch.verifiedIsolation ? 'verified' : 'unverified',
1630
+ }, this.now());
1631
+ if (recorded.resolution === 'failed' || recorded.resolution === 'unverified') {
1632
+ const reviewRecordedEvent = recorded.reviewRecordedEvent;
1633
+ const reviewerAttestationEvent = recorded.reviewerAttestationEvent;
1634
+ if (!reviewRecordedEvent || !reviewerAttestationEvent) {
1635
+ throw new WorkflowError('STATE_CORRUPT', 'Strict Step Review remediation recording requires review and attestation sidecars.', {
1636
+ taskId: task.id,
1637
+ stepId,
1638
+ resolution: recorded.resolution,
1639
+ });
1640
+ }
1641
+ appendRemediationEvent(this.store, task, stepId, recorded.resolution === 'failed' ? 'review-failed' : 'review-unverified', {
1642
+ completionCommit: reviewRecordedEvent.completionCommit,
1643
+ checksHash: reviewRecordedEvent.checksHash,
1644
+ reviewEventHash: reviewRecordedEvent.eventHash,
1645
+ reviewerAttestationHash: reviewerAttestationEvent.eventHash,
1646
+ }, planRiskAudit, this.now());
1647
+ }
1648
+ return this.store.writeTask(applyStrictStepReviewResolution(task, stepId, recorded.resolution, launch.review.summary), expectedRevision);
1649
+ }
1650
+ submitTask(repository, taskId, expectedRevision, writerToken, actor) {
1651
+ const context = this.context(repository);
1652
+ const lease = context.locks.heartbeat(taskId, writerToken);
1653
+ const task = this.store.readTask(context.identity.projectId, taskId);
1654
+ assertExpectedRevision(task, expectedRevision);
1655
+ this.assertTaskMutationAllowedByHandoff(task, actor ?? null, lease.token);
897
1656
  assertTaskBranch(context.identity.repositoryRoot, task);
898
1657
  if (!['in_progress', 'ready'].includes(task.status)) {
899
1658
  throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} cannot be submitted while ${task.status}.`);
@@ -920,10 +1679,13 @@ export class WorkflowService {
920
1679
  this.store.publishArtifact(stagedEvidence);
921
1680
  return saved;
922
1681
  }
923
- recordReview(repository, taskId, expectedRevision, review) {
1682
+ recordReview(repository, taskId, expectedRevision, review, actor, writerToken = null) {
924
1683
  const { identity } = this.context(repository);
1684
+ this.readCurrentAdoptionPostureStrict(identity.projectId);
925
1685
  const task = this.store.readTask(identity.projectId, taskId);
926
1686
  assertExpectedRevision(task, expectedRevision);
1687
+ this.proveOptionalWriterLeaseToken(this.context(repository), taskId, writerToken);
1688
+ this.assertTaskMutationAllowedByHandoff(task, actor ?? null, writerToken);
927
1689
  const retryingUnverified = task.status === 'blocked' && task.review.status === 'unverified';
928
1690
  if (task.status !== 'validating' && !retryingUnverified) {
929
1691
  throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} is not ready for review.`);
@@ -947,7 +1709,11 @@ export class WorkflowService {
947
1709
  if (recorded.findings.length > 0) {
948
1710
  const file = path.join(this.store.taskRoot(task.projectId, task.id), 'findings.jsonl');
949
1711
  for (const finding of recorded.findings) {
950
- appendFileSync(file, `${JSON.stringify({ ...finding, recordedAt: recorded.recordedAt })}\n`, {
1712
+ appendFileSync(file, `${JSON.stringify({
1713
+ ...finding,
1714
+ reviewer: recorded.reviewer,
1715
+ recordedAt: recorded.recordedAt,
1716
+ })}\n`, {
951
1717
  encoding: 'utf8',
952
1718
  mode: 0o600,
953
1719
  });
@@ -955,6 +1721,59 @@ export class WorkflowService {
955
1721
  }
956
1722
  return saved;
957
1723
  }
1724
+ recordStepCorrectiveDecision(repository, taskId, stepId, expectedRevision, correctiveAudit, actor, writerToken = null) {
1725
+ const { identity } = this.context(repository);
1726
+ this.readCurrentAdoptionPostureStrict(identity.projectId);
1727
+ const task = this.store.readTask(identity.projectId, taskId);
1728
+ assertExpectedRevision(task, expectedRevision);
1729
+ this.proveOptionalWriterLeaseToken(this.context(repository), taskId, writerToken);
1730
+ this.assertTaskMutationAllowedByHandoff(task, actor ?? null, writerToken);
1731
+ if (task.status !== 'needs_fix' && task.status !== 'blocked') {
1732
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} cannot record a corrective decision while ${task.status}.`);
1733
+ }
1734
+ const planRiskAudit = readCurrentPlanRiskAudit(this.store, task);
1735
+ if (!planRiskAudit) {
1736
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} has no current Plan Risk Audit for corrective decision recording.`, {
1737
+ taskId,
1738
+ stepId,
1739
+ });
1740
+ }
1741
+ if (!task.planHash) {
1742
+ throw new WorkflowError('STATE_CORRUPT', `Task ${taskId} has no current Plan hash for corrective decision recording.`, {
1743
+ taskId,
1744
+ stepId,
1745
+ });
1746
+ }
1747
+ const step = requireStep(task, stepId);
1748
+ if (!isStepReviewRequired(task, stepId, planRiskAudit.reviewRequiredStepIds)) {
1749
+ throw new WorkflowError('TRANSITION_BLOCKED', `Step ${stepId} is not guarded by the current Plan Risk Audit and cannot receive a corrective decision.`, {
1750
+ taskId,
1751
+ stepId,
1752
+ });
1753
+ }
1754
+ if (task.status === 'needs_fix') {
1755
+ if (step.status !== 'failed') {
1756
+ throw new WorkflowError('TRANSITION_BLOCKED', `Corrective decision for ${stepId} requires a currently failed guarded Step while task ${taskId} is needs_fix.`, {
1757
+ taskId,
1758
+ stepId,
1759
+ stepStatus: step.status,
1760
+ });
1761
+ }
1762
+ }
1763
+ else {
1764
+ const currentCycle = buildCurrentStrictStepReviewCycle(this.store, task, stepId);
1765
+ if (step.status !== 'in_progress' || !step.evidence || currentCycle.resolution !== 'unverified') {
1766
+ throw new WorkflowError('TRANSITION_BLOCKED', `Corrective decision for ${stepId} requires a blocked unverified guarded Step with current evidence.`, {
1767
+ taskId,
1768
+ stepId,
1769
+ stepStatus: step.status,
1770
+ resolution: currentCycle.resolution,
1771
+ });
1772
+ }
1773
+ }
1774
+ assertCorrectiveAuditorIndependence(this.store, task, stepId, correctiveAudit.auditor);
1775
+ return appendCorrectiveDecisionEvent(this.store, task, stepId, correctiveAudit, planRiskAudit, task.planHash, this.now());
1776
+ }
958
1777
  setTaskResult(repository, taskId, expectedRevision, summary, limitations) {
959
1778
  const { identity } = this.context(repository);
960
1779
  const task = this.store.readTask(identity.projectId, taskId);
@@ -1059,10 +1878,12 @@ export class WorkflowService {
1059
1878
  this.store.publishArtifact(stagedEvidence);
1060
1879
  return saved;
1061
1880
  }
1062
- mergeTask(repository, taskId, expectedRevision, writerToken) {
1881
+ mergeTask(repository, taskId, expectedRevision, writerToken, actor) {
1063
1882
  const context = this.context(repository);
1064
1883
  const task = this.store.readTask(context.identity.projectId, taskId);
1065
1884
  assertExpectedRevision(task, expectedRevision);
1885
+ const lease = context.locks.heartbeat(taskId, writerToken);
1886
+ this.assertTaskMutationAllowedByHandoff(task, actor ?? null, lease.token);
1066
1887
  if (task.status !== 'merging' || task.workspaceOwner !== 'local' || !task.taskBranch || !task.baseCommit) {
1067
1888
  throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} is not ready for a local merge.`);
1068
1889
  }
@@ -1079,10 +1900,12 @@ export class WorkflowService {
1079
1900
  context.locks.release(taskId, writerToken);
1080
1901
  return saved;
1081
1902
  }
1082
- confirmExternalMerge(repository, taskId, expectedRevision, writerToken) {
1903
+ confirmExternalMerge(repository, taskId, expectedRevision, writerToken, actor) {
1083
1904
  const context = this.context(repository);
1084
1905
  const task = this.store.readTask(context.identity.projectId, taskId);
1085
1906
  assertExpectedRevision(task, expectedRevision);
1907
+ const lease = context.locks.heartbeat(taskId, writerToken);
1908
+ this.assertTaskMutationAllowedByHandoff(task, actor ?? null, lease.token);
1086
1909
  if (task.status !== 'ready_to_merge' || task.workspaceOwner !== 'external') {
1087
1910
  throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} is not awaiting external merge confirmation.`);
1088
1911
  }
@@ -1096,12 +1919,91 @@ export class WorkflowService {
1096
1919
  }
1097
1920
  status(repository) {
1098
1921
  const { identity } = this.context(repository);
1922
+ const tasks = this.store.listTasks(identity.projectId);
1923
+ const rawMilestones = this.store.listMilestones(identity.projectId);
1924
+ const adoption = this.inspectAdoptionPreparation(identity, tasks, rawMilestones);
1099
1925
  return {
1100
1926
  projectId: identity.projectId,
1101
1927
  discoveries: this.store.listDiscoveries(identity.projectId),
1102
- tasks: this.store.listTasks(identity.projectId),
1103
- milestones: this.store.listMilestones(identity.projectId),
1928
+ tasks,
1929
+ milestones: this.listMilestonesCurrent(identity.projectId, adoption.currentPosture),
1104
1930
  delegations: this.store.listDelegations(identity.projectId),
1931
+ adoption,
1932
+ };
1933
+ }
1934
+ recoverMilestoneTransactions(repository) {
1935
+ const { identity } = this.context(repository);
1936
+ const milestones = this.listMilestonesCurrent(identity.projectId, readCurrentAdoptionPosture(this.store, identity.projectId));
1937
+ return {
1938
+ projectId: identity.projectId,
1939
+ recoveredMilestoneIds: milestones.map((milestone) => milestone.id),
1940
+ };
1941
+ }
1942
+ prepareAdoption(repository) {
1943
+ const { identity } = this.context(repository);
1944
+ return this.inspectAdoptionPreparation(identity, this.store.listTasks(identity.projectId), this.store.listMilestones(identity.projectId));
1945
+ }
1946
+ applyAdoption(repository, actor, confirmationCode) {
1947
+ const context = this.context(repository);
1948
+ const tasks = context.store.listTasks(context.identity.projectId);
1949
+ const milestones = context.store.listMilestones(context.identity.projectId);
1950
+ return applyAdoptionPosture(this.store, {
1951
+ projectId: context.identity.projectId,
1952
+ repositoryIdentity: context.identity,
1953
+ packageVersion: PACKAGE_VERSION,
1954
+ protocolVersion: PROTOCOL_VERSION,
1955
+ stateSchemaVersion: STATE_SCHEMA_VERSION,
1956
+ tasks,
1957
+ milestones,
1958
+ sidecarInventory: this.collectAdoptionSidecarInventory(context.identity.projectId, tasks, milestones),
1959
+ blockers: this.computeAdoptionBoundaryBlockers(context.identity, tasks, context.locks.list()),
1960
+ now: this.now(),
1961
+ }, actor, confirmationCode);
1962
+ }
1963
+ updatePreflight(repository) {
1964
+ const context = this.context(repository);
1965
+ const tasks = context.store.listTasks(context.identity.projectId);
1966
+ const milestones = context.store.listMilestones(context.identity.projectId);
1967
+ const dirty = changedFiles(context.identity.repositoryRoot);
1968
+ const runningSteps = tasks.flatMap((task) => task.steps
1969
+ .filter((step) => step.status === 'in_progress')
1970
+ .map((step) => ({ taskId: task.id, stepId: step.id })));
1971
+ const leases = context.locks.list();
1972
+ const now = this.now().getTime();
1973
+ const activeLeases = leases.filter((lease) => Date.parse(lease.expiresAt) > now);
1974
+ const staleLeases = leases.filter((lease) => Date.parse(lease.expiresAt) <= now);
1975
+ const blockers = [
1976
+ ...(dirty.length > 0 ? ['Repository checkout is not clean.'] : []),
1977
+ ...(runningSteps.length > 0 ? ['At least one Worker Step is in progress.'] : []),
1978
+ ...(activeLeases.length > 0 ? ['At least one writer lease is active.'] : []),
1979
+ ...(staleLeases.length > 0 ? ['At least one stale writer lease requires explicit repair.'] : []),
1980
+ ];
1981
+ const adoption = buildAdoptionPreparation({
1982
+ projectId: context.identity.projectId,
1983
+ repositoryIdentity: context.identity,
1984
+ packageVersion: PACKAGE_VERSION,
1985
+ protocolVersion: PROTOCOL_VERSION,
1986
+ stateSchemaVersion: STATE_SCHEMA_VERSION,
1987
+ tasks,
1988
+ milestones,
1989
+ sidecarInventory: this.collectAdoptionSidecarInventory(context.identity.projectId, tasks, milestones),
1990
+ blockers,
1991
+ now: this.now(),
1992
+ }, readCurrentAdoptionPosture(this.store, context.identity.projectId));
1993
+ return {
1994
+ safe: blockers.length === 0,
1995
+ packageVersion: PACKAGE_VERSION,
1996
+ protocolVersion: PROTOCOL_VERSION,
1997
+ stateSchemaVersion: STATE_SCHEMA_VERSION,
1998
+ projectId: context.identity.projectId,
1999
+ repositoryRoot: context.identity.repositoryRoot,
2000
+ clean: dirty.length === 0,
2001
+ changedFiles: dirty,
2002
+ runningSteps,
2003
+ activeLeases,
2004
+ staleLeases,
2005
+ blockers,
2006
+ adoption,
1105
2007
  };
1106
2008
  }
1107
2009
  scanKnowledgeMap(repository) {
@@ -1177,12 +2079,35 @@ export class WorkflowService {
1177
2079
  }
1178
2080
  next(repository) {
1179
2081
  const status = this.status(repository);
2082
+ const adoptionInfo = {
2083
+ status: status.adoption.status,
2084
+ safe: status.adoption.safe,
2085
+ blockers: status.adoption.blockers,
2086
+ baselineDigestHash: status.adoption.baselineDigestHash,
2087
+ sidecarBaselineHash: status.adoption.sidecarBaselineHash,
2088
+ confirmationCodeBindingHash: status.adoption.confirmationCodeBindingHash,
2089
+ confirmationCode: status.adoption.confirmationCode,
2090
+ currentEventType: status.adoption.currentPosture?.eventType ?? null,
2091
+ };
2092
+ if (status.adoption.status !== 'applied') {
2093
+ return {
2094
+ scope: 'project',
2095
+ projectId: status.projectId,
2096
+ status: status.adoption.status,
2097
+ action: status.adoption.safe ? 'state adoption-apply' : 'restore safe boundary, then state adoption-prepare',
2098
+ adoption: adoptionInfo,
2099
+ };
2100
+ }
2101
+ const withAdoption = (value) => ({ ...value, adoption: adoptionInfo });
1180
2102
  const repositoryRoot = resolveRepositoryIdentity(repository).repositoryRoot;
1181
2103
  const milestonesById = new Map(status.milestones.map((milestone) => [milestone.id, milestone]));
1182
2104
  const activeTask = status.tasks.find((task) => taskIsActionableFromRepositoryNext(task, milestonesById));
1183
2105
  if (activeTask) {
1184
2106
  if (!taskCanRebindKnowledge(activeTask) && activeTask.status !== 'planning') {
1185
- return this.nextForTaskWithDelegation(status.projectId, activeTask);
2107
+ const planRiskAudit = activeTask.planHash
2108
+ ? readCurrentPlanRiskAudit(this.store, activeTask)
2109
+ : null;
2110
+ return withAdoption(this.nextForTaskWithDelegation(status.projectId, activeTask, planRiskAudit));
1186
2111
  }
1187
2112
  const knowledgeMap = this.knowledgeMapStatus(repository);
1188
2113
  if (knowledgeMap.status !== 'active' || !knowledgeMap.approval) {
@@ -1190,21 +2115,37 @@ export class WorkflowService {
1190
2115
  const delegatedApprovalOptions = action === 'project-memory approve'
1191
2116
  ? this.delegatedApprovalOptions(status.projectId, 'project_memory.approve', knowledgeMap)
1192
2117
  : [];
1193
- return {
2118
+ const storedKnowledgeMap = this.store.readKnowledgeMap(status.projectId);
2119
+ const contextRefreshOptions = action === 'project-memory reconcile'
2120
+ && storedKnowledgeMap.status === 'active'
2121
+ && storedKnowledgeMap.approval
2122
+ && isContentOnlyKnowledgeRefresh(storedKnowledgeMap, knowledgeMap)
2123
+ ? this.delegatedContextRefreshOptions(status.projectId, activeTask, knowledgeMap)
2124
+ : [];
2125
+ return withAdoption({
1194
2126
  scope: 'project-memory',
1195
2127
  status: knowledgeMap.status,
1196
2128
  action,
1197
2129
  revision: knowledgeMap.revision,
1198
2130
  taskId: activeTask.id,
1199
2131
  ...(delegatedApprovalOptions.length > 0 ? { delegatedApprovalOptions } : {}),
1200
- };
2132
+ ...(contextRefreshOptions.length > 0
2133
+ ? {
2134
+ contextRefresh: {
2135
+ action: 'task context-refresh',
2136
+ mode: 'delegated-content-only',
2137
+ options: contextRefreshOptions,
2138
+ },
2139
+ }
2140
+ : {}),
2141
+ });
1201
2142
  }
1202
2143
  const knowledgeMapHash = hashKnowledgeMap(knowledgeMap);
1203
2144
  if (taskCanRebindKnowledge(activeTask) &&
1204
2145
  activeTask.planHash &&
1205
2146
  (activeTask.knowledgeMapRevision !== knowledgeMap.revision ||
1206
2147
  activeTask.knowledgeMapHash !== knowledgeMapHash)) {
1207
- return {
2148
+ return withAdoption({
1208
2149
  scope: 'task',
1209
2150
  id: activeTask.id,
1210
2151
  status: activeTask.status,
@@ -1212,9 +2153,12 @@ export class WorkflowService {
1212
2153
  revision: activeTask.revision,
1213
2154
  knowledgeMapRevision: knowledgeMap.revision,
1214
2155
  knowledgeMapHash,
1215
- };
2156
+ });
1216
2157
  }
1217
- return this.nextForTaskWithDelegation(status.projectId, activeTask);
2158
+ const planRiskAudit = activeTask.planHash && activeTask.status !== 'planning'
2159
+ ? readCurrentPlanRiskAudit(this.store, activeTask)
2160
+ : null;
2161
+ return withAdoption(this.nextForTaskWithDelegation(status.projectId, activeTask, planRiskAudit));
1218
2162
  }
1219
2163
  const activeMilestone = status.milestones.find((milestone) => !['accepted', 'cancelled'].includes(milestone.status));
1220
2164
  if (activeMilestone) {
@@ -1232,17 +2176,17 @@ export class WorkflowService {
1232
2176
  evidence.headCommit !== currentHead ||
1233
2177
  resultHash !== activeMilestone.resultHash ||
1234
2178
  evidenceHash !== activeMilestone.evidenceHash) {
1235
- return {
2179
+ return withAdoption({
1236
2180
  scope: 'milestone',
1237
2181
  id: activeMilestone.id,
1238
2182
  status: activeMilestone.status,
1239
2183
  action: 'restore clean base, then milestone validate',
1240
2184
  revision: activeMilestone.revision,
1241
2185
  reason: 'final validation evidence is stale',
1242
- };
2186
+ });
1243
2187
  }
1244
2188
  const delegatedApprovalOptions = this.delegatedApprovalOptions(status.projectId, 'milestone.final_accept', activeMilestone);
1245
- return {
2189
+ return withAdoption({
1246
2190
  scope: 'milestone',
1247
2191
  id: activeMilestone.id,
1248
2192
  status: activeMilestone.status,
@@ -1251,32 +2195,53 @@ export class WorkflowService {
1251
2195
  : 'await explicit human final acceptance',
1252
2196
  revision: activeMilestone.revision,
1253
2197
  requiredHumanGate: milestoneFinalAcceptanceGate(activeMilestone, evidence.headCommit),
2198
+ completionPolicy: {
2199
+ resultEvidenceAndHeadMustMatch: true,
2200
+ finalAcceptanceTransition: 'milestone.final_accept',
2201
+ delegatedActorMustRemainDelegate: true,
2202
+ },
1254
2203
  ...(delegatedApprovalOptions.length > 0 ? { delegatedApprovalOptions } : {}),
1255
- };
2204
+ });
1256
2205
  }
1257
2206
  const delegatedApprovalOptions = activeMilestone.status === 'awaiting_execution_authorization'
1258
2207
  ? this.delegatedApprovalOptions(status.projectId, 'milestone.execution_authorize', activeMilestone)
1259
2208
  : [];
1260
- return {
2209
+ return withAdoption({
1261
2210
  scope: 'milestone',
1262
2211
  id: activeMilestone.id,
1263
2212
  status: activeMilestone.status,
1264
2213
  action: nextForMilestone(activeMilestone),
1265
2214
  revision: activeMilestone.revision,
2215
+ ...(activeMilestone.status === 'active'
2216
+ ? {
2217
+ completionPolicy: {
2218
+ requiredTasksMustBeMerged: true,
2219
+ validationAction: 'milestone validate',
2220
+ finalAcceptanceTransition: 'milestone.final_accept',
2221
+ delegatedActorMustRemainDelegate: true,
2222
+ requiredTasks: activeMilestone.memberships
2223
+ .filter((membership) => membership.disposition === 'required')
2224
+ .map((membership) => {
2225
+ const task = status.tasks.find((candidate) => candidate.id === membership.taskId);
2226
+ return { taskId: membership.taskId, status: task?.status ?? 'missing' };
2227
+ }),
2228
+ },
2229
+ }
2230
+ : {}),
1266
2231
  ...(delegatedApprovalOptions.length > 0 ? { delegatedApprovalOptions } : {}),
1267
- };
2232
+ });
1268
2233
  }
1269
2234
  const activeDiscovery = status.discoveries.find((discovery) => !['materialized', 'abandoned'].includes(discovery.status));
1270
2235
  if (activeDiscovery) {
1271
- return {
2236
+ return withAdoption({
1272
2237
  scope: 'discovery',
1273
2238
  id: activeDiscovery.id,
1274
2239
  status: activeDiscovery.status,
1275
2240
  action: activeDiscovery.status === 'ready_to_materialize' ? 'discovery materialize' : 'discovery update',
1276
2241
  readiness: assessDiscovery(activeDiscovery),
1277
- };
2242
+ });
1278
2243
  }
1279
- return { scope: 'repository', status: 'idle', action: 'discovery start' };
2244
+ return withAdoption({ scope: 'repository', status: 'idle', action: 'discovery start' });
1280
2245
  }
1281
2246
  delegatedAuthorization(projectId, grantId, transition, actor, target) {
1282
2247
  if (!grantId) {
@@ -1288,7 +2253,37 @@ export class WorkflowService {
1288
2253
  const grant = this.store.readDelegation(projectId, grantId);
1289
2254
  return assertDelegationCanAuthorize(grant, transition, actor, target, this.now());
1290
2255
  }
1291
- nextForTaskWithDelegation(projectId, task) {
2256
+ assertHandoffGrant(projectId, task, targetActor, grantId) {
2257
+ const grant = this.store.readDelegation(projectId, grantId);
2258
+ const policyHash = hashDelegationPolicy(projectId, {
2259
+ principal: grant.principal,
2260
+ delegate: grant.delegate,
2261
+ scope: grant.scope,
2262
+ transitions: grant.transitions,
2263
+ expiresAt: grant.expiresAt,
2264
+ });
2265
+ const scopeMatches = grant.scope.kind === 'project'
2266
+ || (grant.scope.kind === 'task' && grant.scope.id === task.id)
2267
+ || (grant.scope.kind === 'milestone' && grant.scope.id === task.milestoneId);
2268
+ if (grant.status !== 'active'
2269
+ || grant.policyHash !== policyHash
2270
+ || Date.parse(grant.expiresAt) <= this.now().getTime()
2271
+ || grant.delegate !== targetActor.trim()
2272
+ || !scopeMatches) {
2273
+ throw new WorkflowError('TRANSITION_BLOCKED', `Delegation ${grantId} cannot be bound to this C1 handoff.`, {
2274
+ grantId,
2275
+ taskId: task.id,
2276
+ targetActor: targetActor.trim(),
2277
+ grantStatus: grant.status,
2278
+ expectedPolicyHash: policyHash,
2279
+ actualPolicyHash: grant.policyHash,
2280
+ grantDelegate: grant.delegate,
2281
+ grantScope: grant.scope,
2282
+ grantExpiresAt: grant.expiresAt,
2283
+ });
2284
+ }
2285
+ }
2286
+ nextForTaskWithDelegation(projectId, task, planRiskAudit = null) {
1292
2287
  const transition = task.status === 'awaiting_execution_authorization'
1293
2288
  ? 'task.execution_authorize'
1294
2289
  : task.status === 'awaiting_final_acceptance'
@@ -1297,10 +2292,89 @@ export class WorkflowService {
1297
2292
  const delegatedApprovalOptions = transition
1298
2293
  ? this.delegatedApprovalOptions(projectId, transition, task)
1299
2294
  : [];
1300
- return {
1301
- ...nextForTask(task),
2295
+ const failedReviewAttempts = countFailedReviewAttempts(this.store.taskRoot(projectId, task.id));
2296
+ const next = nextForTask(task);
2297
+ const handoffPosture = readTaskC1Posture(this.store, task);
2298
+ const strictStepReviewStep = planRiskAudit
2299
+ ? task.steps.find((step) => {
2300
+ if (step.status !== 'in_progress' || !step.evidence || !planRiskAudit.reviewRequiredStepIds.includes(step.id)) {
2301
+ return false;
2302
+ }
2303
+ if (task.status !== 'blocked')
2304
+ return true;
2305
+ return buildCurrentStrictStepReviewCycle(this.store, task, step.id).resolution === 'unverified';
2306
+ }) ?? null
2307
+ : null;
2308
+ const handoffOverlay = handoffPosture.state === 'pending'
2309
+ ? {
2310
+ action: 'task claim',
2311
+ blockedAction: next.action,
2312
+ c1Handoff: {
2313
+ state: 'pending',
2314
+ targetActor: handoffPosture.targetActor,
2315
+ eventId: handoffPosture.latestEvent.eventId,
2316
+ },
2317
+ }
2318
+ : handoffPosture.state === 'claimed'
2319
+ ? {
2320
+ c1Handoff: {
2321
+ state: 'claimed',
2322
+ claimant: handoffPosture.claimant,
2323
+ writerLeaseTokenBound: handoffPosture.writerLeaseTokenHash !== null,
2324
+ requiredActor: handoffPosture.claimant,
2325
+ },
2326
+ }
2327
+ : {};
2328
+ const result = {
2329
+ ...next,
2330
+ ...handoffOverlay,
2331
+ ...(strictStepReviewStep
2332
+ ? {
2333
+ action: 'task step-review',
2334
+ stepId: strictStepReviewStep.id,
2335
+ commitPolicy: {
2336
+ owner: 'workflow-core',
2337
+ manualCommitAllowed: false,
2338
+ completionAction: 'task step-review',
2339
+ },
2340
+ strictStepReview: {
2341
+ reviewRequired: true,
2342
+ completionCommit: strictStepReviewStep.evidence.commitSha,
2343
+ },
2344
+ }
2345
+ : {}),
2346
+ ...(planRiskAudit
2347
+ ? {
2348
+ planRiskAudit: {
2349
+ decision: planRiskAudit.audit.decision,
2350
+ scope: planRiskAudit.scope,
2351
+ postureEventType: planRiskAudit.postureEventType,
2352
+ reviewRequiredStepIds: planRiskAudit.reviewRequiredStepIds,
2353
+ },
2354
+ }
2355
+ : {}),
2356
+ ...(task.status === 'needs_fix' && task.review.status === 'failed' && failedReviewAttempts >= 2
2357
+ ? {
2358
+ action: 'task plan-set with independent corrective audit',
2359
+ correctivePlanGate: {
2360
+ failedReviewAttempts,
2361
+ auditFileRequired: true,
2362
+ auditorMustDifferFromFailedReviewer: true,
2363
+ allowedDecision: 'continue-fix',
2364
+ escalationDecisions: ['replan-required', 'split-required', 'stop-escalate'],
2365
+ },
2366
+ }
2367
+ : {}),
1302
2368
  ...(delegatedApprovalOptions.length > 0 ? { delegatedApprovalOptions } : {}),
1303
2369
  };
2370
+ if (handoffPosture.state === 'pending') {
2371
+ return {
2372
+ ...result,
2373
+ action: 'task claim',
2374
+ blockedAction: next.action,
2375
+ };
2376
+ }
2377
+ return result;
1304
2378
  }
1305
2379
  delegatedApprovalOptions(projectId, transition, target) {
1306
2380
  return this.store.listDelegations(projectId)
@@ -1315,6 +2389,21 @@ export class WorkflowService {
1315
2389
  policyHash: grant.policyHash,
1316
2390
  }));
1317
2391
  }
2392
+ delegatedContextRefreshOptions(projectId, task, knowledgeMap) {
2393
+ return this.store.listDelegations(projectId)
2394
+ .filter((grant) => delegationCanAuthorize(grant, 'project_memory.approve', knowledgeMap, this.now()))
2395
+ .filter((grant) => delegationCanAuthorize(grant, 'task.execution_authorize', task, this.now()))
2396
+ .map((grant) => ({
2397
+ grantId: grant.id,
2398
+ principal: grant.principal,
2399
+ delegate: grant.delegate,
2400
+ transition: 'task.execution_authorize',
2401
+ transitions: ['project_memory.approve', 'task.execution_authorize'],
2402
+ scope: grant.scope,
2403
+ expiresAt: grant.expiresAt,
2404
+ policyHash: grant.policyHash,
2405
+ }));
2406
+ }
1318
2407
  assertDelegationScopeExists(projectId, policy) {
1319
2408
  if (policy.scope.kind === 'task') {
1320
2409
  this.store.readTask(projectId, policy.scope.id);
@@ -1352,6 +2441,75 @@ export class WorkflowService {
1352
2441
  throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone authorization is stale.');
1353
2442
  }
1354
2443
  }
2444
+ readMilestoneCurrent(projectId, milestoneId) {
2445
+ return readMilestoneWithIntegrity(this.store, projectId, milestoneId, (taskId) => this.store.readTask(projectId, taskId), () => this.store.listTasks(projectId));
2446
+ }
2447
+ readMilestoneScopeChangeCurrent(projectId, milestoneId) {
2448
+ return readMilestoneForScopeChange(this.store, projectId, milestoneId, (taskId) => this.store.readTask(projectId, taskId), () => this.store.listTasks(projectId));
2449
+ }
2450
+ listMilestonesCurrent(projectId, posture = this.readCurrentAdoptionPostureStrict(projectId)) {
2451
+ const milestones = this.store.listMilestones(projectId);
2452
+ if (!posture)
2453
+ return milestones;
2454
+ const grandfatheredMilestones = new Set(posture.terminalMilestones.map((milestone) => `${milestone.milestoneId}:${milestone.revision}:${milestone.status}`));
2455
+ return milestones.map((milestone) => (grandfatheredMilestones.has(`${milestone.id}:${milestone.revision}:${milestone.status}`)
2456
+ ? milestone
2457
+ : readMilestoneWithIntegrity(this.store, projectId, milestone.id, (taskId) => this.store.readTask(projectId, taskId), () => this.store.listTasks(projectId))));
2458
+ }
2459
+ prepareMilestoneScopeChangeCandidate(projectId, milestone, plan, actor) {
2460
+ validateMilestonePlan(plan);
2461
+ return prepareMilestoneScopeChangeCandidate({
2462
+ milestone,
2463
+ plan,
2464
+ readTask: (taskId) => this.store.readTask(projectId, taskId),
2465
+ actor,
2466
+ });
2467
+ }
2468
+ proveWriterLeaseTokenForHandoff(context, taskId, writerToken) {
2469
+ const currentLease = context.locks.inspect(taskId);
2470
+ if (!currentLease) {
2471
+ if (writerToken !== null) {
2472
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} has no active writer lease for the provided writer token.`, {
2473
+ taskId,
2474
+ });
2475
+ }
2476
+ return null;
2477
+ }
2478
+ if (writerToken === null) {
2479
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} requires the active writer token while a lease is present.`, {
2480
+ taskId,
2481
+ leaseOwner: currentLease.owner,
2482
+ expiresAt: currentLease.expiresAt,
2483
+ });
2484
+ }
2485
+ return context.locks.heartbeat(taskId, writerToken);
2486
+ }
2487
+ proveOptionalWriterLeaseToken(context, taskId, writerToken) {
2488
+ const currentLease = context.locks.inspect(taskId);
2489
+ if (!currentLease) {
2490
+ if (writerToken !== null) {
2491
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} has no active writer lease for the provided writer token.`, {
2492
+ taskId,
2493
+ });
2494
+ }
2495
+ return null;
2496
+ }
2497
+ if (writerToken === null)
2498
+ return null;
2499
+ return context.locks.heartbeat(taskId, writerToken);
2500
+ }
2501
+ assertTaskMutationAllowedByHandoff(task, actor, writerToken) {
2502
+ const posture = readTaskC1Posture(this.store, task);
2503
+ if (posture.state === 'none')
2504
+ return;
2505
+ if (!actor || !actor.trim()) {
2506
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} has active C1 coordination and requires an explicit actor for mutation.`, {
2507
+ taskId: task.id,
2508
+ posture: posture.state,
2509
+ });
2510
+ }
2511
+ assertTaskMutationAllowedByC1(this.store, task, { actor: actor.trim(), writerToken });
2512
+ }
1355
2513
  readGraphBindingOrNull(projectId, graphKind) {
1356
2514
  try {
1357
2515
  return this.store.readGraphBinding(projectId, graphKind);
@@ -1374,6 +2532,93 @@ export class WorkflowService {
1374
2532
  }
1375
2533
  return inspected;
1376
2534
  }
2535
+ strictReviewKnowledgeMap(repositoryRoot, task) {
2536
+ const stored = this.store.readKnowledgeMap(task.projectId);
2537
+ const inspected = inspectKnowledgeMap(stored, scanProjectKnowledge(repositoryRoot, task.projectId, this.now()));
2538
+ if (inspected.status === 'active' && inspected.approval)
2539
+ return inspected;
2540
+ const storedHash = hashKnowledgeMap(stored);
2541
+ if (stored.status === 'active'
2542
+ && stored.approval
2543
+ && task.knowledgeMapRevision === stored.revision
2544
+ && task.knowledgeMapHash === storedHash
2545
+ && stored.approval.mapHash === storedHash) {
2546
+ return stored;
2547
+ }
2548
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Strict Step Review has neither a current active Knowledge Map nor its exact historical Task binding.', {
2549
+ taskId: task.id,
2550
+ inspectedStatus: inspected.status,
2551
+ taskKnowledgeMapRevision: task.knowledgeMapRevision,
2552
+ storedKnowledgeMapRevision: stored.revision,
2553
+ taskKnowledgeMapHash: task.knowledgeMapHash,
2554
+ storedKnowledgeMapHash: storedHash,
2555
+ });
2556
+ }
2557
+ inspectAdoptionPreparation(identity, tasks, milestones) {
2558
+ const locks = new WriterLockManager(this.store.lockRoot(identity.projectId), this.now);
2559
+ const currentPosture = readCurrentAdoptionPosture(this.store, identity.projectId);
2560
+ if (currentPosture) {
2561
+ assertAdoptionBaselinePreserved(currentPosture, tasks, milestones);
2562
+ }
2563
+ return buildAdoptionPreparation({
2564
+ projectId: identity.projectId,
2565
+ repositoryIdentity: identity,
2566
+ packageVersion: PACKAGE_VERSION,
2567
+ protocolVersion: PROTOCOL_VERSION,
2568
+ stateSchemaVersion: STATE_SCHEMA_VERSION,
2569
+ tasks,
2570
+ milestones,
2571
+ sidecarInventory: this.collectAdoptionSidecarInventory(identity.projectId, tasks, milestones),
2572
+ blockers: this.computeAdoptionBoundaryBlockers(identity, tasks, locks.list()),
2573
+ now: this.now(),
2574
+ }, currentPosture);
2575
+ }
2576
+ computeAdoptionBoundaryBlockers(identity, tasks, leases) {
2577
+ const dirty = changedFiles(identity.repositoryRoot);
2578
+ const runningSteps = tasks.flatMap((task) => task.steps
2579
+ .filter((step) => step.status === 'in_progress')
2580
+ .map((step) => ({ taskId: task.id, stepId: step.id })));
2581
+ const now = this.now().getTime();
2582
+ const activeLeases = leases.filter((lease) => Date.parse(lease.expiresAt) > now);
2583
+ const staleLeases = leases.filter((lease) => Date.parse(lease.expiresAt) <= now);
2584
+ return [
2585
+ ...(dirty.length > 0 ? ['Repository checkout is not clean.'] : []),
2586
+ ...(runningSteps.length > 0 ? ['At least one Worker Step is in progress.'] : []),
2587
+ ...(activeLeases.length > 0 ? ['At least one writer lease is active.'] : []),
2588
+ ...(staleLeases.length > 0 ? ['At least one stale writer lease requires explicit repair.'] : []),
2589
+ ];
2590
+ }
2591
+ collectAdoptionSidecarInventory(projectId, tasks, milestones) {
2592
+ const inventory = [];
2593
+ const collect = (scope, ownerId, root) => {
2594
+ if (!existsSync(root))
2595
+ return;
2596
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
2597
+ if (entry.isSymbolicLink()) {
2598
+ throw new WorkflowError('STATE_CORRUPT', `Adoption sidecar inventory rejects symlink entries: ${path.join(root, entry.name)}`);
2599
+ }
2600
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
2601
+ continue;
2602
+ const filePath = path.join(root, entry.name);
2603
+ const stat = lstatSync(filePath);
2604
+ if (!stat.isFile()) {
2605
+ throw new WorkflowError('STATE_CORRUPT', `Adoption sidecar inventory requires regular files only: ${filePath}`);
2606
+ }
2607
+ inventory.push({
2608
+ scope,
2609
+ ownerId,
2610
+ sidecarName: entry.name,
2611
+ contentHash: createHash('sha256').update(readFileSync(filePath)).digest('hex'),
2612
+ });
2613
+ }
2614
+ };
2615
+ collect('project', projectId, this.store.projectRoot(projectId));
2616
+ for (const task of tasks)
2617
+ collect('task', task.id, this.store.taskRoot(projectId, task.id));
2618
+ for (const milestone of milestones)
2619
+ collect('milestone', milestone.id, this.store.milestoneRoot(projectId, milestone.id));
2620
+ return inventory;
2621
+ }
1377
2622
  assertKnowledgeMapBinding(repositoryRoot, projectId, revision, mapHash) {
1378
2623
  const map = this.requireActiveKnowledgeMap(repositoryRoot, projectId);
1379
2624
  const currentHash = hashKnowledgeMap(map);
@@ -1387,6 +2632,29 @@ export class WorkflowService {
1387
2632
  }
1388
2633
  return map;
1389
2634
  }
2635
+ readCurrentAdoptionPostureStrict(projectId) {
2636
+ const posture = readCurrentAdoptionPosture(this.store, projectId);
2637
+ if (!posture)
2638
+ return null;
2639
+ assertAdoptionBaselinePreserved(posture, this.store.listTasks(projectId), this.store.listMilestones(projectId));
2640
+ return posture;
2641
+ }
2642
+ requireExecutionAdoptionPosture(identity, taskId) {
2643
+ const tasks = this.store.listTasks(identity.projectId);
2644
+ const milestones = this.store.listMilestones(identity.projectId);
2645
+ const posture = this.readCurrentAdoptionPostureStrict(identity.projectId);
2646
+ if (posture)
2647
+ return posture;
2648
+ const adoption = this.inspectAdoptionPreparation(identity, tasks, milestones);
2649
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Alpha.6 adoption posture is required before execution can proceed.', {
2650
+ taskId,
2651
+ adoptionStatus: adoption.status,
2652
+ requiredAction: adoption.safe ? 'state adoption-apply' : 'restore safe boundary, then state adoption-prepare',
2653
+ blockers: adoption.blockers,
2654
+ confirmationCode: adoption.confirmationCode,
2655
+ confirmationCodeBindingHash: adoption.confirmationCodeBindingHash,
2656
+ });
2657
+ }
1390
2658
  assertTaskKnowledgeBinding(repositoryRoot, task) {
1391
2659
  if (task.knowledgeMapRevision === null || !task.knowledgeMapHash) {
1392
2660
  throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} has no Project Knowledge Map binding.`);
@@ -1458,6 +2726,62 @@ function dependenciesComplete(step, completed) {
1458
2726
  const completedIds = new Set(completed);
1459
2727
  return step.dependencies.every((dependency) => completedIds.has(dependency));
1460
2728
  }
2729
+ function applyStrictStepReviewResolution(task, stepId, resolution, summary) {
2730
+ if (resolution === 'pending') {
2731
+ throw new WorkflowError('STATE_CORRUPT', `Strict Step Review resolution is still pending for ${stepId}.`, {
2732
+ taskId: task.id,
2733
+ stepId,
2734
+ });
2735
+ }
2736
+ const step = requireStep(task, stepId);
2737
+ if (!step.evidence) {
2738
+ throw new WorkflowError('STATE_CORRUPT', `Strict Step Review resolution requires canonical evidence for ${stepId}.`, {
2739
+ taskId: task.id,
2740
+ stepId,
2741
+ resolution,
2742
+ });
2743
+ }
2744
+ if (resolution === 'passed_verified') {
2745
+ const completedIds = new Set([
2746
+ ...task.steps.filter((candidate) => candidate.status === 'completed').map((candidate) => candidate.id),
2747
+ stepId,
2748
+ ]);
2749
+ return {
2750
+ ...task,
2751
+ status: 'in_progress',
2752
+ steps: task.steps.map((candidate) => {
2753
+ if (candidate.id === stepId)
2754
+ return { ...candidate, status: 'completed', evidence: step.evidence };
2755
+ if (candidate.status === 'blocked' && dependenciesComplete(candidate, completedIds)) {
2756
+ return { ...candidate, status: 'planned' };
2757
+ }
2758
+ return candidate;
2759
+ }),
2760
+ blockReason: null,
2761
+ };
2762
+ }
2763
+ if (resolution === 'failed') {
2764
+ return {
2765
+ ...task,
2766
+ status: 'needs_fix',
2767
+ steps: task.steps.map((candidate) => candidate.id === stepId
2768
+ ? { ...candidate, status: 'failed', evidence: null }
2769
+ : candidate),
2770
+ blockReason: summary?.trim() || `Strict Step Review failed for ${stepId}.`,
2771
+ systemCommits: task.systemCommits.includes(step.evidence.commitSha)
2772
+ ? task.systemCommits
2773
+ : [...task.systemCommits, step.evidence.commitSha],
2774
+ };
2775
+ }
2776
+ return {
2777
+ ...task,
2778
+ status: 'blocked',
2779
+ steps: task.steps.map((candidate) => candidate.id === stepId
2780
+ ? { ...candidate, status: 'in_progress', evidence: step.evidence }
2781
+ : candidate),
2782
+ blockReason: summary?.trim() || `Strict Step Review isolation could not be verified for ${stepId}.`,
2783
+ };
2784
+ }
1461
2785
  function identityRelative(repositoryRoot, value) {
1462
2786
  return path.resolve(repositoryRoot, value);
1463
2787
  }
@@ -1511,6 +2835,83 @@ function validateReviewInput(review) {
1511
2835
  throw new WorkflowError('INVALID_ARGUMENT', 'A failed review requires at least one finding.');
1512
2836
  }
1513
2837
  }
2838
+ function countFailedReviewAttempts(taskRoot) {
2839
+ const file = path.join(taskRoot, 'findings.jsonl');
2840
+ if (!existsSync(file))
2841
+ return 0;
2842
+ const attempts = new Set();
2843
+ for (const line of readFileSync(file, 'utf8').split('\n').filter(Boolean)) {
2844
+ try {
2845
+ const finding = JSON.parse(line);
2846
+ if (typeof finding.recordedAt === 'string' && finding.recordedAt) {
2847
+ attempts.add(`${finding.recordedAt}\0${typeof finding.reviewer === 'string' ? finding.reviewer : ''}`);
2848
+ }
2849
+ }
2850
+ catch {
2851
+ throw new WorkflowError('STATE_CORRUPT', `Invalid review finding log: ${file}`);
2852
+ }
2853
+ }
2854
+ return attempts.size;
2855
+ }
2856
+ function validateCorrectivePlanAudit(task, audit, failedReviewAttempts) {
2857
+ if (!audit) {
2858
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} has ${failedReviewAttempts} failed independent reviews. A separate corrective Plan audit is required before another plan-set.`, {
2859
+ failedReviewAttempts,
2860
+ requiredAction: 'Run a separate read-only Auditor and pass its evidence through --corrective-audit-file.',
2861
+ });
2862
+ }
2863
+ if (!audit.auditor.trim() || !audit.summary.trim()) {
2864
+ throw new WorkflowError('INVALID_ARGUMENT', 'Corrective Plan audit requires auditor and summary.');
2865
+ }
2866
+ if (audit.auditor.trim() === task.review.reviewer.trim()) {
2867
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Corrective Plan audit must be performed by an Auditor distinct from the failed-review author.');
2868
+ }
2869
+ const latestFindingIds = new Set(task.review.findings.map((finding) => finding.id));
2870
+ const reviewedFindingIds = new Set(audit.reviewedFindingIds);
2871
+ if (reviewedFindingIds.size !== audit.reviewedFindingIds.length || audit.reviewedFindingIds.some((id) => !id.trim())) {
2872
+ throw new WorkflowError('INVALID_ARGUMENT', 'Corrective Plan audit finding IDs must be non-empty and unique.');
2873
+ }
2874
+ const missing = [...latestFindingIds].filter((findingId) => !reviewedFindingIds.has(findingId));
2875
+ if (missing.length > 0) {
2876
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Corrective Plan audit does not cover every latest finding.', {
2877
+ missingFindingIds: missing,
2878
+ });
2879
+ }
2880
+ if (audit.decision !== 'continue-fix') {
2881
+ throw new WorkflowError('TRANSITION_BLOCKED', `Corrective Auditor decision ${audit.decision} requires coordinator/user redirection before implementation continues.`, { decision: audit.decision, summary: audit.summary });
2882
+ }
2883
+ }
2884
+ function appendCorrectivePlanAudit(taskRoot, previous, saved, failedReviewAttempts, audit, now) {
2885
+ appendFileSync(path.join(taskRoot, 'corrective-plan-audits.jsonl'), `${JSON.stringify({
2886
+ recordedAt: now.toISOString(),
2887
+ taskId: previous.id,
2888
+ taskRevision: previous.revision,
2889
+ failedReviewAttempts,
2890
+ previousPlanHash: previous.planHash,
2891
+ correctivePlanHash: saved.planHash,
2892
+ ...audit,
2893
+ })}\n`, { encoding: 'utf8', mode: 0o600 });
2894
+ }
2895
+ function assertContentOnlyKnowledgeRefresh(approved, inspected) {
2896
+ if (!isContentOnlyKnowledgeRefresh(approved, inspected)) {
2897
+ 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.' });
2898
+ }
2899
+ }
2900
+ function isContentOnlyKnowledgeRefresh(approved, inspected) {
2901
+ const classification = (map) => JSON.stringify({
2902
+ entries: map.entries.map((entry) => ({
2903
+ id: entry.id,
2904
+ category: entry.category,
2905
+ path: entry.path,
2906
+ scope: entry.scope,
2907
+ authority: entry.authority,
2908
+ discoveredBy: entry.discoveredBy,
2909
+ })),
2910
+ gaps: map.gaps,
2911
+ conflicts: map.conflicts,
2912
+ });
2913
+ return classification(approved) === classification(inspected);
2914
+ }
1514
2915
  function validateMilestonePlan(plan) {
1515
2916
  if (!plan.outcome.trim())
1516
2917
  throw new WorkflowError('INVALID_ARGUMENT', 'Milestone outcome is required.');
@@ -1606,11 +3007,14 @@ function nextForMilestone(milestone) {
1606
3007
  }
1607
3008
  function nextForTask(task) {
1608
3009
  const stepsComplete = task.steps.every((step) => step.status === 'completed' || step.status === 'skipped');
3010
+ const activeStep = task.steps.find((step) => step.status === 'in_progress');
3011
+ const failedStep = task.steps.find((step) => step.status === 'failed');
3012
+ const runnableStep = task.steps.find((step) => step.status === 'planned' && step.dependencies.every((dependency) => ['completed', 'skipped'].includes(requireStep(task, dependency).status)));
1609
3013
  const actionByStatus = {
1610
3014
  planning: 'task plan-set',
1611
3015
  awaiting_execution_authorization: 'task authorize',
1612
3016
  ready: task.workspaceOwner ? (stepsComplete ? 'task submit' : 'task run') : 'task start',
1613
- in_progress: stepsComplete ? 'task submit' : 'task run or task submit',
3017
+ in_progress: activeStep ? 'task step-complete' : stepsComplete ? 'task submit' : 'task run',
1614
3018
  validating: task.review.status === 'passed' ? 'task result-set' : 'task review-record',
1615
3019
  needs_fix: task.steps.some((step) => step.status === 'failed') ? 'task run' : 'task plan-set',
1616
3020
  awaiting_final_acceptance: 'task accept',
@@ -1620,7 +3024,24 @@ function nextForTask(task) {
1620
3024
  blocked: task.review.status === 'unverified' ? 'task review-record' : 'doctor',
1621
3025
  cancelled: 'none',
1622
3026
  };
1623
- return { scope: 'task', id: task.id, status: task.status, action: actionByStatus[task.status], revision: task.revision };
3027
+ const selectedStep = activeStep ?? failedStep ?? runnableStep;
3028
+ return {
3029
+ scope: 'task',
3030
+ id: task.id,
3031
+ status: task.status,
3032
+ action: actionByStatus[task.status],
3033
+ revision: task.revision,
3034
+ ...(selectedStep ? { stepId: selectedStep.id } : {}),
3035
+ ...(activeStep
3036
+ ? {
3037
+ commitPolicy: {
3038
+ owner: 'workflow-core',
3039
+ manualCommitAllowed: false,
3040
+ completionAction: 'task step-complete',
3041
+ },
3042
+ }
3043
+ : {}),
3044
+ };
1624
3045
  }
1625
3046
  function knowledgeSummary(map) {
1626
3047
  return {