@devflow-tools/delivery-line 0.18.34 → 0.18.36

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.
@@ -43,15 +43,23 @@ function normalizeDecisionOptions(options) {
43
43
  return false;
44
44
  seen.add(id);
45
45
  return true;
46
- }).map(option => ({ id: option.id.trim(), label: option.label.trim(), ...(option.recommended ? { recommended: true } : {}) }));
46
+ }).map(option => ({
47
+ id: option.id.trim(),
48
+ label: option.label.trim(),
49
+ ...(option.description?.trim() ? { description: option.description.trim() } : {}),
50
+ ...(option.recommended ? { recommended: true } : {}),
51
+ }));
52
+ }
53
+ function normalizeDecisionText(value) {
54
+ return value.replace(/[\u0000-\u001f\u007f\ufffd]+/gu, ' ').replace(/\s+/gu, ' ').trim();
47
55
  }
48
56
  function optionsForExistingImplementations(paths) {
49
57
  return [
50
- { id: 'create-new', label: 'Create a new implementation' },
58
+ { id: 'create-new', label: 'Create a new implementation', description: 'Add a separate implementation and leave every discovered candidate unchanged.' },
51
59
  ...paths.flatMap((path, index) => [
52
- { id: `extend-${index + 1}`, label: `Extend existing implementation: ${path}` },
53
- { id: `replace-${index + 1}`, label: `Replace existing implementation: ${path}` },
54
- { id: `retain-${index + 1}`, label: `Retain existing implementation unchanged: ${path}` },
60
+ { id: `extend-${index + 1}`, label: `Extend existing implementation: ${path}`, description: `Reuse ${path} and preserve its existing public behavior while adding the requested capability.` },
61
+ { id: `replace-${index + 1}`, label: `Replace existing implementation: ${path}`, description: `Replace ${path}; the plan must preserve or explicitly revise every current behavior and caller.` },
62
+ { id: `retain-${index + 1}`, label: `Retain existing implementation unchanged: ${path}`, description: `Keep ${path} unchanged and constrain the new work to a separate integration point.` },
55
63
  ]),
56
64
  ];
57
65
  }
@@ -68,8 +76,10 @@ function nextActionsFor(record) {
68
76
  && record.interaction.blockingUnknowns.length === 0
69
77
  && record.artifacts.some(artifact => artifact.type === 'intent'))
70
78
  || (record.stage === 'spec_ready' && record.artifacts.some(artifact => artifact.type === 'spec'))
71
- || record.stage === 'plan_ready'
79
+ || (record.stage === 'plan_ready' && record.artifacts.some(artifact => artifact.type === 'plan'))
72
80
  || record.stage === 'reviewed';
81
+ if (record.stage === 'plan_ready' && !record.artifacts.some(artifact => artifact.type === 'plan'))
82
+ nextActions.push('prepare_plan');
73
83
  if (approvalReady)
74
84
  nextActions.push('approve');
75
85
  if (record.blockers?.some(blocker => blocker.code === 'WORKTREE_ADMISSION_REQUIRED'))
