fluffy-context 0.3.0 → 0.5.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 (35) hide show
  1. package/README.md +39 -4
  2. package/dist/src/agent/api.js +20 -9
  3. package/dist/src/cli/main.js +96 -2
  4. package/dist/src/cognition/git-observer.d.ts +13 -0
  5. package/dist/src/cognition/git-observer.js +61 -0
  6. package/dist/src/cognition/journal.d.ts +24 -0
  7. package/dist/src/cognition/journal.js +113 -0
  8. package/dist/src/cognition/migration-v1.d.ts +7 -0
  9. package/dist/src/cognition/migration-v1.js +199 -0
  10. package/dist/src/cognition/orient.d.ts +9 -0
  11. package/dist/src/cognition/orient.js +85 -0
  12. package/dist/src/cognition/projections.d.ts +7 -0
  13. package/dist/src/cognition/projections.js +127 -0
  14. package/dist/src/cognition/readiness.d.ts +2 -0
  15. package/dist/src/cognition/readiness.js +81 -0
  16. package/dist/src/cognition/runtime.d.ts +12 -0
  17. package/dist/src/cognition/runtime.js +295 -0
  18. package/dist/src/cognition/types.d.ts +132 -0
  19. package/dist/src/cognition/types.js +1 -0
  20. package/dist/src/cognition/v1-bridge.d.ts +7 -0
  21. package/dist/src/cognition/v1-bridge.js +74 -0
  22. package/dist/src/git/git-adapter.d.ts +7 -0
  23. package/dist/src/git/git-adapter.js +16 -6
  24. package/dist/src/hooks/claude-code.js +53 -0
  25. package/dist/src/integrations/claude-code.js +36 -13
  26. package/dist/src/runtime/diagnostics.js +6 -0
  27. package/dist/src/runtime/knowledge.js +5 -0
  28. package/dist/src/runtime/notes.js +8 -1
  29. package/dist/src/runtime/runtime.d.ts +1 -0
  30. package/dist/src/runtime/runtime.js +28 -0
  31. package/dist/src/storage/layout.d.ts +18 -0
  32. package/dist/src/storage/layout.js +54 -0
  33. package/dist/src/version.d.ts +1 -1
  34. package/dist/src/version.js +1 -1
  35. package/package.json +1 -1
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # Context Runtime
2
2
 
3
- `fluffy-context` 是面向 AI 编程会话的本地 Context Runtime CLI。0.3.0 提供可重复、安全调用的任务定位入口、MCP 工具,以及 Claude Code 的阶段性工作流引导,让 Agent 不必自行拼接恢复、共识发现与短期记录。
3
+ `fluffy-context` 正在从面向 AI 编程会话的本地 Context Runtime CLI,演进为 **Agent 无关的本地 Knowledge Runtime**。它负责自动捕获开发过程中产生的事件、候选共识、问题、不可达路径和分支工作状态,并向任意 Agent 注入有界、可验证的业务认知。
4
4
 
5
- 它更接近“Context 的 Git”,而不是代码备份工具:Git 仍然负责代码和真实文件变更;Context Runtime 负责 AI 工作状态、已验证共识与交接信息的本地保存和恢复。
5
+ Git 仍然负责代码和真实文件变更,Issue 系统仍然负责任务,项目文档仍然负责设计;`fluffy-context` 补齐的是持续演进的项目认知层。最终架构是 `project → knowledge runtime → LLM`:CLI、MCP、Claude Hook 和未来 IDE/Agent 集成只是事件入口与信息注入适配器,而不是认知本体。
6
+
7
+ 0.5.0 在 v2 迁移、可验证事件日志、确定性投影和按项目 Runtime 基础上,完成首个 branch-aware 注入闭环:迁移并启用 Runtime 后,Orient、MCP 与 Claude Hook 会按当前 worktree/ref 选择 Context,避免跨分支任务状态泄漏。v1 的 checkpoint、Note、Knowledge 与 Deadend 写入会最佳努力镜像为 v2 事件;Runtime 也会协调检测到的外部 v1 变更。自动产物默认仍仅进入 Candidate,Verified Knowledge 仍需治理确认。
6
8
 
7
9
  ## 适用场景
8
10
 
@@ -92,10 +94,43 @@ ctx activity --context <context-id> --open
92
94
  ctx knowledge discover "支付回调"
93
95
  ctx knowledge verify <knowledge-id>
