fluffy-context 0.3.1 → 0.6.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 (43) hide show
  1. package/README.md +61 -5
  2. package/dist/src/agent/api.d.ts +5 -1
  3. package/dist/src/agent/api.js +50 -10
  4. package/dist/src/agent/index.d.ts +1 -1
  5. package/dist/src/agent/types.d.ts +21 -0
  6. package/dist/src/cli/main.js +177 -9
  7. package/dist/src/cognition/feedback.d.ts +9 -0
  8. package/dist/src/cognition/feedback.js +30 -0
  9. package/dist/src/cognition/git-observer.d.ts +13 -0
  10. package/dist/src/cognition/git-observer.js +61 -0
  11. package/dist/src/cognition/journal.d.ts +24 -0
  12. package/dist/src/cognition/journal.js +113 -0
  13. package/dist/src/cognition/migration-v1.d.ts +7 -0
  14. package/dist/src/cognition/migration-v1.js +201 -0
  15. package/dist/src/cognition/orient.d.ts +13 -0
  16. package/dist/src/cognition/orient.js +94 -0
  17. package/dist/src/cognition/projections.d.ts +7 -0
  18. package/dist/src/cognition/projections.js +130 -0
  19. package/dist/src/cognition/readiness.d.ts +2 -0
  20. package/dist/src/cognition/readiness.js +81 -0
  21. package/dist/src/cognition/retrieval.d.ts +26 -0
  22. package/dist/src/cognition/retrieval.js +273 -0
  23. package/dist/src/cognition/runtime.d.ts +12 -0
  24. package/dist/src/cognition/runtime.js +295 -0
  25. package/dist/src/cognition/types.d.ts +170 -0
  26. package/dist/src/cognition/types.js +1 -0
  27. package/dist/src/cognition/v1-bridge.d.ts +7 -0
  28. package/dist/src/cognition/v1-bridge.js +82 -0
  29. package/dist/src/git/git-adapter.d.ts +7 -0
  30. package/dist/src/git/git-adapter.js +16 -6
  31. package/dist/src/hooks/claude-code.js +53 -0
  32. package/dist/src/runtime/diagnostics.js +6 -0
  33. package/dist/src/runtime/knowledge.d.ts +2 -0
  34. package/dist/src/runtime/knowledge.js +68 -0
  35. package/dist/src/runtime/notes.js +8 -1
  36. package/dist/src/runtime/runtime.d.ts +1 -0
  37. package/dist/src/runtime/runtime.js +28 -0
  38. package/dist/src/runtime/types.d.ts +19 -0
  39. package/dist/src/storage/layout.d.ts +19 -0
  40. package/dist/src/storage/layout.js +57 -0
  41. package/dist/src/version.d.ts +1 -1
  42. package/dist/src/version.js +1 -1
  43. package/package.json +2 -2