@@ -157,7 +167,15 @@ function artifactApprovalDecision(record) {
157
167
  caseVersion: record.version,
158
168
  turn: record.interaction.turn,
159
169
  targetDecisionHash: hash({ caseId: record.id, caseVersion: record.version, turn: record.interaction.turn, approval }),
160
- options: [],
170
+ options: stage === 'release'
171
+ ? [
172
+ { id: 'approve', label: 'Approve release', description: 'Accept the reviewed integration revision and continue to package export.' },
173
+ { id: 'reject', label: 'Reject release', description: 'Stop this delivery because the reviewed revision is not acceptable.' },
174
+ ]
175
+ : [
176
+ { id: 'approve', label: `Approve ${stage} and continue`, description: `Accept the current ${stage} artifact and continue to the next delivery stage.` },
177
+ { id: 'reject', label: `Reject ${stage} and stop`, description: `Reject the current ${stage} artifact and stop this delivery case.` },
178
+ ],
161
179
  approval,
162
180
  status: 'pending',
163
181
  createdAt: Date.now(),
@@ -348,12 +366,21 @@ export class DeliveryLineFacade {
348
366
  requireIdentity(input);
349
367
  if (input.actor.kind !== 'agent')
350
368
  throw new DeliveryLineError('UNAUTHORIZED_ROLE', 'only an agent may propose clarification; a trusted user must resolve it');
369
+ const current = await this.ports.store.get(caseId);
370
+ const pending = await this.ports.store.getPendingDecision?.(caseId);
371
+ const options = normalizeDecisionOptions(input.options);
372
+ if (pending?.status === 'pending') {
373
+ const sameQuestion = normalizeDecisionText(current.interaction.openQuestions[0] ?? '') === normalizeDecisionText(input.question);
374
+ const sameOptions = JSON.stringify(pending.options) === JSON.stringify(options);
375
+ if (sameQuestion && sameOptions)
376
+ return this.view(current);
377
+ 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.' });
378
+ }
351
379
  return this.mutate(caseId, input, 'proposal.clarification', async (record) => {
352
380
  if (!input.question.trim())
353
381
  throw new DeliveryLineError('MISSING_PREREQUISITE', 'clarification question is required');
354
382
  const nextTurn = record.interaction.turn + 1;
355
383
  record.interaction = { ...record.interaction, status: 'awaiting_user', turn: nextTurn, openQuestions: [input.question], blockingUnknowns: [input.question] };
356
- const options = normalizeDecisionOptions(input.options);
357
384
  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() });
358
385
  return record;
359
386
  });
