@ericrisco/rsc 2.0.4 → 2.0.6

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.4",
2
+ "version": "2.0.6",
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.4",
3
+ "version": "2.0.6",
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
@@ -17,6 +17,16 @@ import { resolveCommands, commandPath, targetHasCommands } from '../targets/comm
17
17
  import { inspectMemoryWiring } from '../targets/memory.js';
18
18
  import { metricsSummary } from '../targets/session-memory-core.mjs';
19
19
  import { agentPath } from '../targets/agents.js';
20
+ import { projectOptOuts } from '../targets/opt-outs.js';
21
+
22
+ // Gates the committed manifest says the team disarmed, still armed here. Read through the same
23
+ // partition the installer uses, so a machine-only switch somebody's older rsc wrote into the
24
+ // manifest (`harness`, `context7`) is not reported as something to converge on.
25
+ function optOutsNotApplied(root) {
26
+ let manifest = null;
27
+ try { manifest = readProjectManifest(root); } catch { return []; }
28
+ return projectOptOuts(manifest?.optOuts).filter((n) => !existsSync(join(root, '.rsc', `.no-${n}`)));
29
+ }
20
30
 
21
31
  // The sello's health, surfaced where the user already looks (spec: non-blocking
22
32
  // findings live in the project and are SUMMARIZED here, never nagged about).
@@ -223,6 +233,11 @@ export function doctor({ target, home, cwd }) {
223
233
  gitmojiGuard: state.policy?.gitmojiGuard === false
224
234
  ? 'deferred'
225
235
  : existsSync(join(root, '.rsc', '.no-gitmoji')) ? 'opted-out' : 'armed',
236
+ // The reverse of the same honesty, one level up: a gate the TEAM disarmed in the committed
237
+ // manifest and that is still armed on this machine. Nothing is applied from here — a pull
238
+ // never rewrites somebody's machine — so the only thing owed is saying it out loud, and
239
+ // saying it every run, because a notice offered once per session is a notice missed.
240
+ optOutsNotApplied: optOutsNotApplied(root),
226
241
  // Counted, never interpreted — by spec, the gap log's reader is the user.
227
242
  automationGaps: countGaps(root),
228
243
  memory,
@@ -5,10 +5,12 @@ import { fileURLToPath } from 'node:url';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { planInstall } from './install-plan.js';
7
7
  import { targetPaths, writeSkill, wireHook, unwireHook, baseDir, TARGET_IDS } from '../targets/index.js';
8
+ import { shadowTarget } from '../targets/agents-md-shadow.js';
8
9
  import {
9
10
  targetHasAgents, reconcileAgents, agentPath, agentNames,
10
- resolveAgentNames, agentByName, allAgentNames, readDeveloperTier,
11
+ resolveAgentNames, agentByName, allAgentNames, readDeveloperTier, writeDeveloperTier,
11
12
  } from '../targets/agents.js';
13
+ import { projectOptOuts } from '../targets/opt-outs.js';
12
14
  import { readState, writeState } from './lib/state.js';
13
15
  import { withDefaultSkillFloor } from './lib/default-skill-floor.js';
14
16
  import { readManifest, writeManifest } from './lib/manifest-file.js';
@@ -114,6 +116,12 @@ export function managedPathsForInstall({ skillIds, agentIds = [], target, home,
114
116
  out.push(step.to, ...generatedHookFiles({ target, cwd, policy }));
115
117
  }
116
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);
117
125
  return [...new Set(out)];
118
126
  }
119
127
 
