@ericrisco/rsc 2.0.5 → 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 +53 -5
- package/targets/clone-bootstrap.mjs +47 -3
- package/targets/opt-outs.js +40 -0
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
|
@@ -8,8 +8,9 @@ import { targetPaths, writeSkill, wireHook, unwireHook, baseDir, TARGET_IDS } fr
|
|
|
8
8
|
import { shadowTarget } from '../targets/agents-md-shadow.js';
|
|
9
9
|
import {
|
|
10
10
|
targetHasAgents, reconcileAgents, agentPath, agentNames,
|
|
11
|
-
resolveAgentNames, agentByName, allAgentNames, readDeveloperTier,
|
|
11
|
+
resolveAgentNames, agentByName, allAgentNames, readDeveloperTier, writeDeveloperTier,
|
|
12
12
|
} from '../targets/agents.js';
|
|
13
|
+
import { projectOptOuts } from '../targets/opt-outs.js';
|
|
13
14
|
import { readState, writeState } from './lib/state.js';
|
|
14
15
|
import { withDefaultSkillFloor } from './lib/default-skill-floor.js';
|
|
15
16
|
import { readManifest, writeManifest } from './lib/manifest-file.js';
|
|
@@ -128,17 +129,56 @@ export function managedPathsForInstall({ skillIds, agentIds = [], target, home,
|
|
|
128
129
|
// which is machine-local and therefore lost on every clone: a team that disarmed the
|
|
129
130
|
// gitmoji guard found it armed again on the next checkout, with nobody having decided
|
|
130
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.
|
|
131
144
|
function localDecisions(cwd) {
|
|
132
145
|
const dir = join(cwd, '.rsc');
|
|
133
|
-
let optOuts =
|
|
146
|
+
let optOuts = null;
|
|
134
147
|
try {
|
|
135
|
-
optOuts = readdirSync(dir).filter((f) => f.startsWith('.no-')).map((f) => f.slice(4))
|
|
136
|
-
} 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" */ }
|
|
137
150
|
let tier = null;
|
|
138
151
|
try { tier = JSON.parse(readFileSync(join(dir, 'developer.json'), 'utf8')).tier ?? null; } catch { /* unset */ }
|
|
139
152
|
return { optOuts, tier };
|
|
140
153
|
}
|
|
141
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
|
+
|
|
142
182
|
// Record the decision, merging so a second assistant never erases the first: installing
|
|
143
183
|
// into codex on a machine that already had claude is adding, not replacing. Union, sorted
|
|
144
184
|
// where order carries no meaning, so the file stays diffable and a merge conflict stays
|
|
@@ -158,17 +198,25 @@ export function recordInManifest({ cwd, target, skillIds, agentIds = [], catalog
|
|
|
158
198
|
ownSkills: prev.ownSkills || [],
|
|
159
199
|
catalogVersion,
|
|
160
200
|
tier: tier ?? prev.tier ?? null,
|
|
161
|
-
optOuts: optOuts
|
|
201
|
+
optOuts: optOuts ?? projectOptOuts(prev.optOuts),
|
|
162
202
|
memory: prev.memory,
|
|
163
203
|
onboarding: onboarding ?? prev.onboarding,
|
|
164
204
|
});
|
|
165
205
|
}
|
|
166
206
|
|
|
167
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'));
|
|
168
213
|
const paths = targetPaths(target, home, cwd);
|
|
169
214
|
const plan = planInstall({ skillIds, target, home, cwd, hooks: policy?.alwaysOn !== false });
|
|
170
215
|
const managedPaths = managedPathsForInstall({ skillIds, agentIds, target, home, cwd, policy });
|
|
171
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));
|
|
172
220
|
const state = readState(paths.stateFile);
|
|
173
221
|
const backup = createBackup({ cwd, operation, target, paths: managedPaths, cliVersion: CLI_VERSION });
|
|
174
222
|
// Decide base refresh per skill (see baseVersionsFile): a base is re-materialized when
|
|
@@ -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();
|