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