@ericrisco/rsc 3.0.7 → 3.0.9
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 +27 -12
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/audit.js +11 -0
- package/scripts/doctor.js +69 -11
- package/scripts/install-apply.js +221 -18
- package/scripts/lib/constitution-draft.js +156 -0
- package/scripts/lib/onboarding-apply.js +31 -25
- package/scripts/lib/onboarding.js +26 -4
- package/scripts/lib/tools-skeleton.js +76 -0
- package/scripts/rsc.js +109 -14
- package/skills/constitution/SKILL.md +2 -0
- package/skills/ftd/SKILL.md +9 -0
- package/skills/sdd/SKILL.md +2 -2
- package/skills/specify/SKILL.md +1 -1
- package/skills/suggest/SKILL.md +5 -4
- package/targets/branch-guard.mjs +94 -26
- package/targets/claude.js +36 -6
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/danger-guard.mjs +80 -14
- package/targets/ftd-nudge.mjs +137 -0
- package/targets/gitmoji-guard.mjs +14 -1
- package/targets/knowledge-sync.mjs +490 -48
- package/targets/opt-outs.js +2 -0
- package/targets/sello.mjs +9 -2
- package/targets/session-memory-adapter.mjs +27 -3
- package/targets/session-memory-core.mjs +219 -36
- package/targets/session-start.mjs +40 -5
- package/targets/shell-unwrap.mjs +145 -0
- package/targets/ship-guard.mjs +40 -9
- package/targets/team-safe-start.mjs +76 -10
- package/targets/worktree-reaper.mjs +114 -4
package/targets/opt-outs.js
CHANGED
|
@@ -22,6 +22,8 @@ export const PROJECT_OPT_OUTS = [
|
|
|
22
22
|
'claudemd-check', // the root CLAUDE.md context budget
|
|
23
23
|
'danger-guard', // foot-gun denial, keyed to the committed user profile
|
|
24
24
|
'feature-gate', // per-turn re-injection of the SDD gate
|
|
25
|
+
'ftd-close', // a landed branch's feature documents marked done by the post-merge hook
|
|
26
|
+
'ftd-nudge', // once-per-session reminder: code changed and no feature document yet
|
|
25
27
|
'gitmoji', // the commit-message convention
|
|
26
28
|
'knowledge-sync', // 01-TOOLS/ + 02-DOCS/ sync: one person off and the team stops seeing their work
|
|
27
29
|
'ship-guard', // branch/trunk discipline
|
package/targets/sello.mjs
CHANGED
|
@@ -333,12 +333,19 @@ const GIT_OPTS = '(?:-{1,2}[\\w-]+(?:=\\S+)?\\s+|-[A-Za-z]\\s+\\S+\\s+)*';
|
|
|
333
333
|
const GIT_DELIVERY = new RegExp(`\\bgit\\s+${GIT_OPTS}(?:commit|push)(?![\\w-])`);
|
|
334
334
|
const GH_DELIVERY = new RegExp(`\\bgh\\s+${GIT_OPTS}pr\\s+${GIT_OPTS}(?:create|merge)(?![\\w-])`);
|
|
335
335
|
|
|
336
|
+
// A delivery handed to a shell as a string (`bash -c "git push"`, `eval …`) is still a delivery
|
|
337
|
+
// (E2E defect 15): the segments the shell really runs are judged too. Sibling import, fail-soft.
|
|
338
|
+
const SH = await import(new URL('./shell-unwrap.mjs', import.meta.url)).catch(() => null);
|
|
339
|
+
|
|
336
340
|
export function isDeliveryCommand(command) {
|
|
337
341
|
if (typeof command !== 'string' || !command) return false;
|
|
338
342
|
// Blank out quoted strings so a command that merely MENTIONS the words is not
|
|
339
343
|
// mistaken for one that runs them.
|
|
340
|
-
const
|
|
341
|
-
|
|
344
|
+
const delivers = (c) => {
|
|
345
|
+
const bare = c.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""');
|
|
346
|
+
return GIT_DELIVERY.test(bare) || GH_DELIVERY.test(bare);
|
|
347
|
+
};
|
|
348
|
+
return delivers(command) || (SH ? SH.expand(command).some(delivers) : false);
|
|
342
349
|
}
|
|
343
350
|
|
|
344
351
|
// ---------------------------------------------------------------- messages (P6)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
4
|
-
import { resolve, join, dirname } from 'node:path';
|
|
4
|
+
import { resolve, join, dirname, isAbsolute, sep } from 'node:path';
|
|
5
5
|
import { capture, otherActiveSessions, resume } from './session-memory-core.mjs';
|
|
6
6
|
|
|
7
7
|
const LOCAL_TARGETS = new Set(['claude', 'codex', 'cursor', 'deepseek', 'gemini', 'opencode']);
|
|
@@ -20,16 +20,39 @@ function projectSettings(cwd) {
|
|
|
20
20
|
// The hook payload's `cwd` is only where the tool call happened to run; inside a container of child
|
|
21
21
|
// repos that is routinely a subdirectory with no harness of its own, and anchoring there scatters
|
|
22
22
|
// one session's journal across children. With no harness anywhere above, the cwd stays the project.
|
|
23
|
-
|
|
23
|
+
//
|
|
24
|
+
// One exception, and it is the team workflow: a worktree under `<project>/.worktrees/<branch>/`
|
|
25
|
+
// checks out its OWN committed `.rsc.json`, so the walk stopped there and the session's later events
|
|
26
|
+
// went to a second store inside the worktree — while its open record stayed in the project's store
|
|
27
|
+
// and kept telling every later session in the main checkout that someone was working there (team
|
|
28
|
+
// simulation W1). A worktree the project made belongs to the project: its journal lives in the
|
|
29
|
+
// project's store, with the worktree recorded as WHERE the session works.
|
|
30
|
+
export function nearestHarness(dir) {
|
|
24
31
|
let current = dir;
|
|
25
32
|
for (;;) {
|
|
26
|
-
if (existsSync(join(current, '.rsc.json'))) return current;
|
|
33
|
+
if (existsSync(join(current, '.rsc.json'))) return parentProjectOf(current) || current;
|
|
27
34
|
const parent = dirname(current);
|
|
28
35
|
if (parent === current) return dir;
|
|
29
36
|
current = parent;
|
|
30
37
|
}
|
|
31
38
|
}
|
|
32
39
|
|
|
40
|
+
function parentProjectOf(dir) {
|
|
41
|
+
const parts = resolve(dir).split(sep);
|
|
42
|
+
for (let index = 1; index < parts.length; index += 1) {
|
|
43
|
+
if (parts[index] !== '.worktrees') continue;
|
|
44
|
+
const project = parts.slice(0, index).join(sep) || sep;
|
|
45
|
+
if (existsSync(join(project, '.rsc.json'))) return project;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function editedPath(native) {
|
|
51
|
+
const input = native?.tool_input || native?.toolInput || {};
|
|
52
|
+
const path = input.file_path || input.notebook_path || input.path || native?.file_path || null;
|
|
53
|
+
return typeof path === 'string' && isAbsolute(path) ? path : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
33
56
|
function sessionId(native, target) {
|
|
34
57
|
return native?.session_id || native?.sessionId || native?.sessionID || native?.conversation_id
|
|
35
58
|
|| native?.thread_id || `${target}-${process.ppid}`;
|
|
@@ -144,6 +167,7 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
|
|
|
144
167
|
editDelta: event === 'edit' ? 1 : 0,
|
|
145
168
|
settings: config,
|
|
146
169
|
};
|
|
170
|
+
if (event === 'edit' && editedPath(native)) captureInput.filePath = editedPath(native);
|
|
147
171
|
if (typeof native.cost === 'number') captureInput.cost = native.cost;
|
|
148
172
|
if (Number.isInteger(native.tool_calls)) captureInput.toolCalls = native.tool_calls;
|
|
149
173
|
const captured = capture(captureInput);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
-
appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync,
|
|
2
|
+
appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync,
|
|
3
3
|
} from 'node:fs';
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
@@ -83,21 +83,27 @@ export function chooseMemoryRoot(cwd = process.cwd()) {
|
|
|
83
83
|
return { root: join(project, '.rsc', 'memory'), kind: 'local-state', reason: 'without-git', git: false };
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// The harness itself commits `worklog/.gitkeep` so the folder exists in every clone. A placeholder
|
|
87
|
+
// is not the team committing its worklog, and reading it as one warned every teammate in every clone
|
|
88
|
+
// about a tracked worklog that was an empty file (team simulation D13).
|
|
86
89
|
const worklog = join(project, '02-DOCS', 'raw', 'worklog');
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
const local = join(project, '.rsc', 'memory');
|
|
91
|
+
const trackedFiles = existsSync(worklog)
|
|
92
|
+
? git(project, ['ls-files', '--', '02-DOCS/raw/worklog']).out.split('\n').filter(Boolean) : [];
|
|
93
|
+
const trackedNotes = trackedFiles.filter((file) => !/(^|\/)\.(gitkeep|keep)$/u.test(file));
|
|
94
|
+
// A clone whose journal already lives in .rsc/memory keeps it there: moving would orphan its history.
|
|
95
|
+
const placeholderOnly = trackedFiles.length > 0 && trackedNotes.length === 0;
|
|
96
|
+
const settled = placeholderOnly && existsSync(join(local, 'sessions'));
|
|
97
|
+
if (existsSync(worklog) && !trackedNotes.length && !settled) {
|
|
98
|
+
const candidate = join(worklog, '.rsc-memory');
|
|
99
|
+
if (ensureExcluded(project, candidate)) {
|
|
100
|
+
return { root: candidate, kind: 'wiki-worklog', reason: 'untracked-worklog', git: true };
|
|
94
101
|
}
|
|
95
102
|
}
|
|
96
103
|
|
|
97
|
-
const local = join(project, '.rsc', 'memory');
|
|
98
104
|
if (ensureExcluded(project, local)) {
|
|
99
|
-
const
|
|
100
|
-
return { root: local, kind: 'local-state', reason
|
|
105
|
+
const reason = trackedNotes.length ? 'tracked-worklog' : settled ? 'placeholder-worklog' : 'no-wiki';
|
|
106
|
+
return { root: local, kind: 'local-state', reason, git: true };
|
|
101
107
|
}
|
|
102
108
|
|
|
103
109
|
const gitRoot = git(project, ['rev-parse', '--git-path', 'rsc-memory']).out;
|
|
@@ -108,7 +114,7 @@ export function chooseMemoryRoot(cwd = process.cwd()) {
|
|
|
108
114
|
}
|
|
109
115
|
|
|
110
116
|
function ensureStore(root) {
|
|
111
|
-
for (const dir of ['sessions', 'anchors', 'lessons']) mkdirSync(join(root, dir), { recursive: true });
|
|
117
|
+
for (const dir of ['sessions', 'anchors', 'lessons', 'presence']) mkdirSync(join(root, dir), { recursive: true });
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
function atomicJson(path, value) {
|
|
@@ -169,9 +175,13 @@ function fingerprintFiles(worktree, files) {
|
|
|
169
175
|
}));
|
|
170
176
|
}
|
|
171
177
|
|
|
178
|
+
// `📥 docs(auto): sync desde rsc/knowledge` — written by knowledge-sync.mjs when it imports the team's
|
|
179
|
+
// documents. Authored locally, but its content is other people's.
|
|
180
|
+
const KNOWLEDGE_IMPORT = /^\S*\s*docs\(auto\): sync desde /u;
|
|
181
|
+
|
|
172
182
|
function snapshot(cwd, baselineHead = null) {
|
|
173
183
|
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: [] };
|
|
184
|
+
if (!top.ok) return { git: false, branch: null, worktree: resolve(cwd), head: null, files: [], dirty: [], committed: [], fingerprints: {}, commits: [] };
|
|
175
185
|
const worktree = resolve(top.out);
|
|
176
186
|
const branch = cleanString(git(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']).out, 255);
|
|
177
187
|
const head = cleanString(git(cwd, ['rev-parse', '--verify', 'HEAD']).out, 64);
|
|
@@ -179,11 +189,36 @@ function snapshot(cwd, baselineHead = null) {
|
|
|
179
189
|
let committed = [];
|
|
180
190
|
let commits = [];
|
|
181
191
|
if (baselineHead && head && git(cwd, ['cat-file', '-e', `${baselineHead}^{commit}`]).ok) {
|
|
182
|
-
|
|
183
|
-
|
|
192
|
+
// The session's OWN commits: the first-parent chain, merges left out. `baseline..head` after a
|
|
193
|
+
// `git merge origin/main` also walks every teammate commit the merge brought in, and the memory
|
|
194
|
+
// then told the next session it had done their work and should carry out their next step (team
|
|
195
|
+
// simulation, 2026-10-07). The files follow from those commits only — never `diff baseline..head`,
|
|
196
|
+
// which counts everything the merge changed. The knowledge-sync import commit is machine-made and
|
|
197
|
+
// carries other people's feature documents, so it is not the session's work either.
|
|
198
|
+
//
|
|
199
|
+
// A merge the session itself made IS its work, though (second team simulation: a session that
|
|
200
|
+
// merged main and resolved five conflicts was recorded as «no commits, files: none»). So merges on
|
|
201
|
+
// the first-parent chain stay, and from a merge only what the session resolved counts — the
|
|
202
|
+
// combined diff (`--cc`), never everything the merge brought in.
|
|
203
|
+
const own = git(cwd, ['log', '--first-parent', '--format=%H%x09%P%x09%s', `${baselineHead}..${head}`]).out
|
|
204
|
+
.split('\n').filter(Boolean).map((line) => line.split('\t'))
|
|
205
|
+
.filter(([, , subject = '']) => !KNOWLEDGE_IMPORT.test(subject));
|
|
206
|
+
commits = own.map(([sha]) => cleanString(sha, 64)).filter(Boolean);
|
|
207
|
+
const merges = new Set(own.filter(([, parents = '']) => parents.trim().split(/\s+/).length > 1).map(([sha]) => sha));
|
|
208
|
+
const touched = new Set();
|
|
209
|
+
for (const sha of commits) {
|
|
210
|
+
const args = merges.has(sha)
|
|
211
|
+
? ['diff-tree', '--no-commit-id', '--name-only', '--cc', '-z', sha]
|
|
212
|
+
: ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', '--root', sha];
|
|
213
|
+
for (const item of git(cwd, args).out.split('\0')) {
|
|
214
|
+
const clean = cleanString(item, 1000);
|
|
215
|
+
if (clean) touched.add(clean);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
committed = [...touched];
|
|
184
219
|
}
|
|
185
220
|
const files = [...new Set([...dirty, ...committed])].sort();
|
|
186
|
-
return { git: true, branch, worktree, head, files, fingerprints: fingerprintFiles(worktree, dirty), commits };
|
|
221
|
+
return { git: true, branch, worktree, head, files, dirty, committed, fingerprints: fingerprintFiles(worktree, dirty), commits };
|
|
187
222
|
}
|
|
188
223
|
|
|
189
224
|
function ledgerSnapshot(cwd) {
|
|
@@ -228,19 +263,76 @@ function prune(root, now, config) {
|
|
|
228
263
|
const updated = value?.timestamps?.updatedAt || value?.startedAt;
|
|
229
264
|
if (!updated || new Date(updated).getTime() < cutoff) rmSync(path, { force: true });
|
|
230
265
|
}
|
|
266
|
+
// Presence only means anything inside the active window; a day is generous and keeps the dir small.
|
|
267
|
+
const presenceCutoff = new Date(now).getTime() - 86400000;
|
|
268
|
+
for (const path of presenceFiles(root)) {
|
|
269
|
+
const value = readJson(path);
|
|
270
|
+
if (!value?.updatedAt || new Date(value.updatedAt).getTime() < presenceCutoff) rmSync(path, { force: true });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function presenceFiles(root) {
|
|
275
|
+
const dir = join(root, 'presence');
|
|
276
|
+
if (!existsSync(dir)) return [];
|
|
277
|
+
return readdirSync(dir).filter((name) => name.endsWith('.json')).map((name) => join(dir, name));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const PRESENCE_FIELDS = ['schemaVersion', 'sessionId', 'target', 'branch', 'worktree', 'updatedAt'];
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* "A session is here", written on every event from the first one (team simulation W2). The journal
|
|
284
|
+
* only writes a RECORD once there is work, so a session that had just started — or was still reading
|
|
285
|
+
* before its first edit — was invisible, and a second session opened twenty seconds later was never
|
|
286
|
+
* told to isolate. A record cannot carry this: resume would hand that empty record to the next
|
|
287
|
+
* session as "your continuation". So presence is its own tiny file: who and where, never what — no
|
|
288
|
+
* prompt, no file, no content (P3) — and it is removed when the session ends.
|
|
289
|
+
*/
|
|
290
|
+
function writePresence(root, storageId, presence) {
|
|
291
|
+
const value = Object.fromEntries(PRESENCE_FIELDS.map((key) => [key, presence[key] ?? null]));
|
|
292
|
+
if (Object.values(value).some((item) => secretShaped(item))) return;
|
|
293
|
+
atomicJson(join(root, 'presence', `${storageId}.json`), value);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function readPresence(root) {
|
|
297
|
+
return presenceFiles(root).map(readJson).filter((item) => item && item.schemaVersion === 1
|
|
298
|
+
&& typeof item.sessionId === 'string' && typeof item.updatedAt === 'string'
|
|
299
|
+
&& Object.keys(item).every((key) => PRESENCE_FIELDS.includes(key)));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Where a session is, in the parent project's terms: the worktree under `<project>/.worktrees/` that
|
|
303
|
+
// holds `path`, or null when `path` is not inside one.
|
|
304
|
+
function nestedWorktreeOf(project, path) {
|
|
305
|
+
if (!path) return null;
|
|
306
|
+
const rel = relativeSlash(project, path);
|
|
307
|
+
if (!rel.startsWith('.worktrees/')) return null;
|
|
308
|
+
// git needs a directory to stand in: the file's own folder, or the nearest one that exists yet.
|
|
309
|
+
let probe = existsSync(path) && statSync(path).isDirectory() ? path : dirname(path);
|
|
310
|
+
while (!existsSync(probe)) {
|
|
311
|
+
const parent = dirname(probe);
|
|
312
|
+
if (parent === probe) return null;
|
|
313
|
+
probe = parent;
|
|
314
|
+
}
|
|
315
|
+
const top = git(probe, ['rev-parse', '--show-toplevel']);
|
|
316
|
+
if (!top.ok) return null;
|
|
317
|
+
const worktree = resolve(top.out);
|
|
318
|
+
return relativeSlash(project, worktree).startsWith('.worktrees/') ? worktree : null;
|
|
231
319
|
}
|
|
232
320
|
|
|
233
321
|
function noticeText(info) {
|
|
322
|
+
if (info.reason === 'placeholder-worklog') return null; // nothing the person needs to know
|
|
234
323
|
if (info.reason === 'tracked-worklog') return 'rsc memory: tracked worklog detected; using ignored local state instead.';
|
|
235
324
|
if (info.reason === 'untracked-worklog') return 'rsc memory: using the ignored wiki worklog store.';
|
|
236
325
|
if (info.reason === 'without-git') return 'rsc memory: running without git; branch and commit metadata are unavailable.';
|
|
237
326
|
if (info.reason === 'local-state-tracked') return 'rsc memory: .rsc memory was tracked; using git-private local state.';
|
|
238
|
-
|
|
327
|
+
// The ordinary case (no wiki worklog, local state) is not news: said to every new clone it was noise
|
|
328
|
+
// (second team simulation), like the placeholder case above.
|
|
329
|
+
return null;
|
|
239
330
|
}
|
|
240
331
|
|
|
241
332
|
function consumeNotice(info) {
|
|
242
333
|
const marker = join(info.root, `.notice-${info.reason}`);
|
|
243
334
|
if (existsSync(marker)) return null;
|
|
335
|
+
if (!noticeText(info)) return null;
|
|
244
336
|
writeFileSync(marker, `${iso()}\n`, { mode: 0o600 });
|
|
245
337
|
return noticeText(info);
|
|
246
338
|
}
|
|
@@ -267,8 +359,10 @@ export const ACTIVE_WINDOW_MS = 30 * 60 * 1000;
|
|
|
267
359
|
* never WHAT is in the project (constitution P3), reads only the local journal (no network, no
|
|
268
360
|
* content), and its worst failure is one worktree too many or too few.
|
|
269
361
|
*
|
|
270
|
-
* A session that only talks has no
|
|
271
|
-
*
|
|
362
|
+
* A session that only talks has no RECORD — the journal only writes when there is work — but it has a
|
|
363
|
+
* PRESENCE from its first event, so a session opened seconds after another one is still told to
|
|
364
|
+
* isolate (team simulation W2). Sessions are counted where they are NOW: one that moved into
|
|
365
|
+
* `.worktrees/<b>/` no longer counts in the main checkout.
|
|
272
366
|
*/
|
|
273
367
|
export function otherActiveSessions(input = {}) {
|
|
274
368
|
try {
|
|
@@ -280,12 +374,30 @@ export function otherActiveSessions(input = {}) {
|
|
|
280
374
|
if (!existsSync(store.root)) return [];
|
|
281
375
|
const now = new Date(input.now || Date.now()).getTime();
|
|
282
376
|
const windowMs = Number.isFinite(input.windowMs) ? input.windowMs : ACTIVE_WINDOW_MS;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
.
|
|
377
|
+
// Each session once, at its LATEST known position: its record (work) or its presence (any event),
|
|
378
|
+
// whichever is newer. A session that moved into a worktree is no longer here, whatever an older
|
|
379
|
+
// record of it says.
|
|
380
|
+
const latest = new Map();
|
|
381
|
+
for (const r of readRecords(store.root)) {
|
|
382
|
+
latest.set(`${r.target}--${r.sessionId}`, {
|
|
383
|
+
target: r.target, sessionId: r.sessionId, branch: r.branch, worktree: r.worktree,
|
|
384
|
+
updatedAt: r.timestamps?.updatedAt, completedAt: r.timestamps?.completedAt || null,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
for (const p of readPresence(store.root)) {
|
|
388
|
+
const key = `${p.target}--${p.sessionId}`;
|
|
389
|
+
const known = latest.get(key);
|
|
390
|
+
const newer = !known || new Date(p.updatedAt).getTime() >= new Date(known.updatedAt || 0).getTime();
|
|
391
|
+
// A presence newer than the record's close means the session came back (resumed by id).
|
|
392
|
+
if (newer) latest.set(key, { ...p, completedAt: known?.completedAt && new Date(known.completedAt).getTime() >= new Date(p.updatedAt).getTime() ? known.completedAt : null });
|
|
393
|
+
}
|
|
394
|
+
const self = (s) => s.sessionId === cleanId(input.sessionId, 'session') && s.target === input.target;
|
|
395
|
+
return [...latest.values()]
|
|
396
|
+
.filter((s) => s.worktree === here)
|
|
397
|
+
.filter((s) => !self(s))
|
|
398
|
+
.filter((s) => !s.completedAt)
|
|
399
|
+
.filter((s) => now - new Date(s.updatedAt || 0).getTime() <= windowMs)
|
|
400
|
+
.map((s) => ({ target: s.target, sessionId: s.sessionId, branch: s.branch, updatedAt: s.updatedAt }));
|
|
289
401
|
} catch {
|
|
290
402
|
return [];
|
|
291
403
|
}
|
|
@@ -322,7 +434,7 @@ export function capture(input = {}) {
|
|
|
322
434
|
// The second has to be asked of git from where the agent actually works: asked from the harness,
|
|
323
435
|
// every worktree nested under it came back as the harness itself, and a sibling's record then
|
|
324
436
|
// "matched exactly". See GHSA-8gjp-3r7c-33f4.
|
|
325
|
-
|
|
437
|
+
let here = resolve(input.worktreeCwd || cwd);
|
|
326
438
|
const config = settings(input.settings);
|
|
327
439
|
if (!config.enabled) return { record: null, path: null, notice: null, compactionHint: false };
|
|
328
440
|
const store = chooseMemoryRoot(cwd);
|
|
@@ -338,7 +450,33 @@ export function capture(input = {}) {
|
|
|
338
450
|
const recordPath = join(store.root, 'sessions', `${storageId}.json`);
|
|
339
451
|
let anchor = readJson(anchorPath);
|
|
340
452
|
let existing = readJson(recordPath);
|
|
453
|
+
// A session that moved into `.worktrees/<b>/` is THERE, also on the events that do not say so.
|
|
454
|
+
// An edit names its file — the first sign of the move when the agent writes by absolute path and
|
|
455
|
+
// its cwd never changed — and `Stop`/`SessionEnd` name nothing, so the move is remembered on the
|
|
456
|
+
// anchor until the session edits the main checkout again. Without this the record stayed on the
|
|
457
|
+
// main checkout, open, and every later session there was told for half an hour that someone was
|
|
458
|
+
// working beside it (team simulation W1).
|
|
459
|
+
const projectTop = git(cwd, ['rev-parse', '--show-toplevel']);
|
|
460
|
+
const project = projectTop.ok ? resolve(projectTop.out) : cwd;
|
|
461
|
+
const fileHint = typeof input.filePath === 'string' && isAbsolute(input.filePath) ? resolve(input.filePath) : null;
|
|
462
|
+
let movedTo = anchor?.movedTo || null;
|
|
463
|
+
const fromFile = nestedWorktreeOf(project, fileHint);
|
|
464
|
+
if (fromFile) here = fromFile;
|
|
465
|
+
const nestedHere = nestedWorktreeOf(project, here);
|
|
466
|
+
if (nestedHere) movedTo = nestedHere;
|
|
467
|
+
else if (fileHint && !relativeSlash(project, fileHint).startsWith('..')) movedTo = null; // back in the main checkout
|
|
468
|
+
else if (movedTo && existsSync(movedTo)) here = movedTo;
|
|
469
|
+
else movedTo = null;
|
|
341
470
|
const firstSnapshot = snapshot(here, null);
|
|
471
|
+
// Presence: on every event, before any early return — the point is to be seen before there is work.
|
|
472
|
+
const ending = ['end', 'sessionEnd', 'stop'].includes(input.event);
|
|
473
|
+
if (ending) rmSync(join(store.root, 'presence', `${storageId}.json`), { force: true });
|
|
474
|
+
else {
|
|
475
|
+
writePresence(store.root, storageId, {
|
|
476
|
+
schemaVersion: 1, sessionId, target, branch: cleanString(firstSnapshot.branch, 255),
|
|
477
|
+
worktree: cleanString(firstSnapshot.worktree, 1000), updatedAt: now,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
342
480
|
if (!anchor) {
|
|
343
481
|
anchor = {
|
|
344
482
|
sessionId,
|
|
@@ -346,8 +484,13 @@ export function capture(input = {}) {
|
|
|
346
484
|
baselineHead: cleanString(input.baselineHead, 64) || firstSnapshot.head,
|
|
347
485
|
baselineFiles: firstSnapshot.files,
|
|
348
486
|
baselineFingerprints: firstSnapshot.fingerprints,
|
|
487
|
+
...(movedTo ? { movedTo } : {}),
|
|
349
488
|
};
|
|
350
489
|
atomicJson(anchorPath, anchor);
|
|
490
|
+
} else if ((anchor.movedTo || null) !== movedTo) {
|
|
491
|
+
const { movedTo: _previous, ...rest } = anchor;
|
|
492
|
+
anchor = movedTo ? { ...rest, movedTo } : rest;
|
|
493
|
+
atomicJson(anchorPath, anchor);
|
|
351
494
|
}
|
|
352
495
|
if (input.event === 'start' && !existing) return { record: null, path: null, notice: null, compactionHint: false };
|
|
353
496
|
|
|
@@ -391,7 +534,11 @@ export function capture(input = {}) {
|
|
|
391
534
|
worktree: cleanString(repo.worktree, 1000),
|
|
392
535
|
baselineHead: cleanString(anchor.baselineHead, 64),
|
|
393
536
|
head: cleanString(repo.head, 64),
|
|
394
|
-
|
|
537
|
+
// What THIS session touched: what it committed, plus what is dirty now and was not dirty — or not
|
|
538
|
+
// this content — when it started. The plain union listed whatever an install had left untracked,
|
|
539
|
+
// so a session that never opened `.rsc.json` reported it as its work (E2E 2026-10-07).
|
|
540
|
+
files: [...new Set([...repo.committed, ...repo.dirty.filter((path) => !baselineFiles.has(path)
|
|
541
|
+
|| baselineFingerprints[path] !== repo.fingerprints[path])])].sort().filter((item) => !secretShaped(item)),
|
|
395
542
|
commits: repo.commits.filter((item) => !secretShaped(item)),
|
|
396
543
|
ledger: ledgerSnapshot(cwd),
|
|
397
544
|
timestamps: { startedAt: anchor.startedAt, updatedAt: now, completedAt: completed },
|
|
@@ -455,19 +602,54 @@ function truncateUtf8(text, maxBytes) {
|
|
|
455
602
|
return output;
|
|
456
603
|
}
|
|
457
604
|
|
|
458
|
-
|
|
605
|
+
// WHAT the last session did, not only where it stopped: the subjects of its commits and the next step
|
|
606
|
+
// its feature document wrote down. Read at resume time from git and the document — never from the
|
|
607
|
+
// prompts, which the record promises not to keep — so the record's closed schema does not change.
|
|
608
|
+
function subjects(cwd, commits) {
|
|
609
|
+
return commits.slice(0, 8).map((sha) => {
|
|
610
|
+
const out = git(cwd, ['log', '-1', '--format=%h %s', sha]);
|
|
611
|
+
return out.ok ? cleanString(out.out, 160) : cleanString(sha.slice(0, 7), 7);
|
|
612
|
+
}).filter((item) => item && !secretShaped(item));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// A document whose frontmatter says `status: done` is finished work, not a next step. On a closed
|
|
616
|
+
// default branch the close travels by rsc/knowledge and is not in the checkout yet (knowledge-sync
|
|
617
|
+
// `closeLanded`), so the exchange branch's copy is asked too — a local ref, no network.
|
|
618
|
+
const isDone = (text) => /^status:[ \t]*done[ \t]*$/mu.test(String(text || '').replace(/\r\n/gu, '\n').match(/^---\n([\s\S]*?)\n---(?:\n|$)/u)?.[1] || '');
|
|
619
|
+
|
|
620
|
+
function nextSteps(cwd, files) {
|
|
621
|
+
const out = [];
|
|
622
|
+
for (const rel of files.filter((f) => /^02-DOCS\/wiki\/(ftd|sdd\/(specs|plans|progress))\/[^/]+\.md$/u.test(f)).slice(0, 3)) {
|
|
623
|
+
let body = '';
|
|
624
|
+
try { body = readFileSync(join(cwd, rel), 'utf8'); } catch { continue; }
|
|
625
|
+
if (isDone(body)) continue;
|
|
626
|
+
const exchange = git(cwd, ['show', `refs/remotes/origin/rsc/knowledge:${rel}`]);
|
|
627
|
+
if (exchange.ok && isDone(exchange.out)) continue;
|
|
628
|
+
const section = body.match(/^##\s*(Next|Siguiente|Próximo)[^\n]*\n+([\s\S]*?)(?=\n##\s|$)/imu)?.[2];
|
|
629
|
+
const first = section?.split('\n').map((l) => l.replace(/^[-*]\s*/u, '').trim()).find(Boolean);
|
|
630
|
+
const open = (body.match(/^- \[ \]/gmu) || []).length;
|
|
631
|
+
if (first || open) out.push(`${rel}: ${first ? cleanString(first, 200) : 'no next step written'}${open ? ` (${open} open)` : ''}`);
|
|
632
|
+
}
|
|
633
|
+
return out;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function renderContext(record, match, lessons, config, cwd = process.cwd()) {
|
|
459
637
|
if (!record && !lessons.length) return '';
|
|
638
|
+
const done = record ? subjects(cwd, record.commits) : [];
|
|
639
|
+
const next = record ? nextSteps(cwd, record.files) : [];
|
|
460
640
|
const lines = record ? [
|
|
461
641
|
`[rsc local ${match} continuation]`,
|
|
462
|
-
`source: ${record.target}/${record.sessionId}`,
|
|
642
|
+
`source: ${record.target}/${record.sessionId} (${record.timestamps.startedAt} → ${record.timestamps.updatedAt})`,
|
|
463
643
|
`branch: ${record.branch || 'unavailable'}`,
|
|
464
644
|
`worktree: ${record.worktree || 'unavailable'}`,
|
|
465
645
|
`head: ${record.head || 'unavailable'}`,
|
|
646
|
+
`done: ${done.length ? done.join(' | ') : 'no commits'}`,
|
|
466
647
|
`files: ${record.files.join(', ') || 'none'}`,
|
|
467
|
-
`
|
|
648
|
+
...(next.length ? [`next: ${next.join(' | ')}`] : []),
|
|
468
649
|
] : ['[rsc local approved lessons]'];
|
|
469
650
|
if (record?.ledger.length) lines.push(`ledger: ${record.ledger.map((item) => `${item.path}=${item.status}/${item.openItems}`).join(', ')}`);
|
|
470
|
-
|
|
651
|
+
// Only when something is known: «cost=unknown toolCalls=unknown» on every resume told the model nothing.
|
|
652
|
+
if (record && (record.cost != null || record.toolCalls != null)) lines.push(`metrics: cost=${record.cost ?? 'unknown'} toolCalls=${record.toolCalls ?? 'unknown'}`);
|
|
471
653
|
if (record?.concurrent) lines.push('parallel sessions detected; this record was not merged with them.');
|
|
472
654
|
for (const lesson of lessons) lines.push(`approved lesson (${lesson.confidence}): ${lesson.text}`);
|
|
473
655
|
return truncateUtf8(`${lines.join('\n')}\n`, config.contextBytes);
|
|
@@ -497,7 +679,7 @@ export function resume(input = {}) {
|
|
|
497
679
|
if (record) match = 'nearby';
|
|
498
680
|
}
|
|
499
681
|
const lessons = selectedLessons(store.root, config);
|
|
500
|
-
return { match, record, lessons, context: renderContext(record, match, lessons, config) };
|
|
682
|
+
return { match, record, lessons, context: renderContext(record, match, lessons, config, here) };
|
|
501
683
|
}
|
|
502
684
|
|
|
503
685
|
export function learn(input = {}) {
|
|
@@ -530,10 +712,11 @@ export function metricsSummary(input = {}) {
|
|
|
530
712
|
cost: sessions.filter((row) => row.cost === null).length,
|
|
531
713
|
toolCalls: sessions.filter((row) => row.toolCalls === null).length,
|
|
532
714
|
};
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
715
|
+
// A sum over nothing known is unknown, not zero: doctor printed `cost: 0` for three sessions whose
|
|
716
|
+
// cost nobody had measured.
|
|
717
|
+
const knownSum = (key) => (unknown[key] === sessions.length ? null
|
|
718
|
+
: sessions.reduce((sum, row) => sum + (row[key] ?? 0), 0));
|
|
719
|
+
const knownTotal = { cost: knownSum('cost'), toolCalls: knownSum('toolCalls') };
|
|
537
720
|
const total = {
|
|
538
721
|
cost: !sessions.length || unknown.cost ? null : knownTotal.cost,
|
|
539
722
|
toolCalls: !sessions.length || unknown.toolCalls ? null : knownTotal.toolCalls,
|
|
@@ -9,6 +9,7 @@ import { readFileSync, existsSync, readdirSync, mkdirSync, writeFileSync } from
|
|
|
9
9
|
import { updateNotice } from './auto-update.mjs';
|
|
10
10
|
import { join, resolve } from 'node:path';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
12
13
|
import { claimOnce, readHookInput } from './hook-once.mjs';
|
|
13
14
|
|
|
14
15
|
const STALE_AUDIT_DAYS = 14; // periodic skill-audit cadence (kept in sync with scripts/audit.js)
|
|
@@ -36,11 +37,14 @@ function hasContext7() {
|
|
|
36
37
|
} catch { return false; }
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
// Has a skill audit run within the cadence? Missing stamp → never audited → due.
|
|
40
|
+
// Has a skill audit run within the cadence? Missing stamp → never audited → due. Onboarding writes a
|
|
41
|
+
// `baseline` (no audit ran yet), so the first one falls due a full cadence after install.
|
|
40
42
|
function auditDue() {
|
|
41
43
|
try {
|
|
42
|
-
const { lastRun } = JSON.parse(readFileSync(join(root, '.rsc', 'audit.json'), 'utf8'));
|
|
43
|
-
|
|
44
|
+
const { lastRun, baseline } = JSON.parse(readFileSync(join(root, '.rsc', 'audit.json'), 'utf8'));
|
|
45
|
+
const since = new Date(lastRun || baseline).getTime();
|
|
46
|
+
if (Number.isNaN(since)) return true;
|
|
47
|
+
return (Date.now() - since) / 86400000 >= STALE_AUDIT_DAYS;
|
|
44
48
|
} catch { return true; }
|
|
45
49
|
}
|
|
46
50
|
|
|
@@ -117,7 +121,38 @@ Opt out with .rsc/.no-scope-check · this notice repeats at most every ${SCOPE_W
|
|
|
117
121
|
const profile = join(root, '02-DOCS', 'wiki', 'harness', 'user-profile.md');
|
|
118
122
|
const optout = join(root, '.rsc', '.no-harness');
|
|
119
123
|
const profileExists = existsSync(profile);
|
|
120
|
-
|
|
124
|
+
// A teammate's clone of an onboarded project: `.rsc.json` is COMMITTED and carries the accepted
|
|
125
|
+
// onboarding, while the profile is personal and never committed, so it is missing in every clone by
|
|
126
|
+
// design. Treating that as a fresh setup told the agent, every session, to run first-install
|
|
127
|
+
// onboarding before the person's task (team simulation D6). There it is an offer, made once per clone.
|
|
128
|
+
function teammateClone() {
|
|
129
|
+
try {
|
|
130
|
+
const manifest = JSON.parse(readFileSync(join(root, '.rsc.json'), 'utf8'));
|
|
131
|
+
if (!manifest?.onboarding?.acceptedPlanId) return null;
|
|
132
|
+
const r = spawnSync('git', ['-C', root, 'ls-files', '--error-unmatch', '--', '.rsc.json'], { windowsHide: true, stdio: 'ignore' });
|
|
133
|
+
if (r.status !== 0) return null;
|
|
134
|
+
const level = manifest.onboarding.plan?.record?.technicalLevel;
|
|
135
|
+
return { level: typeof level === 'string' ? level : null };
|
|
136
|
+
} catch { return null; }
|
|
137
|
+
}
|
|
138
|
+
const clone = !existsSync(profile) && !existsSync(optout) ? teammateClone() : null;
|
|
139
|
+
const offered = join(root, '.rsc', '.profile-offered');
|
|
140
|
+
if (clone) {
|
|
141
|
+
if (!existsSync(offered)) {
|
|
142
|
+
process.stdout.write(`
|
|
143
|
+
===== rsc onboarding =====
|
|
144
|
+
This is a clone of a project already set up with rsc. The personal profile
|
|
145
|
+
(02-DOCS/wiki/harness/user-profile.md) is never committed, so it is not here. Nothing is blocked.
|
|
146
|
+
ACTION: ask the person once, in one line, whether they want technical terms or analogies; then continue with their task.
|
|
147
|
+
Do not run \`init\`. If they answer, write \`technical_level: technical\` or \`technical_level: non-technical\`
|
|
148
|
+
to 02-DOCS/wiki/harness/user-profile.md (personal, excluded from commits). If they decline or skip it,
|
|
149
|
+
use ${clone.level ? `the level the project was installed with (${clone.level})` : 'analogies'} and do not ask again.
|
|
150
|
+
Said once per clone (.rsc/.profile-offered).
|
|
151
|
+
==========================
|
|
152
|
+
`);
|
|
153
|
+
try { mkdirSync(join(root, '.rsc'), { recursive: true }); writeFileSync(offered, `${new Date().toISOString()}\n`); } catch { /* unwritable → said again next session */ }
|
|
154
|
+
}
|
|
155
|
+
} else if (!existsSync(profile) && !existsSync(optout)) {
|
|
121
156
|
process.stdout.write(`
|
|
122
157
|
===== rsc onboarding =====
|
|
123
158
|
Fresh setup: 02-DOCS/wiki/harness/user-profile.md is missing.
|
|
@@ -258,7 +293,7 @@ A no holds for this session. Permanent off: .rsc/.no-worktree-cleanup
|
|
|
258
293
|
if (has('.git')) {
|
|
259
294
|
try {
|
|
260
295
|
const T = await import('./team-safe-start.mjs');
|
|
261
|
-
for (const said of [T.teamSafeAnnouncement(root), await T.rescueTrunkCommits(root), await T.relocateOldWorktrees(root)]) {
|
|
296
|
+
for (const said of [T.teamSafeAnnouncement(root), await T.rescueTrunkCommits(root), await T.relocateOldWorktrees(root), T.harnessCommitNotice?.(root)]) {
|
|
262
297
|
if (said) process.stdout.write(said.startsWith('\n') ? said : `\n${said}\n`);
|
|
263
298
|
}
|
|
264
299
|
} catch { /* module missing → nothing to do; never worth breaking startup for */ }
|