@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 CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.0.5",
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.5",
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,
@@ -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)).sort();
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.length ? optOuts : (prev.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') 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();