@ericrisco/rsc 2.0.1 → 2.0.2

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": "2.0.1",
2
+ "version": "2.0.2",
3
3
  "counts": {
4
4
  "skills": 273,
5
5
  "agents": 33,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
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": {
package/scripts/doctor.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { hooksDir, hooksDirIsOurs } from '../targets/worktree-reaper.mjs';
1
2
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
3
  import { join, dirname } from 'node:path';
3
4
  import { homedir } from 'node:os';
@@ -84,9 +85,21 @@ export function missingHookScripts({ target, home = homedir(), cwd = process.cwd
84
85
  // different answers: armed, absent (repair it), or someone else's (leave it — chaining is our job
85
86
  // at install time, and overwriting a husky hook to fix a convenience would be indefensible).
86
87
  function worktreeCleanupStatus(root) {
87
- const hook = join(root, '.git', 'hooks', 'post-merge');
88
88
  if (!existsSync(join(root, '.git'))) return { state: 'not-a-repo' };
89
89
  if (existsSync(join(root, '.rsc', '.no-worktree-cleanup'))) return { state: 'off-by-choice' };
90
+ // Ask git where hooks live instead of assuming `.git/hooks`. Until 2.0.1 this read the assumed
91
+ // path, so on a repo with its own `core.hooksPath` it found our inert file there and answered
92
+ // `armed` about a hook git would never run — the check confirming the exact failure it exists to
93
+ // catch. `unreachable` is its own state on purpose: `repair` re-runs the same install, so
94
+ // recommending it here would send the reader round the same loop (P6 wants a way out, not a ring).
95
+ const dir = hooksDir(root);
96
+ if (!hooksDirIsOurs(root, dir)) {
97
+ return {
98
+ state: 'unreachable',
99
+ action: `git reads hooks from ${dir}, which belongs to this repo or to another tool (husky, lefthook, a committed dispatcher). rsc will not write there. To arm the cleanup, add \`node "$(git rev-parse --show-toplevel)/.rsc/worktree-reaper.mjs" "$(git rev-parse --show-toplevel)" auto\` to your own post-merge hook.`,
100
+ };
101
+ }
102
+ const hook = join(dir, 'post-merge');
90
103
  if (!existsSync(hook)) {
91
104
  return { state: 'absent', action: 'Run `npx @ericrisco/rsc repair` to arm the post-merge cleanup in this clone.' };
92
105
  }
@@ -252,15 +252,20 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
252
252
  // content — declares a narrower set of paths, and writing a git hook it never agreed to would be
253
253
  // acting outside the plan the user accepted (P4). Caught by the onboarding route-inventory gate,
254
254
  // which is exactly the kind of catch that justifies its existence.
255
+ // The answer used to be discarded, so a hook that could not arm looked exactly like one that
256
+ // did — and the only place that could have noticed, `doctor`, was reading the same wrong path.
257
+ // Reported 2026-09-18. Carried out, never thrown: the harness installing is the point, and the
258
+ // merge hook is a convenience layered on top of it.
259
+ let mergeHook = { installed: false, state: 'skipped' };
255
260
  try {
256
261
  if (policy?.codeHooks === false) throw new Error('code hooks not governed by the accepted plan');
257
262
  mkdirSync(join(cwd, '.rsc'), { recursive: true });
258
263
  cpSync(join(ROOT, 'targets', 'worktree-reaper.mjs'), join(cwd, '.rsc', 'worktree-reaper.mjs'));
259
264
  const { installMergeHook } = await import('../targets/worktree-reaper.mjs');
260
- if (existsSync(join(cwd, '.git'))) installMergeHook(cwd);
261
- } catch { /* a project without git, or a read-only .git: the cleanup simply does not arm here */ }
265
+ if (existsSync(join(cwd, '.git'))) mergeHook = installMergeHook(cwd);
266
+ } catch (err) { mergeHook = { installed: false, state: 'skipped', reason: err.message }; }
262
267
  ignoreLocalState(cwd, target);
263
- return { ...state, backup };
268
+ return { ...state, backup, mergeHook };
264
269
  }
265
270
 
266
271
  // `.rsc/` holds local machine state — hook scripts, install markers, the sello's
@@ -428,6 +428,41 @@ node "$root/.rsc/worktree-reaper.mjs" "$root" auto 2>/dev/null || true
428
428
  exit 0
429
429
  `;
430
430
 
431
+ /**
432
+ * Where git will ACTUALLY look for hooks, asked of git rather than assumed.
433
+ *
434
+ * The assumption was `<root>/.git/hooks`, and it is wrong in three ordinary situations at once:
435
+ * a repo that sets `core.hooksPath` (husky v9 writes `.husky/_`, lefthook and committed
436
+ * `.githooks/` dispatchers do the same), a linked worktree where `.git` is a FILE and the mkdir
437
+ * raised ENOTDIR, and a submodule. Reported from a user's repo on 2026-09-18 against 2.0.1: rsc
438
+ * wrote the hook where git does not look, `doctor` read the same wrong path back, and the report
439
+ * said `armed` about something that could never run. Verified there and here that a single
440
+ * `rev-parse` answers all three.
441
+ *
442
+ * `--path-format=absolute` needs git 2.31 (Mar 2021). Older git gets the old path — which is
443
+ * correct whenever `core.hooksPath` is unset, i.e. the case it was always right about.
444
+ */
445
+ export function hooksDir(root) {
446
+ const r = git(root, ['rev-parse', '--path-format=absolute', '--git-path', 'hooks']);
447
+ if (r.ok && r.out) return r.out;
448
+ return join(real(root), '.git', 'hooks');
449
+ }
450
+
451
+ // Whether that directory is ours to write in. Inside the repository's own git dir: yes, it is
452
+ // private plumbing that is never cloned and never committed. Anywhere else it belongs to the user
453
+ // or to another tool — a committed `.githooks/` is their source tree (writing there would put rsc
454
+ // in their diff, and moving an existing hook aside would rename a TRACKED file), and `.husky/_` is
455
+ // regenerated by husky, so anything we left there disappears on their next install. Report it and
456
+ // let the person decide; that is P4, and it is the same reasoning the comment on this function has
457
+ // claimed since the hook shipped, finally applied instead of only stated.
458
+ export function hooksDirIsOurs(root, dir) {
459
+ const r = git(root, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
460
+ if (!r.ok || !r.out) return false;
461
+ const gitDir = real(r.out);
462
+ const target = real(dir);
463
+ return target === gitDir || target.startsWith(gitDir + sep);
464
+ }
465
+
431
466
  /**
432
467
  * Install the trigger. The judgement has existed and been tested for a while; what never existed was
433
468
  * something that runs it at the moment work lands. `post-merge` is that moment, and it covers both
@@ -442,7 +477,10 @@ exit 0
442
477
  */
443
478
  export function installMergeHook(root) {
444
479
  try {
445
- const dir = join(real(root), '.git', 'hooks');
480
+ const dir = hooksDir(root);
481
+ if (!hooksDirIsOurs(root, dir)) {
482
+ return { installed: false, state: 'unreachable', reason: `git reads hooks from ${dir}, which belongs to this repository or to another tool — rsc will not write there.` };
483
+ }
446
484
  const hook = join(dir, 'post-merge');
447
485
  mkdirSync(dir, { recursive: true });
448
486
  if (existsSync(hook)) {
@@ -461,7 +499,7 @@ export function installMergeHook(root) {
461
499
  chmodSync(hook, 0o755);
462
500
  return { installed: true, chained: existsSync(join(dir, 'post-merge.rsc-local')) };
463
501
  } catch (err) {
464
- return { installed: false, reason: err.message };
502
+ return { installed: false, state: 'failed', reason: err.message };
465
503
  }
466
504
  }
467
505