94
96
  ctx deadend verify <deadend-id>
97
+ ctx migrate v1 --dry-run
98
+ ctx migrate v1 --apply
99
+ ctx migrate verify
100
+ ctx runtime enable
101
+ ctx runtime start
102
+ ctx runtime status
103
+ ctx runtime stop
95
104
  ctx status
96
105
  ctx doctor
97
106
  ```
98
107
 
108
+ ### v2 迁移与本地 Runtime
109
+
110
+ v2 与既有 `.context/` 数据并存,迁移绝不会重写或修复 v1 文件。先执行只读检查,再显式导入并验证投影一致性:
111
+
112
+ ```bash
113
+ ctx migrate v1 --dry-run
114
+ ctx migrate v1 --apply
115
+ ctx migrate verify
116
+ ```
117
+
118
+ 只有完成迁移后才可启用 Runtime:
119
+
120
+ ```bash
121
+ ctx runtime enable
122
+ ctx runtime start
123
+ ctx runtime status
124
+ ctx runtime stop
125
+ ctx runtime disable
126
+ ```
127
+
128
+ Runtime 是单项目、按需启动的本地守护进程。它只绑定 `127.0.0.1` 的动态端口,并要求存储在项目本地 `.context/v2/runtime/secret` 中的能力令牌;令牌不会显示在 CLI 状态输出或写入事件日志。启动后,Runtime 持续观察当前 worktree 的 Git 状态:初始状态会记录 `git.state.observed`,分支/ref 切换记录 `git.branch.changed`,HEAD 更新记录 `git.head.changed`。这些事件会重建 branch workspace 投影,但不会自动创建 checkpoint 或将候选信息提升为已验证共识。
129
+
130
+ 完成迁移并显式 `ctx runtime enable` 后,`ctx orient`、MCP `context_orient` 与 Claude Hook 会优先选择当前 worktree/ref 的 v2 workspace;它不会借用其他分支的 Context。没有匹配 workspace、v2 状态不可用或 Runtime 已禁用时,接口会安全回退到既有 v1 选择逻辑。Runtime 停止时仍可读取已验证的 v2 状态,但运行中的 Runtime 负责持续 Git 观察和 v1 写入镜像。
131
+
132
+ Claude Hook 仅在宿主提供稳定的 session 与 event 标识时记录 `host.session.started` 或 `host.prompt.submitted`。提示只保存按 `promptIntentMaxChars` 截断并经过敏感值脱敏后的 intent,绝不保存原始提示;缺少稳定标识时不写入事件。默认 Hook 不启动守护进程;将 v2 policy 的 `allowHookStartup` 设为 `true` 后,SessionStart 才会在严格超时内尽力启动 Runtime,失败仍会 fail open。UserPromptSubmit 不会启动 Runtime。
133
+
99
134
  ### `ctx checkpoint` 和 `ctx resume`
100
135
 
101
136
  第一次 checkpoint 创建 baseline,后续变化创建 patch。相同内容返回 `no_change`;短时间内的变化可能返回 `rate_limited`,应在完成更多阶段工作后再保存。`ctx resume` 返回轻量摘要和按需使用的完整 details,并会保留其既有的 `lastUsedAt` 更新语义。
@@ -187,6 +222,6 @@ npm pack --dry-run --json
187
222
 
188
223
  ## 当前范围
189
224
 
190
- 0.3.0 聚焦单项目、本地优先的可靠闭环和 Agent 采用路径:有界 Orient、显式 checkpoint、确定性 Knowledge/Deadend discovery、MCP `context_orient` 与可选的 Claude Code Hook 集成。
225
+ 0.5.0 聚焦单项目、本地优先的 branch-aware Knowledge Runtime MVP:有界 Orient、显式 checkpoint、确定性 Knowledge/Deadend discovery、MCP `context_orient`、可选 Claude Code Hook,以及 v1 到 v2 的显式事件日志迁移和 Git-aware 本地 Runtime。完成迁移并启用 Runtime 后,Orient、MCP 与 Hook 使用当前 worktree/ref 的 workspace 选择分支正确的 Context,并在 v2 不可用时保持 v1 回退;Runtime 负责可靠捕获生命周期、Git 元数据与后续 v1 写入镜像。带稳定宿主标识的 Hook 事件仅以脱敏、限长 intent 进入 journal。
191
226
 
192
- 远程同步、多人协作、复杂语义检索、自动模型总结、自动 checkpoint、Context merge,以及更多状态变更型 MCP 工具不属于当前版本范围。
227
+ 远程同步、多人协作、复杂语义检索、自动模型总结、自动 checkpoint、Context merge、自动验证候选共识,以及更多状态变更型 MCP 工具不属于当前版本范围。
@@ -2,7 +2,8 @@ import { readdir } from 'node:fs/promises';
2
2
  import { resolveProjectRoot } from '../project/project-resolver.js';
3
3
  import { contextsRoot, contextMetadataPath } from '../storage/layout.js';
4
4
  import { isRecord, readJson } from '../storage/json-store.js';
5
- import { checkpoint, rebuildSnapshot, resume } from '../runtime/runtime.js';
5
+ import { checkpoint, rebuildSnapshot, resume, resumeSnapshot } from '../runtime/runtime.js';
6
+ import { selectV2Orientation } from '../cognition/orient.js';
6
7
  import { discoverDeadends, discoverKnowledge, listDeadends } from '../runtime/knowledge.js';
7
8
  import { listNotes } from '../runtime/notes.js';
8
9
  function normalized(value) {
@@ -157,7 +158,10 @@ export async function contextOrient(startPath, options = {}) {
157
158
  throw new Error('context orient query must not be empty');
158
159
  let loaded;
159
160
  try {
160
- loaded = await resume(projectRoot, options.contextId, options.maxChars ?? 4000, { touchLastUsedAt: false });
161
+ const v2 = await selectV2Orientation(projectRoot, options.contextId);
162
+ loaded = v2
163
+ ? await resumeSnapshot(projectRoot, v2.contextId, v2.snapshotId, options.maxChars ?? 4000)
164
+ : await resume(projectRoot, options.contextId, options.maxChars ?? 4000, { touchLastUsedAt: false });
161
165
  }
162
166
  catch (error) {
163
167
  if (error instanceof Error && error.message === 'no active context found') {
@@ -176,26 +180,33 @@ export async function contextOrient(startPath, options = {}) {
176
180
  }
177
181
  throw error;
178
182
  }
179
- const notes = await listNotes(projectRoot, {
183
+ const v2 = await selectV2Orientation(projectRoot, options.contextId);
184
+ const notes = (await listNotes(projectRoot, {
180
185
  contextId: loaded.context.id,
181
186
  openOnly: true,
182
- limit: options.noteLimit ?? 20,
187
+ limit: v2 ? Number.MAX_SAFE_INTEGER : options.noteLimit ?? 20,
183
188
  maxChars: options.maxChars ?? 4000,
184
- });
189
+ })).filter((note) => !v2 || v2.noteIds.has(note.noteId)).slice(0, options.noteLimit ?? 20);
185
190
  const knowledge = query === null
186
191
  ? emptyKnowledge('')
187
192
  : await discoverKnowledge(projectRoot, query, {
188
193
  scope: options.scope,
189
- limit: options.knowledgeLimit ?? 10,
194
+ limit: v2 ? Number.MAX_SAFE_INTEGER : options.knowledgeLimit ?? 10,
190
195
  maxChars: options.maxChars ?? 4000,
191
196
  });
192
197
  const deadends = query === null
193
198
  ? emptyDeadends('')
194
199
  : await discoverDeadends(projectRoot, query, {
195
200
  scope: options.scope,
196
- limit: options.deadendLimit ?? 10,
201
+ limit: v2 ? Number.MAX_SAFE_INTEGER : options.deadendLimit ?? 10,
197
202
  maxChars: options.maxChars ?? 4000,
198
203
  });
204
+ const applicableKnowledge = v2
205
+ ? { ...knowledge, total: knowledge.hits.filter((hit) => v2.knowledgeIds.has(hit.knowledge.knowledgeId)).length, hits: knowledge.hits.filter((hit) => v2.knowledgeIds.has(hit.knowledge.knowledgeId)).slice(0, options.knowledgeLimit ?? 10) }
206
+ : knowledge;
207
+ const applicableDeadends = v2
208
+ ? { ...deadends, total: deadends.hits.filter((hit) => v2.deadendIds.has(hit.deadend.deadendId)).length, hits: deadends.hits.filter((hit) => v2.deadendIds.has(hit.deadend.deadendId)).slice(0, options.deadendLimit ?? 10) }
209
+ : deadends;
199
210
  const budget = { remaining: Math.max(0, options.maxChars ?? 4000) };
200
211
  const resumeSummary = {
201
212
  progressSummary: limited(loaded.resumeSummary.progressSummary, budget),
@@ -214,8 +225,8 @@ export async function contextOrient(startPath, options = {}) {
214
225
  || textLength(resumeSummary.decisions) < textLength(loaded.resumeSummary.decisions)
215
226
  || textLength(resumeSummary.risks) < textLength(loaded.resumeSummary.risks)
216
227
  || textLength(resumeSummary.relatedFiles) < textLength(loaded.resumeSummary.relatedFiles);
217
- const limitedKnowledgeResult = limitKnowledge(knowledge, budget);
218
- const limitedDeadendResult = limitDeadends(deadends, budget);
228
+ const limitedKnowledgeResult = limitKnowledge(applicableKnowledge, budget);
229
+ const limitedDeadendResult = limitDeadends(applicableDeadends, budget);
219
230
  const limitedNotesResult = limitNotes(notes, budget);
220
231
  return {
221
232
  status: 'ready',
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { checkpoint, resume } from '../runtime/runtime.js';
3
3
  import { contextOrient } from '../agent/api.js';
4
+ import { importV1, verifyV1Import } from '../cognition/migration-v1.js';
5
+ import { disableRuntime, enableRuntime, runtimeStatus, serveRuntime, startRuntime, stopRuntime } from '../cognition/runtime.js';
6
+ import { migrationReadiness } from '../cognition/readiness.js';
4
7
  import { runClaudeCodeHook } from '../hooks/claude-code.js';
5
8
  import { inspectClaudeIntegration, installClaudeIntegration } from '../integrations/claude-code.js';
6
9
  import { doctor, status } from '../runtime/diagnostics.js';
@@ -235,9 +238,51 @@ Options:
235
238
  --open Only open notes
236
239
  --limit <number> Maximum items (default: 50)
237
240
  --max-chars <number> Maximum message characters`,