@@ -121,17 +129,56 @@ export function managedPathsForInstall({ skillIds, agentIds = [], target, home,
121
129
  // which is machine-local and therefore lost on every clone: a team that disarmed the
122
130
  // gitmoji guard found it armed again on the next checkout, with nobody having decided
123
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.
124
144
  function localDecisions(cwd) {
125
145
  const dir = join(cwd, '.rsc');
126
- let optOuts = [];
146
+ let optOuts = null;
127
147
  try {
128
- optOuts = readdirSync(dir).filter((f) => f.startsWith('.no-')).map((f) => f.slice(4)).sort();
129
- } 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" */ }
130
150
  let tier = null;
131
151
  try { tier = JSON.parse(readFileSync(join(dir, 'developer.json'), 'utf8')).tier ?? null; } catch { /* unset */ }
132
152
  return { optOuts, tier };
133
153
  }
134
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
+
135
182
  // Record the decision, merging so a second assistant never erases the first: installing
136
183
  // into codex on a machine that already had claude is adding, not replacing. Union, sorted
137
184
  // where order carries no meaning, so the file stays diffable and a merge conflict stays
@@ -151,17 +198,25 @@ export function recordInManifest({ cwd, target, skillIds, agentIds = [], catalog
151
198
  ownSkills: prev.ownSkills || [],
152
199
  catalogVersion,
153
200
  tier: tier ?? prev.tier ?? null,
154
- optOuts: optOuts.length ? optOuts : (prev.optOuts || []),
201
+ optOuts: optOuts ?? projectOptOuts(prev.optOuts),
155
202
  memory: prev.memory,
156
203
  onboarding: onboarding ?? prev.onboarding,
157
204
  });
158
205
  }
159
206
 
160
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'));
161
213
  const paths = targetPaths(target, home, cwd);
162
214
  const plan = planInstall({ skillIds, target, home, cwd, hooks: policy?.alwaysOn !== false });
163
215
  const managedPaths = managedPathsForInstall({ skillIds, agentIds, target, home, cwd, policy });
164
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));
165
220
  const state = readState(paths.stateFile);
166
221
  const backup = createBackup({ cwd, operation, target, paths: managedPaths, cliVersion: CLI_VERSION });
167
222
  // Decide base refresh per skill (see baseVersionsFile): a base is re-materialized when
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import { linkOrCopy } from './index.js';
4
+ import { ensureShadowClaudeMd } from './agents-md-shadow.js';
4
5
 
5
6
  // Shared adapter for every assistant whose "always-on" surface is a plain
6
7
  // markdown instructions/rules file (AGENTS.md, copilot-instructions.md, a
@@ -33,7 +34,13 @@ export function wireHook(paths, sourceMd, policy = {}) {
33
34
  }
34
35
  mkdirSync(dirname(paths.hookTarget), { recursive: true });
35
36
  writeFileSync(paths.hookTarget, doc);
36
- return [paths.hookTarget];
37
+
38
+ // This file may BE the root AGENTS.md, which Claude Code 2.1.277+ reads as project instructions
39
+ // when no CLAUDE.md exists — doubling an always-on body the SessionStart hook already prints.
40
+ // No-op unless the Claude Code target is also wired here. Called from both adapters so the fix
41
+ // holds whichever assistant the user installs second. See targets/agents-md-shadow.js.
42
+ const shadow = ensureShadowClaudeMd(paths.projectRoot);
43
+ return shadow ? [paths.hookTarget, shadow] : [paths.hookTarget];
37
44
  }
38
45
 
39
46
  // Inverse of wireHook: remove the marked rsc-suggest block from the shared
