@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 +1 -1
- package/package.json +1 -1
- package/scripts/doctor.js +15 -0
- package/scripts/install-apply.js +60 -5
- package/targets/_md-block.js +8 -1
- package/targets/agents-md-shadow.js +153 -0
- package/targets/claude.js +16 -4
- package/targets/clone-bootstrap.mjs +47 -3
- package/targets/opt-outs.js +40 -0
- package/targets/session-start.mjs +17 -5
package/manifest.json
CHANGED
package/package.json
CHANGED
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,
|
package/scripts/install-apply.js
CHANGED
|
@@ -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))
|
|
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
|
|
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
|
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
|
}
|
|
@@ -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')
|
|
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
|
-
|
|
130
|
-
|
|
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,
|
|
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
|