@prodero/curator 0.2.1 → 0.3.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 (44) hide show
  1. package/ARCHITECTURE.md +12 -0
  2. package/CHANGELOG.md +20 -0
  3. package/README.md +60 -6
  4. package/dist/chat-text.js +139 -0
  5. package/dist/cli.js +9 -2
  6. package/dist/contracts.js +2 -0
  7. package/dist/conversation-presentation.js +101 -142
  8. package/dist/core/agent-instructions.js +7 -4
  9. package/dist/core/context.js +4 -2
  10. package/dist/core/curation-graphical.js +1 -1
  11. package/dist/core/curation-review.js +61 -17
  12. package/dist/core/curation-revision.js +2 -0
  13. package/dist/core/curation-semantic-jobs.js +2 -2
  14. package/dist/core/json-schema.js +6 -0
  15. package/dist/core/memory-index.js +1 -1
  16. package/dist/core/memory-pressure.js +16 -5
  17. package/dist/core/memory-sync.js +16 -9
  18. package/dist/core/memory-transformation-proposal.js +2 -2
  19. package/dist/core/project.js +18 -4
  20. package/dist/core/session-index.js +2 -2
  21. package/dist/core/session-reconciliation.js +320 -0
  22. package/dist/curation-review-adapter.js +1 -1
  23. package/dist/hosts/install-command.js +130 -0
  24. package/dist/hosts/manager.js +1 -1
  25. package/dist/memory-presentation.js +5 -175
  26. package/dist/presentation-server.js +12 -6
  27. package/dist/presentation-trace.js +3 -2
  28. package/dist/presentation.js +57 -136
  29. package/dist/providers/mcp-sampling.js +6 -6
  30. package/dist/server.js +63 -29
  31. package/dist/status-presentation.js +4 -2
  32. package/dist/text-layout.js +16 -24
  33. package/dist-ui/index.html +140 -128
  34. package/docs/COMPATIBILITY.md +14 -4
  35. package/docs/INSTALL-WITH-AGENT.md +64 -0
  36. package/docs/RELEASING.md +2 -2
  37. package/docs/V0.3.0-CHAT-TEXT.md +75 -0
  38. package/docs/V0.3.0-MAINTENANCE.md +23 -0
  39. package/docs/hosts.md +18 -4
  40. package/instructions/AGENTS.md +6 -4
  41. package/package.json +8 -5
  42. package/plugins/migrate_ai_studio/schema.cjs +1 -2
  43. package/plugins/migrate_ai_studio/ui/dist/index.html +75 -42
  44. package/setup.md +151 -0
@@ -25,7 +25,7 @@ export class CurationReviewError extends Error {
25
25
  }
26
26
  }
27
27
  const fail = (code, message) => { throw new CurationReviewError(code, message); };
28
- const decisionSchema = z.record(z.enum(['current', 'suggested', 'deferred']));
28
+ const decisionSchema = z.record(z.string(), z.enum(['current', 'suggested', 'deferred']));
29
29
  const legacySchema = z.object({ revision: z.number().int().nonnegative(), snapshotHash: z.string(), decisions: decisionSchema }).strict();
30
30
  const eventSchema = z.object({
31
31
  revision: z.number().int().positive(), at: z.string().datetime(),
@@ -34,7 +34,7 @@ const eventSchema = z.object({
34
34
  }).strict();
35
35
  const journalSchema = z.object({
36
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(),
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.string(), z.object({ id: z.string(), basisHash: z.string(), sourceIds: z.array(z.string()), state: clarificationStateSchema }).strict()).optional() }).strict(),
38
38
  events: z.array(eventSchema).max(REVIEW_RETENTION.maxEvents),
39
39
  }).strict();
40
40
  export function reviewUnits(plan) {
@@ -176,6 +176,20 @@ async function names(root) {
176
176
  return []; throw e; });
177
177
  return files.filter(n => /^review_[a-zA-Z0-9_]+\.json$/.test(n)).sort();
178
178
  }