@@ -0,0 +1,153 @@
1
+ // agents-md-shadow.js — keep the always-on body from arriving twice in Claude Code.
2
+ //
3
+ // THE DEFECT. rsc delivers its always-on layer through two different surfaces, one per
4
+ // assistant family:
5
+ // - Claude Code → a SessionStart hook (targets/claude.js) that PRINTS suggest's body.
6
+ // - AGENTS.md family (codex, opencode, amp, jules, zed) → the body is written INTO the
7
+ // root AGENTS.md between markers (targets/_md-block.js).
8
+ // Wiring both in one project is an ordinary install — this repo's own workspace does it.
9
+ // Until Claude Code 2.1.277 the two never met, because Claude Code did not read AGENTS.md.
10
+ //
11
+ // From 2.1.277 it does: with NO CLAUDE.md in the working directory or above it, Claude Code
12
+ // reads the root AGENTS.md as the project instructions. In a dual-wired project that makes
13
+ // the same ~7 KB body land twice per session — once printed by the hook, once loaded from
14
+ // the file. Anthropic documents the shape of this exact collision and its remedy ("A
15
+ // SessionStart hook that prints AGENTS.md: remove it. Once Claude reads AGENTS.md directly,
16
+ // the hook adds a second copy to the context").
17
+ //
18
+ // WHY THIS IS NOT SOLVED BY MAKING THE HOOK STAY SILENT. The hook cannot know whether it is
19
+ // talking to a Claude Code that reads AGENTS.md: the SessionStart payload carries session_id,
20
+ // cwd, source and sometimes model — there is no version field, and no env var exposes one.
21
+ // Staying silent on a guess would, on every client older than 2.1.277, swallow the always-on
22
+ // layer completely. hook-once.mjs states the rule this file obeys: "A duplicated block costs
23
+ // context; a swallowed always-on layer costs the session its behaviour."
24
+ //
25
+ // THE FIX, version-free. Claude Code reads AGENTS.md only when no CLAUDE.md exists. So give
26
+ // it one. A CLAUDE.md at the project root makes every Claude Code — old or new — take the
27
+ // hook as the single source of the always-on body, and leaves AGENTS.md to the assistants it
28
+ // was written for. No version sniffing, no guess, same bytes on every client.
29
+ //
30
+ // The shadow deliberately does NOT `@AGENTS.md`-import: the import would expand the rsc block
31
+ // into context and recreate the duplication it exists to prevent.
32
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
33
+ import { dirname, join, parse } from 'node:path';
34
+
35
+ // The marker _md-block.js wraps the always-on body in. Its presence in a root AGENTS.md is
36
+ // what makes that file a second carrier of the body rather than the user's own prose.
37
+ const SUGGEST_MARK = '<!-- rsc-suggest:start -->';
38
+
39
+ // Identifies a CLAUDE.md as ours. Block-level HTML comments are stripped from Claude Code's
40
+ // context before injection, so everything explanatory below costs the user zero tokens.
41
+ export const SHADOW_MARK = '<!-- rsc:claude-md-shadow -->';
42
+
43
+ export const SHADOW_BODY = `${SHADOW_MARK}
44
+ # Project instructions
45
+
46
+ Claude Code reads this file. Every other assistant wired in this project reads \`AGENTS.md\`.
47
+
48
+ Project instructions for Claude Code go below.
49
+
50
+ <!--
51
+ Why rsc created this file
52
+ =========================
53
+ rsc's always-on layer reaches Claude Code through its SessionStart hook, and reaches the
54
+ AGENTS.md-family assistants through the root AGENTS.md. From Claude Code 2.1.277, a project
55
+ with no CLAUDE.md is read through AGENTS.md directly — which would deliver that same body a
56
+ second time, every session. The existence of this file is what prevents it.
57
+
58
+ Do NOT add "@AGENTS.md" here: the import would expand the always-on block into context and
59
+ bring the duplication back.
60
+
61
+ rsc removes this file on uninstall while it is still untouched. Edit it and it is yours —
62
+ rsc then leaves it alone, and it keeps doing its job just as well.
63
+ -->
64
+ `;
65
+
66
+ // The three files whose presence stops Claude Code reading AGENTS.md (per its memory docs).
67
+ // A user's own CLAUDE.md already does the shadow's job, so we never touch a project that has one.
68
+ const CLAUDE_MD_FORMS = ['CLAUDE.md', join('.claude', 'CLAUDE.md'), 'CLAUDE.local.md'];
69
+
70
+ // Claude Code looks for those files in the working directory AND every directory above it, so a
71
+ // CLAUDE.md two levels up already suppresses the AGENTS.md read. Walking up costs a few stats and
72
+ // keeps us from planting a file that was never needed.
73
+ function claudeMdAboveOrAt(root) {
74
+ let dir = root;
75
+ for (;;) {
76
+ for (const form of CLAUDE_MD_FORMS) if (existsSync(join(dir, form))) return join(dir, form);
77
+ const up = dirname(dir);
78
+ if (up === dir || dir === parse(dir).root) return null;
79
+ dir = up;
80
+ }
81
+ }
82
+
83
+ // Is the Claude Code target wired in this project? Same predicate session-start.mjs and doctor
84
+ // use: an rsc command in .claude/settings.json. The backslash normalization is not cosmetic —
85
+ // a Windows install stores the path with separators that JSON escapes as pairs, and reading only
86
+ // for `.rsc/` answered "not wired" on the exact platform where duplication hurt most.
87
+ function claudeWired(root) {
88
+ try {
89
+ return readFileSync(join(root, '.claude', 'settings.json'), 'utf8')
90
+ .replace(/\\\\/g, '/')
91
+ .includes('.rsc/');
92
+ } catch { return false; }
93
+ }
94
+
95
+ // Does the root AGENTS.md carry rsc's always-on block (as opposed to being the user's own prose)?
96
+ function agentsMdCarriesBody(root) {
97
+ try { return readFileSync(join(root, 'AGENTS.md'), 'utf8').includes(SUGGEST_MARK); } catch { return false; }
98
+ }
99
+
100
+ /**
101
+ * Write the shadow CLAUDE.md when — and only when — this project is in the configuration that
102
+ * duplicates: Claude Code wired, the root AGENTS.md carrying rsc's block, and no CLAUDE.md of
103
+ * any form at the root or above it.
104
+ *
105
+ * Called from BOTH adapters' wireHook. Installs run one target per call, so whichever of the two
106
+ * is wired second is the one that sees the full picture; calling from both is what makes the fix
107
+ * independent of the order the user picks their assistants in.
108
+ *
109
+ * @returns the path written, or null when nothing was needed. Never throws: a shadow we could not
110
+ * write costs context, and that must not be the thing that fails an install.
111
+ */
112
+ export function ensureShadowClaudeMd(projectRoot) {
113
+ try {
114
+ const dest = shadowTarget(projectRoot);
115
+ if (!dest) return null;
116
+ mkdirSync(dirname(dest), { recursive: true });
117
+ writeFileSync(dest, SHADOW_BODY);
118
+ return dest;
119
+ } catch { return null; }
120
+ }
121
+
122
+ /**
123
+ * Where the shadow would go, or null when this project does not need one — the decision without
124
+ * the write, so the installer can declare the file BEFORE it exists. A path this returns but the
125
+ * install plan omits is a file no --dry-run mentions and no `rsc restore` can bring back.
126
+ *
127
+ * @param assumeClaudeWired for a claude install in flight: settings.json names the project as
128
+ * wired only after wireHook writes it, so at plan time the answer has to be assumed, not read.
129
+ */
130
+ export function shadowTarget(projectRoot, { assumeClaudeWired = false } = {}) {
131
+ try {
132
+ if (!assumeClaudeWired && !claudeWired(projectRoot)) return null;
133
+ if (!agentsMdCarriesBody(projectRoot)) return null;
134
+ if (claudeMdAboveOrAt(projectRoot)) return null;
135
+ return join(projectRoot, 'CLAUDE.md');
136
+ } catch { return null; }
137
+ }
138
+
139
+ /**
140
+ * Take back exactly what we gave: remove the shadow only while it is byte-identical to what
141
+ * ensureShadowClaudeMd wrote. The moment the user edits it, it is their project instructions —
142
+ * uninstalling rsc must not delete those. Same discipline _md-block.js applies to its block.
143
+ *
144
+ * @returns the path removed, or null.
145
+ */
146
+ export function removeShadowClaudeMd(projectRoot) {
147
+ try {
148
+ const dest = join(projectRoot, 'CLAUDE.md');
149
+ if (readFileSync(dest, 'utf8') !== SHADOW_BODY) return null;
150
+ rmSync(dest, { force: true });
151
+ return dest;
152
+ } catch { return null; }
153
+ }
package/targets/claude.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, rmSyn
2
2
  import { dirname, join, relative, sep } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { linkOrCopy } from './index.js';
