@prodero/curator 0.0.0-stage → 0.1.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 (137) hide show
  1. package/ARCHITECTURE.md +298 -0
  2. package/CHANGELOG.md +16 -0
  3. package/CONTRIBUTING.md +9 -0
  4. package/LICENSE +21 -0
  5. package/README.md +94 -2
  6. package/SECURITY.md +7 -0
  7. package/bin/prodero +7 -0
  8. package/dist/cli.js +228 -0
  9. package/dist/context-instructions.js +2 -0
  10. package/dist/contracts.js +50 -0
  11. package/dist/conversation-presentation.js +262 -0
  12. package/dist/core/agent-instructions.js +39 -0
  13. package/dist/core/applicability-label.js +11 -0
  14. package/dist/core/applicability.js +143 -0
  15. package/dist/core/bootstrap.js +129 -0
  16. package/dist/core/consolidation.js +315 -0
  17. package/dist/core/constants.js +21 -0
  18. package/dist/core/context-allocation.js +54 -0
  19. package/dist/core/context-snapshot.js +12 -0
  20. package/dist/core/context.js +489 -0
  21. package/dist/core/curation-clauses.js +68 -0
  22. package/dist/core/curation-evidence.js +98 -0
  23. package/dist/core/curation-graphical.js +105 -0
  24. package/dist/core/curation-review-presentation.js +4 -0
  25. package/dist/core/curation-review-state.js +62 -0
  26. package/dist/core/curation-review.js +573 -0
  27. package/dist/core/curation-revision.js +106 -0
  28. package/dist/core/curation-semantic-contract.js +239 -0
  29. package/dist/core/curation-semantic-jobs.js +280 -0
  30. package/dist/core/diagnostics.js +17 -0
  31. package/dist/core/format.js +114 -0
  32. package/dist/core/ids.js +6 -0
  33. package/dist/core/imports.js +169 -0
  34. package/dist/core/index.js +93 -0
  35. package/dist/core/instruction-review.js +86 -0
  36. package/dist/core/integrity-files.js +174 -0
  37. package/dist/core/intelligence.js +95 -0
  38. package/dist/core/knowledge-analysis.js +46 -0
  39. package/dist/core/knowledge-authority.js +143 -0
  40. package/dist/core/knowledge-curation.js +316 -0
  41. package/dist/core/knowledge-engine.js +475 -0
  42. package/dist/core/knowledge-graph.js +191 -0
  43. package/dist/core/knowledge-lifecycle.js +212 -0
  44. package/dist/core/knowledge-management.js +113 -0
  45. package/dist/core/knowledge-migration.js +84 -0
  46. package/dist/core/knowledge-policy.js +38 -0
  47. package/dist/core/knowledge-provenance.js +90 -0
  48. package/dist/core/knowledge-write-policy.js +85 -0
  49. package/dist/core/language.js +14 -0
  50. package/dist/core/matching.js +6 -0
  51. package/dist/core/memory-index.js +239 -0
  52. package/dist/core/memory-intelligence.js +288 -0
  53. package/dist/core/memory-link-repair.js +66 -0
  54. package/dist/core/memory-operations.js +122 -0
  55. package/dist/core/memory-pressure.js +212 -0
  56. package/dist/core/memory-sync-curator.js +62 -0
  57. package/dist/core/memory-sync.js +463 -0
  58. package/dist/core/memory-transformation-bundle.js +163 -0
  59. package/dist/core/memory-transformation-proposal.js +135 -0
  60. package/dist/core/memory-transformation.js +108 -0
  61. package/dist/core/memory.js +186 -0
  62. package/dist/core/migrations.js +569 -0
  63. package/dist/core/path-scope.js +27 -0
  64. package/dist/core/plugin-files.js +116 -0
  65. package/dist/core/project-root.js +39 -0
  66. package/dist/core/project.js +634 -0
  67. package/dist/core/promotion.js +279 -0
  68. package/dist/core/reconciliation.js +61 -0
  69. package/dist/core/secrets.js +67 -0
  70. package/dist/core/semantic-safety.js +33 -0
  71. package/dist/core/session-index.js +112 -0
  72. package/dist/core/session-review.js +150 -0
  73. package/dist/core/sessions.js +289 -0
  74. package/dist/core/settings.js +76 -0
  75. package/dist/core/skill-curation.js +239 -0
  76. package/dist/core/skill-polish.js +41 -0
  77. package/dist/core/skills.js +368 -0
  78. package/dist/core/storage.js +408 -0
  79. package/dist/core/sync-transaction.js +254 -0
  80. package/dist/core/telemetry.js +64 -0
  81. package/dist/core/templates.js +53 -0
  82. package/dist/core/types.js +2 -0
  83. package/dist/core/update.js +320 -0
  84. package/dist/curation-review-adapter.js +92 -0
  85. package/dist/diagnostic-boundary.js +54 -0
  86. package/dist/filesystem/permissions.js +39 -0
  87. package/dist/hosts/adapters.js +119 -0
  88. package/dist/hosts/files.js +133 -0
  89. package/dist/hosts/manager.js +150 -0
  90. package/dist/hosts/public-entry.js +47 -0
  91. package/dist/hosts/runtime-command.js +66 -0
  92. package/dist/hosts/runtime.js +139 -0
  93. package/dist/hosts/wizard.js +114 -0
  94. package/dist/knowledge-table.js +38 -0
  95. package/dist/mcp-development.js +69 -0
  96. package/dist/mcp-workspace.js +141 -0
  97. package/dist/memory-presentation.js +172 -0
  98. package/dist/plugins/api.js +2 -0
  99. package/dist/plugins/context.js +20 -0
  100. package/dist/plugins/mcp.js +72 -0
  101. package/dist/plugins/registry.js +106 -0
  102. package/dist/presentation-cards.js +32 -0
  103. package/dist/presentation-server.js +79 -0
  104. package/dist/presentation-trace.js +174 -0
  105. package/dist/presentation.js +412 -0
  106. package/dist/providers/mcp-sampling.js +45 -0
  107. package/dist/server.js +656 -0
  108. package/dist/settings-definition.js +22 -0
  109. package/dist/skill-instructions.js +2 -0
  110. package/dist-ui/index.html +214 -0
  111. package/docs/APPLICABILITY.md +151 -0
  112. package/docs/COMPATIBILITY.md +49 -0
  113. package/docs/CONTEXT-SELECTION-V2.md +61 -0
  114. package/docs/CURATION-APP-REVIEW.md +156 -0
  115. package/docs/CURATION-CLAUSE-REFINEMENT.md +45 -0
  116. package/docs/IMPORTER-CURATION.md +142 -0
  117. package/docs/KNOWLEDGE-AUTHORITY.md +165 -0
  118. package/docs/KNOWLEDGE-SCHEMA-V2.md +150 -0
  119. package/docs/MIGRATIONS.md +99 -0
  120. package/docs/PRIVACY.md +28 -0
  121. package/docs/RECOVERY.md +56 -0
  122. package/docs/RELEASING.md +40 -0
  123. package/docs/hosts.md +134 -0
  124. package/docs/plugins.md +83 -0
  125. package/docs/ui-themes.md +43 -0
  126. package/instructions/AGENTS.md +33 -0
  127. package/package.json +88 -4
  128. package/plugins/migrate_ai_studio/README.md +101 -0
  129. package/plugins/migrate_ai_studio/index.cjs +396 -0
  130. package/plugins/migrate_ai_studio/instructions/audit.md +25 -0
  131. package/plugins/migrate_ai_studio/instructions/classify.md +41 -0
  132. package/plugins/migrate_ai_studio/instructions/migrate.md +34 -0
  133. package/plugins/migrate_ai_studio/instructions/verify.md +20 -0
  134. package/plugins/migrate_ai_studio/plugin.json +11 -0
  135. package/plugins/migrate_ai_studio/schema.cjs +20 -0
  136. package/plugins/migrate_ai_studio/ui/dist/index.html +77 -0
  137. package/prodero.release.json +5 -0
