@damiangilgonzalez/harness 1.3.12 → 1.3.13

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.
Files changed (61) hide show
  1. package/README.md +31 -9
  2. package/manifest.json +1 -1
  3. package/package.json +3 -2
  4. package/scripts/doctor.js +109 -2
  5. package/scripts/install-apply.js +127 -7
  6. package/scripts/lib/capabilities.js +19 -2
  7. package/scripts/lib/divergence.js +8 -1
  8. package/scripts/lib/onboarding-apply.js +18 -1
  9. package/scripts/lib/onboarding.js +27 -2
  10. package/scripts/lib/versions.js +31 -0
  11. package/scripts/rsc.js +66 -16
  12. package/skills/animar/SKILL.md +1 -0
  13. package/skills/claude-project-setup/SKILL.md +1 -0
  14. package/skills/code-review/SKILL.md +1 -0
  15. package/skills/diseno-apple/SKILL.md +1 -0
  16. package/skills/diseno-landing/SKILL.md +1 -0
  17. package/skills/domain-modeling/SKILL.md +1 -0
  18. package/skills/flujo-interfaz/SKILL.md +1 -0
  19. package/skills/grill-me/SKILL.md +1 -0
  20. package/skills/grill-with-docs/SKILL.md +1 -0
  21. package/skills/grilling/SKILL.md +1 -0
  22. package/skills/handoff/SKILL.md +1 -0
  23. package/skills/implement/SKILL.md +1 -0
  24. package/skills/ingeniero-diseno-web/SKILL.md +1 -0
  25. package/skills/leyes-de-percepcion/SKILL.md +1 -0
  26. package/skills/leyes-de-retencion/SKILL.md +1 -0
  27. package/skills/mejor-accesibilidad/SKILL.md +1 -0
  28. package/skills/mejor-colores/SKILL.md +1 -0
  29. package/skills/mejor-layout/SKILL.md +1 -0
  30. package/skills/mejor-redaccion/SKILL.md +1 -0
  31. package/skills/mejor-tipografia/SKILL.md +1 -0
  32. package/skills/mejor-ui/SKILL.md +1 -0
  33. package/skills/muscle-memory/SKILL.md +1 -0
  34. package/skills/prototype/SKILL.md +1 -0
  35. package/skills/research/SKILL.md +1 -0
  36. package/skills/revision-de-cambios/SKILL.md +1 -0
  37. package/skills/revision-interfaz/SKILL.md +1 -0
  38. package/skills/sitios-calidad-premio/SKILL.md +1 -0
  39. package/skills/suggest/SKILL.md +10 -9
  40. package/skills/tastemaker/SKILL.md +1 -0
  41. package/skills/teach/SKILL.md +1 -0
  42. package/skills/to-questionnaire/SKILL.md +1 -0
  43. package/skills/to-spec/SKILL.md +1 -0
  44. package/skills/to-tickets/SKILL.md +1 -0
  45. package/skills/video-a-superprompt/SKILL.md +1 -0
  46. package/skills/vocabulario-animacion/SKILL.md +1 -0
  47. package/skills/wait-what/SKILL.md +1 -0
  48. package/skills/wayfinder/SKILL.md +1 -0
  49. package/skills/write-adr/SKILL.md +1 -0
  50. package/skills/writing-for-agents/SKILL.md +1 -0
  51. package/targets/_md-block.js +8 -1
  52. package/targets/agents-md-shadow.js +153 -0
  53. package/targets/agents.js +9 -2
  54. package/targets/claude.js +52 -11
  55. package/targets/clone-bootstrap.mjs +454 -0
  56. package/targets/gitmoji-guard.mjs +30 -3
  57. package/targets/opt-outs.js +40 -0
  58. package/targets/session-memory-adapter.mjs +70 -14
  59. package/targets/session-memory-core.mjs +39 -7
  60. package/targets/session-start.mjs +63 -10
  61. package/targets/worktree-reaper.mjs +174 -10
package/README.md CHANGED
@@ -284,23 +284,43 @@ The harness travels by git, but not all of it — and the split is the point.
284
284
  | `docs/raw/worklog/.rsc-memory/` | Preferred session journal when a local wiki exists; protected with git's local exclude |
285
285
  | The skill entries rsc manages | Symlinks on macOS/Linux, real copies on Windows — two incompatible shapes of one thing |
286
286
 
287
- Whoever clones runs **one command** and ends up with the same harness:
287
+ Three files carry the harness through git: `.rsc.json` (what the team decided),
288
+ `.claude/settings.json` (the wiring), and `.claude/rsc-bootstrap.mjs` — the small file that
289
+ notices, in a clone, that the rest is not there yet. **Commit all three.** If your project
290
+ ignores the assistant's directory wholesale, rsc adds the lines that keep those files versioned —
291
+ verified against git, not against the pattern.
292
+
293
+ **And say it plainly, because committing it is the point:** `.claude/rsc-bootstrap.mjs` is code that
294
+ runs on every session and every shell call, so a pull request that edits *that file* runs on the
295
+ machine of whoever reviews the branch. That is true of `.claude/settings.json` already, and of any
296
+ hook-based harness — but it is worth knowing before you agree to commit a third one. Review changes
297
+ to it the way you would review a CI workflow.
298
+
299
+ Whoever clones runs **one command** and ends up with the same harness — at the version the
300
+ project pinned, which is the `catalogVersion` in its `.rsc.json`:
288
301
 
