@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.
@@ -0,0 +1,145 @@
1
+ // rsc shell unwrap — what a Bash command RUNS, as opposed to what it says.
2
+ //
3
+ // Shared by the PreToolUse guards (branch-guard, gitmoji-guard, danger-guard, and the sello that
4
+ // ship-guard loads). Hooks are materialized file by file under `.rsc/`, so this travels as their
5
+ // SIBLING (targets/claude.js copies it with the lifecycle files) and each guard imports it
6
+ // dynamically: without it a guard falls back to what it read before, never to a crash.
7
+ //
8
+ // E2E defect 15 (2026-10-07): `bash -c "git commit -m x"` hid a commit inside a quoted string, and
9
+ // every guard blanks quoted text so that `grep "git commit"` is not mistaken for a commit. Both are
10
+ // right, and they meet here: quoted text stays text EXCEPT where a wrapper hands it to a shell —
11
+ // `bash|sh|zsh|dash|ksh -c <string>` and `eval …` — which is unwrapped recursively. Leading
12
+ // `VAR=x`, `env`, `command`, `exec`, `sudo`, `nohup`, `nice`, `time`, `xargs` are peeled off so
13
+ // the segment starts at the real command. Not a shell parser: `$(…)`, functions, aliases and
14
+ // subshell scoping of `cd` are not modelled.
15
+
16
+ /** Heredoc bodies are text being written, not commands: `cat > notes.md <<EOF … git commit … EOF`. */
17
+ export const withoutHeredocs = (command) => command.replace(/<<-?\s*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n[\s\S]*?\n\s*\2\s*(?=\n|$)/g, '');
18
+
19
+ /** The command split where the shell would run one thing after another, never inside quotes. */
20
+ export function segments(command) {
21
+ const out = [];
22
+ let cur = '';
23
+ let q = null;
24
+ for (let i = 0; i < command.length; i++) {
25
+ const c = command[i];
26
+ if (q) { cur += c; if (c === '\\' && q === '"') cur += command[++i] ?? ''; else if (c === q) q = null; continue; }
27
+ if (c === '"' || c === "'") { q = c; cur += c; continue; }
28
+ if (c === ';' || c === '\n' || c === '|' || c === '&') {
29
+ if ((c === '&' || c === '|') && command[i + 1] === c) i++;
30
+ out.push(cur); cur = ''; continue;
31
+ }
32
+ cur += c;
33
+ }
34
+ out.push(cur);
35
+ return out.filter((s) => s.trim());
36
+ }
37
+
38
+ /** Shell words of one segment, quotes removed, each with where it starts in the segment. */
39
+ export function words(segment) {
40
+ const out = [];
41
+ let i = 0;
42
+ const n = segment.length;
43
+ while (i < n) {
44
+ while (i < n && /\s/.test(segment[i])) i++;
45
+ if (i >= n) break;
46
+ const start = i;
47
+ let text = '';
48
+ while (i < n && !/\s/.test(segment[i])) {
49
+ const c = segment[i];
50
+ if (c === "'") { const e = segment.indexOf("'", i + 1); const end = e < 0 ? n : e; text += segment.slice(i + 1, end); i = end + 1; continue; }
51
+ if (c === '"') {
52
+ i++;
53
+ while (i < n && segment[i] !== '"') {
54
+ if (segment[i] === '\\' && /["\\$`]/.test(segment[i + 1] ?? '')) { text += segment[i + 1]; i += 2; continue; }
55
+ text += segment[i++];
56
+ }
57
+ i++; continue;
58
+ }
59
+ if (c === '\\') { text += segment[i + 1] ?? ''; i += 2; continue; }
60
+ text += c; i++;
61
+ }
62
+ out.push({ text, start });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ const base = (w) => w.replace(/^.*\//, '');
68
+ const ASSIGN = /^[A-Za-z_]\w*=/;
69
+ const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh']);
70
+ // Wrapper → the options of it that take a separate argument.
71
+ const WRAPPERS = {
72
+ env: new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string']),
73
+ command: new Set(),
74
+ exec: new Set(['-a']),
75
+ sudo: new Set(['-u', '-g', '-C', '-h', '-p', '-U', '-r', '-t', '-D']),
76
+ doas: new Set(['-u', '-C']),
77
+ nohup: new Set(),
78
+ nice: new Set(['-n']),
79
+ time: new Set(['-f', '-o']),
80
+ xargs: new Set(['-I', '-n', '-L', '-P', '-d', '-E', '-s', '-a']),
81
+ };
82
+
83
+ /**
84
+ * One segment, its wrappers peeled: `{ inner }` when it hands a string to a shell (`bash -c`,
85
+ * `eval`), else `{ rest }` — the segment from the real command on. `null` for nothing to run.
86
+ */
87
+ export function unwrap(segment) {
88
+ const w = words(segment);
89
+ let i = 0;
90
+ while (i < w.length) {
91
+ const t = w[i].text;
92
+ if (ASSIGN.test(t)) { i++; continue; }
93
+ const b = base(t);
94
+ if (b === 'command' && /^-[vV]$/.test(w[i + 1]?.text ?? '')) break; // a lookup, not a run
95
+ if (Object.hasOwn(WRAPPERS, b)) {
96
+ const takesArg = WRAPPERS[b];
97
+ i++;
98
+ while (i < w.length && w[i].text.startsWith('-') && w[i].text !== '-') {
99
+ const opt = w[i].text;
100
+ i += takesArg.has(opt) ? 2 : 1;
101
+ if (opt === '--') break;
102
+ }
103
+ continue;
104
+ }
105
+ if (b === 'eval') {
106
+ const inner = w.slice(i + 1).map((x) => x.text).join(' ');
107
+ return inner.trim() ? { inner } : null;
108
+ }
109
+ if (SHELLS.has(b)) {
110
+ for (let j = i + 1; j < w.length; j++) {
111
+ const o = w[j].text;
112
+ if (!o.startsWith('-') && !o.startsWith('+')) break; // a script file: run as is
113
+ if (o === '-o' || o === '+o') { j++; continue; }
114
+ if (/^-[A-Za-z]*c[A-Za-z]*$/.test(o)) return w[j + 1] ? { inner: w[j + 1].text } : null;
115
+ }
116
+ }
117
+ break;
118
+ }
119
+ if (i >= w.length) return null;
120
+ return { rest: segment.slice(w[i].start).trim() };
121
+ }
122
+
123
+ const MAX_DEPTH = 8;
124
+
125
+ /** Every segment the shell would really run, wrappers peeled and shell strings unwrapped. */
126
+ export function expand(command, depth = 0) {
127
+ const out = [];
128
+ for (const seg of segments(withoutHeredocs(String(command ?? '')))) {
129
+ const u = unwrap(seg);
130
+ if (!u) continue;
131
+ if (u.inner !== undefined && depth < MAX_DEPTH) out.push(...expand(u.inner, depth + 1));
132
+ else out.push(u.rest ?? seg.trim());
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /** The strings this command hands to a shell (`bash -c "<this>"`, `eval <this>`), one level deep. */
138
+ export function innerScripts(command) {
139
+ const out = [];
140
+ for (const seg of segments(withoutHeredocs(String(command ?? '')))) {
141
+ const u = unwrap(seg);
142
+ if (u?.inner !== undefined) out.push(u.inner);
143
+ }
144
+ return out;
145
+ }
@@ -5,7 +5,10 @@
5
5
  //
6
6
  // Enforces the "close the feature before you leave it" rule at the one deterministic
7
7
  // moment it matters: when a Bash command tries to switch to the trunk (main/master)
8
- // or merge into it. If the current feature branch has uncommitted changes or commits
8
+ // or merge into it. A merge run ON the feature branch (`git merge origin/main`) brings
9
+ // the trunk in — it leaves nothing behind, so it is not judged (team simulation D10);
10
+ // a merge counts only where it lands on a trunk checkout (`git -C ../main merge feat`).
11
+ // rsc's own knowledge commits (📥/📝 docs(auto)) are not the person's unpushed work. If the current feature branch has uncommitted changes or commits
9
12
  // that were never pushed, the guard DENIES the command and tells the agent to run
10
13
  // `ship` (commit → push → PR). Opening the PR itself is `ship`'s job and the skill's
11
14
  // hard rule; this hook guarantees you cannot quietly abandon unsaved/unpushed work.
@@ -14,7 +17,7 @@
14
17
  // and FAIL-OPEN — any ambiguity (detached HEAD, no repo, git error) allows the command.
15
18
  // Opt out per project with .rsc/.no-ship-guard.
16
19
  import { existsSync, readFileSync } from 'node:fs';
17
- import { join } from 'node:path';
20
+ import { isAbsolute, join, resolve } from 'node:path';
18
21
  import { spawnSync } from 'node:child_process';
19
22
 
20
23
  const root = process.argv[2] || process.cwd();
@@ -62,14 +65,42 @@ try {
62
65
  if (existsSync(join(root, '.rsc', '.no-ship-guard'))) allow();
63
66
 
64
67
  // Does this command try to land on / move to the trunk?
65
- const TRUNK = /\bgit\s+(?:checkout|switch)\s+(?:-{1,2}\S+\s+)*(?:main|master)\b/;
66
- const MERGE = /\bgit\s+merge\b/;
67
- if (!TRUNK.test(command) && !MERGE.test(command)) allow();
68
+ // Global options may sit before the subcommand (`git -C . switch main`, `git -c k=v merge`), and the
69
+ // command may be handed to a shell (`bash -c "git switch main"`): the segments judged are the ones the
70
+ // shell really runs, quoted text blanked (shell-unwrap.mjs, shared with the other guards — E2E defect
71
+ // 15). Without that sibling, the raw command as before.
72
+ const GOPTS = String.raw`(?:(?:-[Cc]\s+\S+|--\S+)\s+)*`;
73
+ const TRUNK = new RegExp(String.raw`\bgit\s+${GOPTS}(?:checkout|switch)\s+(?:-{1,2}\S+\s+)*(?:main|master)\b`);
74
+ const MERGE = new RegExp(String.raw`\bgit\s+${GOPTS}merge\b`);
75
+ const SH = await import(new URL('./shell-unwrap.mjs', import.meta.url)).catch(() => null);
76
+ const judged = SH ? SH.expand(command).map((s) => s.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""')) : [command];
68
77
 
69
- const git = (...args) => {
70
- const r = spawnSync('git', ['-C', root, ...args], { windowsHide: true, encoding: 'utf8' });
78
+ const gitIn = (dir, ...args) => {
79
+ const r = spawnSync('git', ['-C', dir, ...args], { windowsHide: true, encoding: 'utf8' });
71
80
  return r.status === 0 ? (r.stdout || '').trim() : null;
72
81
  };
82
+ const git = (...args) => gitIn(root, ...args);
83
+
84
+ // A merge lands on the trunk only where the checkout it runs in IS on the trunk. Here that means a
85
+ // `git -C <dir> merge` aimed at another checkout; a merge in this one runs on the feature branch
86
+ // (checked below), and `git switch main && git merge x` is already caught by its switch.
87
+ function mergesIntoTrunk(segment) {
88
+ if (!MERGE.test(segment)) return false;
89
+ const m = segment.match(/\bgit\s+(?:-c\s+\S+\s+)*-C\s+(\S+)/);
90
+ if (!m) return false;
91
+ const dir = isAbsolute(m[1]) ? m[1] : resolve(root, m[1]);
92
+ const b = gitIn(dir, 'rev-parse', '--abbrev-ref', 'HEAD');
93
+ return b === 'main' || b === 'master';
94
+ }
95
+ if (!judged.some((s) => TRUNK.test(s) || mergesIntoTrunk(s))) allow();
96
+
97
+ // Commits in a range that are the person's: rsc's own knowledge commits are left out.
98
+ const RSC_OWN = /^(?:📥|📝) docs\(auto\)/u;
99
+ function ownCommits(range) {
100
+ const log = git('log', '--format=%s', range);
101
+ if (log === null) return null;
102
+ return log.split('\n').filter((s) => s && !RSC_OWN.test(s)).length;
103
+ }
73
104
 
74
105
  const branch = git('rev-parse', '--abbrev-ref', 'HEAD');
75
106
  // Not on a feature branch (already trunk, detached, or git failed) → nothing to guard.
@@ -86,13 +117,13 @@ if (dirty && dirty.length > 0) {
86
117
  // 2) Commits exist but were never pushed.
87
118
  const upstream = git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}');
88
119
  if (upstream) {
89
- const ahead = git('rev-list', '--count', `${upstream}..HEAD`);
120
+ const ahead = ownCommits(`${upstream}..HEAD`);
90
121
  if (ahead && Number(ahead) > 0) {
91
122
  deny(`Feature branch "${branch}" has ${ahead} commit(s) not pushed to ${upstream}. Push them and open the PR — run the \`ship\` skill — before switching to the trunk.${tail}`);
92
123
  }
93
124
  } else {
94
125
  // No upstream at all: if the branch carries commits beyond the trunk, it was never pushed.
95
- const aheadOfTrunk = git('rev-list', '--count', 'main..HEAD') ?? git('rev-list', '--count', 'master..HEAD');
126
+ const aheadOfTrunk = ownCommits('main..HEAD') ?? ownCommits('master..HEAD');
96
127
  if (aheadOfTrunk && Number(aheadOfTrunk) > 0) {
97
128
  deny(`Feature branch "${branch}" was never pushed (no upstream, ${aheadOfTrunk} commit(s) ahead of the trunk). Push it and open a PR — run the \`ship\` skill — before leaving it.${tail}`);
98
129
  }
@@ -12,7 +12,7 @@
12
12
  // Each returns the text to say, or ''. None throws: a session start is never worth breaking.
13
13
  // Standalone siblings under `.rsc/`: trunk-policy.mjs, worktree-reaper.mjs, session-memory-core.mjs.
14
14
  import { execFileSync } from 'node:child_process';
15
- import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
15
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
16
16
  import { basename, join, sep } from 'node:path';
17
17
 
18
18
  const git = (root, args) => {
@@ -92,13 +92,23 @@ export async function relocateOldWorktrees(root) {
92
92
 
93
93
  const MARK = '.team-safe-3';
94
94
 
95
- export function teamSafeAnnouncement(root) {
95
+ // Which language to say it in. The profile's `language:` frontmatter field when there is one, then the
96
+ // locale the assistant runs under, and English when neither says: this banner reached English-speaking
97
+ // users in Spanish. The other always-on texts are English and ask the agent to relay them in the
98
+ // person's language; this one keeps a Spanish version because it was written for Spanish users first.
99
+ export function announcementLanguage(root, env = process.env) {
96
100
  try {
97
- const mark = join(root, '.rsc', MARK);
98
- if (existsSync(mark)) return '';
99
- mkdirSync(join(root, '.rsc'), { recursive: true });
100
- writeFileSync(mark, `${new Date().toISOString()}\n`);
101
- return `
101
+ const profile = readFileSync(join(root, '02-DOCS', 'wiki', 'harness', 'user-profile.md'), 'utf8');
102
+ const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(profile)?.[1] || '';
103
+ const declared = /^(?:language|lang|idioma)\s*:\s*["']?([^"'\r\n]+)/mi.exec(front)?.[1]?.trim().toLowerCase();
104
+ if (declared) return /^(es|spa|español|espanol|castellano|spanish)\b/.test(declared) ? 'es' : 'en';
105
+ } catch { /* no profile: fall through to the locale */ }
106
+ const locale = String(env.LC_ALL || env.LC_MESSAGES || env.LANG || '').toLowerCase();
107
+ return /^es([_.-]|$)/.test(locale) ? 'es' : 'en';
108
+ }
109
+
110
+ const ANNOUNCEMENT = {
111
+ es: `
102
112
  ===== rsc 3.0 · equipo seguro por defecto =====
103
113
  ACTION: díselo a la persona en pocas líneas, una vez, antes de su petición:
104
114
  1. Si la rama principal está abierta (proyectos sencillos), el agente trabaja en ella sin preguntar.
@@ -108,9 +118,65 @@ ACTION: díselo a la persona en pocas líneas, una vez, antes de su petición:
108
118
  worktree en .worktrees/<rama>/. Para no hacerlo: «no uses worktrees».
109
119
  3. El agente elige solo entre FTD (lo sencillo) y SDD (lo grande o complejo) y lo dice; se puede
110
120
  pedir el otro.
111
- 4. 01-TOOLS/ y 02-DOCS/ viajan al equipo por la rama rsc/knowledge y llegan a la principal dentro de
112
- las PRs. Para apagarlo: rsc knowledge-sync off.
121
+ 4. Las herramientas (01-TOOLS/) y la wiki (02-DOCS/wiki/, attachments/) viajan al equipo por la
122
+ rama rsc/knowledge y llegan a la principal dentro de las PRs. Para apagarlo: rsc knowledge-sync off.
113
123
  ==============================================
114
- `;
124
+ `,
125
+ en: `
126
+ ===== rsc 3.0 · team-safe by default =====
127
+ ACTION: tell the person in a few lines, once, before their request, in their language:
128
+ 1. If the default branch is open (simple projects), the agent works on it without asking.
129
+ If it is closed (long-lived code, CI or a team), it asks before each change: this branch,
130
+ a new one, or unlock? It never opens a branch on its own. To change it: "unlock main" or
131
+ "lock main" (rsc main unlock | lock).
132
+ 2. If another assistant session is working in this same folder, new work goes to a worktree in
133
+ .worktrees/<branch>/. To turn that off: "don't use worktrees" (rsc isolation off).
134
+ 3. The agent picks between FTD (simple work) and SDD (large or complex work) and says which; you
135
+ can ask for the other.
136
+ 4. Tools (01-TOOLS/) and the wiki (02-DOCS/wiki/, attachments/) travel to the team on the
137
+ rsc/knowledge branch and reach the default branch inside pull requests. Off: rsc knowledge-sync off.
138
+ ==========================================
139
+ `,
140
+ };
141
+
142
+ export function teamSafeAnnouncement(root, env = process.env) {
143
+ try {
144
+ const mark = join(root, '.rsc', MARK);
145
+ if (existsSync(mark)) return '';
146
+ mkdirSync(join(root, '.rsc'), { recursive: true });
147
+ writeFileSync(mark, `${new Date().toISOString()}\n`);
148
+ return ANNOUNCEMENT[announcementLanguage(root, env)];
149
+ } catch { return ''; }
150
+ }
151
+
152
+ /**
153
+ * The harness only reaches a teammate through what is committed: `.rsc.json` (the decision), the
154
+ * Claude settings that wire the guards and hooks, the bootstrap a clone runs. Field tests 3.0.7 and
155
+ * 3.0.8: five sessions each, and none of those files was ever committed — «team-safe by default»
156
+ * that never left the machine. Said at session start while `.rsc.json` is untracked in a repo with a
157
+ * remote; at most once a week, so it informs without nagging (P7). The agent proposes, never commits
158
+ * on its own.
159
+ */
160
+ const HARNESS_FILES = ['.rsc.json', '.claude/settings.json', '.claude/rsc-bootstrap.mjs', '.gitignore'];
161
+ const COMMIT_MARK = '.harness-commit-offered';
162
+ const WEEK_MS = 7 * 24 * 3600 * 1000;
163
+
164
+ export function harnessCommitNotice(root, now = Date.now()) {
165
+ try {
166
+ const run = (args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
167
+ if (!existsSync(join(root, '.rsc.json'))) return '';
168
+ if (!run(['remote'])) return '';
169
+ if (run(['ls-files', '--', '.rsc.json'])) return '';
170
+ const mark = join(root, '.rsc', COMMIT_MARK);
171
+ if (existsSync(mark) && now - Date.parse(readFileSync(mark, 'utf8').trim()) < WEEK_MS) return '';
172
+ mkdirSync(join(root, '.rsc'), { recursive: true });
173
+ writeFileSync(mark, `${new Date(now).toISOString()}\n`);
174
+ const files = HARNESS_FILES.filter((f) => existsSync(join(root, f)));
175
+ return '===== rsc · harness not committed =====\n' +
176
+ `The harness files (${files.join(', ')}) are not in git, so teammates and new clones get no guards, ` +
177
+ 'hooks or decisions. ACTION: tell the person in one line, in their language, and offer to commit ' +
178
+ 'them with their next change (on the branch this project uses). Do not commit them on your own.\n' +
179
+ '=======================================\n';
115
180
  } catch { return ''; }
116
181
  }
182
+
@@ -16,7 +16,7 @@
16
16
  //
17
17
  // Imported by `.rsc/session-start.mjs` (the sweep) and by `scripts/rsc.js` (`rsc worktrees`), so the
18
18
  // rule exists once and both entry points cannot drift apart. Same shape as `sello.mjs`.
19
- import { existsSync, realpathSync, mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync } from 'node:fs';
19
+ import { existsSync, realpathSync, mkdirSync, readFileSync, readdirSync, writeFileSync, renameSync, chmodSync, copyFileSync } from 'node:fs';
20
20
  import { join, resolve, dirname, basename, relative, sep } from 'node:path';
21
21
  import { spawnSync } from 'node:child_process';
22
22
 
@@ -50,6 +50,14 @@ export const REGENERABLE = [
50
50
  // directory name means something only as a directory.
51
51
  export const REGENERABLE_FILES = ['.DS_Store', 'Thumbs.db', '.coverage'];
52
52
 
53
+ // rsc's OWN state inside a worktree: hook markers, knowledge-sync's index, the memory journal. It is
54
+ // in no commit by design, so the contents check read it as work that "would be lost" and refused
55
+ // every landed worktree a session had ever worked in (team simulation D8) — the default cleanup
56
+ // never ran. It does not block. The one part of it that is history — the session journal — is moved
57
+ // to the project's store before the directory goes (`adoptJournal`), never dropped.
58
+ export const RSC_STATE = ['.rsc/', '02-DOCS/raw/worklog/.rsc-memory/'];
59
+ const isRscState = (path) => RSC_STATE.some((prefix) => path === prefix || path === prefix.slice(0, -1) || path.startsWith(prefix));
60
+
53
61
  // Trunk candidates, most authoritative first. The remote tip beats a local branch that may be stale.
54
62
  const TRUNKS = ['origin/main', 'main', 'origin/master', 'master'];
55
63
 
@@ -251,7 +259,7 @@ export function contentOutsideHistory(wtPath) {
251
259
  // does not exist — the exact failure the `raw` comment above exists to prevent (P6).
252
260
  if (code[0] === 'R' || code[0] === 'C') { i++; dirty.push(path); continue; }
253
261
  if (code === '??' || code === '!!') {
254
- if (!isRegenerable(path)) outside.push(path);
262
+ if (!isRegenerable(path) && !isRscState(path)) outside.push(path);
255
263
  } else dirty.push(path);
256
264
  }
257
265
  return { dirty, outside, readable: true };
@@ -355,6 +363,11 @@ export function reapWorktree(root, targetPath, { confirmed = false } = {}) {
355
363
  return { removed: false, reason: refusal(candidate) };
356
364
  }
357
365
 
366
+ // The journal first: if it cannot be carried home, the directory stays (fail towards keeping).
367
+ try { adoptJournal(root, target); } catch (err) {
368
+ return { removed: false, reason: `its session journal could not be moved to the project, so nothing was removed: ${err.message}` };
369
+ }
370
+
358
371
  const removal = git(root, ['worktree', 'remove', '--force', target]);
359
372
  if (!removal.ok) {
360
373
  return { removed: false, reason: `git refused to remove it: ${removal.err || removal.out}` };
@@ -379,6 +392,96 @@ export function reapWorktree(root, targetPath, { confirmed = false } = {}) {
379
392
  };
380
393
  }
381
394
 
395
+ /**
396
+ * Carry a worktree's memory journal into the project's store before the worktree is removed.
397
+ *
398
+ * Since sessions inside `.worktrees/` journal into the project's store directly, this is the net for
399
+ * what older versions (and any session that ran before that fix) left inside the worktree. Merge by
400
+ * file name, the newer record wins, nothing in the project is ever overwritten by something older.
401
+ * The project's store is the same one the memory picks: the ignored wiki worklog when it is in use,
402
+ * `.rsc/memory` otherwise.
403
+ */
404
+ export function adoptJournal(root, worktreePath) {
405
+ const sources = [join(worktreePath, '.rsc', 'memory'), join(worktreePath, '02-DOCS', 'raw', 'worklog', '.rsc-memory')]
406
+ .filter((dir) => existsSync(dir));
407
+ if (!sources.length) return { moved: [] };
408
+ const worklogStore = join(root, '02-DOCS', 'raw', 'worklog', '.rsc-memory');
409
+ const dest = existsSync(join(worklogStore, 'sessions')) ? worklogStore : join(root, '.rsc', 'memory');
410
+ const stamp = (value) => new Date(value?.timestamps?.updatedAt || value?.updatedAt || value?.approvedAt || value?.startedAt || 0).getTime() || 0;
411
+ const read = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
412
+ const moved = [];
413
+ for (const source of sources) {
414
+ for (const kind of ['sessions', 'anchors', 'lessons']) {
415
+ const dir = join(source, kind);
416
+ if (!existsSync(dir)) continue;
417
+ for (const name of readdirSync(dir).filter((n) => n.endsWith('.json'))) {
418
+ const from = join(dir, name);
419
+ const to = join(dest, kind, name);
420
+ if (existsSync(to) && stamp(read(to)) >= stamp(read(from))) continue;
421
+ mkdirSync(join(dest, kind), { recursive: true });
422
+ copyFileSync(from, to);
423
+ chmodSync(to, 0o600);
424
+ moved.push(`${kind}/${name}`);
425
+ }
426
+ }
427
+ }
428
+ return { moved };
429
+ }
430
+
431
+ // Branches that are never a cleanup's side effect, whatever their state: the knowledge branch carries
432
+ // 01-TOOLS/ and 02-DOCS/ to the team (knowledge-sync) and is merged into the trunk as a matter of course.
433
+ export const KEEP_BRANCHES = ['rsc/knowledge'];
434
+
435
+ /**
436
+ * Plain local branches whose work has landed: the half of "al fusionar se borra solo" that has no
437
+ * directory. E2E defect 14 (2026-10-07): a branch merged into the default branch — or whose remote
438
+ * branch the forge deleted after the PR, `[gone]` — stayed in `git branch` for ever, landed work
439
+ * looking exactly like live work.
440
+ *
441
+ * Deleted only when ALL hold, each failing towards keeping the branch:
442
+ * - the cleanup is on, and a trunk resolves (the remote tip first, like the worktrees);
443
+ * - it is not the default branch, a trunk name, `rsc/knowledge`, the current branch, or checked out
444
+ * in ANY worktree (`branch -d` would refuse that last one anyway; we do not even ask);
445
+ * - its tip is an ancestor of the trunk — no commit of its own left. Against `origin/<default>`
446
+ * when there is one, so a branch merged locally but not yet pushed keeps its unpushed commits
447
+ * (being ahead of its own upstream then loses nothing: those commits are on the remote trunk);
448
+ * - it really carried work: a commit in its reflog, or an upstream the forge deleted. A branch cut a
449
+ * moment ago is an ancestor of everything and has landed nothing (same rule as `hasLandedWork`).
450
+ * And then git decides: `branch -d`, never -D. A squash-merged branch is `[gone]` but has commits of
451
+ * its own by identity, so it is not an ancestor and stays — its commits remain recoverable.
452
+ *
453
+ * Never throws (it runs from the post-merge hook, like `autoReap`).
454
+ */
455
+ export function reapMergedBranches(root) {
456
+ const out = { deleted: [], kept: [] };
457
+ try {
458
+ if (!isCleanupEnabled(root)) return out;
459
+ const trunk = resolveTrunk(root);
460
+ if (!trunk) return out;
461
+ const defaultName = trunk.replace(/^origin\//, '');
462
+ const current = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']).out || null;
463
+ const checkedOut = new Set(listWorktrees(root).map((wt) => wt.branch).filter(Boolean));
464
+ const refs = git(root, ['for-each-ref', '--format=%(refname:short)%00%(upstream:track)', 'refs/heads']);
465
+ if (!refs.ok) return out;
466
+ for (const line of refs.out.split('\n').filter(Boolean)) {
467
+ const [branch, track = ''] = line.split('\0');
468
+ const keep = (reason) => out.kept.push({ branch, reason });
469
+ if (branch === defaultName || isTrunkName(branch) || KEEP_BRANCHES.includes(branch)) continue;
470
+ if (branch === current) { keep('current branch'); continue; }
471
+ if (checkedOut.has(branch)) { keep('checked out in a worktree'); continue; }
472
+ if (!git(root, ['merge-base', '--is-ancestor', `refs/heads/${branch}`, trunk]).ok) continue; // live work
473
+ const gone = track.includes('gone');
474
+ if (!gone && !hasLandedWork(root, { branch })) { keep('never carried a commit'); continue; }
475
+ const del = git(root, ['branch', '-d', branch]);
476
+ if (del.ok) out.deleted.push(branch);
477
+ else keep(`git branch -d refused: ${del.err || del.out}`);
478
+ }
479
+ } catch (err) {
480
+ out.kept.push({ branch: null, reason: `branch cleanup could not run: ${err.message}` });
481
+ }
482
+ return out;
483
+ }
484
+
382
485
  /**
383
486
  * Every refusal carries the way out, because the person receiving it may not be able to deduce one (P6).
384
487
  *
@@ -401,7 +504,7 @@ export function reapWorktree(root, targetPath, { confirmed = false } = {}) {
401
504
  * - it is silent when there is nothing to do, so the common merge prints nothing.
402
505
  */
403
506
  export function autoReap(root) {
404
- const result = { reaped: [], skipped: [], disabled: false };
507
+ const result = { reaped: [], skipped: [], disabled: false, branches: { deleted: [], kept: [] } };
405
508
  try {
406
509
  if (!isCleanupEnabled(root)) {
407
510
  result.disabled = true;
@@ -421,6 +524,8 @@ export function autoReap(root) {
421
524
  if (out.removed) result.reaped.push(candidate.path);
422
525
  else result.skipped.push({ path: candidate.path, reason: out.reason });
423
526
  }
527
+ // After the worktrees: removing one frees its branch, which may then be a landed plain branch.
528
+ result.branches = reapMergedBranches(root);
424
529
  } catch (err) {
425
530
  // Swallowed deliberately, and recorded rather than discarded: the merge must survive whatever
426
531
  // went wrong in here, but a silent failure that leaves no trace is how this rots unnoticed.
@@ -444,8 +549,12 @@ if [ -x "$hook_dir/post-merge.rsc-local" ]; then
444
549
  "$hook_dir/post-merge.rsc-local" "$@" || true
445
550
  fi
446
551
  root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
447
- [ -f "$root/.rsc/worktree-reaper.mjs" ] || exit 0
448
552
  command -v node >/dev/null 2>&1 || exit 0
553
+ # First, while the landed branch still exists: close its feature documents (off: .rsc/.no-ftd-close).
554
+ if [ -f "$root/.rsc/knowledge-sync.mjs" ]; then
555
+ node "$root/.rsc/knowledge-sync.mjs" close "$root" 2>/dev/null || true
556
+ fi
557
+ [ -f "$root/.rsc/worktree-reaper.mjs" ] || exit 0
449
558
  node "$root/.rsc/worktree-reaper.mjs" "$root" auto 2>/dev/null || true
450
559
  exit 0
451
560
  `;
@@ -605,6 +714,7 @@ if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.
605
714
  // The unattended entry point, called by the post-merge hook. Prints only what it actually did.
606
715
  const out = autoReap(root);
607
716
  for (const p of out.reaped) process.stdout.write(`rsc: retired worktree ${p}\n`);
717
+ for (const b of out.branches.deleted) process.stdout.write(`rsc: deleted merged branch ${b}\n`);
608
718
  } else if (process.argv[3] === 'reap') {
609
719
  const one = process.argv[4];
610
720
  const targets = one ? [resolve(one)] : candidates.filter((c) => c.verdict === 'safe').map((c) => c.path);