@prodero/curator 0.1.0 → 0.2.1

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 (50) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/CHANGELOG.md +25 -2
  3. package/README.md +32 -33
  4. package/dist/cli.js +58 -20
  5. package/dist/contracts.js +1 -0
  6. package/dist/conversation-presentation.js +62 -27
  7. package/dist/core/agent-instructions.js +31 -18
  8. package/dist/core/curation-graphical.js +22 -10
  9. package/dist/core/curation-question.js +28 -0
  10. package/dist/core/curation-semantic-contract.js +4 -1
  11. package/dist/core/curation-semantic-jobs.js +78 -18
  12. package/dist/core/knowledge-graph.js +5 -4
  13. package/dist/core/local-preferences.js +76 -0
  14. package/dist/core/memory-index.js +2 -0
  15. package/dist/core/memory-link-repair.js +6 -2
  16. package/dist/core/memory-sync-curator.js +25 -5
  17. package/dist/core/memory-sync.js +124 -29
  18. package/dist/core/npm-update.js +289 -0
  19. package/dist/core/project.js +8 -8
  20. package/dist/core/session-index.js +10 -6
  21. package/dist/core/sessions.js +3 -3
  22. package/dist/core/settings.js +34 -16
  23. package/dist/core/skills.js +21 -2
  24. package/dist/core/storage.js +55 -1
  25. package/dist/core/sync-transaction.js +85 -49
  26. package/dist/core/update.js +9 -3
  27. package/dist/curation-review-adapter.js +1 -1
  28. package/dist/diagnostic-boundary.js +4 -3
  29. package/dist/memory-presentation.js +26 -16
  30. package/dist/memory-sync-operation.js +88 -0
  31. package/dist/presentation-server.js +25 -10
  32. package/dist/presentation.js +92 -61
  33. package/dist/server.js +59 -51
  34. package/dist/settings-definition.js +3 -3
  35. package/dist/status-presentation.js +20 -0
  36. package/dist/text-layout.js +278 -0
  37. package/dist/update-command.js +53 -0
  38. package/dist/update-instructions.js +4 -0
  39. package/dist-ui/index.html +117 -115
  40. package/docs/COMPATIBILITY.md +36 -15
  41. package/docs/MIGRATIONS.md +4 -0
  42. package/docs/RECOVERY.md +3 -1
  43. package/docs/RELEASING.md +69 -11
  44. package/docs/hosts.md +25 -7
  45. package/docs/images/prodero-context-ready-cropped.png +0 -0
  46. package/docs/images/prodero-overview-cropped.png +0 -0
  47. package/docs/images/prodero-session-learned-cropped.png +0 -0
  48. package/docs/ui-themes.md +10 -4
  49. package/instructions/AGENTS.md +24 -18
  50. package/package.json +6 -2
@@ -1,19 +1,39 @@
1
1
  /** Sync orchestration adapter. Questions, answers and jobs belong to Curator. */
2
- import { createCurationReview, resumeCurationReview, latestCurationReview } from './curation-review.js';
2
+ import { createCurationReview, resumeCurationReview, latestCurationReview, loadCurationReview, assertCurationHead, CurationReviewError } from './curation-review.js';
3
3
  import { planKnowledgeCuration } from './knowledge-curation.js';
4
4
  import { graphicalReviewPayload } from './curation-graphical.js';
5
- import { prepareSemanticCuration } from './curation-semantic-jobs.js';
5
+ import { prepareSemanticCuration, inspectSemanticCuration, curationAnswers } from './curation-semantic-jobs.js';
6
6
  import { prepareMemoryTransformation, validateMemoryTransformationProposal } from './memory-transformation-proposal.js';
7
7
  import { previewMemoryTransformation } from './memory-transformation.js';