289
302
  ```bash
290
- npx @damiangilgonzalez/harness@latest sync
303
+ npx @damiangilgonzalez/harness@<catalogVersion> sync
291
304
  ```
292
305
 
293
- Same skills, same version — `.rsc.json` pins the catalog, so a teammate who clones in three
294
- months gets what you had, not what shipped since. Upgrading is a deliberate act, never a side
295
- effect of rebuilding.
306
+ **You do not have to know that, or find it.** Open the project and the assistant tells you, in
307
+ the first message: what is missing, what would be installed, and the exact command, naming the
308
+ pinned version. It asks; it does not install anything on its own and it does not hold up
309
+ whatever you sat down to do. Say no and it stops asking on that machine.
310
+
311
+ Not `@latest`, and the difference is the whole point of sharing: a teammate who clones in three
312
+ months gets what you had, not what shipped since. Upgrading is something a person decides, writes
313
+ into `.rsc.json`, and commits — and then it reaches everyone through git, like any other change.
296
314
 
297
315
  When someone changes the harness and you `git pull`, `rsc doctor` tells you what no longer
298
316
  matches. **Nothing is ever written to your machine by a pull** — you are told, and you decide.
299
317
 
300
318
  **Own skills.** A skill your team wrote lives in the repo and already works for whoever clones,
301
- with no command at all. Declare it in `.rsc.json` under `ownSkills` and `doctor` will also say
302
- when someone is missing it — that is all declaring does. rsc never installs, updates or
303
- overwrites it: its version is the commit.
319
+ with no command at all. rsc never installs, updates or overwrites it: its version is the commit.
320
+ Installing a catalog skill of the same name is refused rather than silently winning, and `doctor`
321
+ lists what is yours by reading the files themselves — no list to keep in `.rsc.json`, because a
322
+ list somebody has to remember to update is a list that goes quietly stale. Declaring a skill under
323
+ `ownSkills` still works and still reports when someone is missing it.
304
324
 
305
325
 
306
326
  ## 🩹 Something's off? One command
@@ -312,10 +332,12 @@ Recognise any of these? They are all the same fix.
312
332
  | `"target": "codex"` when you work in Claude Code | |
313
333
  | `This target has no hook injection` and you did not expect that | |
314
334
  | Skills appear that you never asked for | |
315
- | You cloned a repo and your assistant sees no skills at all | |
316
335
  | A hook seems to run several times per turn | |
317
336
  | Template lines showed up inside your hand-written `AGENTS.md` | |
318
337
 
338
+ (A *fresh* clone is not in this table any more: nothing is broken there, the harness was simply
339
+ never built on that machine, and the assistant now says so itself — see **sharing by git** above.)
340
+
319
341
  ```bash
320
342
  npx @damiangilgonzalez/harness@latest repair
321
343
  ```
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.3.12",
2
+ "version": "1.3.13",
3
3
  "counts": {
4
4
  "skills": 291,
5
5
  "agents": 33,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@damiangilgonzalez/harness",
3
- "version": "1.3.12",
3
+ "version": "1.3.13",
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": {
@@ -64,5 +64,6 @@
64
64
  },
65
65
  "publishConfig": {
66
66
  "access": "public"
67
- }
67
+ },
68
+ "rscFixes": []
68
69
  }
