@prodero/curator 0.0.0-stage → 0.2.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 (148) hide show
  1. package/ARCHITECTURE.md +298 -0
  2. package/CHANGELOG.md +33 -0
  3. package/CONTRIBUTING.md +9 -0
  4. package/LICENSE +21 -0
  5. package/README.md +93 -2
  6. package/SECURITY.md +7 -0
  7. package/bin/prodero +7 -0
  8. package/dist/cli.js +266 -0
  9. package/dist/context-instructions.js +2 -0
  10. package/dist/contracts.js +51 -0
  11. package/dist/conversation-presentation.js +297 -0
  12. package/dist/core/agent-instructions.js +52 -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 +117 -0
  24. package/dist/core/curation-question.js +28 -0
  25. package/dist/core/curation-review-presentation.js +4 -0
  26. package/dist/core/curation-review-state.js +62 -0
  27. package/dist/core/curation-review.js +573 -0
  28. package/dist/core/curation-revision.js +106 -0
  29. package/dist/core/curation-semantic-contract.js +242 -0
  30. package/dist/core/curation-semantic-jobs.js +340 -0
  31. package/dist/core/diagnostics.js +17 -0
  32. package/dist/core/format.js +114 -0
  33. package/dist/core/ids.js +6 -0
  34. package/dist/core/imports.js +169 -0
  35. package/dist/core/index.js +93 -0
  36. package/dist/core/instruction-review.js +86 -0
  37. package/dist/core/integrity-files.js +174 -0
  38. package/dist/core/intelligence.js +95 -0
  39. package/dist/core/knowledge-analysis.js +46 -0
  40. package/dist/core/knowledge-authority.js +143 -0
  41. package/dist/core/knowledge-curation.js +316 -0
  42. package/dist/core/knowledge-engine.js +475 -0
  43. package/dist/core/knowledge-graph.js +192 -0
  44. package/dist/core/knowledge-lifecycle.js +212 -0
  45. package/dist/core/knowledge-management.js +113 -0
  46. package/dist/core/knowledge-migration.js +84 -0
  47. package/dist/core/knowledge-policy.js +38 -0
  48. package/dist/core/knowledge-provenance.js +90 -0
  49. package/dist/core/knowledge-write-policy.js +85 -0
  50. package/dist/core/language.js +14 -0
  51. package/dist/core/local-preferences.js +76 -0
  52. package/dist/core/matching.js +6 -0
  53. package/dist/core/memory-index.js +241 -0
  54. package/dist/core/memory-intelligence.js +288 -0
  55. package/dist/core/memory-link-repair.js +70 -0
  56. package/dist/core/memory-operations.js +122 -0
  57. package/dist/core/memory-pressure.js +212 -0
  58. package/dist/core/memory-sync-curator.js +82 -0
  59. package/dist/core/memory-sync.js +558 -0
  60. package/dist/core/memory-transformation-bundle.js +163 -0
  61. package/dist/core/memory-transformation-proposal.js +135 -0
  62. package/dist/core/memory-transformation.js +108 -0
  63. package/dist/core/memory.js +186 -0
  64. package/dist/core/migrations.js +569 -0
  65. package/dist/core/npm-update.js +289 -0
  66. package/dist/core/path-scope.js +27 -0
  67. package/dist/core/plugin-files.js +116 -0
  68. package/dist/core/project-root.js +39 -0
  69. package/dist/core/project.js +634 -0
  70. package/dist/core/promotion.js +279 -0
  71. package/dist/core/reconciliation.js +61 -0
  72. package/dist/core/secrets.js +67 -0
  73. package/dist/core/semantic-safety.js +33 -0
  74. package/dist/core/session-index.js +116 -0
  75. package/dist/core/session-review.js +150 -0
  76. package/dist/core/sessions.js +289 -0
  77. package/dist/core/settings.js +94 -0
  78. package/dist/core/skill-curation.js +239 -0
  79. package/dist/core/skill-polish.js +41 -0
  80. package/dist/core/skills.js +387 -0
  81. package/dist/core/storage.js +462 -0
  82. package/dist/core/sync-transaction.js +290 -0
  83. package/dist/core/telemetry.js +64 -0
  84. package/dist/core/templates.js +53 -0
  85. package/dist/core/types.js +2 -0
  86. package/dist/core/update.js +326 -0
  87. package/dist/curation-review-adapter.js +92 -0
  88. package/dist/diagnostic-boundary.js +55 -0
  89. package/dist/filesystem/permissions.js +39 -0
  90. package/dist/hosts/adapters.js +119 -0
  91. package/dist/hosts/files.js +133 -0
  92. package/dist/hosts/manager.js +150 -0
  93. package/dist/hosts/public-entry.js +47 -0
  94. package/dist/hosts/runtime-command.js +66 -0
  95. package/dist/hosts/runtime.js +139 -0
  96. package/dist/hosts/wizard.js +114 -0
  97. package/dist/knowledge-table.js +38 -0
  98. package/dist/mcp-development.js +69 -0
  99. package/dist/mcp-workspace.js +141 -0
  100. package/dist/memory-presentation.js +182 -0
  101. package/dist/memory-sync-operation.js +88 -0
  102. package/dist/plugins/api.js +2 -0
  103. package/dist/plugins/context.js +20 -0
  104. package/dist/plugins/mcp.js +72 -0
  105. package/dist/plugins/registry.js +106 -0
  106. package/dist/presentation-cards.js +32 -0
  107. package/dist/presentation-server.js +94 -0
  108. package/dist/presentation-trace.js +174 -0
  109. package/dist/presentation.js +443 -0
  110. package/dist/providers/mcp-sampling.js +45 -0
  111. package/dist/server.js +664 -0
  112. package/dist/settings-definition.js +22 -0
  113. package/dist/skill-instructions.js +2 -0
  114. package/dist/status-presentation.js +20 -0
  115. package/dist/text-layout.js +278 -0
  116. package/dist/update-command.js +53 -0
  117. package/dist/update-instructions.js +4 -0
  118. package/dist-ui/index.html +216 -0
  119. package/docs/APPLICABILITY.md +151 -0
  120. package/docs/COMPATIBILITY.md +68 -0
  121. package/docs/CONTEXT-SELECTION-V2.md +61 -0
  122. package/docs/CURATION-APP-REVIEW.md +156 -0
  123. package/docs/CURATION-CLAUSE-REFINEMENT.md +45 -0
  124. package/docs/IMPORTER-CURATION.md +142 -0
  125. package/docs/KNOWLEDGE-AUTHORITY.md +165 -0
  126. package/docs/KNOWLEDGE-SCHEMA-V2.md +150 -0
  127. package/docs/MIGRATIONS.md +103 -0
  128. package/docs/PRIVACY.md +28 -0
  129. package/docs/RECOVERY.md +58 -0
  130. package/docs/RELEASING.md +98 -0
  131. package/docs/hosts.md +152 -0
  132. package/docs/images/prodero-context-ready-cropped.png +0 -0
  133. package/docs/images/prodero-overview-cropped.png +0 -0
  134. package/docs/images/prodero-session-learned-cropped.png +0 -0
  135. package/docs/plugins.md +83 -0
  136. package/docs/ui-themes.md +49 -0
  137. package/instructions/AGENTS.md +39 -0
  138. package/package.json +92 -4
  139. package/plugins/migrate_ai_studio/README.md +101 -0
  140. package/plugins/migrate_ai_studio/index.cjs +396 -0
  141. package/plugins/migrate_ai_studio/instructions/audit.md +25 -0
  142. package/plugins/migrate_ai_studio/instructions/classify.md +41 -0
  143. package/plugins/migrate_ai_studio/instructions/migrate.md +34 -0
  144. package/plugins/migrate_ai_studio/instructions/verify.md +20 -0
  145. package/plugins/migrate_ai_studio/plugin.json +11 -0
  146. package/plugins/migrate_ai_studio/schema.cjs +20 -0
  147. package/plugins/migrate_ai_studio/ui/dist/index.html +77 -0
  148. package/prodero.release.json +5 -0
