@ericrisco/rsc 2.0.1 → 2.0.3

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.3",
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.3",
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
@@ -75,7 +75,11 @@ Run this in order. Each check prevents a class of "lost work" you can't easily u
75
75
  local HEAD. Stale base = predictable merge pain later. Default: fresh from `origin/<default>`.
76
76
  6. **Choose a name** tied to the feature slug — the same `<slug>` the spec and plan use
77
77
  (`feat/<slug>`), so the branch, the spec at `02-DOCS/wiki/sdd/specs/<slug>.md`, and the plan at
78
- `02-DOCS/wiki/sdd/plans/<slug>.md` all line up and are trivially traceable.
78
+ `02-DOCS/wiki/sdd/plans/<slug>.md` all line up and are trivially traceable. A change that is not
79
+ a feature takes the type that fits — `fix/<slug>`, `docs/<slug>`, `chore/<slug>` — from the same
80
+ Conventional Commits vocabulary the commit guard already enforces here. **The prefix is load
81
+ bearing**: the automatic cleanup uses it, with the location, as the two signals that say a
82
+ worktree is ours to remove. A name outside that vocabulary is never swept for you.
79
83
 
80
84
  Only once the tree state is understood and the user's WIP is accounted for do you create anything.
81
85
 
@@ -189,7 +193,7 @@ the dial controls verbosity, never whether you check before risking someone's WI
189
193
  | "There's uncommitted WIP, I'll stash it and switch" | A worktree avoids the stash entirely and can't drop it. Use a worktree; if you must stash, confirm with the user and name the stash. |
190
194
  | "I'll branch off local HEAD, fetching is slow" | Stale base = merge conflicts you pay for later. Branch off an up-to-date `origin/<default>` unless the user wants HEAD. |
191
195
  | "The worktree's dirty but I'll `remove --force` to clean up" | Force-removing a dirty/unmerged worktree throws away work irreversibly. Resolve or confirm explicitly first; never silent-force. |
192
- | "I'll name it `wip` / `temp` / `branch2`" | An untraceable name divorces the branch from its spec/plan. Name it `feat/<slug>` to match the SDD artifacts. |
196
+ | "I'll name it `wip` / `temp` / `branch2`" | An untraceable name divorces the branch from its spec/plan, and the automatic cleanup will not claim it. Use `<type>/<slug>` — `feat/` for a feature, `fix/`, `docs/`, `chore/` for the rest. |
193
197
  | "I'll create the worktree AND start writing code right here" | This skill only isolates. Hand a clean isolated workspace to `implement`; don't blur the two steps. |
194
198
  | "Already on a feature branch, I'll make a worktree anyway" | Redundant isolation is just clutter. If the current branch is already isolated and clean, say so and proceed. |
195
199
  | "I'll record the worktree path into 02-DOCS so it's tracked" | Isolation is plumbing, not knowledge. No artifact; the branch name traces it. Don't pollute the wiki. |
@@ -53,8 +53,20 @@ export const REGENERABLE_FILES = ['.DS_Store', 'Thumbs.db', '.coverage'];
53
53
  // Trunk candidates, most authoritative first. The remote tip beats a local branch that may be stale.
54
54
  const TRUNKS = ['origin/main', 'main', 'origin/master', 'master'];
55
55
 
56
- // Branch shapes `worktrees` imposes on the isolation it creates.
57
- const RSC_BRANCH = /^(?:feat|feature)\//;
56
+ // Branch shapes the catalog's isolation produces.
57
+ //
58
+ // `worktrees` documents `feat/<slug>`, and while SDD was the only lane that was the whole set:
59
+ // every isolated branch was a feature. 2.0.0 made FTD the default and FTD names a branch for what
60
+ // it is — a fix is not a feature — so `fix/`, `docs/` and `chore/` fell outside, scored as half a
61
+ // signal, and were never swept, while `ftd` promises in writing that the cleanup is automatic once
62
+ // the branch lands. Found by using it: the two fixes released on 2026-09-18 both had to be removed
63
+ // by hand. These are the Conventional Commits types, which is what the commit guard already
64
+ // enforces on every commit in a project rsc governs — so the branch shape and the commit shape now
65
+ // come from the same vocabulary instead of two that drifted apart.
66
+ //
67
+ // Widening one signal, never the conjunction: a branch outside this vocabulary is still `ambiguous`
68
+ // wherever it sits, and one of these in a directory rsc does not own is still `ambiguous` too.
69
+ const RSC_BRANCH = /^(?:feat|feature|fix|docs|chore|refactor|test|perf|ci|build|style)\//;
58
70
 
