aimakeall-mcp 0.14.2 → 0.14.4

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.
@@ -4,6 +4,10 @@ import path from "node:path";
4
4
  import { savePayloadHandle } from "./media-store.mjs";
5
5
  import { canonicalYoutubeWatchUrl } from "./payload-guard.mjs";
6
6
  import { inspectReviewLocalFile, readReviewHandle, reviewPublicSources, validateReviewSources } from "./review-sources.mjs";
7
+ import { createReviewStoryJobClient, REVIEW_STORY_TRANSPORT } from "./review-story-job-request.mjs";
8
+ import { createReviewNativeJobClient, REVIEW_NATIVE_TRANSPORT, unwrapReviewNativeJobResult } from "./review-native-job-request.mjs";
9
+ import { DEFAULT_API_BASE } from "./config.mjs";
10
+ import { matchesApiClientAuthority } from "./api-client.mjs";
7
11
 
8
12
  const KIND = "aimakeall-review-full-analysis";
9
13
  const MAX_CHUNK_BYTES = 10 * 1024 * 1024;
@@ -68,14 +72,64 @@ function lockAnalysis(config, analysisId) {
68
72
  function receipt(value, stage, chunkId = "") {
69
73
  return { stage, chunkId, ...Object.fromEntries(RECEIPT_KEYS.filter(key => value?.[key] !== undefined).map(key => [key, value[key]])) };
70
74
  }
75
+ function upsertStoryReceipt(state, value, operationId) {
76
+ upsertStageReceipt(state, value, operationId, "synthesizing");
77
+ }
78
+ function upsertStageReceipt(state, value, operationId, stage, chunkId = "") {
79
+ const item = { ...receipt(value, stage, chunkId), clientOperationId: operationId };
80
+ const index = state.receipts.findIndex(row => row.clientOperationId === operationId);
81
+ if (index < 0) state.receipts.push(item);
82
+ else {
83
+ const previous = state.receipts[index];
84
+ const unique = field => [...new Map([...(previous[field] || []), ...(item[field] || [])]
85
+ .map(event => [event.accountCostEventId || event.eventId || event.id || JSON.stringify(event), event])).values()];
86
+ state.receipts[index] = { ...previous, ...item, usage: item.usage || previous.usage,
87
+ operationId: item.operationId || previous.operationId, costUsd: Math.max(Number(previous.costUsd) || 0, Number(item.costUsd) || 0),
88
+ accountCostEvents: unique("accountCostEvents"), acceptedTaskCosts: unique("acceptedTaskCosts") };
89
+ }
90
+ }
91
+ function authorityBinding(config) {
92
+ const scope = new URL(config.apiBase || DEFAULT_API_BASE).href.replace(/\/$/u, "");
93
+ return { apiScope: scope, authorityFingerprint: createHash("sha256").update(JSON.stringify([scope, config.pat || ""])).digest("hex") };
94
+ }
95
+ function assertCheckpointAuthority(config, state) {
96
+ const current = authorityBinding(config);
97
+ if (!state.authorityBinding && (state.inFlight || state.pendingStageResult || state.receipts?.length || Object.keys(state.analyses || {}).length)) {
98
+ throw fail("REVIEW_CHECKPOINT_AUTHORITY_MISSING", "이전 분석 기록에 서버·계정 연결 증명이 없습니다. 기존 파일의 로컬 확인만 가능하며 현재 PAT로 결과를 적용하거나 유료 요청을 다시 보내지 않습니다.");
99
+ }
100
+ if (state.authorityBinding && (state.authorityBinding.apiScope !== current.apiScope || state.authorityBinding.authorityFingerprint !== current.authorityFingerprint)) {
101
+ throw fail("REVIEW_CHECKPOINT_AUTHORITY_CHANGED", "분석을 시작한 서버와 PAT로 기존 작업을 확인해 주세요. 계정이 변경된 상태에서는 업로드·유료 요청·결과 조회를 하지 않습니다.");
102
+ }
103
+ state.authorityBinding ||= current;
104
+ }
105
+ function storyClient(config, api) {
106
+ return createReviewStoryJobClient({ apiScope: config.apiBase || DEFAULT_API_BASE,
107
+ getAuthority: () => transportAuthority(config, api),
108
+ request: (route, options) => api.request(route, { ...options, ...(options.body === undefined ? {} : { body: JSON.parse(options.body) }) }),
109
+ });
110
+ }
111
+ function nativeClient(config, api) {
112
+ return createReviewNativeJobClient({ apiScope: config.apiBase || DEFAULT_API_BASE,
113
+ getAuthority: () => transportAuthority(config, api),
114
+ request: (route, options) => api.request(route, { ...options, ...(options.body === undefined ? {} : { body: JSON.parse(options.body) }) }),
115
+ });
116
+ }
117
+ function transportAuthority(config, api) {
118
+ if (matchesApiClientAuthority(api, { apiBase: config.apiBase || DEFAULT_API_BASE, pat: config.pat }) === false) return null;
119
+ return config.pat ? JSON.stringify([config.apiBase || DEFAULT_API_BASE, config.pat]) : api;
120
+ }
121
+ function mediaDescriptor(media) {
122
+ const { videoBase64, ...chunk } = media.chunk;
123
+ return { source: media.source, chunk, ...(media.workTitle !== undefined ? { workTitle: media.workTitle } : {}) };
124
+ }
71
125
  function safeStageResult(value) {
72
126
  if (Array.isArray(value)) return value.map(safeStageResult);
73
127
  if (!value || typeof value !== "object") return value;
74
- return Object.fromEntries(Object.entries(value).filter(([key]) => !/base64|dataUrl|localPath|sourceHandle|apiKey|token/i.test(key)).map(([key, item]) => [key, safeStageResult(item)]));
128
+ return Object.fromEntries(Object.entries(value).filter(([key]) => !/base64|dataUrl|localPath|sourceHandle|apiKey|^token$|accessToken|refreshToken|authorization|cookie/i.test(key)).map(([key, item]) => [key, safeStageResult(item)]));
75
129
  }
76
130
  function billing(state) {
77
131
  const costs = state.receipts || [];
78
- const unique = (field) => [...new Map(costs.flatMap(item => item[field] || []).map(item => [item.eventId || item.id || JSON.stringify(item), item])).values()];
132
+ const unique = (field) => [...new Map(costs.flatMap(item => item[field] || []).map(item => [item.accountCostEventId || item.eventId || item.id || JSON.stringify(item), item])).values()];
79
133
  return { accountCostEvents: unique("accountCostEvents"), acceptedTaskCosts: unique("acceptedTaskCosts"),
80
134
  costUsd: costs.reduce((sum, item) => sum + (Number(item.costUsd) || 0), 0), stageReceipts: costs };
81
135
  }
@@ -94,12 +148,58 @@ function summary(state, resumePath) {
94
148
  analyzedTimelineSec: analyzedMs / 1000, totalTimelineSec: state.sources.reduce((sum, item) => sum + item.durationSec, 0) },
95
149
  sources: reviewPublicSources(state.sources), ...billing(state),
96
150
  warnings: state.warnings || [], error: state.error || undefined,
151
+ ...(state.inFlight?.transport === REVIEW_STORY_TRANSPORT ? { storyJob: { requestId: state.inFlight.operationId,
152
+ jobId: state.inFlight.jobId || null, apiScope: state.inFlight.apiScope, recovery: "read-only-known-job", paidReplayAllowed: false } } : {}),
153
+ ...(state.inFlight?.transport === REVIEW_NATIVE_TRANSPORT ? { nativeJob: { stage: state.inFlight.stage,
154
+ requestId: state.inFlight.operationId, jobId: state.inFlight.jobId || null, status: state.inFlight.jobStatus || "unknown",
155
+ apiScope: state.inFlight.apiScope, mediaAttemptId: state.inFlight.mediaAttempt?.attemptId || null,
156
+ fileState: state.inFlight.mediaReference ? "ready" : state.inFlight.mediaAttempt ? "reconciliation-required" : "not-uploaded",
157
+ recovery: state.inFlight.jobId ? "read-only-known-job" : state.inFlight.submissionAttempted ? "submission-unknown-no-replay" : "reconcile-upload",
158
+ paidReplayAllowed: false, automaticUploadRetryAllowed: false } } : {}),
159
+ mediaFiles: { ready: Object.keys(state.mediaReferences || {}).length, retainedForVerification: true },
160
+ reviewRequired: state.reviewRequired === true,
97
161
  failedStageResultPath: state.failedStageResultPath || undefined,
98
162
  requiresHumanReview: true, next: state.stage === "complete" ? "plan_review_video({reviewSourcePath,...})" : state.stage === "blocked" ? "실패 원인·이미 접수된 비용을 확인하세요. 자동 재시도하지 않습니다." : "analyze_review_sources({resumePath,allowCloudVideoUpload:true})로 다음 1개 단계를 계속하세요.",
99
163
  };