@@ -368,6 +395,13 @@ export class DeliveryLineFacade {
368
395
  const options = normalizeDecisionOptions(input.options);
369
396
  if (!input.question.trim() || options.length < 2)
370
397
  throw new DeliveryLineError('MISSING_PREREQUISITE', 'at least two materially different options are required');
398
+ if (pending?.status === 'pending' && !enrichesInitialDecision) {
399
+ const sameQuestion = normalizeDecisionText(current.interaction.openQuestions[0] ?? '') === normalizeDecisionText(input.question);
400
+ const sameOptions = JSON.stringify(pending.options) === JSON.stringify(options);
401
+ if (sameQuestion && sameOptions)
402
+ return this.view(current);
403
+ 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.' });
404
+ }
371
405
  return this.mutate(caseId, input, 'proposal.options', async (record) => {
372
406
  const nextTurn = enrichesInitialDecision ? record.interaction.turn : record.interaction.turn + 1;
373
407
  record.interaction = { ...record.interaction, status: 'awaiting_user', turn: nextTurn, openQuestions: [input.question], blockingUnknowns: [input.question] };
@@ -424,8 +458,10 @@ export class DeliveryLineFacade {
424
458
  if (acceptanceCriteria.length === 0)
425
459
  acceptanceCriteria.push(rawAnswer);
426
460
  record.interaction = answerInteraction(record.interaction, input.turn);
427
- if (record.unknownRegistry)
428
- record.unknownRegistry = record.unknownRegistry.map(item => ({ ...item, blocking: false }));
461
+ if (record.unknownRegistry) {
462
+ const answeredQuestion = normalizeDecisionText(question);
463
+ record.unknownRegistry = record.unknownRegistry.map(item => normalizeDecisionText(item.question) === answeredQuestion ? { ...item, blocking: false } : item);
464
+ }
429
465
  if (input.trustedDecision) {
430
466
  const authority = { id: input.actor.id, kind: 'human' };
431
467
  record.authorities = { ...record.authorities, decisionAuthority: record.authorities?.decisionAuthority ?? authority, approvalAuthority: record.authorities?.approvalAuthority ?? authority };
@@ -434,7 +470,7 @@ export class DeliveryLineFacade {
434
470
  const decisionHash = hash({ questionId, optionId: input.optionId, selectedLabel, rawAnswer, acceptanceCriteria, optionsSnapshot });
435
471
  record.decisions.push({ id: randomUUID(), kind: 'product', questionId, question, options: optionsSnapshot.map(option => option.label), optionsSnapshot, optionId: input.optionId, selectedLabel, rawAnswer, acceptanceCriteria, decisionHash, sessionId: input.sessionId, toolUseId: input.toolUseId, decision: selectedLabel, decidedBy: input.actor, artifactHash: decisionHash });
436
472
  if (record.interaction.phase === 'clarification')
437
- record.artifacts.push(await this.createArtifact(record, 'intent', renderIntentMarkdown({ title: record.title, originalRequest: record.originalRequest, problem: selectedLabel, outcome: selectedLabel, answer: rawAnswer, contextQuality: record.contextQuality, affectedSystems: [record.projectRoot], constraints: ['Respect the approved Delivery Line gates.'], successCriteria: [selectedLabel, ...acceptanceCriteria], openQuestions: record.taskSemantics && record.taskSemantics.confidence > 0 ? (record.unknownRegistry?.filter(item => item.blocking).map(item => item.question) ?? []) : record.interaction.blockingUnknowns, unknownRegistry: record.taskSemantics && record.taskSemantics.confidence > 0 ? record.unknownRegistry : undefined, actor: input.actor }), input.actor));
473
+ record.artifacts.push(await this.createArtifact(record, 'intent', renderIntentMarkdown({ title: record.title, originalRequest: record.originalRequest, problem: record.originalRequest ?? record.title, outcome: selectedLabel, answer: rawAnswer, contextQuality: record.contextQuality, affectedSystems: [record.projectRoot], constraints: ['Preserve existing behavior outside the confirmed scope.'], successCriteria: acceptanceCriteria, openQuestions: record.taskSemantics && record.taskSemantics.confidence > 0 ? (record.unknownRegistry?.filter(item => item.blocking).map(item => item.question) ?? []) : record.interaction.blockingUnknowns, unknownRegistry: record.taskSemantics && record.taskSemantics.confidence > 0 ? record.unknownRegistry : undefined, actor: input.actor }), input.actor));
438
474
  await this.createProjectionArtifact(record, 'stack-facts.md', 'Stack facts', 'Project stack evidence bound at case creation.', { baseRef: record.baseRef, targetRef: record.targetRef, projectRoot: record.projectRoot }, input.actor);
439
475
  await this.createProjectionArtifact(record, 'decisions.md', 'Decisions', 'Current-turn decision ledger projection.', { decisionHash, questionId, optionId: input.optionId, selectedLabel }, input.actor);
440
476
  record.interaction = appendInteractionSummary(record.interaction, { id: randomUUID(), phase: record.interaction.phase, turn: input.turn, question, answer: rawAnswer, rawAnswer, acceptanceCriteria, decision: selectedLabel, options: optionsSnapshot.map(option => option.label), contextReceipt: input.contextReceipt, artifactHashes: [decisionHash], actor: input.actor });
@@ -511,10 +547,118 @@ export class DeliveryLineFacade {
511
547
  const requiredContextChannels = [...new Set(Object.values(selectedCapabilities?.selected ?? {}).flatMap(manifest => manifest.requiredContextChannels))]
512
548
  .filter((channel) => ['code', 'memory', 'knowledge'].includes(channel));
513
549
  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() };
550
+ if (selectedCapabilities && this.ports.capabilities) {
551
+ const stackFactsFingerprint = record.stackFacts?.fingerprint ?? record.stackFactsHash ?? hash({ projectRoot: record.projectRoot, baseRef: record.baseRef, targetRef: record.targetRef });
552
+ for (const role of ['analyzer', 'designer']) {
553
+ const selected = selectedCapabilities.selected[role];
554
+ if (!selected || selected.kind !== 'runner')
555
+ continue;
556
+ const runtimeManifest = await this.ports.capabilities.manifest(selected.name, role);
557
+ const receipt = await this.ports.capabilities.run({
558
+ caseId: record.id,
559
+ taskId: `${record.id}:${role}:${record.interaction.turn}`,
560
+ role,
561
+ pluginName: runtimeManifest.pluginName,
562
+ capability: runtimeManifest.capability,
563
+ manifestHash: runtimeManifest.manifestHash,
564
+ stackFactsFingerprint,
565
+ allowedFiles: [],
566
+ payload: {
567
+ changedFiles: [],
568
+ role,
569
+ request: record.originalRequest ?? record.title,
570
+ componentName: record.taskSemantics?.entities[0] ?? selectedLabel,
571
+ contextReceiptHash: record.contextReceipt ?? input.contextReceipt,
572
+ decisionHash,
573
+ optionsSnapshot,
574
+ },
575
+ });
576
+ if (receipt.manifestHash !== runtimeManifest.manifestHash || receipt.verified !== true)
577
+ throw new DeliveryLineError('ARTIFACT_HASH_MISMATCH', `plugin ${role} receipt is not verified`);
578
+ record.evidence.push({
579
+ id: randomUUID(),
580
+ kind: 'tool',
581
+ hash: hash({ role, pluginName: runtimeManifest.pluginName, pluginVersion: receipt.pluginVersion, manifestHash: receipt.manifestHash, outputHash: receipt.outputHash, contextReceiptHash: record.contextReceipt ?? input.contextReceipt, decisionHash }),
582
+ createdAt: Date.now(),
583
+ metadata: { role, pluginName: runtimeManifest.pluginName, pluginVersion: receipt.pluginVersion, manifestHash: receipt.manifestHash, outputHash: receipt.outputHash, stackFactsFingerprint, contextReceiptHash: record.contextReceipt ?? input.contextReceipt, decisionHash, verified: receipt.verified },
584
+ });
585
+ }
586
+ }
514
587
  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);
515
588
  record.stage = 'spec_ready';
516
589
  record.interaction = { ...record.interaction, phase: 'design', status: 'awaiting_approval', openQuestions: [], blockingUnknowns: [] };
517
- record.artifacts.push(await this.createArtifact(record, 'spec', renderSpecMarkdown({ title: record.title, option: selectedLabel, contextReceipt: input.contextReceipt, actor: input.actor }), input.actor));
590
+ const selectedOption = optionsSnapshot.find(option => option.id === optionId);
591
+ const acceptanceCriteria = record.decisions
592
+ .filter(decision => decision.kind === 'product')
593
+ .flatMap(decision => decision.acceptanceCriteria ?? [decision.selectedLabel ?? decision.decision]);
594
+ record.artifacts.push(await this.createArtifact(record, 'spec', renderSpecMarkdown({
595
+ title: record.title,
596
+ request: record.originalRequest ?? record.title,
597
+ selectedOption,
598
+ alternatives: optionsSnapshot.filter(option => option.id !== optionId),
599
+ acceptanceCriteria,
600
+ constraints: ['Preserve existing behavior outside the approved scope.', 'Use only dependencies and project conventions verified from the current repository.'],
601
+ existingImplementations: record.existingImplementations,
602
+ detectedStacks: record.stackFacts?.detectedStacks,
603
+ contextReceipt: record.contextReceipt ?? input.contextReceipt,
604
+ contextGaps: record.contextQuality?.gaps,
605
+ actor: input.actor,
606
+ }), input.actor));
607
+ return record;
608
+ });
609
+ }
610
+ /**
611
+ * Compile and persist a plan proposal before asking the user to approve it.
612
+ * Approval must consume this immutable draft by hash; it must never depend on
613
+ * the model being able to submit a plan object through a trusted command.
614
+ */
615
+ async preparePlan(caseId, input) {
616
+ requireIdentity(input);
617
+ if (input.actor.kind !== 'agent')
618
+ throw new DeliveryLineError('UNAUTHORIZED_ROLE', 'only an agent may propose an executable plan; a trusted user must approve it');
619
+ return this.mutate(caseId, input, 'plan.prepared', async (record) => {
620
+ if (input.turn !== record.interaction.turn)
621
+ throw staleTurnError(record, input.turn);
622
+ if (record.stage !== 'plan_ready')
623
+ throw new DeliveryLineError('INVALID_TRANSITION', 'plan preparation requires a ready plan stage', { currentStage: record.stage, recoveryAction: 'Refresh delivery_status and follow its nextActions.' });
624
+ if (record.artifacts.some(artifact => artifact.type === 'plan'))
625
+ throw new DeliveryLineError('IDEMPOTENCY_CONFLICT', 'a plan draft is already prepared; refresh delivery_status and approve the pending plan decision');
626
+ if (this.ports.context) {
627
+ const plannerReceipt = await this.ports.context.build({
628
+ role: 'planner',
629
+ ...(record.pluginSelection?.requiredContextChannels?.length ? { requiredChannels: record.pluginSelection.requiredContextChannels } : {}),
630
+ retrieval: {
631
+ taskRevision: record.version + 1,
632
+ projectRoot: record.projectRoot,
633
+ query: `${record.originalRequest ?? record.title} implementation plan`,
634
+ sessionId: input.sessionId,
635
+ executionId: input.executionId,
636
+ requestId: `delivery:plan:${record.id}:${record.version}`,
637
+ turnId: `delivery:plan:${record.id}:${record.interaction.turn}`,
638
+ },
639
+ });
640
+ record.contextReceipt = plannerReceipt.receiptHash;
641
+ if (plannerReceipt.channelStatus && plannerReceipt.gaps && plannerReceipt.attribution)
642
+ 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 } : {}) };
643
+ }
644
+ const planContent = renderPlanMarkdown(input.plan);
645
+ const planHash = contentHash(planContent);
646
+ new PlanCompiler().compile({
647
+ caseId: record.id,
648
+ projectRoot: record.projectRoot,
649
+ baseRevision: record.baseRef,
650
+ planHash,
651
+ contextReceiptHash: record.contextReceipt ?? input.contextReceipt,
652
+ hostSelectionSnapshotHash: hash({ mode: 'manual-plan', actor: input.actor, sessionId: input.sessionId, executionId: input.executionId }),
653
+ executionAuthority: hash({ caseId: record.id, stage: 'plan', planHash }),
654
+ plan: input.plan,
655
+ });
656
+ const artifact = await this.createArtifact(record, 'plan', planContent, input.actor, { path: 'plan.json', content: renderPlanJson(input.plan) });
657
+ if (artifact.contentHash !== planHash)
658
+ throw new DeliveryLineError('ARTIFACT_HASH_MISMATCH', 'plan artifact hash changed while persisting the proposal');
659
+ record.planDraft = structuredClone(input.plan);
660
+ record.artifacts.push(artifact);
661
+ record.interaction = { ...record.interaction, status: 'awaiting_approval' };
518
662
  return record;
519
663
  });