241
+ migrate: `usage: ctx migrate v1 --dry-run|--apply [--path <path>]
242
+ ctx migrate verify [--path <path>]
243
+
244
+ Inspect, explicitly import, or verify v1 Context data for the event-journal runtime.`,
245
+ 'migrate v1': `usage: ctx migrate v1 --dry-run|--apply [--path <path>]
246
+
247
+ Validate v1 Context, Snapshot, Note, Knowledge, and Deadend data for v2 migration.
248
+
249
+ Options:
250
+ --dry-run Inspect only; no v2 data is created
251
+ --apply Explicitly append immutable v1 provenance events
252
+ --path <path> Project path`,
253
+ 'migrate verify': `usage: ctx migrate verify [--path <path>]
254
+
255
+ Rebuild v2 projections and verify imported v1 parity.
256
+
257
+ Options:
258
+ --path <path> Project path`,
259
+ runtime: `usage: ctx runtime enable|disable|start|stop|status [--path <path>]
260
+
261
+ Control the local event-driven Knowledge Runtime after v1 migration verification.`,
262
+ 'runtime status': `usage: ctx runtime status [--path <path>]
263
+
264
+ Show the read-only runtime policy, migration and authenticated lease status.
265
+
266
+ Options:
267
+ --path <path> Project path`,
268
+ 'runtime enable': `usage: ctx runtime enable [--path <path>]
269
+
270
+ Explicitly enable the migrated local Knowledge Runtime.`,
271
+ 'runtime disable': `usage: ctx runtime disable [--path <path>]
272
+
273
+ Stop the local runtime and disable future startup without deleting v2 history.`,
274
+ 'runtime start': `usage: ctx runtime start [--path <path>]
275
+
276
+ Start the enabled runtime as a loopback-only background process.`,
277
+ 'runtime stop': `usage: ctx runtime stop [--path <path>]
278
+
279
+ Stop the local runtime through authenticated loopback IPC.`,
280
+ 'runtime serve': `usage: ctx runtime serve [--path <path>]
281
+
282
+ Internal runtime daemon entry point.`,
238
283
  };
