fluffy-context 0.7.6 → 0.8.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 (36) hide show
  1. package/README.md +115 -36
  2. package/dist/src/agent/api.d.ts +49 -4
  3. package/dist/src/agent/api.js +183 -131
  4. package/dist/src/agent/index.d.ts +7 -1
  5. package/dist/src/agent/index.js +1 -0
  6. package/dist/src/cli/main.js +301 -15
  7. package/dist/src/cognition/evidence-graph.d.ts +4 -0
  8. package/dist/src/cognition/evidence-graph.js +187 -0
  9. package/dist/src/cognition/evolution.d.ts +36 -0
  10. package/dist/src/cognition/evolution.js +221 -0
  11. package/dist/src/cognition/journal.js +143 -2
  12. package/dist/src/cognition/projections.js +124 -2
  13. package/dist/src/cognition/recipes.d.ts +25 -0
  14. package/dist/src/cognition/recipes.js +149 -0
  15. package/dist/src/cognition/skills.d.ts +45 -0
  16. package/dist/src/cognition/skills.js +184 -0
  17. package/dist/src/cognition/types.d.ts +196 -2
  18. package/dist/src/cognition/usage-report.d.ts +40 -0
  19. package/dist/src/cognition/usage-report.js +232 -0
  20. package/dist/src/cognition/usage.d.ts +5 -0
  21. package/dist/src/cognition/usage.js +48 -0
  22. package/dist/src/compiler/compile.js +73 -4
  23. package/dist/src/compiler/graph.d.ts +7 -0
  24. package/dist/src/compiler/graph.js +117 -0
  25. package/dist/src/compiler/identity.d.ts +1 -1
  26. package/dist/src/compiler/identity.js +3 -1
  27. package/dist/src/compiler/sources.d.ts +3 -1
  28. package/dist/src/compiler/sources.js +11 -2
  29. package/dist/src/compiler/types.d.ts +23 -2
  30. package/dist/src/mcp/server.js +228 -6
  31. package/dist/src/storage/layout.d.ts +5 -0
  32. package/dist/src/storage/layout.js +15 -0
  33. package/dist/src/version.d.ts +1 -1
  34. package/dist/src/version.js +1 -1
  35. package/package.json +1 -1
  36. package/skills/fluffy-context/SKILL.md +53 -5