520
664
  }
@@ -530,31 +674,17 @@ export class DeliveryLineFacade {
530
674
  let planContent;
531
675
  let proposedArtifactHash;
532
676
  if (input.stage === 'plan') {
533
- if (!input.plan)
534
- throw new DeliveryLineError('PLAN_NOT_EXECUTABLE', 'plan approval requires an executable plan');
535
- if (this.ports.context) {
536
- const plannerReceipt = await this.ports.context.build({
537
- role: 'planner',
538
- ...(record.pluginSelection?.requiredContextChannels?.length ? { requiredChannels: record.pluginSelection.requiredContextChannels } : {}),
539
- retrieval: {
540
- taskRevision: record.version + 1,
541
- projectRoot: record.projectRoot,
542
- query: `${record.title} implementation plan`,
543
- sessionId: input.sessionId,
544
- executionId: input.executionId,
545
- requestId: `delivery:plan:${record.id}:${record.version}`,
546
- turnId: `delivery:plan:${record.id}:${record.interaction.turn}`,
547
- },
548
- });
549
- record.contextReceipt = plannerReceipt.receiptHash;
550
- if (plannerReceipt.channelStatus && plannerReceipt.gaps && plannerReceipt.attribution)
551
- 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 } : {}) };
552
- }
553
- planContent = renderPlanMarkdown(input.plan);
677
+ const proposedPlan = record.planDraft;
678
+ if (!proposedPlan)
679
+ throw new DeliveryLineError('PLAN_NOT_EXECUTABLE', 'plan approval requires a prepared executable plan');
680
+ planContent = renderPlanMarkdown(proposedPlan);
554
681
  proposedArtifactHash = contentHash(planContent);
