@sema-agent/client-core 0.104.1 → 0.106.0

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.
Files changed (175) hide show
  1. package/CHANGELOG.md +310 -0
  2. package/README.md +36 -14
  3. package/dist/adapt/arms.js +23 -6
  4. package/dist/adapter/activeRunSelfHeal.d.ts +14 -2
  5. package/dist/adapter/activeRunSelfHeal.js +90 -12
  6. package/dist/adapter/downstream/eventToSdkMessage.d.ts +0 -5
  7. package/dist/adapter/downstream/eventToSdkMessage.js +24 -23
  8. package/dist/adapter/downstream/terminalToSdkResult.js +2 -3
  9. package/dist/adapter/downstream/wiringManifestView.d.ts +7 -3
  10. package/dist/adapter/downstream/wiringManifestView.js +25 -1
  11. package/dist/adapter/runStream.js +8 -5
  12. package/dist/approvalReachWire.d.ts +1 -0
  13. package/dist/approvalReachWire.js +4 -1
  14. package/dist/approvalSeat.d.ts +21 -0
  15. package/dist/approvalSeat.js +73 -0
  16. package/dist/assistantInbox.d.ts +2 -0
  17. package/dist/assistantInbox.js +3 -0
  18. package/dist/checkpointRowVerdict.d.ts +23 -0
  19. package/dist/checkpointRowVerdict.js +89 -0
  20. package/dist/cloudConfigWireCaps.js +3 -0
  21. package/dist/compensations.js +6 -6
  22. package/dist/coreValuePorts.d.ts +14 -1
  23. package/dist/coreValuePorts.js +14 -0
  24. package/dist/decideFailureNote.js +3 -1
  25. package/dist/decideReceipt.d.ts +9 -0
  26. package/dist/decideReceipt.js +29 -1
  27. package/dist/declared/supportFloorDeclared.js +2 -2
  28. package/dist/deviceRebind.d.ts +7 -0
  29. package/dist/deviceRebind.js +39 -5
  30. package/dist/displayUntrusted.d.ts +4 -2
  31. package/dist/displayUntrusted.js +45 -22
  32. package/dist/engineAddress.d.ts +3 -0
  33. package/dist/engineAddress.js +25 -0
  34. package/dist/engineDirectory.js +9 -17
  35. package/dist/engineErrorCodes.d.ts +8 -0
  36. package/dist/engineErrorCodes.js +8 -0
  37. package/dist/engineGenerationPort.d.ts +11 -2
  38. package/dist/engineGenerationPort.js +63 -9
  39. package/dist/engineHealthFacts.d.ts +5 -0
  40. package/dist/engineHealthFacts.js +11 -0
  41. package/dist/engineNoticeCodes.d.ts +26 -3
  42. package/dist/engineNoticeCodes.js +52 -7
  43. package/dist/engineRowRef.js +1 -1
  44. package/dist/errorSupplementRoles.js +2 -0
  45. package/dist/eventLeg.d.ts +3 -3
  46. package/dist/eventLeg.js +25 -4
  47. package/dist/fleet/fleetProjection.js +26 -5
  48. package/dist/generated/engineFactTables.d.ts +3 -1
  49. package/dist/generated/engineFactTables.js +4 -1
  50. package/dist/generated/engineNoticeTables.js +19 -4
  51. package/dist/generated/sdkFactTables.d.ts +5 -0
  52. package/dist/generated/sdkFactTables.js +24 -0
  53. package/dist/generated/settingsSchemaFns.d.ts +11 -0
  54. package/dist/generated/settingsSchemaFns.js +2 -1
  55. package/dist/generated/settingsSchemaSafetyMergeSpec.js +1 -1
  56. package/dist/generated/settingsSchemaSettingsSources.d.ts +1 -1
  57. package/dist/generated/settingsSchemaSettingsSources.js +22 -2
  58. package/dist/headlessReconnectWire.js +31 -4
  59. package/dist/hitl/approvalLiveFace.d.ts +17 -0
  60. package/dist/hitl/approvalLiveFace.js +35 -0
  61. package/dist/hitl/approvalQueueRow.d.ts +21 -0
  62. package/dist/hitl/approvalQueueRow.js +69 -0
  63. package/dist/hitl/approvalsFeed.d.ts +5 -4
  64. package/dist/hitl/approvalsFeed.js +38 -6
  65. package/dist/hitl/askGateWire.js +26 -2
  66. package/dist/hitl/askParkRowRouting.d.ts +4 -3
  67. package/dist/hitl/askParkRowRouting.js +15 -3
  68. package/dist/hitl/automaticAnswerVerdict.js +13 -10
  69. package/dist/hitl/crashConverged.d.ts +5 -1
  70. package/dist/hitl/decisionConflict.js +2 -1
  71. package/dist/hitl/decisionRefusal.js +1 -1
  72. package/dist/hitl/decisionSend.d.ts +1 -0
  73. package/dist/hitl/decisionSend.js +14 -2
  74. package/dist/hitl/frameRouter.d.ts +4 -0
  75. package/dist/hitl/frameRouter.js +18 -5
  76. package/dist/hitl/gateIdentity.d.ts +1 -0
  77. package/dist/hitl/gateIdentity.js +3 -0
  78. package/dist/hitl/hitlBridge.d.ts +1 -1
  79. package/dist/hitl/hitlBridge.js +26 -5
  80. package/dist/hitl/livePendingAsk.d.ts +5 -0
  81. package/dist/hitl/livePendingAsk.js +6 -1
  82. package/dist/hitl/planReviewCard.d.ts +23 -0
  83. package/dist/hitl/planReviewCard.js +250 -0
  84. package/dist/hitl/planReviewWire.d.ts +39 -14
  85. package/dist/hitl/planReviewWire.js +450 -163
  86. package/dist/hitl/sessionAllowRoute.d.ts +2 -0
  87. package/dist/hitl/sessionAllowRoute.js +7 -0
  88. package/dist/hitl/sessionPolicyWire.d.ts +1 -7
  89. package/dist/hitl/sessionPolicyWire.js +3 -43
  90. package/dist/hitl/subagentSource.d.ts +1 -0
  91. package/dist/hitl/subagentSource.js +13 -0
  92. package/dist/hitl/toolApprovalWire.d.ts +3 -2
  93. package/dist/hitl/toolApprovalWire.js +79 -37
  94. package/dist/hostSettingsSource.d.ts +5 -2
  95. package/dist/hostSettingsSource.js +19 -10
  96. package/dist/hostTurnSettings.d.ts +5 -6
  97. package/dist/hostTurnSettings.js +106 -26
  98. package/dist/i18n/messages.en.d.ts +85 -12
  99. package/dist/i18n/messages.en.js +85 -12
  100. package/dist/i18n/messages.zh.js +88 -12
  101. package/dist/index.d.ts +35 -4
  102. package/dist/index.js +19 -2
  103. package/dist/liveInitToolFace.js +0 -1
  104. package/dist/liveModelCatalog.d.ts +15 -0
  105. package/dist/liveModelCatalog.js +40 -0
  106. package/dist/mcpTransportKindsCapability.d.ts +18 -0
  107. package/dist/mcpTransportKindsCapability.js +75 -0
  108. package/dist/mcpWireCaps.d.ts +4 -1
  109. package/dist/mcpWireCaps.js +25 -14
  110. package/dist/modelCapabilityProbe.js +66 -33
  111. package/dist/notifications.d.ts +9 -0
  112. package/dist/notifications.js +11 -2
  113. package/dist/ownKey.d.ts +4 -1
  114. package/dist/ownKey.js +8 -2
  115. package/dist/permissionModeWriteCapability.d.ts +13 -0
  116. package/dist/permissionModeWriteCapability.js +35 -0
  117. package/dist/permissionsAllowGrantCapability.d.ts +3 -0
  118. package/dist/permissionsAllowGrantCapability.js +26 -3
  119. package/dist/permissionsDeclaration.d.ts +25 -3
  120. package/dist/permissionsDeclaration.js +69 -41
  121. package/dist/planReviewCardFacts.d.ts +116 -0
  122. package/dist/planReviewCardFacts.js +469 -0
  123. package/dist/projectSeed.d.ts +39 -0
  124. package/dist/projectSeed.js +80 -0
  125. package/dist/promptDelivery.js +1 -1
  126. package/dist/reattachWindow.d.ts +8 -1
  127. package/dist/reattachWindow.js +44 -12
  128. package/dist/replayBody.d.ts +7 -6
  129. package/dist/replayBody.js +62 -7
  130. package/dist/request/taskRequest.js +3 -4
  131. package/dist/ruleCompileVerdict.d.ts +0 -2
  132. package/dist/ruleCompileVerdict.js +0 -31
  133. package/dist/ruleRefusalSentence.js +0 -3
  134. package/dist/runFollow.d.ts +30 -0
  135. package/dist/runFollow.js +117 -0
  136. package/dist/runLedgerSnapshot.d.ts +20 -0
  137. package/dist/runLedgerSnapshot.js +272 -0
  138. package/dist/runPermissionModeSwitch.d.ts +57 -0
  139. package/dist/runPermissionModeSwitch.js +135 -0
  140. package/dist/runReattachPolicy.d.ts +16 -5
  141. package/dist/runReattachPolicy.js +72 -24
  142. package/dist/runTerminal.js +30 -8
  143. package/dist/sandboxWire.d.ts +0 -3
  144. package/dist/sandboxWire.js +0 -9
  145. package/dist/seam.d.ts +17 -7
  146. package/dist/seam.js +13 -1
  147. package/dist/seatContract.d.ts +57 -7
  148. package/dist/seatContract.js +133 -3
  149. package/dist/serverSupportRange.d.ts +18 -0
  150. package/dist/serverSupportRange.js +25 -0
  151. package/dist/sessionEngine.d.ts +156 -0
  152. package/dist/sessionEngine.js +464 -0
  153. package/dist/sessionMap.d.ts +11 -1
  154. package/dist/sessionMap.js +35 -9
  155. package/dist/sessionMemoryErase.d.ts +1 -1
  156. package/dist/sessionMemoryErase.js +5 -0
  157. package/dist/sessionMemoryStatus.d.ts +27 -1
  158. package/dist/sessionMemoryStatus.js +117 -0
  159. package/dist/sessionMemorySync.d.ts +31 -0
  160. package/dist/sessionMemorySync.js +95 -0
  161. package/dist/sessionRowTag.d.ts +4 -0
  162. package/dist/sessionRowTag.js +18 -0
  163. package/dist/settingsLayerAdmission.d.ts +1 -0
  164. package/dist/settingsLayerAdmission.js +4 -1
  165. package/dist/sseIdleTriage.d.ts +3 -1
  166. package/dist/sseIdleTriage.js +19 -4
  167. package/dist/subagent/engineSubagentTail.js +2 -0
  168. package/dist/subagentContentStore.d.ts +3 -0
  169. package/dist/subagentContentStore.js +10 -1
  170. package/dist/toolResult.js +1 -1
  171. package/dist/turnFailurePresentation.d.ts +5 -2
  172. package/dist/turnFailurePresentation.js +12 -4
  173. package/dist/wireRefusalCopy.js +2 -6
  174. package/docs/INTEGRATION-CLIENTS.md +1507 -18
  175. package/package.json +5 -5