8
- import { scopeEffect } from './curation-semantic-contract.js';
8
+ import { scopeEffect, semanticHash } from './curation-semantic-contract.js';
9
+ import { deterministicBaseline } from './curation-revision.js';
10
+ /** Explicit expiry refresh carries staged semantics and non-approval decisions
11
+ * into a fresh revision. It neither reopens a question nor records an apply. */
12
+ export async function refreshSyncQuestions(root, sourceIds, input) {
13
+ const review = await loadCurationReview(root, input.reviewId, { allowExpired: true }), revision = review.snapshot.revision;
14
+ await assertCurationHead(root, review);
15
+ if (revision.id !== input.reviewRevisionId || review.snapshot.planHash !== input.planHash || review.state.revision !== input.decisionRevision || semanticHash(await curationAnswers(root, revision.lineageId)) !== input.answerLogHash)
16
+ throw new CurationReviewError('review_changed', 'This saved question changed. Inspect current progress.');
17
+ const payload = await graphicalReviewPayload(root, review), baseline = deterministicBaseline(revision);
18
+ if (payload.projection.lifecycle !== 'stale')
19
+ throw new CurationReviewError('review_changed', 'This review has not expired. Inspect its current questions.');
20
+ if ((await planKnowledgeCuration(root, { memorySyncSources: sourceIds })).planId !== baseline.planId)
21
+ throw new CurationReviewError('semantic_stale', 'Knowledge or evidence changed. Re-analyze before refreshing this question.');
22
+ const next = await createCurationReview(root, { parent: review, operationId: `question_refresh_${semanticHash([revision.id, input.decisionRevision, input.answerLogHash])}`, applied: [] }, sourceIds);
23
+ return graphicalReviewPayload(root, next);
24
+ }
9
25
  import { sameApplicability } from './applicability.js';