682
+ const preparedArtifact = [...record.artifacts].reverse().find(item => item.type === 'plan');
683
+ if (preparedArtifact && preparedArtifact.contentHash !== proposedArtifactHash)
684
+ throw new DeliveryLineError('ARTIFACT_HASH_MISMATCH', 'prepared plan does not match the approval artifact');
555
685
  }
556
686
  let artifact = [...record.artifacts].reverse().find(item => item.type === artifactType);
557
- if (input.stage !== 'plan') {
687
+ if (input.stage !== 'release') {
558
688
  if (input.artifactId && artifact?.id !== input.artifactId)
559
689
  throw new DeliveryLineError('ARTIFACT_VERSION_CONFLICT', 'approval artifact id is stale');
560
690
  if (input.expectedVersion !== undefined && artifact?.version !== input.expectedVersion)
@@ -596,6 +726,9 @@ export class DeliveryLineFacade {
596
726
  if (input.stage === 'spec')
597
727
  record.stage = 'plan_ready';
598
728
  if (input.stage === 'plan') {
729
+ const proposedPlan = record.planDraft;
730
+ if (!proposedPlan)
731
+ throw new DeliveryLineError('PLAN_NOT_EXECUTABLE', 'plan approval requires a prepared executable plan');
599
732
  const envelopes = new PlanCompiler().compile({
600
733
  caseId: record.id,
601
734
  projectRoot: record.projectRoot,
@@ -604,13 +737,8 @@ export class DeliveryLineFacade {
604
737
  contextReceiptHash: record.contextReceipt ?? input.contextReceipt,
605
738
  hostSelectionSnapshotHash: hash({ mode: 'manual-plan', actor: input.actor, sessionId: input.sessionId, executionId: input.executionId }),
606
739
  executionAuthority: hash({ actor: input.actor, stage: input.stage, planHash: proposedArtifactHash }),
607
- plan: input.plan,
740
+ plan: proposedPlan,
608
741
  });
609
- record.artifacts = record.artifacts.filter(item => item.type !== 'plan');
610
- artifact = await this.createArtifact(record, 'plan', planContent, input.actor, { path: 'plan.json', content: renderPlanJson(input.plan) });
611
- if (artifact.contentHash !== proposedArtifactHash)
612
- throw new DeliveryLineError('ARTIFACT_HASH_MISMATCH', 'plan artifact hash changed while persisting the approval artifact');
613
- record.artifacts.push(artifact);
614
742
  record.stage = 'plan_approved';
615
743
  const decision = [...record.decisions].reverse().find(item => item.decisionHash);
616
744
  if (decision && record.pluginSelection && record.stackFactsHash) {
@@ -1036,7 +1164,7 @@ export class DeliveryLineFacade {
1036
1164
  id: pending.id,
1037
1165
  kind: pending.kind,
1038
1166
  question: `Approve the current ${pending.approval.stage} artifact?`,
1039
- options: [],
1167
+ options: pending.options,
1040
1168
  blocking: true,
1041
1169
  turn: pending.turn,
1042
1170
  command: `/devflow:approve ${pending.id}`,