@@ -0,0 +1,150 @@
1
+ import { reconcileCandidate, reconciliationSignal, actionClassification } from './reconciliation.js';
2
+ import { INTELLIGENCE_LIMITS } from './intelligence.js';
3
+ import fs from 'node:fs/promises';
4
+ import { summarizeSessionMemory } from './telemetry.js';
5
+ import { z } from 'zod';
6
+ import { readSession, renderSession, serializeObservation, validateEntries, OBSERVATION_SECTIONS } from './sessions.js';
7
+ import { exactKnowledge } from './semantic-safety.js';
8
+ import { sessionCandidates, candidateReviewSignals } from './consolidation.js';
9
+ import { discoverSessions } from './session-index.js';
10
+ import { loadMemoryCatalog } from './memory-index.js';
11
+ import { listProjectSkills } from './skills.js';
12
+ import { readConfig, checkedDataPath, sha256, writeFileAtomic } from './storage.js';
13
+ import { withSyncLock, recoverSyncTransaction } from './sync-transaction.js';
14
+ export const sessionReviewMutationSchema = z.object({
15
+ sessionId: z.string().regex(/^sess_[a-zA-Z0-9_]+$/), revision: z.string().regex(/^[a-f0-9]{64}$/),
16
+ action: z.enum(['edit', 'discard', 'discard-all']), id: z.string().max(300).optional(),
17
+ content: z.string().trim().min(1).max(10000).optional(), title: z.string().trim().min(1).max(200).regex(/^[^\r\n]+$/).optional(),
18
+ type: z.enum(['decision', 'lesson', 'discovery', 'state_change', 'global_instruction_candidate', 'skill_candidate']).optional(),
19
+ scope: z.enum(['global', 'module', 'task']).optional(), scopeRef: z.string().max(300).nullable().optional(), scopeKind: z.enum(['path', 'topic']).nullable().optional(),
20
+ }).strict();
21
+ async function reviewLocked(root, sessionId, provider) {
22
+ provider = (await readConfig(root))?.knowledgeEngine?.mode === 'assisted' ? provider : undefined;
23
+ const session = await readSession(root, sessionId);
24
+ const file = await checkedDataPath(root, 'sessions', session.path);
25
+ const revision = sha256(await fs.readFile(file, 'utf8'));
26
+ const discovery = await discoverSessions(root);
27
+ const consolidated = discovery.processedSessionPaths.has(session.path);
28
+ const editable = session.status === 'closed' && !consolidated;
29
+ const catalog = await loadMemoryCatalog(root);
30
+ const skills = consolidated ? await listProjectSkills(root) : [];
31
+ const positions = Object.entries(session.sections).flatMap(([section, rows]) => rows.filter(row => row.trim()).map((_, index) => ({ section, index })));
32
+ const candidates = sessionCandidates(session);
33
+ // Child remember sessions keep their own checkpoints and observation evidence. The
34
+ // parent link is canonical session provenance, never a second learning store.
35
+ const sources = new Set([sessionId, ...discovery.sessions.filter(s => s.parentSessionId === sessionId).map(s => s.id)]);
36
+ const produced = await Promise.all(catalog.summaries.filter(m => m.sourceSessions.some(id => sources.has(id))).map(async (m) => ({ ...await catalog.load(m.id), layer: m.layer })));
37
+ const items = await Promise.all(candidates.map(async (candidate, index) => {
38
+ const position = positions[index];
39
+ const type = Object.entries(OBSERVATION_SECTIONS).find(([, section]) => section === position.section)[0];
40
+ const entry = { type, applicability: candidate.applicability, title: candidate.title, content: candidate.content, scopeHint: candidate.scope,
41
+ ...(candidate.scopeRef ? { scopeRef: candidate.scopeRef } : {}), ...(candidate.scopeKind ? { scopeKind: candidate.scopeKind } : {}),
42
+ importanceHint: candidate.priority, categoryHint: candidate.category, description: candidate.description, triggers: candidate.triggers,
43
+ proposedReason: candidate.proposedReason, supersedes: candidate.supersedes, relations: candidate.relations };
44
+ const related = await Promise.all(catalog.selectRelated({ ...candidate, explicitIds: candidate.supersedes }).map(item => catalog.load(item.id)));
45
+ let signals = candidateReviewSignals(candidate, related);
46
+ if (!consolidated && provider && index < INTELLIGENCE_LIMITS.candidatesPerBatch && related.length && !candidate.skill && !candidate.instructionCandidate) {
47
+ const evidence = { ...candidate, id: `candidate_review_${index}`, kind: 'knowledge', sources: [candidate.derivedFrom], evidence: [{ source: candidate.derivedFrom, quote: candidate.content }] };
48
+ try {
49
+ const action = await reconcileCandidate(evidence, related, provider);
50
+ signals = action.sources.filter(id => id !== evidence.id).map(id => reconciliationSignal(actionClassification(action), related.find(m => m.id === id)));
51
+ }
52
+ catch { /* Optional review retains neutral offline signals; Sync still validates its plan. */ }
53
+ }
54
+ try {
55
+ validateEntries([entry]);
56
+ }
57
+ catch (error) {
58
+ signals.push({ kind: 'validation', detail: error instanceof Error ? error.message : String(error), severity: 'info', requiresReview: true });
59
+ }
60
+ const outputs = consolidated ? [...produced.filter(m => m.derivedFrom.includes(candidate.derivedFrom)), ...related.filter(m => !produced.some(p => p.id === m.id) && exactKnowledge(m.content) === exactKnowledge(candidate.content)).map(m => ({ ...m, layer: catalog.summaries.find(s => s.id === m.id).layer }))] : [];
61
+ return { id: candidate.derivedFrom, type, title: candidate.title, content: candidate.content, applicability: candidate.applicability, scope: candidate.scope, scopeRef: candidate.scopeRef, scopeKind: candidate.scopeKind,
62
+ signals: consolidated ? outputs.filter(m => m.status === 'disputed').map(m => reconciliationSignal('potential_conflict', m)) : signals,
63
+ outputs, skills: skills.filter(s => s.derivedFrom.includes(candidate.derivedFrom)), entry, position };
64
+ }));
65
+ items.sort((a, b) => Number(b.signals.some(s => s.requiresReview)) - Number(a.signals.some(s => s.requiresReview)));
66
+ const represented = new Set(items.flatMap(i => i.outputs.map(o => o.id)));
67
+ const knowledge = await Promise.all(produced.filter(m => !represented.has(m.id)).map(async (m) => ({ ...m,
68
+ signals: [
69
+ ...(m.status === 'disputed' ? [reconciliationSignal('potential_conflict')] : []),
70
+ ...await Promise.all(m.supersedes.filter(id => catalog.summaries.some(s => s.id === id)).map(async (id) => {
71
+ const previous = await catalog.load(id);
72
+ return reconciliationSignal('state_transition', previous);
73
+ })),
74
+ ],
75
+ })));
76
+ knowledge.sort((a, b) => Number(b.signals.some(s => s.requiresReview)) - Number(a.signals.some(s => s.requiresReview)) || a.id.localeCompare(b.id));
77
+ const needsReview = [...items, ...knowledge].filter(i => i.signals.some(s => s.requiresReview)).length;
78
+ return { root, sessionId, revision, status: session.status, editable, consolidated, items, knowledge,
79
+ learned: { ...summarizeSessionMemory(session), syncState: consolidated ? "synchronized" : "pending-auto-sync", synchronization: null },
80
+ summary: { total: items.length + knowledge.length, localKnowledge: knowledge.filter(m => m.layer === 'local').length, versionedKnowledge: knowledge.filter(m => m.layer === 'versioned').length, needsReview, automatic: items.length + knowledge.length - needsReview } };
81
+ }
82
+ export async function reviewSessionKnowledge(root, sessionId, provider) {
83
+ return withSyncLock(root, async () => { await recoverSyncTransaction(root); return reviewLocked(root, sessionId, provider); }, { waitMs: 5000 });
84
+ }
85
+ export async function mutateSessionKnowledge(root, raw, provider) {
86
+ const input = sessionReviewMutationSchema.parse(raw);
87
+ return withSyncLock(root, async () => {
88
+ await recoverSyncTransaction(root);
89
+ const view = await reviewLocked(root, input.sessionId);
90
+ if (!view.editable)
91
+ throw new Error('This session has already synchronized or is still active. Use Knowledge to revise durable items; session history cannot be rewritten.');
92
+ if (view.revision !== input.revision)
93
+ throw new Error('Session knowledge changed. Reload before applying changes. Your draft has not been saved.');
94
+ const session = await readSession(root, input.sessionId);
95
+ if (input.action === 'discard-all')
96
+ for (const section of Object.keys(session.sections))
97
+ session.sections[section] = [];
98
+ else {
99
+ const item = view.items.find(i => i.id === input.id);
100
+ if (!item)
101
+ throw new Error('Session observation was not found. Reload the review.');
102
+ const section = item.position.section;
103
+ if (input.action === 'discard')
104
+ session.sections[section].splice(item.position.index, 1);
105
+ else {
106
+ if (input.content === undefined)
107
+ throw new Error('Editing requires the complete observation content.');
108
+ const entry = { ...item.entry, content: input.content, title: input.title ?? item.title, type: input.type ?? item.type,
109
+ scopeHint: input.scope ?? item.scope, scopeRef: input.scopeRef === undefined ? item.entry.scopeRef : input.scopeRef ?? undefined,
110
+ scopeKind: input.scopeKind === undefined ? item.entry.scopeKind : input.scopeKind ?? undefined };
111
+ if (item.entry.applicability && item.entry.applicability.kind !== 'legacy' && [input.scope, input.scopeRef, input.scopeKind].some(v => v !== undefined) && (entry.scopeHint !== item.scope || entry.scopeRef !== item.entry.scopeRef || entry.scopeKind !== item.entry.scopeKind))
112
+ throw new Error('Structured work boundaries are preserved when editing an observation.');
113
+ if (entry.scopeHint === 'global') {
114
+ entry.scopeRef = undefined;
115
+ entry.scopeKind = undefined;
116
+ }
117
+ if (entry.scopeHint === 'module' && !entry.scopeRef?.trim())
118
+ throw new Error('Module scope requires a scope reference.');
119
+ if (entry.type !== item.type) {
120
+ entry.categoryHint = { decision: 'decision', lesson: 'lesson', discovery: 'state', state_change: 'state', global_instruction_candidate: 'instruction', skill_candidate: 'instruction' }[entry.type];
121
+ if (entry.type === 'global_instruction_candidate') {
122
+ entry.applicability = undefined;
123
+ entry.scopeHint = 'global';
124
+ entry.scopeRef = undefined;
125
+ entry.scopeKind = undefined;
126
+ }
127
+ }
128
+ validateEntries([entry]);
129
+ const id = item.id.split('#')[1];
130
+ if (!/^obs_[a-zA-Z0-9_]+$/.test(id))
131
+ throw new Error('Legacy observation identities are read-only. Record a corrected observation in a new session.');
132
+ const rendered = serializeObservation(id, entry);
133
+ if (OBSERVATION_SECTIONS[entry.type] === section)
134
+ session.sections[section][item.position.index] = rendered;
135
+ else {
136
+ session.sections[section].splice(item.position.index, 1);
137
+ session.sections[OBSERVATION_SECTIONS[entry.type]].push(rendered);
138
+ }
139
+ }
140
+ }
141
+ session.updatedAt = new Date().toISOString();
142
+ const target = await checkedDataPath(root, 'sessions', session.path);
143
+ if (sha256(await fs.readFile(target, 'utf8')) !== view.revision)
144
+ throw new Error('Session changed during review. Reload before saving.');
145
+ await writeFileAtomic(target, renderSession(session, session.sections));
146
+ // Discovery verifies file stamps and content hashes, rebuilding derived state on change.
147
+ return reviewLocked(root, input.sessionId, provider);
148
+ }, { waitMs: 5000 });
149
+ }
150
+ //# sourceMappingURL=session-review.js.map
@@ -0,0 +1,289 @@
1
+ import { writeFileExclusive, CANONICAL_ID_PATTERN } from "./storage.js";
2
+ import { validateApplicability, inferApplicability, buildTaskContext } from "./applicability.js";
3
+ import { assertPathScope } from "./path-scope.js";
4
+ import { assertNoSecrets } from "./secrets.js";
5
+ import { execFile } from "node:child_process";
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { promisify } from "node:util";
9
+ import { frontmatter, parseFrontmatter, sectionBullets } from "./format.js";
10
+ import { skillInputSchema } from "./skills.js";
11
+ import { sortableId } from "./ids.js";
12
+ import { exists, checkedDataPath } from "./storage.js";
13
+ import { writeFileAtomic } from "./storage.js";
14
+ import { withSyncLock } from "./sync-transaction.js";
15
+ import { assertEnglishProse } from "./language.js";
16
+ const exec = promisify(execFile);
17
+ export const SESSION_SECTIONS = [
18
+ "Decisions",
19
+ "Lessons",
20
+ "Discoveries",
21
+ "Project State Changes",
22
+ "Global Instruction Candidates",
23
+ "Skill Candidates",
24
+ ];
25
+ export const OBSERVATION_SECTIONS = {
26
+ decision: "Decisions",
27
+ lesson: "Lessons",
28
+ discovery: "Discoveries",
29
+ state_change: "Project State Changes",
30
+ global_instruction_candidate: "Global Instruction Candidates",
31
+ skill_candidate: "Skill Candidates",
32
+ };
33
+ export class SessionFormatError extends Error {
34
+ constructor(message) { super(`Invalid Prodero session: ${message}`); this.name = "SessionFormatError"; }
35
+ }
36
+ function stringOrNull(value) {
37
+ return typeof value === "string" && value !== "null" ? value : null;
38
+ }
39
+ async function gitValue(root, args) {
40
+ try {
41
+ return (await exec("git", ["-C", root, ...args])).stdout.trim() || null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ export function renderSession(session, sections) {
48
+ assertNoSecrets([session, sections]);
49
+ const body = [`# Prodero Session ${session.id}`];
50
+ for (const heading of SESSION_SECTIONS) {
51
+ body.push(`## ${heading}`);
52
+ const items = sections?.[heading] ?? [];
53
+ body.push(items.length ? items.map((item) => `- ${item.replace(/\r?\n/g, "\n ")}`).join("\n") : "_No durable observations recorded._");
54
+ }
55
+ return frontmatter({
56
+ id: session.id,
57
+ kind: "session-memory",
58
+ ...(session.taskContext ? { task_context: session.taskContext } : {}),
59
+ ...(session.parentSessionId ? { parent_session_id: session.parentSessionId } : {}),
60
+ created_at: session.createdAt,
61
+ updated_at: session.updatedAt,
62
+ status: session.status,
63
+ agent: session.agent,
64
+ branch: session.branch,
65
+ base_commit: session.baseCommit,
66
+ }, body.join("\n\n"));
67
+ }
68
+ export function parseSession(content, sessionPath) {
69
+ assertNoSecrets(content);
70
+ const { metadata, body } = parseFrontmatter(content, sessionPath);
71
+ // v1 checkpoint-ready sessions used date-based IDs and omitted timestamps.
72
+ const legacyCheckpoint = metadata.kind === "session-memory" && metadata.status === "checkpoint-ready" && metadata.version === undefined
73
+ && typeof metadata.id === "string" && /^\d{4}-\d{2}-\d{2}-[a-zA-Z0-9_-]+$/.test(metadata.id) && `${metadata.id}.md` === path.basename(sessionPath);
74
+ const modern = !legacyCheckpoint && (metadata.kind === "session-memory" || path.basename(sessionPath).startsWith("sess_"));
75
+ if (metadata.kind !== undefined && metadata.kind !== "session-memory")
76
+ throw new SessionFormatError(`${sessionPath} has unsupported kind.`);
77
+ if (metadata.version !== undefined)
78
+ throw new SessionFormatError(`${sessionPath} has unsupported version.`);
79
+ if (modern && (typeof metadata.id !== "string" || !/^sess_[a-zA-Z0-9_]+$/.test(metadata.id) || metadata.id !== path.basename(sessionPath, ".md")))
80
+ throw new SessionFormatError(`${sessionPath} id does not match its filename.`);
81
+ if (!legacyCheckpoint && (modern || metadata.status !== undefined) && !["active", "open", "closed"].includes(String(metadata.status)))
82
+ throw new SessionFormatError(`${sessionPath} has unsupported status.`);
83
+ for (const field of ["created_at", "updated_at"])
84
+ if ((modern || metadata[field] !== undefined) && (typeof metadata[field] !== "string" || !Number.isFinite(Date.parse(metadata[field]))))
85
+ throw new SessionFormatError(`${sessionPath} has invalid ${field}.`);
86
+ if (metadata.parent_session_id !== undefined && (typeof metadata.parent_session_id !== "string" || !/^sess_[a-zA-Z0-9_]+$/.test(metadata.parent_session_id) || metadata.parent_session_id === metadata.id))
87
+ throw new SessionFormatError(`${sessionPath} has invalid parent_session_id.`);
88
+ const statelessId = path.basename(sessionPath, path.extname(sessionPath));
89
+ const createdAt = stringOrNull(metadata.created_at) ?? new Date(0).toISOString();
90
+ const rawStatus = stringOrNull(metadata.status);
91
+ const status = rawStatus === "closed" || rawStatus === "active" ? rawStatus : "open";
92
+ return {
93
+ id: stringOrNull(metadata.id) ?? statelessId,
94
+ ...(metadata.parent_session_id ? { parentSessionId: metadata.parent_session_id } : {}),
95
+ ...(metadata.task_context ? { taskContext: metadata.task_context } : {}),
96
+ createdAt,
97
+ updatedAt: stringOrNull(metadata.updated_at) ?? createdAt,
98
+ status,
99
+ agent: stringOrNull(metadata.agent) ?? "unknown",
100
+ branch: stringOrNull(metadata.branch),
101
+ baseCommit: stringOrNull(metadata.base_commit),
102
+ path: path.basename(sessionPath),
103
+ sections: sectionBullets(body, SESSION_SECTIONS),
104
+ };
105
+ }
106
+ function parseSessionStrict(content, sessionPath) {
107
+ const { metadata } = parseFrontmatter(content);
108
+ if (typeof metadata.id !== "string" || !/^sess_[a-zA-Z0-9_]+$/.test(metadata.id))
109
+ throw new SessionFormatError(`${sessionPath} has a missing or invalid id.`);
110
+ if (metadata.status !== "open" && metadata.status !== "active" && metadata.status !== "closed")
111
+ throw new SessionFormatError(`${sessionPath} has an unsupported status.`);
112
+ if (typeof metadata.created_at !== "string" || Number.isNaN(Date.parse(metadata.created_at)))
113
+ throw new SessionFormatError(`${sessionPath} has an invalid created_at.`);
114
+ const session = parseSession(content, sessionPath);
115
+ if (session.id !== path.basename(sessionPath, ".md"))
116
+ throw new SessionFormatError(`${sessionPath} id does not match its filename.`);
117
+ return session;
118
+ }
119
+ export async function listSessions(root) {
120
+ const directory = await checkedDataPath(root, "sessions");
121
+ if (!(await exists(directory)))
122
+ return [];
123
+ const names = (await fs.readdir(directory)).filter((name) => name.endsWith(".md") && !name.startsWith(".")).sort();
124
+ const sessions = [];
125
+ for (const name of names)
126
+ sessions.push(parseSession(await fs.readFile(await checkedDataPath(root, "sessions", name), "utf8"), name));
127
+ return sessions;
128
+ }
129
+ export async function readSession(root, sessionId) {
130
+ const filename = sessionFilename(sessionId);
131
+ const target = await checkedDataPath(root, "sessions", filename);
132
+ if (!(await exists(target)))
133
+ throw new Error(`Prodero session ${sessionId} was not found.`);
134
+ return parseSessionStrict(await fs.readFile(target, "utf8"), filename);
135
+ }
136
+ export async function createSession(root, options = {}) {
137
+ assertNoSecrets(options);
138
+ return withSyncLock(root, () => createSessionLocked(root, options), { waitMs: 5_000 });
139
+ }
140
+ async function createSessionLocked(root, options) {
141
+ if (options.parentSessionId) {
142
+ const parent = await readSession(root, options.parentSessionId);
143
+ if (parent.status === "closed" || parent.parentSessionId)
144
+ throw new Error("Remember provenance requires an active work session.");
145
+ }
146
+ const id = sortableId("sess");
147
+ const now = new Date().toISOString();
148
+ const session = {
149
+ id,
150
+ ...(options.parentSessionId ? { parentSessionId: options.parentSessionId } : {}),
151
+ ...(options.taskContext ? { taskContext: options.taskContext } : {}),
152
+ createdAt: now,
153
+ updatedAt: now,
154
+ status: options.status ?? "active",
155
+ agent: options.agent ?? "coding-agent",
156
+ branch: await gitValue(root, ["branch", "--show-current"]),
157
+ baseCommit: await gitValue(root, ["rev-parse", "HEAD"]),
158
+ };
159
+ const filename = `${id}.md`;
160
+ await writeFileExclusive(await checkedDataPath(root, "sessions", filename), renderSession(session));
161
+ return parseSession(await fs.readFile(await checkedDataPath(root, "sessions", filename), "utf8"), filename);
162
+ }
163
+ function sessionFilename(sessionId) {
164
+ if (!/^sess_[a-zA-Z0-9_]+$/.test(sessionId))
165
+ throw new SessionFormatError("sessionId is malformed.");
166
+ return `${sessionId}.md`;
167
+ }
168
+ function annotationValue(value) {
169
+ return JSON.stringify(value.trim());
170
+ }
171
+ export function serializeObservation(id, entry) {
172
+ const attributes = [`id=${id}`];
173
+ if (entry.title?.trim())
174
+ attributes.push(`title=${annotationValue(entry.title)}`);
175
+ if (entry.applicability)
176
+ attributes.push(`applicability=${annotationValue(JSON.stringify(validateApplicability(entry.applicability)))}`);
177
+ if (entry.scopeKind)
178
+ attributes.push(`scope_kind=${entry.scopeKind}`);
179
+ if (entry.scopeHint)
180
+ attributes.push(`scope=${entry.scopeHint}`);
181
+ if (entry.scopeRef?.trim())
182
+ attributes.push(`scope_ref=${annotationValue(entry.scopeRef)}`);
183
+ if (entry.importanceHint)
184
+ attributes.push(`priority=${entry.importanceHint}`);
185
+ if (entry.categoryHint)
186
+ attributes.push(`category=${entry.categoryHint}`);
187
+ if (entry.description)
188
+ attributes.push(`description=${annotationValue(entry.description)}`);
189
+ if (entry.triggers)
190
+ attributes.push(`triggers=${annotationValue(JSON.stringify(entry.triggers))}`);
191
+ if (entry.proposedReason)
192
+ attributes.push(`proposed_reason=${annotationValue(entry.proposedReason)}`);
193
+ if (entry.supersedes?.length)
194
+ attributes.push(`supersedes=${annotationValue(entry.supersedes.join(","))}`);
195
+ if (entry.relations?.length)
196
+ attributes.push(`relations=${annotationValue(JSON.stringify(entry.relations))}`);
197
+ const content = entry.type === "skill_candidate" ? entry.content.trim() : entry.content.replace(/\s+/g, " ").trim();
198
+ return `[${attributes.join(" ")}] ${content}`;
199
+ }
200
+ export function validateEntries(entries) {
201
+ assertNoSecrets(entries);
202
+ if (entries.length > 100)
203
+ throw new Error("A Prodero session record batch may contain at most 100 observations.");
204
+ for (const entry of entries) {
205
+ if (entry.applicability)
206
+ validateApplicability(entry.applicability);
207
+ if (entry.scopeKind !== undefined && !["path", "topic"].includes(entry.scopeKind))
208
+ throw new Error("Invalid scope kind");
209
+ assertPathScope({ scope: entry.scopeHint ?? "task", scopeRef: entry.scopeRef ?? null, scopeKind: entry.scopeKind });
210
+ if (!(entry.type in OBSERVATION_SECTIONS))
211
+ throw new Error(`Unsupported Prodero observation type: ${String(entry.type)}.`);
212
+ const content = entry.content.trim();
213
+ if (!content)
214
+ throw new Error("Prodero session observations require non-empty content.");
215
+ assertEnglishProse([entry.title, content, entry.description, ...(entry.triggers ?? []), entry.proposedReason], "Prodero session knowledge");
216
+ if (entry.type === "skill_candidate") {
217
+ skillInputSchema.parse({ title: entry.title ?? "Candidate", description: entry.description ?? "Candidate", content, scope: entry.scopeRef ?? "project", triggers: entry.triggers ?? [] });
218
+ if (entry.proposedReason && entry.proposedReason.length > 1_000)
219
+ throw new Error("Skill proposal reasons may not exceed 1,000 characters.");
220
+ }
221
+ if (entry.supersedes?.some((id) => !CANONICAL_ID_PATTERN.test(id)))
222
+ throw new Error("Superseded Prodero memory ids are malformed.");
223
+ if (entry.relations?.some((relation) => !["related_to", "depends_on", "implements"].includes(relation.type) || !CANONICAL_ID_PATTERN.test(relation.target)))
224
+ throw new Error("Prodero memory relationships are malformed.");
225
+ if (content.length > 10_000)
226
+ throw new Error("A Prodero session observation may not exceed 10,000 characters.");
227
+ }
228
+ }
229
+ async function mutateSession(root, sessionId, entries, close) {
230
+ return withSyncLock(root, () => mutateSessionLocked(root, sessionId, entries, close), { waitMs: 5_000 });
231
+ }
232
+ async function mutateSessionLocked(root, sessionId, entries, close) {
233
+ const context = (await readSession(root, sessionId)).taskContext;
234
+ entries = entries.map(entry => ({ ...entry, applicability: entry.applicability ?? (['skill_candidate', 'global_instruction_candidate'].includes(entry.type) ? undefined : entry.taskContext ? inferApplicability(entry.content, buildTaskContext('', entry.taskContext)) : entry.scopeHint ? { kind: 'legacy' } : inferApplicability(entry.content, context)) }));
235
+ validateEntries(entries);
236
+ const filename = sessionFilename(sessionId);
237
+ const target = await checkedDataPath(root, "sessions", filename);
238
+ const session = await readSession(root, sessionId);
239
+ if (session.status === "closed") {
240
+ // Retrying an identical final batch after a lost response or failed Sync must
241
+ // not append fresh observation IDs. Compare the recorded suffix per section.
242
+ const repeatedBatch = SESSION_SECTIONS.every((section) => {
243
+ const expected = entries.filter((entry) => OBSERVATION_SECTIONS[entry.type] === section)
244
+ .map((entry) => serializeObservation("retry", entry));
245
+ const actual = session.sections[section].slice(-expected.length)
246
+ .map((entry) => entry.replace(/^\[id=obs_[a-zA-Z0-9_]+/, "[id=retry"));
247
+ return expected.length === 0 || (actual.length === expected.length && expected.every((entry, index) => entry === actual[index]));
248
+ });
249
+ if (close && repeatedBatch)
250
+ return { session, recorded: [], alreadyClosed: true };
251
+ throw new Error(`Prodero session ${sessionId} is closed and cannot accept new observations.`);
252
+ }
253
+ const recorded = [];
254
+ for (const entry of entries) {
255
+ if (entry.applicability)
256
+ validateApplicability(entry.applicability);
257
+ if (entry.scopeKind !== undefined && !["path", "topic"].includes(entry.scopeKind))
258
+ throw new Error("Invalid scope kind");
259
+ assertPathScope({ scope: entry.scopeHint ?? "task", scopeRef: entry.scopeRef ?? null, scopeKind: entry.scopeKind });
260
+ const id = sortableId("obs");
261
+ session.sections[OBSERVATION_SECTIONS[entry.type]].push(serializeObservation(id, entry));
262
+ recorded.push({ id, type: entry.type, content: entry.content.trim() });
263
+ }
264
+ session.updatedAt = new Date().toISOString();
265
+ if (close)
266
+ session.status = "closed";
267
+ await writeFileAtomic(target, renderSession(session, session.sections));
268
+ return { session: parseSessionStrict(await fs.readFile(target, "utf8"), filename), recorded, alreadyClosed: false };
269
+ }
270
+ export async function recordSessionObservations(root, sessionId, entries) {
271
+ if (!entries.length)
272
+ throw new Error("Prodero session recording requires at least one observation.");
273
+ return mutateSession(root, sessionId, entries, false);
274
+ }
275
+ export async function closeSession(root, sessionId, finalEntries = []) {
276
+ return mutateSession(root, sessionId, finalEntries, true);
277
+ }
278
+ /** A retry explicitly refreshes future observation context, not previously delivered snapshots. */
279
+ export async function updateSessionTaskContext(root, id, context) {
280
+ return withSyncLock(root, async () => {
281
+ const session = await readSession(root, id);
282
+ if (session.status === 'closed')
283
+ throw new Error('Cannot enrich a closed session');
284
+ session.taskContext = context;
285
+ await writeFileAtomic(await checkedDataPath(root, 'sessions', sessionFilename(id)), renderSession(session, session.sections));
286
+ return session;
287
+ });
288
+ }
289
+ //# sourceMappingURL=sessions.js.map
@@ -0,0 +1,94 @@
1
+ import { SETTINGS, settingKeySchema } from '../settings-definition.js';
2
+ import fs from 'node:fs/promises';
3
+ import { presentationConfigSchema } from '../settings-definition.js';
4
+ import { readConfig, writeConfig, projectDataPath, assertSafePath, exists, assertSupportedSchema, assertSupportedKnowledgeSchema } from './storage.js';
5
+ import { withSyncLock } from './sync-transaction.js';
6
+ import { readLocalPreferences, saveLocalPreference } from './local-preferences.js';
7
+ async function supportedConfig(root) {
8
+ const config = await readConfig(root);
9
+ if (config) {
10
+ assertSupportedSchema(config.schemaVersion);
11
+ assertSupportedKnowledgeSchema(config.knowledgeSchemaVersion);
12
+ }
13
+ return config;
14
+ }
15
+ export function effectiveSettings(config, local = {}) {
16
+ const legacy = { 'presentation.mode': config?.presentation?.mode, 'presentation.theme': config?.presentation?.theme, 'context.max_tokens': config?.context?.max_tokens ?? config?.context?.budgetTokens };
17
+ const overrides = { 'presentation.mode': local.mode, 'presentation.theme': local.theme, 'context.max_tokens': undefined };
18
+ const values = { ...legacy, 'presentation.mode': local.mode ?? legacy['presentation.mode'], 'presentation.theme': local.theme ?? legacy['presentation.theme'] };
19
+ return SETTINGS.map(def => {
20
+ const value = values[def.key] ?? def.defaultValue;
21
+ const option = 'options' in def ? def.options.find(o => o.id === value) : undefined;
22
+ return { key: def.key, group: def.group, label: def.label, description: option && 'description' in option ? option.description : def.description, type: def.type, value,
23
+ displayValue: option?.label ?? (typeof value === 'number' ? `${value.toLocaleString('en-US')} ${'unit' in def ? def.unit : ''}`.trim() : String(value)),
24
+ source: overrides[def.key] !== undefined ? 'local override' : legacy[def.key] === undefined ? 'default' : def.group === 'Presentation' ? 'legacy project default' : 'project override',
25
+ defaultValue: def.defaultValue, ...('options' in def ? { options: def.options } : {}), ...('min' in def ? { min: def.min, max: def.max } : {}) };
26
+ });
27
+ }
28
+ export async function projectSettings(root) {
29
+ let config;
30
+ try {
31
+ config = await supportedConfig(root);
32
+ }
33
+ catch (error) {
34
+ // Presentation preferences are metadata, never a permission to read/write newer knowledge.
35
+ const marker = projectDataPath(root, '.transactions/migration.json');
36
+ await assertSafePath(marker);
37
+ if (!String(error).includes('SCHEMA_NEWER') && !await exists(marker))
38
+ throw error;
39
+ const file = projectDataPath(root, 'config.json');
40
+ await assertSafePath(file);
41
+ let raw;
42
+ try {
43
+ raw = JSON.parse(await fs.readFile(file, 'utf8'));
44
+ }
45
+ catch {
46
+ raw = {};
47
+ }
48
+ const presentation = presentationConfigSchema.safeParse(raw?.presentation);
49
+ config = { presentation: presentation.success ? presentation.data : undefined };
50
+ }
51
+ const local = await readLocalPreferences(root), settings = effectiveSettings(config, local.presentation);
52
+ return { root, initialized: config !== null, settings, presentation: { mode: local.presentation.mode ?? config?.presentation?.mode ?? SETTINGS[0].defaultValue, theme: local.presentation.theme ?? config?.presentation?.theme ?? SETTINGS[1].defaultValue },
53
+ presentationSources: { mode: settings[0].source, theme: settings[1].source }, diagnostics: local.diagnostics };
54
+ }
55
+ export function renderSettings(view) {
56
+ const lines = ['Prodero · Project Settings'];
57
+ for (const group of [...new Set(view.settings.map(s => s.group))]) {
58
+ lines.push(group);
59
+ for (const s of view.settings.filter(s => s.group === group))
60
+ lines.push(`${s.label}: ${s.displayValue} (${s.source})`, s.description);
61
+ }
62
+ lines.push('Display mode and theme are saved on this checkout. Context budget is shared project configuration.');
63
+ for (const diagnostic of view.diagnostics)
64
+ lines.push(`${diagnostic.code}: ${diagnostic.cause}`, diagnostic.recovery);
65
+ if (!view.initialized)
66
+ lines.push('Project is not initialized. Defaults are shown; initialize Prodero before saving settings.');
67
+ return lines.join('\n\n');
68
+ }
69
+ export async function setProjectSetting(root, key, value, options = {}) {
70
+ const name = settingKeySchema.parse(key), def = SETTINGS.find(s => s.key === name);
71
+ const reset = options.reset === true;
72
+ if (reset && (value !== undefined || name === 'context.max_tokens'))
73
+ throw new Error('Reset requires a presentation key and no value.');
74
+ const parsed = reset ? undefined : def.validation.parse(value);
75
+ if (!await supportedConfig(root))
76
+ throw new Error('Initialize Prodero before changing project settings.');
77
+ return withSyncLock(root, async () => {
78
+ const config = await supportedConfig(root);
79
+ if (!config)
80
+ throw new Error('Initialize Prodero before changing project settings.');
81
+ switch (name) {
82
+ case 'presentation.mode':
83
+ case 'presentation.theme':
84
+ await saveLocalPreference(root, name === 'presentation.mode' ? 'mode' : 'theme', parsed, reset);
85
+ return projectSettings(root);
86
+ case 'context.max_tokens':
87
+ config.context = { ...config.context, max_tokens: parsed };
88
+ break;
89
+ }
90
+ await writeConfig(root, config);
91
+ return projectSettings(root);
92
+ }, { waitMs: 5000, ...(name === 'context.max_tokens' ? {} : { recoverLifecycle: false, maintainIntegration: false }) });
93
+ }
94
+ //# sourceMappingURL=settings.js.map