package/scripts/doctor.js CHANGED
@@ -1,10 +1,12 @@
1
- import { existsSync, readFileSync, statSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { hooksDir, hooksDirIsOurs } from '../targets/worktree-reaper.mjs';
2
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
3
4
  import { homedir } from 'node:os';
4
5
  import { targetPaths, TARGET_IDS } from '../targets/index.js';
5
6
  import { readState } from './lib/state.js';
6
7
  import { divergence } from './lib/divergence.js';
7
8
  import { loadManifest } from './lib/manifest.js';
9
+ import { readManifest as readProjectManifest } from './lib/manifest-file.js';
8
10
  import { listBackups } from './lib/backups.js';
9
11
  import { WORKFLOW_GATE_TEXT } from '../targets/hook-once.mjs';
10
12
  import { isEnabled, checkSello, readSello, countFindings, readEffectiveConfig, validateRiskConfig } from '../targets/sello.mjs';
@@ -15,6 +17,17 @@ import { resolveCommands, commandPath, targetHasCommands } from '../targets/comm
15
17
  import { inspectMemoryWiring } from '../targets/memory.js';
16
18
  import { metricsSummary } from '../targets/session-memory-core.mjs';
17
19
  import { agentPath } from '../targets/agents.js';
20
+ import { projectOptOuts } from '../targets/opt-outs.js';
21
+ import { versionReport } from './lib/versions.js';
22
+
23
+ // Gates the committed manifest says the team disarmed, still armed here. Read through the same
24
+ // partition the installer uses, so a machine-only switch somebody's older rsc wrote into the
25
+ // manifest (`harness`, `context7`) is not reported as something to converge on.
26
+ function optOutsNotApplied(root) {
27
+ let manifest = null;
28
+ try { manifest = readProjectManifest(root); } catch { return []; }
29
+ return projectOptOuts(manifest?.optOuts).filter((n) => !existsSync(join(root, '.rsc', `.no-${n}`)));
30
+ }
18
31
 
19
32
  // The sello's health, surfaced where the user already looks (spec: non-blocking
20
33
  // findings live in the project and are SUMMARIZED here, never nagged about).
@@ -79,6 +92,75 @@ export function missingHookScripts({ target, home = homedir(), cwd = process.cwd
79
92
  return [...seen];
80
93
  }
81
94
 
95
+ // Is the post-merge trigger actually armed in this clone? Three states, because they need three
96
+ // different answers: armed, absent (repair it), or someone else's (leave it — chaining is our job
97
+ // at install time, and overwriting a husky hook to fix a convenience would be indefensible).
98
+ function worktreeCleanupStatus(root) {
99
+ if (!existsSync(join(root, '.git'))) return { state: 'not-a-repo' };
100
+ if (existsSync(join(root, '.rsc', '.no-worktree-cleanup'))) return { state: 'off-by-choice' };
101
+ // Ask git where hooks live instead of assuming `.git/hooks`. Until 2.0.1 this read the assumed
102
+ // path, so on a repo with its own `core.hooksPath` it found our inert file there and answered
103
+ // `armed` about a hook git would never run — the check confirming the exact failure it exists to
104
+ // catch. `unreachable` is its own state on purpose: `repair` re-runs the same install, so
105
+ // recommending it here would send the reader round the same loop (P6 wants a way out, not a ring).
106
+ const dir = hooksDir(root);
107
+ if (!hooksDirIsOurs(root, dir)) {
108
+ return {
109
+ state: 'unreachable',
110
+ 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.`,
111
+ };
112
+ }
113
+ const hook = join(dir, 'post-merge');
114
+ if (!existsSync(hook)) {
115
+ return { state: 'absent', action: 'Run `npx @damiangilgonzalez/harness repair` to arm the post-merge cleanup in this clone.' };
116
+ }
117
+ try {
118
+ const armed = readFileSync(hook, 'utf8').includes('rsc-managed worktree cleanup');
119
+ return armed
120
+ ? { state: 'armed' }
121
+ : { state: 'foreign', action: 'A post-merge hook from another tool is in place; `npx @damiangilgonzalez/harness repair` chains ours behind it.' };
122
+ } catch { return { state: 'unreadable' }; }
123
+ }
124
+
125
+ // Every skill this catalog ships declares its origin in its own frontmatter: `risco` for the skills
126
+ // inherited from the original catalog, `damiangil` for the ones this fork added. So "is this ours?"
127
+ // is answerable from the file itself, and does not need the list in
128
+ // `.rsc.json` that nobody ever filled: that list is parallel accounting (P3), it has been empty in
129
+ // every project since it was introduced, and `doctor` has been faithfully reporting on the emptiness.
130
+ //
131
+ // Failing towards "the user's" is deliberate. Reading a catalog skill as the user's leaves a file
132
+ // that stops being updated; reading the user's as the catalog's invites something to overwrite it.
133
+ // Only one of those loses work.
134
+ const CATALOG_ORIGINS = new Set(['risco', 'damiangil']);
135
+
136
+ function ownSkillsIn(paths, declared = []) {
137
+ const present = [];
138
+ let dir;
139
+ try { dir = dirname(paths.skillDir('_')); } catch { return { present, missing: [], declared }; }
140
+ let entries = [];
141
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { /* nothing installed here yet */ }
142
+ for (const entry of entries) {
143
+ // Symlinks included, deliberately. rsc installs a catalog skill AS a symlink into `.rsc/skills/`
144
+ // and a hand-written one is a real directory, so the entry type is itself a provenance signal —
145
+ // and skipping symlinks would have made the frontmatter check below dead code that still looked
146
+ // like it worked. It did: a mutant that removed the check passed every test until this line was
147
+ // fixed. Both signals are read now, and the frontmatter is the one that decides.
148
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
149
+ const md = join(dir, entry.name, 'SKILL.md');
150
+ if (!existsSync(md)) continue;
151
+ let origin = null;
152
+ try {
153
+ const head = readFileSync(md, 'utf8').slice(0, 2048);
154
+ origin = (/^origin:\s*(\S+)\s*$/m.exec(head) || [])[1] || null;
155
+ } catch { continue; }
156
+ if (!CATALOG_ORIGINS.has(origin)) present.push(entry.name);
157
+ }
158
+ // Declared-but-absent stays meaningful for anyone who did fill the list by hand, and it is the
159
+ // only half a derivation cannot cover: a file that is gone cannot describe itself.
160
+ const missing = declared.filter((name) => !existsSync(paths.skillDir(name)));
161
+ return { present: present.sort(), missing, declared };
162
+ }
163
+
82
164
  export function doctor({ target, home, cwd }) {
83
165
  const root = cwd || process.cwd();
84
166
  const paths = targetPaths(target, home, cwd);
@@ -134,6 +216,11 @@ export function doctor({ target, home, cwd }) {
134
216
  latest: backups[0]?.id || null,
135
217
  },
136
218
  contextBudget: contextBudget({ target, home, cwd }),
219
+ // The worktree cleanup's trigger is a git hook, and `.git/hooks/` is not cloned. So a repo can
220
+ // carry a perfectly healthy harness and still never clean up, on every machine but the one that
221
+ // ran the installer. Reported, never nagged about: `repair` is what puts it back (P6).
222
+ worktreeCleanup: worktreeCleanupStatus(root),
223
+ ownSkills: ownSkillsIn(paths, readProjectManifest(root)?.ownSkills || []),
137
224
  sello: selloStatus(root),
138
225
  // Whether this harness has a design identity at all. `design` has always DECLARED that it
139
226
  // stops without one; until lib/design-identity.js nothing checked it (P2). Reported here,
@@ -148,6 +235,14 @@ export function doctor({ target, home, cwd }) {
148
235
  gitmojiGuard: state.policy?.gitmojiGuard === false
149
236
  ? 'deferred'
150
237
  : existsSync(join(root, '.rsc', '.no-gitmoji')) ? 'opted-out' : 'armed',
238
+ // The reverse of the same honesty, one level up: a gate the TEAM disarmed in the committed
239
+ // manifest and that is still armed on this machine. Nothing is applied from here — a pull
240
+ // never rewrites somebody's machine — so the only thing owed is saying it out loud, and
241
+ // saying it every run, because a notice offered once per session is a notice missed.
242
+ optOutsNotApplied: optOutsNotApplied(root),
243
+ // First thing to read on any bug report: a project behind its CLI runs old hooks, and "it is
244
+ // already fixed" only helps once the fix is in `.rsc/`.
245
+ versions: versionReport(root),
151
246
  // Counted, never interpreted — by spec, the gap log's reader is the user.
152
247
  automationGaps: countGaps(root),
153
248
  memory,
@@ -383,6 +478,18 @@ export function contextBudget({ target, home = homedir(), cwd = process.cwd() }
383
478
  });
384
479
  }
385
480
  const drift = divergence({ cwd, target, home });
481
+ // A separate finding, not a fourth clause of the one below: that one says this checkout
482
+ // is behind its own team, and the fix is to align with the declaration. This one says the
483
+ // declaration itself is behind the catalog, so aligning with it changes nothing. Folded
484
+ // together, the report would name the right skill and the wrong reason.
485
+ if (drift.floorMissing?.length) {
486
+ findings.push({
487
+ id: 'floor-missing',
488
+ severity: 'high',
489
+ 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.`,
490
+ action: 'Run `npx @damiangilgonzalez/harness sync` — it adds what the catalog requires and touches nothing else.',
491
+ });
492
+ }
386
493
  if (drift.missing.length || drift.extra.length || drift.ownMissing.length) {
387
494
  // The day-two case: a teammate changed the harness and this checkout has not caught
388
495
  // up. Reported always, even after someone declines to align — the divergence does not
@@ -2,13 +2,17 @@
2
2
  import { rmSync, existsSync, cpSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, appendFileSync, readdirSync } from 'node:fs';
3
3
  import { join, dirname, relative, resolve, sep } from 'node:path';
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ import { spawnSync } from 'node:child_process';
5
6
  import { planInstall } from './install-plan.js';
6
7
  import { targetPaths, writeSkill, wireHook, unwireHook, baseDir, TARGET_IDS } from '../targets/index.js';
8
+ import { shadowTarget } from '../targets/agents-md-shadow.js';
7
9
  import {
8
10
  targetHasAgents, reconcileAgents, agentPath, agentNames,
9
- resolveAgentNames, agentByName, allAgentNames, readDeveloperTier,
11
+ resolveAgentNames, agentByName, allAgentNames, readDeveloperTier, writeDeveloperTier,
10
12
  } from '../targets/agents.js';
13
+ import { projectOptOuts } from '../targets/opt-outs.js';
11
14
  import { readState, writeState } from './lib/state.js';
15
+ import { withDefaultSkillFloor } from './lib/default-skill-floor.js';
12
16
  import { readManifest, writeManifest } from './lib/manifest-file.js';
13
17
  import { createBackup } from './lib/backups.js';
14
18
  import {
@@ -66,6 +70,9 @@ function ensureBase(id, cwd, baseVersions) {
66
70
  export function generatedHookFiles({ target, cwd, policy }) {
67
71
  if (target !== 'claude') return [];
68
72
  const lifecycle = [
73
+ // Committed, not machine state — and therefore the one entry here that, if omitted, leaves an
74
+ // executable behind in a tree the user pushes.
75
+ join(cwd, '.claude', 'rsc-bootstrap.mjs'),
69
76
  join(cwd, '.rsc', 'session-start.mjs'),
70
77
  join(cwd, '.rsc', 'worklog-checkpoint.mjs'),
71
78
  join(cwd, '.rsc', 'hook-once.mjs'),
@@ -109,6 +116,12 @@ export function managedPathsForInstall({ skillIds, agentIds = [], target, home,
109
116
  out.push(step.to, ...generatedHookFiles({ target, cwd, policy }));
110
117
  }
111
118
  }
119
+ // The shadow CLAUDE.md that keeps a natively-read AGENTS.md from doubling the always-on body.
120
+ // Written by whichever of the two adapters is wired second, so it is declared for both families,
121
+ // not just for claude. A claude install must assume its own wiring: settings.json does not name
122
+ // it yet at plan time.
123
+ const shadow = shadowTarget(cwd, { assumeClaudeWired: target === 'claude' });
124
+ if (shadow) out.push(shadow);
112
125
  return [...new Set(out)];
113
126
  }
114
127
 
@@ -116,17 +129,56 @@ export function managedPathsForInstall({ skillIds, agentIds = [], target, home,
116
129
  // which is machine-local and therefore lost on every clone: a team that disarmed the
117
130
  // gitmoji guard found it armed again on the next checkout, with nobody having decided
118
131
  // that. They are decisions, so they belong in the committed declaration.
132
+ //
133
+ // Two distinctions this function exists to keep, both of which used to be collapsed:
134
+ //
135
+ // ABSENT is not EMPTY. In a clone `.rsc/` does not exist, and reading "no markers here" as
136
+ // "the team re-armed everything" would have sync quietly delete the decision it came to
137
+ // rebuild. Unreadable returns null — "I know nothing" — and the caller keeps what was
138
+ // declared. A readable directory with no markers returns [], which IS a statement: somebody
139
+ // deleted the last marker, and deleting it is how a gate gets re-armed.
140
+ //
141
+ // Not every switch is the team's to make. `.no-harness` and friends are decisions of one
142
+ // machine (see targets/opt-outs.js), and committing them would impose one laptop's answer on
143
+ // every future clone. They stay local, and never reach the declaration.
119
144
  function localDecisions(cwd) {
120
145
  const dir = join(cwd, '.rsc');
121
- let optOuts = [];
146
+ let optOuts = null;
122
147
  try {
123
- optOuts = readdirSync(dir).filter((f) => f.startsWith('.no-')).map((f) => f.slice(4)).sort();
124
- } catch { /* no .rsc yet */ }
148
+ optOuts = projectOptOuts(readdirSync(dir).filter((f) => f.startsWith('.no-')).map((f) => f.slice(4)));
149
+ } catch { /* no .rsc yet → null, meaning "unknown", never "none" */ }
125
150
  let tier = null;
126
151
  try { tier = JSON.parse(readFileSync(join(dir, 'developer.json'), 'utf8')).tier ?? null; } catch { /* unset */ }
127
152
  return { optOuts, tier };
128
153
  }
129
154
 
155
+ // The other half of the same promise: what the declaration says is rebuilt as the real local
156
+ // state a hook can see. Hooks are materialized standalone under `.rsc/` and decide with one
157
+ // `existsSync` — they cannot read the manifest, and making them read it would let a `git pull`
158
+ // change how somebody's session behaves in silence, which `project-manifest` ruled out on
159
+ // purpose. So the manifest is applied HERE, inside the install the person chose to run.
160
+ //
161
+ // Only ever on a machine where `.rsc/` did not exist: there, "no marker" carries no information
162
+ // and the declaration is all there is. Where `.rsc/` is already built, the local state is the
163
+ // authority — otherwise the next sync would silently undo a re-arm, and the gate coming back
164
+ // would look like a bug in the guard rather than a decision nobody made.
165
+ export function hydrateLocalDecisions(cwd, manifest) {
166
+ const applied = { optOuts: [], tier: null };
167
+ if (!manifest) return applied;
168
+ const dir = join(cwd, '.rsc');
169
+ for (const name of projectOptOuts(manifest.optOuts)) {
170
+ const file = join(dir, `.no-${name}`);
171
+ if (existsSync(file)) continue;
172
+ mkdirSync(dir, { recursive: true });
173
+ writeFileSync(file, `Declared in .rsc.json by this project; rebuilt here by rsc ${CLI_VERSION}.\n`);
174
+ applied.optOuts.push(name);
175
+ }
176
+ if (manifest.tier && !existsSync(join(dir, 'developer.json'))) {
177
+ applied.tier = writeDeveloperTier(cwd, manifest.tier);
178
+ }
179
+ return applied;
180
+ }
181
+
130
182
  // Record the decision, merging so a second assistant never erases the first: installing
131
183
  // into codex on a machine that already had claude is adding, not replacing. Union, sorted
132
184
  // where order carries no meaning, so the file stays diffable and a merge conflict stays
@@ -146,17 +198,25 @@ export function recordInManifest({ cwd, target, skillIds, agentIds = [], catalog
146
198
  ownSkills: prev.ownSkills || [],
147
199
  catalogVersion,
148
200
  tier: tier ?? prev.tier ?? null,
149
- optOuts: optOuts.length ? optOuts : (prev.optOuts || []),
201
+ optOuts: optOuts ?? projectOptOuts(prev.optOuts),
150
202
  memory: prev.memory,
151
203
  onboarding: onboarding ?? prev.onboarding,
152
204
  });
153
205
  }
154
206
 
155
207
  export async function applyInstall({ skillIds = [], agentIds = [], target, home, cwd = process.cwd(), operation = 'install', dryRun = false, policy, onboarding }) {
208
+ // Read BEFORE anything can create it — the backup writes under `.rsc/`, and one line later the
209
+ // answer would always be "yes, it exists". This single boolean is what separates a clone (the
210
+ // declaration is the only thing that knows what the team decided) from a built machine (the
211
+ // local markers are the authority, and a pull must not overwrite them).
212
+ const fromScratch = !existsSync(join(cwd, '.rsc'));
156
213
  const paths = targetPaths(target, home, cwd);
157
214
  const plan = planInstall({ skillIds, target, home, cwd, hooks: policy?.alwaysOn !== false });
158
215
  const managedPaths = managedPathsForInstall({ skillIds, agentIds, target, home, cwd, policy });
159
216
  if (dryRun) return { dryRun: true, skills: skillIds, agents: agentIds, paths: managedPaths };
217
+ // Rebuild the declared decisions first, so everything below (the agent tier especially) reads
218
+ // the state the team declared rather than the default it would otherwise assume.
219
+ if (fromScratch) hydrateLocalDecisions(cwd, readManifest(cwd));
160
220
  const state = readState(paths.stateFile);
161
221
  const backup = createBackup({ cwd, operation, target, paths: managedPaths, cliVersion: CLI_VERSION });
162
222
  // Decide base refresh per skill (see baseVersionsFile): a base is re-materialized when
@@ -237,8 +297,30 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
237
297
  mkdirSync(dirname(versionFile(cwd)), { recursive: true });
238
298
  writeFileSync(versionFile(cwd), CLI_VERSION + '\n');
239
299
  recordInManifest({ cwd, target, skillIds, agentIds, onboarding });
300
+ // The worktree cleanup's trigger. It lives in `.git/hooks/`, which is NOT cloned, so it has to be
301
+ // (re)written by every operation that touches a user's repo — install, sync and repair all land
302
+ // here. Target-agnostic on purpose: it is a git hook, not an assistant hook, and a cleanup that
303
+ // only worked for one of the sixteen assistants would be a cleanup nobody could rely on. The
304
+ // reaper travels with it for the same reason; until now only the Claude adapter materialized it,
305
+ // although `managedPathsForInstall` has always declared it for every target.
306
+ // Only where the accepted plan governs code hooks. An operations harness — company, research,
307
+ // content — declares a narrower set of paths, and writing a git hook it never agreed to would be
308
+ // acting outside the plan the user accepted (P4). Caught by the onboarding route-inventory gate,
309
+ // which is exactly the kind of catch that justifies its existence.
310
+ // The answer used to be discarded, so a hook that could not arm looked exactly like one that
311
+ // did — and the only place that could have noticed, `doctor`, was reading the same wrong path.
312
+ // Reported 2026-09-18. Carried out, never thrown: the harness installing is the point, and the
313
+ // merge hook is a convenience layered on top of it.
314
+ let mergeHook = { installed: false, state: 'skipped' };
315
+ try {
316
+ if (policy?.codeHooks === false) throw new Error('code hooks not governed by the accepted plan');
317
+ mkdirSync(join(cwd, '.rsc'), { recursive: true });
318
+ cpSync(join(ROOT, 'targets', 'worktree-reaper.mjs'), join(cwd, '.rsc', 'worktree-reaper.mjs'));
319
+ const { installMergeHook } = await import('../targets/worktree-reaper.mjs');
320
+ if (existsSync(join(cwd, '.git'))) mergeHook = installMergeHook(cwd);
321
+ } catch (err) { mergeHook = { installed: false, state: 'skipped', reason: err.message }; }
240
322
  ignoreLocalState(cwd, target);
241
- return { ...state, backup };
323
+ return { ...state, backup, mergeHook };
242
324
  }
243
325
 
244
326
  // `.rsc/` holds local machine state — hook scripts, install markers, the sello's
@@ -287,6 +369,32 @@ export function ignoreLocalState(cwd = process.cwd(), target) {
287
369
  // keeps this idempotent against a .gitignore a human wrote in their own spelling.
288
370
  const norm = (l) => l.trim().replace(/^\//, '').replace(/\/$/, '');
289
371
  const present = new Set(text.split('\n').map(norm));
372
+ // `.rsc/` is machine state and stays ignored. The two files below are the opposite: they are the
373
+ // whole reason a clone can help itself, and a project that excluded the assistant's directory
374
+ // wholesale (rsc's own repo does exactly that, so copying the example is likely) swallows both with
375
+ // no symptom at all.
376
+ //
377
+ // Asked of GIT, not of the pattern. The first attempt at this reasoned about spellings and shipped
378
+ // a negation that was INERT in every case it fired: git does not descend into an excluded
379
+ // directory, so `!.claude/rsc-bootstrap.mjs` under `.claude/` rescues nothing. Measured, not
380
+ // argued — and the lesson is the one this repo keeps relearning, that a rule believed and untrue is
381
+ // worse than no rule.
382
+ const RESCUE = ['.claude/rsc-bootstrap.mjs', '.claude/settings.json'];
383
+ const swallowed = RESCUE.filter((f) => {
384
+ try {
385
+ return spawnSync('git', ['check-ignore', '-q', '--', f], { cwd }).status === 0;
386
+ } catch {
387
+ return false;
388
+ }
389
+ });
390
+ if (swallowed.length) {
391
+ // Append-only, and this exact order is what makes it work at all: re-include the directory so git
392
+ // will descend into it, exclude everything inside it again, then rescue by name. Verified against
393
+ // git itself — `settings.local.json` and the skill entries stay ignored.
394
+ const dir = swallowed[0].split('/')[0];
395
+ for (const line of [`!${dir}/`, `${dir}/*`, ...swallowed.map((f) => `!${f}`)]) wanted.push(line);
396
+ }
397
+
290
398
  const add = wanted.filter((w) => !present.has(norm(w)));
291
399
  if (!add.length) return null;
292
400
 
@@ -482,7 +590,19 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
482
590
  const ids = Object.keys(state.skills || {});
483
591
  const manifest = readManifest(cwd);
484
592
  const governedSkills = manifest?.onboarding?.plan?.policy?.skills;
485
- const declared = governedSkills || (ids.length ? ids : (manifest?.skills || []));
593
+ // The accepted plan is a FLOOR, not a ceiling. Reading it as the whole declaration is what deleted
594
+ // users' skills: `add` recorded the new skill in `manifest.skills` — the living list — and sync
595
+ // then rebuilt from a governed list that predated it and pruned the difference, recursively.
596
+ // Reported by a user and reproduced 2026-09-17. The receipt stays untouched, because it is
597
+ // hash-checked against what the user accepted and is not a log of what happened since.
598
+ const declaredRaw = governedSkills
599
+ ? [...new Set([...governedSkills, ...(manifest?.skills || [])])].sort()
600
+ : (ids.length ? ids : (manifest?.skills || []));
601
+ // A declaration is frozen at the moment it was written, so a skill the catalog later makes
602
+ // mandatory never reaches an existing harness: `add` and `install` apply the floor, sync did
603
+ // not, and sync is the only one an upgrade runs. Applied only to a harness that already declares
604
+ // something: a directory declaring nothing is not a harness missing its floor.
605
+ const declared = declaredRaw.length ? withDefaultSkillFloor(declaredRaw) : declaredRaw;
486
606
  const declaredAgents = state.explicitAgents?.length ? state.explicitAgents : (manifest?.agents || []);
487
607
  if (!declared.length && !declaredAgents.length) return dryRun ? { dryRun: true, synced: [], syncedAgents: [], paths: [] } : { synced: [], syncedAgents: [], backup: null };
488
608
  if (dryRun) {
@@ -44,6 +44,12 @@ function agentSpecFor(target, root) {
44
44
  return { dir: dirname(p), ext };
45
45
  }
46
46
 
47
+ // A real file, or a link that leads to one. `statSync` follows; a dangling link throws and is
48
+ // therefore not a file, which is the answer we want.
49
+ function isFile(path) {
50
+ try { return statSync(path).isFile(); } catch { return false; }
51
+ }
52
+
47
53
  // Real files only. Directories (a dir named `foo.md` is not an agent), dangling
48
54
  // symlinks and dotfiles are not capabilities.
49
55
  function entriesWithExt(dir, ext) {
@@ -96,8 +102,19 @@ export function listSkills({ target, home, cwd = process.cwd() } = {}) {
96
102
  try {
97
103
  for (const e of readdirSync(paths.dir, { withFileTypes: true })) {
98
104
  if (e.name.startsWith('.')) continue;
99
- if (ext) { if (e.isFile() && e.name.endsWith(ext)) out.push({ id: e.name.slice(0, -ext.length), scope }); }
100
- else if (e.isDirectory() && existsSync(join(paths.dir, e.name, 'SKILL.md'))) out.push({ id: e.name, scope });
105
+ // Ask what a skill IS, never how it was put on disk. A `Dirent` from `withFileTypes`
106
+ // describes the LINK and not its target, so `isDirectory()`/`isFile()` are both false for
107
+ // every skill on macOS and Linux — where a symlink per assistant, pointing at one shared
108
+ // copy, is the layout the installer deliberately writes. Windows gets real files (relative
109
+ // dir symlinks need Developer Mode there), which is why this read as correct for so long
110
+ // and why CI never caught it.
111
+ //
112
+ // `existsSync` and `statSync` follow links on their own, so one question answers all three
113
+ // platforms with no branch: is there a SKILL.md behind this name? Following a link is not
114
+ // the same as believing anything — a dangling link, a directory with no SKILL.md and a
115
+ // plain file all still fail it.
116
+ if (ext) { if (e.name.endsWith(ext) && isFile(join(paths.dir, e.name))) out.push({ id: e.name.slice(0, -ext.length), scope }); }
117
+ else if (existsSync(join(paths.dir, e.name, 'SKILL.md'))) out.push({ id: e.name, scope });
101
118
  }
102
119
  } catch { /* unreadable scope → contributes nothing */ }
103
120
  }
@@ -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
  }
@@ -4,6 +4,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { applyInstall, pruneSharedBases, removeTargetInstall } from '../install-apply.js';
6
6
  import { targetPaths } from '../../targets/index.js';
7
+ import { targetHasAgents } from '../../targets/agents.js';
7
8
  import { readState } from './state.js';
8
9
  import { readManifest, writeManifest } from './manifest-file.js';
9
10
  import { encodeGoal, identifyPlan } from './onboarding.js';
@@ -11,6 +12,22 @@ import { createBackup, restoreBackup } from './backups.js';
11
12
 
12
13
  const sameSet = (a = [], b = []) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
13
14
 
15
+ /**
16
+ * What THIS target should be holding, which is not the same question as what the project decided.
17
+ *
18
+ * The policy is the PROJECT's: "these are the agents this project wants." Whether an assistant can
19
+ * hold a subagent at all is the TARGET's business, and nine of the seventeen cannot — amp, jules,
20
+ * zed, antigravity, windsurf, cline, roo, continue and aider have no such concept. The installer
21
+ * always knew this and correctly wrote none; the verifier did not, and demanded the project's list
22
+ * from every target, so onboarding could never complete on any of the nine. Since the failure rolls
23
+ * back, what the person got was empty directory shells and no harness — with a recovery command
24
+ * that leads straight back to the same wall.
25
+ *
26
+ * Asking amp for a subagent is not a divergence. It is a category error, and this is where the two
27
+ * categories stop being confused.
28
+ */
29
+ export const expectedAgentsFor = (target, plan) => (targetHasAgents(target) ? (plan.policy.agents || []) : []);
30
+
14
31
  function inside(root, path) {
15
32
  return path === root || path.startsWith(`${root}${sep}`);
16
33
  }
@@ -92,7 +109,7 @@ export function verifyOnboarding(cwd, plan, planId) {
92
109
  for (const target of plan.policy.targets) {
93
110
  const state = readState(targetPaths(target, undefined, cwd).stateFile);
94
111
  if (!sameSet(Object.keys(state.skills || {}), plan.policy.skills)) differences.push(`${target}: installed skills differ from accepted policy`);
95
- if (!sameSet(state.agents || [], plan.policy.agents || [])) differences.push(`${target}: installed agents differ from accepted policy`);
112
+ if (!sameSet(state.agents || [], expectedAgentsFor(target, plan))) differences.push(`${target}: installed agents differ from accepted policy`);
96
113
  if (state.policy?.alwaysOn !== plan.policy.alwaysOn) differences.push(`${target}: always-on policy differs`);
97
114
  if (state.policy?.codeHooks !== plan.policy.codeHooks) differences.push(`${target}: code-hook policy differs`);
98
115
  if (state.policy?.memory !== plan.policy.memory) differences.push(`${target}: memory policy differs`);
@@ -4,6 +4,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path';
4
4
  import { TARGET_IDS } from '../../targets/index.js';
5
5
  import { resolveAgentNames } from '../../targets/agents.js';
6
6
  import { loadManifest, skillsForProfile } from './manifest.js';
7
+ import { readManifest } from './manifest-file.js';
7
8
  import { managedPathsForInstall } from '../install-apply.js';
8
9
 
9
10
  export const ONBOARDING_SCHEMA_VERSION = 1;
@@ -68,7 +69,17 @@ export function scanProject(root = process.cwd()) {
68
69
  let markdownCount = 0;
69
70
  let sourceFileCount = 0;
70
71
  const visit = (dir) => {
71
- for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
72
+ // A directory we are not allowed to open is a fact about that directory, not a reason to abandon
73
+ // the install. Reported from a real Windows box: the global harness died walking `Temp\WinSAT`,
74
+ // which Windows protects, on 1.3.2 and again on 1.3.6 — so it had never worked there at all.
75
+ // The safe form already existed in `detect-repo.js` and was simply not reused here.
76
+ let entries;
77
+ try {
78
+ entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
79
+ } catch {
80
+ return;
81
+ }
82
+ for (const entry of entries) {
72
83
  if (ignored.has(entry.name) || entry.isSymbolicLink()) continue;
73
84
  const path = join(dir, entry.name);
74
85
  const rel = relative(absolute, path).split(sep).join('/');
@@ -169,7 +180,21 @@ export function buildOnboardingPlan(record, evidence) {
169
180
  const catalog = loadManifest();
170
181
  const catalogIds = new Set(catalog.skills.map((skill) => skill.id));
171
182
  const detectedSkills = (evidence.stacks || []).filter((stack) => catalogIds.has(stack));
172
- const skills = [...new Set([...skillsForProfile(catalog, profile), ...detectedSkills])].sort();
183
+ // Skills the user asked for BY NAME are kept across an update; skills a profile handed out are not.
184
+ //
185
+ // Without this, an update recomputed the set from the profile and the detected stacks alone, and
186
+ // the install step then deleted from disk whatever was not in that fresh set — every `rsc add`,
187
+ // on every update. But choosing a narrower scope on purpose still has to be able to REMOVE what the
188
+ // wider one installed, so the difference comes from where the skill came from: a profile skill
189
+ // declares its profiles, and a skill you can only get by naming it declares none. What a profile
190
+ // gave you, a profile may take away; what you asked for by name, only you may. Filtered against the
191
+ // catalog on the way in, because preserving a declaration is not the same as trusting it.
192
+ //
193
+ // `readManifest` returns null on a first install, which is the most common path of all.
194
+ const inAnyProfile = new Set(catalog.skills.filter((s) => (s.profiles || []).length).map((s) => s.id));
195
+ const declared = (readManifest(evidence.root ?? process.cwd())?.skills ?? [])
196
+ .filter((id) => catalogIds.has(id) && !inAnyProfile.has(id));
197
+ const skills = [...new Set([...skillsForProfile(catalog, profile), ...detectedSkills, ...declared])].sort();
173
198
  const baseAgents = needsWorkflow;
174
199
  const hooks = needsWorkflow;
175
200
  const agents = baseAgents ? resolveAgentNames(skills, []).sort() : [];