10
- export async function prepareSyncCuration(root, sourceIds, reviewId) {
26
+ export async function prepareSyncCuration(root, sourceIds, reviewId, retry = false) {
11
27
  const previous = reviewId ? undefined : await latestCurationReview(root, (await planKnowledgeCuration(root, { memorySyncSources: sourceIds })).planId);
12
28
  const review = reviewId ? await resumeCurationReview(root, reviewId) : previous ?? await createCurationReview(root, undefined, sourceIds);
13
29
  const payload = await graphicalReviewPayload(root, review), g = payload.graphical;
14
30
  if (g.activeQuestionCount || g.deferredQuestionIds.length)
15
31
  return { reviewId: review.snapshot.reviewId, payload };
16
- const job = await prepareSemanticCuration(root, { reviewId: review.snapshot.reviewId, reviewRevisionId: review.snapshot.revision.id, componentId: review.snapshot.revision.dependencies.groups[0].component, requestId: `sync_${review.snapshot.reviewId}`, reason: 'initial', limits: { maxItems: 24 } });
32
+ // Replaying Prepare after Retry must return its live attempt, not the old initial request.
33
+ if (g.continuationJobId)
34
+ return { reviewId: review.snapshot.reviewId, payload, job: await inspectSemanticCuration(root, g.continuationJobId) };
35
+ const failed = retry ? g.jobs.find(j => !j.suspended && ['rejected', 'stale'].includes(j.status)) : undefined;
36
+ const job = await prepareSemanticCuration(root, { reviewId: review.snapshot.reviewId, reviewRevisionId: review.snapshot.revision.id, componentId: failed?.componentId ?? review.snapshot.revision.dependencies.groups[0].component, requestId: failed ? `sync_retry_${failed.id}` : `sync_${review.snapshot.reviewId}`, reason: failed ? (failed.status === 'rejected' ? 'validation_correction' : 'resume') : 'initial', ...(failed ? failed.status === 'rejected' ? { correctionForJobId: failed.id } : { resumeForJobId: failed.id } : {}), limits: { maxItems: 24 } });
17
37
  return { reviewId: review.snapshot.reviewId, payload, job };
18
38
  }
19
39
  export async function inspectSyncCuration(root, reviewId) {
@@ -1,9 +1,12 @@
1
1
  import { loadKnowledgeGraph } from './knowledge-graph.js';
2
+ import { memorySyncOperation } from '../memory-sync-operation.js';
3
+ import { curationAnswers } from './curation-semantic-jobs.js';
4
+ import { graphicalReviewPayload } from './curation-graphical.js';
2
5
  import { previewMemoryLinkRepair, applyMemoryLinkRepair } from './memory-link-repair.js';
3
- import { loadCurationReview } from './curation-review.js';
6
+ import { loadCurationReview, CurationReviewError } from './curation-review.js';
4
7
  import { requireCurrentProject } from "./migrations.js";
5
8
  import { publicDiagnostic } from './diagnostics.js';
6
- import { prepareSyncCuration, inspectSyncCuration, previewSyncCuration } from './memory-sync-curator.js';
9
+ import { prepareSyncCuration, inspectSyncCuration, previewSyncCuration, refreshSyncQuestions } from './memory-sync-curator.js';
7
10
  /** User-initiated orchestration. One bounded local workflow; no provider or background caller. */
8
11
  import fs from 'node:fs/promises';
9
12
  import { randomUUID } from 'node:crypto';
@@ -37,6 +40,7 @@ export const memorySyncActionSchema = z.discriminatedUnion('action', [
37
40
  z.object({ action: z.literal('approve'), ...requestIdentity, reviewId: z.string(), approved: z.literal(true) }).strict(),
38
41
  z.object({ action: z.literal('skip'), ...requestIdentity, candidateId: id.optional() }).strict(),
39
42
  z.object({ action: z.literal('retry'), ...requestIdentity, candidateId: id }).strict(),
43
+ z.object({ action: z.literal('refresh_questions'), ...requestIdentity, candidateId: id, reviewId: z.string(), reviewRevisionId: z.string(), planHash: z.string(), decisionRevision: z.number().int().nonnegative(), answerLogHash: z.string() }).strict(),
40
44
  z.object({ action: z.literal('promotions'), ...requestIdentity, ids: z.array(z.string()).max(50) }).strict(),
41
45
  z.object({ action: z.literal('complete'), ...requestIdentity }).strict(),
42
46
  ]);
@@ -45,13 +49,13 @@ async function fingerprint(root, excludeCheckpoint) { return { knowledge: await
45
49
  async function load(root) { const target = await checkedDataPath(root, FILE); try {
46
50
  const stat = await fs.stat(target);
47
51
  if (stat.size > MEMORY_SYNC_LIMITS.bytes)
48
- throw new Error('Memory Sync state exceeds bound');
52
+ throw new Error('Curate Memory state exceeds bound');
49
53
  const envelope = JSON.parse(await fs.readFile(target, 'utf8'));
50
54
  if (envelope.hash !== digest(envelope.workflow))
51
- throw new Error('Memory Sync checkpoint is corrupt');
55
+ throw new Error('Curate Memory checkpoint is corrupt');
52
56
  const state = stateSchema.parse(envelope.workflow);
53
57
  if (state.root !== await fs.realpath(root))
54
- throw new Error('Memory Sync belongs to another project');
58
+ throw new Error('Curate Memory belongs to another project');
55
59
  return state;
56
60
  }
57
61
  catch (e) {
@@ -60,7 +64,7 @@ catch (e) {
60
64
  throw e;
61
65
  } }
62
66
  async function save(root, w) { w.revision++; const workflow = stateSchema.parse(w); const text = JSON.stringify({ hash: digest(workflow), workflow }); if (Buffer.byteLength(text) > MEMORY_SYNC_LIMITS.bytes)
63
- throw new Error('Memory Sync checkpoint exceeds bounded storage; split work'); await ensureDataDirectory(root, 'local'); await writeFileAtomic(await checkedDataPath(root, FILE), text); }
67
+ throw new Error('Curate Memory checkpoint exceeds bounded storage; split work'); await ensureDataDirectory(root, 'local'); await writeFileAtomic(await checkedDataPath(root, FILE), text); }
64
68
  async function promotions(root, w) { const rows = await promotionRecommendations(root, { persistCache: false }); w.promotionCandidates = rows.slice(0, 50); w.promotionTotal = rows.length; }
65
69
  function currentJob(w) { return w.jobs.find(j => j.selected && ['pending', 'semantic_work', 'awaiting_review', 'awaiting_clarification'].includes(j.status)); }
66
70
  async function advance(root, w) {
@@ -87,10 +91,43 @@ async function advance(root, w) {
87
91
  else
88
92
  w.state = 'completed';
89
93
  }
90
- function view(w, clarification) {
94
+ async function deferredQuestions(root, w) {
95
+ const questions = [];
96
+ for (const job of w.jobs.filter(j => j.selected && j.status === 'deferred' && j.curationReviewId)) {
97
+ const payload = await inspectSyncCuration(root, job.curationReviewId), review = payload.review, g = payload.graphical;
98
+ const seen = new Set();
99
+ for (const q of g.questions.filter(q => q.state === 'deferred')) {
100
+ const related = q.reopenedQuestions, key = related.map(other => other.id).sort().join(',');
101
+ if (seen.has(key))
102
+ continue;
103
+ seen.add(key);
104
+ questions.push({ candidateId: job.id, title: q.question.title, prompt: q.question.prompt, expired: payload.projection.lifecycle === 'stale', affectedQuestions: related,
105
+ action: 'question_reopen', reviewId: review.snapshot.reviewId, reviewRevisionId: review.snapshot.revision.id, planHash: review.snapshot.planHash, decisionRevision: review.state.revision, answerLogHash: g.answerLogHash, questionId: q.question.id, questionVersion: q.question.version });
106
+ }
107
+ }
108
+ return questions;
109
+ }
110
+ /** Adopt only an explicitly reopened saved question. Inspection never reopens
111
+ * one, preempts independent reasoning, or replaces an active exact review. */
112
+ async function resumeRevisitedQuestion(root, w) {
113
+ if (!w.improveEnabled || currentJob(w) || w.active || ['stale', 'applying', 'awaiting_selection'].includes(w.state))
114
+ return;
115
+ for (const job of w.jobs.filter(j => j.selected && j.status === 'deferred' && j.curationReviewId)) {
116
+ const payload = await inspectSyncCuration(root, job.curationReviewId);
117
+ if (!payload.graphical.activeQuestionCount && !payload.graphical.analysisPending && !payload.graphical.questions.some(q => q.state === 'answered_pending_analysis'))
118
+ continue;
119
+ job.curationReviewId = payload.review.snapshot.reviewId;
120
+ job.status = 'semantic_work';
121
+ delete job.error;
122
+ w.state = 'semantic_work';
123
+ await save(root, w);
124
+ return;
125
+ }
126
+ }
127
+ function view(w, clarification, deferred = []) {
91
128
  const applied = w.jobs.filter(j => j.status === 'applied').map(j => j.result.summary);
92
129
  const promotion = w.promotionResult;
93
- return { ...w, clarification, active: undefined, review: w.active ? { kind: w.active.kind, reviewId: w.active.reviewId, preview: w.active.preview } : undefined,
130
+ return { ...w, clarification, deferredQuestions: deferred, active: undefined, review: w.active ? { kind: w.active.kind, reviewId: w.active.reviewId, preview: w.active.preview } : undefined,
94
131
  health: { transformationCandidates: w.jobs.length, contextPressureCandidates: w.jobs.filter(j => j.signals.includes('context_pressure')).length, potentialConflictCandidates: w.jobs.filter(j => j.signals.includes('potential_conflict')).length, localPromotionCandidates: w.promotionTotal, possibleVersionedTransformationRecords: w.jobs.reduce((n, j) => n + j.layers.versioned, 0) },
95
132
  summary: { brokenLinksFixed: w.repairLinksResult?.fixed ?? 0, brokenLinksRemaining: w.repairLinksResult?.remaining ?? w.brokenLinks ?? 0, areasProcessed: applied.length, areasFailed: w.jobs.filter(j => j.status === 'failed').length, areasSkipped: w.jobs.filter(j => j.status === 'skipped').length, questionsDeferred: w.jobs.filter(j => j.status === 'deferred').length,
96
133
  sourcesBefore: applied.reduce((n, r) => n + r.consolidated.length + r.superseded.length + r.kept.length + r.unresolved.length, 0), currentAfter: applied.reduce((n, r) => n + r.created.length + r.kept.length + r.unresolved.length, 0),
@@ -101,6 +138,34 @@ function view(w, clarification) {
101
138
  next: w.state === 'semantic_work' ? { action: 'semantic_work_required', candidateId: currentJob(w)?.id, instructions: w.improveEnabled ? 'Use prodero_memory_sync prepare for the next candidate. For its curation job, use the existing prodero_curation_reasoning_prepare/propose protocol, then inspect this Sync workflow. Continue sequentially until a question, review or completion. Never Apply or approve; Core handles eligible Local changes. No session lifecycle or extra scans.' : 'Use prodero_memory_sync action=prepare, then reason only over that one bundle and submit action=propose with the returned workflow ID/revision. Continue sequentially while semantic_work_required. Never approve review automatically. Do not use session prepare/record/close for this workflow.' } : ['awaiting_clarification', 'awaiting_promotion_selection', 'awaiting_review'].includes(w.state) ? { action: w.state === 'awaiting_review' ? 'human_review_required' : w.state, tool: 'prodero_sync', arguments: { projectRoot: w.root, workflowId: w.id }, instructions: 'When an unattached continuation returns this state, present the pending human step before ending the turn: read project presentation settings and invoke prodero_sync with these exact arguments once (use prodero_text for text mode). This reopens the same saved workflow without restarting analysis. After presenting it, stop and wait; do not repeatedly reopen an unchanged step. Never select records, answer questions, approve or publish for the human.' } : { action: w.state },
102
139
  };
103
140
  }
141
+ async function workflowView(root, w, clarification, deferred) {
142
+ const previous = currentJob(w)?.id;
143
+ if (w.state !== 'stale')
144
+ await resumeRevisitedQuestion(root, w);
145
+ if (!previous && currentJob(w))
146
+ clarification = await refreshCuration(root, w);
147
+ const snapshot = view(w, clarification, deferred ?? (w.state !== 'stale' ? await deferredQuestions(root, w) : []));
148
+ const lineages = new Set(), questions = new Set();
149
+ let savedAnswerEvents = 0, unsupersededAnswerEvents = 0;
150
+ let reasoning;
151
+ for (const job of w.jobs.filter(j => j.selected && j.curationReviewId)) {
152
+ const payload = clarification && clarification.review?.snapshot.reviewId === job.curationReviewId ? clarification : await graphicalReviewPayload(root, await loadCurationReview(root, job.curationReviewId, { allowExpired: true }));
153
+ const lineage = payload.review.snapshot.revision.lineageId;
154
+ if (job.id === currentJob(w)?.id)
155
+ reasoning = payload.graphical;
156
+ if (lineages.has(lineage))
157
+ continue;
158
+ lineages.add(lineage);
159
+ for (const q of payload.graphical.questions)
160
+ if (q.state === 'deferred')
161
+ questions.add(`${lineage}:${q.question.id}`);
162
+ const answers = await curationAnswers(root, lineage);
163
+ savedAnswerEvents += answers.length;
164
+ unsupersededAnswerEvents += answers.filter(a => !answers.some(next => next.replacesAnswerId === a.id)).length;
165
+ }
166
+ const projected = { ...snapshot, reasoning, questionCounts: { deferred: questions.size, savedAnswerEvents, unsupersededAnswerEvents, coverage: 'saved_selected_reviews' } };
167
+ return { ...projected, operation: memorySyncOperation(projected) };
168
+ }
104
169
  async function refreshCuration(root, w) {
105
170
  const j = currentJob(w);
106
171
  if (!w.improveEnabled || !j?.curationReviewId || !['semantic_work', 'awaiting_clarification'].includes(w.state))
@@ -248,7 +313,7 @@ export async function runMemorySync(root, raw) {
248
313
  const input = memorySyncActionSchema.parse(raw);
249
314
  root = await findProjectRoot(root);
250
315
  if (!await readConfig(root))
251
- throw new Error('Prodero must be initialized before Sync Memory');
316
+ throw new Error('Prodero must be initialized before Curate Memory');
252
317
  if (input.action === 'start') {
253
318
  await requireCurrentProject(root);
254
319
  await autoMigrateProject(root);
@@ -268,20 +333,27 @@ export async function runMemorySync(root, raw) {
268
333
  const w = { brokenLinks: (await loadKnowledgeGraph(root, undefined, { persistCache: false })).diagnostics.brokenReferences, version: 1, id: randomUUID(), root: await fs.realpath(root), revision: 0, createdAt: new Date().toISOString(), state: 'awaiting_selection', usage: { sessions: plan.coverage.sessions, available: plan.coverage.telemetry === 'available' }, baseline: await fingerprint(root), jobs: plan.candidates.map(c => ({ id: c.id, label: c.label, signals: c.signals, recordCount: c.recordCount, counts: c.counts, categories: c.categories, sourceIds: c.records.map(r => r.id), layers: c.layers, selected: false, status: 'unselected' })), promotionEnabled: false, promotionDone: false, promotionCandidates: [], promotionTotal: 0, recovery };
269
334
  await promotions(root, w);
270
335
  await save(root, w);
271
- return view(w);
336
+ return workflowView(root, w);
272
337
  });
273
338
  }
274
339
  return withSyncLock(root, async () => {
275
340
  await recoverSyncTransaction(root);
276
341
  const w = await load(root);
277
342
  if (!w)
278
- throw new Error('Start Sync Memory first');
343
+ throw new Error('Start Curate Memory first');
279
344
  if (input.workflowId && input.workflowId !== w.id)
280
- throw new Error('Memory Sync workflow expired or was replaced');
345
+ throw new Error('Curate Memory workflow expired or was replaced');
281
346
  const before = w.revision;
282
347
  await resume(root, w);
283
- if (input.action === 'inspect')
284
- return view(w, w.state !== 'stale' ? await refreshCuration(root, w) : undefined);
348
+ if (input.action === 'inspect') {
349
+ if (w.state !== 'stale')
350
+ await resumeRevisitedQuestion(root, w);
351
+ const clarification = w.state !== 'stale' ? await refreshCuration(root, w) : undefined;
352
+ // Finishing independent work may make a deliberately reopened question next.
353
+ if (w.state !== 'stale')
354
+ await resumeRevisitedQuestion(root, w);
355
+ return workflowView(root, w, clarification ?? (w.state !== 'stale' ? await refreshCuration(root, w) : undefined), w.state !== 'stale' ? await deferredQuestions(root, w) : []);
356
+ }
285
357
  if (w.state === 'stale')
286
358
  throw new Error(w.error);
287
359
  if (w.state === 'applying')
@@ -318,18 +390,18 @@ export async function runMemorySync(root, raw) {
318
390
  throw new Error('Prepare only the next selected candidate');
319
391
  if (w.improveEnabled) {
320
392
  try {
321
- const curation = await prepareSyncCuration(root, j.sourceIds, j.curationReviewId);
393
+ const curation = await prepareSyncCuration(root, j.sourceIds, j.curationReviewId, j.status === 'pending' && !!j.curationReviewId);
322
394
  j.curationReviewId = curation.reviewId;
323
395
  j.status = 'semantic_work';
324
396
  await save(root, w);
325
- return { ...view(w), curation };
397
+ return { ...await workflowView(root, w), curation };
326
398
  }
327
399
  catch (e) {
328
400
  j.status = 'failed';
329
401
  j.error = publicDiagnostic(e).slice(0, 1000);
330
402
  await advance(root, w);
331
403
  await save(root, w);
332
- return view(w);
404
+ return workflowView(root, w);
333
405
  }
334
406
  }
335
407
  try {
@@ -340,7 +412,7 @@ export async function runMemorySync(root, raw) {
340
412
  throw new Error('Candidate membership changed');
341
413
  j.status = 'semantic_work';
342
414
  await save(root, w);
343
- return { ...view(w), semantic };
415
+ return { ...await workflowView(root, w), semantic };
344
416
  }
345
417
  catch (e) {
346
418
  j.status = 'failed';
@@ -350,7 +422,7 @@ export async function runMemorySync(root, raw) {
350
422
  }
351
423
  else if (input.action === 'propose') {
352
424
  if (w.improveEnabled)
353
- throw new Error('Submit this job through Curator reasoning, then inspect Sync Memory');
425
+ throw new Error('Submit this job through Curator reasoning, then inspect Curate Memory');
354
426
  const j = currentJob(w);
355
427
  if (w.state !== 'semantic_work' || j?.status !== 'semantic_work' || input.candidateId !== j.id || !input.proposal)
356
428
  throw new Error('Prepare selected candidate before submitting its proposal');
@@ -370,18 +442,18 @@ export async function runMemorySync(root, raw) {
370
442
  j.error = publicDiagnostic(e).slice(0, 1000);
371
443
  await advance(root, w);
372
444
  await save(root, w);
373
- return view(w);
445
+ return workflowView(root, w);
374
446
  }
375
447
  if (w.active.preview.localDirectApplyEligible)
376
448
  await beginApply(root, w);
377
- return view(w);
449
+ return workflowView(root, w);
378
450
  }
379
451
  else if (input.action === 'approve') {
380
452
  if (w.state !== 'awaiting_review' || !w.active || input.approved !== true || input.reviewId !== w.active.reviewId)
381
453
  throw new Error('Explicit approval of this exact review is required');
382
454
  w.active.approved = true;
383
455
  await beginApply(root, w);
384
- return view(w);
456
+ return workflowView(root, w);
385
457
  }
386
458
  else if (input.action === 'skip') {
387
459
  if (w.active?.kind === 'link-repair') {
@@ -403,6 +475,15 @@ export async function runMemorySync(root, raw) {
403
475
  await advance(root, w);
404
476
  }
405
477
  }
478
+ else if (input.action === 'refresh_questions') {
479
+ const j = w.jobs.find(j => j.id === input.candidateId && j.selected && j.status === 'deferred' && j.curationReviewId);
480
+ if (!j || w.active)
481
+ throw new CurationReviewError('review_changed', 'Refresh only this saved deferred review after finishing the active exact review.');
482
+ if ((await inspectSyncCuration(root, j.curationReviewId)).review.snapshot.reviewId !== input.reviewId)
483
+ throw new CurationReviewError('review_changed', 'This deferred review changed. Inspect saved progress.');
484
+ const fresh = await refreshSyncQuestions(root, j.sourceIds, input);
485
+ j.curationReviewId = fresh.review.snapshot.reviewId;
486
+ }
406
487
  else if (input.action === 'retry') {
407
488
  if (w.active || w.promotionResult)
408
489
  throw new Error('Finish review before retry, or start a fresh workflow after promotion');
@@ -430,7 +511,7 @@ export async function runMemorySync(root, raw) {
430
511
  w.state = 'completed';
431
512
  }
432
513
  await save(root, w);
433
- return view(w);
514
+ return workflowView(root, w);
434
515
  });
435
516
  }
436
517
  /** Open-only diagnostic projection. Never resumes, recovers, migrates or persists a workflow. */
@@ -452,12 +533,26 @@ export async function getMemorySyncOverview(root) {
452
533
  /** Resolve continuation ownership from persisted review lineage, not a particular App instance. */
453
534
  export async function syncWorkflowForCuration(root, lineageId) {
454
535
  const w = await load(root);
455
- if (!w || !w.improveEnabled || !['semantic_work', 'awaiting_clarification'].includes(w.state))
456
- return undefined;
457
- const reviewId = currentJob(w)?.curationReviewId;
458
- if (!reviewId)
536
+ if (!w || !w.improveEnabled || w.state === 'stale')
459
537
  return undefined;
460
- const review = await loadCurationReview(root, reviewId);
461
- return review.snapshot.revision?.lineageId === lineageId ? w.id : undefined;
538
+ for (const job of w.jobs.filter(j => j.selected && j.curationReviewId && ['pending', 'semantic_work', 'awaiting_clarification', 'deferred'].includes(j.status))) {
539
+ const review = await loadCurationReview(root, job.curationReviewId, { allowExpired: true });
540
+ if (review.snapshot.revision?.lineageId === lineageId)
541
+ return w.id;
542
+ }
543
+ return undefined;
544
+ }
545
+ /** Internal adapter ownership check under the common writer lock. A review ID
546
+ * from another workflow/candidate never grants conversational access. */
547
+ export async function syncCurationReview(root, workflowId, candidateId, reviewId) {
548
+ const w = await load(root), job = w?.jobs.find(j => j.id === candidateId && j.selected && j.curationReviewId && ['pending', 'semantic_work', 'awaiting_clarification', 'deferred', 'failed'].includes(j.status));
549
+ if (!w || w.id !== workflowId || !w.improveEnabled || !job || w.state === 'stale')
550
+ throw new CurationReviewError('review_changed', 'This question is not part of the current Sync workflow. Inspect saved progress.');
551
+ if (digest(await fingerprint(root)) !== digest(w.baseline))
552
+ throw new CurationReviewError('semantic_stale', 'Knowledge or evidence changed. Inspect the current workflow.');
553
+ const payload = await inspectSyncCuration(root, job.curationReviewId);
554
+ if (payload.review.snapshot.reviewId !== reviewId)
555
+ throw new CurationReviewError('review_changed', 'This review changed. Inspect saved progress.');
556
+ return payload;
462
557
  }
463
558
  //# sourceMappingURL=memory-sync.js.map