@devflow-tools/delivery-line 0.18.33 → 0.18.35
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/dist/adapters/plugin-capability.d.ts +1 -1
- package/dist/adapters/plugin-capability.d.ts.map +1 -1
- package/dist/adapters/plugin-capability.js +6 -6
- package/dist/adapters/plugin-capability.js.map +1 -1
- package/dist/domain/clarification-policy.js +1 -1
- package/dist/domain/clarification-policy.js.map +1 -1
- package/dist/domain/types.d.ts +12 -2
- package/dist/domain/types.d.ts.map +1 -1
- package/dist/domain/types.js.map +1 -1
- package/dist/facade/delivery-line-facade.d.ts +10 -1
- package/dist/facade/delivery-line-facade.d.ts.map +1 -1
- package/dist/facade/delivery-line-facade.js +255 -48
- package/dist/facade/delivery-line-facade.js.map +1 -1
- package/dist/persistence/memory-store.d.ts +1 -0
- package/dist/persistence/memory-store.d.ts.map +1 -1
- package/dist/persistence/memory-store.js +2 -0
- package/dist/persistence/memory-store.js.map +1 -1
- package/dist/persistence/sqlite-store.d.ts +1 -0
- package/dist/persistence/sqlite-store.d.ts.map +1 -1
- package/dist/persistence/sqlite-store.js +2 -0
- package/dist/persistence/sqlite-store.js.map +1 -1
- package/dist/ports/index.d.ts +5 -1
- package/dist/ports/index.d.ts.map +1 -1
- package/package.json +4 -4
|
@@ -45,6 +45,9 @@ function normalizeDecisionOptions(options) {
|
|
|
45
45
|
return true;
|
|
46
46
|
}).map(option => ({ id: option.id.trim(), label: option.label.trim(), ...(option.recommended ? { recommended: true } : {}) }));
|
|
47
47
|
}
|
|
48
|
+
function normalizeDecisionText(value) {
|
|
49
|
+
return value.replace(/[\u0000-\u001f\u007f\ufffd]+/gu, ' ').replace(/\s+/gu, ' ').trim();
|
|
50
|
+
}
|
|
48
51
|
function optionsForExistingImplementations(paths) {
|
|
49
52
|
return [
|
|
50
53
|
{ id: 'create-new', label: 'Create a new implementation' },
|
|
@@ -63,7 +66,16 @@ function nextActionsFor(record) {
|
|
|
63
66
|
nextActions.push('revise');
|
|
64
67
|
if (record.stage === 'intent_approved' || record.stage === 'designing')
|
|
65
68
|
nextActions.push('compare_options');
|
|
66
|
-
|
|
69
|
+
const approvalReady = (record.stage === 'clarifying'
|
|
70
|
+
&& record.interaction.openQuestions.length === 0
|
|
71
|
+
&& record.interaction.blockingUnknowns.length === 0
|
|
72
|
+
&& record.artifacts.some(artifact => artifact.type === 'intent'))
|
|
73
|
+
|| (record.stage === 'spec_ready' && record.artifacts.some(artifact => artifact.type === 'spec'))
|
|
74
|
+
|| (record.stage === 'plan_ready' && record.artifacts.some(artifact => artifact.type === 'plan'))
|
|
75
|
+
|| record.stage === 'reviewed';
|
|
76
|
+
if (record.stage === 'plan_ready' && !record.artifacts.some(artifact => artifact.type === 'plan'))
|
|
77
|
+
nextActions.push('prepare_plan');
|
|
78
|
+
if (approvalReady)
|
|
67
79
|
nextActions.push('approve');
|
|
68
80
|
if (record.blockers?.some(blocker => blocker.code === 'WORKTREE_ADMISSION_REQUIRED'))
|
|
69
81
|
nextActions.push('admit_worktree');
|
|
@@ -112,6 +124,52 @@ function releaseBindingHash(record) {
|
|
|
112
124
|
recipients: record.recipients ?? [],
|
|
113
125
|
});
|
|
114
126
|
}
|
|
127
|
+
function artifactApprovalDecision(record) {
|
|
128
|
+
let stage;
|
|
129
|
+
let artifact;
|
|
130
|
+
let artifactContentHash;
|
|
131
|
+
if (record.stage === 'clarifying' && record.interaction.blockingUnknowns.length === 0 && record.interaction.openQuestions.length === 0) {
|
|
132
|
+
stage = 'intent';
|
|
133
|
+
artifact = [...record.artifacts].reverse().find(item => item.type === 'intent');
|
|
134
|
+
artifactContentHash = artifact?.contentHash;
|
|
135
|
+
}
|
|
136
|
+
else if (record.stage === 'spec_ready') {
|
|
137
|
+
stage = 'spec';
|
|
138
|
+
artifact = [...record.artifacts].reverse().find(item => item.type === 'spec');
|
|
139
|
+
artifactContentHash = artifact?.contentHash;
|
|
140
|
+
}
|
|
141
|
+
else if (record.stage === 'plan_ready') {
|
|
142
|
+
stage = 'plan';
|
|
143
|
+
artifact = [...record.artifacts].reverse().find(item => item.type === 'plan');
|
|
144
|
+
artifactContentHash = artifact?.contentHash;
|
|
145
|
+
}
|
|
146
|
+
else if (record.stage === 'reviewed') {
|
|
147
|
+
stage = 'release';
|
|
148
|
+
artifactContentHash = releaseBindingHash(record);
|
|
149
|
+
}
|
|
150
|
+
if (!stage || !artifactContentHash)
|
|
151
|
+
return undefined;
|
|
152
|
+
const approval = {
|
|
153
|
+
stage,
|
|
154
|
+
...(artifact ? { artifactId: artifact.id, artifactVersion: artifact.version } : {}),
|
|
155
|
+
artifactContentHash,
|
|
156
|
+
};
|
|
157
|
+
const identity = artifact?.id ?? artifactContentHash.slice(0, 16);
|
|
158
|
+
return {
|
|
159
|
+
id: `${record.id}:approval:${stage}:v${record.version}:${identity}`,
|
|
160
|
+
caseId: record.id,
|
|
161
|
+
kind: 'artifact_approval',
|
|
162
|
+
caseVersion: record.version,
|
|
163
|
+
turn: record.interaction.turn,
|
|
164
|
+
targetDecisionHash: hash({ caseId: record.id, caseVersion: record.version, turn: record.interaction.turn, approval }),
|
|
165
|
+
options: stage === 'release'
|
|
166
|
+
? [{ id: 'approve', label: 'Approve release' }, { id: 'reject', label: 'Reject release' }]
|
|
167
|
+
: [{ id: 'approve', label: `Approve ${stage} and continue` }, { id: 'reject', label: `Request changes to ${stage}` }],
|
|
168
|
+
approval,
|
|
169
|
+
status: 'pending',
|
|
170
|
+
createdAt: Date.now(),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
115
173
|
export class DeliveryLineFacade {
|
|
116
174
|
ports;
|
|
117
175
|
projectRoot;
|
|
@@ -224,7 +282,10 @@ export class DeliveryLineFacade {
|
|
|
224
282
|
return this.view(record);
|
|
225
283
|
}
|
|
226
284
|
async status(caseId) { return this.view(await this.ports.store.get(caseId)); }
|
|
227
|
-
async pendingDecision(caseId) {
|
|
285
|
+
async pendingDecision(caseId) {
|
|
286
|
+
const record = await this.ports.store.get(caseId);
|
|
287
|
+
return this.ensurePendingDecision(record);
|
|
288
|
+
}
|
|
228
289
|
async resolveTrustedDecision(input) {
|
|
229
290
|
const evidence = input.evidence;
|
|
230
291
|
const record = await this.ports.store.get(evidence.caseId);
|
|
@@ -242,42 +303,73 @@ export class DeliveryLineFacade {
|
|
|
242
303
|
});
|
|
243
304
|
if (!signature.valid)
|
|
244
305
|
throw new DeliveryLineError('USER_DECISION_REQUIRES_TRUSTED_CHANNEL', 'trusted user evidence is invalid', { reason: signature.reason, recoveryAction: 'Submit the decision again through the host trusted user command.' });
|
|
245
|
-
if (evidence.purpose !== 'resolve_clarification' || !['clarification', 'option_selection'].includes(pending.kind))
|
|
246
|
-
throw new DeliveryLineError('INVALID_TRANSITION', 'this trusted command is not a clarification decision', { purpose: evidence.purpose, pendingKind: pending.kind, recoveryAction: 'Use /devflow:decide for the current pending clarification, or use the gated approval operation for an approval.' });
|
|
247
306
|
if (evidence.sessionId !== record.sessionId || !record.executionId || !record.contextReceipt)
|
|
248
307
|
throw new DeliveryLineError('UNAUTHORIZED_ROLE', 'trusted decision authority does not match the case session');
|
|
249
|
-
|
|
308
|
+
const expectedAuthority = pending.kind === 'artifact_approval' ? record.authorities?.approvalAuthority : record.authorities?.decisionAuthority;
|
|
309
|
+
if (expectedAuthority && expectedAuthority.id !== evidence.actorId)
|
|
250
310
|
throw new DeliveryLineError('UNAUTHORIZED_ROLE', 'trusted decision authority does not match the case authority');
|
|
251
311
|
if (evidence.targetDecisionHash !== pending.targetDecisionHash)
|
|
252
312
|
throw new DeliveryLineError('ARTIFACT_HASH_MISMATCH', 'trusted decision does not match the pending proposal');
|
|
253
313
|
const parsed = parseTrustedDecisionCommand(evidence.rawUserEvent, pending.options.map(option => option.id));
|
|
314
|
+
const trustedDecision = { evidenceHash: evidence.attestation, pendingDecisionId: pending.id, pendingCaseVersion: pending.caseVersion, nonce: evidence.nonce };
|
|
315
|
+
const identity = {
|
|
316
|
+
actor: { id: evidence.actorId, kind: 'human' },
|
|
317
|
+
sessionId: record.sessionId, executionId: record.executionId,
|
|
318
|
+
idempotencyKey: `trusted-decision:${evidence.nonce}`, contextReceipt: record.contextReceipt,
|
|
319
|
+
trustedDecision,
|
|
320
|
+
};
|
|
321
|
+
if (pending.kind === 'artifact_approval') {
|
|
322
|
+
if (!pending.approval)
|
|
323
|
+
throw new DeliveryLineError('MISSING_PREREQUISITE', 'artifact approval binding is missing');
|
|
324
|
+
if (parsed.purpose === 'approve_artifact')
|
|
325
|
+
return this.approve(evidence.caseId, {
|
|
326
|
+
...identity, turn: pending.turn, stage: pending.approval.stage,
|
|
327
|
+
artifactId: pending.approval.artifactId, expectedVersion: pending.approval.artifactVersion,
|
|
328
|
+
expectedContentHash: pending.approval.artifactContentHash,
|
|
329
|
+
artifactHash: pending.approval.artifactContentHash,
|
|
330
|
+
});
|
|
331
|
+
if (parsed.purpose === 'reject_artifact')
|
|
332
|
+
return this.reject(evidence.caseId, {
|
|
333
|
+
...identity, turn: pending.turn, stage: pending.approval.stage, reason: String(parsed.value),
|
|
334
|
+
});
|
|
335
|
+
throw new DeliveryLineError('INVALID_TRANSITION', 'the pending artifact requires /devflow:approve or /devflow:reject');
|
|
336
|
+
}
|
|
337
|
+
if (parsed.purpose !== 'resolve_clarification' || !['clarification', 'option_selection'].includes(pending.kind))
|
|
338
|
+
throw new DeliveryLineError('INVALID_TRANSITION', 'this trusted command does not match the pending decision');
|
|
254
339
|
const selectedOption = pending.options.find(option => option.id === parsed.value);
|
|
255
340
|
const selectedLabel = selectedOption?.label ?? String(parsed.value);
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
trustedDecision: { evidenceHash: evidence.attestation, pendingDecisionId: pending.id, pendingCaseVersion: pending.caseVersion, nonce: evidence.nonce },
|
|
341
|
+
if (pending.kind === 'option_selection' && record.stage === 'designing')
|
|
342
|
+
return this.compareOptions(evidence.caseId, {
|
|
343
|
+
...identity, turn: pending.turn, questionId: pending.id, optionId: String(parsed.value), selectedLabel, optionsSnapshot: pending.options,
|
|
344
|
+
});
|
|
345
|
+
return this.answer(evidence.caseId, {
|
|
346
|
+
...identity,
|
|
263
347
|
turn: pending.turn,
|
|
264
348
|
questionId: pending.id,
|
|
265
349
|
...(pending.options.length ? { optionId: String(parsed.value), optionsSnapshot: pending.options } : {}),
|
|
266
350
|
selectedLabel,
|
|
267
351
|
value: selectedLabel,
|
|
268
352
|
});
|
|
269
|
-
return answered;
|
|
270
353
|
}
|
|
271
354
|
async proposeClarification(caseId, input) {
|
|
272
355
|
requireIdentity(input);
|
|
273
356
|
if (input.actor.kind !== 'agent')
|
|
274
357
|
throw new DeliveryLineError('UNAUTHORIZED_ROLE', 'only an agent may propose clarification; a trusted user must resolve it');
|
|
358
|
+
const current = await this.ports.store.get(caseId);
|
|
359
|
+
const pending = await this.ports.store.getPendingDecision?.(caseId);
|
|
360
|
+
const options = normalizeDecisionOptions(input.options);
|
|
361
|
+
if (pending?.status === 'pending') {
|
|
362
|
+
const sameQuestion = normalizeDecisionText(current.interaction.openQuestions[0] ?? '') === normalizeDecisionText(input.question);
|
|
363
|
+
const sameOptions = JSON.stringify(pending.options) === JSON.stringify(options);
|
|
364
|
+
if (sameQuestion && sameOptions)
|
|
365
|
+
return this.view(current);
|
|
366
|
+
throw new DeliveryLineError('INVALID_TRANSITION', 'a user decision is already pending', { pendingDecisionId: pending.id, currentTurn: pending.turn, recoveryAction: 'Render and resolve the current pending decision before proposing another question.' });
|
|
367
|
+
}
|
|
275
368
|
return this.mutate(caseId, input, 'proposal.clarification', async (record) => {
|
|
276
369
|
if (!input.question.trim())
|
|
277
370
|
throw new DeliveryLineError('MISSING_PREREQUISITE', 'clarification question is required');
|
|
278
371
|
const nextTurn = record.interaction.turn + 1;
|
|
279
372
|
record.interaction = { ...record.interaction, status: 'awaiting_user', turn: nextTurn, openQuestions: [input.question], blockingUnknowns: [input.question] };
|
|
280
|
-
const options = normalizeDecisionOptions(input.options);
|
|
281
373
|
await this.ports.store.createPendingDecision?.({ id: `${record.id}:decision:${nextTurn}`, caseId: record.id, kind: 'clarification', caseVersion: record.version + 1, turn: nextTurn, targetDecisionHash: hash({ caseId: record.id, question: input.question, options }), options, status: 'pending', createdAt: Date.now() });
|
|
282
374
|
return record;
|
|
283
375
|
});
|
|
@@ -292,6 +384,13 @@ export class DeliveryLineFacade {
|
|
|
292
384
|
const options = normalizeDecisionOptions(input.options);
|
|
293
385
|
if (!input.question.trim() || options.length < 2)
|
|
294
386
|
throw new DeliveryLineError('MISSING_PREREQUISITE', 'at least two materially different options are required');
|
|
387
|
+
if (pending?.status === 'pending' && !enrichesInitialDecision) {
|
|
388
|
+
const sameQuestion = normalizeDecisionText(current.interaction.openQuestions[0] ?? '') === normalizeDecisionText(input.question);
|
|
389
|
+
const sameOptions = JSON.stringify(pending.options) === JSON.stringify(options);
|
|
390
|
+
if (sameQuestion && sameOptions)
|
|
391
|
+
return this.view(current);
|
|
392
|
+
throw new DeliveryLineError('INVALID_TRANSITION', 'a user decision is already pending', { pendingDecisionId: pending.id, currentTurn: pending.turn, recoveryAction: 'Render and resolve the current pending decision before proposing another option set.' });
|
|
393
|
+
}
|
|
295
394
|
return this.mutate(caseId, input, 'proposal.options', async (record) => {
|
|
296
395
|
const nextTurn = enrichesInitialDecision ? record.interaction.turn : record.interaction.turn + 1;
|
|
297
396
|
record.interaction = { ...record.interaction, status: 'awaiting_user', turn: nextTurn, openQuestions: [input.question], blockingUnknowns: [input.question] };
|
|
@@ -348,6 +447,10 @@ export class DeliveryLineFacade {
|
|
|
348
447
|
if (acceptanceCriteria.length === 0)
|
|
349
448
|
acceptanceCriteria.push(rawAnswer);
|
|
350
449
|
record.interaction = answerInteraction(record.interaction, input.turn);
|
|
450
|
+
if (record.unknownRegistry) {
|
|
451
|
+
const answeredQuestion = normalizeDecisionText(question);
|
|
452
|
+
record.unknownRegistry = record.unknownRegistry.map(item => normalizeDecisionText(item.question) === answeredQuestion ? { ...item, blocking: false } : item);
|
|
453
|
+
}
|
|
351
454
|
if (input.trustedDecision) {
|
|
352
455
|
const authority = { id: input.actor.id, kind: 'human' };
|
|
353
456
|
record.authorities = { ...record.authorities, decisionAuthority: record.authorities?.decisionAuthority ?? authority, approvalAuthority: record.authorities?.approvalAuthority ?? authority };
|
|
@@ -433,6 +536,43 @@ export class DeliveryLineFacade {
|
|
|
433
536
|
const requiredContextChannels = [...new Set(Object.values(selectedCapabilities?.selected ?? {}).flatMap(manifest => manifest.requiredContextChannels))]
|
|
434
537
|
.filter((channel) => ['code', 'memory', 'knowledge'].includes(channel));
|
|
435
538
|
record.pluginSelection = { selectedManifestHash: pluginSelectionHash, stackFactsHash, decisionRefs: [decisionHash], selectionReason: selectedCapabilities?.selectionReason ?? 'selected by current user decision', rejectedAlternatives: [...new Set([...(selectedCapabilities?.rejectedAlternatives ?? []), ...optionsSnapshot.filter(option => option.id !== optionId).map(option => option.id)])], ...(requiredContextChannels.length ? { requiredContextChannels } : {}), ...(selectedCapabilities ? { selectedByRole: Object.fromEntries(Object.entries(selectedCapabilities.selected).map(([role, manifest]) => [role, { pluginName: manifest.name, version: manifest.version, manifestHash: hash(manifest) }])), ...(selectedCapabilities.hostWorkerFallback ? { hostWorkerFallback: true } : {}) } : {}), selectedAt: Date.now() };
|
|
539
|
+
if (selectedCapabilities && this.ports.capabilities) {
|
|
540
|
+
const stackFactsFingerprint = record.stackFacts?.fingerprint ?? record.stackFactsHash ?? hash({ projectRoot: record.projectRoot, baseRef: record.baseRef, targetRef: record.targetRef });
|
|
541
|
+
for (const role of ['analyzer', 'designer']) {
|
|
542
|
+
const selected = selectedCapabilities.selected[role];
|
|
543
|
+
if (!selected || selected.kind !== 'runner')
|
|
544
|
+
continue;
|
|
545
|
+
const runtimeManifest = await this.ports.capabilities.manifest(selected.name, role);
|
|
546
|
+
const receipt = await this.ports.capabilities.run({
|
|
547
|
+
caseId: record.id,
|
|
548
|
+
taskId: `${record.id}:${role}:${record.interaction.turn}`,
|
|
549
|
+
role,
|
|
550
|
+
pluginName: runtimeManifest.pluginName,
|
|
551
|
+
capability: runtimeManifest.capability,
|
|
552
|
+
manifestHash: runtimeManifest.manifestHash,
|
|
553
|
+
stackFactsFingerprint,
|
|
554
|
+
allowedFiles: [],
|
|
555
|
+
payload: {
|
|
556
|
+
changedFiles: [],
|
|
557
|
+
role,
|
|
558
|
+
request: record.originalRequest ?? record.title,
|
|
559
|
+
componentName: record.taskSemantics?.entities[0] ?? selectedLabel,
|
|
560
|
+
contextReceiptHash: record.contextReceipt ?? input.contextReceipt,
|
|
561
|
+
decisionHash,
|
|
562
|
+
optionsSnapshot,
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
if (receipt.manifestHash !== runtimeManifest.manifestHash || receipt.verified !== true)
|
|
566
|
+
throw new DeliveryLineError('ARTIFACT_HASH_MISMATCH', `plugin ${role} receipt is not verified`);
|
|
567
|
+
record.evidence.push({
|
|
568
|
+
id: randomUUID(),
|
|
569
|
+
kind: 'tool',
|
|
570
|
+
hash: hash({ role, pluginName: runtimeManifest.pluginName, pluginVersion: receipt.pluginVersion, manifestHash: receipt.manifestHash, outputHash: receipt.outputHash, contextReceiptHash: record.contextReceipt ?? input.contextReceipt, decisionHash }),
|
|
571
|
+
createdAt: Date.now(),
|
|
572
|
+
metadata: { role, pluginName: runtimeManifest.pluginName, pluginVersion: receipt.pluginVersion, manifestHash: receipt.manifestHash, outputHash: receipt.outputHash, stackFactsFingerprint, contextReceiptHash: record.contextReceipt ?? input.contextReceipt, decisionHash, verified: receipt.verified },
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
436
576
|
await this.createProjectionArtifact(record, 'plugin-selection.md', 'Plugin selection', 'Capability providers selected by Delivery from stack facts and the current decision.', record.pluginSelection, input.actor);
|
|
437
577
|
record.stage = 'spec_ready';
|
|
438
578
|
record.interaction = { ...record.interaction, phase: 'design', status: 'awaiting_approval', openQuestions: [], blockingUnknowns: [] };
|
|
@@ -440,6 +580,61 @@ export class DeliveryLineFacade {
|
|
|
440
580
|
return record;
|
|
441
581
|
});
|
|
442
582
|
}
|
|
583
|
+
/**
|
|
584
|
+
* Compile and persist a plan proposal before asking the user to approve it.
|
|
585
|
+
* Approval must consume this immutable draft by hash; it must never depend on
|
|
586
|
+
* the model being able to submit a plan object through a trusted command.
|
|
587
|
+
*/
|
|
588
|
+
async preparePlan(caseId, input) {
|
|
589
|
+
requireIdentity(input);
|
|
590
|
+
if (input.actor.kind !== 'agent')
|
|
591
|
+
throw new DeliveryLineError('UNAUTHORIZED_ROLE', 'only an agent may propose an executable plan; a trusted user must approve it');
|
|
592
|
+
return this.mutate(caseId, input, 'plan.prepared', async (record) => {
|
|
593
|
+
if (input.turn !== record.interaction.turn)
|
|
594
|
+
throw staleTurnError(record, input.turn);
|
|
595
|
+
if (record.stage !== 'plan_ready')
|
|
596
|
+
throw new DeliveryLineError('INVALID_TRANSITION', 'plan preparation requires a ready plan stage', { currentStage: record.stage, recoveryAction: 'Refresh delivery_status and follow its nextActions.' });
|
|
597
|
+
if (record.artifacts.some(artifact => artifact.type === 'plan'))
|
|
598
|
+
throw new DeliveryLineError('IDEMPOTENCY_CONFLICT', 'a plan draft is already prepared; refresh delivery_status and approve the pending plan decision');
|
|
599
|
+
if (this.ports.context) {
|
|
600
|
+
const plannerReceipt = await this.ports.context.build({
|
|
601
|
+
role: 'planner',
|
|
602
|
+
...(record.pluginSelection?.requiredContextChannels?.length ? { requiredChannels: record.pluginSelection.requiredContextChannels } : {}),
|
|
603
|
+
retrieval: {
|
|
604
|
+
taskRevision: record.version + 1,
|
|
605
|
+
projectRoot: record.projectRoot,
|
|
606
|
+
query: `${record.originalRequest ?? record.title} implementation plan`,
|
|
607
|
+
sessionId: input.sessionId,
|
|
608
|
+
executionId: input.executionId,
|
|
609
|
+
requestId: `delivery:plan:${record.id}:${record.version}`,
|
|
610
|
+
turnId: `delivery:plan:${record.id}:${record.interaction.turn}`,
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
record.contextReceipt = plannerReceipt.receiptHash;
|
|
614
|
+
if (plannerReceipt.channelStatus && plannerReceipt.gaps && plannerReceipt.attribution)
|
|
615
|
+
record.contextQuality = { blocking: plannerReceipt.blocking ?? false, ...(plannerReceipt.channelAvailability ? { channelAvailability: plannerReceipt.channelAvailability } : {}), channelStatus: plannerReceipt.channelStatus, gaps: plannerReceipt.gaps, attribution: plannerReceipt.attribution, ...('quality' in plannerReceipt && plannerReceipt.quality ? { quality: plannerReceipt.quality } : {}) };
|
|
616
|
+
}
|
|
617
|
+
const planContent = renderPlanMarkdown(input.plan);
|
|
618
|
+
const planHash = contentHash(planContent);
|
|
619
|
+
new PlanCompiler().compile({
|
|
620
|
+
caseId: record.id,
|
|
621
|
+
projectRoot: record.projectRoot,
|
|
622
|
+
baseRevision: record.baseRef,
|
|
623
|
+
planHash,
|
|
624
|
+
contextReceiptHash: record.contextReceipt ?? input.contextReceipt,
|
|
625
|
+
hostSelectionSnapshotHash: hash({ mode: 'manual-plan', actor: input.actor, sessionId: input.sessionId, executionId: input.executionId }),
|
|
626
|
+
executionAuthority: hash({ caseId: record.id, stage: 'plan', planHash }),
|
|
627
|
+
plan: input.plan,
|
|
628
|
+
});
|
|
629
|
+
const artifact = await this.createArtifact(record, 'plan', planContent, input.actor, { path: 'plan.json', content: renderPlanJson(input.plan) });
|
|
630
|
+
if (artifact.contentHash !== planHash)
|
|
631
|
+
throw new DeliveryLineError('ARTIFACT_HASH_MISMATCH', 'plan artifact hash changed while persisting the proposal');
|
|
632
|
+
record.planDraft = structuredClone(input.plan);
|
|
633
|
+
record.artifacts.push(artifact);
|
|
634
|
+
record.interaction = { ...record.interaction, status: 'awaiting_approval' };
|
|
635
|
+
return record;
|
|
636
|
+
});
|
|
637
|
+
}
|
|
443
638
|
async approve(caseId, input) {
|
|
444
639
|
requireIdentity(input);
|
|
445
640
|
requireTrustedUserDecision(input);
|
|
@@ -452,31 +647,17 @@ export class DeliveryLineFacade {
|
|
|
452
647
|
let planContent;
|
|
453
648
|
let proposedArtifactHash;
|
|
454
649
|
if (input.stage === 'plan') {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
role: 'planner',
|
|
460
|
-
...(record.pluginSelection?.requiredContextChannels?.length ? { requiredChannels: record.pluginSelection.requiredContextChannels } : {}),
|
|
461
|
-
retrieval: {
|
|
462
|
-
taskRevision: record.version + 1,
|
|
463
|
-
projectRoot: record.projectRoot,
|
|
464
|
-
query: `${record.title} implementation plan`,
|
|
465
|
-
sessionId: input.sessionId,
|
|
466
|
-
executionId: input.executionId,
|
|
467
|
-
requestId: `delivery:plan:${record.id}:${record.version}`,
|
|
468
|
-
turnId: `delivery:plan:${record.id}:${record.interaction.turn}`,
|
|
469
|
-
},
|
|
470
|
-
});
|
|
471
|
-
record.contextReceipt = plannerReceipt.receiptHash;
|
|
472
|
-
if (plannerReceipt.channelStatus && plannerReceipt.gaps && plannerReceipt.attribution)
|
|
473
|
-
record.contextQuality = { blocking: plannerReceipt.blocking ?? false, ...(plannerReceipt.channelAvailability ? { channelAvailability: plannerReceipt.channelAvailability } : {}), channelStatus: plannerReceipt.channelStatus, gaps: plannerReceipt.gaps, attribution: plannerReceipt.attribution, ...('quality' in plannerReceipt && plannerReceipt.quality ? { quality: plannerReceipt.quality } : {}) };
|
|
474
|
-
}
|
|
475
|
-
planContent = renderPlanMarkdown(input.plan);
|
|
650
|
+
const proposedPlan = record.planDraft;
|
|
651
|
+
if (!proposedPlan)
|
|
652
|
+
throw new DeliveryLineError('PLAN_NOT_EXECUTABLE', 'plan approval requires a prepared executable plan');
|
|
653
|
+
planContent = renderPlanMarkdown(proposedPlan);
|
|
476
654
|
proposedArtifactHash = contentHash(planContent);
|
|
655
|
+
const preparedArtifact = [...record.artifacts].reverse().find(item => item.type === 'plan');
|
|
656
|
+
if (preparedArtifact && preparedArtifact.contentHash !== proposedArtifactHash)
|
|
657
|
+
throw new DeliveryLineError('ARTIFACT_HASH_MISMATCH', 'prepared plan does not match the approval artifact');
|
|
477
658
|
}
|
|
478
659
|
let artifact = [...record.artifacts].reverse().find(item => item.type === artifactType);
|
|
479
|
-
if (input.stage !== '
|
|
660
|
+
if (input.stage !== 'release') {
|
|
480
661
|
if (input.artifactId && artifact?.id !== input.artifactId)
|
|
481
662
|
throw new DeliveryLineError('ARTIFACT_VERSION_CONFLICT', 'approval artifact id is stale');
|
|
482
663
|
if (input.expectedVersion !== undefined && artifact?.version !== input.expectedVersion)
|
|
@@ -489,7 +670,7 @@ export class DeliveryLineFacade {
|
|
|
489
670
|
throw new DeliveryLineError('MISSING_PREREQUISITE', `${artifactType} artifact is required`);
|
|
490
671
|
if (input.artifactHash && input.artifactHash !== approvalArtifactHash)
|
|
491
672
|
throw new DeliveryLineError('ARTIFACT_HASH_MISMATCH', 'approval artifact hash is stale');
|
|
492
|
-
if (input.stage === 'intent' && (record.stage !== 'clarifying' || record.interaction.blockingUnknowns.length > 0 ||
|
|
673
|
+
if (input.stage === 'intent' && (record.stage !== 'clarifying' || record.interaction.blockingUnknowns.length > 0 || record.interaction.openQuestions.length > 0))
|
|
493
674
|
throw new DeliveryLineError('MISSING_PREREQUISITE', 'clarification must be complete before intent approval');
|
|
494
675
|
if (input.stage === 'spec' && record.stage !== 'spec_ready')
|
|
495
676
|
throw new DeliveryLineError('INVALID_TRANSITION', 'spec approval requires a ready spec');
|
|
@@ -518,6 +699,9 @@ export class DeliveryLineFacade {
|
|
|
518
699
|
if (input.stage === 'spec')
|
|
519
700
|
record.stage = 'plan_ready';
|
|
520
701
|
if (input.stage === 'plan') {
|
|
702
|
+
const proposedPlan = record.planDraft;
|
|
703
|
+
if (!proposedPlan)
|
|
704
|
+
throw new DeliveryLineError('PLAN_NOT_EXECUTABLE', 'plan approval requires a prepared executable plan');
|
|
521
705
|
const envelopes = new PlanCompiler().compile({
|
|
522
706
|
caseId: record.id,
|
|
523
707
|
projectRoot: record.projectRoot,
|
|
@@ -526,13 +710,8 @@ export class DeliveryLineFacade {
|
|
|
526
710
|
contextReceiptHash: record.contextReceipt ?? input.contextReceipt,
|
|
527
711
|
hostSelectionSnapshotHash: hash({ mode: 'manual-plan', actor: input.actor, sessionId: input.sessionId, executionId: input.executionId }),
|
|
528
712
|
executionAuthority: hash({ actor: input.actor, stage: input.stage, planHash: proposedArtifactHash }),
|
|
529
|
-
plan:
|
|
713
|
+
plan: proposedPlan,
|
|
530
714
|
});
|
|
531
|
-
record.artifacts = record.artifacts.filter(item => item.type !== 'plan');
|
|
532
|
-
artifact = await this.createArtifact(record, 'plan', planContent, input.actor, { path: 'plan.json', content: renderPlanJson(input.plan) });
|
|
533
|
-
if (artifact.contentHash !== proposedArtifactHash)
|
|
534
|
-
throw new DeliveryLineError('ARTIFACT_HASH_MISMATCH', 'plan artifact hash changed while persisting the approval artifact');
|
|
535
|
-
record.artifacts.push(artifact);
|
|
536
715
|
record.stage = 'plan_approved';
|
|
537
716
|
const decision = [...record.decisions].reverse().find(item => item.decisionHash);
|
|
538
717
|
if (decision && record.pluginSelection && record.stackFactsHash) {
|
|
@@ -582,6 +761,7 @@ export class DeliveryLineFacade {
|
|
|
582
761
|
}
|
|
583
762
|
async reject(caseId, input) {
|
|
584
763
|
requireIdentity(input);
|
|
764
|
+
requireTrustedUserDecision(input);
|
|
585
765
|
if (!stageApprovals.has(input.stage))
|
|
586
766
|
throw new DeliveryLineError('INVALID_TRANSITION', `unsupported rejection stage: ${String(input.stage)}`, { requestedStage: input.stage, recoveryAction: 'Use intent, spec, plan, or release approval stages.' });
|
|
587
767
|
return this.mutate(caseId, input, 'approval.rejected', async (record) => { if (input.turn !== record.interaction.turn)
|
|
@@ -885,7 +1065,8 @@ export class DeliveryLineFacade {
|
|
|
885
1065
|
} record.stage = 'cancelled'; return record; }); }
|
|
886
1066
|
async mutate(caseId, input, type, fn, pendingDecisionUpdate) {
|
|
887
1067
|
const record = await this.ports.store.get(caseId);
|
|
888
|
-
const
|
|
1068
|
+
const consumesPendingDecision = type === 'interaction.answered' || type === 'design.compared' || type.startsWith('approval.');
|
|
1069
|
+
const pendingBeforeMutation = consumesPendingDecision ? await this.ports.store.getPendingDecision?.(caseId) : undefined;
|
|
889
1070
|
if (record.contextReceipt && record.contextReceipt !== input.contextReceipt)
|
|
890
1071
|
throw new DeliveryLineError('MISSING_PREREQUISITE', 'context receipt does not match case');
|
|
891
1072
|
if ((record.sessionId && record.sessionId !== input.sessionId) || (record.executionId && record.executionId !== input.executionId))
|
|
@@ -911,12 +1092,12 @@ export class DeliveryLineFacade {
|
|
|
911
1092
|
else if (input.trustedDecision) {
|
|
912
1093
|
if (!this.ports.store.saveAndConsumePendingDecision)
|
|
913
1094
|
throw new DeliveryLineError('MISSING_PREREQUISITE', 'trusted decision storage transaction is unavailable', { recoveryAction: 'Use a Delivery store with atomic pending-decision consumption.' });
|
|
914
|
-
await this.ports.store.saveAndConsumePendingDecision(next, record.version, { decisionId: input.trustedDecision.pendingDecisionId, expectedCaseVersion: input.trustedDecision.pendingCaseVersion, nonce: input.trustedDecision.nonce });
|
|
1095
|
+
await this.ports.store.saveAndConsumePendingDecision(next, record.version, { decisionId: input.trustedDecision.pendingDecisionId, expectedCaseVersion: input.trustedDecision.pendingCaseVersion, nonce: input.trustedDecision.nonce, replacement: artifactApprovalDecision(next) });
|
|
915
1096
|
}
|
|
916
1097
|
else {
|
|
917
1098
|
await this.ports.store.save(next, record.version);
|
|
918
1099
|
}
|
|
919
|
-
if (
|
|
1100
|
+
if (consumesPendingDecision && !input.trustedDecision && pendingBeforeMutation && this.ports.store.consumePendingDecision) {
|
|
920
1101
|
await this.ports.store.consumePendingDecision({ caseId, decisionId: pendingBeforeMutation.id, expectedCaseVersion: pendingBeforeMutation.caseVersion, nonce: input.idempotencyKey });
|
|
921
1102
|
}
|
|
922
1103
|
await appendLedgerEvent(this.ports.store, { caseId, type, actor: input.actor, idempotencyKey: input.idempotencyKey, payload: { from: record.stage, to: next.stage } });
|
|
@@ -951,8 +1132,16 @@ export class DeliveryLineFacade {
|
|
|
951
1132
|
}
|
|
952
1133
|
evidence(kind, value) { return { id: randomUUID(), kind, hash: hash(value), createdAt: Date.now(), metadata: value }; }
|
|
953
1134
|
async view(record) {
|
|
954
|
-
const pending = await this.
|
|
955
|
-
const pendingDecisions =
|
|
1135
|
+
const pending = await this.ensurePendingDecision(record);
|
|
1136
|
+
const pendingDecisions = pending?.kind === 'artifact_approval' && pending.approval ? [{
|
|
1137
|
+
id: pending.id,
|
|
1138
|
+
kind: pending.kind,
|
|
1139
|
+
question: `Approve the current ${pending.approval.stage} artifact?`,
|
|
1140
|
+
options: pending.options,
|
|
1141
|
+
blocking: true,
|
|
1142
|
+
turn: pending.turn,
|
|
1143
|
+
command: `/devflow:approve ${pending.id}`,
|
|
1144
|
+
}] : record.interaction.openQuestions.map(question => ({
|
|
956
1145
|
id: pending?.id ?? `${record.id}:decision:${record.interaction.turn}`,
|
|
957
1146
|
kind: 'product',
|
|
958
1147
|
question,
|
|
@@ -964,6 +1153,24 @@ export class DeliveryLineFacade {
|
|
|
964
1153
|
const blockers = record.blockers ?? (['held', 'failed', 'needs_changes', 'blocked'].includes(record.stage) ? [this.defaultBlocker(record)] : []);
|
|
965
1154
|
return { caseId: record.id, currentTurn: record.interaction.turn, contextReceipt: record.contextReceipt, ...(record.contextQuality ? { contextQuality: record.contextQuality } : {}), ...(record.sessionId && record.executionId ? { canonicalIdentity: { sessionId: record.sessionId, executionId: record.executionId, actorId: record.createdBy.id } } : {}), ...(record.runtimeActivation ? { runtimeActivationHash: record.runtimeActivation.snapshotHash } : {}), stage: record.stage, pendingDecisions, nextActions, blockers, artifactRefs: record.artifacts, evidenceRefs: record.evidence, taskSummaries: record.tasks.map(task => ({ taskId: task.id, status: task.status, attempt: task.attempt })), budget: { interactionsUsed: record.interaction.turn, taskAttemptsUsed: record.tasks.reduce((sum, task) => sum + task.attempt, 0), wallTimeMs: Math.max(0, Date.now() - (record.createdAt ?? record.updatedAt)), toolCalls: 0 } };
|
|
966
1155
|
}
|
|
1156
|
+
async ensurePendingDecision(record) {
|
|
1157
|
+
const current = await this.ports.store.getPendingDecision?.(record.id);
|
|
1158
|
+
if (current)
|
|
1159
|
+
return current;
|
|
1160
|
+
const derived = artifactApprovalDecision(record);
|
|
1161
|
+
if (!derived || !this.ports.store.createPendingDecision)
|
|
1162
|
+
return undefined;
|
|
1163
|
+
try {
|
|
1164
|
+
await this.ports.store.createPendingDecision(derived);
|
|
1165
|
+
return derived;
|
|
1166
|
+
}
|
|
1167
|
+
catch (error) {
|
|
1168
|
+
const concurrent = await this.ports.store.getPendingDecision?.(record.id);
|
|
1169
|
+
if (concurrent)
|
|
1170
|
+
return concurrent;
|
|
1171
|
+
throw error;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
967
1174
|
defaultBlocker(record) {
|
|
968
1175
|
const task = record.tasks.find(item => ['blocked', 'failed', 'needs_changes'].includes(item.status));
|
|
969
1176
|
const code = record.stage === 'held' ? 'HELD' : record.stage.toUpperCase();
|