@@ -2,23 +2,26 @@ import { publishQuestionFrame, publishQuestionFrameFor, registerLocalQuestionRes
2
2
  import { hostLog } from '../host.js';
3
3
  import { abortableSleep, settleWithinDeadline } from '../abortableSleep.js';
4
4
  import { clientHasVerbs, engineWireFor, noAnswerKindOf, sendOnce } from '../engineWireFor.js';
5
- import { engineCapTrue } from '../engineCapsCache.js';
6
5
  import { engineWireTarget } from '../engineWireTarget.js';
7
6
  import { enqueuePlanReviewOutcome } from '../notifications.js';
8
7
  import { corruptStoredRowContent, corruptStoredRowWhere } from '../wireErrorTriage.js';
9
8
  import { decideFailureNote } from '../decideFailureNote.js';
10
- import { wireFailureTextsOf } from '../wireFailureShape.js';
9
+ import { readThrownExtra, wireFailureTextsOf } from '../wireFailureShape.js';
11
10
  import { appendOwnElement, readOwnDataNonEmptyString, readOwnDataValue } from '../ownKey.js';
12
11
  import { checkpointUnsupportedVersionContent, checkpointUnsupportedVersionFromError, resumeContextUnavailableContent, resumeContextUnavailableFromError } from '../resumeRefusalCopy.js';
13
- import { REQUEST_FIELD_CONFLICT } from '../engineErrorCodes.js';
12
+ import { APPROVAL_STALE, REQUEST_FIELD_CONFLICT } from '../engineErrorCodes.js';
14
13
  import { refusalDeliveryOf } from './decisionRefusal.js';
15
14
  import { directFailureVerdictOf, directSafetyStopOf } from './approvalResolution.js';
16
15
  import { __resetDecisionLedgerForTests, decisionInFlightOf, decisionKeyInFlight, decisionLedgerKeysOf, decisionOpsWithKey, decisionReadMark, dropDecisionOp, latestDecisionHandedMark, markLatestDecisionNotApplied, openDecisionOp, planDecisionKey, readMarkOf, settleDecisionOp, settleDecisionOpsOnGateLeft, } from './decisionLedger.js';
17
16
  import { denyReasonForWire, isPlanReviewModeAfter } from './hitlBridge.js';
18
- import { planReviewQuestionId, REOPEN_ID_TAIL, taskIdFromPlanReviewQuestionId } from './gateIdentity.js';
17
+ import { freshPlanReviewQuestionId, planReviewQuestionId, REOPEN_ID_TAIL, taskIdFromPlanReviewQuestionId } from './gateIdentity.js';
18
+ import { isPlanReviewPark, mintPlanReviewCardFacts, notePlanReviewCardIssued, pausedCheckpointIdOf, planReviewCardFactsFrom, planReviewFactsCurrent, registeredPlanReviewCardFacts, samePlanReviewCardFacts, unknownBody, _resetPlanReviewRunsForTest, } from '../planReviewCardFacts.js';
19
+ import { planReviewAskAgainKey, planReviewCardNoteRef, planReviewDecisionReasonRef, planReviewUnreadableNoteRef, takePlanReviewFacts, _resetPlanReviewFactsFlightsForTest, } from './planReviewCard.js';
20
+ import { EN_MESSAGES } from '../i18n/messages.en.js';
21
+ import { msg, renderMessage, DATA_FACE_LANGUAGE } from '../i18n/translate.js';
19
22
  import { notePlanReviewAnswered, notePlanReviewAnsweredFor, planReviewArmedKeyFor, waitForGateArmedFor, wasGateArmedFor, } from './armedGateRegistry.js';
20
23
  import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
21
- import { PLAN_REVIEW_GATE_KIND, durableDecideLegOf } from '../adapter/activeRunSelfHeal.js';
24
+ import { durableDecideLegOf } from '../adapter/activeRunSelfHeal.js';
22
25
  import { readRunTerminal, runTerminalGateKind } from '../runTerminal.js';
23
26
  const PLAN_REVIEW_RESUME_TIMEOUT_MS = 6 * 60 * 60_000;
24
27
  export const PLAN_REVIEW_APPROVE_LABEL = 'Yes, approve and run the plan';
@@ -27,17 +30,33 @@ export const PLAN_REVIEW_APPROVE_AUTO_LABEL = 'Yes, and auto-accept edits';
27
30
  export const PLAN_REVIEW_APPROVE_MANUAL_LABEL = 'Yes, and manually approve edits';
28
31
  const APPROVE_LABEL = PLAN_REVIEW_APPROVE_LABEL;
29
32
  const REJECT_LABEL = PLAN_REVIEW_REJECT_LABEL;
30
- const LEGACY_TWO_CHOICE_LABELS = Object.freeze([PLAN_REVIEW_APPROVE_LABEL, PLAN_REVIEW_REJECT_LABEL]);
31
- const THREE_CHOICE_LABELS = Object.freeze([PLAN_REVIEW_APPROVE_AUTO_LABEL, PLAN_REVIEW_APPROVE_MANUAL_LABEL, PLAN_REVIEW_REJECT_LABEL]);
32
- const ANY_CARD_SHAPE_LABELS = Object.freeze([PLAN_REVIEW_APPROVE_AUTO_LABEL, PLAN_REVIEW_APPROVE_MANUAL_LABEL, PLAN_REVIEW_APPROVE_LABEL, PLAN_REVIEW_REJECT_LABEL]);
33
+ const ASK_AGAIN_LABEL = EN_MESSAGES['approval.planReview.option.askAgain.label'];
34
+ const REJECT_OPTION = Object.freeze({ label: REJECT_LABEL, description: 'Discard this plan and stay in plan mode to refine it', choice: 'reject' });
35
+ const OPTIONS_BY_SHAPE = Object.freeze({
36
+ two_choice: Object.freeze([
37
+ Object.freeze({ label: APPROVE_LABEL, description: 'Resume the task now and execute the plan (runs to completion engine-side)', choice: 'approve' }),
38
+ REJECT_OPTION,
39
+ ]),
40
+ three_choice: Object.freeze([
41
+ Object.freeze({ label: PLAN_REVIEW_APPROVE_AUTO_LABEL, description: 'Resume and execute the plan; edits inside the working directory are not asked about one by one', choice: 'approve_accept_edits' }),
42
+ Object.freeze({ label: PLAN_REVIEW_APPROVE_MANUAL_LABEL, description: 'Resume and execute the plan; every edit still asks for approval', choice: 'approve_manual' }),
43
+ REJECT_OPTION,
44
+ ]),
45
+ withheld: Object.freeze([REJECT_OPTION, Object.freeze({ label: ASK_AGAIN_LABEL, description: EN_MESSAGES['approval.planReview.option.askAgain.description'], choice: 'ask_again' })]),
46
+ });
47
+ const LEGACY_TWO_CHOICE_LABELS = Object.freeze(OPTIONS_BY_SHAPE.two_choice.map((o) => o.label));
48
+ const THREE_CHOICE_LABELS = Object.freeze(OPTIONS_BY_SHAPE.three_choice.map((o) => o.label));
49
+ const WITHHELD_LABELS = Object.freeze(OPTIONS_BY_SHAPE.withheld.map((o) => o.label));
50
+ const LABELS_BY_SHAPE = Object.freeze({ three_choice: THREE_CHOICE_LABELS, two_choice: LEGACY_TWO_CHOICE_LABELS, withheld: WITHHELD_LABELS });
51
+ const ANY_CARD_SHAPE_LABELS = Object.freeze([...new Set([...THREE_CHOICE_LABELS, ...LEGACY_TWO_CHOICE_LABELS, ...WITHHELD_LABELS])]);
33
52
  export function planReviewDecisionFromAnswer(answer, shownLabels = LEGACY_TWO_CHOICE_LABELS) {
34
53
  const { choice } = readPlanReviewAnswer(answer, shownLabels);
35
- if (choice === 'dismissed')
36
- return 'dismissed';
54
+ if (typeof choice === 'string')
55
+ return choice;
37
56
  return choice.decision;
38
57
  }
39
- function readDismissed() {
40
- return { choice: 'dismissed', noteIgnoredOnApprove: false };
58
+ function readNonDecision(choice) {
59
+ return { choice, noteIgnoredOnApprove: false };
41
60
  }
42
61
  function freeTextNoteOf(entry) {
43
62
  try {
@@ -50,23 +69,25 @@ function freeTextNoteOf(entry) {
50
69
  }
51
70
  function readPlanReviewAnswer(answer, shownLabels) {
52
71
  const answers = answer?.answers;
53
- if (!Array.isArray(answers) || answers.length !== 1)
54
- return readDismissed();
72
+ if (!Array.isArray(answers) || answers.length === 0)
73
+ return readNonDecision('dismissed');
74
+ if (answers.length !== 1)
75
+ return readNonDecision('inadmissible');
55
76
  const entry = answers[0];
56
77
  const picked = entry?.selected;
57
78
  if (!Array.isArray(picked))
58
- return readDismissed();
79
+ return readNonDecision('dismissed');
59
80
  const note = freeTextNoteOf(entry);
60
81
  if (picked.length === 0) {
61
82
  if (note === undefined || !shownLabels.includes(REJECT_LABEL))
62
- return readDismissed();
83
+ return readNonDecision('dismissed');
63
84
  return { choice: { decision: 'reject', reason: note }, noteIgnoredOnApprove: false };
64
85
  }
65
86
  if (picked.length !== 1)
66
- return readDismissed();
87
+ return readNonDecision('inadmissible');
67
88
  const selected = picked[0];
68
89
  if (typeof selected !== 'string' || !shownLabels.includes(selected))
69
- return readDismissed();
90
+ return readNonDecision('inadmissible');
70
91
  const ignored = note !== undefined;
71
92
  if (selected === PLAN_REVIEW_APPROVE_AUTO_LABEL)
72
93
  return { choice: { decision: 'approve', permissionModeAfter: 'acceptEdits' }, noteIgnoredOnApprove: ignored };
@@ -76,7 +97,9 @@ function readPlanReviewAnswer(answer, shownLabels) {
76
97
  return { choice: { decision: 'approve' }, noteIgnoredOnApprove: ignored };
77
98
  if (selected === REJECT_LABEL)
78
99
  return { choice: note === undefined ? { decision: 'reject' } : { decision: 'reject', reason: note }, noteIgnoredOnApprove: false };
79
- return readDismissed();
100
+ if (selected === ASK_AGAIN_LABEL)
101
+ return { choice: note === undefined ? { decision: 'ask_again' } : { decision: 'ask_again', reason: note }, noteIgnoredOnApprove: false };
102
+ return readNonDecision('inadmissible');
80
103
  }
81
104
  export function planReviewChoiceFromAnswer(answer, shownLabels = LEGACY_TWO_CHOICE_LABELS) {
82
105
  const { choice, noteIgnoredOnApprove } = readPlanReviewAnswer(answer, shownLabels);
@@ -85,21 +108,53 @@ export function planReviewChoiceFromAnswer(answer, shownLabels = LEGACY_TWO_CHOI
85
108
  return choice;
86
109
  }
87
110
  export function planReviewCardOptions(offerModeAfter) {
88
- const reject = { label: REJECT_LABEL, description: 'Discard this plan and stay in plan mode to refine it' };
89
- if (!offerModeAfter) {
90
- return [{ label: APPROVE_LABEL, description: 'Resume the task now and execute the plan (runs to completion engine-side)' }, reject];
111
+ return OPTIONS_BY_SHAPE[offerModeAfter ? 'three_choice' : 'two_choice'].map(({ label, description }) => ({ label, description }));
112
+ }
113
+ export function planReviewOptionsOf(facts, opts) {
114
+ return OPTIONS_BY_SHAPE[cardShapeOf(facts, submittedInPlanModeOf(opts))];
115
+ }
116
+ function submittedInPlanModeOf(opts) {
117
+ try {
118
+ return readOwnDataValue(opts, 'submittedInPlanMode') === true;
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
124
+ function cardShapeOf(facts, submittedInPlanMode) {
125
+ try {
126
+ if (planReviewCardFactsFrom(facts)?.body.kind !== 'shown')
127
+ return 'withheld';
128
+ }
129
+ catch {
130
+ return 'withheld';
131
+ }
132
+ return submittedInPlanMode ? 'three_choice' : 'two_choice';
133
+ }
134
+ export function planReviewCardViewOf(frame, lang) {
135
+ try {
136
+ const questionId = readOwnDataNonEmptyString(frame ?? undefined, 'questionId');
137
+ const taskId = questionId === undefined ? undefined : taskIdFromPlanReviewQuestionId(questionId);
138
+ if (taskId === undefined)
139
+ return undefined;
140
+ const facts = planReviewCardFactsFrom(readOwnDataValue(frame ?? undefined, '_sema_planReview'));
141
+ if (facts === undefined || facts.taskId !== taskId) {
142
+ return Object.freeze({ options: OPTIONS_BY_SHAPE.withheld, note: renderMessage(planReviewUnreadableNoteRef(), lang) });
143
+ }
144
+ const shape = cardShapeOf(facts, frame !== null && frame !== undefined && offeredModeFromLabels(frame) === 'three_choice');
145
+ const noteRef = planReviewCardNoteRef(facts);
146
+ return Object.freeze({ facts, options: OPTIONS_BY_SHAPE[shape], ...(noteRef !== undefined ? { note: renderMessage(noteRef, lang) } : {}) });
147
+ }
148
+ catch {
149
+ return Object.freeze({ options: OPTIONS_BY_SHAPE.withheld, note: renderMessage(planReviewUnreadableNoteRef(), lang) });
91
150
  }
92
- return [
93
- { label: PLAN_REVIEW_APPROVE_AUTO_LABEL, description: 'Resume and execute the plan; edits inside the working directory are not asked about one by one' },
94
- { label: PLAN_REVIEW_APPROVE_MANUAL_LABEL, description: 'Resume and execute the plan; every edit still asks for approval' },
95
- reject,
96
- ];
97
151
  }
98
152
  export function notePlanReviewAnsweredIfDecisive(questionId, answer) {
99
153
  notePlanReviewAnsweredIfDecisiveFor(DEFAULT_SESSION_KEY, questionId, answer);
100
154
  }
101
155
  function notePlanReviewAnsweredIfDecisiveFor(sessionKey, questionId, answer) {
102
- if (planReviewDecisionFromAnswer(answer ?? { answers: [] }, planReviewShownLabelsOf(questionId) ?? ANY_CARD_SHAPE_LABELS) === 'dismissed')
156
+ const decided = planReviewDecisionFromAnswer(answer ?? { answers: [] }, planReviewShownLabelsOf(questionId) ?? ANY_CARD_SHAPE_LABELS);
157
+ if (decided === 'dismissed' || decided === 'inadmissible')
103
158
  return;
104
159
  notePlanReviewAnsweredFor(sessionKey, questionId);
105
160
  const taskId = typeof questionId === 'string' ? taskIdFromPlanReviewQuestionId(questionId) : undefined;
@@ -110,27 +165,7 @@ function notePlanReviewAnsweredIfDecisiveFor(sessionKey, questionId, answer) {
110
165
  return;
111
166
  notePlanReviewDecisiveAnswerFor(sessionKey, taskId);
112
167
  }
113
- export function isPlanReviewPark(result) {
114
- const read = readRunTerminal(result);
115
- if (read === null || read.kind !== 'paused')
116
- return false;
117
- if (runTerminalGateKind(read) !== PLAN_REVIEW_GATE_KIND)
118
- return false;
119
- const taskId = result?.taskId;
120
- return typeof taskId === 'string' && taskId.length > 0;
121
- }
122
- function pausedCheckpointIdOf(carrier) {
123
- try {
124
- const read = readRunTerminal(carrier);
125
- if (read === null || read.kind !== 'paused')
126
- return undefined;
127
- const id = read.checkpointId;
128
- return typeof id === 'string' && id.length > 0 ? id : undefined;
129
- }
130
- catch {
131
- return undefined;
132
- }
133
- }
168
+ export { isPlanReviewPark };
134
169
  function pausedGateKindOf(carrier) {
135
170
  try {
136
171
  const read = readRunTerminal(carrier);
@@ -143,15 +178,16 @@ function pausedGateKindOf(carrier) {
143
178
  return undefined;
144
179
  }
145
180
  }
146
- const armedPlanReviewTaskIds = new Set();
147
- const offerModeAfterByTask = new Map();
181
+ const armedPlanReviews = new Map();
148
182
  const offeredModeByQuestionId = new Map();
149
183
  const OFFERED_MODE_LEDGER_MAX = 64;
150
184
  export function _resetArmedPlanReviewsForTest() {
151
- armedPlanReviewTaskIds.clear();
152
- offerModeAfterByTask.clear();
185
+ engineRefusedGateIds.clear();
186
+ armedPlanReviews.clear();
153
187
  offeredModeByQuestionId.clear();
154
188
  planReviewDismissalsByKey.clear();
189
+ _resetPlanReviewRunsForTest();
190
+ _resetPlanReviewFactsFlightsForTest();
155
191
  }
156
192
  const ON_SCREEN = Promise.resolve(true);
157
193
  const planReviewDismissalsByKey = createSessionSlot();
@@ -241,31 +277,40 @@ export function clearPlanReviewUserDismissals(sessionKey = DEFAULT_SESSION_KEY)
241
277
  }
242
278
  const ARM_QUESTION = 'The plan is ready for review. Approve it and start the implementation?';
243
279
  const REOPEN_QUESTION = 'This plan is still waiting for your review — it is what is holding this session. Approve it and start the implementation?';
244
- function planReviewQuestions(question, offerModeAfter = false) {
280
+ function planReviewQuestions(question, shape) {
245
281
  return [
246
282
  {
247
283
  header: 'Plan review',
248
284
  question,
249
- options: planReviewCardOptions(offerModeAfter),
285
+ options: OPTIONS_BY_SHAPE[shape].map(({ label, description }) => ({ label, description })),
250
286
  multiSelect: false,
251
287
  },
252
288
  ];
253
289
  }
254
- function planReviewQuestionFrame(questionId, question, offerModeAfter, reopened = false) {
255
- if (!reopened) {
256
- offeredModeByQuestionId.delete(questionId);
257
- offeredModeByQuestionId.set(questionId, offerModeAfter);
258
- while (offeredModeByQuestionId.size > OFFERED_MODE_LEDGER_MAX) {
259
- const oldest = offeredModeByQuestionId.keys().next();
260
- if (oldest.done === true)
261
- break;
262
- offeredModeByQuestionId.delete(oldest.value);
263
- }
290
+ function planReviewQuestionFrame(questionId, question, shape, facts) {
291
+ offeredModeByQuestionId.delete(questionId);
292
+ offeredModeByQuestionId.set(questionId, shape);
293
+ while (offeredModeByQuestionId.size > OFFERED_MODE_LEDGER_MAX) {
294
+ const oldest = offeredModeByQuestionId.keys().next();
295
+ if (oldest.done === true)
296
+ break;
297
+ offeredModeByQuestionId.delete(oldest.value);
264
298
  }
265
- return { type: 'question', questionId, questions: planReviewQuestions(question, offerModeAfter) };
299
+ return {
300
+ type: 'question',
301
+ questionId,
302
+ questions: planReviewQuestions(question, shape),
303
+ ...(facts !== undefined ? { _sema_planReview: facts } : {}),
304
+ };
266
305
  }
267
- function publishPlanReviewCard(taskId) {
268
- publishQuestionFrame(planReviewQuestionFrame(planReviewQuestionId(taskId), ARM_QUESTION, offerModeAfterByTask.get(taskId) === true));
306
+ function publishArmedPlanReviewCard(inst) {
307
+ publishQuestionFrame(planReviewQuestionFrame(inst.questionId, ARM_QUESTION, inst.shape, inst.facts));
308
+ }
309
+ function retireArmedPlanReview(taskId, inst) {
310
+ if (armedPlanReviews.get(taskId) === inst)
311
+ armedPlanReviews.delete(taskId);
312
+ unregisterLocalQuestionResponder(inst.questionId);
313
+ publishQuestionFrame({ type: 'question_complete', questionId: inst.questionId });
269
314
  }
270
315
  const OFFERED_MODE_SCAN_MAX = 32;
271
316
  function ownElementsOf(v, max) {
@@ -280,6 +325,7 @@ function ownElementsOf(v, max) {
280
325
  function offeredModeFromLabels(card) {
281
326
  let modeAfter = false;
282
327
  let plain = false;
328
+ let askAgain = false;
283
329
  for (const q of ownElementsOf(readOwnDataValue(card, 'questions'), OFFERED_MODE_SCAN_MAX)) {
284
330
  if (typeof q !== 'object' || q === null)
285
331
  continue;
@@ -291,8 +337,12 @@ function offeredModeFromLabels(card) {
291
337
  modeAfter = true;
292
338
  else if (label === APPROVE_LABEL)
293
339
  plain = true;
340
+ else if (label === ASK_AGAIN_LABEL)
341
+ askAgain = true;
294
342
  }
295
343
  }
344
+ if (askAgain)
345
+ return modeAfter || plain ? undefined : 'withheld';
296
346
  if (modeAfter && !plain)
297
347
  return 'three_choice';
298
348
  if (plain && !modeAfter)
@@ -310,10 +360,7 @@ export function planReviewTaskIdFromQuestionId(questionId) {
310
360
  function offeredModeFromQuestionId(questionId) {
311
361
  if (taskIdFromPlanReviewQuestionId(questionId) === undefined)
312
362
  return 'unknown';
313
- const recorded = offeredModeByQuestionId.get(questionId);
314
- if (recorded !== undefined)
315
- return recorded ? 'three_choice' : 'two_choice';
316
- return questionId.includes(REOPEN_ID_TAIL) ? 'two_choice' : 'unknown';
363
+ return offeredModeByQuestionId.get(questionId) ?? 'unknown';
317
364
  }
318
365
  export function planReviewOfferedModeOf(card) {
319
366
  try {
@@ -337,14 +384,16 @@ export function planReviewShownLabelsOf(questionId) {
337
384
  return undefined;
338
385
  const recorded = offeredModeByQuestionId.get(questionId);
339
386
  if (recorded !== undefined)
340
- return recorded ? THREE_CHOICE_LABELS : LEGACY_TWO_CHOICE_LABELS;
341
- for (const t of armedPlanReviewTaskIds) {
342
- if (planReviewQuestionId(t) === questionId)
343
- return offerModeAfterByTask.get(t) === true ? THREE_CHOICE_LABELS : LEGACY_TWO_CHOICE_LABELS;
387
+ return LABELS_BY_SHAPE[recorded];
388
+ for (const inst of armedPlanReviews.values()) {
389
+ if (inst.questionId === questionId)
390
+ return LABELS_BY_SHAPE[inst.shape];
344
391
  }
345
- if (taskIdFromPlanReviewQuestionId(questionId) === undefined)
346
- return undefined;
347
- return questionId.includes(REOPEN_ID_TAIL) ? LEGACY_TWO_CHOICE_LABELS : undefined;
392
+ for (const r of activeReopenResponders.values()) {
393
+ if (r.questionId === questionId)
394
+ return LABELS_BY_SHAPE[r.shape];
395
+ }
396
+ return undefined;
348
397
  }
349
398
  catch {
350
399
  return undefined;
@@ -384,28 +433,81 @@ export function armPlanReviewApproval(result, sessionKey, opts) {
384
433
  : undefined;
385
434
  const { taskId } = result;
386
435
  const gateBefore = pausedCheckpointIdOf(result);
436
+ if (gateBefore === undefined) {
437
+ presentArmedPlanReview(mintPlanReviewCardFacts(taskId, undefined, unknownBody('gate_unidentified')), delivery);
438
+ return true;
439
+ }
440
+ const taking = takePlanReviewFacts(DEFAULT_SESSION_KEY, taskId, gateBefore, () => planReviewLedgerSourceFor(wire, DEFAULT_SESSION_KEY));
441
+ if (taking.kind === 'now') {
442
+ presentArmedPlanReview(taking.taken.facts, delivery);
443
+ return true;
444
+ }
445
+ hostLog('debug', `planReviewWire: arm-await-ledger task=${taskId}`);
446
+ void taking.pending.then((taken) => {
447
+ try {
448
+ if (taken !== undefined && planReviewFactsCurrent(taken)) {
449
+ presentArmedPlanReview(taken.facts, delivery);
450
+ return;
451
+ }
452
+ const current = registeredPlanReviewCardFacts(DEFAULT_SESSION_KEY, taskId, gateBefore);
453
+ if (current !== undefined) {
454
+ presentArmedPlanReview(current, delivery);
455
+ return;
456
+ }
457
+ hostLog('debug', `planReviewWire: arm-superseded task=${taskId}`);
458
+ }
459
+ catch (e) {
460
+ if (armedTaskId !== undefined)
461
+ armedPlanReviews.delete(armedTaskId);
462
+ hostLog('debug', `planReviewWire: arm-publish-failed task=${taskId} err=${String(e)}`);
463
+ }
464
+ });
465
+ return true;
466
+ }
467
+ catch (e) {
468
+ if (armedTaskId !== undefined)
469
+ armedPlanReviews.delete(armedTaskId);
470
+ hostLog('debug', `planReviewWire: arm failed (fail-soft): ${String(e)}`);
471
+ return false;
472
+ }
473
+ function presentArmedPlanReview(facts, delivery) {
474
+ if (!isPlanReviewPark(result))
475
+ return;
476
+ const { taskId } = result;
477
+ const gateBefore = pausedCheckpointIdOf(result);
478
+ const shape = cardShapeOf(facts, submittedInPlanModeOf(opts));
387
479
  retireActiveReopen(DEFAULT_SESSION_KEY, planReviewQuestionId(taskId));
388
- if (armedPlanReviewTaskIds.has(taskId)) {
389
- if (hasLocalQuestionResponder(planReviewQuestionId(taskId))) {
480
+ const prev = armedPlanReviews.get(taskId);
481
+ const hadArmedInstance = prev !== undefined;
482
+ if (prev !== undefined) {
483
+ if (hasLocalQuestionResponder(prev.questionId) &&
484
+ prev.checkpointId !== undefined &&
485
+ prev.checkpointId === gateBefore &&
486
+ samePlanReviewCardFacts(prev.facts, facts)) {
390
487
  hostLog('debug', `planReviewWire: arm replayed — re-presenting the still-armed card for task ${taskId}`);
391
- publishPlanReviewCard(taskId);
392
- return true;
488
+ publishArmedPlanReviewCard(prev);
489
+ return;
393
490
  }
394
- hostLog('debug', `planReviewWire: stale armed state for task ${taskId} (no local responder) — re-arming from scratch`);
395
- armedPlanReviewTaskIds.delete(taskId);
491
+ hostLog('debug', `planReviewWire: arm-retire task=${taskId} card=${prev.questionId}`);
492
+ retireArmedPlanReview(taskId, prev);
396
493
  }
397
- armedPlanReviewTaskIds.add(taskId);
494
+ const issuedBefore = notePlanReviewCardIssued(DEFAULT_SESSION_KEY, taskId);
495
+ const questionId = issuedBefore || hadArmedInstance ? freshPlanReviewQuestionId(taskId, reopenIdSuffix()) : planReviewQuestionId(taskId);
496
+ const instance = Object.freeze({ questionId, ...(gateBefore !== undefined ? { checkpointId: gateBefore } : {}), facts, shape });
497
+ armedPlanReviews.set(taskId, instance);
398
498
  armedTaskId = taskId;
399
- const offerModeAfter = opts?.submittedInPlanMode === true;
400
- if (offerModeAfter)
401
- offerModeAfterByTask.set(taskId, true);
402
- else
403
- offerModeAfterByTask.delete(taskId);
404
- const questionId = planReviewQuestionId(taskId);
405
- const unregister = registerLocalQuestionResponder(questionId, async (_id, answer) => {
499
+ let unregister = () => { };
500
+ const responder = async (_id, answer) => {
406
501
  const choice = planReviewAnswerChoiceOf(questionId, answer);
502
+ if (choice === 'inadmissible') {
503
+ if (armedPlanReviews.get(taskId) === instance && !hasLocalQuestionResponder(questionId))
504
+ unregister = registerLocalQuestionResponder(questionId, responder);
505
+ hostLog('debug', `planReviewWire: answer-inadmissible card=${questionId}`);
506
+ return { ok: false, inadmissible: true };
507
+ }
407
508
  unregister();
408
- armedPlanReviewTaskIds.delete(taskId);
509
+ if (armedPlanReviews.get(taskId) === instance)
510
+ armedPlanReviews.delete(taskId);
409
511
  if (choice === 'dismissed') {
410
512
  notePlanReviewUserDismissalFor(DEFAULT_SESSION_KEY, taskId, opts?.readGateInstanceKeys, questionId);
411
513
  return { ok: true };
@@ -413,25 +515,16 @@ export function armPlanReviewApproval(result, sessionKey, opts) {
413
515
  const decided = choice.decision;
414
516
  notePlanReviewDecisiveAnswerFor(DEFAULT_SESSION_KEY, taskId);
415
517
  const modeAfter = choice.decision === 'approve' ? choice.permissionModeAfter : undefined;
416
- const cardReason = choice.decision === 'reject' ? choice.reason : undefined;
518
+ const cardReason = choice.decision === 'approve' ? undefined : choice.reason;
417
519
  const cardDelivery = cardReason !== undefined ? { ...delivery, reason: cardReason } : delivery;
418
- offerModeAfterByTask.delete(taskId);
419
520
  notePlanReviewAnswered(questionId);
420
521
  hostLog('debug', `planReviewWire: arm responder delivering ${decided} for task ${taskId} (path=arm)`);
421
- void decidePlanReviewFor(taskId, decided, modeAfter, cardDelivery, gateBefore);
522
+ void decidePlanReviewFor(taskId, decided, modeAfter, cardDelivery, facts);
422
523
  return { ok: true };
423
- });
424
- publishPlanReviewCard(taskId);
524
+ };
525
+ unregister = registerLocalQuestionResponder(questionId, responder);
526
+ publishArmedPlanReviewCard(instance);
425
527
  hostLog('debug', `planReviewWire: approval card armed for parked plan (task ${taskId})`);
426
- return true;
427
- }
428
- catch (e) {
429
- if (armedTaskId !== undefined) {
430
- armedPlanReviewTaskIds.delete(armedTaskId);
431
- offerModeAfterByTask.delete(armedTaskId);
432
- }
433
- hostLog('debug', `planReviewWire: arm failed (fail-soft): ${String(e)}`);
434
- return false;
435
528
  }
436
529
  }
437
530
  export function planReviewOutcomeTail(taskId, decision, effect, cause) {
@@ -441,7 +534,7 @@ export function planReviewOutcomeTail(taskId, decision, effect, cause) {
441
534
  switch (effect) {
442
535
  case 'took_effect':
443
536
  return decision === 'approve'
444
- ? `Use TaskOutput("${taskId}") if you need the resumed task's full output, then give the user a brief report of what was done.`
537
+ ? `The result of the resumed task ${JSON.stringify(taskId)} arrives in its completion notification; when it arrives, give the user a brief report of what was done.`
445
538
  : 'Briefly acknowledge to the user that the plan was rejected and continue planning.';
446
539
  case 'advanced':
447
540
  return `Tell the user the ${decision} went through and that another approval is now waiting for them; do not report the task as finished.`;
@@ -450,9 +543,9 @@ export function planReviewOutcomeTail(taskId, decision, effect, cause) {
450
543
  case 'not_sent':
451
544
  return `Nothing was sent to the engine. Do not tell the user the plan was ${decision === 'approve' ? 'approved' : 'rejected'}; the plan is still waiting for a decision.`;
452
545
  case 'not_applied':
453
- return `The ${decision} did not take effect. Do not tell the user the plan was ${decision === 'approve' ? 'approved' : 'rejected'}; check the task's current state with TaskOutput("${taskId}") before saying anything about the plan.`;
546
+ return `The ${decision} did not take effect. Do not tell the user the plan was ${decision === 'approve' ? 'approved' : 'rejected'}; find out where task ${JSON.stringify(taskId)} stands (the background task list or its next notification shows it) before saying anything about the plan.`;
454
547
  case 'unconfirmed':
455
- return `Do not state the result as fact: tell the user the ${decision} was submitted but its effect could not be confirmed; TaskOutput("${taskId}") shows where the task stands.`;
548
+ return `Do not state the result as fact: tell the user the ${decision} was submitted but its effect could not be confirmed; the background task list or the next notification of task ${JSON.stringify(taskId)} shows where it stands.`;
456
549
  }
457
550
  }
458
551
  function planReviewDecisionTimedOutOutcome(decision) {
@@ -539,12 +632,39 @@ export function latestPlanReviewDecisionMark(taskId) {
539
632
  export function __resetPlanReviewDecisionLatchForTests() {
540
633
  __resetDecisionLedgerForTests('plan_review');
541
634
  }
635
+ const engineRefusedGateIds = new Set();
636
+ const ENGINE_REFUSED_GATE_IDS_MAX = 64;
637
+ function noteEngineRefusedGate(id) {
638
+ if (engineRefusedGateIds.has(id))
639
+ return;
640
+ engineRefusedGateIds.add(id);
641
+ while (engineRefusedGateIds.size > ENGINE_REFUSED_GATE_IDS_MAX) {
642
+ const oldest = engineRefusedGateIds.values().next();
643
+ if (oldest.done === true)
644
+ break;
645
+ engineRefusedGateIds.delete(oldest.value);
646
+ }
647
+ }
648
+ function planReviewDecisionReasonSegment(reason, card) {
649
+ if (reason === undefined)
650
+ return '';
651
+ try {
652
+ return ` ${renderMessage(planReviewDecisionReasonRef(reason, card), DATA_FACE_LANGUAGE)}`;
653
+ }
654
+ catch {
655
+ return '';
656
+ }
657
+ }
542
658
  export async function decidePlanReview(taskId, decision, permissionModeAfter, opts) {
543
- return decidePlanReviewFor(taskId, decision, permissionModeAfter, opts, parkedGateIdentityOf(opts));
659
+ return decidePlanReviewFor(taskId, decision, permissionModeAfter, opts, decisionCardOf(taskId, opts));
544
660
  }
545
- function parkedGateIdentityOf(opts) {
661
+ function decisionCardOf(taskId, opts) {
546
662
  try {
547
- return opts !== null && typeof opts === 'object' ? pausedCheckpointIdOf(opts.parkedResult) : undefined;
663
+ if (opts === null || typeof opts !== 'object')
664
+ return undefined;
665
+ const raw = readOwnDataValue(opts, 'card');
666
+ const facts = raw !== null && typeof raw === 'object' ? planReviewCardFactsFrom(raw) : undefined;
667
+ return facts !== undefined && facts.taskId === taskId ? facts : undefined;
548
668
  }
549
669
  catch {
550
670
  return undefined;
@@ -588,7 +708,17 @@ function bodySaysReparked(body) {
588
708
  return false;
589
709
  }
590
710
  }
591
- async function decidePlanReviewFor(taskId, decision, permissionModeAfter, opts, gateBefore) {
711
+ function askAgainReasonOf(card, opts) {
712
+ try {
713
+ const sentence = renderMessage(msg(planReviewAskAgainKey(card === undefined ? undefined : planReviewCardFactsFrom(card))), DATA_FACE_LANGUAGE);
714
+ const note = opts !== null && typeof opts === 'object' ? opts.reason : undefined;
715
+ return typeof note === 'string' && note.trim() !== '' ? renderMessage(msg('approval.planReview.askAgain.withNote', { sentence, note }), DATA_FACE_LANGUAGE) : sentence;
716
+ }
717
+ catch {
718
+ return renderMessage(msg('approval.planReview.askAgain.unknown'), DATA_FACE_LANGUAGE);
719
+ }
720
+ }
721
+ async function decidePlanReviewFor(taskId, decision, permissionModeAfter, opts, card) {
592
722
  if (typeof taskId === 'string' && planReviewOpsOf(taskId).some((op) => op.phase === 'posting')) {
593
723
  hostLog('error', `planReviewWire: decidePlanReview(${decision}) for task ${taskId} REFUSED — a decision for this task is already in flight (duplicate delivery latched)`);
594
724
  return;
@@ -619,7 +749,9 @@ async function decidePlanReviewFor(taskId, decision, permissionModeAfter, opts,
619
749
  const dispatchNo = ++decideDispatchSeq;
620
750
  hostLog('debug', `planReviewWire: decidePlanReview dispatch #${dispatchNo} task=${taskId} decision=${decision} mode=${permissionModeAfter ?? '-'}`);
621
751
  try {
622
- await decidePlanReviewInner(taskId, decision, permissionModeAfter, dispatchNo, releaseLatch, settle, opts, gateBefore);
752
+ const wireDecision = decision === 'ask_again' ? 'reject' : decision;
753
+ const wireOpts = decision === 'ask_again' ? { ...opts, reason: askAgainReasonOf(card, opts) } : opts;
754
+ await decidePlanReviewInner(taskId, wireDecision, permissionModeAfter, dispatchNo, releaseLatch, settle, wireOpts, card, decision);
623
755
  }
624
756
  finally {
625
757
  releaseLatch();
@@ -640,8 +772,11 @@ function planReviewClientsFor(resolved) {
640
772
  repull: () => resolved.make({ timeoutMs: 15_000 }),
641
773
  };
642
774
  }
643
- async function decidePlanReviewInner(taskId, decision, permissionModeAfter, dispatchNo, releaseLatch, settle, opts, gateBefore) {
775
+ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, dispatchNo, releaseLatch, settle, opts, card, semanticDecision) {
644
776
  let outcome;
777
+ let decisionReason;
778
+ const gateBefore = card?.checkpointId;
779
+ const approvalWithheld = decision !== 'approve' ? undefined : card === undefined ? 'card_missing' : card.body.kind !== 'shown' || card.checkpointId === undefined ? 'card_withholds' : undefined;
645
780
  let effect = 'unconfirmed';
646
781
  let stillParkedCause;
647
782
  let statusSeen;
@@ -653,6 +788,12 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
653
788
  effect = 'not_sent';
654
789
  outcome = `The plan_review ${decision} could NOT be sent: the post-approval permission mode given with it is not valid (it must be "default" or "acceptEdits"). Tell the user plainly that the decision did not go through; the plan is still waiting.`;
655
790
  }
791
+ else if (approvalWithheld !== undefined) {
792
+ hostLog('debug', `planReviewWire: not-sent decision=${decision} reason=${approvalWithheld}`);
793
+ effect = 'not_sent';
794
+ decisionReason = approvalWithheld;
795
+ outcome = renderMessage(msg('approval.planReview.decision.notSent', { decision }), DATA_FACE_LANGUAGE);
796
+ }
656
797
  else if (resolved.kind === 'no-target') {
657
798
  hostLog('error', `planReviewWire: ${decision} NOT sent — engineWireTarget() unavailable`);
658
799
  effect = 'not_sent';
@@ -672,11 +813,24 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
672
813
  }
673
814
  else {
674
815
  const client = clients.decide;
816
+ const checkpointId = gateBefore !== undefined && !engineRefusedGateIds.has(gateBefore) ? gateBefore : undefined;
817
+ const repullRow = async () => {
818
+ const probe = clients.repull();
819
+ if (!probe)
820
+ return undefined;
821
+ if (wire !== undefined) {
822
+ const got = await settleWithinDeadline(PLAN_REVIEW_INJECTED_REPULL_TIMEOUT_MS, (signal) => probe.runs.get(taskId, { signal }));
823
+ if (got.kind === 'error')
824
+ throw new Error(got.detail);
825
+ if (got.kind === 'deadline')
826
+ throw new Error(`post-decide status re-pull passed its ${PLAN_REVIEW_INJECTED_REPULL_TIMEOUT_MS} ms deadline`);
827
+ return got.value;
828
+ }
829
+ return (await probe.runs.get(taskId));
830
+ };
675
831
  try {
676
832
  const mode = isPlanReviewModeAfter(permissionModeAfter) ? permissionModeAfter : undefined;
677
833
  const reason = denyReasonForWire(opts?.reason, `plan-review ${taskId}`);
678
- const capsBase = resolved.kind === 'target' ? resolved.target.baseUrl : resolved.capsBaseUrl;
679
- const checkpointId = gateBefore !== undefined && engineCapTrue(capsBase, 'planReviewCheckpointId') ? gateBefore : undefined;
680
834
  const reqBody = { decision };
681
835
  if (reason !== undefined)
682
836
  reqBody.reason = reason;
@@ -707,20 +861,8 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
707
861
  let gateAfter;
708
862
  let gateKindAfter;
709
863
  try {
710
- const probe = clients.repull();
711
- if (probe) {
712
- let post;
713
- if (wire !== undefined) {
714
- const got = await settleWithinDeadline(PLAN_REVIEW_INJECTED_REPULL_TIMEOUT_MS, (signal) => probe.runs.get(taskId, { signal }));
715
- if (got.kind === 'error')
716
- throw new Error(got.detail);
717
- if (got.kind === 'deadline')
718
- throw new Error(`post-decide status re-pull passed its ${PLAN_REVIEW_INJECTED_REPULL_TIMEOUT_MS} ms deadline`);
719
- post = got.value;
720
- }
721
- else {
722
- post = (await probe.runs.get(taskId));
723
- }
864
+ const post = await repullRow();
865
+ if (post !== undefined) {
724
866
  postStatus = bodyWordOf(post, 'status');
725
867
  gateAfter = pausedCheckpointIdOf(post?.result);
726
868
  gateKindAfter = pausedGateKindOf(post?.result);
@@ -793,6 +935,36 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
793
935
  `This ${decision} was not applied: the engine no longer has a pending plan review for this task — an earlier decision most likely already went through (or the review expired). ` +
794
936
  'This is NOT a rejection of the plan and NOT a failure of the task; the answer just given simply had nothing left to act on.';
795
937
  }
938
+ else if (status === 409 && refusedCode === APPROVAL_STALE) {
939
+ let untrusted = false;
940
+ if (checkpointId !== undefined) {
941
+ const bag = readThrownExtra(e);
942
+ const noCurrentPending = bag.state === 'object' &&
943
+ (() => {
944
+ try {
945
+ return Object.getOwnPropertyDescriptor(bag.value, 'currentPending') === undefined;
946
+ }
947
+ catch {
948
+ return false;
949
+ }
950
+ })();
951
+ if (noCurrentPending) {
952
+ try {
953
+ const row = await repullRow();
954
+ untrusted = row !== undefined && pausedCheckpointIdOf(row?.result) === checkpointId;
955
+ }
956
+ catch {
957
+ untrusted = false;
958
+ }
959
+ }
960
+ }
961
+ if (untrusted && checkpointId !== undefined)
962
+ noteEngineRefusedGate(checkpointId);
963
+ hostLog('debug', `planReviewWire: approval-stale decision=${decision} reason=${untrusted ? 'gate_untrusted' : 'gate_moved'}`);
964
+ effect = 'not_applied';
965
+ decisionReason = untrusted ? 'gate_untrusted' : 'gate_moved';
966
+ outcome = renderMessage(msg('approval.planReview.decision.refused', { decision }), DATA_FACE_LANGUAGE);
967
+ }
796
968
  else if (checkpointUnsupportedVersionFromError(e) !== null) {
797
969
  const cu = checkpointUnsupportedVersionFromError(e);
798
970
  hostLog('debug', `planReviewWire: ${decision} → 409 checkpoint.unsupported_version (the server cannot continue this parked review in its stored form; still pending)`);
@@ -834,12 +1006,13 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
834
1006
  }
835
1007
  }
836
1008
  }
1009
+ outcome = `${outcome}${planReviewDecisionReasonSegment(decisionReason, card)}`;
837
1010
  const tail = planReviewOutcomeTail(taskId, decision, effect, stillParkedCause);
838
1011
  const prompt = `<plan-review-outcome>\n${outcome}\n${tail}\n</plan-review-outcome>`;
839
1012
  const onOutcome = opts?.onOutcome;
840
1013
  settle(effect === 'not_sent' || effect === 'still_parked');
841
1014
  try {
842
- const delivered = enqueuePlanReviewOutcome(prompt, { taskId, dispatchNo, decision, effect, ...(effect === 'still_parked' && stillParkedCause !== undefined ? { cause: stillParkedCause } : {}) });
1015
+ const delivered = enqueuePlanReviewOutcome(prompt, { taskId, dispatchNo, decision: semanticDecision, effect, ...(effect === 'still_parked' && stillParkedCause !== undefined ? { cause: stillParkedCause } : {}) });
843
1016
  if (!delivered) {
844
1017
  if (onOutcome !== undefined) {
845
1018
  hostLog('debug', `planReviewWire: outcome queue port not installed for task ${taskId} — outcome handed to the host's onOutcome callback only`);
@@ -857,7 +1030,7 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
857
1030
  onOutcome({
858
1031
  taskId,
859
1032
  dispatchNo,
860
- decision,
1033
+ decision: semanticDecision,
861
1034
  effect,
862
1035
  ...(effect === 'still_parked' && stillParkedCause !== undefined ? { cause: stillParkedCause } : {}),
863
1036
  detail: outcome,
@@ -907,8 +1080,8 @@ function joinReopenFlight(flight, taskId, receiptMs) {
907
1080
  return refusedReopen('not_presented');
908
1081
  });
909
1082
  }
910
- function mintPlanReviewReopen(taskId, opts, sessionKey, receiptMs) {
911
- const flightKey = `${sessionKey}\u0000${taskId}`;
1083
+ function mintPlanReviewReopen(taskId, opts, sessionKey, receiptMs, taken) {
1084
+ const flightKey = `${sessionKey}\u0000${taskId}\u0000${taken.facts.checkpointId ?? ''}`;
912
1085
  const inFlight = reopenFlights.get(flightKey);
913
1086
  if (inFlight !== undefined)
914
1087
  return joinReopenFlight(inFlight, taskId, receiptMs);
@@ -926,7 +1099,7 @@ function mintPlanReviewReopen(taskId, opts, sessionKey, receiptMs) {
926
1099
  };
927
1100
  let verdict;
928
1101
  try {
929
- verdict = mintPlanReviewReopenCard(taskId, opts, sessionKey, receiptMs);
1102
+ verdict = mintPlanReviewReopenCard(taskId, opts, sessionKey, receiptMs, taken);
930
1103
  }
931
1104
  catch (e) {
932
1105
  settle(false);
@@ -940,7 +1113,12 @@ function mintPlanReviewReopen(taskId, opts, sessionKey, receiptMs) {
940
1113
  throw e;
941
1114
  });
942
1115
  }
943
- function mintPlanReviewReopenCard(taskId, opts, sessionKey, receiptMs) {
1116
+ function mintPlanReviewReopenCard(taskId, opts, sessionKey, receiptMs, taken) {
1117
+ if (!planReviewFactsCurrent(taken)) {
1118
+ hostLog('debug', `planReviewWire: reopen-superseded task=${taskId}`);
1119
+ return Promise.resolve(refusedReopen('not_presented'));
1120
+ }
1121
+ const facts = taken.facts;
944
1122
  const markBefore = dismissalEntryFor(sessionKey, taskId);
945
1123
  const settleOnReceipt = (receipt, firstSight) => receipt.then((presented) => {
946
1124
  if (!presented)
@@ -952,58 +1130,72 @@ function mintPlanReviewReopenCard(taskId, opts, sessionKey, receiptMs) {
952
1130
  const armedKey = planReviewArmedKeyFor(sessionKey, taskId);
953
1131
  const firstSight = !wasGateArmedFor(sessionKey, armedKey);
954
1132
  const mintFresh = opts?.mintFreshQuestionId !== false;
955
- if (!mintFresh && hasLocalQuestionResponder(canonicalId)) {
1133
+ const armed = armedPlanReviews.get(taskId);
1134
+ if (!mintFresh &&
1135
+ armed !== undefined &&
1136
+ hasLocalQuestionResponder(armed.questionId) &&
1137
+ armed.checkpointId !== undefined &&
1138
+ armed.checkpointId === facts.checkpointId &&
1139
+ samePlanReviewCardFacts(armed.facts, facts)) {
956
1140
  hostLog('debug', `planReviewWire: reopen for task ${taskId} reusing the canonical responder (path=reopen-canonical)`);
957
1141
  retireActiveReopen(sessionKey, canonicalId);
958
- const receipt = waitForGateArmedFor(sessionKey, [canonicalId], receiptMs);
959
- publishQuestionFrameFor(sessionKey, planReviewQuestionFrame(canonicalId, ARM_QUESTION, offerModeAfterByTask.get(taskId) === true));
1142
+ const receipt = waitForGateArmedFor(sessionKey, [armed.questionId], receiptMs);
1143
+ publishQuestionFrameFor(sessionKey, planReviewQuestionFrame(armed.questionId, ARM_QUESTION, armed.shape, armed.facts));
960
1144
  return settleOnReceipt(receipt, firstSight);
961
1145
  }
962
1146
  retireActiveReopen(sessionKey, canonicalId);
963
- if (unregisterLocalQuestionResponder(canonicalId)) {
964
- hostLog('debug', `planReviewWire: reopen retired the canonical responder for task ${taskId} (single-active discipline)`);
1147
+ const armedId = armed?.questionId ?? canonicalId;
1148
+ if (unregisterLocalQuestionResponder(armedId)) {
1149
+ hostLog('debug', `planReviewWire: reopen-retire-armed task=${taskId} card=${armedId}`);
965
1150
  }
966
- publishQuestionFrameFor(sessionKey, { type: 'question_complete', questionId: canonicalId });
967
- const questionId = `${canonicalId}${REOPEN_ID_TAIL}${reopenIdSuffix()}`;
968
- hostLog('debug', `planReviewWire: reopen for task ${taskId} minting a fresh card ${questionId} (path=reopen-fresh)`);
1151
+ publishQuestionFrameFor(sessionKey, { type: 'question_complete', questionId: armedId });
1152
+ const questionId = freshPlanReviewQuestionId(taskId, reopenIdSuffix());
1153
+ hostLog('debug', `planReviewWire: path=reopen-fresh task=${taskId} card=${questionId}`);
969
1154
  const delivery = opts?.wire !== undefined || opts?.onOutcome !== undefined
970
1155
  ? { ...(opts.wire !== undefined ? { wire: opts.wire } : {}), ...(opts.onOutcome !== undefined ? { onOutcome: opts.onOutcome } : {}) }
971
1156
  : undefined;
972
1157
  const deliver = opts?.deliverDecision ??
973
- ((tid, decision, reason) => {
1158
+ ((tid, decision, reason, card) => {
974
1159
  hostLog('debug', `planReviewWire: reopen responder delivering ${decision} for task ${tid} (path=reopen-default)`);
975
1160
  const withReason = reason === undefined ? delivery : { ...delivery, reason };
976
- void (withReason === undefined ? decidePlanReview(tid, decision) : decidePlanReview(tid, decision, undefined, withReason));
1161
+ void decidePlanReviewFor(tid, decision, undefined, withReason, card);
977
1162
  });
978
1163
  const activeKey = `${sessionKey}\u0000${canonicalId}`;
979
- const unregister = registerLocalQuestionResponder(questionId, async (_id, answer) => {
1164
+ let unregister = () => { };
1165
+ const responder = async (_id, answer) => {
1166
+ const choice = planReviewAnswerChoiceOf(questionId, answer);
1167
+ if (choice === 'inadmissible') {
1168
+ if (activeReopenResponders.get(activeKey)?.questionId === questionId && !hasLocalQuestionResponder(questionId)) {
1169
+ unregister = registerLocalQuestionResponder(questionId, responder);
1170
+ }
1171
+ hostLog('debug', `planReviewWire: answer-inadmissible card=${questionId}`);
1172
+ return { ok: false, inadmissible: true };
1173
+ }
980
1174
  unregister();
981
1175
  if (activeReopenResponders.get(activeKey)?.questionId === questionId) {
982
1176
  activeReopenResponders.delete(activeKey);
983
1177
  }
984
- const choice = planReviewAnswerChoiceOf(questionId, answer);
985
1178
  if (choice === 'dismissed') {
986
1179
  notePlanReviewUserDismissalFor(sessionKey, taskId, opts?.readGateInstanceKeys, questionId);
987
1180
  return { ok: true };
988
1181
  }
989
1182
  const decided = choice.decision;
990
- const cardReason = choice.decision === 'reject' ? choice.reason : undefined;
1183
+ const cardReason = choice.decision === 'approve' ? undefined : choice.reason;
991
1184
  notePlanReviewDecisiveAnswerFor(sessionKey, taskId);
992
1185
  notePlanReviewAnsweredFor(sessionKey, questionId);
993
1186
  try {
994
- if (cardReason === undefined)
995
- await deliver(taskId, decided);
996
- else
997
- await deliver(taskId, decided, cardReason);
1187
+ await deliver(taskId, decided, cardReason, facts);
998
1188
  }
999
1189
  catch (e) {
1000
1190
  hostLog('error', `planReviewWire: reopen decision delivery threw for task ${taskId}: ${String(e)}`);
1001
1191
  }
1002
1192
  return { ok: true };
1003
- });
1004
- activeReopenResponders.set(activeKey, { questionId, unregister });
1193
+ };
1194
+ const shape = cardShapeOf(facts, false);
1195
+ unregister = registerLocalQuestionResponder(questionId, responder);
1196
+ activeReopenResponders.set(activeKey, { questionId, unregister: () => unregister(), shape });
1005
1197
  const receipt = waitForGateArmedFor(sessionKey, [questionId], receiptMs);
1006
- publishQuestionFrameFor(sessionKey, planReviewQuestionFrame(questionId, REOPEN_QUESTION, false, true));
1198
+ publishQuestionFrameFor(sessionKey, planReviewQuestionFrame(questionId, REOPEN_QUESTION, shape, facts));
1007
1199
  return settleOnReceipt(receipt, firstSight);
1008
1200
  }
1009
1201
  function refusedReopen(cause) {
@@ -1015,7 +1207,7 @@ function noPresentationSurfaceVerdict(taskId, sessionKey) {
1015
1207
  `${JSON.stringify(sessionKey)} (onQuestionFrameFor), so the card has nowhere to render`);
1016
1208
  return verdict;
1017
1209
  }
1018
- async function reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, mark, recheckReadStart) {
1210
+ async function reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, mark, recheckReadStart, taken) {
1019
1211
  const refusedByUser = { reopened: false, dismissedByUser: true };
1020
1212
  try {
1021
1213
  let fresh = false;
@@ -1061,7 +1253,7 @@ async function reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, ma
1061
1253
  putDismissalEntry(sessionKey, taskId, reopening);
1062
1254
  let verdict = { reopened: false };
1063
1255
  try {
1064
- verdict = await mintPlanReviewReopen(taskId, opts, sessionKey, receiptMs);
1256
+ verdict = await mintPlanReviewReopen(taskId, opts, sessionKey, receiptMs, taken);
1065
1257
  }
1066
1258
  finally {
1067
1259
  const presented = verdict.reopened === true;
@@ -1140,15 +1332,110 @@ export function reopenPlanReviewCard(taskId, opts) {
1140
1332
  '此处拒绝而不是照发 —— 照发会把这条会话的决断发到另一个槽上。');
1141
1333
  return Promise.resolve(refusedReopen('no_delivery_path'));
1142
1334
  }
1335
+ const taking = takeReopenPlanReviewFacts(taskId, opts, sessionKey);
1336
+ if (taking.kind === 'now')
1337
+ return reopenWithPlanReviewFacts(taskId, opts, sessionKey, receiptMs, recheckReadStart, taking.taken);
1338
+ return taking.pending.then((taken) => {
1339
+ if (taken === undefined) {
1340
+ hostLog('debug', `planReviewWire: reopen-superseded task=${taskId}`);
1341
+ return refusedReopen('not_presented');
1342
+ }
1343
+ if (decisionInFlightOf({ gate: 'plan_review', taskId }, recheckReadStart)) {
1344
+ hostLog('debug', `planReviewWire: reopen-refused-in-flight-after-read task=${taskId}`);
1345
+ return { reopened: false, _sema_decisionInFlight: true };
1346
+ }
1347
+ return reopenWithPlanReviewFacts(taskId, opts, sessionKey, receiptMs, recheckReadStart, taken);
1348
+ }, () => refusedReopen('internal_error'));
1349
+ }
1350
+ catch {
1351
+ return Promise.resolve(refusedReopen('internal_error'));
1352
+ }
1353
+ }
1354
+ function takeReopenPlanReviewFacts(taskId, opts, sessionKey) {
1355
+ const raw = opts.checkpointId;
1356
+ const checkpointId = typeof raw === 'string' && raw.length > 0 ? raw : undefined;
1357
+ const wire = opts.mintFreshQuestionId === false ? undefined : opts.wire;
1358
+ return takePlanReviewFacts(sessionKey, taskId, checkpointId, () => planReviewLedgerSourceFor(wire, sessionKey));
1359
+ }
1360
+ function reopenWithPlanReviewFacts(taskId, opts, sessionKey, receiptMs, recheckReadStart, taken) {
1361
+ try {
1143
1362
  const trigger = opts.trigger === undefined ? undefined : opts.trigger === 'user' ? 'user' : 'automatic';
1144
1363
  if (trigger === 'automatic') {
1145
1364
  const mark = planReviewUserDismissalMarkFor(sessionKey, taskId);
1146
1365
  if (mark !== undefined)
1147
- return reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, mark, recheckReadStart);
1366
+ return reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, mark, recheckReadStart, taken);
1148
1367
  }
1149
- return mintPlanReviewReopen(taskId, opts, sessionKey, receiptMs);
1368
+ return mintPlanReviewReopen(taskId, opts, sessionKey, receiptMs, taken);
1150
1369
  }
1151
1370
  catch {
1152
1371
  return Promise.resolve(refusedReopen('internal_error'));
1153
1372
  }
1154
1373
  }
1374
+ const PLAN_REVIEW_LEDGER_CLIENT_TIMEOUT_MS = 15_000;
1375
+ function usableLedgerClient(client) {
1376
+ return clientHasVerbs(client, [['runs', 'get'], ['runs', 'events']]);
1377
+ }
1378
+ function planReviewLedgerSourceFor(wire, sessionKey) {
1379
+ try {
1380
+ const resolved = engineWireFor(wire, sessionKey, usableLedgerClient);
1381
+ let client;
1382
+ if (resolved.kind === 'injected')
1383
+ client = resolved.client;
1384
+ else if (resolved.kind === 'target')
1385
+ client = resolved.make({ timeoutMs: PLAN_REVIEW_LEDGER_CLIENT_TIMEOUT_MS });
1386
+ else
1387
+ client = null;
1388
+ if (client === null)
1389
+ return undefined;
1390
+ const c = client;
1391
+ return {
1392
+ runRow: (taskId, signal) => c.runs.get(taskId, { signal }),
1393
+ frames: (taskId, signal) => c.runs.events(taskId, { signal }),
1394
+ };
1395
+ }
1396
+ catch {
1397
+ return undefined;
1398
+ }
1399
+ }
1400
+ function abortSignalOf(v) {
1401
+ if (typeof v !== 'object' || v === null)
1402
+ return undefined;
1403
+ const s = v;
1404
+ return typeof s.aborted === 'boolean' && typeof s.addEventListener === 'function' && typeof s.removeEventListener === 'function' ? v : undefined;
1405
+ }
1406
+ export async function readPlanReviewCardFacts(taskId, opts) {
1407
+ if (typeof taskId !== 'string' || taskId.length === 0)
1408
+ return mintPlanReviewCardFacts('', undefined, unknownBody('gate_unidentified'));
1409
+ let checkpointId;
1410
+ try {
1411
+ checkpointId = readOwnDataNonEmptyString(opts, 'checkpointId');
1412
+ const wire = readOwnDataValue(opts, 'wire');
1413
+ const signal = abortSignalOf(readOwnDataValue(opts, 'signal'));
1414
+ if (signal?.aborted === true)
1415
+ return mintPlanReviewCardFacts(taskId, checkpointId, unknownBody('unreadable'));
1416
+ const taking = takePlanReviewFacts(DEFAULT_SESSION_KEY, taskId, checkpointId, () => planReviewLedgerSourceFor(wire, DEFAULT_SESSION_KEY), signal);
1417
+ if (taking.kind === 'now')
1418
+ return taking.taken.facts;
1419
+ let taken;
1420
+ if (signal === undefined)
1421
+ taken = await taking.pending;
1422
+ else {
1423
+ let onAbort;
1424
+ const aborted = new Promise((resolve) => {
1425
+ onAbort = () => resolve(undefined);
1426
+ signal.addEventListener('abort', onAbort, { once: true });
1427
+ });
1428
+ try {
1429
+ taken = await Promise.race([taking.pending, aborted]);
1430
+ }
1431
+ finally {
1432
+ if (onAbort !== undefined)
1433
+ signal.removeEventListener('abort', onAbort);
1434
+ }
1435
+ }
1436
+ return taken?.facts ?? mintPlanReviewCardFacts(taskId, checkpointId, unknownBody('unreadable'));
1437
+ }
1438
+ catch {
1439
+ return mintPlanReviewCardFacts(taskId, checkpointId, unknownBody('unreadable'));
1440
+ }
1441
+ }