@ericrisco/rsc 2.0.0 → 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.0",
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.0",
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
  }
@@ -445,6 +458,18 @@ export function contextBudget({ target, home = homedir(), cwd = process.cwd() }
445
458
  });
446
459
  }
447
460
  const drift = divergence({ cwd, target, home });
461
+ // A separate finding, not a fourth clause of the one below: that one says this checkout
462
+ // is behind its own team, and the fix is to align with the declaration. This one says the
463
+ // declaration itself is behind the catalog, so aligning with it changes nothing. Folded
464
+ // together, the report would name the right skill and the wrong reason.
465
+ if (drift.floorMissing?.length) {
466
+ findings.push({
467
+ id: 'floor-missing',
468
+ severity: 'high',
469
+ summary: `.rsc.json predates a skill the catalog now requires: ${drift.floorMissing.join(', ')}. The always-on body routes work to it, so that route currently answers nowhere.`,
470
+ action: 'Run `npx @ericrisco/rsc sync` — it adds what the catalog requires and touches nothing else.',
471
+ });
472
+ }
448
473
  if (drift.missing.length || drift.extra.length || drift.ownMissing.length) {
449
474
  // The day-two case: a teammate changed the harness and this checkout has not caught
450
475
  // up. Reported always, even after someone declines to align — the divergence does not
@@ -10,6 +10,7 @@ import {
10
10
  resolveAgentNames, agentByName, allAgentNames, readDeveloperTier,
11
11
  } from '../targets/agents.js';
12
12
  import { readState, writeState } from './lib/state.js';
13
+ import { withDefaultSkillFloor } from './lib/default-skill-floor.js';
13
14
  import { readManifest, writeManifest } from './lib/manifest-file.js';
14
15
  import { createBackup } from './lib/backups.js';
15
16
  import {
@@ -251,15 +252,20 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
251
252
  // content — declares a narrower set of paths, and writing a git hook it never agreed to would be
252
253
  // acting outside the plan the user accepted (P4). Caught by the onboarding route-inventory gate,
253
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' };
254
260
  try {
255
261
  if (policy?.codeHooks === false) throw new Error('code hooks not governed by the accepted plan');
256
262
  mkdirSync(join(cwd, '.rsc'), { recursive: true });
257
263
  cpSync(join(ROOT, 'targets', 'worktree-reaper.mjs'), join(cwd, '.rsc', 'worktree-reaper.mjs'));
258
264
  const { installMergeHook } = await import('../targets/worktree-reaper.mjs');
259
- if (existsSync(join(cwd, '.git'))) installMergeHook(cwd);
260
- } 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 }; }
261
267
  ignoreLocalState(cwd, target);
262
- return { ...state, backup };
268
+ return { ...state, backup, mergeHook };
263
269
  }
264
270
 
265
271
  // `.rsc/` holds local machine state — hook scripts, install markers, the sello's
@@ -534,9 +540,16 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
534
540
  // then rebuilt from a governed list that predated it and pruned the difference, recursively.
535
541
  // Reported by a user and reproduced 2026-09-17. The receipt stays untouched, because it is
536
542
  // hash-checked against what the user accepted and is not a log of what happened since.
537
- const declared = governedSkills
543
+ const declaredRaw = governedSkills
538
544
  ? [...new Set([...governedSkills, ...(manifest?.skills || [])])].sort()
539
545
  : (ids.length ? ids : (manifest?.skills || []));
546
+ // A declaration is frozen at the moment it was written, so a skill the catalog later makes
547
+ // mandatory never reaches an existing harness: `add` and `install` apply the floor, sync did
548
+ // not, and sync is the only one an upgrade runs. 2.0.0 shipped the three-lane decisor inside
549
+ // `suggest` — already declared, so already upgraded — routing ordinary work to an `ftd` that
550
+ // sync had no reason to fetch. Applied only to a harness that already declares something: a
551
+ // directory declaring nothing is not a harness missing its floor.
552
+ const declared = declaredRaw.length ? withDefaultSkillFloor(declaredRaw) : declaredRaw;
540
553
  const declaredAgents = state.explicitAgents?.length ? state.explicitAgents : (manifest?.agents || []);
541
554
  if (!declared.length && !declaredAgents.length) return dryRun ? { dryRun: true, synced: [], syncedAgents: [], paths: [] } : { synced: [], syncedAgents: [], backup: null };
542
555
  if (dryRun) {
@@ -4,7 +4,12 @@
4
4
  // `orient` and `suggest` are runtime always-on. `bro` is installed by default
5
5
  // but remains conditionally loaded: its description routes human-language asks
6
6
  // without paying for its body on unrelated turns.
7
- export const DEFAULT_SKILL_FLOOR = Object.freeze(['orient', 'suggest', 'bro']);
7
+ //
8
+ // `ftd` joined in 2.0.1 by the same criterion as `suggest`: the always-on decisor
9
+ // routes ordinary work to `../ftd/SKILL.md`, so a harness without it has a default
10
+ // lane that points at nothing. Membership of every profile was not enough — a
11
+ // declaration written before the skill existed never gains it (see `syncInstalled`).
12
+ export const DEFAULT_SKILL_FLOOR = Object.freeze(['orient', 'suggest', 'bro', 'ftd']);
8
13
 
9
14
  export function withDefaultSkillFloor(skillIds = []) {
10
15
  return [...new Set([...DEFAULT_SKILL_FLOOR, ...skillIds])];
@@ -2,6 +2,7 @@ import { readManifest } from './manifest-file.js';
2
2
  import { readState } from './state.js';
3
3
  import { targetPaths } from '../../targets/index.js';
4
4
  import { existsSync } from 'node:fs';
5
+ import { DEFAULT_SKILL_FLOOR } from './default-skill-floor.js';
5
6
 
6
7
  // What the team declared versus what is on this machine.
7
8
  //
@@ -15,10 +16,13 @@ import { existsSync } from 'node:fs';
15
16
  // ownMissing — declared team skill, absent from the repo. Aligning says so and writes
16
17
  // nothing: it comes from the repo, not from us.
17
18
  // extra — installed BY RSC, no longer declared.
19
+ // floorMissing — a skill the catalog makes mandatory that this declaration predates.
20
+ // Not drift between teammates: drift between the harness and the catalog,
21
+ // and the only bucket that survives everyone running the same `git pull`.
18
22
  // A hand-written skill that nobody declared is in none of them. Not declaring is a valid
19
23
  // way to keep things of your own, not an oversight to correct.
20
24
  export function divergence({ cwd = process.cwd(), target, home } = {}) {
21
- const empty = { missing: [], extra: [], ownMissing: [] };
25
+ const empty = { missing: [], extra: [], ownMissing: [], floorMissing: [] };
22
26
  const manifest = readManifest(cwd);
23
27
  if (!manifest) return empty;
24
28
 
@@ -29,5 +33,8 @@ export function divergence({ cwd = process.cwd(), target, home } = {}) {
29
33
  missing: (manifest.skills || []).filter((id) => !installed.has(id)),
30
34
  extra: [...installed].filter((id) => !(manifest.skills || []).includes(id)),
31
35
  ownMissing: (manifest.ownSkills || []).filter((name) => !existsSync(paths.skillDir(name))),
36
+ // Read off the declaration, not off the disk: a skill installed by hand into a harness
37
+ // that still does not declare it is one sync away from being pruned again.
38
+ floorMissing: DEFAULT_SKILL_FLOOR.filter((id) => !(manifest.skills || []).includes(id)),
32
39
  };
33
40
  }
@@ -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