@damiangilgonzalez/harness 1.3.17 → 1.3.18

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/targets/memory.js CHANGED
@@ -76,10 +76,15 @@ function handler(target, event) {
76
76
  : target === 'codex'
77
77
  ? '"$(git rev-parse --show-toplevel)/.rsc/session-memory-adapter.mjs"'
78
78
  : '".rsc/session-memory-adapter.mjs"';
79
+ // PreCompact gets more room than the per-turn events. It runs once, right before a compaction that
80
+ // itself takes tens of seconds, so a longer ceiling costs nothing in the normal case; and a
81
+ // cancelled PreCompact is the one that loses the budget reset. The per-turn events keep 5 s:
82
+ // they run on every tool call and must stay cheap.
83
+ const seconds = event === 'compact' ? 15 : event === 'end' && target === 'codex' ? 3 : 5;
79
84
  const value = {
80
85
  type: 'command',
81
86
  command: `node ${unixScript} ${target} ${event}`,
82
- timeout: target === 'gemini' ? 5000 : event === 'end' && target === 'codex' ? 3 : 5,
87
+ timeout: target === 'gemini' ? seconds * 1000 : seconds,
83
88
  };
84
89
  if (target === 'codex') {
85
90
  value.commandWindows = `node \"$((git rev-parse --show-toplevel).Trim())/.rsc/session-memory-adapter.mjs\" ${target} ${event}`;
@@ -108,7 +108,10 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
108
108
  const id = sessionId(native, target);
109
109
  const eventName = hookEventName(target, event, native);
110
110
  if (event === 'start') {
111
- const started = capture({ cwd: project, worktreeCwd: here, sessionId: id, target, event: 'start', settings: config });
111
+ // `source: "compact"` is the SessionStart that follows a compaction. It resets the edit budget
112
+ // on its own, so the count still restarts when the PreCompact hook itself never finished.
113
+ const reset = native?.source === 'compact';
114
+ const started = capture({ cwd: project, worktreeCwd: here, sessionId: id, target, event: 'start', reset, settings: config });
112
115
  const resumed = resume({ cwd: project, worktreeCwd: here, target, settings: config });
113
116
  return {
114
117
  output: nativeOutput(target, eventName, resumed.context),
@@ -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';
@@ -167,11 +167,22 @@ function parseStatus(output) {
167
167
  // runs on every hook event: a few hundred dirty files were enough to blow the 5 s hook timeout.
168
168
  // `--stdin-paths` stops at the first path it cannot read (a deleted file), so on failure each file
169
169
  // is hashed on its own, which keeps the old per-file `null` for the unreadable ones.
170
+ //
171
+ // A deleted file is the ordinary case, not the exotic one — any `rm` or `git rm` puts one in the
172
+ // status — and it used to send EVERY file down the per-file path, back over the timeout (400 dirty
173
+ // files and one deletion: ~20 s per event, so PreCompact was cancelled). So paths that are not a
174
+ // readable regular file are settled here, as `null`, before the batch ever sees them.
170
175
  function fingerprintFiles(worktree, files) {
171
176
  if (!files.length) return {};
172
- const batch = git(worktree, ['hash-object', '--no-filters', '--stdin-paths'], `${files.join('\n')}\n`);
177
+ const readable = files.filter((path) => {
178
+ try { return statSync(join(worktree, path)).isFile(); } catch { return false; }
179
+ });
180
+ const isReadable = new Set(readable);
181
+ const missing = Object.fromEntries(files.filter((path) => !isReadable.has(path)).map((path) => [path, null]));
182
+ if (!readable.length) return missing;
183
+ const batch = git(worktree, ['hash-object', '--no-filters', '--stdin-paths'], `${readable.join('\n')}\n`);
173
184
  const hashes = batch.ok ? batch.out.split('\n') : [];
174
- if (hashes.length === files.length) return Object.fromEntries(files.map((path, i) => [path, hashes[i]]));
185
+ if (hashes.length === readable.length) return { ...missing, ...Object.fromEntries(readable.map((path, i) => [path, hashes[i]])) };
175
186
  return Object.fromEntries(files.map((path) => {
176
187
  const result = git(worktree, ['hash-object', '--no-filters', '--', path]);
177
188
  return [path, result.ok ? result.out : null];
@@ -346,13 +357,27 @@ export function capture(input = {}) {
346
357
  const observed = Object.entries(repo.fingerprints || {})
347
358
  .filter(([path, fingerprint]) => fingerprint && previousFingerprints[path] !== fingerprint).length;
348
359
  const editDelta = Math.max(finiteOrNull(input.editDelta, true) || 0, observed);
349
- // Compaction closes a context window, so the edit budget starts again from zero.
350
- const previousEdits = input.event === 'compact' ? 0 : (existing?.editCount || 0);
360
+ // Compaction closes a context window, so the edit budget starts again from zero. Two signals say
361
+ // it happened, and either is enough: PreCompact (`compact`) and the SessionStart that follows it
362
+ // (`reset`, from `source: "compact"`). Relying on PreCompact alone meant a cancelled PreCompact
363
+ // hook — which is what the 5 s timeout did on a dirty tree — left the old count standing.
364
+ const resetsBudget = input.event === 'compact' || input.reset === true;
365
+ const previousEdits = resetsBudget ? 0 : (existing?.editCount || 0);
351
366
  const editCount = Math.max(0, previousEdits + editDelta);
352
367
  // Hint once per threshold crossed, and only on the edit that crosses it: re-emitting on
353
368
  // every later event (Stop in particular) wakes the model again and loops.
354
369
  const crossedThreshold = editCount > previousEdits
355
370
  && Math.floor(editCount / config.editThreshold) > Math.floor(previousEdits / config.editThreshold);
371
+ // And never ON a turn end, even the one that crosses. A Stop hook that speaks is a Stop hook that
372
+ // can start another turn, and that turn ends in another Stop: the field report is ~150 empty
373
+ // assistant replies in a row. A crossing seen at a turn end is kept on the anchor and said by the
374
+ // next event that is not one (the person's next prompt, or the next tool call). Compaction and
375
+ // start never say it either — "consider compacting" while compacting is noise — and a reset
376
+ // drops whatever was pending, because the window it was about is gone.
377
+ const mutedEvents = ['turn', 'stop', 'compact', 'start', 'end', 'sessionEnd'];
378
+ const hintDue = !resetsBudget && (crossedThreshold || anchor.hintPending === true);
379
+ const speaks = !mutedEvents.includes(input.event);
380
+ const hintPending = hintDue && !speaks;
356
381
  const baselineFiles = new Set(anchor.baselineFiles || []);
357
382
  const newDirtyPath = repo.files.some((path) => !baselineFiles.has(path));
358
383
  const baselineFingerprints = anchor.baselineFingerprints || {};
@@ -391,13 +416,20 @@ export function capture(input = {}) {
391
416
  if (errors.length) return { record: null, path: null, notice: null, compactionHint: false, errors };
392
417
  atomicJson(recordPath, record);
393
418
  existing = record;
394
- const compactionHint = Boolean(config.compactionHint && crossedThreshold);
419
+ const compactionHint = Boolean(config.compactionHint && hintDue && speaks);
395
420
  // The fingerprints the next event compares against live on the ANCHOR, not on the record: the
396
421
  // record's schema is closed and every field of it is required, so a new field there would fail
397
422
  // validation on every record already written. Written only when they moved, so a read-only shell
398
- // command costs no extra disk.
423
+ // command costs no extra disk. The pending hint lives there for the same reason.
399
424
  const fingerprintsMoved = observed > 0 || !anchor.lastFingerprints;
400
- if (fingerprintsMoved) atomicJson(anchorPath, { ...anchor, lastFingerprints: repo.fingerprints || {} });
425
+ if (fingerprintsMoved || Boolean(anchor.hintPending) !== hintPending) {
426
+ const { hintPending: _previous, ...rest } = anchor;
427
+ atomicJson(anchorPath, {
428
+ ...rest,
429
+ lastFingerprints: fingerprintsMoved ? (repo.fingerprints || {}) : anchor.lastFingerprints,
430
+ ...(hintPending ? { hintPending: true } : {}),
431
+ });
432
+ }
401
433
  return {
402
434
  record,
403
435
  path: recordPath,
@@ -274,7 +274,8 @@ ${candidates.map((c) => W.summarize(c, root)).join('\n')}
274
274
  ACTION: offer to retire them in ONE line and wait for a yes. On a yes run:
275
275
  npx @damiangilgonzalez/harness worktrees reap (the ones marked safe)
276
276
  npx @damiangilgonzalez/harness worktrees reap <path> (one the user confirmed by name)
277
- A yes in bulk covers only the safe ones; anything marked \`ask\` is confirmed on its own.
277
+ npx @damiangilgonzalez/harness worktrees reap --merged --confirm (safe + "provenance unclear" only, after a fetch; say so when offering)
278
+ A yes in bulk covers only those; any other \`ask\` (uncommitted changes, files in no commit) is confirmed on its own.
278
279
  A no holds for this session. Permanent off: .rsc/.no-worktree-cleanup
279
280
  ================================
280
281
  `;
@@ -5,9 +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
9
- // that were never pushed, the guard DENIES the command and tells the agent to run
10
- // `ship` (commit → push → PR). Opening the PR itself is `ship`'s job and the skill's
8
+ // or merge into it. If the current feature branch has commits that were never pushed,
9
+ // the guard DENIES the command and tells the agent to run `ship` (commit → push → PR).
10
+ // Uncommitted changes alone only earn a hint: they may be the user's own work, and git
11
+ // already refuses a switch that would overwrite them. Opening the PR itself is `ship`'s job and the skill's
11
12
  // hard rule; this hook guarantees you cannot quietly abandon unsaved/unpushed work.
12
13
  //
13
14
  // Design: precise (only fires on a trunk switch/merge), local-only (no network, no gh),
@@ -77,10 +78,26 @@ if (!branch || branch === 'HEAD' || branch === 'main' || branch === 'master') al
77
78
 
78
79
  const tail = '\n(If this is intentional and you accept the risk, create .rsc/.no-ship-guard to disable this guard.)';
79
80
 
80
- // 1) Uncommitted work would be carried off the feature branch.
81
+ // 1) Uncommitted changes are NOT a reason to deny. They may well be the user's own work in the main
82
+ // checkout, not the agent's: in real sessions this denied five branch switches over a diff the agent
83
+ // had no business committing, and "commit them first" pushed it to do exactly that. Git itself
84
+ // refuses a switch that would overwrite local changes, and carries the rest along untouched, so the
85
+ // switch is left to git and the agent gets a hint: leave that diff alone and work in a worktree.
86
+ // The unpushed-commits rule below is the guard's real purpose and still denies.
81
87
  const dirty = git('status', '--porcelain');
82
- if (dirty && dirty.length > 0) {
83
- deny(`You're leaving feature branch "${branch}" with uncommitted changes. Commit them first — run the \`ship\` skill (commit → push → PR), don't abandon the diff.${tail}`);
88
+ const worktreeHint = dirty && dirty.length > 0
89
+ ? `The working tree on "${branch}" has uncommitted changes, and they may be the user's, not yours: ` +
90
+ `do not commit, stash or discard them to make room. If git refuses the switch, work in a separate ` +
91
+ `worktree instead: \`git worktree add .claude/worktrees/<name> -b <branch> origin/main\` ` +
92
+ `(or \`npx @damiangilgonzalez/harness worktrees new <name>\`).`
93
+ : '';
94
+ function hint() {
95
+ if (!worktreeHint) allow();
96
+ process.stdout.write(JSON.stringify({
97
+ systemMessage: 'ship-guard: hay cambios sin commitear en el árbol; no se tocan.',
98
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: worktreeHint },
99
+ }));
100
+ process.exit(0);
84
101
  }
85
102
 
86
103
  // 2) Commits exist but were never pushed.
@@ -88,14 +105,14 @@ const upstream = git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'
88
105
  if (upstream) {
89
106
  const ahead = git('rev-list', '--count', `${upstream}..HEAD`);
90
107
  if (ahead && Number(ahead) > 0) {
91
- 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}`);
108
+ 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.${worktreeHint ? `\n${worktreeHint}` : ''}${tail}`);
92
109
  }
93
110
  } else {
94
111
  // No upstream at all: if the branch carries commits beyond the trunk, it was never pushed.
95
112
  const aheadOfTrunk = git('rev-list', '--count', 'main..HEAD') ?? git('rev-list', '--count', 'master..HEAD');
96
113
  if (aheadOfTrunk && Number(aheadOfTrunk) > 0) {
97
- 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}`);
114
+ 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.${worktreeHint ? `\n${worktreeHint}` : ''}${tail}`);
98
115
  }
99
116
  }
100
117
 
101
- allow();
118
+ hint();
@@ -14,7 +14,7 @@
14
14
  import { existsSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
  import { homedir } from 'node:os';
17
- import { claimOnce, readHookInput, WORKFLOW_GATE_TEXT } from './hook-once.mjs';
17
+ import { claimOnce, readHookInput, renderGate, WORKFLOW_GATE_TEXT } from './hook-once.mjs';
18
18
 
19
19
  const root = process.argv[2] || process.cwd();
20
20
 
@@ -33,4 +33,9 @@ if (hook.session_id && turnKey && !claimOnce(`up:${hook.session_id}:${turnKey}`)
33
33
  // that is not installed sends the agent looking for something that does not exist, so the line only
34
34
  // ships when the skill is present in this project or in the user scope.
35
35
  const hasFlujo = [root, homedir()].some((r) => existsSync(join(r, '.claude', 'skills', 'flujo-interfaz', 'SKILL.md')));
36
- process.stdout.write(hasFlujo ? WORKFLOW_GATE_TEXT : WORKFLOW_GATE_TEXT.split('\n').filter((l) => !l.includes('flujo-interfaz')).join('\n'));
36
+ const gate = hasFlujo ? WORKFLOW_GATE_TEXT : WORKFLOW_GATE_TEXT.split('\n').filter((l) => !l.includes('flujo-interfaz')).join('\n');
37
+ // Skills marked `disable-model-invocation: true` are rendered as the slash command the user types,
38
+ // read from the installed SKILL.md so the gate cannot contradict the skill (see renderGate).
39
+ let out = gate;
40
+ try { out = renderGate(gate, [root, homedir()]); } catch { /* fail-safe: keep the plain gate */ }
41
+ process.stdout.write(out);
@@ -5,7 +5,7 @@
5
5
  // Reminds the agent to run a Worklog Sweep (capture what we did this session into
6
6
  // docs/raw/worklog/). The hook only reminds; the agent writes the worklog.
7
7
  // Silent when this workspace has no harness wiki yet.
8
- import { existsSync } from 'node:fs';
8
+ import { existsSync, readdirSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
 
11
11
  const root = process.argv[2] || process.cwd();
@@ -13,10 +13,25 @@ const root = process.argv[2] || process.cwd();
13
13
  // No harness wiki here → nothing to document into. Stay silent.
14
14
  if (!existsSync(join(root, 'docs', 'wiki'))) process.exit(0);
15
15
 
16
+ // The sweep used to open with the agent running `ls docs/raw/worklog` to see what was there, and in
17
+ // a wiki scaffolded before that folder existed (or whose .gitkeep never got committed) that printed
18
+ // an error into the session on every compaction. The hook already knows the answer, so it says it:
19
+ // what is there, or that the folder has to be created first. Nothing is left to list blindly.
20
+ // It does not create the folder itself: an untracked docs/raw/worklog/ is where the session memory
21
+ // prefers to keep its journal, and moving that store mid-session would orphan the session's anchor.
22
+ let state;
23
+ try {
24
+ const entries = readdirSync(join(root, 'docs', 'raw', 'worklog')).filter((name) => name.endsWith('.md'));
25
+ state = `docs/raw/worklog/ exists with ${entries.length} worklog file(s); no need to list it.`;
26
+ } catch {
27
+ state = 'docs/raw/worklog/ does not exist yet: create it first (mkdir -p docs/raw/worklog); do not list it.';
28
+ }
29
+
16
30
  process.stdout.write(`
17
31
  ===== rsc worklog checkpoint =====
18
32
  If this session did meaningful work (files changed, a decision made, a commit),
19
33
  run a WORKLOG SWEEP before context is lost:
34
+ ${state}
20
35
  1. Write docs/raw/worklog/<YYYY-MM-DD>-<slug>.md using the harness
21
36
  wiki-worklog-template.md (what we did · why · files touched · outcome · next).
22
37
  2. Compile it into wiki/ (update existing articles first; wikilinks + Related);
@@ -115,9 +115,127 @@ export function isCleanupEnabled(root) {
115
115
 
116
116
  /** The first trunk ref that actually exists, or null when there is nothing to compare against. */
117
117
  export function resolveTrunk(root) {
118
+ // What the remote itself calls its default branch comes first: a trunk called `develop` or
119
+ // `trunk` is invisible to the table below, and guessing `main` there compares against nothing.
120
+ const announced = remoteDefault(root);
121
+ if (announced) return announced.ref;
118
122
  return TRUNKS.find((ref) => git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).ok) || null;
119
123
  }
120
124
 
125
+ // `origin/HEAD` as the remote announced it, or null. A plain `git init` + `remote add` never writes
126
+ // that ref (only `clone` and `remote set-head` do), so null is common and is not an error.
127
+ function remoteDefault(root) {
128
+ const sym = git(root, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']);
129
+ if (!sym.ok || !sym.out.startsWith('refs/remotes/origin/')) return null;
130
+ const name = sym.out.slice('refs/remotes/origin/'.length);
131
+ if (!git(root, ['rev-parse', '--verify', '--quiet', `origin/${name}^{commit}`]).ok) return null;
132
+ return { ref: `origin/${name}`, name };
133
+ }
134
+
135
+ /**
136
+ * The branch a new worktree starts from: origin's default, never the local one.
137
+ *
138
+ * Measured in a user's project: the local `main` was 400 commits behind origin, and a worktree cut
139
+ * from it starts 400 commits in the past — every review and every merge after that pays for it.
140
+ * The remote tip is the only base that is current by construction. Null when there is no origin.
141
+ */
142
+ export function defaultBranch(root) {
143
+ const announced = remoteDefault(root);
144
+ if (announced) return announced;
145
+ for (const name of ['main', 'master']) {
146
+ if (git(root, ['rev-parse', '--verify', '--quiet', `origin/${name}^{commit}`]).ok) return { ref: `origin/${name}`, name };
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /** `git fetch origin`, reported rather than thrown: offline is a warning, not a crash. */
152
+ export function fetchOrigin(root) {
153
+ const r = git(root, ['fetch', '--quiet', 'origin']);
154
+ return { ok: r.ok, err: r.err };
155
+ }
156
+
157
+ // A worktree name becomes a directory under `.claude/worktrees/` and, by default, a branch. One
158
+ // segment of plain characters, so `../../elsewhere` or an absolute path can never be where it lands.
159
+ const WORKTREE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
160
+
161
+ /** Why this name cannot be used, or null when it can. */
162
+ export function invalidWorktreeName(name) {
163
+ if (typeof name !== 'string' || !name) return 'a name is required';
164
+ if (!WORKTREE_NAME.test(name) || name.includes('..') || name.endsWith('.lock') || name.endsWith('.')) {
165
+ return `"${name}" is not a valid worktree name: use letters, digits, '.', '_' or '-' (no '/', no '..')`;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ /**
171
+ * The preparation hook a project defines for a new worktree, or null.
172
+ *
173
+ * A worktree is born with what git tracks and nothing else: no `.env`, no dependencies. What each
174
+ * project needs to be runnable is the project's knowledge, not ours, so rsc does not guess it — it
175
+ * runs the project's own script if there is one. The new worktree's copy wins (it is the code of
176
+ * the branch that will run), the main checkout's is the fallback (a hook not yet committed).
177
+ */
178
+ export const PREPARE_HOOKS = ['scripts/dev/preparar-worktree.sh', 'scripts/dev/preparar-worktree.mjs'];
179
+ export function findPrepareHook(...dirs) {
180
+ for (const dir of dirs) {
181
+ for (const rel of PREPARE_HOOKS) if (existsSync(join(dir, rel))) return join(dir, rel);
182
+ }
183
+ return null;
184
+ }
185
+
186
+ /**
187
+ * Create a worktree at `.claude/worktrees/<name>` on a NEW branch cut from origin's default branch.
188
+ *
189
+ * `--no-track` because a branch cut from `origin/main` would otherwise track `origin/main`, and
190
+ * the first `git pull` from the worktree would merge the trunk into the feature behind your back.
191
+ * The branch gets its own upstream on its first `git push -u`.
192
+ */
193
+ export function createWorktree(root, name, { branch } = {}) {
194
+ const bad = invalidWorktreeName(name);
195
+ if (bad) return { created: false, reason: bad };
196
+ const ref = branch || name;
197
+ if (!git(root, ['check-ref-format', '--branch', ref]).ok) return { created: false, reason: `"${ref}" is not a valid branch name` };
198
+ if (git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${ref}`]).ok) {
199
+ return { created: false, reason: `branch ${ref} already exists; pick another with --branch <rama>` };
200
+ }
201
+ const path = join(real(root), '.claude', 'worktrees', name);
202
+ if (existsSync(path)) return { created: false, reason: `${path} already exists` };
203
+
204
+ const fetched = fetchOrigin(root);
205
+ const base = defaultBranch(root);
206
+ if (!base) {
207
+ return { created: false, reason: 'no origin default branch to start from (no `origin` remote, or neither origin/main nor origin/master exists). Add the remote and push the trunk first.' };
208
+ }
209
+ mkdirSync(dirname(path), { recursive: true });
210
+ const add = git(root, ['worktree', 'add', '--quiet', '--no-track', '-b', ref, path, base.ref]);
211
+ if (!add.ok) return { created: false, reason: `git refused to create it: ${add.err || add.out}` };
212
+ return { created: true, path: real(path), branch: ref, base: base.ref, fetched: fetched.ok, fetchError: fetched.ok ? null : fetched.err };
213
+ }
214
+
215
+ /** Run the preparation hook inside the new worktree. Its output goes straight to the person. */
216
+ export function runPrepareHook(hook, worktreePath, mainRoot) {
217
+ const runner = hook.endsWith('.mjs') ? process.execPath : 'bash';
218
+ const r = spawnSync(runner, [hook], {
219
+ cwd: worktreePath,
220
+ stdio: 'inherit',
221
+ windowsHide: true,
222
+ env: { ...process.env, RSC_MAIN_WORKTREE: real(mainRoot), RSC_WORKTREE: worktreePath },
223
+ });
224
+ if (r.error) return { ok: false, reason: `could not start ${runner}: ${r.error.message}` };
225
+ return { ok: r.status === 0, reason: r.status === 0 ? null : `exited with code ${r.status}` };
226
+ }
227
+
228
+ /**
229
+ * The candidates a bulk `reap --merged` may take: landed and empty of anything unsaved, where the
230
+ * only thing missing is certainty about provenance. That doubt is what `--confirm` accepts, once,
231
+ * for the whole list the person was just shown. Nothing else is: a dirty worktree, or one holding a
232
+ * file in no commit, is never part of a bulk yes (test 14's rule, unchanged).
233
+ */
234
+ export function mergedCandidates(classified) {
235
+ return classified.filter((c) => c.verdict === 'safe'
236
+ || (c.verdict === 'ask' && c.reasons.length && c.reasons.every((r) => r === 'provenance-ambiguous')));
237
+ }
238
+
121
239
  /** Every working tree git knows about. The first entry is always the main one. */
122
240
  export function listWorktrees(root) {
123
241
  const { ok, out } = git(root, ['worktree', 'list', '--porcelain']);
@@ -166,8 +284,12 @@ export function provenanceOf(root, wt) {
166
284
  const p = wt.path;
167
285
  const home = real(root);
168
286
  const inside = (dir) => p === dir || p.startsWith(dir + sep);
287
+ // `.claude/worktrees/` is where Claude Code puts the worktrees it opens, and where
288
+ // `rsc worktrees new` puts them too. Left out, every one of them read as foreign: up to fourteen
289
+ // piled up in one user's project, invisible to the cleanup, until they were removed by hand.
169
290
  const location = inside(join(home, '.worktrees'))
170
291
  || inside(join(home, 'worktrees'))
292
+ || inside(join(home, '.claude', 'worktrees'))
171
293
  || (dirname(p) === dirname(home) && basename(p).startsWith(`${basename(home)}-`));
172
294
  const branch = Boolean(wt.branch && RSC_BRANCH.test(wt.branch));
173
295
  if (location && branch) return 'rsc';
@@ -236,7 +358,7 @@ export function integrationOf(root, wt, trunk) {
236
358
  * with nothing pending can still hold the only copy of an .env, a local database or a page of notes,
237
359
  * and removing the directory takes them with it. `--ignored` is the only way to be told they exist.
238
360
  */
239
- export function contentOutsideHistory(wtPath) {
361
+ export function contentOutsideHistory(wtPath, mainRoot) {
240
362
  const { ok, raw } = git(wtPath, ['status', '--porcelain', '-z', '-uall', '--ignored=matching']);
241
363
  const dirty = [];
242
364
  const outside = [];
@@ -252,12 +374,27 @@ export function contentOutsideHistory(wtPath) {
252
374
  // does not exist — the exact failure the `raw` comment above exists to prevent (P6).
253
375
  if (code[0] === 'R' || code[0] === 'C') { i++; dirty.push(path); continue; }
254
376
  if (code === '??' || code === '!!') {
255
- if (!isRegenerable(path)) outside.push(path);
377
+ if (!isRegenerable(path) && !copiedFromMain(wtPath, mainRoot, path)) outside.push(path);
256
378
  } else dirty.push(path);
257
379
  }
258
380
  return { dirty, outside, readable: true };
259
381
  }
260
382
 
383
+ // A file byte-identical to the same path in the main checkout is a copy, not the only copy: the
384
+ // preparation hook of `rsc worktrees new` puts the `.env` there exactly that way. Without this, every
385
+ // prepared worktree would hold "a file in no commit" for ever and no cleanup could take it. Compared
386
+ // by content, never by name: an `.env` edited inside the worktree differs and still blocks.
387
+ function copiedFromMain(wtPath, mainRoot, path) {
388
+ if (!mainRoot || path.endsWith('/')) return false;
389
+ const original = join(mainRoot, path);
390
+ if (real(original) === real(join(wtPath, path))) return false;
391
+ try {
392
+ return readFileSync(original).equals(readFileSync(join(wtPath, path)));
393
+ } catch {
394
+ return false;
395
+ }
396
+ }
397
+
261
398
  // A directory name counts anywhere it is used as a DIRECTORY; a filename counts only as a filename.
262
399
  // Matching the table against the last segment made `config/env` — a dotenv full of live credentials —
263
400
  // read as a virtualenv and get deleted. Matching it only at position 0 fixed that and broke every
@@ -307,7 +444,7 @@ export function classifyWorktrees(root) {
307
444
  if (integration !== 'integrated') return { ...base, reasons: [integration] };
308
445
  if (!hasLandedWork(root, wt)) return { ...base, reasons: ['nothing-landed'] };
309
446
 
310
- const { dirty, outside, readable } = contentOutsideHistory(wt.path);
447
+ const { dirty, outside, readable } = contentOutsideHistory(wt.path, real(root));
311
448
  if (!readable) return { ...base, reasons: ['unreadable'] };
312
449
 
313
450
  const reasons = [];
@@ -371,7 +508,10 @@ export function reapWorktree(root, targetPath, { confirmed = false } = {}) {
371
508
  // `-d` refuses an unmerged branch, which is the recovery net — but it happily deletes the TRUNK,
372
509
  // which is an ancestor of everything. A sibling checkout of `main` classified as ambiguous, the user
373
510
  // confirmed removing a stray directory, and the trunk ref went with it.
374
- const branchDeleted = candidate.branch && !isTrunkName(candidate.branch)
511
+ // The default branch the remote announces is a trunk too, whatever it is called.
512
+ const announced = remoteDefault(root);
513
+ const isTrunk = isTrunkName(candidate.branch || '') || Boolean(announced && candidate.branch === announced.name);
514
+ const branchDeleted = candidate.branch && !isTrunk
375
515
  ? git(root, ['branch', '-d', candidate.branch]).ok
376
516
  : false;
377
517
  // No `git worktree prune` here. It has no expiry of its own, so running it while another worktree's