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