100
164
  }
101
165
  export function fullReviewStatus(config, resumePath) { const loaded = loadFullReviewAnalysis(config, resumePath); return summary(loaded.state, loaded.resumePath); }
102
166
 
167
+ export async function refreshFullReviewStatus(config, api, resumePath) {
168
+ const loaded = loadFullReviewAnalysis(config, resumePath);
169
+ const release = lockAnalysis(config, loaded.state.analysisId);
170
+ try {
171
+ let { state, resumePath: latestPath } = loadFullReviewAnalysis(config, resumePath);
172
+ assertCheckpointAuthority(config, state);
173
+ const operation = state.inFlight;
174
+ if (![REVIEW_STORY_TRANSPORT, REVIEW_NATIVE_TRANSPORT].includes(operation?.transport)
175
+ || operation.transport === REVIEW_STORY_TRANSPORT && !operation.jobId) return summary(state, latestPath);
176
+ try {
177
+ const job = operation.transport === REVIEW_STORY_TRANSPORT
178
+ ? await storyClient(config, api).read(operation.jobId, { requestId: operation.operationId, apiScope: operation.apiScope })
179
+ : await nativeClient(config, api).read(operation.stage, { ...operation, requestId: operation.operationId,
180
+ onCheckpoint: async metadata => {
181
+ assertCheckpointAuthority(config, state); Object.assign(operation, metadata);
182
+ if (operation.mediaReference) (state.mediaReferences ||= {})[operation.chunkId] = operation.mediaReference;
183
+ latestPath = snapshot(config, state);
184
+ } });
185
+ assertCheckpointAuthority(config, state);
186
+ operation.jobStatus = job.status;
187
+ if (job.status === "completed") {
188
+ if (!job.result || typeof job.result !== "object" || Array.isArray(job.result)) throw fail("REVIEW_STORY_RESULT_MISSING", "저장된 줄거리 결과가 없습니다.");
189
+ const result = operation.transport === REVIEW_STORY_TRANSPORT ? job.result
190
+ : unwrapReviewNativeJobResult(operation.stage, job.result, operation.mediaReference);
191
+ upsertStageReceipt(state, result, operation.operationId, operation.stage, operation.chunkId);
192
+ state.pendingStageResult = safeStageResult(result);
193
+ }
194
+ latestPath = snapshot(config, state);
195
+ } catch (error) {
196
+ state.error = { code: error.code || error.errorCode || "REVIEW_JOB_READ_FAILED", message: String(error.message || error) };
197
+ latestPath = snapshot(config, state);
198
+ }
199
+ return summary(state, latestPath);
200
+ } finally { release(); }
201
+ }
202
+
103
203
  // This limits only the editing candidate list, never the full story/transcript
