@ericrisco/rsc 1.1.6 → 1.2.1
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 +62 -4
- package/manifest.json +834 -2
- package/package.json +3 -2
- package/schema/ecc-originality.json +16 -0
- package/schema/ecc-originality.u64 +0 -0
- package/scripts/build-manifest.js +43 -3
- package/scripts/doctor.js +47 -1
- package/scripts/install-apply.js +148 -31
- package/scripts/lib/capabilities.js +21 -0
- package/scripts/lib/manifest-file.js +4 -1
- package/scripts/originality-check.js +137 -0
- package/scripts/rsc.js +168 -14
- package/skills/ship/SKILL.md +15 -8
- package/skills/worktrees/SKILL.md +30 -22
- package/targets/agent-catalog.js +220 -0
- package/targets/agent-sources.json +48 -0
- package/targets/agents.js +61 -13
- package/targets/claude.js +3 -0
- package/targets/commands.js +212 -0
- package/targets/memory.js +276 -0
- package/targets/opencode-memory-plugin.js +65 -0
- package/targets/session-memory-adapter.mjs +117 -0
- package/targets/session-memory-core.mjs +477 -0
- package/targets/session-memory.mjs +27 -0
- package/targets/session-start.mjs +31 -0
- package/targets/worktree-reaper.mjs +458 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync,
|
|
3
|
+
} from 'node:fs';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
export const SESSION_RECORD_FIELDS = Object.freeze([
|
|
9
|
+
'schemaVersion', 'sessionId', 'target', 'event', 'branch', 'worktree', 'baselineHead', 'head',
|
|
10
|
+
'files', 'commits', 'ledger', 'timestamps', 'toolCalls', 'cost', 'editCount', 'concurrent',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_MEMORY_SETTINGS = Object.freeze({
|
|
14
|
+
enabled: true,
|
|
15
|
+
metrics: true,
|
|
16
|
+
compactionHint: true,
|
|
17
|
+
editThreshold: 20,
|
|
18
|
+
retentionDays: 30,
|
|
19
|
+
contextBytes: 4096,
|
|
20
|
+
lessonThreshold: 0.7,
|
|
21
|
+
lessonLimit: 5,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const SECRET_SHAPES = [
|
|
25
|
+
/\bsk-[A-Za-z0-9_-]{12,}\b/u,
|
|
26
|
+
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/u,
|
|
27
|
+
/\bgh[opusr]_[A-Za-z0-9]{20,}\b/u,
|
|
28
|
+
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/u,
|
|
29
|
+
/\b(?:password|passwd|api[_-]?key|access[_-]?token)\s*[:=]\s*\S+/iu,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function secretShaped(value) {
|
|
33
|
+
return typeof value === 'string' && SECRET_SHAPES.some((pattern) => pattern.test(value));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function cleanString(value, max = 1024) {
|
|
37
|
+
if (typeof value !== 'string' || secretShaped(value)) return null;
|
|
38
|
+
const clean = value.replace(/[\u0000-\u001f\u007f]/gu, '').trim();
|
|
39
|
+
return clean ? clean.slice(0, max) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cleanId(value, prefix = 'session') {
|
|
43
|
+
const clean = cleanString(value, 120);
|
|
44
|
+
if (clean && /^[A-Za-z0-9._-]+$/u.test(clean)) return clean;
|
|
45
|
+
const hash = createHash('sha256').update(String(value ?? randomUUID())).digest('hex').slice(0, 20);
|
|
46
|
+
return `${prefix}-${hash}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function git(cwd, args) {
|
|
50
|
+
try {
|
|
51
|
+
return { ok: true, out: execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 2 * 1024 * 1024 }).trim() };
|
|
52
|
+
} catch {
|
|
53
|
+
return { ok: false, out: '' };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function relativeSlash(cwd, path) {
|
|
58
|
+
return relative(cwd, path).split(sep).join('/');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ensureExcluded(cwd, root) {
|
|
62
|
+
const rel = relativeSlash(cwd, root);
|
|
63
|
+
if (!rel || rel.startsWith('../')) return false;
|
|
64
|
+
const tracked = git(cwd, ['ls-files', '--', rel]).out;
|
|
65
|
+
if (tracked) return false;
|
|
66
|
+
const gitPath = git(cwd, ['rev-parse', '--git-path', 'info/exclude']);
|
|
67
|
+
if (!gitPath.ok || !gitPath.out) return false;
|
|
68
|
+
const exclude = isAbsolute(gitPath.out) ? gitPath.out : resolve(cwd, gitPath.out);
|
|
69
|
+
mkdirSync(dirname(exclude), { recursive: true });
|
|
70
|
+
const pattern = `/${rel.replace(/\/$/u, '')}/`;
|
|
71
|
+
const current = existsSync(exclude) ? readFileSync(exclude, 'utf8') : '';
|
|
72
|
+
if (!current.split('\n').includes(pattern)) {
|
|
73
|
+
const prefix = current && !current.endsWith('\n') ? '\n' : '';
|
|
74
|
+
appendFileSync(exclude, `${prefix}${pattern}\n`);
|
|
75
|
+
}
|
|
76
|
+
return git(cwd, ['check-ignore', '-q', '--no-index', `${rel}/.rsc-probe`]).ok;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function chooseMemoryRoot(cwd = process.cwd()) {
|
|
80
|
+
const project = resolve(cwd);
|
|
81
|
+
const inGit = git(project, ['rev-parse', '--is-inside-work-tree']).out === 'true';
|
|
82
|
+
if (!inGit) {
|
|
83
|
+
return { root: join(project, '.rsc', 'memory'), kind: 'local-state', reason: 'without-git', git: false };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const worklog = join(project, '02-DOCS', 'raw', 'worklog');
|
|
87
|
+
if (existsSync(worklog)) {
|
|
88
|
+
const tracked = git(project, ['ls-files', '--', '02-DOCS/raw/worklog']).out;
|
|
89
|
+
if (!tracked) {
|
|
90
|
+
const candidate = join(worklog, '.rsc-memory');
|
|
91
|
+
if (ensureExcluded(project, candidate)) {
|
|
92
|
+
return { root: candidate, kind: 'wiki-worklog', reason: 'untracked-worklog', git: true };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const local = join(project, '.rsc', 'memory');
|
|
98
|
+
if (ensureExcluded(project, local)) {
|
|
99
|
+
const trackedWorklog = existsSync(worklog) && Boolean(git(project, ['ls-files', '--', '02-DOCS/raw/worklog']).out);
|
|
100
|
+
return { root: local, kind: 'local-state', reason: trackedWorklog ? 'tracked-worklog' : 'no-wiki', git: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const gitRoot = git(project, ['rev-parse', '--git-path', 'rsc-memory']).out;
|
|
104
|
+
return {
|
|
105
|
+
root: isAbsolute(gitRoot) ? gitRoot : resolve(project, gitRoot || '.git/rsc-memory'),
|
|
106
|
+
kind: 'git-private-state', reason: 'local-state-tracked', git: true,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function ensureStore(root) {
|
|
111
|
+
for (const dir of ['sessions', 'anchors', 'lessons']) mkdirSync(join(root, dir), { recursive: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function atomicJson(path, value) {
|
|
115
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
116
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
117
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
118
|
+
renameSync(temporary, path);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readJson(path) {
|
|
122
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function iso(value) {
|
|
126
|
+
const parsed = new Date(value ?? Date.now());
|
|
127
|
+
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function settings(input = {}) {
|
|
131
|
+
const merged = { ...DEFAULT_MEMORY_SETTINGS };
|
|
132
|
+
for (const key of Object.keys(DEFAULT_MEMORY_SETTINGS)) {
|
|
133
|
+
if (Object.hasOwn(input, key)) merged[key] = input[key];
|
|
134
|
+
}
|
|
135
|
+
merged.retentionDays = Math.max(1, Number(merged.retentionDays) || DEFAULT_MEMORY_SETTINGS.retentionDays);
|
|
136
|
+
merged.contextBytes = Math.max(64, Number(merged.contextBytes) || DEFAULT_MEMORY_SETTINGS.contextBytes);
|
|
137
|
+
merged.lessonLimit = Math.max(0, Math.floor(Number(merged.lessonLimit) || 0));
|
|
138
|
+
merged.lessonThreshold = Math.min(1, Math.max(0, Number(merged.lessonThreshold) || 0));
|
|
139
|
+
merged.editThreshold = Math.max(1, Math.floor(Number(merged.editThreshold) || DEFAULT_MEMORY_SETTINGS.editThreshold));
|
|
140
|
+
return merged;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseStatus(output) {
|
|
144
|
+
const entries = output.split('\0').filter(Boolean);
|
|
145
|
+
const files = [];
|
|
146
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
147
|
+
const entry = entries[index];
|
|
148
|
+
// `git()` trims command output, so a leading space in porcelain's XY column may be
|
|
149
|
+
// absent only for the first entry. Locate the separator instead of dropping a fixed
|
|
150
|
+
// three bytes; filenames must remain byte-for-byte identifiers, never content.
|
|
151
|
+
const separator = entry.indexOf(' ');
|
|
152
|
+
const status = separator === 1 ? entry.slice(0, 1) : entry.slice(0, 2);
|
|
153
|
+
const paths = [entry.slice(separator + 1).replace(/^ +/u, '')];
|
|
154
|
+
// In porcelain -z, rename/copy records carry the destination in this entry
|
|
155
|
+
// and the source in the next NUL-delimited entry. Both are touched paths.
|
|
156
|
+
if (/[RC]/u.test(status) && entries[index + 1]) paths.push(entries[++index]);
|
|
157
|
+
for (const path of paths) {
|
|
158
|
+
const clean = cleanString(path, 1000);
|
|
159
|
+
if (clean) files.push(clean);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return files;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function fingerprintFiles(worktree, files) {
|
|
166
|
+
return Object.fromEntries(files.map((path) => {
|
|
167
|
+
const result = git(worktree, ['hash-object', '--no-filters', '--', path]);
|
|
168
|
+
return [path, result.ok ? result.out : null];
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function snapshot(cwd, baselineHead = null) {
|
|
173
|
+
const top = git(cwd, ['rev-parse', '--show-toplevel']);
|
|
174
|
+
if (!top.ok) return { git: false, branch: null, worktree: resolve(cwd), head: null, files: [], fingerprints: {}, commits: [] };
|
|
175
|
+
const worktree = resolve(top.out);
|
|
176
|
+
const branch = cleanString(git(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']).out, 255);
|
|
177
|
+
const head = cleanString(git(cwd, ['rev-parse', '--verify', 'HEAD']).out, 64);
|
|
178
|
+
const dirty = parseStatus(git(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all']).out);
|
|
179
|
+
let committed = [];
|
|
180
|
+
let commits = [];
|
|
181
|
+
if (baselineHead && head && git(cwd, ['cat-file', '-e', `${baselineHead}^{commit}`]).ok) {
|
|
182
|
+
committed = git(cwd, ['diff', '--name-only', '-z', `${baselineHead}..${head}`]).out.split('\0').map((item) => cleanString(item, 1000)).filter(Boolean);
|
|
183
|
+
commits = git(cwd, ['log', '--format=%H', `${baselineHead}..${head}`]).out.split('\n').map((item) => cleanString(item, 64)).filter(Boolean);
|
|
184
|
+
}
|
|
185
|
+
const files = [...new Set([...dirty, ...committed])].sort();
|
|
186
|
+
return { git: true, branch, worktree, head, files, fingerprints: fingerprintFiles(worktree, dirty), commits };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function ledgerSnapshot(cwd) {
|
|
190
|
+
const roots = [
|
|
191
|
+
['spec', join(cwd, '02-DOCS', 'wiki', 'sdd', 'specs')],
|
|
192
|
+
['plan', join(cwd, '02-DOCS', 'wiki', 'sdd', 'plans')],
|
|
193
|
+
['progress', join(cwd, '02-DOCS', 'wiki', 'sdd', 'progress')],
|
|
194
|
+
];
|
|
195
|
+
const out = [];
|
|
196
|
+
for (const [type, root] of roots) {
|
|
197
|
+
if (!existsSync(root)) continue;
|
|
198
|
+
for (const name of readdirSync(root).filter((item) => item.endsWith('.md')).sort()) {
|
|
199
|
+
const path = join(root, name);
|
|
200
|
+
let body = '';
|
|
201
|
+
try { body = readFileSync(path, 'utf8'); } catch { continue; }
|
|
202
|
+
const rel = cleanString(relativeSlash(cwd, path), 1000);
|
|
203
|
+
if (!rel) continue;
|
|
204
|
+
const status = cleanString(body.match(/^status:\s*([a-z-]+)/imu)?.[1]?.toLowerCase(), 40) || 'unknown';
|
|
205
|
+
const openItems = (body.match(/^- \[ \]/gmu) || []).length;
|
|
206
|
+
out.push({ path: rel, type, status, openItems });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sessionFiles(root) {
|
|
213
|
+
const dir = join(root, 'sessions');
|
|
214
|
+
if (!existsSync(dir)) return [];
|
|
215
|
+
return readdirSync(dir).filter((name) => name.endsWith('.json')).map((name) => join(dir, name));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function lessonFiles(root) {
|
|
219
|
+
const dir = join(root, 'lessons');
|
|
220
|
+
if (!existsSync(dir)) return [];
|
|
221
|
+
return readdirSync(dir).filter((name) => name.endsWith('.json')).map((name) => join(dir, name));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function prune(root, now, config) {
|
|
225
|
+
const cutoff = new Date(now).getTime() - config.retentionDays * 86400000;
|
|
226
|
+
for (const path of [...sessionFiles(root), ...readdirSync(join(root, 'anchors')).filter((name) => name.endsWith('.json')).map((name) => join(root, 'anchors', name))]) {
|
|
227
|
+
const value = readJson(path);
|
|
228
|
+
const updated = value?.timestamps?.updatedAt || value?.startedAt;
|
|
229
|
+
if (!updated || new Date(updated).getTime() < cutoff) rmSync(path, { force: true });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function noticeText(info) {
|
|
234
|
+
if (info.reason === 'tracked-worklog') return 'rsc memory: tracked worklog detected; using ignored local state instead.';
|
|
235
|
+
if (info.reason === 'untracked-worklog') return 'rsc memory: using the ignored wiki worklog store.';
|
|
236
|
+
if (info.reason === 'without-git') return 'rsc memory: running without git; branch and commit metadata are unavailable.';
|
|
237
|
+
if (info.reason === 'local-state-tracked') return 'rsc memory: .rsc memory was tracked; using git-private local state.';
|
|
238
|
+
return 'rsc memory: no wiki worklog found; using ignored local state.';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function consumeNotice(info) {
|
|
242
|
+
const marker = join(info.root, `.notice-${info.reason}`);
|
|
243
|
+
if (existsSync(marker)) return null;
|
|
244
|
+
writeFileSync(marker, `${iso()}\n`, { mode: 0o600 });
|
|
245
|
+
return noticeText(info);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function finiteOrNull(value, integer = false) {
|
|
249
|
+
if (value === null || value === undefined || value === '') return null;
|
|
250
|
+
const number = Number(value);
|
|
251
|
+
if (!Number.isFinite(number) || number < 0 || (integer && !Number.isInteger(number))) return null;
|
|
252
|
+
return number;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function activeConcurrent(records, sessionId, target, worktree, now) {
|
|
256
|
+
const recent = new Date(now).getTime() - 2 * 60 * 60 * 1000;
|
|
257
|
+
return records.some((record) => (record?.sessionId !== sessionId || record?.target !== target) && record?.worktree === worktree
|
|
258
|
+
&& !record?.timestamps?.completedAt && new Date(record?.timestamps?.updatedAt || 0).getTime() >= recent);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function validateSessionRecord(record) {
|
|
262
|
+
const errors = [];
|
|
263
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) return ['record: object required'];
|
|
264
|
+
for (const field of Object.keys(record)) if (!SESSION_RECORD_FIELDS.includes(field)) errors.push(`${field}: unknown field`);
|
|
265
|
+
for (const field of SESSION_RECORD_FIELDS) if (!Object.hasOwn(record, field)) errors.push(`${field}: required`);
|
|
266
|
+
if (record.schemaVersion !== 1) errors.push('schemaVersion: must be 1');
|
|
267
|
+
if (!Array.isArray(record.files) || !Array.isArray(record.commits) || !Array.isArray(record.ledger)) errors.push('files/commits/ledger: arrays required');
|
|
268
|
+
if (!record.timestamps || Object.keys(record.timestamps).some((key) => !['startedAt', 'updatedAt', 'completedAt'].includes(key))) errors.push('timestamps: closed schema required');
|
|
269
|
+
for (const item of record.ledger || []) {
|
|
270
|
+
if (!item || Object.keys(item).some((key) => !['path', 'type', 'status', 'openItems'].includes(key))) errors.push('ledger: closed schema required');
|
|
271
|
+
}
|
|
272
|
+
const walk = (value) => {
|
|
273
|
+
if (secretShaped(value)) errors.push('secret-shaped string rejected');
|
|
274
|
+
else if (Array.isArray(value)) value.forEach(walk);
|
|
275
|
+
else if (value && typeof value === 'object') Object.values(value).forEach(walk);
|
|
276
|
+
};
|
|
277
|
+
walk(record);
|
|
278
|
+
return errors;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function readRecords(root) {
|
|
282
|
+
return sessionFiles(root).map(readJson).filter((record) => record && validateSessionRecord(record).length === 0);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function capture(input = {}) {
|
|
286
|
+
const cwd = resolve(input.cwd || process.cwd());
|
|
287
|
+
const config = settings(input.settings);
|
|
288
|
+
if (!config.enabled) return { record: null, path: null, notice: null, compactionHint: false };
|
|
289
|
+
const store = chooseMemoryRoot(cwd);
|
|
290
|
+
ensureStore(store.root);
|
|
291
|
+
const now = iso(input.now);
|
|
292
|
+
prune(store.root, now, config);
|
|
293
|
+
const sessionId = cleanId(input.sessionId, 'session');
|
|
294
|
+
const target = cleanId(input.target || 'unknown', 'target');
|
|
295
|
+
// Providers own their session-id namespaces. Prefixing the storage key prevents
|
|
296
|
+
// an identical id from two local assistants overwriting the other journal.
|
|
297
|
+
const storageId = `${target}--${sessionId}`;
|
|
298
|
+
const anchorPath = join(store.root, 'anchors', `${storageId}.json`);
|
|
299
|
+
const recordPath = join(store.root, 'sessions', `${storageId}.json`);
|
|
300
|
+
let anchor = readJson(anchorPath);
|
|
301
|
+
let existing = readJson(recordPath);
|
|
302
|
+
const firstSnapshot = snapshot(cwd, null);
|
|
303
|
+
if (!anchor) {
|
|
304
|
+
anchor = {
|
|
305
|
+
sessionId,
|
|
306
|
+
startedAt: now,
|
|
307
|
+
baselineHead: cleanString(input.baselineHead, 64) || firstSnapshot.head,
|
|
308
|
+
baselineFiles: firstSnapshot.files,
|
|
309
|
+
baselineFingerprints: firstSnapshot.fingerprints,
|
|
310
|
+
};
|
|
311
|
+
atomicJson(anchorPath, anchor);
|
|
312
|
+
}
|
|
313
|
+
if (input.event === 'start' && !existing) return { record: null, path: null, notice: null, compactionHint: false };
|
|
314
|
+
|
|
315
|
+
const repo = snapshot(cwd, anchor.baselineHead);
|
|
316
|
+
const editCount = Math.max(0, (existing?.editCount || 0) + (finiteOrNull(input.editDelta, true) || 0));
|
|
317
|
+
const baselineFiles = new Set(anchor.baselineFiles || []);
|
|
318
|
+
const newDirtyPath = repo.files.some((path) => !baselineFiles.has(path));
|
|
319
|
+
const baselineFingerprints = anchor.baselineFingerprints || {};
|
|
320
|
+
const dirtyChanged = Object.entries(baselineFingerprints)
|
|
321
|
+
.some(([path, fingerprint]) => repo.fingerprints[path] !== fingerprint);
|
|
322
|
+
const hasWork = Boolean(input.force === true || existing || editCount || newDirtyPath || dirtyChanged || repo.commits.length);
|
|
323
|
+
if (!hasWork) return { record: null, path: null, notice: null, compactionHint: false };
|
|
324
|
+
|
|
325
|
+
const records = readRecords(store.root);
|
|
326
|
+
const completed = ['end', 'sessionEnd', 'stop'].includes(input.event) ? now : (existing?.timestamps?.completedAt || null);
|
|
327
|
+
const cost = config.metrics
|
|
328
|
+
? (Object.hasOwn(input, 'cost') ? finiteOrNull(input.cost) : (existing?.cost ?? null))
|
|
329
|
+
: null;
|
|
330
|
+
const toolCalls = config.metrics
|
|
331
|
+
? (Object.hasOwn(input, 'toolCalls') ? finiteOrNull(input.toolCalls, true) : (existing?.toolCalls ?? null))
|
|
332
|
+
: null;
|
|
333
|
+
const record = {
|
|
334
|
+
schemaVersion: 1,
|
|
335
|
+
sessionId,
|
|
336
|
+
target: cleanString(input.target, 40) || existing?.target || 'unknown',
|
|
337
|
+
event: cleanString(input.event, 40) || 'capture',
|
|
338
|
+
branch: cleanString(repo.branch, 255),
|
|
339
|
+
worktree: cleanString(repo.worktree, 1000),
|
|
340
|
+
baselineHead: cleanString(anchor.baselineHead, 64),
|
|
341
|
+
head: cleanString(repo.head, 64),
|
|
342
|
+
files: repo.files.filter((item) => !secretShaped(item)),
|
|
343
|
+
commits: repo.commits.filter((item) => !secretShaped(item)),
|
|
344
|
+
ledger: ledgerSnapshot(cwd),
|
|
345
|
+
timestamps: { startedAt: anchor.startedAt, updatedAt: now, completedAt: completed },
|
|
346
|
+
toolCalls,
|
|
347
|
+
cost,
|
|
348
|
+
editCount,
|
|
349
|
+
concurrent: activeConcurrent(records, sessionId, target, repo.worktree, now),
|
|
350
|
+
};
|
|
351
|
+
const errors = validateSessionRecord(record);
|
|
352
|
+
if (errors.length) return { record: null, path: null, notice: null, compactionHint: false, errors };
|
|
353
|
+
atomicJson(recordPath, record);
|
|
354
|
+
existing = record;
|
|
355
|
+
return {
|
|
356
|
+
record,
|
|
357
|
+
path: recordPath,
|
|
358
|
+
notice: consumeNotice(store),
|
|
359
|
+
compactionHint: Boolean(config.compactionHint && editCount >= config.editThreshold),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function validAnchor(cwd, record) {
|
|
364
|
+
if (!record?.head) return true;
|
|
365
|
+
return git(cwd, ['cat-file', '-e', `${record.head}^{commit}`]).ok;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function selectedLessons(root, config) {
|
|
369
|
+
return lessonFiles(root).map(readJson).filter((lesson) => lesson?.approvedAt && lesson.confidence >= config.lessonThreshold
|
|
370
|
+
&& !secretShaped(lesson.text) && !secretShaped(lesson.evidence))
|
|
371
|
+
.sort((a, b) => b.confidence - a.confidence || b.approvedAt.localeCompare(a.approvedAt))
|
|
372
|
+
.slice(0, config.lessonLimit);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function truncateUtf8(text, maxBytes) {
|
|
376
|
+
if (Buffer.byteLength(text) <= maxBytes) return text;
|
|
377
|
+
let output = '';
|
|
378
|
+
for (const character of text) {
|
|
379
|
+
if (Buffer.byteLength(output + character) > maxBytes) break;
|
|
380
|
+
output += character;
|
|
381
|
+
}
|
|
382
|
+
return output;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function renderContext(record, match, lessons, config) {
|
|
386
|
+
if (!record && !lessons.length) return '';
|
|
387
|
+
const lines = record ? [
|
|
388
|
+
`[rsc local ${match} continuation]`,
|
|
389
|
+
`source: ${record.target}/${record.sessionId}`,
|
|
390
|
+
`branch: ${record.branch || 'unavailable'}`,
|
|
391
|
+
`worktree: ${record.worktree || 'unavailable'}`,
|
|
392
|
+
`head: ${record.head || 'unavailable'}`,
|
|
393
|
+
`files: ${record.files.join(', ') || 'none'}`,
|
|
394
|
+
`commits: ${record.commits.join(', ') || 'none'}`,
|
|
395
|
+
] : ['[rsc local approved lessons]'];
|
|
396
|
+
if (record?.ledger.length) lines.push(`ledger: ${record.ledger.map((item) => `${item.path}=${item.status}/${item.openItems}`).join(', ')}`);
|
|
397
|
+
if (record) lines.push(`metrics: cost=${record.cost ?? 'unknown'} toolCalls=${record.toolCalls ?? 'unknown'}`);
|
|
398
|
+
if (record?.concurrent) lines.push('parallel sessions detected; this record was not merged with them.');
|
|
399
|
+
for (const lesson of lessons) lines.push(`approved lesson (${lesson.confidence}): ${lesson.text}`);
|
|
400
|
+
return truncateUtf8(`${lines.join('\n')}\n`, config.contextBytes);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function resume(input = {}) {
|
|
404
|
+
const cwd = resolve(input.cwd || process.cwd());
|
|
405
|
+
const config = settings(input.settings);
|
|
406
|
+
if (!config.enabled) return { match: 'none', record: null, lessons: [], context: '' };
|
|
407
|
+
const store = chooseMemoryRoot(cwd);
|
|
408
|
+
ensureStore(store.root);
|
|
409
|
+
const now = iso(input.now);
|
|
410
|
+
prune(store.root, now, config);
|
|
411
|
+
const current = snapshot(cwd, null);
|
|
412
|
+
const records = readRecords(store.root).filter((record) => validAnchor(cwd, record))
|
|
413
|
+
.sort((a, b) => b.timestamps.updatedAt.localeCompare(a.timestamps.updatedAt));
|
|
414
|
+
let record = records.find((candidate) => candidate.branch === current.branch && candidate.worktree === current.worktree) || null;
|
|
415
|
+
let match = record ? 'exact' : 'none';
|
|
416
|
+
if (!record) {
|
|
417
|
+
record = records.find((candidate) => candidate.worktree === current.worktree) || records[0] || null;
|
|
418
|
+
if (record) match = 'nearby';
|
|
419
|
+
}
|
|
420
|
+
const lessons = selectedLessons(store.root, config);
|
|
421
|
+
return { match, record, lessons, context: renderContext(record, match, lessons, config) };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function learn(input = {}) {
|
|
425
|
+
if (input.approved !== true) return { saved: false, reason: 'individual explicit approval required' };
|
|
426
|
+
const text = cleanString(input.text, 1000);
|
|
427
|
+
const evidence = cleanString(input.evidence, 1000);
|
|
428
|
+
if (!text || !evidence || secretShaped(input.text) || secretShaped(input.evidence)) return { saved: false, reason: 'secret-shaped or invalid lesson' };
|
|
429
|
+
if (!['project', 'global'].includes(input.scope)) return { saved: false, reason: 'scope must be project or global' };
|
|
430
|
+
const confidence = finiteOrNull(input.confidence);
|
|
431
|
+
if (confidence === null || confidence > 1) return { saved: false, reason: 'confidence must be between 0 and 1' };
|
|
432
|
+
const cwd = resolve(input.cwd || process.cwd());
|
|
433
|
+
const store = chooseMemoryRoot(cwd);
|
|
434
|
+
ensureStore(store.root);
|
|
435
|
+
const approvedAt = iso(input.now);
|
|
436
|
+
const id = createHash('sha256').update(`${input.scope}\0${text}\0${evidence}`).digest('hex').slice(0, 20);
|
|
437
|
+
const lesson = { schemaVersion: 1, id, text, evidence, scope: input.scope, confidence, approvedAt };
|
|
438
|
+
const path = join(store.root, 'lessons', `${id}.json`);
|
|
439
|
+
atomicJson(path, lesson);
|
|
440
|
+
return { saved: true, lesson, path, notice: consumeNotice(store) };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function metricsSummary(input = {}) {
|
|
444
|
+
const cwd = resolve(input.cwd || process.cwd());
|
|
445
|
+
const config = settings(input.settings);
|
|
446
|
+
const store = chooseMemoryRoot(cwd);
|
|
447
|
+
ensureStore(store.root);
|
|
448
|
+
prune(store.root, iso(input.now), config);
|
|
449
|
+
const sessions = readRecords(store.root).map(({ sessionId, target, cost, toolCalls, timestamps }) => ({ sessionId, target, cost, toolCalls, timestamps }));
|
|
450
|
+
const unknown = {
|
|
451
|
+
cost: sessions.filter((row) => row.cost === null).length,
|
|
452
|
+
toolCalls: sessions.filter((row) => row.toolCalls === null).length,
|
|
453
|
+
};
|
|
454
|
+
const knownTotal = {
|
|
455
|
+
cost: sessions.reduce((sum, row) => sum + (row.cost ?? 0), 0),
|
|
456
|
+
toolCalls: sessions.reduce((sum, row) => sum + (row.toolCalls ?? 0), 0),
|
|
457
|
+
};
|
|
458
|
+
const total = {
|
|
459
|
+
cost: !sessions.length || unknown.cost ? null : knownTotal.cost,
|
|
460
|
+
toolCalls: !sessions.length || unknown.toolCalls ? null : knownTotal.toolCalls,
|
|
461
|
+
};
|
|
462
|
+
return { sessions, total, knownTotal, unknown };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function memoryStatus(input = {}) {
|
|
466
|
+
const cwd = resolve(input.cwd || process.cwd());
|
|
467
|
+
const store = chooseMemoryRoot(cwd);
|
|
468
|
+
ensureStore(store.root);
|
|
469
|
+
return {
|
|
470
|
+
enabled: settings(input.settings).enabled,
|
|
471
|
+
root: store.root,
|
|
472
|
+
kind: store.kind,
|
|
473
|
+
reason: store.reason,
|
|
474
|
+
sessions: readRecords(store.root).length,
|
|
475
|
+
lessons: lessonFiles(store.root).map(readJson).filter(Boolean).length,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { capture, learn, memoryStatus, metricsSummary, resume } from './session-memory-core.mjs';
|
|
4
|
+
|
|
5
|
+
function input() {
|
|
6
|
+
try {
|
|
7
|
+
const raw = readFileSync(0, 'utf8');
|
|
8
|
+
return raw.trim() ? JSON.parse(raw) : {};
|
|
9
|
+
} catch {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const operation = process.argv[2] || 'status';
|
|
15
|
+
const payload = { ...input(), cwd: process.env.RSC_PROJECT_CWD || process.cwd() };
|
|
16
|
+
let result;
|
|
17
|
+
if (operation === 'capture') result = capture(payload);
|
|
18
|
+
else if (operation === 'resume') result = resume(payload);
|
|
19
|
+
else if (operation === 'learn') result = learn(payload);
|
|
20
|
+
else if (operation === 'metrics') result = metricsSummary(payload);
|
|
21
|
+
else if (operation === 'status') result = memoryStatus(payload);
|
|
22
|
+
else {
|
|
23
|
+
process.stderr.write(`Unknown memory operation: ${operation}\n`);
|
|
24
|
+
process.exitCode = 2;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (result !== undefined) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
@@ -209,6 +209,37 @@ Opt out with .rsc/.no-claudemd-check.
|
|
|
209
209
|
} catch { /* no CLAUDE.md → nothing to check */ }
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
// Worktree sweep: a worktree whose work already reached the trunk is landed work wearing the same
|
|
213
|
+
// clothes as live work, and the list stops being readable once a couple of them pile up. `ship`
|
|
214
|
+
// retires the one it just landed; this is for the merge that happened somewhere else — a pull request
|
|
215
|
+
// accepted in the forge, a merge by hand, a session that ended before ship. It only ever NAMES them:
|
|
216
|
+
// at session start the user has asked for nothing, and acting on disk before they say a word is how a
|
|
217
|
+
// safeguard earns itself an opt-out. Silent when there is nothing to say. Off with .rsc/.no-worktree-cleanup.
|
|
218
|
+
if (has('.git')) {
|
|
219
|
+
try {
|
|
220
|
+
// No opt-out check here: `classifyWorktrees` returns nothing when the project turned the cleanup
|
|
221
|
+
// off, so a second check could never fail and would only look like protection (P2). Test 36 holds
|
|
222
|
+
// the behaviour end to end, through this hook.
|
|
223
|
+
const W = await import('./worktree-reaper.mjs');
|
|
224
|
+
{
|
|
225
|
+
const candidates = W.classifyWorktrees(root).filter((c) => c.verdict !== 'skip');
|
|
226
|
+
if (candidates.length) {
|
|
227
|
+
process.stdout.write(`
|
|
228
|
+
===== rsc worktree cleanup =====
|
|
229
|
+
${candidates.length} worktree(s) hold work that is already in the trunk:
|
|
230
|
+
${candidates.map((c) => W.summarize(c, root)).join('\n')}
|
|
231
|
+
ACTION: offer to retire them in ONE line and wait for a yes. On a yes run:
|
|
232
|
+
npx @ericrisco/rsc worktrees reap (the ones marked safe)
|
|
233
|
+
npx @ericrisco/rsc worktrees reap <path> (one the user confirmed by name)
|
|
234
|
+
A yes in bulk covers only the safe ones; anything marked \`ask\` is confirmed on its own.
|
|
235
|
+
A no holds for this session. Permanent off: .rsc/.no-worktree-cleanup
|
|
236
|
+
================================
|
|
237
|
+
`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} catch { /* reaper missing or git unhappy → say nothing; this is never worth breaking startup for */ }
|
|
241
|
+
}
|
|
242
|
+
|
|
212
243
|
// Update check: compare the installed version (.rsc/.version, written at install)
|
|
213
244
|
// against the latest published on npm, and nudge the agent to offer an update.
|
|
214
245
|
// Fail-silent (offline / missing baseline / parse error → nothing). Disable with
|