@ericrisco/rsc 2.0.4 → 2.0.5

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.5",
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.5",
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": {
@@ -5,6 +5,7 @@ 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
11
  resolveAgentNames, agentByName, allAgentNames, readDeveloperTier,
@@ -114,6 +115,12 @@ export function managedPathsForInstall({ skillIds, agentIds = [], target, home,
114
115
  out.push(step.to, ...generatedHookFiles({ target, cwd, policy }));
115
116
  }
116
117
  }
118
+ // The shadow CLAUDE.md that keeps a natively-read AGENTS.md from doubling the always-on body.
119
+ // Written by whichever of the two adapters is wired second, so it is declared for both families,
120
+ // not just for claude. A claude install must assume its own wiring: settings.json does not name
121
+ // it yet at plan time.
122
+ const shadow = shadowTarget(cwd, { assumeClaudeWired: target === 'claude' });
123
+ if (shadow) out.push(shadow);
117
124
  return [...new Set(out)];
118
125
  }
119
126
 
@@ -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
  }
@@ -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