@@ -0,0 +1,573 @@
1
+ import { assertSafeCurationData } from './curation-evidence.js';
2
+ import { materializeSemanticPlan } from './curation-semantic-contract.js';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { z } from 'zod';
6
+ import { checkedDataPath, ensureDataDirectory, writeFileAtomic, sha256, listCheckpoints } from './storage.js';
7
+ import { loadMemoryCatalog } from './memory-index.js';
8
+ import { sealRevision, validateRevision, deriveDependencies, curationSelectionHash, deterministicBaseline } from './curation-revision.js';
9
+ import { withSyncLock, ownsSyncLock } from './sync-transaction.js';
10
+ import { sortableId } from './ids.js';
11
+ import { planKnowledgeCuration, validateCurationPlan, curationReviewEntries } from './knowledge-curation.js';
12
+ import { clarificationStateSchema, analysisOutcome, projectCurationReview } from './curation-review-state.js';
13
+ export { projectCurationReview } from './curation-review-state.js';
14
+ export const REVIEW_RETENTION = { maxCount: 32, maxAgeMs: 7 * 24 * 60 * 60 * 1000, maxBytes: 8 * 1024 * 1024, maxEvents: 2048 };
15
+ const directory = 'local/curation-reviews';
16
+ const idPattern = /^review_[a-zA-Z0-9_]+$/;
17
+ const hash = (value) => sha256(JSON.stringify(value));
18
+ export class CurationReviewError extends Error {
19
+ code;
20
+ details;
21
+ constructor(code, message, details) {
22
+ super(message);
23
+ this.code = code;
24
+ this.details = details;
25
+ }
26
+ }
27
+ const fail = (code, message) => { throw new CurationReviewError(code, message); };
28
+ const decisionSchema = z.record(z.enum(['current', 'suggested', 'deferred']));
29
+ const legacySchema = z.object({ revision: z.number().int().nonnegative(), snapshotHash: z.string(), decisions: decisionSchema }).strict();
30
+ const eventSchema = z.object({
31
+ revision: z.number().int().positive(), at: z.string().datetime(),
32
+ kind: z.enum(['decide', 'defer_issue', 'defer_change', 'pause_review', 'resume_review', 'reopen_issue']),
33
+ decisions: decisionSchema, previousHash: z.string(), hash: z.string(),
34
+ }).strict();
35
+ const journalSchema = z.object({
36
+ version: z.literal(2), projectIdentity: z.string(), snapshotHash: z.string(),
37
+ base: z.object({ revision: z.number().int().nonnegative(), decisions: decisionSchema, paused: z.boolean(), origin: z.enum(['new', 'legacy_v1', 'inherited']), inheritedFrom: z.array(z.string()), pauseUpdatedAt: z.string().datetime().optional(), analysis: z.enum(['complete', 'pending', 'failed']).optional(), clarifications: z.record(z.object({ id: z.string(), basisHash: z.string(), sourceIds: z.array(z.string()), state: clarificationStateSchema }).strict()).optional() }).strict(),
38
+ events: z.array(eventSchema).max(REVIEW_RETENTION.maxEvents),
39
+ }).strict();
40
+ export function reviewUnits(plan) {
41
+ const remaining = new Set(plan.proposals.map(p => p.id)), units = [];
42
+ while (remaining.size) {
43
+ const ids = new Set([remaining.values().next().value]);
44
+ let added = true;
45
+ while (added) {
46
+ added = false;
47
+ for (const p of plan.proposals) {
48
+ const linked = [p.id, ...(p.consolidationSources ?? []), ...plan.proposals.filter(q => q.outputs.some(o => p.outputs.some(v => v.id === o.id))).map(q => q.id)];
49
+ if (linked.some(id => ids.has(id)))
50
+ for (const id of linked)
51
+ if (!ids.has(id)) {
52
+ ids.add(id);
53
+ added = true;
54
+ }
55
+ }
56
+ }
57
+ const proposalIds = [...ids].sort(), proposals = proposalIds.map(id => plan.proposals.find(p => p.id === id));
58
+ if (proposals.some(p => !p))
59
+ fail('invalid_review', 'Review contains an unknown dependency.');
60
+ units.push({ id: `item_${hash(proposalIds).slice(0, 24)}`, proposalIds, applicable: proposals.every(p => p.changes.length > 0 && !p.attention.length && p.outputs.every(o => o.applicability?.kind !== 'unresolved')), attention: proposals.some(p => p.attention.length > 0) });
61
+ for (const id of ids)
62
+ remaining.delete(id);
63
+ }
64
+ return units;
65
+ }
66
+ /** Presentation prose and whole-plan hashes are not issue identity. */
67
+ export function reviewUnitBasis(plan, unit) {
68
+ return hash(unit.proposalIds.map(id => plan.proposals.find(p => p.id === id)).map(p => ({ source: p.sourceId, revision: p.revision,
69
+ issueBasis: p.issueBasis ?? [], relations: p.before.relations ?? [],
70
+ evidence: (p.investigation?.evidence ?? []).map(e => [e.path, e.revision]).sort(),
71
+ })).sort((a, b) => a.source.localeCompare(b.source)));
72
+ }
73
+ /** Compatibility for snapshots/checkpoints sealed before basis metadata existed.
74
+ * This never changes an Apply planHash or grants selection on a new snapshot. */
75
+ function legacyPlanHash(plan) {
76
+ const { planId, ...body } = plan;
77
+ return hash({ ...body, proposals: plan.proposals.map(({ issueBasis, ...proposal }) => proposal) });
78
+ }
79
+ function sameUnitBasis(previous, current, old, unit) {
80
+ if (reviewUnitBasis(previous, old) === reviewUnitBasis(current, unit))
81
+ return true;
82
+ const adapted = structuredClone(previous);
83
+ for (const id of old.proposalIds) {
84
+ const before = adapted.proposals.find(p => p.id === id), after = current.proposals.find(p => p.id === id);
85
+ if (before.issueBasis)
86
+ continue;
87
+ const refs = (after.issueBasis ?? []).map(ref => ({ id: ref.id, revision: previous.proposals.find(p => p.id === ref.id)?.revision ?? (previous.fingerprint === current.fingerprint ? ref.revision : 'unknown-legacy-basis') }));
88
+ before.issueBasis = refs;
89
+ }
90
+ return reviewUnitBasis(adapted, old) === reviewUnitBasis(current, unit);
91
+ }
92
+ function stateFrom(snapshot, journal) {
93
+ const decisions = { ...journal.base.decisions };
94
+ let revision = journal.base.revision, paused = journal.base.paused;
95
+ let previousHash = hash(journal.base);
96
+ for (const event of journal.events) {
97
+ const { hash: eventHash, ...body } = event;
98
+ if (event.revision !== revision + 1 || event.previousHash !== previousHash || eventHash !== hash(body))
99
+ fail('invalid_review', 'Review decision history is invalid.');
100
+ if (event.kind === 'pause_review' || event.kind === 'resume_review') {
101
+ if (Object.keys(event.decisions).length)
102
+ fail('invalid_review', 'Invalid review control event.');
103
+ paused = event.kind === 'pause_review';
104
+ }
105
+ else {
106
+ for (const [id, choice] of Object.entries(event.decisions)) {
107
+ const unit = snapshot.units.find(u => u.id === id);
108
+ if (!unit || event.kind === 'defer_issue' && analysisOutcome(snapshot.plan, unit) !== 'unresolved' || event.kind === 'defer_change' && analysisOutcome(snapshot.plan, unit) !== 'proposed' || event.kind !== 'decide' && choice !== 'deferred')
109
+ fail('invalid_review', 'Invalid review decision event.');
110
+ }
111
+ if (event.kind === 'reopen_issue')
112
+ for (const id of Object.keys(event.decisions))
113
+ delete decisions[id];
114
+ else
115
+ Object.assign(decisions, event.decisions);
116
+ }
117
+ revision = event.revision;
118
+ previousHash = eventHash;
119
+ }
120
+ if (Object.keys(decisions).some(id => !snapshot.units.some(u => u.id === id)))
121
+ fail('invalid_review', 'Unknown review decision.');
122
+ const items = {};
123
+ for (const unit of snapshot.units) {
124
+ const outcome = analysisOutcome(snapshot.plan, unit), choice = decisions[unit.id];
125
+ if (choice === 'suggested' && !unit.applicable)
126
+ fail('invalid_selection', 'Unresolved knowledge cannot use the suggested version.');
127
+ const issue = { id: `issue_${hash(unit.proposalIds).slice(0, 24)}`, basisHash: reviewUnitBasis(snapshot.plan, unit), sourceIds: [...unit.proposalIds], state: 'open' };
128
+ const persistedIssue = journal.base.clarifications?.[unit.id];
129
+ if (persistedIssue && (outcome !== 'unresolved' || persistedIssue.id !== issue.id || persistedIssue.basisHash !== issue.basisHash || hash(persistedIssue.sourceIds) !== hash(issue.sourceIds)))
130
+ fail('invalid_review', 'Clarification basis changed.');
131
+ items[unit.id] = { outcome, disposition: choice === 'suggested' ? 'included' : choice === 'current' ? 'keep_current' : choice === 'deferred' ? 'deferred' : null, execution: 'not_applied',
132
+ ...(outcome === 'unresolved' ? { issue: { ...issue, ...persistedIssue, state: choice === 'deferred' ? 'deferred' : choice === 'current' ? 'open' : journal.events.some(e => e.kind === 'reopen_issue' && Object.hasOwn(e.decisions, unit.id)) ? 'open' : persistedIssue?.state ?? 'open' } } : {}) };
133
+ }
134
+ return { revision, snapshotHash: snapshot.snapshotHash, decisions, items, paused, analysis: journal.base.analysis ?? 'complete' };
135
+ }
136
+ async function reviewPath(root, id, state = false) {
137
+ if (!idPattern.test(id))
138
+ fail('invalid_review', 'Invalid review identifier.');
139
+ return checkedDataPath(root, directory, `${id}${state ? '.state' : ''}.json`);
140
+ }
141
+ async function readJson(file) {
142
+ try {
143
+ if ((await fs.stat(file)).size > REVIEW_RETENTION.maxBytes)
144
+ fail('invalid_review', 'Review is too large.');
145
+ const data = JSON.parse(await fs.readFile(file, 'utf8'));
146
+ assertSafeCurationData(data);
147
+ return data;
148
+ }
149
+ catch (e) {
150
+ if (e instanceof CurationReviewError)
151
+ throw e;
152
+ return fail('invalid_review', 'Review is unavailable. Analyze knowledge again.');
153
+ }
154
+ }
155
+ async function readJournal(root, snapshot) {
156
+ const data = await readJson(await reviewPath(root, snapshot.reviewId, true));
157
+ if (data.version !== undefined && data.version !== 2)
158
+ fail('unsupported_review_schema', 'This review uses a newer format. Update Prodero.');
159
+ if (data.version === undefined) {
160
+ const legacy = legacySchema.safeParse(data);
161
+ if (!legacy.success || legacy.data.snapshotHash !== snapshot.snapshotHash)
162
+ fail('invalid_review', 'Review decisions are invalid.');
163
+ return { version: 2, projectIdentity: snapshot.projectIdentity, snapshotHash: snapshot.snapshotHash,
164
+ base: { revision: legacy.data.revision, decisions: legacy.data.decisions, paused: false, origin: 'legacy_v1', inheritedFrom: [] }, events: [] };
165
+ }
166
+ const parsed = journalSchema.safeParse(data);
167
+ if (!parsed.success)
168
+ fail('invalid_review', 'Review decision history is invalid.');
169
+ const journal = parsed.data;
170
+ if (journal.snapshotHash !== snapshot.snapshotHash || journal.projectIdentity !== snapshot.projectIdentity)
171
+ fail('invalid_review', 'Review history belongs to another snapshot.');
172
+ return journal;
173
+ }
174
+ async function names(root) {
175
+ const files = await fs.readdir(await checkedDataPath(root, directory)).catch((e) => { if (e.code === 'ENOENT')
176
+ return []; throw e; });
177
+ return files.filter(n => /^review_[a-zA-Z0-9_]+\.json$/.test(n)).sort();
178
+ }
179
+ async function loadStored(root, reviewId) {
180
+ const snapshot = await readJson(await reviewPath(root, reviewId));
181
+ if (snapshot.version !== 1 && snapshot.version !== 2 && snapshot.version !== 3)
182
+ fail('unsupported_review_schema', 'This review uses a newer format. Update Prodero.');
183
+ const { snapshotHash, ...body } = snapshot;
184
+ if (snapshot.reviewId !== reviewId || hash(body) !== snapshotHash || !Number.isFinite(Date.parse(snapshot.createdAt)))
185
+ fail('invalid_review', 'Review contents changed. Analyze knowledge again.');
186
+ const projectIdentity = await fs.realpath(root);
187
+ if (snapshot.projectIdentity !== projectIdentity || snapshot.plan.project !== snapshot.project || await fs.realpath(snapshot.project).catch(() => null) !== projectIdentity)
188
+ fail('project_mismatch', 'Review belongs to another project.');
189
+ try {
190
+ if (!snapshot.revision)
191
+ validateCurationPlan(snapshot.plan);
192
+ }
193
+ catch {
194
+ fail('stale_review', 'Knowledge changed since this review.');
195
+ }
196
+ if (snapshot.version >= 2 && !snapshot.revision)
197
+ fail('invalid_review', 'Missing immutable review revision.');
198
+ if (snapshot.version === 1 && snapshot.revision)
199
+ fail('invalid_review', 'Legacy snapshot cannot contain a revision.');
200
+ if (snapshot.revision) {
201
+ try {
202
+ validateRevision(snapshot.revision);
203
+ }
204
+ catch {
205
+ fail('invalid_review', 'Review revision is invalid or unsupported.');
206
+ }
207
+ const r = snapshot.revision;
208
+ if (r.reviewId !== snapshot.reviewId || r.projectIdentity !== snapshot.projectIdentity || hash(r.typedPlan.plan) !== hash(snapshot.plan) || hash(r.groups) !== hash(snapshot.units))
209
+ fail('invalid_review', 'Review revision binding changed.');
210
+ }
211
+ if (snapshot.planHash !== snapshot.plan.planId || hash(snapshot.units) !== hash(reviewUnits(snapshot.plan)))
212
+ fail('invalid_review', 'Review contents changed.');
213
+ const journal = await readJournal(root, snapshot);
214
+ if (snapshot.revision) {
215
+ const initial = stateFrom(snapshot, { base: journal.base, events: [] });
216
+ const binding = { revision: initial.revision, decisions: initial.decisions, items: initial.items, paused: initial.paused, analysis: initial.analysis };
217
+ if (hash(binding) !== hash(snapshot.revision.decisionBinding))
218
+ fail('invalid_review', 'Review decision baseline changed.');
219
+ }
220
+ return { review: { snapshot, state: stateFrom(snapshot, journal) }, journal };
221
+ }
222
+ async function applyReceiptState(root, review) {
223
+ const receipts = (await listCheckpoints(root)).map(c => c.curation?.review).filter(r => r?.reviewId === review.snapshot.reviewId && r.planHash === review.snapshot.planHash);
224
+ for (const unit of review.snapshot.units)
225
+ if (receipts.some(r => unit.proposalIds.every(id => r.applied.includes(id))))
226
+ review.state.items[unit.id].execution = 'applied';
227
+ }
228
+ export async function loadCurationReview(root, reviewId, options = {}) {
229
+ const { review, journal } = await loadStored(root, reviewId);
230
+ review.state.paused = await legacyPause(root, review.snapshot.plan, journal);
231
+ await applyReceiptState(root, review);
232
+ if (!options.allowExpired && expired(review.snapshot))
233
+ fail('stale_review', 'Knowledge review expired. Review updated changes.');
234
+ return review;
235
+ }
236
+ const expired = (snapshot) => Date.now() - Date.parse(snapshot.createdAt) > REVIEW_RETENTION.maxAgeMs;
237
+ // Pin all decision-bearing history, even after expiry. Refuse at capacity instead
238
+ // of silently removing unresolved decisions. Empty abandoned previews may expire.
239
+ async function pruneForCreation(root, now) {
240
+ const referenced = await (await import('./curation-semantic-jobs.js')).semanticReferencedReviews(root);
241
+ const records = [];
242
+ for (const name of await names(root)) {
243
+ const id = name.slice(0, -5), { review, journal } = await loadStored(root, id);
244
+ const stat = await fs.stat(await reviewPath(root, id));
245
+ const pinned = referenced.has(review.snapshot.reviewId) || !!review.snapshot.revision?.parentRevisionId || journal.events.length > 0 || Object.keys(journal.base.decisions).length > 0 || journal.base.paused;
246
+ records.push({ id: review.snapshot.reviewId, pinned, time: stat.mtimeMs });
247
+ }
248
+ records.sort((a, b) => a.time - b.time || a.id.localeCompare(b.id));
249
+ if (records.filter(r => r.pinned).length >= REVIEW_RETENTION.maxCount)
250
+ fail('review_retention_full', 'Saved review decisions reached the retention limit. No decisions were deleted.');
251
+ let remaining = records.length;
252
+ for (const record of records)
253
+ if (!record.pinned && (remaining >= REVIEW_RETENTION.maxCount || now - record.time > REVIEW_RETENTION.maxAgeMs)) {
254
+ await fs.rm(await reviewPath(root, record.id));
255
+ await fs.rm(await reviewPath(root, record.id, true), { force: true });
256
+ remaining--;
257
+ }
258
+ }
259
+ async function inheritedDecisions(root, plan, units) {
260
+ const decisions = {}, from = [];
261
+ const times = new Map();
262
+ for (const name of await names(root)) {
263
+ const { review, journal } = await loadStored(root, name.slice(0, -5));
264
+ for (const unit of units) {
265
+ const old = review.snapshot.units.find(u => u.id === unit.id);
266
+ if (!old || !sameUnitBasis(review.snapshot.plan, plan, old, unit))
267
+ continue;
268
+ const event = journal.events.filter(e => Object.hasOwn(e.decisions, old.id)).at(-1);
269
+ if (!event && journal.base.origin === 'inherited')
270
+ continue;
271
+ const at = event?.at ?? review.snapshot.createdAt;
272
+ if ((times.get(unit.id) ?? '') > at)
273
+ continue;
274
+ times.set(unit.id, at);
275
+ const decision = review.state.decisions[old.id];
276
+ // Never carry Suggested to a different snapshot. Current overrides defer.
277
+ if (decision === 'current' || decision === 'deferred') {
278
+ decisions[unit.id] = decision;
279
+ from.push(review.snapshot.reviewId);
280
+ }
281
+ else if (decision === 'suggested' || event?.kind === 'reopen_issue')
282
+ delete decisions[unit.id];
283
+ }
284
+ }
285
+ return { decisions, from: [...new Set(from)] };
286
+ }
287
+ async function legacyPause(root, plan, journal) {
288
+ const checkpoint = (await listCheckpoints(root)).filter(c => (c.curationDefer === plan.planId || c.curationDefer === legacyPlanHash(plan))).sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
289
+ const control = journal?.events.filter(e => e.kind === 'pause_review' || e.kind === 'resume_review').at(-1);
290
+ if (control && (!checkpoint || control.at >= checkpoint.createdAt))
291
+ return control.kind === 'pause_review';
292
+ if (journal?.base.pauseUpdatedAt && (!checkpoint || journal.base.pauseUpdatedAt >= checkpoint.createdAt))
293
+ return journal.base.paused;
294
+ return !!checkpoint || !!journal?.base.paused;
295
+ }
296
+ export async function createCurationReview(root, successor, memorySyncSources) {
297
+ const create = async () => {
298
+ if (await fs.stat(await checkedDataPath(root, '.transactions/pending.json')).then(() => true, () => false))
299
+ fail('recovery_required', 'Finish the pending transaction before starting a new review.');
300
+ const baselinePlan = await planKnowledgeCuration(root, { memorySyncSources }), now = Date.now();
301
+ let plan = baselinePlan, carriedSemantic;
302
+ const previousTyped = successor?.parent.snapshot.revision?.typedPlan;
303
+ if (previousTyped?.kind === 'semantic-v2') {
304
+ const catalog = await loadMemoryCatalog(root, { persistCache: false });
305
+ await Promise.all(catalog.summaries.map(s => catalog.load(s.id)));
306
+ const analyses = previousTyped.analyses.filter(a => a.bundle.expectedSources.every(s => catalog.originalHashes.get(s.id) === s.revision) && a.bundle.sources.filter(s => s.editable).every(s => baselinePlan.proposals.some(p => p.id === s.id && p.revision === s.revision)) && a.bundle.evidence.every(e => baselinePlan.proposals.some(p => (p.investigation?.evidence ?? []).some(v => `evidence_${hash([v.path, v.revision, v.line]).slice(0, 24)}` === e.id && v.revision === e.revision))));
307
+ if (analyses.length) {
308
+ plan = materializeSemanticPlan(baselinePlan, analyses);
309
+ carriedSemantic = { kind: 'semantic-v2', validator: 'semantic-1', basePlan: baselinePlan, analyses, plan };
310
+ }
311
+ }
312
+ const units = reviewUnits(plan);
313
+ const inherited = successor ? { decisions: {}, from: [successor.parent.snapshot.reviewId] } : await inheritedDecisions(root, plan, units);
314
+ const invalidatedDecisions = [];
315
+ if (successor)
316
+ for (const [id, decision] of Object.entries(successor.parent.state.decisions)) {
317
+ if (decision === 'suggested')
318
+ continue;
319
+ const old = successor.parent.snapshot.units.find(u => u.id === id), unit = units.find(u => u.id === id);
320
+ if (unit && sameUnitBasis(successor.parent.snapshot.plan, plan, old, unit))
321
+ inherited.decisions[id] = decision;
322
+ else
323
+ invalidatedDecisions.push(id);
324
+ }
325
+ const previous = await latestCurationReview(root, plan.planId);
326
+ const body = { version: carriedSemantic ? 3 : 2, reviewId: successor ? `review_${hash(successor.operationId).slice(0, 40)}` : sortableId('obs').replace(/^obs_/, 'review_'), project: plan.project, projectIdentity: await fs.realpath(root), planHash: plan.planId, createdAt: new Date(now).toISOString(), plan, units };
327
+ let snapshot = JSON.parse(JSON.stringify({ ...body, snapshotHash: hash(body) }));
328
+ const journal = { version: 2, projectIdentity: snapshot.projectIdentity, snapshotHash: snapshot.snapshotHash,
329
+ base: { revision: 0, decisions: inherited.decisions, paused: successor?.parent.state.paused ?? previous?.state.paused ?? await legacyPause(root, plan), origin: inherited.from.length ? 'inherited' : 'new', inheritedFrom: inherited.from }, events: [] };
330
+ journal.base.pauseUpdatedAt = new Date(now).toISOString();
331
+ journal.base.analysis = 'complete';
332
+ journal.base.clarifications = Object.fromEntries(Object.entries(stateFrom(snapshot, journal).items).filter(([, i]) => i.issue).map(([id, i]) => [id, i.issue]));
333
+ const initial = stateFrom(snapshot, journal), catalog = await loadMemoryCatalog(root, { persistCache: false });
334
+ const revisionSources = catalog.summaries.filter(s => !memorySyncSources || memorySyncSources.includes(s.id));
335
+ await Promise.all(revisionSources.map(s => catalog.load(s.id)));
336
+ const revision = sealRevision({ version: carriedSemantic ? 2 : 1, lineageId: successor?.parent.snapshot.revision?.lineageId ?? successor?.parent.snapshot.reviewId ?? snapshot.reviewId,
337
+ parentRevisionId: successor?.parent.snapshot.revision?.id ?? null, parentReviewId: successor?.parent.snapshot.reviewId ?? null, reviewId: snapshot.reviewId, projectIdentity: snapshot.projectIdentity,
338
+ baseline: plan.fingerprint, sources: revisionSources.map(s => ({ id: s.id, revision: catalog.originalHashes.get(s.id) })).sort((a, b) => a.id.localeCompare(b.id)),
339
+ evidence: [...new Map(plan.proposals.flatMap(p => (p.investigation?.evidence ?? []).map(e => [e.path, { path: e.path, revision: e.revision }]))).values()].sort((a, b) => a.path.localeCompare(b.path)),
340
+ typedPlan: carriedSemantic ?? { kind: 'extractive-v1', validator: 'extractive-1', plan }, groups: units, dependencies: deriveDependencies(plan, units, revisionSources.map(s => ({ id: s.id, revision: catalog.originalHashes.get(s.id) }))),
341
+ decisionBinding: { revision: initial.revision, decisions: initial.decisions, items: initial.items, paused: initial.paused, analysis: initial.analysis },
342
+ analyzedSourceIds: [...new Set([...(successor?.parent.snapshot.revision?.analyzedSourceIds ?? []), ...plan.proposals.map(p => p.sourceId)])].sort(), answerBindings: carriedSemantic?.analyses.flatMap(a => a.bundle.answers.map(answer => ({ questionId: answer.questionId, answerEventId: answer.id, basisHash: answer.basisHash }))) ?? [], appliedHistory: [...new Set([...(successor?.parent.snapshot.revision?.appliedHistory ?? []), ...(successor ? successor.parent.snapshot.units.filter(u => u.proposalIds.every(id => successor.applied.includes(id))).map(u => u.id) : [])])], invalidatedDecisions });
343
+ validateRevision(revision);
344
+ const { snapshotHash: discard, ...unsealed } = snapshot;
345
+ snapshot = JSON.parse(JSON.stringify({ ...unsealed, revision, snapshotHash: hash({ ...unsealed, revision }) }));
346
+ journal.snapshotHash = snapshot.snapshotHash;
347
+ if (Buffer.byteLength(JSON.stringify(snapshot)) > REVIEW_RETENTION.maxBytes)
348
+ fail('review_too_large', 'Review is too large. Reduce the review scope first.');
349
+ await ensureDataDirectory(root, directory);
350
+ await pruneForCreation(root, now);
351
+ // Publish the immutable snapshot last. A crash can leave only an unpublished seed.
352
+ await writeFileAtomic(await reviewPath(root, snapshot.reviewId, true), JSON.stringify(journal));
353
+ await writeFileAtomic(await reviewPath(root, snapshot.reviewId), JSON.stringify(snapshot));
354
+ return { snapshot, state: stateFrom(snapshot, journal) };
355
+ };
356
+ return ownsSyncLock(root) ? create() : withSyncLock(root, create, { recoverLifecycle: false, waitMs: 2000 });
357
+ }
358
+ export function selectedCurationProposals(review) {
359
+ if (review.state.analysis !== 'complete' || Object.values(review.state.items).some(i => i.issue?.state === 'answered_pending_analysis'))
360
+ fail('invalid_selection', 'Review analysis is not complete.');
361
+ if (review.state.paused)
362
+ fail('review_paused', 'Resume this review before applying changes.');
363
+ const selected = review.snapshot.units.filter(u => review.state.decisions[u.id] === 'suggested');
364
+ if (selected.some(u => !u.applicable))
365
+ fail('invalid_selection', 'Unresolved knowledge cannot use the suggested version.');
366
+ const ids = selected.flatMap(u => u.proposalIds).sort();
367
+ if (ids.length)
368
+ curationReviewEntries(review.snapshot.plan, ids, review.snapshot.revision);
369
+ return ids;
370
+ }
371
+ export async function stageCurationDecisions(root, input) {
372
+ return stageEvent(root, input, 'decide', input.decisions);
373
+ }
374
+ export async function controlCurationReview(root, input) {
375
+ const kind = input.action === 'pause' ? 'pause_review' : input.action === 'resume' ? 'resume_review' : input.action;
376
+ if ((kind === 'defer_issue' || kind === 'defer_change') && !input.unitId)
377
+ fail('invalid_selection', 'Choose the item to defer.');
378
+ return stageEvent(root, input, kind, input.unitId ? { [input.unitId]: 'deferred' } : {});
379
+ }
380
+ export async function stageEvent(root, input, kind, decisions) {
381
+ const operation = async () => {
382
+ const { review, journal } = await loadStored(root, input.reviewId);
383
+ if (expired(review.snapshot) || review.snapshot.planHash !== input.planHash)
384
+ fail('stale_review', 'Knowledge changed since this review.');
385
+ if (review.state.revision !== input.decisionRevision)
386
+ fail('review_changed', 'Your review changed in another window. Reload your decisions.');
387
+ if (!decisionSchema.safeParse(decisions).success)
388
+ fail('invalid_selection', 'Invalid review decision.');
389
+ for (const [id, choice] of Object.entries(decisions)) {
390
+ const unit = review.snapshot.units.find(u => u.id === id);
391
+ if (!unit || choice === 'suggested' && !unit.applicable)
392
+ fail('invalid_selection', 'This suggestion needs clarification and cannot be applied.');
393
+ if (kind === 'defer_issue' && analysisOutcome(review.snapshot.plan, unit) !== 'unresolved' || kind === 'defer_change' && analysisOutcome(review.snapshot.plan, unit) !== 'proposed')
394
+ fail('invalid_selection', 'This deferral does not match the item.');
395
+ }
396
+ if (journal.events.length >= REVIEW_RETENTION.maxEvents)
397
+ fail('review_retention_full', 'Saved review decisions reached the retention limit. No decisions were deleted.');
398
+ const body = { revision: review.state.revision + 1, at: new Date().toISOString(), kind, decisions, previousHash: journal.events.at(-1)?.hash ?? hash(journal.base) };
399
+ journal.events.push({ ...body, hash: hash(body) });
400
+ const state = stateFrom(review.snapshot, journal), data = JSON.stringify(journal);
401
+ if (Buffer.byteLength(data) > REVIEW_RETENTION.maxBytes)
402
+ fail('review_retention_full', 'Saved review decisions reached the retention limit. No decisions were deleted.');
403
+ // Atomic append of immutable hash-chained history, with no separate mutable head.
404
+ await writeFileAtomic(await reviewPath(root, input.reviewId, true), data);
405
+ return { snapshot: review.snapshot, state };
406
+ };
407
+ return ownsSyncLock(root) ? operation() : withSyncLock(root, operation, { recoverLifecycle: false, waitMs: 2000 });
408
+ }
409
+ export function curationOperationId(reviewId, selectedIds, revisionId, selectionHash) { return `curation_${hash(revisionId ? [reviewId, revisionId, selectionHash, [...selectedIds].sort()] : [reviewId, [...selectedIds].sort()])}`; }
410
+ export function curationApplyBinding(review, selectedIds) {
411
+ const id = review.snapshot.revision?.id;
412
+ return id ? { reviewRevisionId: id, selectionHash: curationSelectionHash(id, review.state.revision, selectedIds) } : {};
413
+ }
414
+ export async function latestCurationReview(root, planHash) {
415
+ if (!ownsSyncLock(root))
416
+ await recoverCurationSuccessors(root);
417
+ const reviews = await Promise.all((await names(root)).map(name => loadCurationReview(root, name.slice(0, -5), { allowExpired: true })));
418
+ const parents = new Set(reviews.map(r => r.snapshot.revision?.parentRevisionId).filter(Boolean));
419
+ return reviews.filter(r => !parents.has(r.snapshot.revision?.id) && (r.snapshot.planHash === planHash || (r.snapshot.revision ? deterministicBaseline(r.snapshot.revision).planId : r.snapshot.planHash) === planHash)).sort((a, b) => b.snapshot.createdAt.localeCompare(a.snapshot.createdAt))[0];
420
+ }
421
+ /** Authoritative saved-state projection, including old pauses and completed receipts. */
422
+ export async function curationReviewView(root, plan, supplied) {
423
+ const candidate = supplied ?? await latestCurationReview(root, plan.planId);
424
+ const stored = candidate ? await loadStored(root, candidate.snapshot.reviewId) : undefined;
425
+ const review = stored?.review;
426
+ plan = review?.snapshot.plan ?? plan;
427
+ const units = review?.snapshot.units ?? reviewUnits(plan);
428
+ const inherited = review ? undefined : await inheritedDecisions(root, plan, units);
429
+ const journal = stored?.journal;
430
+ const state = review ? structuredClone(review.state) : stateFrom({ snapshotHash: '', plan, units }, {
431
+ base: { revision: 0, decisions: inherited.decisions, paused: false, origin: 'inherited', inheritedFrom: inherited.from }, events: [],
432
+ });
433
+ state.paused = await legacyPause(root, plan, journal);
434
+ if (review)
435
+ await applyReceiptState(root, { snapshot: review.snapshot, state });
436
+ const semantic = review ? await (await import('./curation-semantic-jobs.js')).semanticReviewStatus(root, review) : undefined;
437
+ return { review: review ? { ...review, state } : undefined, projection: projectCurationReview(plan, units, state, { semantic, stale: !!review && expired(review.snapshot), revision: review?.snapshot.revision, legacy: review?.snapshot.version === 1 }) };
438
+ }
439
+ /** Receipts remain the sole evidence of execution, including pre-v2 history. */
440
+ export function curationReceiptProjection(plan, receipt) {
441
+ assertSafeCurationData(plan);
442
+ const units = reviewUnits(plan);
443
+ const state = stateFrom({ snapshotHash: '', plan, units }, { base: { revision: 0, decisions: decisionSchema.parse(receipt.decisions), paused: false, origin: 'legacy_v1', inheritedFrom: [] }, events: [] });
444
+ for (const unit of units)
445
+ if (unit.proposalIds.every(id => receipt.applied.includes(id)))
446
+ state.items[unit.id].execution = 'applied';
447
+ return projectCurationReview(plan, units, state, { legacy: true });
448
+ }
449
+ /** A committed receipt owns successor recovery. Reconstruct local review state only;
450
+ * transaction recovery and canonical writes remain exclusively in Apply. */
451
+ export async function ensureCurationSuccessor(root, receipt) {
452
+ if (!receipt.reviewSnapshot?.revision || !receipt.reviewState)
453
+ return;
454
+ validateRevisionReceipt(receipt);
455
+ if (receipt.projectIdentity !== await fs.realpath(root) || receipt.reviewSnapshot.project !== path.resolve(root))
456
+ fail('project_mismatch', 'Receipt belongs to another project.');
457
+ const id = `review_${hash(receipt.operationId).slice(0, 40)}`;
458
+ const existing = await fs.stat(await reviewPath(root, id)).then(() => true, (e) => { if (e.code === 'ENOENT')
459
+ return false; throw e; });
460
+ if (existing) {
461
+ const { review } = await loadStored(root, id);
462
+ if (review.snapshot.revision?.parentRevisionId !== receipt.reviewSnapshot.revision.id)
463
+ fail('invalid_review', 'Successor parent changed.');
464
+ return review;
465
+ }
466
+ return createCurationReview(root, { parent: { snapshot: receipt.reviewSnapshot, state: receipt.reviewState }, operationId: receipt.operationId, applied: receipt.applied });
467
+ }
468
+ export async function recoverCurationSuccessors(root) {
469
+ const pending = [];
470
+ for (const checkpoint of await listCheckpoints(root)) {
471
+ const receipt = checkpoint.curation?.review;
472
+ if (!receipt?.reviewSnapshot?.revision)
473
+ continue;
474
+ validateRevisionReceipt(receipt);
475
+ const id = `review_${hash(receipt.operationId).slice(0, 40)}`;
476
+ if (!await fs.stat(await reviewPath(root, id)).then(() => true, (e) => { if (e.code === 'ENOENT')
477
+ return false; throw e; }))
478
+ pending.push(receipt);
479
+ }
480
+ // Ordinary read-only presentation must not even create lock infrastructure.
481
+ if (!pending.length)
482
+ return;
483
+ const recover = async () => { for (const receipt of pending)
484
+ await ensureCurationSuccessor(root, receipt); };
485
+ return ownsSyncLock(root) ? recover() : withSyncLock(root, recover, { recoverLifecycle: false, waitMs: 2000 });
486
+ }
487
+ export async function resumeCurationReview(root, reviewId) {
488
+ await recoverCurationSuccessors(root);
489
+ let review = await loadCurationReview(root, reviewId, { allowExpired: true });
490
+ const seen = new Set();
491
+ while (true) {
492
+ if (seen.has(review.snapshot.reviewId))
493
+ fail('invalid_review', 'Cyclic review lineage.');
494
+ seen.add(review.snapshot.reviewId);
495
+ let child;
496
+ for (const name of await names(root)) {
497
+ const stored = await loadStored(root, name.slice(0, -5));
498
+ if (stored.review.snapshot.revision?.parentRevisionId === review.snapshot.revision?.id && review.snapshot.revision) {
499
+ if (child)
500
+ fail('invalid_review', 'Forked review lineage.');
501
+ child = stored.review;
502
+ }
503
+ }
504
+ if (!child)
505
+ return review;
506
+ review = child;
507
+ }
508
+ }
509
+ export function validateRevisionReceipt(receipt) {
510
+ assertSafeCurationData(receipt);
511
+ const snapshot = receipt.reviewSnapshot, state = receipt.reviewState;
512
+ if (!snapshot?.revision || !state)
513
+ throw new CurationReviewError('invalid_review', 'Missing execution revision.');
514
+ try {
515
+ validateRevision(snapshot.revision);
516
+ }
517
+ catch {
518
+ fail('invalid_review', 'Invalid execution revision.');
519
+ }
520
+ const { snapshotHash, ...body } = snapshot;
521
+ if (![2, 3].includes(snapshot.version) || state.snapshotHash !== snapshotHash || snapshot.projectIdentity !== receipt.projectIdentity || hash(receipt.applied) !== hash(receipt.selectedIds) || receipt.operationId !== curationOperationId(receipt.reviewId, receipt.selectedIds, receipt.reviewRevisionId, receipt.selectionHash) || hash(body) !== snapshotHash || snapshot.planHash !== receipt.planHash || snapshot.reviewId !== receipt.reviewId || snapshot.revision.id !== receipt.reviewRevisionId || hash(snapshot.revision.typedPlan.plan) !== hash(snapshot.plan) || hash(state.decisions) !== hash(receipt.decisions) || state.revision !== receipt.decisionRevision || receipt.selectionHash !== curationSelectionHash(snapshot.revision.id, state.revision, receipt.selectedIds) || receipt.executionHash !== hash([snapshotHash, state, receipt.selectedIds]))
522
+ fail('invalid_review', 'Execution binding changed.');
523
+ }
524
+ export async function reserveSuccessorCapacity(root) { await pruneForCreation(root, Date.now()); }
525
+ /** Read-only lineage check; semantic tools must never invoke transaction recovery. */
526
+ export async function assertCurationHead(root, review) {
527
+ for (const name of await names(root)) {
528
+ const { review: other } = await loadStored(root, name.slice(0, -5));
529
+ if (other.snapshot.revision?.lineageId !== review.snapshot.revision?.lineageId && other.snapshot.createdAt > review.snapshot.createdAt && other.snapshot.revision && review.snapshot.revision && deterministicBaseline(other.snapshot.revision).planId === deterministicBaseline(review.snapshot.revision).planId)
530
+ fail('stale_review', 'A newer review supersedes this input.');
531
+ if (other.snapshot.revision?.parentRevisionId === review.snapshot.revision?.id && review.snapshot.revision)
532
+ fail('stale_review', 'Review has a successor revision.');
533
+ }
534
+ if ((await listCheckpoints(root)).some(c => c.curation?.review?.reviewRevisionId === review.snapshot.revision?.id && review.snapshot.revision))
535
+ fail('stale_review', 'Review was already applied.');
536
+ }
537
+ /** Publishes only local staged state. Caller owns the writer lock, validates the
538
+ * candidate and expected head. No recovery, grant, transaction or canonical I/O. */
539
+ export async function publishSemanticReview(root, parent, typedPlan, jobId) {
540
+ if (!ownsSyncLock(root))
541
+ fail('invalid_review', 'Semantic publication requires the review lock.');
542
+ const reviewId = `review_${hash(['semantic', jobId]).slice(0, 40)}`;
543
+ if (await fs.stat(await reviewPath(root, reviewId)).then(() => true, () => false))
544
+ return (await loadStored(root, reviewId)).review;
545
+ await assertCurationHead(root, parent);
546
+ const plan = typedPlan.plan, units = reviewUnits(plan), decisions = {};
547
+ for (const unit of units) {
548
+ const old = parent.snapshot.units.find(u => u.id === unit.id);
549
+ const choice = parent.state.decisions[unit.id];
550
+ if (old && choice && choice !== 'suggested' && sameUnitBasis(parent.snapshot.plan, plan, old, unit))
551
+ decisions[unit.id] = choice;
552
+ }
553
+ const body = { version: 3, reviewId, project: parent.snapshot.project, projectIdentity: parent.snapshot.projectIdentity, planHash: plan.planId, createdAt: new Date().toISOString(), plan, units };
554
+ let snapshot = { ...body, snapshotHash: hash(body) };
555
+ const journal = { version: 2, projectIdentity: snapshot.projectIdentity, snapshotHash: snapshot.snapshotHash, base: { revision: 0, decisions, paused: parent.state.paused, origin: 'inherited', inheritedFrom: [parent.snapshot.reviewId], analysis: 'complete' }, events: [] };
556
+ const initial = stateFrom(snapshot, journal);
557
+ const { id: parentRevisionHash, ...parentRevision } = parent.snapshot.revision;
558
+ const revision = sealRevision({ ...parentRevision, version: 2, reviewId, parentReviewId: parent.snapshot.reviewId, parentRevisionId: parent.snapshot.revision.id, typedPlan, groups: units, dependencies: deriveDependencies(plan, units, parent.snapshot.revision.sources),
559
+ decisionBinding: { revision: 0, decisions, items: initial.items, paused: initial.paused, analysis: initial.analysis }, answerBindings: typedPlan.analyses.flatMap(a => a.bundle.answers.map(answer => ({ questionId: answer.questionId, answerEventId: answer.id, basisHash: answer.basisHash }))), invalidatedDecisions: Object.keys(parent.state.decisions).filter(id => parent.state.decisions[id] !== 'suggested' && !decisions[id]) });
560
+ snapshot = { ...body, revision, snapshotHash: '' };
561
+ const { snapshotHash: oldHash, ...sealed } = snapshot;
562
+ snapshot = JSON.parse(JSON.stringify({ ...sealed, snapshotHash: hash(sealed) }));
563
+ journal.snapshotHash = snapshot.snapshotHash;
564
+ validateRevision(snapshot.revision);
565
+ if (Buffer.byteLength(JSON.stringify(snapshot)) > REVIEW_RETENTION.maxBytes)
566
+ fail('review_too_large', 'Review is too large.');
567
+ await pruneForCreation(root, Date.now());
568
+ await ensureDataDirectory(root, directory);
569
+ await writeFileAtomic(await reviewPath(root, reviewId, true), JSON.stringify(journal));
570
+ await writeFileAtomic(await reviewPath(root, reviewId), JSON.stringify(snapshot));
571
+ return { snapshot, state: stateFrom(snapshot, journal) };
572
+ }
573
+ //# sourceMappingURL=curation-review.js.map