@damiangilgonzalez/harness 1.3.16 → 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 +2 -2
- 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/code-review/SKILL.md +3 -1
- package/skills/flujo-interfaz/SKILL.md +7 -0
- 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 +2 -1
- package/skills/implement-spec/SKILL.md +5 -4
- package/skills/init/SKILL.md +7 -5
- package/skills/orient/SKILL.md +3 -0
- package/skills/orient/references/orientation-contract.md +9 -0
- package/skills/postgresdb/SKILL.md +1 -1
- package/skills/pr/SKILL.md +15 -1
- package/skills/pr/evals/cases.yaml +6 -0
- 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 +21 -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
|
@@ -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
|