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.
- package/README.md +39 -4
- package/dist/src/agent/api.js +20 -9
- package/dist/src/cli/main.js +96 -2
- package/dist/src/cognition/git-observer.d.ts +13 -0
- package/dist/src/cognition/git-observer.js +61 -0
- package/dist/src/cognition/journal.d.ts +24 -0
- package/dist/src/cognition/journal.js +113 -0
- package/dist/src/cognition/migration-v1.d.ts +7 -0
- package/dist/src/cognition/migration-v1.js +199 -0
- package/dist/src/cognition/orient.d.ts +9 -0
- package/dist/src/cognition/orient.js +85 -0
- package/dist/src/cognition/projections.d.ts +7 -0
- package/dist/src/cognition/projections.js +127 -0
- package/dist/src/cognition/readiness.d.ts +2 -0
- package/dist/src/cognition/readiness.js +81 -0
- package/dist/src/cognition/runtime.d.ts +12 -0
- package/dist/src/cognition/runtime.js +295 -0
- package/dist/src/cognition/types.d.ts +132 -0
- package/dist/src/cognition/types.js +1 -0
- package/dist/src/cognition/v1-bridge.d.ts +7 -0
- package/dist/src/cognition/v1-bridge.js +74 -0
- package/dist/src/git/git-adapter.d.ts +7 -0
- package/dist/src/git/git-adapter.js +16 -6
- package/dist/src/hooks/claude-code.js +53 -0
- package/dist/src/integrations/claude-code.js +36 -13
- package/dist/src/runtime/diagnostics.js +6 -0
- package/dist/src/runtime/knowledge.js +5 -0
- package/dist/src/runtime/notes.js +8 -1
- package/dist/src/runtime/runtime.d.ts +1 -0
- package/dist/src/runtime/runtime.js +28 -0
- package/dist/src/storage/layout.d.ts +18 -0
- package/dist/src/storage/layout.js +54 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Context Runtime
|
|
2
2
|
|
|
3
|
-
`fluffy-context`
|
|
3
|
+
`fluffy-context` 正在从面向 AI 编程会话的本地 Context Runtime CLI,演进为 **Agent 无关的本地 Knowledge Runtime**。它负责自动捕获开发过程中产生的事件、候选共识、问题、不可达路径和分支工作状态,并向任意 Agent 注入有界、可验证的业务认知。
|
|
4
4
|
|
|
5
|
-
|
|
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.
|
|
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
|
|
227
|
+
远程同步、多人协作、复杂语义检索、自动模型总结、自动 checkpoint、Context merge、自动验证候选共识,以及更多状态变更型 MCP 工具不属于当前版本范围。
|
package/dist/src/agent/api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
218
|
-
const limitedDeadendResult = limitDeadends(
|
|
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',
|
package/dist/src/cli/main.js
CHANGED
|
@@ -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
|
|
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
|
+
}>;
|