ruvnet-brain 4.3.20 → 4.3.25
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 +5 -5
- package/bin/install.mjs +383 -78
- package/console/app.js +141 -9
- package/console/index.html +51 -24
- package/console/scope.css +137 -0
- package/console/scope.html +144 -0
- package/console/scope.js +209 -0
- package/console/tips.html +1 -0
- package/kb/corpus-release-identity.mjs +239 -0
- package/kb/update-storage-transaction.mjs +20 -3
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +61 -0
- package/plugin/hooks/codex-hooks.json +64 -1
- package/plugin/hooks/hook-contracts.json +299 -6
- package/plugin/hooks/hooks.json +81 -1
- package/plugin/mcp/server.mjs +23 -0
- package/plugin/scripts/advocacy-catalog.mjs +245 -0
- package/plugin/scripts/advocacy-route.mjs +460 -0
- package/plugin/scripts/continuation-gate.mjs +25 -2
- package/plugin/scripts/continuation-objective.mjs +7 -1
- package/plugin/scripts/continuity-hook-policy.mjs +190 -15
- package/plugin/scripts/coverage-integrity.mjs +7 -0
- package/plugin/scripts/gates.mjs +113 -10
- package/plugin/scripts/grounding-turn-gate.mjs +167 -0
- package/plugin/scripts/grounding-turn-mark.mjs +91 -0
- package/plugin/scripts/hook-shim.mjs +14 -0
- package/plugin/scripts/host-shell-boundary.mjs +43 -0
- package/plugin/scripts/nightly-scheduler.mjs +37 -4
- package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
- package/plugin/scripts/project-progression-contract.mjs +16 -0
- package/plugin/scripts/project-progression-hook.mjs +3 -0
- package/plugin/scripts/project-progression-producer.mjs +252 -0
- package/plugin/scripts/project-progression-reader.mjs +271 -0
- package/plugin/scripts/project-progression-session-start.mjs +93 -16
- package/plugin/scripts/project-progression-sources.mjs +220 -0
- package/plugin/scripts/project-progression-store.mjs +106 -13
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
- package/plugin/scripts/session-snapshot-hook.mjs +115 -7
- package/plugin/scripts/session-start-budget.mjs +59 -0
- package/plugin/scripts/session-start-core.mjs +234 -457
- package/plugin/scripts/session-start-fsutil.mjs +61 -0
- package/plugin/scripts/session-start-health.mjs +64 -0
- package/plugin/scripts/session-start-hook-description.mjs +45 -0
- package/plugin/scripts/session-start-issue-alert.mjs +77 -0
- package/plugin/scripts/session-start-repo-identity.mjs +54 -0
- package/plugin/scripts/session-start-signals.mjs +73 -0
- package/plugin/scripts/session-start-trace.mjs +86 -0
- package/plugin/scripts/session-start-update-plane.mjs +104 -0
- package/plugin/scripts/unprompted-runtime.mjs +32 -2
- package/plugin/scripts/update-apply.mjs +2 -32
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
- package/scripts/adr-072-completion.mjs +1 -1
- package/scripts/agentdb-fleet-doctor.mjs +5 -1
- package/scripts/approved-runtime.mjs +197 -0
- package/scripts/brain-novice-50.mjs +16 -1
- package/scripts/brain-score.mjs +23 -5
- package/scripts/build-bundle.mjs +971 -530
- package/scripts/build-concepts.mjs +36 -116
- package/scripts/console-engine.test.mjs +8 -7
- package/scripts/console-runtime-identity.mjs +4 -0
- package/scripts/corpus-aggregates.mjs +94 -77
- package/scripts/corpus-candidate.mjs +475 -222
- package/scripts/corpus-next-seed.mjs +225 -0
- package/scripts/corpus-promotion.mjs +58 -0
- package/scripts/corpus-reconcile.mjs +411 -105
- package/scripts/doc-currency.mjs +16 -1
- package/scripts/dual-host-deliberation.mjs +25 -2
- package/scripts/dual-host-suggest.mjs +17 -1
- package/scripts/falsify.mjs +13 -3
- package/scripts/gist-receipts.mjs +482 -87
- package/scripts/github-health-watch.mjs +12 -2
- package/scripts/handoff-asset.mjs +34 -0
- package/scripts/hook-retirement-check.mjs +8 -1
- package/scripts/host-registry.mjs +1 -1
- package/scripts/ingest-gists.mjs +74 -101
- package/scripts/job-heartbeat.sh +77 -14
- package/scripts/learning-replay-execution.mjs +10 -4
- package/scripts/nightly-gists.sh +27 -13
- package/scripts/nightly-two-run-proof.mjs +1 -1
- package/scripts/nightly-watchdog.mjs +61 -4
- package/scripts/onboarding-console.mjs +319 -27
- package/scripts/oracle/produce-questions.mjs +293 -0
- package/scripts/oracle/producer-hosts.mjs +235 -0
- package/scripts/oracle/repo-recall.mjs +448 -0
- package/scripts/oracle/retrieval-accuracy.mjs +818 -0
- package/scripts/oracle/source-tree.mjs +165 -0
- package/scripts/oracle/source-units.mjs +391 -0
- package/scripts/oracle/spike-run.mjs +98 -0
- package/scripts/oracle/unit-inventory.mjs +141 -0
- package/scripts/oracle/unit-sampling.mjs +128 -0
- package/scripts/oracle/validate-labels.mjs +250 -0
- package/scripts/private-overlay.mjs +248 -0
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/proxy/claude-proxied.sh +6 -0
- package/scripts/proxy/proxy-revert.sh +5 -0
- package/scripts/proxy/proxy-up.sh +6 -0
- package/scripts/proxy/proxy-verify.mjs +4 -0
- package/scripts/public-inputs.mjs +409 -0
- package/scripts/public-verification-inputs.mjs +112 -26
- package/scripts/public-verification-lane.mjs +1 -1
- package/scripts/published-surface-probe.mjs +34 -4
- package/scripts/qe/card-lane-gate.mjs +16 -1
- package/scripts/qe/session-start-gate.mjs +16 -1
- package/scripts/rebuild-gists-from-receipts.mjs +58 -78
- package/scripts/record-lesson.mjs +4 -1
- package/scripts/rehearse-corpus-pipeline.mjs +994 -0
- package/scripts/release-abort-stale.mjs +5 -1
- package/scripts/release-authority.mjs +104 -12
- package/scripts/release-channel-kind.mjs +86 -0
- package/scripts/release-convergence-watchdog.mjs +7 -2
- package/scripts/release-projection.mjs +177 -72
- package/scripts/release-transaction-provider.mjs +23 -6
- package/scripts/release.mjs +252 -17
- package/scripts/retrieval-canary.mjs +87 -0
- package/scripts/rvf-index-audit.mjs +573 -13
- package/scripts/rvf-wire.mjs +269 -0
- package/scripts/seal-gist-receipt.mjs +65 -0
- package/scripts/selfcheck.mjs +42 -21
- package/scripts/source-coverage.mjs +253 -24
- package/scripts/status-honesty.mjs +25 -0
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-version.mjs +2 -0
- package/scripts/trismart.mjs +42 -0
- package/scripts/updater-manifest.mjs +162 -0
- package/scripts/verify-channels.mjs +17 -5
- package/scripts/wired-check.mjs +48 -10
- package/tri-smart-skill/QUICKSTART.md +37 -0
- package/tri-smart-skill/README.md +92 -0
- package/tri-smart-skill/install.cmd +14 -0
- package/tri-smart-skill/install.command +13 -0
- package/tri-smart-skill/install.mjs +51 -0
- package/tri-smart-skill/install.sh +9 -0
- package/tri-smart-skill/tri-smart/SKILL.md +90 -0
- package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
- package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
- package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
- package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
- package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
- package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
- package/scripts/corpus-seed-publish.mjs +0 -110
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-progression-reader.mjs — the READ-ONLY fast path for project continuity restoration.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS (measured, not supposed). `restoreProgressionForSession` gives the whole restore
|
|
5
|
+
* SESSION_CONTINUITY_DEADLINE_MS = 2500ms, and every read it performs is a separate `ruflo memory`
|
|
6
|
+
* PROCESS. Measured on this machine (ruflo 3.41.2, Node v24.18.0, warm Transformers cache), in an
|
|
7
|
+
* isolated temp git repo, with `plugin/scripts/project-progression-session-start.mjs` unchanged:
|
|
8
|
+
*
|
|
9
|
+
* 1 snapshot → restore 923 / 952 / 1001 ms → "PROJECT CONTINUITY RESTORED"
|
|
10
|
+
* 6 snapshots → restore 2519 / 2530 / 3181 ms → "PROJECT CONTINUITY UNKNOWN" (restore-failed)
|
|
11
|
+
*
|
|
12
|
+
* The cost is O(N) PROCESS SPAWNS: one `memory list` plus one `memory retrieve` per snapshot, each
|
|
13
|
+
* ~400ms of Node + CLI boot. So continuity does not fail loudly on day one — it decays silently as
|
|
14
|
+
* the journal grows, and the seventh checkpoint is the one that switches a project's memory off.
|
|
15
|
+
* A grep of the installed CLI (memory/memory-initializer.js, commands/memory.js, memory/memory-bridge.js)
|
|
16
|
+
* found exactly three memory env switches — CLAUDE_FLOW_DB_PATH, CLAUDE_FLOW_DISABLE_BRIDGE,
|
|
17
|
+
* CLAUDE_FLOW_MEMORY_PATH (plus RUFLO_MEMORY_SCAN_ON_WRITE) — and none of them skips CLI boot, so
|
|
18
|
+
* there is no flag to reach for. The per-call floor stays ~400ms however it is invoked.
|
|
19
|
+
*
|
|
20
|
+
* WHAT THIS DOES. Node 24 ships `node:sqlite`, so the same rows can be read in-process, read-only,
|
|
21
|
+
* in about a millisecond. `~/.claude/hooks/agentdb-ensure.sh` already reads this exact file directly
|
|
22
|
+
* with `sqlite3`, so direct reads of memory.db are the established precedent, not a new liberty.
|
|
23
|
+
*
|
|
24
|
+
* WHAT THIS DELIBERATELY DOES NOT DO. It never writes. `ruflo memory store` remains the ONLY writer
|
|
25
|
+
* of memory.db (upstream #2786/#3155/#3196 keep memory.db and agentdb-memory.db separate on purpose;
|
|
26
|
+
* a second writer is exactly the two-writers-one-path footgun the memory policy exists to stop). It
|
|
27
|
+
* is also strictly a FALLBACK-ABLE optimisation: any reason it cannot answer authoritatively —
|
|
28
|
+
* no node:sqlite, absent file, an encrypted image (CLAUDE_FLOW_ENCRYPT_AT_REST writes an "RFE1"
|
|
29
|
+
* blob, not a SQLite file), a WAL sidecar it cannot open read-only, a schema that is not this one —
|
|
30
|
+
* raises ProgressionReaderUnavailable, and the caller replays the whole operation through the CLI.
|
|
31
|
+
*
|
|
32
|
+
* Reading directly also AVOIDS a hazard the CLI has: `getEntry` bumps access_count and then rewrites
|
|
33
|
+
* the WHOLE database image (memory-initializer.js:3070-3077). Every "read" during a restore is
|
|
34
|
+
* therefore a full-image write under a lock. This path takes no lock and mutates nothing.
|
|
35
|
+
*
|
|
36
|
+
* STRUCTURAL GUARANTEES ARE UNCHANGED, because they are the point of the restore and not negotiable:
|
|
37
|
+
* • COMPLETE ENUMERATION — one statement returns every active row in the namespace; a count past
|
|
38
|
+
* the caller's bound is an error, never a silent truncation (the CLI path's own failure mode).
|
|
39
|
+
* • EXACT KEY/PAYLOAD IDENTITY — rows are fetched by (namespace, key); two active rows sharing one
|
|
40
|
+
* key is a structural error, not a LIMIT-1 coin toss.
|
|
41
|
+
* • DIGEST VERIFICATION — untouched: the caller still validates payloadDigest on every snapshot.
|
|
42
|
+
*/
|
|
43
|
+
import fs from 'node:fs';
|
|
44
|
+
import { createRequire } from 'node:module';
|
|
45
|
+
|
|
46
|
+
/** Rows that `ruflo memory` itself considers live (memory-initializer.js ACTIVE_MEMORY_ROW_SQL). */
|
|
47
|
+
const ACTIVE_ROW_SQL = "(status = 'active' OR status IS NULL)";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every SQLite database file begins with this 16-byte header (sqlite.org/fileformat.html §1.3):
|
|
51
|
+
* the ASCII text "SQLite format 3" followed by one NUL. Built from bytes rather than written as a
|
|
52
|
+
* string escape so no editor, patch tool, or copy-paste can silently turn that NUL into a space —
|
|
53
|
+
* which would make the header never match and quietly disable this whole fast path.
|
|
54
|
+
*/
|
|
55
|
+
const SQLITE_HEADER = Buffer.concat([Buffer.from('SQLite format 3', 'latin1'), Buffer.of(0)]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Raised when this reader cannot answer AUTHORITATIVELY. It is never a verdict about the data — it
|
|
59
|
+
* means "ask the CLI instead". A structural violation of the guarantees above throws a plain Error,
|
|
60
|
+
* which must propagate: downgrading "two rows claim one key" to a CLI retry would convert a
|
|
61
|
+
* detectable corruption into a silent, successful, wrong restore.
|
|
62
|
+
*/
|
|
63
|
+
export class ProgressionReaderUnavailable extends Error {
|
|
64
|
+
constructor(reason) {
|
|
65
|
+
super(`canonical progression reader unavailable: ${reason}`);
|
|
66
|
+
this.name = 'ProgressionReaderUnavailable';
|
|
67
|
+
this.reason = reason;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* THE PINNED SCHEMA FINGERPRINT — captured from the REAL canonical store on this machine
|
|
73
|
+
* (ruflo 3.41.2, a real project's own <project>/.swarm/memory.db), not transcribed from source:
|
|
74
|
+
*
|
|
75
|
+
* PRAGMA user_version -> 0
|
|
76
|
+
* PRAGMA table_info(memory_entries) -> the 18 columns below
|
|
77
|
+
*
|
|
78
|
+
* WHY PIN IT. Reading someone else's table is only safe while it is the table you measured. If a
|
|
79
|
+
* later ruflo renames `content`, adds a second liveness column, or partitions rows, a reader that
|
|
80
|
+
* merely SELECTs would answer confidently and WRONGLY — and the wrong answer here is "this project
|
|
81
|
+
* has no history", which is indistinguishable from a fresh project. So a fingerprint mismatch is not
|
|
82
|
+
* an error: it is this module standing down so the CLI, which OWNS the schema, answers instead.
|
|
83
|
+
* Slower is a cost. Silently empty is a lie.
|
|
84
|
+
*/
|
|
85
|
+
const SCHEMA_FINGERPRINT = Object.freeze({
|
|
86
|
+
userVersion: 0,
|
|
87
|
+
columns: Object.freeze([
|
|
88
|
+
'access_count', 'content', 'created_at', 'embedding', 'embedding_dimensions', 'embedding_model',
|
|
89
|
+
'expires_at', 'id', 'key', 'last_accessed_at', 'metadata', 'namespace', 'owner_id',
|
|
90
|
+
'provenance_type', 'status', 'tags', 'type', 'updated_at',
|
|
91
|
+
]),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/** The fingerprint this module requires, so the doctor and tests can name it exactly. */
|
|
95
|
+
export function expectedSchemaFingerprint() {
|
|
96
|
+
return { userVersion: SCHEMA_FINGERPRINT.userVersion, columns: [...SCHEMA_FINGERPRINT.columns] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function assertSchemaFingerprint(database) {
|
|
100
|
+
let columns;
|
|
101
|
+
let userVersion;
|
|
102
|
+
try {
|
|
103
|
+
columns = database.prepare('PRAGMA table_info(memory_entries)').all().map((row) => String(row.name)).sort();
|
|
104
|
+
userVersion = database.prepare('PRAGMA user_version').get()?.user_version;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw new ProgressionReaderUnavailable(`schema mismatch: ${error.message}`);
|
|
107
|
+
}
|
|
108
|
+
if (columns.length === 0) throw new ProgressionReaderUnavailable('schema mismatch: memory_entries is absent');
|
|
109
|
+
if (userVersion !== SCHEMA_FINGERPRINT.userVersion) {
|
|
110
|
+
throw new ProgressionReaderUnavailable(
|
|
111
|
+
`schema fingerprint mismatch: user_version ${userVersion} is not ${SCHEMA_FINGERPRINT.userVersion}`);
|
|
112
|
+
}
|
|
113
|
+
if (columns.join(',') !== SCHEMA_FINGERPRINT.columns.join(',')) {
|
|
114
|
+
const missing = SCHEMA_FINGERPRINT.columns.filter((name) => !columns.includes(name));
|
|
115
|
+
const added = columns.filter((name) => !SCHEMA_FINGERPRINT.columns.includes(name));
|
|
116
|
+
throw new ProgressionReaderUnavailable('schema fingerprint mismatch: memory_entries columns differ'
|
|
117
|
+
+ `${missing.length ? ` (missing ${missing.join('/')})` : ''}`
|
|
118
|
+
+ `${added.length ? ` (unexpected ${added.join('/')})` : ''}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let sqliteBinding;
|
|
123
|
+
function databaseSync() {
|
|
124
|
+
if (sqliteBinding === undefined) {
|
|
125
|
+
try {
|
|
126
|
+
sqliteBinding = createRequire(import.meta.url)('node:sqlite').DatabaseSync ?? null;
|
|
127
|
+
} catch { sqliteBinding = null; }
|
|
128
|
+
}
|
|
129
|
+
return sqliteBinding;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** True when this Node build exposes node:sqlite at all. Exported for diagnostics and tests. */
|
|
133
|
+
export function canonicalReaderSupported() {
|
|
134
|
+
return typeof databaseSync() === 'function';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function looksLikeSqliteFile(dbPath) {
|
|
138
|
+
let handle;
|
|
139
|
+
try {
|
|
140
|
+
handle = fs.openSync(dbPath, 'r');
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const head = Buffer.alloc(SQLITE_HEADER.length);
|
|
146
|
+
const read = fs.readSync(handle, head, 0, head.length, 0);
|
|
147
|
+
return read === head.length && head.equals(SQLITE_HEADER);
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
} finally {
|
|
151
|
+
fs.closeSync(handle);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function requireKey(value) {
|
|
156
|
+
if (typeof value !== 'string' || !value) throw new Error('malformed progression row: missing key');
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Open a read-only view of one canonical memory.db.
|
|
162
|
+
*
|
|
163
|
+
* @returns {{ listKeys: Function, readContent: Function, close: Function }}
|
|
164
|
+
* @throws {ProgressionReaderUnavailable} when the CLI must be used instead.
|
|
165
|
+
*/
|
|
166
|
+
export function openProgressionReader(dbPath) {
|
|
167
|
+
const DatabaseSync = databaseSync();
|
|
168
|
+
if (typeof DatabaseSync !== 'function') throw new ProgressionReaderUnavailable('node:sqlite is unavailable');
|
|
169
|
+
if (typeof dbPath !== 'string' || !dbPath) throw new ProgressionReaderUnavailable('no canonical store path');
|
|
170
|
+
let stat;
|
|
171
|
+
try { stat = fs.statSync(dbPath); } catch { throw new ProgressionReaderUnavailable('canonical store does not exist'); }
|
|
172
|
+
if (!stat.isFile()) throw new ProgressionReaderUnavailable('canonical store is not a regular file');
|
|
173
|
+
// An encrypted image (CLAUDE_FLOW_ENCRYPT_AT_REST) starts with the vault's "RFE1" magic, so the
|
|
174
|
+
// header check covers encryption, truncation and any other non-SQLite blob in one test — before
|
|
175
|
+
// node:sqlite gets a chance to report the generic "file is not a database".
|
|
176
|
+
if (!looksLikeSqliteFile(dbPath)) throw new ProgressionReaderUnavailable('canonical store is not a plain SQLite image');
|
|
177
|
+
|
|
178
|
+
let database;
|
|
179
|
+
try {
|
|
180
|
+
database = new DatabaseSync(dbPath, { readOnly: true });
|
|
181
|
+
} catch (error) {
|
|
182
|
+
throw new ProgressionReaderUnavailable(`read-only open failed: ${error.message}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
assertSchemaFingerprint(database);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
try { database.close(); } catch { /* the fingerprint verdict is the news */ }
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const prepare = (sql) => {
|
|
193
|
+
try { return database.prepare(sql); } catch (error) {
|
|
194
|
+
throw new ProgressionReaderUnavailable(`schema mismatch: ${error.message}`);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
let listStatement;
|
|
198
|
+
let readStatement;
|
|
199
|
+
try {
|
|
200
|
+
// Prepared eagerly so an unexpected schema (or a WAL image this process cannot read) is
|
|
201
|
+
// reported as UNAVAILABLE now, before the caller has committed to the fast path.
|
|
202
|
+
listStatement = prepare(`SELECT key FROM memory_entries WHERE ${ACTIVE_ROW_SQL} AND namespace = ? ORDER BY key`);
|
|
203
|
+
readStatement = prepare(`SELECT content FROM memory_entries WHERE ${ACTIVE_ROW_SQL} AND namespace = ? AND key = ?`);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
try { database.close(); } catch { /* the open failure is the news */ }
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const query = (statement, params) => {
|
|
210
|
+
try { return statement.all(...params); } catch (error) {
|
|
211
|
+
throw new ProgressionReaderUnavailable(`read failed: ${error.message}`);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
/** Every active key in `namespace`, sorted, deduplicated-by-error, bounded. */
|
|
217
|
+
listKeys(namespace, { maxEntries = 10_000 } = {}) {
|
|
218
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) throw new TypeError('maxEntries must be a positive safe integer');
|
|
219
|
+
const rows = query(listStatement, [namespace]);
|
|
220
|
+
if (rows.length > maxEntries) throw new Error('progression enumeration exceeds its bound');
|
|
221
|
+
const keys = [];
|
|
222
|
+
const seen = new Set();
|
|
223
|
+
for (const row of rows) {
|
|
224
|
+
const key = requireKey(row?.key);
|
|
225
|
+
if (seen.has(key)) throw new Error(`duplicate progression key in store: ${key}`);
|
|
226
|
+
seen.add(key);
|
|
227
|
+
keys.push(key);
|
|
228
|
+
}
|
|
229
|
+
return keys;
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
/** The exact stored value for one (namespace, key), or null when that row does not exist. */
|
|
233
|
+
readContent(namespace, key) {
|
|
234
|
+
requireKey(key);
|
|
235
|
+
const rows = query(readStatement, [namespace, key]);
|
|
236
|
+
if (rows.length === 0) return null;
|
|
237
|
+
if (rows.length > 1) throw new Error(`duplicate progression key in store: ${key}`);
|
|
238
|
+
const content = rows[0]?.content;
|
|
239
|
+
// A non-text column is not this schema; fall back rather than guess at an encoding.
|
|
240
|
+
if (typeof content !== 'string') throw new ProgressionReaderUnavailable('progression row content is not text');
|
|
241
|
+
return content;
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
close() {
|
|
245
|
+
try { database.close(); } catch { /* closing a spent read handle is never news */ }
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Run `work(reader)` against a read-only view, closing it afterwards.
|
|
252
|
+
* Returns `{ ok: true, value }`, or `{ ok: false, reason }` when the CLI must be used instead.
|
|
253
|
+
* Structural errors are NOT converted — they propagate, by design (see ProgressionReaderUnavailable).
|
|
254
|
+
*/
|
|
255
|
+
export function withProgressionReader(dbPath, work) {
|
|
256
|
+
let reader;
|
|
257
|
+
try {
|
|
258
|
+
reader = openProgressionReader(dbPath);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error instanceof ProgressionReaderUnavailable) return { ok: false, reason: error.reason };
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
return { ok: true, value: work(reader) };
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error instanceof ProgressionReaderUnavailable) return { ok: false, reason: error.reason };
|
|
267
|
+
throw error;
|
|
268
|
+
} finally {
|
|
269
|
+
reader.close();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -3,6 +3,9 @@ import path from 'node:path';
|
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import { ProjectProgressionStore } from './project-progression-store.mjs';
|
|
5
5
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
6
|
+
import { withProgressionReader } from './project-progression-reader.mjs';
|
|
7
|
+
|
|
8
|
+
const PROGRESSION_NAMESPACE = 'project-progression';
|
|
6
9
|
|
|
7
10
|
export const SESSION_CONTINUITY_LIMIT_BYTES = 8 * 1024;
|
|
8
11
|
export const SESSION_CONTINUITY_DEADLINE_MS = 2_500;
|
|
@@ -21,17 +24,56 @@ const UNKNOWN_EXPLANATIONS = Object.freeze({
|
|
|
21
24
|
'output-bound': 'The verified resume payload exceeds the host context bound.',
|
|
22
25
|
'no-coherent-state': 'No coherent progression head survived validation.',
|
|
23
26
|
'restore-failed': 'The exact structural restore did not complete.',
|
|
27
|
+
// MEASURED, and named rather than hidden. The in-process read path costs ~15ms for six snapshots;
|
|
28
|
+
// the `ruflo memory` CLI fallback costs 3634ms for the same six and does NOT fit the 2500ms
|
|
29
|
+
// restore deadline. Both produce an identical result, so this is a SPEED limit, not a correctness
|
|
30
|
+
// one — but a user whose store is encrypted, or whose Node lacks node:sqlite, will lose continuity
|
|
31
|
+
// past a handful of snapshots, and they deserve to be told which of the two it was.
|
|
32
|
+
'fallback-too-slow': 'The in-process read path was unavailable, and the managed Ruflo CLI fallback'
|
|
33
|
+
+ ' could not finish inside the restore deadline.',
|
|
24
34
|
});
|
|
25
35
|
|
|
26
|
-
|
|
36
|
+
/**
|
|
37
|
+
* UNKNOWN IS NOT NEUTRAL WHEN THERE WAS SOMETHING TO FIND.
|
|
38
|
+
*
|
|
39
|
+
* "I could not read your store" and "your store is empty" render almost identically to a reader, and
|
|
40
|
+
* the first is a FAILURE: verified progression exists on disk and the session is about to proceed
|
|
41
|
+
* without it. So when the canonical store exists and holds progression rows, the banner says so in
|
|
42
|
+
* as many words and the result carries `severity: 'error'` for any surface that colours its output.
|
|
43
|
+
* When we cannot even count the rows, the severity stays 'warning' — claiming a failure we cannot
|
|
44
|
+
* evidence would be the same sin one step over.
|
|
45
|
+
*/
|
|
46
|
+
function unknown(reason, { rowCount = null } = {}) {
|
|
27
47
|
const explanation = UNKNOWN_EXPLANATIONS[reason] ?? UNKNOWN_EXPLANATIONS['restore-failed'];
|
|
48
|
+
const failing = Number.isInteger(rowCount) && rowCount > 0;
|
|
49
|
+
const header = failing ? `${UNKNOWN_HEADER} — RESTORE FAILED` : UNKNOWN_HEADER;
|
|
50
|
+
const evidence = failing
|
|
51
|
+
? ` ${rowCount} verified progression row(s) are present in the canonical store and could NOT be restored;`
|
|
52
|
+
+ ' treat this as a failure to recover known state, not as a project without history.'
|
|
53
|
+
: '';
|
|
28
54
|
return {
|
|
29
55
|
status: 'unknown',
|
|
30
56
|
reason,
|
|
31
|
-
|
|
57
|
+
severity: failing ? 'error' : 'warning',
|
|
58
|
+
rowCount,
|
|
59
|
+
context: `${header}\n${explanation}${evidence}`
|
|
60
|
+
+ ' Do not claim project state was restored; verify the canonical store before relying on remembered state.',
|
|
32
61
|
};
|
|
33
62
|
}
|
|
34
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Count committed progression rows WITHOUT paying for a restore. Used only to decide how loudly an
|
|
66
|
+
* UNKNOWN should speak, so it never throws and never falls back to a CLI spawn: a count we cannot
|
|
67
|
+
* take cheaply is reported as null ("cannot tell"), which downgrades the banner rather than the run.
|
|
68
|
+
*/
|
|
69
|
+
function committedRowCount(canonicalAgentDbPath) {
|
|
70
|
+
try {
|
|
71
|
+
const result = withProgressionReader(canonicalAgentDbPath,
|
|
72
|
+
(reader) => reader.listKeys(PROGRESSION_NAMESPACE).length);
|
|
73
|
+
return result.ok ? result.value : null;
|
|
74
|
+
} catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
35
77
|
function classify(error) {
|
|
36
78
|
const message = String(error?.message ?? error ?? '');
|
|
37
79
|
if (/ruflo was not found|managed global ruflo/i.test(message)) return 'managed-ruflo-unavailable';
|
|
@@ -95,16 +137,33 @@ function initializeCanonicalStore(store, resolution) {
|
|
|
95
137
|
}
|
|
96
138
|
}
|
|
97
139
|
|
|
140
|
+
/**
|
|
141
|
+
* One line, only when there is something to say. Durable-but-uncommitted snapshots are evidence the
|
|
142
|
+
* session must know about: they will be committed at the next capture boundary, and until then the
|
|
143
|
+
* restored head is not the newest thing that happened.
|
|
144
|
+
*/
|
|
145
|
+
function pendingNotice(pendingReplay) {
|
|
146
|
+
if (!Number.isInteger(pendingReplay) || pendingReplay < 1) return '';
|
|
147
|
+
return `\n${pendingReplay} uncommitted snapshot(s) pending replay; they commit at the next capture`
|
|
148
|
+
+ ' boundary (Stop / PreCompact / SessionEnd) or when you run /ruvnet-brain:checkpoint.';
|
|
149
|
+
}
|
|
150
|
+
|
|
98
151
|
function isProject(resolution) {
|
|
99
152
|
if (resolution.kind === 'git') return true;
|
|
100
153
|
return ['.swarm', '.claude-flow', 'package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod']
|
|
101
154
|
.some((name) => fs.existsSync(path.join(resolution.projectRoot, name)));
|
|
102
155
|
}
|
|
103
156
|
|
|
104
|
-
|
|
157
|
+
/**
|
|
158
|
+
* ADR-073 §6 — continuity-unavailable. A directory that is not a writable adopted project has no
|
|
159
|
+
* continuity to restore and no store to blame, so this is neither a success nor a failure: it is the
|
|
160
|
+
* absence of the question. Distinct from `unknown`, which means the question was asked and missed.
|
|
161
|
+
*/
|
|
162
|
+
function unavailable(reason) {
|
|
105
163
|
return {
|
|
106
164
|
status: 'unavailable',
|
|
107
165
|
reason,
|
|
166
|
+
severity: 'info',
|
|
108
167
|
context: '[RuvNet Brain — PROJECT CONTINUITY UNAVAILABLE]\n'
|
|
109
168
|
+ 'This working directory is not a writable adopted project. No AgentDB store was created and no project state was restored.',
|
|
110
169
|
};
|
|
@@ -132,14 +191,17 @@ export function restoreProgressionForSession({
|
|
|
132
191
|
return unknown('canonical-path');
|
|
133
192
|
}
|
|
134
193
|
|
|
135
|
-
if (!isProject(resolution)) return
|
|
136
|
-
if (!writable(resolution.projectRoot)) return
|
|
194
|
+
if (!isProject(resolution)) return unavailable('non-project');
|
|
195
|
+
if (!writable(resolution.projectRoot)) return unavailable('read-only');
|
|
137
196
|
const initializing = !fs.existsSync(resolution.canonicalAgentDbPath);
|
|
197
|
+
const rowCount = initializing ? 0 : committedRowCount(resolution.canonicalAgentDbPath);
|
|
198
|
+
const miss = (reason) => unknown(reason, { rowCount });
|
|
138
199
|
|
|
139
200
|
const prefix = `${RESTORED_HEADER}\n`;
|
|
140
201
|
const payloadLimit = maxOutputBytes - Buffer.byteLength(prefix, 'utf8');
|
|
141
|
-
if (!Number.isSafeInteger(payloadLimit) || payloadLimit < 1) return
|
|
202
|
+
if (!Number.isSafeInteger(payloadLimit) || payloadLimit < 1) return miss('output-bound');
|
|
142
203
|
|
|
204
|
+
let store;
|
|
143
205
|
try {
|
|
144
206
|
const deadlineAt = Date.now() + deadlineMs;
|
|
145
207
|
const boundedRunner = (binary, args, options) => {
|
|
@@ -153,7 +215,7 @@ export function restoreProgressionForSession({
|
|
|
153
215
|
...options,
|
|
154
216
|
runner: boundedRunner,
|
|
155
217
|
}));
|
|
156
|
-
|
|
218
|
+
store = makeStore({
|
|
157
219
|
projectDir,
|
|
158
220
|
requestedStorePath: resolution.canonicalAgentDbPath,
|
|
159
221
|
});
|
|
@@ -161,23 +223,38 @@ export function restoreProgressionForSession({
|
|
|
161
223
|
fs.mkdirSync(path.dirname(resolution.canonicalAgentDbPath), { recursive: true, mode: 0o700 });
|
|
162
224
|
initializeCanonicalStore(store, resolution);
|
|
163
225
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
226
|
+
// COMMITTED ROWS ONLY (ADR-073 §5). Replay is a write, a write is a `ruflo memory store`
|
|
227
|
+
// process, and one of those costs more than this entire boundary's budget. Pending durable
|
|
228
|
+
// snapshots are REPORTED below and replayed at the next capture boundary or by /checkpoint.
|
|
229
|
+
const restored = store.restoreLatest({ maxOutputBytes: payloadLimit, replayPending: false });
|
|
230
|
+
if (!validResume(restored)) return miss('malformed-store');
|
|
231
|
+
const context = `${prefix}${restored.rendered}${pendingNotice(restored.pendingReplay)}`;
|
|
232
|
+
if (Buffer.byteLength(context, 'utf8') > maxOutputBytes) return miss('output-bound');
|
|
233
|
+
return { status: 'restored', severity: 'info', pendingReplay: restored.pendingReplay, context };
|
|
169
234
|
} catch (error) {
|
|
170
235
|
// A structurally enumerated, genuinely empty namespace is normal for a newly adopted project.
|
|
171
236
|
if (/no coherent progression state/i.test(String(error?.message ?? ''))
|
|
172
237
|
&& Array.isArray(error?.rejectedCandidates) && error.rejectedCandidates.length === 0) {
|
|
173
|
-
if (initializing && !fs.existsSync(resolution.canonicalAgentDbPath)) return
|
|
238
|
+
if (initializing && !fs.existsSync(resolution.canonicalAgentDbPath)) return miss('initialization-failed');
|
|
239
|
+
const pending = pendingNotice(error?.pendingReplay);
|
|
174
240
|
return {
|
|
175
241
|
status: initializing ? 'initialized' : 'empty',
|
|
176
|
-
|
|
242
|
+
severity: 'info',
|
|
243
|
+
pendingReplay: error?.pendingReplay ?? 0,
|
|
244
|
+
context: (initializing
|
|
177
245
|
? '[RuvNet Brain — PROJECT CONTINUITY INITIALIZED]\nThe canonical AgentDB store is ready; no prior progression snapshot exists yet.'
|
|
178
|
-
: '[RuvNet Brain — PROJECT CONTINUITY EMPTY]\nThe canonical AgentDB store was structurally enumerated and contains no prior progression snapshot.'
|
|
246
|
+
: '[RuvNet Brain — PROJECT CONTINUITY EMPTY]\nThe canonical AgentDB store was structurally enumerated and contains no prior progression snapshot.')
|
|
247
|
+
+ pending,
|
|
179
248
|
};
|
|
180
249
|
}
|
|
181
|
-
|
|
250
|
+
// NAME WHICH PATH RAN OUT OF TIME. "restore-failed" over a store full of rows tells the user
|
|
251
|
+
// nothing they can act on; "the fast path was unavailable because <reason>, and the CLI fallback
|
|
252
|
+
// is too slow for this deadline" tells them exactly what to fix.
|
|
253
|
+
const readPath = typeof store?.lastReadPath === 'string' ? store.lastReadPath : '';
|
|
254
|
+
if (/deadline exceeded/i.test(String(error?.message ?? '')) && readPath.startsWith('ruflo-cli')) {
|
|
255
|
+
const missed = miss('fallback-too-slow');
|
|
256
|
+
return { ...missed, context: `${missed.context} Read path: ${readPath}.` };
|
|
257
|
+
}
|
|
258
|
+
return miss(classify(error));
|
|
182
259
|
}
|
|
183
260
|
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-progression-sources.mjs — where a snapshot's facts actually come from.
|
|
3
|
+
*
|
|
4
|
+
* Every field a capture stores must be traceable to a REAL source: the git index, the user's own
|
|
5
|
+
* work ledger, the owner's own `project-state-current` note, the previous snapshot head, or a
|
|
6
|
+
* bounded transcript reference. Nothing here invents state, and nothing here copies the
|
|
7
|
+
* conversation: the transcript contributes a REFERENCE (path + line/byte span + a sha256 of the
|
|
8
|
+
* exact excerpt read) plus tightly-bounded DERIVED strings, never the prompt or the reply itself.
|
|
9
|
+
*
|
|
10
|
+
* Kept separate from the producer so each file stays small and so the readers can be tested against
|
|
11
|
+
* real fixtures without constructing a whole snapshot.
|
|
12
|
+
*/
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
|
|
19
|
+
/** The transcript-derived bound. Deliberately far below the hook's 4096-byte observation limit. */
|
|
20
|
+
export const DERIVED_TEXT_LIMIT = 240;
|
|
21
|
+
/** How much of a transcript tail may be READ (never stored) to derive those bounded strings. */
|
|
22
|
+
export const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
|
|
23
|
+
|
|
24
|
+
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
25
|
+
|
|
26
|
+
/** A stable digest for "there is genuinely nothing to digest here", never an empty string. */
|
|
27
|
+
const ABSENT_DIGEST = sha256('ruvnet-brain:absent');
|
|
28
|
+
|
|
29
|
+
function git(cwd, args) {
|
|
30
|
+
try {
|
|
31
|
+
return execFileSync('git', args, {
|
|
32
|
+
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024,
|
|
33
|
+
});
|
|
34
|
+
} catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The source identity, with each digest defined EXACTLY:
|
|
39
|
+
* trackedDigest sha256 of `git ls-files -s` (mode + blob oid + stage + path for every tracked file)
|
|
40
|
+
* untrackedDigest sha256 of one `<content-sha256> <path>` line per untracked, non-ignored file —
|
|
41
|
+
* the NAMES alone would call two different working trees identical
|
|
42
|
+
* dirtyTreeDigest sha256 of `git diff HEAD` (staged AND unstaged, against the commit)
|
|
43
|
+
*
|
|
44
|
+
* KNOWN RACE, recorded rather than pretended away: the working tree can change while these three
|
|
45
|
+
* commands run. HEAD is read before and after; when they differ, `headStable` is false and the
|
|
46
|
+
* digests describe a tree that existed at no single instant. A capture still happens — a slightly
|
|
47
|
+
* smeared snapshot is worth far more than no snapshot — but it never claims to be atomic.
|
|
48
|
+
*/
|
|
49
|
+
export function readSourceIdentity({ checkoutRoot, kind = 'git' } = {}) {
|
|
50
|
+
const worktreeId = sha256(checkoutRoot);
|
|
51
|
+
if (kind !== 'git') {
|
|
52
|
+
// A non-git project has no index, no HEAD and no diff. Say that in the fields rather than
|
|
53
|
+
// fabricating hashes of nothing, and keep every value a non-empty string as the contract requires.
|
|
54
|
+
return {
|
|
55
|
+
identity: {
|
|
56
|
+
checkoutPath: checkoutRoot, worktreeId, branch: 'non-git', head: 'non-git',
|
|
57
|
+
trackedDigest: ABSENT_DIGEST, untrackedDigest: ABSENT_DIGEST, dirtyTreeDigest: ABSENT_DIGEST,
|
|
58
|
+
},
|
|
59
|
+
headStable: true,
|
|
60
|
+
kind,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const headBefore = git(checkoutRoot, ['rev-parse', 'HEAD'])?.trim() || 'unborn';
|
|
65
|
+
const branch = git(checkoutRoot, ['rev-parse', '--abbrev-ref', 'HEAD'])?.trim() || 'detached';
|
|
66
|
+
const tracked = git(checkoutRoot, ['ls-files', '-s']);
|
|
67
|
+
const untrackedList = git(checkoutRoot, ['ls-files', '--others', '--exclude-standard']);
|
|
68
|
+
const diff = git(checkoutRoot, ['diff', 'HEAD']);
|
|
69
|
+
const headAfter = git(checkoutRoot, ['rev-parse', 'HEAD'])?.trim() || 'unborn';
|
|
70
|
+
|
|
71
|
+
const untrackedLines = String(untrackedList ?? '').split('\n').filter(Boolean).map((relative) => {
|
|
72
|
+
let content;
|
|
73
|
+
try { content = fs.readFileSync(path.join(checkoutRoot, relative)); } catch { return `unreadable ${relative}`; }
|
|
74
|
+
return `${sha256(content)} ${relative}`;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
identity: {
|
|
79
|
+
checkoutPath: checkoutRoot,
|
|
80
|
+
worktreeId,
|
|
81
|
+
branch,
|
|
82
|
+
head: headBefore,
|
|
83
|
+
trackedDigest: tracked === null ? ABSENT_DIGEST : sha256(tracked),
|
|
84
|
+
untrackedDigest: untrackedList === null ? ABSENT_DIGEST : sha256(untrackedLines.join('\n')),
|
|
85
|
+
dirtyTreeDigest: diff === null ? ABSENT_DIGEST : sha256(diff),
|
|
86
|
+
},
|
|
87
|
+
headStable: headBefore === headAfter,
|
|
88
|
+
headAfter,
|
|
89
|
+
kind,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The user's own work ledger, read-only, resolved exactly as continuation-gate.mjs resolves it:
|
|
95
|
+
* RUVNET_WORK_LEDGER, else ~/.config/ruvnet-brain/work-ledgers/<projectId with ':' → '-'>.json.
|
|
96
|
+
* This is the AUTHORITATIVE source for open work, because the user put it there.
|
|
97
|
+
*/
|
|
98
|
+
export function readWorkLedger({ projectId, env = process.env, home = os.homedir() } = {}) {
|
|
99
|
+
const file = env.RUVNET_WORK_LEDGER
|
|
100
|
+
|| path.join(home, '.config', 'ruvnet-brain', 'work-ledgers', `${String(projectId).replace(':', '-')}.json`);
|
|
101
|
+
let parsed;
|
|
102
|
+
try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {
|
|
103
|
+
return { file, present: false, open: [], done: [], objective: null };
|
|
104
|
+
}
|
|
105
|
+
const items = Array.isArray(parsed?.items) ? parsed.items : [];
|
|
106
|
+
const text = (item) => (typeof item?.text === 'string' ? item.text : '');
|
|
107
|
+
return {
|
|
108
|
+
file,
|
|
109
|
+
present: true,
|
|
110
|
+
open: items.filter((item) => item && !item.done).map(text).filter(Boolean),
|
|
111
|
+
done: items.filter((item) => item?.done).map(text).filter(Boolean),
|
|
112
|
+
objective: parsed?.objective && typeof parsed.objective === 'object' ? parsed.objective : null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The owner's own convention: append-only `project-state-current-<epochms>[-slug]` free-text notes.
|
|
118
|
+
* 378 of them exist in this repo's canonical store. They are narrative, not schema, so only the head
|
|
119
|
+
* of the newest one is carried, as CONTEXT — never as an instruction and never as authority.
|
|
120
|
+
*/
|
|
121
|
+
export function readOwnerNote(readRows, { limit = 600 } = {}) {
|
|
122
|
+
let rows;
|
|
123
|
+
try { rows = readRows(); } catch { return null; }
|
|
124
|
+
if (!Array.isArray(rows) || rows.length === 0) return null;
|
|
125
|
+
const newest = rows
|
|
126
|
+
.filter((row) => typeof row?.key === 'string' && row.key.startsWith('project-state-current'))
|
|
127
|
+
.sort((left, right) => String(left.key).localeCompare(String(right.key)))
|
|
128
|
+
.at(-1);
|
|
129
|
+
if (!newest || typeof newest.content !== 'string' || !newest.content.trim()) return null;
|
|
130
|
+
const excerpt = newest.content.slice(0, limit);
|
|
131
|
+
return {
|
|
132
|
+
key: newest.key,
|
|
133
|
+
namespace: newest.namespace ?? null,
|
|
134
|
+
excerpt,
|
|
135
|
+
truncated: newest.content.length > limit,
|
|
136
|
+
excerptSha256: sha256(excerpt),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function firstSentence(value) {
|
|
141
|
+
const collapsed = String(value).replace(/\s+/g, ' ').trim();
|
|
142
|
+
if (!collapsed) return '';
|
|
143
|
+
const stop = collapsed.search(/[.!?](\s|$)/);
|
|
144
|
+
const sentence = stop > 0 ? collapsed.slice(0, stop + 1) : collapsed;
|
|
145
|
+
return sentence.length <= DERIVED_TEXT_LIMIT ? sentence : `${sentence.slice(0, DERIVED_TEXT_LIMIT)}…`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function claudeTranscriptRows(text) {
|
|
149
|
+
const rows = [];
|
|
150
|
+
const lines = text.split('\n');
|
|
151
|
+
// The first line of a mid-file tail is almost always a fragment; dropping it is correct, not lossy.
|
|
152
|
+
for (const line of lines.slice(1)) {
|
|
153
|
+
if (!line.trim()) continue;
|
|
154
|
+
try { rows.push(JSON.parse(line)); } catch { /* a partial line in a live-appended file */ }
|
|
155
|
+
}
|
|
156
|
+
return rows;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function textOf(message) {
|
|
160
|
+
if (typeof message?.content === 'string') return message.content;
|
|
161
|
+
if (!Array.isArray(message?.content)) return '';
|
|
162
|
+
return message.content.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
163
|
+
.map((block) => block.text).join('\n');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A BOUNDED, NON-VERBATIM reading of the transcript tail.
|
|
168
|
+
*
|
|
169
|
+
* THE PRIVACY BOUNDARY, stated as a rule rather than a hope: the prompt and the assistant's reply
|
|
170
|
+
* are READ here and are NEVER RETURNED. What comes back is a REFERENCE (path, line span, byte span,
|
|
171
|
+
* and the sha256 of the exact bytes read, so an auditor can prove what was consulted) plus at most
|
|
172
|
+
* two DERIVED single sentences, each capped at DERIVED_TEXT_LIMIT. Anything inferred this way is
|
|
173
|
+
* marked non-authoritative by the producer; the ledger and the owner's note outrank it always.
|
|
174
|
+
*
|
|
175
|
+
* Unknown or unreadable formats return `skipped` with a reason. A host whose transcript we cannot
|
|
176
|
+
* parse must cost the capture nothing at all.
|
|
177
|
+
*/
|
|
178
|
+
export function readTranscriptReference(transcriptPath, { host = 'claude', tailBytes = TRANSCRIPT_TAIL_BYTES } = {}) {
|
|
179
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath) {
|
|
180
|
+
return { skipped: 'no transcript path supplied' };
|
|
181
|
+
}
|
|
182
|
+
if (host !== 'claude') return { skipped: `transcript format unknown for host ${host}` };
|
|
183
|
+
let stat;
|
|
184
|
+
try { stat = fs.statSync(transcriptPath); } catch { return { skipped: 'transcript is unreadable' }; }
|
|
185
|
+
if (!stat.isFile() || stat.size === 0) return { skipped: 'transcript is empty or not a file' };
|
|
186
|
+
if (!/\.jsonl$/i.test(transcriptPath)) return { skipped: 'transcript is not a JSONL transcript' };
|
|
187
|
+
|
|
188
|
+
const byteOffset = Math.max(0, stat.size - tailBytes);
|
|
189
|
+
const byteLength = stat.size - byteOffset;
|
|
190
|
+
let buffer;
|
|
191
|
+
try {
|
|
192
|
+
const handle = fs.openSync(transcriptPath, 'r');
|
|
193
|
+
try {
|
|
194
|
+
buffer = Buffer.alloc(byteLength);
|
|
195
|
+
fs.readSync(handle, buffer, 0, byteLength, byteOffset);
|
|
196
|
+
} finally { fs.closeSync(handle); }
|
|
197
|
+
} catch { return { skipped: 'transcript tail could not be read' }; }
|
|
198
|
+
|
|
199
|
+
const rows = claudeTranscriptRows(buffer.toString('utf8'));
|
|
200
|
+
if (rows.length === 0) return { skipped: 'transcript tail contained no parseable records' };
|
|
201
|
+
const lastUser = [...rows].reverse()
|
|
202
|
+
.find((row) => row?.type === 'user' && row.message?.role === 'user' && textOf(row.message).trim());
|
|
203
|
+
const lastAssistant = [...rows].reverse()
|
|
204
|
+
.find((row) => row?.type === 'assistant' && textOf(row.message).trim());
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
reference: {
|
|
208
|
+
path: transcriptPath,
|
|
209
|
+
byteOffset,
|
|
210
|
+
byteLength,
|
|
211
|
+
recordsRead: rows.length,
|
|
212
|
+
excerptSha256: sha256(buffer),
|
|
213
|
+
format: 'claude-jsonl',
|
|
214
|
+
},
|
|
215
|
+
// DERIVED ONLY. Callers must not treat these as the user's words; they are a bounded
|
|
216
|
+
// single-sentence reduction, recorded so an abandoned session still knows what it was doing.
|
|
217
|
+
derivedGoal: lastUser ? firstSentence(textOf(lastUser.message)) : '',
|
|
218
|
+
derivedNextAction: lastAssistant ? firstSentence(textOf(lastAssistant.message)) : '',
|
|
219
|
+
};
|
|
220
|
+
}
|