239
284
  function usage() {
240
- return `usage: ctx init|checkpoint|resume|orient|agent|hook|integrate|status|doctor|learn|knowledge|deadend|deadends|note|activity [options]
285
+ return `usage: ctx init|checkpoint|resume|orient|agent|hook|integrate|status|doctor|learn|knowledge|deadend|deadends|note|activity|migrate|runtime [options]
241
286
 
242
287
  Run \"ctx <command> --help\" for command details.`;
243
288
  }
@@ -256,7 +301,15 @@ function printHelp(args) {
256
301
  process.stdout.write(`${usage()}\n`);
257
302
  return true;
258
303
  }
259
- const key = args[1] === 'verify' || args[1] === 'add' || args[1] === 'list' || args[1] === 'discover' || args[1] === 'serve' || args[1] === 'claude' ? `${command} ${args[1]}` : command;
304
+ const nested = (command === 'agent' && args[1] === 'serve')
305
+ || (command === 'hook' && args[1] === 'claude-code')
306
+ || (command === 'integrate' && args[1] === 'claude')
307
+ || (command === 'knowledge' && ['verify', 'discover'].includes(args[1]))
308
+ || (command === 'deadend' && args[1] === 'verify')
309
+ || (command === 'note' && ['add', 'list'].includes(args[1]))
310
+ || (command === 'migrate' && ['v1', 'verify'].includes(args[1]))
311
+ || (command === 'runtime' && ['enable', 'disable', 'start', 'stop', 'status', 'serve'].includes(args[1]));
312
+ const key = nested ? `${command} ${args[1]}` : command;
260
313
  const nestedKey = key === 'integrate claude' && (args[2] === 'inspect' || args[2] === 'install') ? `${key} ${args[2]}` : key;
