@damiangilgonzalez/harness 1.3.15 → 1.3.16

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/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.3.15",
2
+ "version": "1.3.16",
3
3
  "counts": {
4
4
  "skills": 294,
5
5
  "agents": 34,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@damiangilgonzalez/harness",
3
- "version": "1.3.15",
3
+ "version": "1.3.16",
4
4
  "description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -354,6 +354,11 @@ export function ignoreLocalState(cwd = process.cwd(), target) {
354
354
  if (target) {
355
355
  const paths = targetPaths(target, undefined, cwd);
356
356
  const rel = (abs) => relative(cwd, abs).split(sep).join('/');
357
+ // `.claude/worktrees/` is where Claude Code puts the checkout of each isolated branch. Nothing
358
+ // ignored it, so every file of every worktree showed up as untracked: noise in `git status`, a
359
+ // `git add .` away from being committed, and the memory hook fingerprinted them all on each turn
360
+ // (54 s on a project with 964 of them, against a 5 s timeout).
361
+ if (target === 'claude') wanted.push(CLAUDE_WORKTREES);
357
362
  for (const id of Object.keys(readState(paths.stateFile).skills || {})) wanted.push(rel(paths.skillDir(id)));
358
363
  // The per-file ledger is derived AND platform-shaped (symlink here, real copy on
359
364
  // Windows), so committing it puts two incompatible inventories of one thing in one
@@ -530,7 +535,7 @@ export async function uninstall({ skillIds = [], agentIds = [], target, home, cw
530
535
  export function removeTargetInstall({ target, home, cwd = process.cwd() }) {
531
536
  const paths = targetPaths(target, home, cwd);
532
537
  const state = readState(paths.stateFile);
533
- const ignored = [paths.stateFile];
538
+ const ignored = [paths.stateFile, ...(target === 'claude' ? [join(cwd, CLAUDE_WORKTREES)] : [])];
534
539
  const ownedIgnoreEntries = new Set(state.ignoreEntriesAdded || []);
535
540
  const owned = [
536
541
  ...Object.values(state.skills || {}).flatMap((entry) => entry.files || []),
@@ -659,6 +664,7 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
659
664
  // own knowledge — kept unless `withDocs` is set. Returns the paths touched.
660
665
  // Note: backups live under `.rsc/backups/`, which this removes — so purge does not
661
666
  // snapshot (a pre-purge backup would delete itself). It is the deliberate escape hatch.
667
+ const CLAUDE_WORKTREES = '.claude/worktrees/';
662
668
  const RSC_IGNORE_HEADER = '# rsc local state (hooks, seals, logs) and managed skill links — machine-local';
663
669
 
664
670
  // Merge consecutive rsc blocks into the first: drop a repeated header and the blank seam before it
@@ -46,9 +46,10 @@ function cleanId(value, prefix = 'session') {
46
46
  return `${prefix}-${hash}`;
47
47
  }
48
48
 
49
- function git(cwd, args) {
49
+ function git(cwd, args, input) {
50
50
  try {
51
- return { ok: true, out: execFileSync('git', args, { windowsHide: true, cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 2 * 1024 * 1024 }).trim() };
51
+ const stdin = input === undefined ? 'ignore' : 'pipe';
52
+ return { ok: true, out: execFileSync('git', args, { windowsHide: true, cwd, input, encoding: 'utf8', stdio: [stdin, 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024 }).trim() };
52
53
  } catch {
53
54
  return { ok: false, out: '' };
54
55
  }
@@ -162,7 +163,15 @@ function parseStatus(output) {
162
163
  return files;
163
164
  }
164
165
 
166
+ // One `git` for every file, not one per file. Spawning a process costs ~25 ms on Windows, and this
167
+ // runs on every hook event: a few hundred dirty files were enough to blow the 5 s hook timeout.
168
+ // `--stdin-paths` stops at the first path it cannot read (a deleted file), so on failure each file
169
+ // is hashed on its own, which keeps the old per-file `null` for the unreadable ones.
165
170
  function fingerprintFiles(worktree, files) {
171
+ if (!files.length) return {};
172
+ const batch = git(worktree, ['hash-object', '--no-filters', '--stdin-paths'], `${files.join('\n')}\n`);
173
+ const hashes = batch.ok ? batch.out.split('\n') : [];
174
+ if (hashes.length === files.length) return Object.fromEntries(files.map((path, i) => [path, hashes[i]]));
166
175
  return Object.fromEntries(files.map((path) => {
167
176
  const result = git(worktree, ['hash-object', '--no-filters', '--', path]);
168
177
  return [path, result.ok ? result.out : null];
@@ -305,7 +314,8 @@ export function capture(input = {}) {
305
314
  const recordPath = join(store.root, 'sessions', `${storageId}.json`);
306
315
  let anchor = readJson(anchorPath);
307
316
  let existing = readJson(recordPath);
308
- const firstSnapshot = snapshot(here, null);
317
+ // The first snapshot only seeds a new anchor; an existing one already has its baseline.
318
+ const firstSnapshot = anchor ? null : snapshot(here, null);
309
319
  if (!anchor) {
310
320
  anchor = {
311
321
  sessionId,
@@ -318,7 +328,11 @@ export function capture(input = {}) {
318
328
  }
319
329
  if (input.event === 'start' && !existing) return { record: null, path: null, notice: null, compactionHint: false };
320
330
 
321
- const repo = snapshot(here, anchor.baselineHead);
331
+ // Against its own HEAD as baseline there is nothing committed to diff, so a fresh anchor's
332
+ // snapshot already is the repo state: reuse it instead of walking the tree a second time.
333
+ const repo = firstSnapshot && anchor.baselineHead === firstSnapshot.head
334
+ ? firstSnapshot
335
+ : snapshot(here, anchor.baselineHead);
322
336
  // Count what CHANGED, not which tool changed it. `editDelta` is +1 per Edit/Write tool event, and
323
337
  // on its own it was blind to everything done through the shell — a heredoc, `sed -i`, a script, a
324
338
  // formatter — which is how automated sessions work. The session that found this had touched dozens