59
71
  function git(cwd, args) {
60
72
  // 64 MiB, because node's 1 MiB default kills git mid-write on any worktree with a few thousand
@@ -428,6 +440,41 @@ node "$root/.rsc/worktree-reaper.mjs" "$root" auto 2>/dev/null || true
428
440
  exit 0
429
441
  `;
430
442
 
443
+ /**
444
+ * Where git will ACTUALLY look for hooks, asked of git rather than assumed.
445
+ *
446
+ * The assumption was `<root>/.git/hooks`, and it is wrong in three ordinary situations at once:
447
+ * a repo that sets `core.hooksPath` (husky v9 writes `.husky/_`, lefthook and committed
448
+ * `.githooks/` dispatchers do the same), a linked worktree where `.git` is a FILE and the mkdir
449
+ * raised ENOTDIR, and a submodule. Reported from a user's repo on 2026-09-18 against 2.0.1: rsc
450
+ * wrote the hook where git does not look, `doctor` read the same wrong path back, and the report
451
+ * said `armed` about something that could never run. Verified there and here that a single
452
+ * `rev-parse` answers all three.
453
+ *
454
+ * `--path-format=absolute` needs git 2.31 (Mar 2021). Older git gets the old path — which is
455
+ * correct whenever `core.hooksPath` is unset, i.e. the case it was always right about.
456
+ */
457
+ export function hooksDir(root) {
458
+ const r = git(root, ['rev-parse', '--path-format=absolute', '--git-path', 'hooks']);
459
+ if (r.ok && r.out) return r.out;
460
+ return join(real(root), '.git', 'hooks');
461
+ }
462
+
463
+ // Whether that directory is ours to write in. Inside the repository's own git dir: yes, it is
464
+ // private plumbing that is never cloned and never committed. Anywhere else it belongs to the user
465
+ // or to another tool — a committed `.githooks/` is their source tree (writing there would put rsc
466
+ // in their diff, and moving an existing hook aside would rename a TRACKED file), and `.husky/_` is
467
+ // regenerated by husky, so anything we left there disappears on their next install. Report it and
468
+ // let the person decide; that is P4, and it is the same reasoning the comment on this function has
469
+ // claimed since the hook shipped, finally applied instead of only stated.
470
+ export function hooksDirIsOurs(root, dir) {
471
+ const r = git(root, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
472
+ if (!r.ok || !r.out) return false;
473
+ const gitDir = real(r.out);
474
+ const target = real(dir);
475
+ return target === gitDir || target.startsWith(gitDir + sep);
476
+ }
477
+
431
478
  /**
432
479
  * Install the trigger. The judgement has existed and been tested for a while; what never existed was
433
480
  * something that runs it at the moment work lands. `post-merge` is that moment, and it covers both
@@ -442,7 +489,10 @@ exit 0
442
489
  */
443
490
  export function installMergeHook(root) {
444
491
  try {
445
- const dir = join(real(root), '.git', 'hooks');
492
+ const dir = hooksDir(root);
493
+ if (!hooksDirIsOurs(root, dir)) {
494
+ 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.` };
495
+ }
446
496
  const hook = join(dir, 'post-merge');
447
497
  mkdirSync(dir, { recursive: true });
448
498
  if (existsSync(hook)) {
@@ -461,7 +511,7 @@ export function installMergeHook(root) {
461
511
  chmodSync(hook, 0o755);
462
512
  return { installed: true, chained: existsSync(join(dir, 'post-merge.rsc-local')) };
463
513
  } catch (err) {
464
- return { installed: false, reason: err.message };
514
+ return { installed: false, state: 'failed', reason: err.message };
465
515
  }
466
516
  }
467
517