@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/README.md +113 -658
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/doctor.js +1 -1
- package/scripts/rsc.js +63 -1
- package/skills/building-agents/SKILL.md +1 -1
- package/skills/grill-with-docs/SKILL.md +0 -1
- package/skills/harness/references/daily-curation-automation.md +2 -1
- package/skills/harness/references/wiki-protocol.md +3 -1
- package/skills/implement/SKILL.md +0 -1
- package/skills/implement-spec/SKILL.md +1 -2
- package/skills/init/SKILL.md +7 -5
- package/skills/postgresdb/SKILL.md +1 -1
- package/skills/suggest/SKILL.md +5 -5
- package/skills/to-spec/SKILL.md +0 -1
- package/skills/to-tickets/SKILL.md +0 -1
- package/skills/wayfinder/SKILL.md +0 -1
- package/targets/_md-block.js +1 -1
- package/targets/claude.js +2 -1
- package/targets/danger-guard.mjs +114 -52
- package/targets/hook-once.mjs +44 -13
- package/targets/memory.js +6 -1
- package/targets/session-memory-adapter.mjs +4 -1
- package/targets/session-memory-core.mjs +40 -8
- package/targets/session-start.mjs +2 -1
- package/targets/ship-guard.mjs +26 -9
- package/targets/userprompt-gate.mjs +7 -2
- package/targets/worklog-checkpoint.mjs +16 -1
- package/targets/worktree-reaper.mjs +144 -4
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' ?
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
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
|
`;
|
package/targets/ship-guard.mjs
CHANGED
|
@@ -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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|