@@ -0,0 +1,61 @@
1
+ import { readGitWorktreeState } from '../git/git-adapter.js';
2
+ import { appendEvent } from './journal.js';
3
+ import { rebuildProjections } from './projections.js';
4
+ function anchor(state) {
5
+ return { worktreeId: state.worktreeId, branch: state.branch, ref: state.ref, head: state.commit };
6
+ }
7
+ function key(state) {
8
+ return `${state.worktreeId ?? 'none'}\0${state.ref ?? 'detached'}\0${state.commit ?? 'missing'}`;
9
+ }
10
+ export class GitObserver {
11
+ projectRoot;
12
+ previous = null;
13
+ pending = Promise.resolve();
14
+ constructor(projectRoot) {
15
+ this.projectRoot = projectRoot;
16
+ }
17
+ observeNow() {
18
+ const observation = this.pending.then(() => this.observe());
19
+ this.pending = observation.then(() => undefined, () => undefined);
20
+ return observation;
21
+ }
22
+ async drain() {
23
+ await this.pending;
24
+ }
25
+ async observe() {
26
+ const state = await readGitWorktreeState(this.projectRoot);
27
+ const previous = this.previous;
28
+ const changed = !previous || key(previous) !== key(state);
29
+ if (!changed)
30
+ return { state, changed: false };
31
+ const currentKey = key(state);
32
+ await appendEvent(this.projectRoot, {
33
+ type: 'git.state.observed',
34
+ source: { kind: 'git-observer' },
35
+ idempotencyKey: `git:state:${currentKey}`,
36
+ git: anchor(state),
37
+ payload: { detached: state.detached, fingerprint: state.fingerprint },
38
+ });
39
+ if (previous && (previous.ref !== state.ref || previous.branch !== state.branch || previous.worktreeId !== state.worktreeId)) {
40
+ await appendEvent(this.projectRoot, {
41
+ type: 'git.branch.changed',
42
+ source: { kind: 'git-observer' },
43
+ idempotencyKey: `git:branch:${previous.worktreeId ?? 'none'}:${previous.ref ?? 'detached'}:${currentKey}`,
44
+ git: anchor(state),
45
+ payload: { previous: anchor(previous), current: anchor(state) },
46
+ });
47
+ }
48
+ if (previous && previous.commit !== state.commit) {
49
+ await appendEvent(this.projectRoot, {
50
+ type: 'git.head.changed',
51
+ source: { kind: 'git-observer' },
52
+ idempotencyKey: `git:head:${previous.worktreeId ?? 'none'}:${previous.commit ?? 'missing'}:${currentKey}`,
53
+ git: anchor(state),
54
+ payload: { previous: anchor(previous), current: anchor(state) },
55
+ });
56
+ }
57
+ this.previous = state;
58
+ await rebuildProjections(this.projectRoot);
59
+ return { state, changed: true };
60
+ }
61
+ }
@@ -0,0 +1,24 @@
1
+ import type { CognitionEvent, CognitionEventSource, CognitionEventType, CognitionGitAnchor } from './types.js';
2
+ export interface AppendEventInput<T = Record<string, unknown>> {
3
+ eventId?: string;
4
+ type: CognitionEventType;
5
+ occurredAt?: string;
6
+ source?: {
7
+ kind: CognitionEventSource;
8
+ instanceId?: string;
9
+ };
10
+ correlationId?: string;
11
+ idempotencyKey: string;
12
+ git?: CognitionGitAnchor;
13
+ privacy?: {
14
+ classification: 'metadata' | 'redacted-text';
15
+ redactionCount: number;
16
+ };
17
+ payload: T;
18
+ }
19
+ export declare function eventHash<T>(event: Omit<CognitionEvent<T>, 'hash'>): string;
20
+ export declare function readJournal(startPath?: string): Promise<CognitionEvent[]>;
21
+ export declare function appendEvent<T>(startPath: string | undefined, input: AppendEventInput<T>): Promise<{
22
+ event: CognitionEvent<T>;
23
+ appended: boolean;
24
+ }>;
@@ -0,0 +1,113 @@
1
+ import crypto from 'node:crypto';
2
+ import { appendFile, mkdir, readFile, readdir, truncate } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { resolveProjectRoot } from '../project/project-resolver.js';
5
+ import { loadManifest } from '../runtime/init.js';
6
+ import { withLock } from '../storage/lock.js';
7
+ import { cognitionJournalDirectory, locksDirectory } from '../storage/layout.js';
8
+ const segmentName = '00000001.ndjson';
9
+ function canonical(value) {
10
+ if (value === null || typeof value !== 'object')
11
+ return JSON.stringify(value);
12
+ if (Array.isArray(value))
13
+ return `[${value.map(canonical).join(',')}]`;
14
+ const record = value;
15
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(',')}}`;
16
+ }
17
+ function digest(value) {
18
+ return crypto.createHash('sha256').update(canonical(value)).digest('hex');
19
+ }
20
+ function eventId() {
21
+ return `evt-${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
22
+ }
23
+ export function eventHash(event) {
24
+ return digest(event);
25
+ }
26
+ async function journalFiles(projectRoot) {
27
+ try {
28
+ return (await readdir(cognitionJournalDirectory(projectRoot))).filter((file) => file.endsWith('.ndjson')).sort();
29
+ }
30
+ catch (error) {
31
+ if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT')
32
+ return [];
33
+ throw error;
34
+ }
35
+ }
36
+ async function recoverPartialTail(projectRoot) {
37
+ const files = await journalFiles(projectRoot);
38
+ const file = files.at(-1);
39
+ if (!file)
40
+ return;
41
+ const filePath = path.join(cognitionJournalDirectory(projectRoot), file);
42
+ const content = await readFile(filePath, 'utf8');
43
+ if (content.endsWith('\n'))
44
+ return;
45
+ const boundary = content.lastIndexOf('\n');
46
+ const tail = content.slice(boundary + 1);
47
+ try {
48
+ JSON.parse(tail);
49
+ }
50
+ catch {
51
+ await truncate(filePath, boundary < 0 ? 0 : boundary + 1);
52
+ }
53
+ }
54
+ export async function readJournal(startPath) {
55
+ const projectRoot = await resolveProjectRoot(startPath);
56
+ const events = [];
57
+ let previousHash = null;
58
+ for (const file of await journalFiles(projectRoot)) {
59
+ const content = await readFile(path.join(cognitionJournalDirectory(projectRoot), file), 'utf8');
60
+ const lines = content.split('\n');
61
+ for (let index = 0; index < lines.length; index += 1) {
62
+ const line = lines[index];
63
+ if (!line.trim())
64
+ continue;
65
+ let event;
66
+ try {
67
+ event = JSON.parse(line);
68
+ }
69
+ catch (error) {
70
+ if (index === lines.length - 1 && !content.endsWith('\n'))
71
+ break;
72
+ throw new Error(`invalid journal record ${file}:${index + 1}: ${error instanceof Error ? error.message : 'invalid JSON'}`);
73
+ }
74
+ const { hash, ...withoutHash } = event;
75
+ if (event.schemaVersion !== 2 || !event.eventId || event.previousHash !== previousHash || hash !== eventHash(withoutHash)) {
76
+ throw new Error(`invalid journal hash chain at ${file}:${index + 1}`);
77
+ }
78
+ previousHash = event.hash;
79
+ events.push(event);
80
+ }
81
+ }
82
+ return events;
83
+ }
84
+ export async function appendEvent(startPath, input) {
85
+ const projectRoot = await resolveProjectRoot(startPath);
86
+ return withLock(`${locksDirectory(projectRoot)}/cognition-journal.lock`, async () => {
87
+ await recoverPartialTail(projectRoot);
88
+ const existing = await readJournal(projectRoot);
89
+ const duplicate = existing.find((event) => event.idempotencyKey === input.idempotencyKey);
90
+ if (duplicate)
91
+ return { event: duplicate, appended: false };
92
+ const now = new Date().toISOString();
93
+ const eventWithoutHash = {
94
+ eventId: input.eventId ?? eventId(),
95
+ schemaVersion: 2,
96
+ type: input.type,
97
+ occurredAt: input.occurredAt ?? now,
98
+ observedAt: now,
99
+ projectId: (await loadManifest(projectRoot)).projectId,
100
+ source: input.source ?? { kind: 'cli' },
101
+ ...(input.correlationId ? { correlationId: input.correlationId } : {}),
102
+ idempotencyKey: input.idempotencyKey,
103
+ git: input.git ?? { worktreeId: null, branch: null, ref: null, head: null },
104
+ privacy: input.privacy ?? { classification: 'metadata', redactionCount: 0 },
105
+ payload: input.payload,
106
+ previousHash: existing.at(-1)?.hash ?? null,
107
+ };
108
+ const event = { ...eventWithoutHash, hash: eventHash(eventWithoutHash) };
109
+ await mkdir(cognitionJournalDirectory(projectRoot), { recursive: true });
110
+ await appendFile(path.join(cognitionJournalDirectory(projectRoot), segmentName), `${JSON.stringify(event)}\n`, 'utf8');
111
+ return { event, appended: true };
112
+ });
113
+ }
@@ -0,0 +1,7 @@
1
+ import { type MigrationReport } from './types.js';
2
+ export declare function importV1(startPath?: string): Promise<MigrationReport>;
3
+ export declare function reconcileV1State(startPath?: string): Promise<void>;
4
+ export declare function verifyV1Import(startPath?: string): Promise<{
5
+ verified: boolean;
6
+ issues: string[];
7
+ }>;
@@ -0,0 +1,201 @@
1
+ import crypto from 'node:crypto';
2
+ import { mkdir, readFile, readdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { resolveProjectRoot } from '../project/project-resolver.js';
5
+ import { loadManifest } from '../runtime/init.js';
6
+ import { listDeadends, listKnowledge } from '../runtime/knowledge.js';
7
+ import { listNotes } from '../runtime/notes.js';
8
+ import { rebuildSnapshot } from '../runtime/runtime.js';
9
+ import { atomicWriteJson } from '../storage/atomic-write.js';
10
+ import { withLock } from '../storage/lock.js';
11
+ import { cognitionManifestPath, cognitionPolicyPath, cognitionQuarantineDirectory, cognitionV1ImportPath, cognitionV1SyncPath, contextMetadataPath, contextsRoot, locksDirectory, manifestPath, snapshotPath, snapshotsDirectory, } from '../storage/layout.js';
12
+ import { readJson } from '../storage/json-store.js';
13
+ import { appendEvent } from './journal.js';
14
+ import { rebuildProjections } from './projections.js';
15
+ import { v1Fingerprint } from './v1-bridge.js';
16
+ import { migrationReadiness } from './readiness.js';
17
+ import { COGNITION_LAYOUT_VERSION } from './types.js';
18
+ function sha256(value) {
19
+ return crypto.createHash('sha256').update(value).digest('hex');
20
+ }
21
+ function isNotFound(error) {
22
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
23
+ }
24
+ function eventType(record, live = false) {
25
+ const status = record.record.status;
26
+ if (record.kind === 'context')
27
+ return live ? 'context.state.recorded' : 'context.checkpoint.imported';
28
+ if (record.kind === 'note')
29
+ return record.record.absorbedBySnapshotId ? 'note.absorbed' : record.record.status === 'resolved' ? 'note.resolved' : 'note.recorded';
30
+ if (record.kind === 'knowledge')
31
+ return status === 'verified' ? 'knowledge.verified' : status === 'deprecated' ? 'knowledge.deprecated' : status === 'rejected' ? 'knowledge.rejected' : 'knowledge.proposed';
32
+ return status === 'verified' ? 'deadend.verified' : status === 'obsolete' ? 'deadend.obsoleted' : status === 'rejected' ? 'deadend.rejected' : 'deadend.proposed';
33
+ }
34
+ async function readOptional(filePath) {
35
+ try {
36
+ return await readFile(filePath, 'utf8');
37
+ }
38
+ catch (error) {
39
+ if (isNotFound(error))
40
+ return null;
41
+ throw error;
42
+ }
43
+ }
44
+ async function sourceRecords(projectRoot) {
45
+ const sources = [];
46
+ const records = [];
47
+ const snapshotAnchors = new Map();
48
+ const v1ManifestPath = manifestPath(projectRoot);
49
+ sources.push({ filePath: v1ManifestPath, content: await readFile(v1ManifestPath, 'utf8') });
50
+ const contextIds = (await readdir(contextsRoot(projectRoot))).sort();
51
+ for (const contextId of contextIds) {
52
+ const contextFile = contextMetadataPath(projectRoot, contextId);
53
+ const contextContent = await readFile(contextFile, 'utf8');
54
+ sources.push({ filePath: contextFile, content: contextContent });
55
+ const context = await readJson(contextFile);
56
+ const snapshots = (await readdir(snapshotsDirectory(projectRoot, contextId))).filter((file) => file.endsWith('.json')).sort();
57
+ for (const file of snapshots) {
58
+ const snapshotId = file.slice(0, -'.json'.length);
59
+ const filePath = snapshotPath(projectRoot, contextId, snapshotId);
60
+ const content = await readFile(filePath, 'utf8');
61
+ sources.push({ filePath, content });
62
+ const snapshot = await readJson(filePath);
63
+ await rebuildSnapshot(projectRoot, contextId, snapshotId);
64
+ const git = { branch: snapshot.branch, head: snapshot.commit };
65
+ snapshotAnchors.set(`${contextId}:${snapshotId}`, git);
66
+ records.push({
67
+ kind: 'context',
68
+ id: `${contextId}:${snapshotId}`,
69
+ occurredAt: snapshot.createdAt,
70
+ git,
71
+ record: { context, snapshot },
72
+ sourcePath: filePath,
73
+ sourceHash: sha256(content),
74
+ });
75
+ }
76
+ }
77
+ const noteFile = path.join(projectRoot, '.context', 'notes.json');
78
+ const knowledgeFile = path.join(projectRoot, '.context', 'knowledge.json');
79
+ const deadendFile = path.join(projectRoot, '.context', 'deadends.json');
80
+ for (const [kind, filePath] of [['note', noteFile], ['knowledge', knowledgeFile], ['deadend', deadendFile]]) {
81
+ const content = await readOptional(filePath);
82
+ if (content === null)
83
+ continue;
84
+ sources.push({ filePath, content });
85
+ const sourceHash = sha256(content);
86
+ const items = kind === 'note' ? await listNotes(projectRoot, { limit: Number.MAX_SAFE_INTEGER, maxChars: Number.MAX_SAFE_INTEGER })
87
+ : kind === 'knowledge' ? await listKnowledge(projectRoot, true)
88
+ : await listDeadends(projectRoot, true);
89
+ for (const item of items) {
90
+ const record = item;
91
+ const id = typeof record.noteId === 'string' ? record.noteId : typeof record.knowledgeId === 'string' ? record.knowledgeId : record.deadendId;
92
+ const occurredAt = item.createdAt;
93
+ const contextId = 'contextId' in item ? item.contextId : 'sourceContextId' in item ? item.sourceContextId : null;
94
+ const snapshotId = 'snapshotId' in item ? item.snapshotId : 'sourceSnapshotId' in item ? item.sourceSnapshotId : null;
95
+ if (snapshotId && !contextId)
96
+ throw new Error(`${kind} ${id} has a snapshot without a context`);
97
+ if (contextId && snapshotId)
98
+ await rebuildSnapshot(projectRoot, contextId, snapshotId);
99
+ records.push({ kind, id, occurredAt, git: contextId && snapshotId ? snapshotAnchors.get(`${contextId}:${snapshotId}`) ?? { branch: null, head: null } : { branch: null, head: null }, record: item, sourcePath: filePath, sourceHash });
100
+ }
101
+ }
102
+ const sourceHash = sha256(sources.sort((left, right) => left.filePath.localeCompare(right.filePath)).map((source) => `${source.filePath}\0${sha256(source.content)}`).join('\n'));
103
+ return { records: records.sort((left, right) => left.occurredAt.localeCompare(right.occurredAt) || left.kind.localeCompare(right.kind) || left.id.localeCompare(right.id)), sourceHash };
104
+ }
105
+ async function writeV2Metadata(projectRoot) {
106
+ const v1 = await loadManifest(projectRoot);
107
+ const manifest = { schemaVersion: 2, layoutVersion: COGNITION_LAYOUT_VERSION, projectId: v1.projectId, createdAt: new Date().toISOString(), policyVersion: 1 };
108
+ const policy = { schemaVersion: 2, enabled: false, allowHookStartup: false, inactivityTimeoutMs: 300_000, promptIntentMaxChars: 500, filesystemEventsEnabled: false, retentionMaxBytes: 64 * 1024 * 1024 };
109
+ await Promise.all([atomicWriteJson(cognitionManifestPath(projectRoot), manifest), atomicWriteJson(cognitionPolicyPath(projectRoot), policy)]);
110
+ }
111
+ async function quarantineReadiness(projectRoot, readiness) {
112
+ await mkdir(cognitionQuarantineDirectory(projectRoot), { recursive: true });
113
+ await atomicWriteJson(path.join(cognitionQuarantineDirectory(projectRoot), 'v1-readiness.json'), {
114
+ schemaVersion: 2,
115
+ recordedAt: new Date().toISOString(),
116
+ eligible: readiness.eligible,
117
+ issues: readiness.issues,
118
+ });
119
+ }
120
+ export async function importV1(startPath) {
121
+ const projectRoot = await resolveProjectRoot(startPath);
122
+ return withLock(`${locksDirectory(projectRoot)}/cognition-migration.lock`, async () => {
123
+ const readiness = await migrationReadiness(projectRoot);
124
+ if (!readiness.eligible) {
125
+ const report = { ...readiness, applied: false, importedEventIds: [] };
126
+ await quarantineReadiness(projectRoot, report);
127
+ return report;
128
+ }
129
+ const { records, sourceHash } = await sourceRecords(projectRoot);
130
+ const existingReceipt = await readOptional(cognitionV1ImportPath(projectRoot));
131
+ if (existingReceipt) {
132
+ const receipt = JSON.parse(existingReceipt);
133
+ if (receipt.schemaVersion !== 2 || receipt.sourceHash !== sourceHash)
134
+ throw new Error('v1 source changed after import; v2 historical import is immutable');
135
+ await rebuildProjections(projectRoot);
136
+ await atomicWriteJson(cognitionV1SyncPath(projectRoot), { schemaVersion: 2, fingerprint: await v1Fingerprint(projectRoot), updatedAt: new Date().toISOString() });
137
+ return { ...readiness, applied: true, importedEventIds: receipt.eventIds };
138
+ }
139
+ await writeV2Metadata(projectRoot);
140
+ const importedEventIds = [];
141
+ for (const record of records) {
142
+ const event = await appendEvent(projectRoot, {
143
+ type: eventType(record),
144
+ occurredAt: record.occurredAt,
145
+ source: { kind: 'migration' },
146
+ idempotencyKey: `v1:${record.sourceHash}:${record.kind}:${record.id}`,
147
+ git: { worktreeId: null, branch: record.git.branch, ref: record.git.branch, head: record.git.head },
148
+ payload: { record: record.record, sourcePath: record.sourcePath, sourceHash: record.sourceHash, anchorProvenance: record.git.branch === null && record.git.head === null ? 'global' : 'snapshot' },
149
+ });
150
+ importedEventIds.push(event.event.eventId);
151
+ }
152
+ await rebuildProjections(projectRoot);
153
+ await atomicWriteJson(cognitionV1SyncPath(projectRoot), { schemaVersion: 2, fingerprint: await v1Fingerprint(projectRoot), updatedAt: new Date().toISOString() });
154
+ const receipt = { schemaVersion: 2, sourceHash, importedAt: new Date().toISOString(), eventIds: importedEventIds, counts: readiness.counts };
155
+ await atomicWriteJson(cognitionV1ImportPath(projectRoot), receipt);
156
+ return { ...readiness, v2Present: true, applied: true, importedEventIds };
157
+ });
158
+ }
159
+ export async function reconcileV1State(startPath) {
160
+ const projectRoot = await resolveProjectRoot(startPath);
161
+ await withLock(`${locksDirectory(projectRoot)}/cognition-migration.lock`, async () => {
162
+ const readiness = await migrationReadiness(projectRoot);
163
+ if (!readiness.eligible)
164
+ return;
165
+ const { records, sourceHash } = await sourceRecords(projectRoot);
166
+ for (const record of records) {
167
+ await appendEvent(projectRoot, {
168
+ type: eventType(record, true),
169
+ occurredAt: record.occurredAt,
170
+ source: { kind: 'migration' },
171
+ idempotencyKey: `v1-live-sync:${sourceHash}:${record.kind}:${record.id}`,
172
+ git: { worktreeId: null, branch: record.git.branch, ref: record.git.branch, head: record.git.head },
173
+ payload: { record: record.record, sourcePath: record.sourcePath, sourceHash: record.sourceHash, anchorProvenance: record.git.branch === null && record.git.head === null ? 'global' : 'snapshot' },
174
+ });
175
+ }
176
+ await rebuildProjections(projectRoot);
177
+ await atomicWriteJson(cognitionV1SyncPath(projectRoot), { schemaVersion: 2, fingerprint: await v1Fingerprint(projectRoot), updatedAt: new Date().toISOString() });
178
+ });
179
+ }
180
+ export async function verifyV1Import(startPath) {
181
+ const projectRoot = await resolveProjectRoot(startPath);
182
+ try {
183
+ const readiness = await migrationReadiness(projectRoot);
184
+ if (!readiness.eligible)
185
+ return { verified: false, issues: readiness.issues };
186
+ const receipt = JSON.parse(await readFile(cognitionV1ImportPath(projectRoot), 'utf8'));
187
+ const { sourceHash } = await sourceRecords(projectRoot);
188
+ if (receipt.sourceHash !== sourceHash)
189
+ return { verified: false, issues: ['v1 source changed after import'] };
190
+ const { projection, cursor } = await rebuildProjections(projectRoot);
191
+ const actual = { contexts: Object.keys(projection.contexts).length, notes: Object.keys(projection.notes).length, knowledge: Object.keys(projection.knowledge).length, deadends: Object.keys(projection.deadends).length };
192
+ const expected = { contexts: readiness.counts.snapshots, notes: readiness.counts.notes, knowledge: readiness.counts.knowledge, deadends: readiness.counts.deadends };
193
+ const issues = Object.entries(expected).flatMap(([key, count]) => actual[key] === count ? [] : [`${key} projection count mismatch`]);
194
+ if (!cursor.lastEventId && receipt.eventIds.length > 0)
195
+ issues.push('projection cursor is missing');
196
+ return { verified: issues.length === 0, issues };
197
+ }
198
+ catch (error) {
199
+ return { verified: false, issues: [error instanceof Error ? error.message : 'invalid v1 import'] };
200
+ }
201
+ }
@@ -0,0 +1,13 @@
1
+ import type { CognitionEvent, CognitionRetrievalProjection } from './types.js';
2
+ export interface V2OrientationPlan {
3
+ contextId: string;
4
+ snapshotId: string;
5
+ knowledgeIds: ReadonlySet<string>;
6
+ deadendIds: ReadonlySet<string>;
7
+ noteIds: ReadonlySet<string>;
8
+ retrieval: CognitionRetrievalProjection;
9
+ currentGit: CognitionEvent['git'];
10
+ selection: 'requested-context' | 'exact-worktree-ref' | 'legacy-ref-branch';
11
+ }
12
+ export declare function selectV2Orientation(projectRoot: string, contextId?: string): Promise<V2OrientationPlan | null>;
13
+ export declare function v2Enabled(projectRoot: string): Promise<boolean>;
@@ -0,0 +1,94 @@
1
+ import { access } from 'node:fs/promises';
2
+ import { readGitWorktreeState } from '../git/git-adapter.js';
3
+ import { cognitionManifestPath, cognitionPolicyPath } from '../storage/layout.js';
4
+ import { readJson } from '../storage/json-store.js';
5
+ import { loadManifest } from '../runtime/init.js';
6
+ import { readJournal } from './journal.js';
7
+ import { projectEvents, workspaceKey } from './projections.js';
8
+ import { buildRetrievalProjection } from './retrieval.js';
9
+ import { v1Synced } from './v1-bridge.js';
10
+ function contextReference(value) {
11
+ const boundary = value.lastIndexOf(':');
12
+ if (boundary <= 0 || boundary === value.length - 1)
13
+ return null;
14
+ return { contextId: value.slice(0, boundary), snapshotId: value.slice(boundary + 1) };
15
+ }
16
+ function record(event) {
17
+ const payload = event.payload;
18
+ return payload.record && typeof payload.record === 'object' && !Array.isArray(payload.record) ? payload.record : null;
19
+ }
20
+ function applicable(event, current) {
21
+ if (event.git.ref === null && event.git.branch === null)
22
+ return true;
23
+ return event.git.ref === current.ref && event.git.branch === current.branch && (event.git.worktreeId === null || event.git.worktreeId === current.worktreeId);
24
+ }
25
+ function latestRecords(events, current, contextId) {
26
+ const knowledge = new Map();
27
+ const deadends = new Map();
28
+ const notes = new Map();
29
+ for (const event of events) {
30
+ const item = record(event);
31
+ if (!item)
32
+ continue;
33
+ if (['knowledge.proposed', 'knowledge.verified'].includes(event.type) && typeof item.knowledgeId === 'string')
34
+ knowledge.set(item.knowledgeId, { event, record: item });
35
+ if (['deadend.proposed', 'deadend.verified'].includes(event.type) && typeof item.deadendId === 'string')
36
+ deadends.set(item.deadendId, { event, record: item });
37
+ if (['note.recorded', 'note.resolved', 'note.absorbed'].includes(event.type) && typeof item.noteId === 'string')
38
+ notes.set(item.noteId, { event, record: item });
39
+ }
40
+ return {
41
+ knowledgeIds: new Set([...knowledge].flatMap(([id, item]) => item.record.status === 'verified' && applicable(item.event, current) ? [id] : [])),
42
+ deadendIds: new Set([...deadends].flatMap(([id, item]) => item.record.status === 'verified' && applicable(item.event, current) ? [id] : [])),
43
+ noteIds: new Set([...notes].flatMap(([id, item]) => item.record.contextId === contextId && item.record.status === 'open' && item.record.absorbedBySnapshotId === null && applicable(item.event, current) ? [id] : [])),
44
+ };
45
+ }
46
+ export async function selectV2Orientation(projectRoot, contextId) {
47
+ try {
48
+ const [manifest, cognition, policy] = await Promise.all([
49
+ loadManifest(projectRoot),
50
+ readJson(cognitionManifestPath(projectRoot)),
51
+ readJson(cognitionPolicyPath(projectRoot)),
52
+ ]);
53
+ if (cognition.schemaVersion !== 2 || cognition.projectId !== manifest.projectId || !policy.enabled || !await v1Synced(projectRoot))
54
+ return null;
55
+ const [events, current] = await Promise.all([readJournal(projectRoot), readGitWorktreeState(projectRoot)]);
56
+ if (events.length === 0)
57
+ return null;
58
+ const projection = projectEvents(events);
59
+ let selected = null;
60
+ let selection = 'requested-context';
61
+ if (contextId) {
62
+ const matching = [...Object.keys(projection.contexts)].reverse().find((reference) => contextReference(reference)?.contextId === contextId);
63
+ selected = matching ? contextReference(matching) : null;
64
+ }
65
+ else {
66
+ const exact = projection.workspaces[workspaceKey(current)];
67
+ const legacy = Object.values(projection.workspaces).find((workspace) => workspace.worktreeId === null && (workspace.ref === current.ref || workspace.branch === current.branch));
68
+ const reference = (exact?.activeContextRef ? exact : legacy)?.activeContextRef;
69
+ selected = reference ? contextReference(reference) : null;
70
+ selection = exact?.activeContextRef ? 'exact-worktree-ref' : 'legacy-ref-branch';
71
+ }
72
+ if (!selected || !projection.contexts[`${selected.contextId}:${selected.snapshotId}`])
73
+ return null;
74
+ return {
75
+ ...selected,
76
+ ...latestRecords(events, current, selected.contextId),
77
+ retrieval: buildRetrievalProjection(events),
78
+ currentGit: { worktreeId: current.worktreeId, branch: current.branch, ref: current.ref, head: current.commit },
79
+ selection,
80
+ };
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ export async function v2Enabled(projectRoot) {
87
+ try {
88
+ await access(cognitionManifestPath(projectRoot));
89
+ return (await readJson(cognitionPolicyPath(projectRoot))).enabled;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
@@ -0,0 +1,7 @@
1
+ import type { CognitionEvent, CognitionGitAnchor, CognitionProjection, ProjectionCursor } from './types.js';
2
+ export declare function workspaceKey(anchor: Pick<CognitionGitAnchor, 'worktreeId' | 'branch' | 'ref'>): string;
3
+ export declare function projectEvents(events: CognitionEvent[]): CognitionProjection;
4
+ export declare function rebuildProjections(projectRoot: string): Promise<{
5
+ projection: CognitionProjection;
6
+ cursor: ProjectionCursor;
7
+ }>;
@@ -0,0 +1,130 @@
1
+ import crypto from 'node:crypto';
2
+ import { readdir, rm } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { atomicWriteJson } from '../storage/atomic-write.js';
5
+ import { cognitionBranchesDirectory, cognitionContextsProjectionPath, cognitionDeadendsProjectionPath, cognitionKnowledgeProjectionPath, cognitionNotesProjectionPath, cognitionProjectionCursorPath, cognitionRetrievalProjectionPath, } from '../storage/layout.js';
6
+ import { readJournal } from './journal.js';
7
+ import { buildRetrievalProjection } from './retrieval.js';
8
+ export function workspaceKey(anchor) {
9
+ return crypto.createHash('sha256')
10
+ .update(`${anchor.worktreeId ?? 'default'}\0${anchor.ref ?? anchor.branch ?? 'detached'}`)
11
+ .digest('hex')
12
+ .slice(0, 16);
13
+ }
14
+ function workspaceFor(event, existing) {
15
+ const key = workspaceKey(event.git);
16
+ return {
17
+ schemaVersion: 2,
18
+ workspaceKey: key,
19
+ worktreeId: event.git.worktreeId,
20
+ branch: event.git.branch,
21
+ ref: event.git.ref,
22
+ head: event.git.head,
23
+ activeContextRef: existing?.activeContextRef ?? null,
24
+ openObservationEventIds: existing?.openObservationEventIds ?? [],
25
+ pathFocus: existing?.pathFocus ?? [],
26
+ recentEventIds: [...(existing?.recentEventIds ?? []), event.eventId].slice(-50),
27
+ lastActivatedAt: event.occurredAt,
28
+ };
29
+ }
30
+ function recordPayload(event) {
31
+ const payload = event.payload;
32
+ if (!payload.record || typeof payload.record !== 'object' || Array.isArray(payload.record))
33
+ return null;
34
+ return payload;
35
+ }
36
+ function recordId(record, kind) {
37
+ const field = kind === 'note' ? 'noteId' : kind === 'knowledge' ? 'knowledgeId' : 'deadendId';
38
+ return typeof record[field] === 'string' ? record[field] : null;
39
+ }
40
+ function latestOpenObservationIds(events, notes) {
41
+ const latest = new Map();
42
+ for (const event of events) {
43
+ if (!['note.recorded', 'note.resolved', 'note.absorbed'].includes(event.type))
44
+ continue;
45
+ const payload = recordPayload(event);
46
+ const note = payload?.record;
47
+ if (!note)
48
+ continue;
49
+ const noteId = recordId(note, 'note');
50
+ if (!noteId)
51
+ continue;
52
+ notes[noteId] = payload;
53
+ latest.set(noteId, { eventId: event.eventId, record: note });
54
+ }
55
+ return new Set([...latest.values()].flatMap(({ eventId, record }) => record.status === 'open' && record.absorbedBySnapshotId === null ? [eventId] : []));
56
+ }
57
+ export function projectEvents(events) {
58
+ const projection = { schemaVersion: 2, contexts: {}, notes: {}, knowledge: {}, deadends: {}, workspaces: {} };
59
+ const openObservationIds = latestOpenObservationIds(events, projection.notes);
60
+ for (const event of events) {
61
+ const key = workspaceKey(event.git);
62
+ const workspace = workspaceFor(event, projection.workspaces[key]);
63
+ const payload = recordPayload(event);
64
+ const record = payload?.record;
65
+ if ((event.type === 'context.checkpoint.imported' || event.type === 'context.state.recorded') && record) {
66
+ const snapshot = record.snapshot;
67
+ if (snapshot && typeof snapshot.contextId === 'string' && typeof snapshot.snapshotId === 'string') {
68
+ const contextRef = `${snapshot.contextId}:${snapshot.snapshotId}`;
69
+ projection.contexts[contextRef] = payload;
70
+ workspace.activeContextRef = contextRef;
71
+ }
72
+ }
73
+ if (event.type === 'note.recorded' && record) {
74
+ const noteId = recordId(record, 'note');
75
+ if (noteId)
76
+ projection.notes[noteId] = payload;
77
+ }
78
+ if (['knowledge.proposed', 'knowledge.verified', 'knowledge.deprecated', 'knowledge.rejected'].includes(event.type) && record) {
79
+ const knowledgeId = recordId(record, 'knowledge');
80
+ if (knowledgeId)
81
+ projection.knowledge[knowledgeId] = payload;
82
+ }
83
+ if (['deadend.proposed', 'deadend.verified', 'deadend.obsoleted', 'deadend.rejected'].includes(event.type) && record) {
84
+ const deadendId = recordId(record, 'deadend');
85
+ if (deadendId)
86
+ projection.deadends[deadendId] = payload;
87
+ }
88
+ if (event.type === 'note.recorded' && openObservationIds.has(event.eventId)) {
89
+ workspace.openObservationEventIds = [...workspace.openObservationEventIds, event.eventId].slice(-50);
90
+ }
91
+ projection.workspaces[key] = workspace;
92
+ }
93
+ return projection;
94
+ }
95
+ async function removeObsoleteWorkspaceFiles(projectRoot, workspaceKeys) {
96
+ try {
97
+ const files = await readdir(cognitionBranchesDirectory(projectRoot));
98
+ await Promise.all(files
99
+ .filter((file) => file.endsWith('.json') && !workspaceKeys.has(file.slice(0, -'.json'.length)))
100
+ .map((file) => rm(path.join(cognitionBranchesDirectory(projectRoot), file), { force: true })));
101
+ }
102
+ catch (error) {
103
+ if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT')
104
+ return;
105
+ throw error;
106
+ }
107
+ }
108
+ export async function rebuildProjections(projectRoot) {
109
+ const events = await readJournal(projectRoot);
110
+ const projection = projectEvents(events);
111
+ const retrieval = buildRetrievalProjection(events);
112
+ const last = events.at(-1);
113
+ const cursor = {
114
+ schemaVersion: 2,
115
+ lastEventId: last?.eventId ?? null,
116
+ lastEventHash: last?.hash ?? null,
117
+ updatedAt: last?.observedAt ?? null,
118
+ };
119
+ await removeObsoleteWorkspaceFiles(projectRoot, new Set(Object.keys(projection.workspaces)));
120
+ await Promise.all([
121
+ atomicWriteJson(cognitionContextsProjectionPath(projectRoot), projection.contexts),
122
+ atomicWriteJson(cognitionNotesProjectionPath(projectRoot), projection.notes),
123
+ atomicWriteJson(cognitionKnowledgeProjectionPath(projectRoot), projection.knowledge),
124
+ atomicWriteJson(cognitionDeadendsProjectionPath(projectRoot), projection.deadends),
125
+ atomicWriteJson(cognitionRetrievalProjectionPath(projectRoot), retrieval),
126
+ ...Object.values(projection.workspaces).map((workspace) => atomicWriteJson(`${cognitionBranchesDirectory(projectRoot)}/${workspace.workspaceKey}.json`, workspace)),
127
+ ]);
128
+ await atomicWriteJson(cognitionProjectionCursorPath(projectRoot), cursor);
129
+ return { projection, cursor };
130
+ }
@@ -0,0 +1,2 @@
1
+ import type { MigrationReadiness } from './types.js';
2
+ export declare function migrationReadiness(startPath?: string): Promise<MigrationReadiness>;