@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.
@@ -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
- if (['clarifying', 'spec_ready', 'plan_ready', 'reviewed'].includes(record.stage))
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) { return this.ports.store.getPendingDecision?.(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
- if (record.authorities?.decisionAuthority && record.authorities.decisionAuthority.id !== evidence.actorId)
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
- const answered = await this.answer(evidence.caseId, {
257
- actor: { id: evidence.actorId, kind: 'human' },
258
- sessionId: record.sessionId,
259
- executionId: record.executionId,
260
- idempotencyKey: `trusted-decision:${evidence.nonce}`,
261
- contextReceipt: record.contextReceipt,
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
- if (!input.plan)
456
- throw new DeliveryLineError('PLAN_NOT_EXECUTABLE', 'plan approval requires an executable plan');
457
- if (this.ports.context) {
458
- const plannerReceipt = await this.ports.context.build({
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 !== 'plan') {
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 || (record.taskSemantics && record.taskSemantics.confidence > 0 && record.unknownRegistry?.some(item => item.blocking))))
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: input.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 pendingBeforeMutation = type === 'interaction.answered' ? await this.ports.store.getPendingDecision?.(caseId) : undefined;
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 (type === 'interaction.answered' && !input.trustedDecision && pendingBeforeMutation && this.ports.store.consumePendingDecision) {
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.ports.store.getPendingDecision?.(record.id);
955
- const pendingDecisions = record.interaction.openQuestions.map(question => ({
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();