@@ -0,0 +1,36 @@
1
+ import type { EvolutionArtifactKind, EvolutionArtifactStatus, EvolutionLineageReference, EvolutionProposalState } from './types.js';
2
+ type ProposalTargetKind = Extract<EvolutionArtifactKind, 'skill' | 'compiler-recipe'>;
3
+ export interface ProposeEvolutionInput {
4
+ eventIds?: string[];
5
+ recentLimit?: number;
6
+ targetKind?: ProposalTargetKind;
7
+ }
8
+ export interface ProposeEvolutionResult {
9
+ experienceId: string;
10
+ experienceEventId: string;
11
+ proposalId: string | null;
12
+ proposalEventId: string | null;
13
+ recorded: boolean;
14
+ }
15
+ export interface AcceptEvolutionCandidateInput {
16
+ proposalId: string;
17
+ rationale: string;
18
+ }
19
+ export interface TransitionEvolutionProposalInput {
20
+ proposalId: string;
21
+ to: Extract<EvolutionArtifactStatus, 'verified' | 'rejected' | 'deprecated' | 'superseded'>;
22
+ rationale: string;
23
+ supersedes?: EvolutionLineageReference;
24
+ }
25
+ export declare function proposeEvolution(startPath: string | undefined, input?: ProposeEvolutionInput): Promise<ProposeEvolutionResult>;
26
+ export declare function listEvolutionProposals(startPath?: string): Promise<EvolutionProposalState[]>;
27
+ export declare function inspectEvolutionProposal(startPath: string | undefined, proposalId: string): Promise<EvolutionProposalState>;
28
+ export declare function acceptEvolutionCandidate(startPath: string | undefined, input: AcceptEvolutionCandidateInput): Promise<{
29
+ accepted: boolean;
30
+ eventId: string;
31
+ }>;
32
+ export declare function transitionEvolutionProposal(startPath: string | undefined, input: TransitionEvolutionProposalInput): Promise<{
33
+ transitioned: boolean;
34
+ eventId: string;
35
+ }>;
36
+ export {};
@@ -0,0 +1,221 @@
1
+ import crypto from 'node:crypto';
2
+ import { readGitWorktreeState } from '../git/git-adapter.js';
3
+ import { resolveProjectRoot } from '../project/project-resolver.js';
4
+ import { appendEvent, readJournal } from './journal.js';
5
+ import { v2Enabled } from './orient.js';
6
+ import { projectEvents, rebuildProjections } from './projections.js';
7
+ const extractor = { id: 'deterministic-maintainer', version: '1' };
8
+ const maxEvidenceEvents = 32;
9
+ const allowedEvidenceTypes = new Set([
10
+ 'host.prompt.submitted',
11
+ 'context.checkpoint.imported', 'context.state.recorded',
12
+ 'note.recorded', 'note.resolved', 'note.absorbed',
13
+ 'git.state.observed', 'git.branch.changed', 'git.head.changed',
14
+ 'workspace.paths.changed', 'record.used', 'usage.operation.completed',
15
+ ]);
16
+ function canonical(value) {
17
+ if (value === null || typeof value !== 'object')
18
+ return JSON.stringify(value);
19
+ if (Array.isArray(value))
20
+ return `[${value.map(canonical).join(',')}]`;
21
+ const record = value;
22
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(',')}}`;
23
+ }
24
+ function hash(value) {
25
+ return crypto.createHash('sha256').update(canonical(value)).digest('hex');
26
+ }
27
+ function anchor(event) {
28
+ return { worktreeId: event.git.worktreeId, branch: event.git.branch, ref: event.git.ref, head: event.git.head };
29
+ }
30
+ function currentAnchor(state) {
31
+ return { worktreeId: state.worktreeId, branch: state.branch, ref: state.ref, head: state.commit };
32
+ }
33
+ function requireReason(reason) {
34
+ const value = reason.trim();
35
+ if (!value)
36
+ throw new Error('evolution rationale must not be empty');
37
+ return value;
38
+ }
39
+ function selectEvidence(events, input) {
40
+ const selected = input.eventIds
41
+ ? input.eventIds.map((eventId) => {
42
+ if (!eventId.trim())
43
+ throw new Error('evidence event ID must not be empty');
44
+ return eventId;
45
+ })
46
+ : events.filter((event) => allowedEvidenceTypes.has(event.type)).slice(-(input.recentLimit ?? maxEvidenceEvents)).map((event) => event.eventId);
47
+ if (selected.length === 0)
48
+ throw new Error('evolution extraction requires evidence events');
49
+ if (selected.length > maxEvidenceEvents)
50
+ throw new Error(`evolution extraction supports at most ${maxEvidenceEvents} evidence events`);
51
+ if (new Set(selected).size !== selected.length)
52
+ throw new Error('evolution evidence event IDs must be unique');
53
+ const byId = new Map(events.map((event) => [event.eventId, event]));
54
+ const evidence = selected.map((eventId) => byId.get(eventId));
55
+ if (evidence.some((event) => !event))
56
+ throw new Error('evolution evidence event was not found');
57
+ if (evidence.some((event) => !allowedEvidenceTypes.has(event.type)))
58
+ throw new Error('evolution evidence event type is not allowed');
59
+ return evidence;
60
+ }
61
+ function stringArray(value) {
62
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
63
+ }
64
+ function sourceReferences(events) {
65
+ const contextIds = new Set();
66
+ const snapshotIds = new Set();
67
+ const paths = new Set();
68
+ for (const event of events) {
69
+ const payload = event.payload;
70
+ const record = payload.record;
71
+ if (record && typeof record === 'object' && !Array.isArray(record)) {
72
+ const item = record;
73
+ const snapshot = item.snapshot;
74
+ if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) {
75
+ const value = snapshot;
76
+ if (typeof value.contextId === 'string')
77
+ contextIds.add(value.contextId);
78
+ if (typeof value.snapshotId === 'string')
79
+ snapshotIds.add(value.snapshotId);
80
+ }
81
+ if (typeof item.contextId === 'string')
82
+ contextIds.add(item.contextId);
83
+ if (typeof item.snapshotId === 'string')
84
+ snapshotIds.add(item.snapshotId);
85
+ }
86
+ for (const value of stringArray(payload.paths))
87
+ paths.add(value.replaceAll('\\', '/'));
88
+ }
89
+ return { contextIds: [...contextIds].sort(), snapshotIds: [...snapshotIds].sort(), paths: [...paths].sort() };
90
+ }
91
+ function privacy(events) {
92
+ const redactionCount = events.reduce((total, event) => total + event.privacy.redactionCount, 0);
93
+ return { classification: events.some((event) => event.privacy.classification === 'redacted-text') ? 'redacted-text' : 'metadata', redactionCount };
94
+ }
95
+ function applicability(event) {
96
+ if (event.git.ref === null && event.git.branch === null)
97
+ return { project: true, branchMode: 'global' };
98
+ return { project: true, branchMode: 'exact', branches: [event.git.ref ?? event.git.branch ?? 'detached'], sourceCommit: event.git.head };
99
+ }
100
+ function candidateEligible(events) {
101
+ const hasContext = events.some((event) => event.type === 'context.checkpoint.imported' || event.type === 'context.state.recorded');
102
+ const hasActivity = events.some((event) => event.type.startsWith('git.') || event.type === 'workspace.paths.changed');
103
+ const hasUse = events.some((event) => event.type === 'record.used');
104
+ return hasContext && hasActivity && hasUse;
105
+ }
106
+ async function enabledProject(startPath) {
107
+ const projectRoot = await resolveProjectRoot(startPath);
108
+ if (!await v2Enabled(projectRoot))
109
+ throw new Error('evolution requires an enabled v2 runtime');
110
+ return projectRoot;
111
+ }
112
+ export async function proposeEvolution(startPath, input = {}) {
113
+ const projectRoot = await enabledProject(startPath);
114
+ const events = await readJournal(projectRoot);
115
+ const evidence = selectEvidence(events, input);
116
+ const references = sourceReferences(evidence);
117
+ const sourceEventIds = evidence.map((event) => event.eventId);
118
+ const last = evidence.at(-1);
119
+ const experienceBasis = { sourceEventIds, sourceContextIds: references.contextIds, sourceSnapshotIds: references.snapshotIds, paths: references.paths, git: anchor(last), privacy: privacy(evidence), extractor, createdAt: last.occurredAt };
120
+ const experience = {
121
+ experienceId: `exp-${hash(experienceBasis).slice(0, 20)}`,
122
+ schemaVersion: 1,
123
+ ...experienceBasis,
124
+ contentHash: hash(experienceBasis),
125
+ summary: `Metadata-only experience from ${sourceEventIds.length} journal events.`,
126
+ };
127
+ const experienceResult = await appendEvent(projectRoot, {
128
+ type: 'experience.recorded', source: { kind: 'agent' }, occurredAt: experience.createdAt,
129
+ idempotencyKey: `experience:${experience.experienceId}`, git: experience.git,
130
+ privacy: experience.privacy, payload: { record: experience },
131
+ });
132
+ let proposalResult = null;
133
+ let proposal = null;
134
+ if (candidateEligible(evidence)) {
135
+ const targetKind = input.targetKind ?? 'skill';
136
+ const proposalBasis = { sourceExperienceIds: [experience.experienceId], evidence: sourceEventIds.map((eventId) => ({ eventId, relation: 'derived_from' })), applicability: applicability(last), targetKind, extractor, createdAt: experience.createdAt };
137
+ const contentHash = hash(proposalBasis);
138
+ proposal = {
139
+ proposalId: `proposal-${contentHash.slice(0, 20)}`,
140
+ schemaVersion: 1,
141
+ targetKind,
142
+ targetId: `${targetKind}-${contentHash.slice(0, 20)}`,
143
+ status: 'candidate',
144
+ sourceExperienceIds: proposalBasis.sourceExperienceIds,
145
+ evidence: proposalBasis.evidence,
146
+ lineage: [],
147
+ applicability: proposalBasis.applicability,
148
+ contentHash,
149
+ summary: `Candidate ${targetKind} derived from metadata-only experience ${experience.experienceId}.`,
150
+ extractor,
151
+ createdAt: experience.createdAt,
152
+ };
153
+ proposalResult = await appendEvent(projectRoot, {
154
+ type: 'evolution.proposed', source: { kind: 'agent' }, occurredAt: proposal.createdAt,
155
+ idempotencyKey: `evolution-proposal:${proposal.proposalId}`, git: experience.git,
156
+ privacy: experience.privacy, payload: { record: proposal },
157
+ });
158
+ }
159
+ if (experienceResult.appended || proposalResult?.appended)
160
+ await rebuildProjections(projectRoot);
161
+ return { experienceId: experience.experienceId, experienceEventId: experienceResult.event.eventId, proposalId: proposal?.proposalId ?? null, proposalEventId: proposalResult?.event.eventId ?? null, recorded: experienceResult.appended || Boolean(proposalResult?.appended) };
162
+ }
163
+ export async function listEvolutionProposals(startPath) {
164
+ const projectRoot = await enabledProject(startPath);
165
+ return Object.values(projectEvents(await readJournal(projectRoot)).proposals).sort((left, right) => left.proposal.proposalId.localeCompare(right.proposal.proposalId));
166
+ }
167
+ export async function inspectEvolutionProposal(startPath, proposalId) {
168
+ const proposal = (await listEvolutionProposals(startPath)).find((item) => item.proposal.proposalId === proposalId);
169
+ if (!proposal)
170
+ throw new Error(`evolution proposal not found: ${proposalId}`);
171
+ return proposal;
172
+ }
173
+ export async function acceptEvolutionCandidate(startPath, input) {
174
+ const projectRoot = await enabledProject(startPath);
175
+ const rationale = requireReason(input.rationale);
176
+ const state = await inspectEvolutionProposal(projectRoot, input.proposalId);
177
+ if (state.proposal.targetKind !== 'skill' && state.proposal.targetKind !== 'compiler-recipe')
178
+ throw new Error('only skill and compiler-recipe proposals can be accepted in Phase 1');
179
+ if (state.status !== 'candidate')
180
+ throw new Error(`candidate acceptance requires a candidate proposal: ${input.proposalId}`);
181
+ if (state.acceptedArtifact)
182
+ throw new Error(`candidate artifact already accepted: ${input.proposalId}`);
183
+ const git = await readGitWorktreeState(projectRoot);
184
+ const artifact = {
185
+ artifactId: state.proposal.targetId,
186
+ schemaVersion: 1,
187
+ proposalId: state.proposal.proposalId,
188
+ kind: state.proposal.targetKind,
189
+ status: 'candidate',
190
+ applicability: state.proposal.applicability,
191
+ contentHash: state.proposal.contentHash,
192
+ lineage: state.proposal.lineage,
193
+ rationale,
194
+ createdAt: new Date().toISOString(),
195
+ };
196
+ const result = await appendEvent(projectRoot, {
197
+ type: 'evolution.candidate.accepted', source: { kind: 'cli' }, idempotencyKey: `evolution-accept:${artifact.artifactId}:${hash(rationale).slice(0, 16)}`,
198
+ git: currentAnchor(git), payload: { record: artifact },
199
+ });
200
+ if (result.appended)
201
+ await rebuildProjections(projectRoot);
202
+ return { accepted: result.appended, eventId: result.event.eventId };
203
+ }
204
+ export async function transitionEvolutionProposal(startPath, input) {
205
+ const projectRoot = await enabledProject(startPath);
206
+ const rationale = requireReason(input.rationale);
207
+ const state = await inspectEvolutionProposal(projectRoot, input.proposalId);
208
+ if (input.to === 'superseded' && !input.supersedes)
209
+ throw new Error('superseding an evolution proposal requires lineage');
210
+ const git = await readGitWorktreeState(projectRoot);
211
+ const result = await appendEvent(projectRoot, {
212
+ type: `evolution.${input.to}`,
213
+ source: { kind: 'cli' },
214
+ idempotencyKey: `evolution-transition:${input.proposalId}:${state.status}:${input.to}:${hash({ rationale, supersedes: input.supersedes }).slice(0, 16)}`,
215
+ git: currentAnchor(git),
216
+ payload: { transition: { entity: 'proposal', entityId: input.proposalId, from: state.status, to: input.to, reason: rationale, ...(input.supersedes ? { supersedes: input.supersedes } : {}) } },
217
+ });
218
+ if (result.appended)
219
+ await rebuildProjections(projectRoot);
220
+ return { transitioned: result.appended, eventId: result.event.eventId };
221
+ }
@@ -10,10 +10,14 @@ const eventTypes = new Set([
10
10
  'git.state.observed', 'git.branch.changed', 'git.head.changed', 'workspace.paths.changed',
11
11
  'context.checkpoint.imported', 'context.state.recorded', 'note.recorded', 'note.resolved', 'note.absorbed',
12
12
  'knowledge.proposed', 'knowledge.verified', 'knowledge.deprecated', 'knowledge.rejected',
13
- 'deadend.proposed', 'deadend.verified', 'deadend.obsoleted', 'deadend.rejected', 'record.used',
13
+ 'deadend.proposed', 'deadend.verified', 'deadend.obsoleted', 'deadend.rejected',
14
+ 'experience.recorded',
15
+ 'evolution.proposed', 'evolution.candidate.accepted', 'evolution.verified', 'evolution.published', 'evolution.rejected', 'evolution.deprecated', 'evolution.superseded',
16
+ 'skill.materialized', 'recipe.materialized', 'skill.invoked', 'skill.outcome.recorded', 'record.used',
17
+ 'usage.operation.completed',
14
18
  'projection.advanced', 'projection.failed',
15
19
  ]);
16
- const sourceKinds = new Set(['cli', 'claude-hook', 'mcp', 'git-observer', 'fs-observer', 'migration']);
20
+ const sourceKinds = new Set(['cli', 'claude-hook', 'mcp', 'agent', 'git-observer', 'fs-observer', 'migration']);
17
21
  const segmentName = '00000001.ndjson';
18
22
  function canonical(value) {
19
23
  if (value === null || typeof value !== 'object')
@@ -55,6 +59,79 @@ function validGitAnchor(value) {
55
59
  function validIsoDate(value) {
56
60
  return typeof value === 'string' && Number.isFinite(Date.parse(value));
57
61
  }
62
+ function validEvolutionId(value, label) {
63
+ if (typeof value !== 'string' || value.trim().length === 0)
64
+ throw new Error(`${label} must not be empty`);
65
+ }
66
+ function validNonNegativeInteger(value) {
67
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
68
+ }
69
+ function validLineage(value) {
70
+ return isRecord(value)
71
+ && (value.entity === 'knowledge' || value.entity === 'deadend' || value.entity === 'skill' || value.entity === 'compiler-recipe' || value.entity === 'experience' || value.entity === 'proposal')
72
+ && typeof value.entityId === 'string' && value.entityId.trim().length > 0
73
+ && (value.relation === 'supports' || value.relation === 'contradicts' || value.relation === 'supersedes' || value.relation === 'used_for');
74
+ }
75
+ function validEvolutionEvidence(value) {
76
+ return isRecord(value)
77
+ && typeof value.eventId === 'string' && value.eventId.trim().length > 0
78
+ && (value.relation === 'derived_from' || value.relation === 'supports' || value.relation === 'contradicts' || value.relation === 'validated_by');
79
+ }
80
+ function validApplicability(value) {
81
+ return isRecord(value)
82
+ && value.project === true
83
+ && (value.branchMode === 'global' || value.branchMode === 'exact' || value.branchMode === 'ancestor-of-source' || value.branchMode === 'descendant-of-source')
84
+ && (value.branches === undefined || Array.isArray(value.branches) && value.branches.every((item) => typeof item === 'string' && item.trim().length > 0))
85
+ && (value.paths === undefined || Array.isArray(value.paths) && value.paths.every((item) => typeof item === 'string' && item.trim().length > 0))
86
+ && (value.sourceCommit === undefined || typeof value.sourceCommit === 'string' || value.sourceCommit === null)
87
+ && (value.dependencyConstraints === undefined || Array.isArray(value.dependencyConstraints) && value.dependencyConstraints.every((item) => typeof item === 'string' && item.trim().length > 0));
88
+ }
89
+ function validEvolutionTransition(from, to) {
90
+ if ((from !== 'candidate' && from !== 'verified' && from !== 'published' && from !== 'rejected' && from !== 'deprecated' && from !== 'superseded' && from !== 'obsolete') || (to !== 'verified' && to !== 'published' && to !== 'rejected' && to !== 'deprecated' && to !== 'superseded' && to !== 'obsolete'))
91
+ return false;
92
+ return (from === 'candidate' && (to === 'verified' || to === 'rejected' || to === 'deprecated'))
93
+ || (from === 'verified' && (to === 'published' || to === 'deprecated' || to === 'superseded' || to === 'obsolete'))
94
+ || (from === 'published' && (to === 'deprecated' || to === 'superseded' || to === 'obsolete'));
95
+ }
96
+ function validSkill(value) {
97
+ return isRecord(value)
98
+ && typeof value.skillId === 'string' && value.skillId.trim().length > 0
99
+ && value.schemaVersion === 1
100
+ && typeof value.proposalId === 'string' && value.proposalId.trim().length > 0
101
+ && typeof value.title === 'string' && value.title.trim().length > 0
102
+ && Array.isArray(value.procedure) && value.procedure.length > 0 && value.procedure.length <= 32 && value.procedure.every((step) => typeof step === 'string' && step.trim().length > 0)
103
+ && validApplicability(value.applicability)
104
+ && value.status === 'candidate'
105
+ && typeof value.contentHash === 'string' && value.contentHash.trim().length > 0
106
+ && Array.isArray(value.lineage) && value.lineage.every(validLineage)
107
+ && validIsoDate(value.createdAt);
108
+ }
109
+ const recipeSources = new Set(['context', 'knowledge', 'deadend', 'note']);
110
+ const recipeSections = new Set(['summary', 'pending', 'decision', 'risk', 'note', 'knowledge', 'deadend', 'activity']);
111
+ function validRecipe(value) {
112
+ if (!isRecord(value))
113
+ return false;
114
+ const allowed = new Set(['recipeId', 'schemaVersion', 'proposalId', 'title', 'applicability', 'preferredSources', 'preferredSections', 'sectionBudgets', 'status', 'contentHash', 'lineage', 'createdAt']);
115
+ if (Object.keys(value).some((key) => !allowed.has(key)))
116
+ return false;
117
+ const validPreferences = (items, allowedItems) => items === undefined
118
+ || (Array.isArray(items) && items.length <= allowedItems.size && items.every((item) => typeof item === 'string' && allowedItems.has(item)) && new Set(items).size === items.length);
119
+ const sectionBudgets = isRecord(value.sectionBudgets) ? value.sectionBudgets : null;
120
+ const validBudgets = value.sectionBudgets === undefined || (sectionBudgets !== null
121
+ && Object.keys(sectionBudgets).every((key) => recipeSections.has(key) && validNonNegativeInteger(sectionBudgets[key]) && sectionBudgets[key] <= 100_000));
122
+ return typeof value.recipeId === 'string' && value.recipeId.trim().length > 0
123
+ && value.schemaVersion === 1
124
+ && typeof value.proposalId === 'string' && value.proposalId.trim().length > 0
125
+ && typeof value.title === 'string' && value.title.trim().length > 0
126
+ && validApplicability(value.applicability)
127
+ && validPreferences(value.preferredSources, recipeSources)
128
+ && validPreferences(value.preferredSections, recipeSections)
129
+ && validBudgets
130
+ && value.status === 'candidate'
131
+ && typeof value.contentHash === 'string' && value.contentHash.trim().length > 0
132
+ && Array.isArray(value.lineage) && value.lineage.every(validLineage)
133
+ && validIsoDate(value.createdAt);
134
+ }
58
135
  function validatePayloadBoundary(input) {
59
136
  const payload = input.payload;
60
137
  if (input.type === 'host.prompt.submitted' && (typeof payload.intent !== 'string' || payload.intent.trim().length === 0)) {
@@ -65,11 +142,74 @@ function validatePayloadBoundary(input) {
65
142
  if (!isRecord(record) || typeof record.message !== 'string' || record.message.trim().length === 0)
66
143
  throw new Error('note message must not be empty');
67
144
  }
145
+ if (input.type === 'usage.operation.completed') {
146
+ const requiredNumbers = ['durationMs', 'inputChars', 'outputChars', 'inputBytes', 'outputBytes', 'estimatedInputTokens', 'estimatedOutputTokens'];
147
+ if (payload.operation !== 'orient' && payload.operation !== 'compile' && payload.operation !== 'expand' && payload.operation !== 'resume')
148
+ throw new Error('invalid usage operation');
149
+ if (payload.interface !== 'cli' && payload.interface !== 'mcp' && payload.interface !== 'agent' && payload.interface !== 'hook')
150
+ throw new Error('invalid usage interface');
151
+ if (typeof payload.success !== 'boolean' || payload.estimatedMethod !== 'chars-div-four' || requiredNumbers.some((key) => typeof payload[key] !== 'number' || !Number.isFinite(payload[key]) || payload[key] < 0))
152
+ throw new Error('invalid usage telemetry payload');
153
+ if (!payload.success && typeof payload.errorClass !== 'string')
154
+ throw new Error('usage errors require an error class');
155
+ }
68
156
  if (input.type === 'workspace.paths.changed') {
69
157
  const changes = payload.changes;
70
158
  if (!Array.isArray(changes) || changes.length === 0)
71
159
  throw new Error('workspace activity must contain changes');
72
160
  }
161
+ if (input.type === 'experience.recorded') {
162
+ const experience = payload.record;
163
+ if (!isRecord(experience))
164
+ throw new Error('experience record is required');
165
+ validEvolutionId(experience.experienceId, 'experience id');
166
+ if (experience.schemaVersion !== 1 || !Array.isArray(experience.sourceEventIds) || experience.sourceEventIds.length === 0 || !experience.sourceEventIds.every((value) => typeof value === 'string' && value.trim().length > 0) || !Array.isArray(experience.sourceContextIds) || !experience.sourceContextIds.every((value) => typeof value === 'string') || !Array.isArray(experience.sourceSnapshotIds) || !experience.sourceSnapshotIds.every((value) => typeof value === 'string') || !Array.isArray(experience.paths) || !experience.paths.every((value) => typeof value === 'string') || !validGitAnchor(experience.git) || !isRecord(experience.privacy) || (experience.privacy.classification !== 'metadata' && experience.privacy.classification !== 'redacted-text') || !validNonNegativeInteger(experience.privacy.redactionCount) || !isRecord(experience.extractor) || typeof experience.extractor.id !== 'string' || experience.extractor.id.trim().length === 0 || typeof experience.extractor.version !== 'string' || experience.extractor.version.trim().length === 0 || typeof experience.contentHash !== 'string' || experience.contentHash.trim().length === 0 || typeof experience.summary !== 'string' || experience.summary.trim().length === 0 || !validIsoDate(experience.createdAt))
167
+ throw new Error('invalid experience record');
168
+ }
169
+ if (input.type === 'evolution.proposed') {
170
+ const proposal = payload.record;
171
+ if (!isRecord(proposal))
172
+ throw new Error('evolution proposal record is required');
173
+ validEvolutionId(proposal.proposalId, 'proposal id');
174
+ if (proposal.schemaVersion !== 1 || (proposal.targetKind !== 'knowledge' && proposal.targetKind !== 'deadend' && proposal.targetKind !== 'skill' && proposal.targetKind !== 'compiler-recipe') || typeof proposal.targetId !== 'string' || proposal.targetId.trim().length === 0 || proposal.status !== 'candidate' || !Array.isArray(proposal.sourceExperienceIds) || proposal.sourceExperienceIds.length === 0 || !proposal.sourceExperienceIds.every((value) => typeof value === 'string' && value.trim().length > 0) || !Array.isArray(proposal.evidence) || proposal.evidence.length === 0 || !proposal.evidence.every(validEvolutionEvidence) || !Array.isArray(proposal.lineage) || !proposal.lineage.every(validLineage) || !validApplicability(proposal.applicability) || typeof proposal.contentHash !== 'string' || proposal.contentHash.trim().length === 0 || typeof proposal.summary !== 'string' || proposal.summary.trim().length === 0 || !isRecord(proposal.extractor) || typeof proposal.extractor.id !== 'string' || proposal.extractor.id.trim().length === 0 || typeof proposal.extractor.version !== 'string' || proposal.extractor.version.trim().length === 0 || !validIsoDate(proposal.createdAt))
175
+ throw new Error('invalid evolution proposal record');
176
+ }
177
+ if (input.type === 'evolution.candidate.accepted') {
178
+ const artifact = payload.record;
179
+ if (!isRecord(artifact) || typeof artifact.artifactId !== 'string' || artifact.artifactId.trim().length === 0 || artifact.schemaVersion !== 1 || typeof artifact.proposalId !== 'string' || artifact.proposalId.trim().length === 0 || (artifact.kind !== 'skill' && artifact.kind !== 'compiler-recipe') || artifact.status !== 'candidate' || !validApplicability(artifact.applicability) || typeof artifact.contentHash !== 'string' || artifact.contentHash.trim().length === 0 || !Array.isArray(artifact.lineage) || !artifact.lineage.every(validLineage) || typeof artifact.rationale !== 'string' || artifact.rationale.trim().length === 0 || !validIsoDate(artifact.createdAt))
180
+ throw new Error('invalid evolution candidate artifact');
181
+ }
182
+ if (['evolution.verified', 'evolution.published', 'evolution.rejected', 'evolution.deprecated', 'evolution.superseded'].includes(input.type)) {
183
+ const transition = payload.transition;
184
+ if (!isRecord(transition) || (transition.entity !== 'knowledge' && transition.entity !== 'deadend' && transition.entity !== 'skill' && transition.entity !== 'compiler-recipe' && transition.entity !== 'proposal') || typeof transition.entityId !== 'string' || transition.entityId.trim().length === 0 || !validEvolutionTransition(transition.from, transition.to) || typeof transition.reason !== 'string' || transition.reason.trim().length === 0 || (transition.supersedes !== undefined && !validLineage(transition.supersedes)))
185
+ throw new Error('invalid evolution lifecycle transition');
186
+ if ((input.type === 'evolution.verified' && transition.to !== 'verified') || (input.type === 'evolution.published' && transition.to !== 'published') || (input.type === 'evolution.rejected' && transition.to !== 'rejected') || (input.type === 'evolution.deprecated' && transition.to !== 'deprecated') || (input.type === 'evolution.superseded' && (transition.to !== 'superseded' || !validLineage(transition.supersedes))))
187
+ throw new Error('evolution lifecycle event does not match transition');
188
+ }
189
+ if (input.type === 'skill.materialized') {
190
+ if (!validSkill(payload.record))
191
+ throw new Error('invalid skill record');
192
+ }
193
+ if (input.type === 'recipe.materialized') {
194
+ if (!validRecipe(payload.record))
195
+ throw new Error('invalid compiler recipe record');
196
+ }
197
+ if (input.type === 'skill.invoked') {
198
+ const invocation = payload.record;
199
+ if (!isRecord(invocation))
200
+ throw new Error('skill invocation record is required');
201
+ validEvolutionId(invocation.invocationId, 'invocation id');
202
+ if (typeof invocation.skillId !== 'string' || invocation.skillId.trim().length === 0 || typeof invocation.callerEventId !== 'string' || invocation.callerEventId.trim().length === 0 || typeof invocation.inputHash !== 'string' || invocation.inputHash.trim().length === 0 || !validIsoDate(invocation.occurredAt))
203
+ throw new Error('invalid skill invocation record');
204
+ }
205
+ if (input.type === 'skill.outcome.recorded') {
206
+ const outcome = payload.record;
207
+ if (!isRecord(outcome))
208
+ throw new Error('skill outcome record is required');
209
+ validEvolutionId(outcome.outcomeId, 'outcome id');
210
+ if (typeof outcome.invocationId !== 'string' || outcome.invocationId.trim().length === 0 || typeof outcome.callerEventId !== 'string' || outcome.callerEventId.trim().length === 0 || (outcome.outcome !== 'success' && outcome.outcome !== 'failure' && outcome.outcome !== 'partial' && outcome.outcome !== 'irrelevant' && outcome.outcome !== 'unknown') || typeof outcome.summary !== 'string' || outcome.summary.trim().length === 0 || !validIsoDate(outcome.occurredAt))
211
+ throw new Error('invalid skill outcome record');
212
+ }
73
213
  }
74
214
  function validateEventShape(event, projectId, file, line) {
75
215
  if (!isRecord(event)
@@ -142,6 +282,7 @@ export async function readJournal(startPath, options = {}) {
142
282
  throw new Error(`invalid journal record ${file}:${index + 1}: ${error instanceof Error ? error.message : 'invalid JSON'}`);
143
283
  }
144
284
  validateEventShape(event, projectId, file, index + 1);
285
+ validatePayloadBoundary(event);
145
286
  const { hash, ...withoutHash } = event;
146
287
  if (event.previousHash !== previousHash || hash !== eventHash(withoutHash)) {
147
288
  throw new Error(`invalid journal hash chain at ${file}:${index + 1}`);
@@ -2,9 +2,10 @@ import crypto from 'node:crypto';
2
2
  import { readdir, rm } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { atomicWriteJson } from '../storage/atomic-write.js';
5
- import { cognitionBranchesDirectory, cognitionContextsProjectionPath, cognitionDeadendsProjectionPath, cognitionKnowledgeProjectionPath, cognitionNotesProjectionPath, cognitionProjectionCursorPath, cognitionRetrievalProjectionPath, } from '../storage/layout.js';
5
+ import { cognitionBranchesDirectory, cognitionContextsProjectionPath, cognitionDeadendsProjectionPath, cognitionExperiencesProjectionPath, cognitionEvidenceGraphProjectionPath, cognitionKnowledgeProjectionPath, cognitionNotesProjectionPath, cognitionProjectionCursorPath, cognitionProposalsProjectionPath, cognitionRetrievalProjectionPath, cognitionSkillsProjectionPath, cognitionRecipesProjectionPath, } from '../storage/layout.js';
6
6
  import { readJournal } from './journal.js';
7
7
  import { buildRetrievalProjection } from './retrieval.js';
8
+ import { buildEvidenceGraph } from './evidence-graph.js';
8
9
  export function workspaceKey(anchor) {
9
10
  return crypto.createHash('sha256')
10
11
  .update(`${anchor.worktreeId ?? 'default'}\0${anchor.ref ?? anchor.branch ?? 'detached'}`)
@@ -48,6 +49,55 @@ function pathFocusFrom(event) {
48
49
  .sort()
49
50
  .slice(-100);
50
51
  }
52
+ function evolutionRecord(event) {
53
+ const payload = event.payload;
54
+ return isRecord(payload.record) ? payload.record : null;
55
+ }
56
+ function isRecord(value) {
57
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
58
+ }
59
+ function lifecycleEntry(event) {
60
+ const payload = event.payload;
61
+ const transition = payload.transition;
62
+ if (!isRecord(transition) || transition.entity !== 'proposal' || typeof transition.entityId !== 'string' || typeof transition.from !== 'string' || typeof transition.to !== 'string' || typeof transition.reason !== 'string')
63
+ return null;
64
+ return {
65
+ eventId: event.eventId,
66
+ occurredAt: event.occurredAt,
67
+ from: transition.from,
68
+ to: transition.to,
69
+ reason: transition.reason,
70
+ ...(isRecord(transition.supersedes) ? { supersedes: transition.supersedes } : {}),
71
+ };
72
+ }
73
+ function skillLifecycleEntry(event) {
74
+ const payload = event.payload;
75
+ const transition = payload.transition;
76
+ if (!isRecord(transition) || transition.entity !== 'skill' || typeof transition.entityId !== 'string' || typeof transition.from !== 'string' || typeof transition.to !== 'string' || typeof transition.reason !== 'string')
77
+ return null;
78
+ return {
79
+ eventId: event.eventId,
80
+ occurredAt: event.occurredAt,
81
+ from: transition.from,
82
+ to: transition.to,
83
+ reason: transition.reason,
84
+ ...(isRecord(transition.supersedes) ? { supersedes: transition.supersedes } : {}),
85
+ };
86
+ }
87
+ function recipeLifecycleEntry(event) {
88
+ const payload = event.payload;
89
+ const transition = payload.transition;
90
+ if (!isRecord(transition) || transition.entity !== 'compiler-recipe' || typeof transition.entityId !== 'string' || typeof transition.from !== 'string' || typeof transition.to !== 'string' || typeof transition.reason !== 'string')
91
+ return null;
92
+ return {
93
+ eventId: event.eventId,
94
+ occurredAt: event.occurredAt,
95
+ from: transition.from,
96
+ to: transition.to,
97
+ reason: transition.reason,
98
+ ...(isRecord(transition.supersedes) ? { supersedes: transition.supersedes } : {}),
99
+ };
100
+ }
51
101
  function latestOpenObservationIds(events, notes) {
52
102
  const latest = new Map();
53
103
  for (const event of events) {
@@ -66,7 +116,7 @@ function latestOpenObservationIds(events, notes) {
66
116
  return new Set([...latest.values()].flatMap(({ eventId, record }) => record.status === 'open' && record.absorbedBySnapshotId === null ? [eventId] : []));
67
117
  }
68
118
  export function projectEvents(events) {
69
- const projection = { schemaVersion: 2, contexts: {}, notes: {}, knowledge: {}, deadends: {}, workspaces: {} };
119
+ const projection = { schemaVersion: 2, contexts: {}, notes: {}, knowledge: {}, deadends: {}, experiences: {}, proposals: {}, skills: {}, recipes: {}, evidenceGraph: { schemaVersion: 1, derivationVersion: 'evidence-graph-v1', nodes: [], edges: [] }, workspaces: {} };
70
120
  const openObservationIds = latestOpenObservationIds(events, projection.notes);
71
121
  for (const event of events) {
72
122
  const key = workspaceKey(event.git);
@@ -96,6 +146,72 @@ export function projectEvents(events) {
96
146
  if (deadendId)
97
147
  projection.deadends[deadendId] = payload;
98
148
  }
149
+ if (event.type === 'experience.recorded') {
150
+ const experience = evolutionRecord(event);
151
+ if (experience && typeof experience.experienceId === 'string')
152
+ projection.experiences[experience.experienceId] = experience;
153
+ }
154
+ if (event.type === 'evolution.proposed') {
155
+ const proposal = evolutionRecord(event);
156
+ if (proposal && typeof proposal.proposalId === 'string') {
157
+ projection.proposals[proposal.proposalId] = { proposal, status: proposal.status, lifecycle: [], acceptedArtifact: null };
158
+ }
159
+ }
160
+ if (event.type === 'evolution.candidate.accepted') {
161
+ const artifact = evolutionRecord(event);
162
+ if (artifact && projection.proposals[artifact.proposalId])
163
+ projection.proposals[artifact.proposalId].acceptedArtifact = artifact;
164
+ }
165
+ if (['evolution.verified', 'evolution.published', 'evolution.rejected', 'evolution.deprecated', 'evolution.superseded'].includes(event.type)) {
166
+ const entry = lifecycleEntry(event);
167
+ const state = entry ? projection.proposals[event.payload.transition.entityId] : null;
168
+ if (entry && state && state.status === entry.from) {
169
+ state.status = entry.to;
170
+ state.lifecycle.push(entry);
171
+ }
172
+ }
173
+ if (event.type === 'skill.materialized') {
174
+ const skill = evolutionRecord(event);
175
+ if (skill && typeof skill.skillId === 'string' && !projection.skills[skill.skillId]) {
176
+ projection.skills[skill.skillId] = { skill, status: skill.status, lifecycle: [], invocations: [], outcomes: [] };
177
+ }
178
+ }
179
+ if (['evolution.verified', 'evolution.published', 'evolution.rejected', 'evolution.deprecated', 'evolution.superseded'].includes(event.type)) {
180
+ const skillEntry = skillLifecycleEntry(event);
181
+ const skillState = skillEntry ? projection.skills[event.payload.transition.entityId] : null;
182
+ if (skillEntry && skillState && skillState.status === skillEntry.from) {
183
+ skillState.status = skillEntry.to;
184
+ skillState.lifecycle.push(skillEntry);
185
+ }
186
+ }
187
+ if (event.type === 'recipe.materialized') {
188
+ const recipe = evolutionRecord(event);
189
+ if (recipe && typeof recipe.recipeId === 'string' && !projection.recipes[recipe.recipeId]) {
190
+ projection.recipes[recipe.recipeId] = { recipe, status: recipe.status, lifecycle: [] };
191
+ }
192
+ }
193
+ if (['evolution.verified', 'evolution.published', 'evolution.rejected', 'evolution.deprecated', 'evolution.superseded'].includes(event.type)) {
194
+ const recipeEntry = recipeLifecycleEntry(event);
195
+ const recipeState = recipeEntry ? projection.recipes[event.payload.transition.entityId] : null;
196
+ if (recipeEntry && recipeState && recipeState.status === recipeEntry.from) {
197
+ recipeState.status = recipeEntry.to;
198
+ recipeState.lifecycle.push(recipeEntry);
199
+ }
200
+ }
201
+ if (event.type === 'skill.invoked') {
202
+ const invocation = evolutionRecord(event);
203
+ const skillState = invocation ? projection.skills[invocation.skillId] : null;
204
+ if (invocation && skillState && skillState.status === 'published' && !skillState.invocations.some((item) => item.invocationId === invocation.invocationId))
205
+ skillState.invocations.push(invocation);
206
+ }
207
+ if (event.type === 'skill.outcome.recorded') {
208
+ const outcome = evolutionRecord(event);
209
+ if (outcome) {
210
+ const skillState = Object.values(projection.skills).find((state) => state.invocations.some((invocation) => invocation.invocationId === outcome.invocationId));
211
+ if (skillState && !skillState.outcomes.some((item) => item.invocationId === outcome.invocationId))
212
+ skillState.outcomes.push(outcome);
213
+ }
214
+ }
99
215
  if (event.type === 'note.recorded' && openObservationIds.has(event.eventId)) {
100
216
  workspace.openObservationEventIds = [...workspace.openObservationEventIds, event.eventId].slice(-50);
101
217
  }
@@ -104,6 +220,7 @@ export function projectEvents(events) {
104
220
  }
105
221
  projection.workspaces[key] = workspace;
106
222
  }
223
+ projection.evidenceGraph = buildEvidenceGraph(events);
107
224
  return projection;
108
225
  }
109
226
  async function removeObsoleteWorkspaceFiles(projectRoot, workspaceKeys) {
@@ -136,6 +253,11 @@ export async function rebuildProjections(projectRoot) {
136
253
  atomicWriteJson(cognitionNotesProjectionPath(projectRoot), projection.notes),
137
254
  atomicWriteJson(cognitionKnowledgeProjectionPath(projectRoot), projection.knowledge),
138
255
  atomicWriteJson(cognitionDeadendsProjectionPath(projectRoot), projection.deadends),
256
+ atomicWriteJson(cognitionExperiencesProjectionPath(projectRoot), projection.experiences),
257
+ atomicWriteJson(cognitionProposalsProjectionPath(projectRoot), projection.proposals),
258
+ atomicWriteJson(cognitionSkillsProjectionPath(projectRoot), projection.skills),
259
+ atomicWriteJson(cognitionRecipesProjectionPath(projectRoot), projection.recipes),
260
+ atomicWriteJson(cognitionEvidenceGraphProjectionPath(projectRoot), projection.evidenceGraph),
139
261
  atomicWriteJson(cognitionRetrievalProjectionPath(projectRoot), retrieval),
140
262
  ...Object.values(projection.workspaces).map((workspace) => atomicWriteJson(`${cognitionBranchesDirectory(projectRoot)}/${workspace.workspaceKey}.json`, workspace)),
141
263
  ]);
@@ -0,0 +1,25 @@
1
+ import type { CompilerRecipeSection, CompilerRecipeSource, CompilerRecipeState, EvolutionArtifactStatus, EvolutionLineageReference } from './types.js';
2
+ export interface MaterializeRecipeInput {
3
+ proposalId: string;
4
+ title: string;
5
+ preferredSources?: CompilerRecipeSource[];
6
+ preferredSections?: CompilerRecipeSection[];
7
+ sectionBudgets?: Partial<Record<CompilerRecipeSection, number>>;
8
+ }
9
+ export interface TransitionRecipeInput {
10
+ recipeId: string;
11
+ to: Extract<EvolutionArtifactStatus, 'verified' | 'published' | 'deprecated' | 'superseded'>;
12
+ rationale: string;
13
+ supersedes?: EvolutionLineageReference;
14
+ }
15
+ export declare function listRecipes(startPath?: string): Promise<CompilerRecipeState[]>;
16
+ export declare function inspectRecipe(startPath: string | undefined, recipeId: string): Promise<CompilerRecipeState>;
17
+ export declare function materializeRecipe(startPath: string | undefined, input: MaterializeRecipeInput): Promise<{
18
+ materialized: boolean;
19
+ recipeId: string;
20
+ eventId: string;
21
+ }>;
22
+ export declare function transitionRecipe(startPath: string | undefined, input: TransitionRecipeInput): Promise<{
23
+ transitioned: boolean;
24
+ eventId: string;
25
+ }>;