5
+ import { ensureShadowClaudeMd, removeShadowClaudeMd } from './agents-md-shadow.js';
5
6
 
6
7
  const HERE = dirname(fileURLToPath(import.meta.url));
7
8
 
@@ -52,11 +53,16 @@ export function unwireHook(paths) {
52
53
  rmSync(join(paths.projectRoot, '.claude', 'rsc-bootstrap.mjs'), { force: true });
53
54
  } catch { /* never let cleanup be the thing that fails */ }
54
55
 
56
+ // The shadow CLAUDE.md exists only to keep the hook from being doubled by a natively-read
57
+ // AGENTS.md. Unwiring the hook removes the reason for it. Only ever takes back an untouched one.
58
+ const shadow = removeShadowClaudeMd(paths.projectRoot);
59
+ const removed = shadow ? [shadow] : [];
60
+
55
61
  const file = paths.hookTarget;
56
- if (!existsSync(file)) return [];
62
+ if (!existsSync(file)) return removed;
57
63
  let settings;
58
- try { settings = JSON.parse(readFileSync(file, 'utf8')); } catch { return []; }
59
- if (!settings.hooks) return [];
64
+ try { settings = JSON.parse(readFileSync(file, 'utf8')); } catch { return removed; }
65
+ if (!settings.hooks) return removed;
60
66
  for (const event of Object.keys(settings.hooks)) {
61
67
  settings.hooks[event] = (settings.hooks[event] || []).filter((e) => {
62
68
  const s = hookWiringOf(e);
@@ -66,7 +72,7 @@ export function unwireHook(paths) {
66
72
  }
67
73
  if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
68
74
  writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
69
- return [file];
75
+ return [...removed, file];
70
76
  }
71
77
 
72
78
  // SessionStart runs a project-local session-start.mjs via `node`: it prints
@@ -236,5 +242,11 @@ export function wireHook(paths, sourceMd, policy = {}) {
236
242
 
237
243
  mkdirSync(dirname(file), { recursive: true });
238
244
  writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
245
+
246
+ // settings.json now names this project as rsc-wired, which is the predicate the shadow reads —
247
+ // so this call belongs AFTER the write, not before it. No-op unless the root AGENTS.md also
248
+ // carries the always-on body and no CLAUDE.md exists: see targets/agents-md-shadow.js.
249
+ const shadow = ensureShadowClaudeMd(paths.projectRoot);
250
+ if (shadow) written.push(shadow);
239
251
  return written;
240
252
  }
@@ -33,6 +33,19 @@ import { pathToFileURL, fileURLToPath } from 'node:url';
33
33
  const PACKAGE = '@ericrisco/rsc';
34
34
  const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?$/i;
35
35
 
36
+ /**
37
+ * The `.no-*` switches that are the TEAM's decision, and therefore the only ones a manifest may
38
+ * speak about. Canonical copy: `targets/opt-outs.js`, which carries the reasoning.
39
+ *
40
+ * Duplicated on purpose and not by oversight. This file is COMMITTED into user repos and runs with
41
+ * the package possibly not installed, so it cannot import anything — the same constraint that makes
42
+ * `sello.mjs` a materialized sibling. A test holds the two copies equal, because duplication that
43
+ * nothing checks is just drift with a head start.
44
+ */
45
+ export const PROJECT_OPT_OUTS = [
46
+ 'audit', 'claudemd-check', 'danger-guard', 'feature-gate', 'gitmoji', 'ship-guard', 'worktree-cleanup',
47
+ ];
48
+
36
49
  /**
37
50
  * Validated at the parse boundary AND again here, on purpose.
38
51
  *
@@ -93,6 +106,11 @@ export function readManifest(root) {
93
106
  // thing to say, and it is what an unusable value becomes.
94
107
  catalogVersion: SEMVER.test(String(parsed.catalogVersion ?? '')) ? parsed.catalogVersion : null,
95
108
  targets: Array.isArray(parsed.targets) ? parsed.targets : [],
109
+ // Same allowlist, same reason, and it bites harder here: these names are pasted into the
110
+ // notice a model reads. A switch is a plain lowercase name or it is not a switch — and only
111
+ // the ones the team is allowed to decide for everybody get through at all.
112
+ optOuts: ids(parsed.optOuts).filter((n) => PROJECT_OPT_OUTS.includes(n)).sort(),
113
+ tier: ['balanced', 'heavy'].includes(parsed.tier) ? parsed.tier : null,
96
114
  };
97
115
  }
98
116
 
@@ -108,7 +126,9 @@ export function readManifest(root) {
108
126
  * assuming otherwise. When it cannot be told apart, it is left alone.
109
127
  */
110
128
  export function evaluateHarness(root, manifest) {
111
- if (manifest.state !== 'declared') return { verdict: 'unknown', missing: [], ownMissing: [] };
129
+ if (manifest.state !== 'declared') {
130
+ return { verdict: 'unknown', missing: [], ownMissing: [], optOutsMissing: [], tierDiffers: null };
131
+ }
112
132
  // Catalog skills: `.rsc/skills/<id>` is where their content lands, and it is the same on all
113
133
  // seventeen assistants — the per-target directories are links into it.
114
134
  const fromCatalog = (id) => existsSync(join(root, '.rsc', 'skills', id));
@@ -126,8 +146,23 @@ export function evaluateHarness(root, manifest) {
126
146
  const fromTeam = (name) => OWN_DIRS.some((dir) => existsSync(join(root, ...dir, name)));
127
147
  const missing = manifest.skills.filter((id) => !fromCatalog(id));
128
148
  const ownMissing = manifest.own.filter((name) => !fromTeam(name));
129
- if (!missing.length && !ownMissing.length) return { verdict: 'current', missing: [], ownMissing: [] };
130
- return { verdict: 'behind', missing, ownMissing };
149
+ // A gate the team disarmed that is still armed here. Declared-and-missing only, exactly as for
150
+ // skills above: a marker this machine has and the manifest does not could be a decision somebody
151
+ // is still making, and this file does not tell people what to think about their own work.
152
+ const optOutsMissing = manifest.optOuts.filter((n) => !existsSync(join(root, '.rsc', `.no-${n}`)));
153
+ const tierDiffers = manifest.tier && manifest.tier !== localTier(root) ? manifest.tier : null;
154
+ if (!missing.length && !ownMissing.length && !optOutsMissing.length && !tierDiffers) {
155
+ return { verdict: 'current', missing: [], ownMissing: [], optOutsMissing: [], tierDiffers: null };
156
+ }
157
+ return { verdict: 'behind', missing, ownMissing, optOutsMissing, tierDiffers };
158
+ }
159
+
160
+ /** The tier this machine actually runs at — `balanced` is the documented default, not a guess. */
161
+ function localTier(root) {
162
+ try {
163
+ return JSON.parse(readFileSync(join(root, '.rsc', 'developer.json'), 'utf8')).tier === 'heavy'
164
+ ? 'heavy' : 'balanced';
165
+ } catch { return 'balanced'; }
131
166
  }
132
167
 
133
168
  /**
@@ -149,6 +184,15 @@ export function composeDivergence(manifest, evaluation) {
149
184
  if (evaluation.missing.length) {
150
185
  text += `MISSING, declared in .rsc.json: ${evaluation.missing.join(', ')}.\n`;
151
186
  }
187
+ // Named as the convention they are, never as the file that implements them: "the team disarmed
188
+ // the commit-message check" is something a person can agree or disagree with; ".no-gitmoji is
189
+ // absent" is a fact about a directory they have never opened.
190
+ if (evaluation.optOutsMissing?.length) {
191
+ text += `DISARMED BY THE TEAM, still armed here: ${evaluation.optOutsMissing.join(', ')}.\n`;
192
+ }
193
+ if (evaluation.tierDiffers) {
194
+ text += `DECLARED developer tier: ${evaluation.tierDiffers} — this machine runs the default.\n`;
195
+ }
152
196
  // Before the command, never after: anything sitting under the action reads as part of what the
153
197
  // action installs, and these are the one thing rsc will not install. Their version is the commit.
154
198
  if (evaluation.ownMissing.length) {
@@ -0,0 +1,40 @@
1
+ // Which `.no-*` switches are the TEAM's decision and which are THIS MACHINE's.
2
+ //
3
+ // `project-manifest` moved disarmed gates into the committed declaration because they are
4
+ // decisions and decisions should not evaporate on `git clone`. What it did not settle is that
5
+ // not every switch is the same kind of decision, and one of them is actively dangerous to share:
6
+ //
7
+ // `.no-harness` means "do not offer to build the harness ON THIS MACHINE" — the bootstrap says
8
+ // exactly that when it offers it. Let it travel and one person declining, once, would silence
9
+ // the offer in every future clone: a whole team losing the harness because somebody's laptop
10
+ // said no. The same reasoning covers an MCP connection (consent is individual), a check that
11
+ // compares two scopes of one machine, and a nudge that can only fire in a project with no
12
+ // `.git/` — which a clone, by construction, is not.
13
+ //
14
+ // A table and not a chain of ifs, so a test can iterate it and `clone-bootstrap.mjs` — which is
15
+ // committed, standalone and cannot import from the package — can be held against it instead of
16
+ // drifting away from it in silence.
17
+
18
+ /** Decisions of the project: they belong in `.rsc.json` and are rebuilt in a clone. */
19
+ export const PROJECT_OPT_OUTS = [
20
+ 'audit', // cadence of this project's skill audit
21
+ 'claudemd-check', // the root CLAUDE.md context budget
22
+ 'danger-guard', // foot-gun denial, keyed to the committed user profile
23
+ 'feature-gate', // per-turn re-injection of the SDD gate
24
+ 'gitmoji', // the commit-message convention
25
+ 'ship-guard', // branch/trunk discipline
26
+ 'worktree-cleanup', // the reaper's notice (it only ever names, never acts)
27
+ ];
28
+
29
+ /** Decisions of one machine: never written to the manifest, never applied from it. */
30
+ export const MACHINE_OPT_OUTS = [
31
+ 'context7', // an MCP connection is machine wiring, and its consent is individual
32
+ 'git', // only fires with no .git/, which a clone cannot be
33
+ 'harness', // "not offered again ON THIS MACHINE" — the bootstrap's own words
34
+ 'scope-check',// compares the project and home scopes OF THIS MACHINE
35
+ ];
36
+
37
+ export const isProjectOptOut = (name) => PROJECT_OPT_OUTS.includes(name);
38
+
39
+ /** The project-level subset of a manifest's `optOuts`, unknown names dropped. */
40
+ export const projectOptOuts = (list) => [...new Set((list || []).filter(isProjectOptOut))].sort();
@@ -194,19 +194,31 @@ ACTION: run \`npx @ericrisco/rsc audit\`. Opt out with .rsc/.no-audit.
194
194
  // it's a relocation, not a delete). Opt out with .rsc/.no-claudemd-check.
195
195
  const CLAUDEMD_MAX_LINES = 200;
196
196
  if (!has('.rsc', '.no-claudemd-check')) {
197
+ // WHICH file is the project's instructions is no longer a constant. From Claude Code 2.1.277, a
198
+ // project with no CLAUDE.md is read through its root AGENTS.md instead — same per-turn cost, same
199
+ // adherence rot when it grows, and until now nothing measured it. Measure the file that is
200
+ // actually being loaded: CLAUDE.md when there is one, otherwise AGENTS.md.
201
+ const name = has('CLAUDE.md') ? 'CLAUDE.md' : (has('AGENTS.md') ? 'AGENTS.md' : null);
197
202
  try {
198
- const lines = readFileSync(join(root, 'CLAUDE.md'), 'utf8').split('\n').length;
203
+ const lines = readFileSync(join(root, name), 'utf8').split('\n').length;
199
204
  if (lines > CLAUDEMD_MAX_LINES) {
205
+ // Only the CLAUDE.md claim is unconditional. A client older than 2.1.277 does not read
206
+ // AGENTS.md at all, and this hook cannot tell which it is talking to (the SessionStart
207
+ // payload carries no version), so the AGENTS.md line says when the cost applies instead of
208
+ // asserting it — a notice that overstates its case is the kind that gets opted out of.
209
+ const why = name === 'CLAUDE.md'
210
+ ? "it's read every turn, so each line costs context"
211
+ : "Claude Code 2.1.277+ loads it every turn when there is no CLAUDE.md, so each line costs context";
200
212
  process.stdout.write(`
201
- ===== rsc CLAUDE.md hygiene =====
202
- CLAUDE.md is ${lines} lines — over the ~${CLAUDEMD_MAX_LINES}-line budget (it's read every turn, so each line costs context).
213
+ ===== rsc ${name} hygiene =====
214
+ ${name} is ${lines} lines — over the ~${CLAUDEMD_MAX_LINES}-line budget (${why}).
203
215
  ACTION: offload the Knowledge map / overgrown sections into 02-DOCS/wiki/index.md and leave a short
204
- pointer in CLAUDE.md (no info lost — it's a move). The \`harness\` skill owns the procedure.
216
+ pointer in ${name} (no info lost — it's a move). The \`harness\` skill owns the procedure.
205
217
  Opt out with .rsc/.no-claudemd-check.
206
218
  =================================
207
219
  `);
208
220
  }
209
- } catch { /* no CLAUDE.md → nothing to check */ }
221
+ } catch { /* no instructions file (name === null) → nothing to check */ }
210
222
  }
211
223
 
212
224
  // Worktree sweep: a worktree whose work already reached the trunk is landed work wearing the same