@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 +1 -1
- package/package.json +1 -1
- package/scripts/install-apply.js +7 -0
- package/targets/_md-block.js +8 -1
- package/targets/agents-md-shadow.js +153 -0
- package/targets/claude.js +16 -4
- package/targets/session-start.mjs +17 -5
package/manifest.json
CHANGED
package/package.json
CHANGED
package/scripts/install-apply.js
CHANGED
|
@@ -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
|
|
package/targets/_md-block.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
202
|
-
|
|
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
|
|
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
|
|
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
|