104
204
  // evidence. All native analysis remains in the original immutable handle.
105
205
  export function selectReviewPlanningCatalog(handle, requestedIds) {
@@ -188,7 +288,8 @@ export async function runFullReviewAnalysis({ config, api, args, companionJson,
188
288
  const health = await companionJson(config, "/health", undefined, 15000);
189
289
  if (health.capabilities?.reviewSourceAnalysis !== true || health.capabilities?.reviewSourceChunks !== true || health.capabilities.reviewSourceChunkMaxDurationSec < 300 || health.capabilities.reviewSourceChunkMaxBytes < MAX_CHUNK_BYTES) throw fail("COMPANION_UPDATE_REQUIRED", "전체 영상·음성 분석 청크를 지원하는 로컬 처리기로 업데이트하세요. 대표 프레임으로 우회하지 않습니다.");
190
290
  const state = { kind: KIND, version: 1, analysisId: randomUUID(), revision: 0, stage: "analyzing", workTitle: args.workTitle || "",
191
- sources: [], chunks: [], analyses: {}, verifications: {}, receipts: [], warnings: ["전체 구간을 압축 영상+오디오로 분석하고 서사 주장을 실제 원본 구간으로 재검증합니다. 공개 권리와 최종 해석은 사람이 확인해야 합니다."], uploadConsent: true };
291
+ sources: [], chunks: [], analyses: {}, verifications: {}, receipts: [], mediaReferences: {}, authorityBinding: authorityBinding(config),
292
+ warnings: ["전체 구간을 압축 영상+오디오로 분석하고 서사 주장을 실제 원본 구간으로 재검증합니다. 공개 권리와 최종 해석은 사람이 확인해야 합니다."], uploadConsent: true };
192
293
  const fingerprints = args.sources.map(source => source.kind === "local" ? inspectReviewLocalFile(source.videoPath) : null);
193
294
  for (const [index, input] of args.sources.entries()) {
194
295
  const id = `review-${randomUUID().slice(0, 8)}`;
@@ -209,16 +310,27 @@ export async function runFullReviewAnalysis({ config, api, args, companionJson,
209
310
  let state, resumePath;
210
311
  try {
211
312
  ({ state, resumePath } = loadFullReviewAnalysis(config, args.resumePath));
212
- validateReviewSources(state.sources);
313
+ assertCheckpointAuthority(config, state);
213
314
  if (state.stage === "complete") return summary(state, resumePath);
315
+ if (state.reviewRequired) return { ...summary(state, resumePath), requiresExplicitRetryApproval: false };
214
316
  if (state.inFlight || state.stage === "blocked") {
317
+ if ([REVIEW_STORY_TRANSPORT, REVIEW_NATIVE_TRANSPORT].includes(state.inFlight?.transport) || state.pendingStageResult) {
318
+ if (!state.inFlight || !["analyzing", "synthesizing", "verifying"].includes(state.inFlight.stage)
319
+ || state.inFlight.transport === REVIEW_STORY_TRANSPORT && state.inFlight.stage !== "synthesizing") throw fail("REVIEW_JOB_STATE_INVALID", "저장된 작업 단계가 다릅니다.");
320
+ state.stage = state.inFlight.stage;
321
+ delete state.error;
322
+ } else {
215
323
  if (args.retryFailedStage !== true) return { ...summary(state, resumePath), requiresExplicitRetryApproval: true };
216
324
  state.stage = state.failedStage || state.inFlight?.stage || "analyzing";
217
325
  delete state.inFlight; delete state.error;
326
+ }
218
327
  resumePath = snapshot(config, state);
219
328
  }
220
329
  const refreshed = new Set();
221
330
  const getMedia = async (chunk, expectedSha256) => {
331
+ // A known server job / saved result does not need the local source. Only
332
+ // a genuinely new media transfer or explicit legacy recheck reads it.
333
+ validateReviewSources(state.sources);
222
334
  const index = state.sources.findIndex(source => source.id === chunk.sourceId);
223
335
  try { return await videoChunk(config, companionJson, state.sources[index], chunk, expectedSha256); }
224
336
  catch (error) {
@@ -232,41 +344,87 @@ export async function runFullReviewAnalysis({ config, api, args, companionJson,
232
344
  if (!onProgress) return;
233
345
  try { await onProgress(summary(state, resumePath)); } catch { /* Progress delivery cannot replay/abort an accepted paid operation. */ }
234
346
  };
235
- const charge = async (stage, chunkId, route, body) => {
236
- state.inFlight = { stage, chunkId, operationId: randomUUID() }; state.failedStage = stage;
347
+ const charge = async (stage, chunkId, body, options = {}) => {
348
+ assertCheckpointAuthority(config, state);
349
+ const existing = state.inFlight;
350
+ if (existing && (existing.stage !== stage || existing.chunkId !== chunkId)) throw fail("REVIEW_JOB_STATE_INVALID", "체크포인트의 작업 구간이 다릅니다.");
351
+ if (state.pendingStageResult) return state.pendingStageResult;
352
+ state.inFlight = existing || { stage, chunkId, operationId: randomUUID(),
353
+ ...(stage === "analyzing" ? { mediaDescriptor: mediaDescriptor(body), priorContext: body.priorContext || "" } : {}) }; state.failedStage = stage;
237
354
  resumePath = snapshot(config, state);
238
355
  await progress();
239
356
  let response;
240
- try { response = await api.request(route, { method: "POST", body: { ...body, costEventId: state.inFlight.operationId }, timeoutMs: 600000 }); }
241
- catch (error) { state.receipts.push(receipt(error, stage, chunkId)); throw error; }
242
- state.receipts.push(receipt(response, stage, chunkId));
357
+ const operationId = state.inFlight.operationId;
358
+ try {
359
+ response = stage === "synthesizing"
360
+ ? await storyClient(config, api).run(body, {
361
+ requestId: operationId, ...(existing ? { transport: existing.transport, jobId: existing.jobId, apiScope: existing.apiScope } : {}),
362
+ onSubmissionAttempt: async metadata => { Object.assign(state.inFlight, metadata); resumePath = snapshot(config, state); },
363
+ onJobAccepted: async metadata => { Object.assign(state.inFlight, metadata); resumePath = snapshot(config, state); },
364
+ })
365
+ : await nativeClient(config, api).run(stage, body, { ...state.inFlight, requestId: operationId,
366
+ // A resumed operation owns its exact reference and origin. Never
367
+ // repair a damaged/missing binding from the analysis-wide cache.
368
+ ...(!existing && options.mediaReference ? { mediaReference: options.mediaReference,
369
+ apiScope: state.authorityBinding.apiScope } : {}),
370
+ getLegacyMedia: options.getLegacyMedia,
371
+ onCheckpoint: async metadata => {
372
+ assertCheckpointAuthority(config, state); Object.assign(state.inFlight, metadata);
373
+ if (state.inFlight.mediaReference) (state.mediaReferences ||= {})[chunkId] = state.inFlight.mediaReference;
374
+ resumePath = snapshot(config, state);
375
+ },
376
+ });
377
+ }
378
+ catch (error) {
379
+ if (stage === "synthesizing") upsertStoryReceipt(state, error, operationId);
380
+ else upsertStageReceipt(state, error, operationId, stage, chunkId);
381
+ throw error;
382
+ }
383
+ assertCheckpointAuthority(config, state);
384
+ if (stage === "synthesizing") upsertStoryReceipt(state, response, operationId);
385
+ else upsertStageReceipt(state, response, operationId, stage, chunkId);
386
+ if (state.inFlight.transport) state.inFlight.jobStatus = "completed";
243
387
  state.pendingStageResult = safeStageResult(response);
388
+ // Commit received output before validation/application can fail or MCP exits.
389
+ resumePath = snapshot(config, state);
244
390
  return response;
245
391
  };
246
392
  for (let step = 0; step < (args.maxSteps ?? 1); step++) {
247
393
  if (state.stage === "analyzing") {
248
394
  const chunk = state.chunks.find(item => !state.analyses[item.chunkId]);
249
395
  if (!chunk) { state.stage = "synthesizing"; resumePath = snapshot(config, state); step--; continue; }
250
- const media = await getMedia(chunk);
251
- media.workTitle = state.workTitle;
252
- const previous = Object.values(state.analyses).filter(item => item.sourceId === chunk.sourceId && item.startMs < chunk.startMs).sort((a, b) => b.startMs - a.startMs)[0];
253
- if (previous) media.priorContext = JSON.stringify({ previousSummary: previous.summary, observedCharacters: previous.characters }).slice(0, 12000);
254
- const response = await charge("analyzing", chunk.chunkId, "/api/tracker/review/analyze-chunk", media);
396
+ let media;
397
+ if (state.inFlight?.transport === REVIEW_NATIVE_TRANSPORT || state.pendingStageResult) {
398
+ media = state.inFlight.mediaDescriptor;
399
+ if (!media?.chunk || media.chunk.chunkId !== chunk.chunkId) throw fail("REVIEW_MEDIA_CHECKPOINT_INVALID", "저장된 미디어 구간 정보가 없습니다. 다시 인코딩하거나 업로드하지 않습니다.");
400
+ } else {
401
+ media = await getMedia(chunk); media.workTitle = state.workTitle;
402
+ const previous = Object.values(state.analyses).filter(item => item.sourceId === chunk.sourceId && item.startMs < chunk.startMs).sort((a, b) => b.startMs - a.startMs)[0];
403
+ if (previous) media.priorContext = JSON.stringify({ previousSummary: previous.summary, observedCharacters: previous.characters }).slice(0, 12000);
404
+ }
405
+ const response = await charge("analyzing", chunk.chunkId, state.inFlight?.transport === REVIEW_NATIVE_TRANSPORT
406
+ ? state.inFlight.jobId ? null : { priorContext: state.inFlight.priorContext || "" } : media);
255
407
  state.analyses[chunk.chunkId] = checkedAnalysis(response, chunk, media);
256
408
  } else if (state.stage === "synthesizing") {
257
409
  const body = { sources: reviewPublicSources(state.sources), chunks: Object.values(state.analyses), workTitle: state.workTitle };
258
410
  textBudget(body);
259
- state.storyResult = await charge("synthesizing", "", "/api/tracker/review/synthesize-story", body);
411
+ state.storyResult = await charge("synthesizing", "", body);
260
412
  state.verifyChunkIds = verificationGroups(state);
261
413
  state.stage = "verifying";
262
414
  } else if (state.stage === "verifying") {
263
415
  const id = state.verifyChunkIds.find(chunkId => !state.verifications[chunkId]);
264
416
  if (!id) { state.stage = "finalizing"; step--; continue; }
265
417
  const chunk = state.chunks.find(item => item.chunkId === id), analyzed = state.analyses[id];
266
- const media = await getMedia(chunk, analyzed.sha256);
267
- const response = await charge("verifying", id, "/api/tracker/review/verify-evidence", { story: state.storyResult.story, chunks: Object.values(state.analyses), chunk: media });
268
- if (response.chunkId !== id || !Array.isArray(response.verdicts) || !response.verdicts.length || response.verdicts.some(item => item.status !== "supported")) throw fail("REVIEW_ORIGINAL_VERIFICATION_FAILED", "원본 재검증에서 모순되거나 근거가 부족한 주장이 발견되었습니다. 전체 이해 완료나 자동 기획으로 진행하지 않습니다. 사용자가 줄거리·근거를 검토해야 합니다.");
418
+ const response = await charge("verifying", id, state.inFlight?.jobId ? null
419
+ : { story: state.storyResult.story, chunks: Object.values(state.analyses), chunkId: id },
420
+ { mediaReference: state.mediaReferences?.[id], getLegacyMedia: async () => ({ ...await getMedia(chunk, analyzed.sha256), workTitle: state.workTitle }) });
421
+ if (response.chunkId !== id || !Array.isArray(response.verdicts) || !response.verdicts.length
422
+ || response.verdicts.some(item => !["supported", "contradicted", "insufficient"].includes(item.status))) throw fail("REVIEW_ORIGINAL_VERIFICATION_INVALID", "저장된 원본 재검증 결과가 불완전합니다. 다시 과금하지 않습니다.");
269
423
  state.verifications[id] = response;
424
+ if (response.verdicts.some(item => item.status !== "supported")) {
425
+ state.reviewRequired = true;
426
+ throw fail("REVIEW_ORIGINAL_VERIFICATION_FAILED", "원본 재검증에서 모순되거나 근거가 부족한 주장이 발견되었습니다. 판정과 비용은 저장했습니다. 자동 재검증·전체 이해 완료·자동 기획으로 진행하지 않으며 사용자의 검토가 필요합니다.");
427
+ }
270
428
  } else if (state.stage === "finalizing") {
271
429
  const completed = { kind: "aimakeall-review-sources", version: 1, analysisScope: "full", analysisId: state.analysisId,
272
430
  sources: state.sources, sourceCatalog: Object.values(state.analyses).flatMap(chunk => chunk.sceneCatalog), globalStory: state.storyResult.story,
@@ -290,13 +448,18 @@ export async function runFullReviewAnalysis({ config, api, args, companionJson,
290
448
  return summary(state, resumePath);
291
449
  } catch (error) {
292
450
  if (!state) throw error;
451
+ // Never attach another account's saved costs/results to the caller or write
452
+ // through its journal after PAT/server changes (including mid-request).
453
+ assertCheckpointAuthority(config, state);
293
454
  state.failedStage ||= state.stage; state.stage = "blocked";
294
455
  state.error = { code: error.errorCode || error.code || "REVIEW_FULL_ANALYSIS_FAILED", message: String(error.message || error) };
295
- if (state.pendingStageResult) {
456
+ if (state.pendingStageResult && state.inFlight?.transport !== REVIEW_STORY_TRANSPORT) {
296
457
  state.failedStageResultPath = savePayloadHandle(config.stateDir, { kind: "aimakeall-review-failed-stage", version: 1, analysisId: state.analysisId, stage: state.failedStage, result: state.pendingStageResult }, "review-failed-stage").filePath;
297
- delete state.pendingStageResult;
458
+ // Keep the received result in the journal too: a restart must apply it,
459
+ // never repeat the paid request (including legacy successful responses).
298
460
  }
299
461
  resumePath = snapshot(config, state);
300
- return { ...summary(state, resumePath), requiresExplicitRetryApproval: true };
462
+ return { ...summary(state, resumePath), requiresExplicitRetryApproval: !state.reviewRequired
463
+ && ![REVIEW_STORY_TRANSPORT, REVIEW_NATIVE_TRANSPORT].includes(state.inFlight?.transport) };
301
464
  } finally { release(); }
302
465
  }