261
314
  const help = HELP[nestedKey];
262
315
  if (!help)
@@ -413,6 +466,47 @@ async function run(args) {
413
466
  }
414
467
  return;
415
468
  }
469
+ case 'migrate': {
470
+ if (args[1] === 'v1') {
471
+ validateOptions(args.slice(2), ['--dry-run', '--apply', '--path'], ['--path']);
472
+ validatePositionals(positionals(args.slice(2), ['--path']), 0, HELP['migrate v1']);
473
+ if (args.includes('--dry-run') === args.includes('--apply'))
474
+ throw new Error(HELP['migrate v1']);
475
+ print(args.includes('--apply') ? await importV1(target) : await migrationReadiness(target));
476
+ }
477
+ else if (args[1] === 'verify') {
478
+ validateOptions(args.slice(2), ['--path'], ['--path']);
479
+ validatePositionals(positionals(args.slice(2), ['--path']), 0, HELP['migrate verify']);
480
+ print(await verifyV1Import(target));
481
+ }
482
+ else {
483
+ throw new Error(HELP.migrate);
484
+ }
485
+ return;
486
+ }
487
+ case 'runtime': {
488
+ if (!['enable', 'disable', 'start', 'stop', 'status', 'serve'].includes(args[1]))
489
+ throw new Error(HELP.runtime);
490
+ validateOptions(args.slice(2), ['--path'], ['--path']);
491
+ validatePositionals(positionals(args.slice(2), ['--path']), 0, HELP[`runtime ${args[1]}`]);
492
+ if (args[1] === 'enable')
493
+ print(await enableRuntime(target));
494
+ else if (args[1] === 'disable')
495
+ print(await disableRuntime(target));
496
+ else if (args[1] === 'start')
497
+ print(await startRuntime(target));
498
+ else if (args[1] === 'stop')
499
+ print(await stopRuntime(target));
500
+ else if (args[1] === 'status')
501
+ print(await runtimeStatus(target));
502
+ else {
503
+ const runtime = await serveRuntime(target);
504
+ process.once('SIGINT', () => { void runtime.close('signal'); });
505
+ process.once('SIGTERM', () => { void runtime.close('signal'); });
506
+ await new Promise(() => undefined);
507
+ }
508
+ return;
509
+ }
416
510
  case 'activity': {
417
511
  const valueOptions = ['--path', '--context', '--since', '--limit', '--max-chars'];
418
512
  validateOptions(args.slice(1), [...valueOptions, '--open'], valueOptions);
@@ -0,0 +1,13 @@
1
+ import { type GitWorktreeState } from '../git/git-adapter.js';
2
+ export declare class GitObserver {
3
+ private readonly projectRoot;
4
+ private previous;
5
+ private pending;
6
+ constructor(projectRoot: string);
7
+ observeNow(): Promise<{
8
+ state: GitWorktreeState;
9
+ changed: boolean;
10
+ }>;
11
+ drain(): Promise<void>;
12
+ private observe;
13
+ }
@@ -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
+ }>;