179
+ /** Explicit management diagnostics only. Never repairs, deletes or trusts old choices. */
180
+ export async function curationHistoryDiagnostics(root) {
181
+ const diagnostics = [];
182
+ for (const name of await names(root))
183
+ try {
184
+ await loadStored(root, name.slice(0, -5));
185
+ }
186
+ catch (error) {
187
+ if (!(error instanceof CurationReviewError))
188
+ throw error;
189
+ diagnostics.push({ reviewId: name.slice(0, -5), code: error.code, message: error.message });
190
+ }
191
+ return diagnostics;
192
+ }
179
193
  async function loadStored(root, reviewId) {
180
194
  const snapshot = await readJson(await reviewPath(root, reviewId));
181
195
  if (snapshot.version !== 1 && snapshot.version !== 2 && snapshot.version !== 3)
@@ -236,11 +250,23 @@ export async function loadCurationReview(root, reviewId, options = {}) {
236
250
  const expired = (snapshot) => Date.now() - Date.parse(snapshot.createdAt) > REVIEW_RETENTION.maxAgeMs;
237
251
  // Pin all decision-bearing history, even after expiry. Refuse at capacity instead
238
252
  // of silently removing unresolved decisions. Empty abandoned previews may expire.
239
- async function pruneForCreation(root, now) {
253
+ async function pruneForCreation(root, now, preserveUnavailable = false) {
240
254
  const referenced = await (await import('./curation-semantic-jobs.js')).semanticReferencedReviews(root);
241
255
  const records = [];
242
256
  for (const name of await names(root)) {
243
- const id = name.slice(0, -5), { review, journal } = await loadStored(root, id);
257
+ const id = name.slice(0, -5);
258
+ let stored;
259
+ try {
260
+ stored = await loadStored(root, id);
261
+ }
262
+ catch (error) {
263
+ if (!preserveUnavailable || !(error instanceof CurationReviewError))
264
+ throw error;
265
+ const stat = await fs.stat(await reviewPath(root, id));
266
+ records.push({ id, pinned: true, time: stat.mtimeMs });
267
+ continue;
268
+ }
269
+ const { review, journal } = stored;
244
270
  const stat = await fs.stat(await reviewPath(root, id));
245
271
  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
272
  records.push({ id: review.snapshot.reviewId, pinned, time: stat.mtimeMs });
@@ -293,7 +319,7 @@ async function legacyPause(root, plan, journal) {
293
319
  return journal.base.paused;
294
320
  return !!checkpoint || !!journal?.base.paused;
295
321
  }
296
- export async function createCurationReview(root, successor, memorySyncSources) {
322
+ export async function createCurationReview(root, successor, memorySyncSources, options = {}) {
297
323
  const create = async () => {
298
324
  if (await fs.stat(await checkedDataPath(root, '.transactions/pending.json')).then(() => true, () => false))
299
325
  fail('recovery_required', 'Finish the pending transaction before starting a new review.');
@@ -309,8 +335,9 @@ export async function createCurationReview(root, successor, memorySyncSources) {
309
335
  carriedSemantic = { kind: 'semantic-v2', validator: 'semantic-1', basePlan: baselinePlan, analyses, plan };
310
336
  }
311
337
  }
338
+ const fresh = options.fresh === true || successor?.parent.snapshot.revision?.historyPolicy === 'fresh-current';
312
339
  const units = reviewUnits(plan);
313
- const inherited = successor ? { decisions: {}, from: [successor.parent.snapshot.reviewId] } : await inheritedDecisions(root, plan, units);
340
+ const inherited = successor ? { decisions: {}, from: [successor.parent.snapshot.reviewId] } : fresh ? { decisions: {}, from: [] } : await inheritedDecisions(root, plan, units);
314
341
  const invalidatedDecisions = [];
315
342
  if (successor)
316
343
  for (const [id, decision] of Object.entries(successor.parent.state.decisions)) {
@@ -322,18 +349,18 @@ export async function createCurationReview(root, successor, memorySyncSources) {
322
349
  else
323
350
  invalidatedDecisions.push(id);
324
351
  }
325
- const previous = await latestCurationReview(root, plan.planId);
352
+ const previous = fresh ? undefined : await latestCurationReview(root, plan.planId);
326
353
  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
354
  let snapshot = JSON.parse(JSON.stringify({ ...body, snapshotHash: hash(body) }));
328
355
  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: [] };
356
+ base: { revision: 0, decisions: inherited.decisions, paused: successor?.parent.state.paused ?? previous?.state.paused ?? (fresh ? false : await legacyPause(root, plan)), origin: inherited.from.length ? 'inherited' : 'new', inheritedFrom: inherited.from }, events: [] };
330
357
  journal.base.pauseUpdatedAt = new Date(now).toISOString();
331
358
  journal.base.analysis = 'complete';
332
359
  journal.base.clarifications = Object.fromEntries(Object.entries(stateFrom(snapshot, journal).items).filter(([, i]) => i.issue).map(([id, i]) => [id, i.issue]));
333
360
  const initial = stateFrom(snapshot, journal), catalog = await loadMemoryCatalog(root, { persistCache: false });
334
361
  const revisionSources = catalog.summaries.filter(s => !memorySyncSources || memorySyncSources.includes(s.id));
335
362
  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,
363
+ const revision = sealRevision({ ...(fresh ? { historyPolicy: 'fresh-current' } : {}), version: carriedSemantic ? 2 : 1, lineageId: successor?.parent.snapshot.revision?.lineageId ?? successor?.parent.snapshot.reviewId ?? snapshot.reviewId,
337
364
  parentRevisionId: successor?.parent.snapshot.revision?.id ?? null, parentReviewId: successor?.parent.snapshot.reviewId ?? null, reviewId: snapshot.reviewId, projectIdentity: snapshot.projectIdentity,
338
365
  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
366
  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)),
@@ -347,7 +374,7 @@ export async function createCurationReview(root, successor, memorySyncSources) {
347
374
  if (Buffer.byteLength(JSON.stringify(snapshot)) > REVIEW_RETENTION.maxBytes)
348
375
  fail('review_too_large', 'Review is too large. Reduce the review scope first.');
349
376
  await ensureDataDirectory(root, directory);
350
- await pruneForCreation(root, now);
377
+ await pruneForCreation(root, now, fresh);
351
378
  // Publish the immutable snapshot last. A crash can leave only an unpublished seed.
352
379
  await writeFileAtomic(await reviewPath(root, snapshot.reviewId, true), JSON.stringify(journal));
353
380
  await writeFileAtomic(await reviewPath(root, snapshot.reviewId), JSON.stringify(snapshot));
@@ -419,18 +446,18 @@ export async function latestCurationReview(root, planHash) {
419
446
  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
447
  }
421
448
  /** 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);
449
+ export async function curationReviewView(root, plan, supplied, options = {}) {
450
+ const candidate = supplied ?? (options.fresh ? undefined : await latestCurationReview(root, plan.planId));
424
451
  const stored = candidate ? await loadStored(root, candidate.snapshot.reviewId) : undefined;
425
452
  const review = stored?.review;
426
453
  plan = review?.snapshot.plan ?? plan;
427
454
  const units = review?.snapshot.units ?? reviewUnits(plan);
428
- const inherited = review ? undefined : await inheritedDecisions(root, plan, units);
455
+ const inherited = review ? undefined : options.fresh ? { decisions: {}, from: [] } : await inheritedDecisions(root, plan, units);
429
456
  const journal = stored?.journal;
430
457
  const state = review ? structuredClone(review.state) : stateFrom({ snapshotHash: '', plan, units }, {
431
458
  base: { revision: 0, decisions: inherited.decisions, paused: false, origin: 'inherited', inheritedFrom: inherited.from }, events: [],
432
459
  });
433
- state.paused = await legacyPause(root, plan, journal);
460
+ state.paused = options.fresh && !review ? false : await legacyPause(root, plan, journal);
434
461
  if (review)
435
462
  await applyReceiptState(root, { snapshot: review.snapshot, state });
436
463
  const semantic = review ? await (await import('./curation-semantic-jobs.js')).semanticReviewStatus(root, review) : undefined;
@@ -494,7 +521,15 @@ export async function resumeCurationReview(root, reviewId) {
494
521
  seen.add(review.snapshot.reviewId);
495
522
  let child;
496
523
  for (const name of await names(root)) {
497
- const stored = await loadStored(root, name.slice(0, -5));
524
+ let stored;
525
+ try {
526
+ stored = await loadStored(root, name.slice(0, -5));
527
+ }
528
+ catch (error) {
529
+ if (review.snapshot.revision?.historyPolicy !== 'fresh-current' || !(error instanceof CurationReviewError))
530
+ throw error;
531
+ continue;
532
+ }
498
533
  if (stored.review.snapshot.revision?.parentRevisionId === review.snapshot.revision?.id && review.snapshot.revision) {
499
534
  if (child)
500
535
  fail('invalid_review', 'Forked review lineage.');
@@ -521,11 +556,20 @@ export function validateRevisionReceipt(receipt) {
521
556
  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
557
  fail('invalid_review', 'Execution binding changed.');
523
558
  }
524
- export async function reserveSuccessorCapacity(root) { await pruneForCreation(root, Date.now()); }
559
+ export async function reserveSuccessorCapacity(root, preserveUnavailable = false) { await pruneForCreation(root, Date.now(), preserveUnavailable); }
525
560
  /** Read-only lineage check; semantic tools must never invoke transaction recovery. */
526
561
  export async function assertCurationHead(root, review) {
527
562
  for (const name of await names(root)) {
528
- const { review: other } = await loadStored(root, name.slice(0, -5));
563
+ let stored;
564
+ try {
565
+ stored = await loadStored(root, name.slice(0, -5));
566
+ }
567
+ catch (error) {
568
+ if (review.snapshot.revision?.historyPolicy !== 'fresh-current' || !(error instanceof CurationReviewError))
569
+ throw error;
570
+ continue;
571
+ }
572
+ const { review: other } = stored;
529
573
  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
574
  fail('stale_review', 'A newer review supersedes this input.');
531
575
  if (other.snapshot.revision?.parentRevisionId === review.snapshot.revision?.id && review.snapshot.revision)
@@ -59,6 +59,8 @@ export function selectionProblems(manifest, selectedIds) {
59
59
  export function sealRevision(body) { return { ...body, id: `revision_${revisionHash(body)}` }; }
60
60
  export function validateRevision(revision) {
61
61
  assertSafeCurationData(revision);
62
+ if (revision.historyPolicy !== undefined && revision.historyPolicy !== 'fresh-current')
63
+ throw new Error('Unsupported curation history policy');
62
64
  if (![1, 2].includes(revision.version) || revision.typedPlan.plan.version !== 1 || revision.dependencies.version !== 1 || revision.dependencies.engine !== 'structural-1')
63
65
  throw new Error('Unsupported curation revision validator');
64
66
  const { id, ...body } = revision;
@@ -3,7 +3,7 @@
3
3
  import { assertSafeCurationData } from './curation-evidence.js';
4
4
  import fs from 'node:fs/promises';
5
5
  import { z } from 'zod';
6
- import { zodToJsonSchema } from 'zod-to-json-schema';
6
+ import { jsonSchema } from './json-schema.js';
7
7
  import { checkedDataPath, ensureDataDirectory, writeFileAtomic } from './storage.js';
8
8
  import { withSyncLock, ownsSyncLock } from './sync-transaction.js';
9
9
  import { loadMemoryCatalog } from './memory-index.js';
@@ -160,7 +160,7 @@ async function makeBundle(root, review, componentId, limits) {
160
160
  bundle.id = `bundle_${hash(bundle)}`;
161
161
  return bundle;
162
162
  }
163
- function response(job) { const { bundle, ...input } = job.input; const history = job.events.map(({ proposal, ...event }) => event); return { jobId: job.input.jobId, status: last(job).status, job: { ...job, input, events: history }, task: { instructions: SEMANTIC_INSTRUCTIONS, proposalSchema: zodToJsonSchema(semanticProposalSchema, { $refStrategy: 'none' }), data: bundle }, acceptance: 'staged_candidate_only', validation: last(job).reason, resultReviewId: last(job).resultReviewId }; }
163
+ function response(job) { const { bundle, ...input } = job.input; const history = job.events.map(({ proposal, ...event }) => event); return { jobId: job.input.jobId, status: last(job).status, job: { ...job, input, events: history }, task: { instructions: SEMANTIC_INSTRUCTIONS, proposalSchema: jsonSchema(semanticProposalSchema), data: bundle }, acceptance: 'staged_candidate_only', validation: last(job).reason, resultReviewId: last(job).resultReviewId }; }
164
164
  const passCount = (history, lineageId) => history.filter(j => j.input.lineageId === lineageId && j.input.reason !== 'validation_correction').length;
165
165
  const passLimit = (history, lineageId, limits) => Math.min(limits.maxPasses, ...history.filter(j => j.input.lineageId === lineageId).map(j => j.input.bundle.limits.maxPasses));
166
166
  function boundedRecoveryLimits(previous, requested) {
@@ -0,0 +1,6 @@
1
+ import { z } from 'zod';
2
+ /** Preserve the existing draft-7 input contract, including optional defaults. */
3
+ export function jsonSchema(schema) {
4
+ return z.toJSONSchema(schema, { target: 'draft-7', io: 'input', reused: 'inline' });
5
+ }
6
+ //# sourceMappingURL=json-schema.js.map
@@ -99,7 +99,7 @@ const summarySchema = z.object({
99
99
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid v3 applicability cache entry' });
100
100
  }
101
101
  });
102
- const memoryCacheSchema = z.object({ version: z.literal(10), entries: z.record(summarySchema) });
102
+ const memoryCacheSchema = z.object({ version: z.literal(10), entries: z.record(z.string(), summarySchema) });
103
103
  async function readCache(root, persistCache) {
104
104
  if (persistCache)
105
105
  await ensureDerivedStateRoot(root);
@@ -5,7 +5,7 @@ import { checkedDataPath, ensureDataDirectory, writeFileAtomic, sha256, parseChe
5
5
  import { withSyncLock } from './sync-transaction.js';
6
6
  export const MEMORY_PRESSURE_LIMITS = { sessions: 32, syncs: 32, records: 128, relationships: 128, bytes: 1048576 };
7
7
  export const MEMORY_PRESSURE_PATH = 'local/memory-pressure.json';
8
- const reasons = ['selected', 'marginal-value', 'context-budget', 'required-budget', 'item-limit', 'rank-window', 'inactive', 'superseded', 'scope-mismatch', 'no-match'];
8
+ const reasons = ['selected', 'task-dispute', 'marginal-value', 'context-budget', 'required-budget', 'item-limit', 'rank-window', 'inactive', 'superseded', 'scope-mismatch', 'no-match'];
9
9
  const count = z.number().finite().nonnegative();
10
10
  const id = z.string().max(256);
11
11
  const recordSchema = z.object({
@@ -20,10 +20,19 @@ const selectionSchema = recordSchema.extend({ bucket: z.enum(['required', 'conte
20
20
  const totalsSchema = z.object({ records: count, tokens: count }).strict();
21
21
  const retrievalSchema = z.object({
22
22
  queryHash: z.string().regex(/^[a-f0-9]{64}$/), purpose: z.string().max(16),
23
+ taskContextHash: z.string().regex(/^[a-f0-9]{64}$/).optional(),
23
24
  budgets: z.object({ contextBudgetTokens: count, requiredContextBudgetTokens: count, maxRequiredItems: count, maxContextItems: count }).strict(),
24
- totals: z.record(z.enum(reasons), totalsSchema), requiredTruncated: z.boolean(), requiredOmitted: count,
25
+ totals: z.partialRecord(z.enum(reasons), totalsSchema), requiredTruncated: z.boolean(), requiredOmitted: count,
25
26
  records: z.array(selectionSchema).max(MEMORY_PRESSURE_LIMITS.records), detailsDropped: count,
26
27
  }).strict();
28
+ /** Bind optional retrieval telemetry to the actual latest native task scope. */
29
+ export function retrievalTaskContextHash(taskContext) {
30
+ const value = (taskContext && typeof taskContext === 'object' ? taskContext : {});
31
+ const dimensions = (key) => Array.isArray(value[key]) ? [...new Set(value[key])].sort() : [];
32
+ // Signal provenance is diagnostic; replaying the same normalized scope can
33
+ // legitimately change 'task-text' to 'argument' without changing authority.
34
+ return sha256(JSON.stringify({ taskText: value.taskText ?? '', paths: dimensions('paths'), subsystems: dimensions('subsystems'), taskKinds: dimensions('taskKinds'), documentationOnly: value.documentationOnly ?? false }));
35
+ }
27
36
  /** Called at existing selection/filter branches; never evaluates relevance itself. */
28
37
  export class RetrievalEvidenceCollector {
29
38
  samples = {};
@@ -40,8 +49,10 @@ export class RetrievalEvidenceCollector {
40
49
  sample.push({ ...pressureRecord(item), bucket, reason, score, rankScore, signals: signals.slice(0, 12).map(({ kind, score }) => ({ kind, score })) });
41
50
  }
42
51
  finish(query, purpose, budgets) {
43
- // Retain selected and budget-pressure details before lower-ranked/filter samples.
44
- const records = ['selected', 'required-budget', 'context-budget', 'item-limit', 'marginal-value', 'rank-window', 'scope-mismatch', 'inactive', 'superseded', 'no-match'].flatMap(r => this.samples[r] ?? []).slice(0, MEMORY_PRESSURE_LIMITS.records);
52
+ // Task-bound dispute diagnostics are read-only evidence, never selected
53
+ // context or a license to activate disputed knowledge. Keep their small
54
+ // bounded set before ordinary samples so real conflicts reach the agent.
55
+ const records = ['task-dispute', 'selected', 'required-budget', 'context-budget', 'item-limit', 'marginal-value', 'rank-window', 'scope-mismatch', 'inactive', 'superseded', 'no-match'].flatMap(r => this.samples[r] ?? []).slice(0, MEMORY_PRESSURE_LIMITS.records);
45
56
  return { queryHash: sha256(query.trim()), purpose, budgets, totals: this.totals, requiredTruncated: this.requiredOmitted > 0, requiredOmitted: this.requiredOmitted, records, detailsDropped: Object.values(this.totals).reduce((n, t) => n + (t?.records ?? 0), 0) - records.length };
46
57
  }
47
58
  }
@@ -50,7 +61,7 @@ const relationshipSchema = z.object({ source: id, target: recordSchema, classifi
50
61
  const syncSchema = z.object({ checkpoint: id, sessionIds: z.array(id).max(128), sessionIdsDropped: count,
51
62
  changes: z.array(recordSchema.extend({ actions: z.array(z.enum(['created', 'updated', 'merged', 'superseded'])).max(4) }).strict()).max(MEMORY_PRESSURE_LIMITS.records),
52
63
  totals: z.object({ created: count, updated: count, merged: count, superseded: count }).strict(), changesDropped: count,
53
- reconciliation: z.array(relationshipSchema).max(MEMORY_PRESSURE_LIMITS.relationships), classifications: z.record(z.enum(categories), count), relationshipsDropped: count,
64
+ reconciliation: z.array(relationshipSchema).max(MEMORY_PRESSURE_LIMITS.relationships), classifications: z.partialRecord(z.enum(categories), count), relationshipsDropped: count,
54
65
  }).strict();
55
66
  export class SyncEvidenceCollector {
56
67
  relationships = [];
@@ -25,7 +25,7 @@ export const MEMORY_SYNC_LIMITS = { workflows: 1, candidates: 20, promotions: 50
25
25
  const digest = (v) => sha256(JSON.stringify(v));
26
26
  const id = z.string().regex(/^area_[a-f0-9]{20}$/);
27
27
  const fingerprintSchema = z.object({ knowledge: z.string(), telemetry: z.string(), checkpoints: z.string() }).strict();
28
- const jobSchema = z.object({ id, label: z.string(), signals: z.array(z.string()), recordCount: z.number(), counts: z.record(z.number()).optional(), categories: z.record(z.number()).optional(), sourceIds: z.array(z.string()).max(24), layers: z.object({ local: z.number(), versioned: z.number() }), selected: z.boolean(), status: z.enum(['unselected', 'pending', 'semantic_work', 'awaiting_review', 'awaiting_clarification', 'deferred', 'applied', 'skipped', 'failed']), curationReviewId: z.string().optional(), curationStateHash: z.string().optional(), error: z.string().optional(), unresolved: z.number().optional(), result: z.unknown().optional() }).strict();
28
+ const jobSchema = z.object({ id, label: z.string(), signals: z.array(z.string()), recordCount: z.number(), counts: z.record(z.string(), z.number()).optional(), categories: z.record(z.string(), z.number()).optional(), sourceIds: z.array(z.string()).max(24), layers: z.object({ local: z.number(), versioned: z.number() }), selected: z.boolean(), status: z.enum(['unselected', 'pending', 'semantic_work', 'awaiting_review', 'awaiting_clarification', 'deferred', 'applied', 'skipped', 'failed']), curationReviewId: z.string().optional(), curationStateHash: z.string().optional(), error: z.string().optional(), unresolved: z.number().optional(), result: z.unknown().optional() }).strict();
29
29
  const stateSchema = z.object({ version: z.literal(1), id: z.string().uuid(), root: z.string(), revision: z.number().int(), createdAt: z.string(), state: z.enum(['awaiting_selection', 'semantic_work', 'awaiting_clarification', 'awaiting_review', 'applying', 'awaiting_promotion_selection', 'completed', 'stale']), improveEnabled: z.boolean().optional(), repairLinksEnabled: z.boolean().optional(), brokenLinks: z.number().int().nonnegative().optional(), repairLinksDone: z.boolean().optional(), repairLinksResult: z.object({ fixed: z.number(), remaining: z.number() }).optional(),
30
30
  usage: z.object({ sessions: z.number(), available: z.boolean() }).optional(), baseline: fingerprintSchema, jobs: z.array(jobSchema).max(20), promotionEnabled: z.boolean(), promotionDone: z.boolean(), promotionCandidates: z.array(z.object({ id: z.string(), title: z.string(), score: z.number(), signals: z.array(z.string()), recommended: z.boolean() })).max(50), promotionTotal: z.number(), promotionResult: z.unknown().optional(), recovery: z.object({ sessions: z.number(), created: z.number() }), error: z.string().optional(),
31
31
  active: z.object({ kind: z.enum(['transformation', 'promotion', 'link-repair']), reviewId: z.string(), candidateId: id.optional(), preview: z.unknown(), approved: z.boolean(), unaffectedKnowledge: z.string().optional(), touchedIds: z.array(z.string()).max(150).optional() }).strict().optional(),
@@ -135,7 +135,7 @@ function view(w, clarification, deferred = []) {
135
135
  estimatedTokens: { scope: 'transformed_areas', before: applied.reduce((n, r) => n + r.estimatedTokens.before, 0), after: applied.reduce((n, r) => n + r.estimatedTokens.after, 0) },
136
136
  versionedCreated: applied.reduce((n, r) => n + r.created.filter(c => c.layer === 'versioned').length, 0), promoted: promotion?.published ?? 0, promotionCreated: promotion?.created.length ?? 0, promotionUpdated: promotion?.updated.length ?? 0, promotionCovered: promotion?.covered.length ?? 0, localArchived: applied.reduce((n, r) => n + r.archives.filter(a => a.layer === 'local').length, 0), versionedArchived: applied.reduce((n, r) => n + r.archives.filter(a => a.layer === 'versioned').length, 0), acknowledgedCandidateIds: w.jobs.filter(j => j.status === 'applied').map(j => j.id), promotionConflicts: promotion?.conflicts.length ?? 0, historicalProvenancePreserved: true },
137
137
  coverage: { promotionCandidatesOmitted: Math.max(0, w.promotionTotal - w.promotionCandidates.length) },
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 },
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: 'Legacy technical continuation only. Preserve this exact saved workflow and its unresolved originals; do not restore the removed moderation UI or ask routine housekeeping questions. A technical caller may inspect these bindings on explicit request. Required exact human approval still applies to protected/shared/link-removal/publication operations; never infer it, fabricate answers or approve for the user. Ordinary work uses the bounded current-session reconciliation protocol.' } : { action: w.state },
139
139
  };
140
140
  }
141
141
  async function workflowView(root, w, clarification, deferred) {
@@ -516,20 +516,27 @@ export async function runMemorySync(root, raw) {
516
516
  }
517
517
  /** Open-only diagnostic projection. Never resumes, recovers, migrates or persists a workflow. */
518
518
  export async function getMemorySyncOverview(root) {
519
- const workflow = await load(root);
520
- const pending = workflow && workflow.state !== 'completed' ? workflow : null;
521
- const stale = pending && (pending.state === 'stale' || pending.state !== 'applying' && digest(await fingerprint(root)) !== digest(pending.baseline));
522
- const workflowStatus = pending ? { id: pending.id, state: stale ? 'stale' : pending.state } : null;
519
+ let workflowStatus = null;
523
520
  try {
521
+ const workflow = await load(root);
522
+ const pending = workflow && workflow.state !== 'completed' ? workflow : null;
523
+ const stale = pending && (pending.state === 'stale' || pending.state !== 'applying' && digest(await fingerprint(root)) !== digest(pending.baseline));
524
+ workflowStatus = pending ? { id: pending.id, state: stale ? 'stale' : pending.state, ...(pending.error ? { error: publicDiagnostic(pending.error) } : {}) } : null;
524
525
  const plan = await getMemoryIntelligencePlan(root);
525
526
  return { available: plan.coverage.telemetry !== 'unavailable', sessions: plan.coverage.sessions,
526
527
  areas: plan.candidates.map(c => ({ id: c.id, label: c.label, recordCount: c.recordCount, signals: c.signals, counts: c.counts, layers: c.layers, categories: c.categories })),
527
- areasOmitted: plan.truncated.candidates, workflow: workflowStatus };
528
+ areasOmitted: plan.truncated.candidates, workflow: workflowStatus,
529
+ ...(workflow?.error ? { diagnostic: { code: 'PRODERO_MEMORY_MAINTENANCE_INCOMPLETE', cause: publicDiagnostic(workflow.error), recovery: 'Original knowledge and saved progress are retained. Inspect the saved operation before retrying.' } } : {}) };
528
530
  }
529
- catch {
530
- return { available: false, sessions: 0, areas: [], areasOmitted: 0, workflow: workflowStatus };
531
+ catch (error) {
532
+ return memoryOverviewFailure(error, workflowStatus);
531
533
  }
532
534
  }
535
+ /** Preserve a sanitized failure separately from missing optional usage history. */
536
+ export function memoryOverviewFailure(error, workflow = null) {
537
+ return { available: false, sessions: 0, areas: [], areasOmitted: 0, workflow,
538
+ diagnostic: { code: 'PRODERO_MEMORY_OVERVIEW_UNAVAILABLE', cause: publicDiagnostic(error), recovery: 'Saved knowledge is preserved. Inspect the storage diagnostic before retrying; no maintenance was applied by this read.' } };
539
+ }
533
540
  /** Resolve continuation ownership from persisted review lineage, not a particular App instance. */
534
541
  export async function syncWorkflowForCuration(root, lineageId) {
535
542
  const w = await load(root);
@@ -1,7 +1,7 @@
1
1
  import { applicabilitySchema } from "./applicability.js";
2
2
  /** Semantic proposals only: no writer, lifecycle mutation, recovery or provider. */
3
3
  import { z } from 'zod';
4
- import { zodToJsonSchema } from 'zod-to-json-schema';
4
+ import { jsonSchema } from './json-schema.js';
5
5
  import { buildMemoryTransformationBundle } from './memory-transformation-bundle.js';
6
6
  import { assertPathScope } from './path-scope.js';
7
7
  import { sha256 } from './storage.js';
@@ -38,7 +38,7 @@ For each source explain coverage, explicitly list only information omitted as re
38
38
  export async function prepareMemoryTransformation(root, candidateId) {
39
39
  const bundle = await buildMemoryTransformationBundle(root, candidateId);
40
40
  return { bundle, instructions: MEMORY_TRANSFORMATION_INSTRUCTIONS,
41
- proposalSchema: zodToJsonSchema(memoryTransformationProposalSchema, { $refStrategy: 'none' }),
41
+ proposalSchema: jsonSchema(memoryTransformationProposalSchema),
42
42
  binding: { version: 1, bundleId: bundle.bundleId, candidateId, sourceRevisions: bundle.revisions },
43
43
  canPropose: bundle.readyForSemanticWork, persistence: 'return_only' };
44
44
  }
@@ -5,6 +5,7 @@ import { recordRetrievalEvidence, recordSyncEvidenceLocked } from "./memory-pres
5
5
  import { publicDiagnostic } from "./diagnostics.js";
6
6
  import { explicitProjectRoot } from "./project-root.js";
7
7
  import { reviewSessionKnowledge } from "./session-review.js";
8
+ import { prepareSessionReconciliation } from './session-reconciliation.js';
8
9
  import { assertNoSecrets } from "./secrets.js";
9
10
  import { intelligenceTelemetry } from "./intelligence.js";
10
11
  import { planAssistedSync, observationEvidence, observationKey } from "./knowledge-engine.js";
@@ -432,9 +433,15 @@ export async function prepareProject(start = process.cwd(), options = {}) {
432
433
  onRequiredOmitted: options.onRequiredOmitted,
433
434
  });
434
435
  context.agentUsage = options.agentUsage ?? null;
435
- if (automaticSync.intelligence?.audit.status === "attention" && !context.attention.some((item) => item.kind === "conflict")) {
436
- context.attention.push({ kind: "memory-health", severity: "important", title: "Knowledge review needed", detail: "The affected-area audit left uncertainty or reached its review budget. Original evidence is preserved." });
437
- }
436
+ // Audit uncertainty/budget is deferred maintenance, not a task blocker or an
437
+ // instruction to ask the user to moderate memory. Context still owns actual
438
+ // task-relevant disputes and Required-policy readiness.
439
+ const maintenance = {
440
+ automatic: { sessions: automaticSync.sessions, created: automaticSync.created, consolidated: automaticSync.consolidated, duplicates: automaticSync.duplicates, checkpoint: automaticSync.checkpoint },
441
+ audit: automaticSync.intelligence?.audit,
442
+ unresolvedRetained: automaticSync.intelligence?.audit.status === 'attention',
443
+ userInputRequired: false,
444
+ };
438
445
  let session = options.sessionId ? await readSession(root, options.sessionId) : undefined;
439
446
  if (session?.status === "closed")
440
447
  session = undefined;
@@ -444,9 +451,16 @@ export async function prepareProject(start = process.cwd(), options = {}) {
444
451
  session = await updateSessionTaskContext(root, session.id, context.taskContext);
445
452
  associatePreparedContext(context, session.id);
446
453
  const memoryTelemetry = retrievalEvidence ? await recordRetrievalEvidence(root, session.id, retrievalEvidence) : "unavailable";
454
+ const reconciliation = context.policyReadiness?.blocking || memoryTelemetry !== 'recorded'
455
+ ? { state: 'deferred', reason: context.policyReadiness?.blocking ? 'current-task-policy-not-ready' : 'current-retrieval-evidence-unavailable', humanDecisionRequired: false }
456
+ : await prepareSessionReconciliation(root, { sessionId: session.id }).catch(error => {
457
+ const diagnostic = publicDiagnostic(error);
458
+ context.attention.push({ kind: 'memory-health', severity: 'important', title: 'Memory maintenance could not finish', detail: diagnostic });
459
+ return { state: 'deferred', reason: 'maintenance-storage-failure', diagnostic, humanDecisionRequired: false };
460
+ });
447
461
  const status = await getProjectStatus(root);
448
462
  const uiState = { blocking: context.policyReadiness?.blocking ?? false, view: context.attention.length ? "attention" : "context-ready", attention: context.attention };
449
- return { memoryTelemetry, project: status, session: { id: session.id, path: `.prodero/sessions/${session.path}`, status: session.status }, automaticSync, context, proderoMetrics: context.metrics, agentUsage: context.agentUsage, uiState };
463
+ return { memoryTelemetry, project: status, session: { id: session.id, path: `.prodero/sessions/${session.path}`, status: session.status }, automaticSync, maintenance: { ...maintenance, reconciliation }, context, proderoMetrics: context.metrics, agentUsage: context.agentUsage, uiState };
450
464
  }
451
465
  export async function recordProjectSession(start, sessionId, entries) {
452
466
  const root = await findProjectRoot(start);
@@ -10,8 +10,8 @@ function sameStamp(left, right) { return Boolean(left && left.size === right.siz
10
10
  const stampSchema = z.object({ size: z.number().nonnegative(), mtimeMs: z.number().finite(), ctimeMs: z.number().finite() });
11
11
  const cacheSchema = z.object({
12
12
  version: z.literal(3),
13
- checkpoints: z.record(stampSchema.extend({ id: z.string(), createdAt: z.string(), processedKeys: z.array(z.string()), observationHashes: z.array(z.string()).optional() })),
14
- sessions: z.record(stampSchema.extend({ id: z.string(), parentSessionId: z.string().regex(/^sess_[a-zA-Z0-9_]+$/).optional(), sha256: z.string().regex(/^[a-f0-9]{64}$/), status: z.enum(["active", "open", "closed"]), hasObservations: z.boolean() })),
13
+ checkpoints: z.record(z.string(), stampSchema.extend({ id: z.string(), createdAt: z.string(), processedKeys: z.array(z.string()), observationHashes: z.array(z.string()).optional() })),
14
+ sessions: z.record(z.string(), stampSchema.extend({ id: z.string(), parentSessionId: z.string().regex(/^sess_[a-zA-Z0-9_]+$/).optional(), sha256: z.string().regex(/^[a-f0-9]{64}$/), status: z.enum(["active", "open", "closed"]), hasObservations: z.boolean() })),
15
15
  });
16
16
  async function readCache(root, persistCache) {
17
17
  if (persistCache)