@ericrisco/rsc 2.0.14 → 2.0.16

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.14",
2
+ "version": "2.0.16",
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.14",
3
+ "version": "2.0.16",
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": {
@@ -85,6 +85,14 @@
85
85
  {
86
86
  "fixedIn": "2.0.12",
87
87
  "symptom": "Security: session continuation context could come from a different git worktree"
88
+ },
89
+ {
90
+ "fixedIn": "2.0.15",
91
+ "symptom": "The 'consider compacting' notice never appears in sessions that edit files through the terminal instead of the Edit tool"
92
+ },
93
+ {
94
+ "fixedIn": "2.0.16",
95
+ "symptom": "Non-technical or mixed users in a non-software project had no guard against irreversible commands; repair wired guards the plan had declined"
88
96
  }
89
97
  ]
90
98
  }
@@ -78,9 +78,12 @@ export function generatedHookFiles({ target, cwd, policy }) {
78
78
  join(cwd, '.rsc', 'hook-once.mjs'),
79
79
  join(cwd, '.rsc', 'worktree-reaper.mjs'),
80
80
  ];
81
- if (policy?.codeHooks === false) return [...lifecycle, join(cwd, '.rsc', 'suggest-always-on.md')];
82
- return [...lifecycle,
83
- join(cwd, '.rsc', 'ship-guard.mjs'), join(cwd, '.rsc', 'danger-guard.mjs'),
81
+ // The danger guard is declared on its own terms (#273): present unless the plan says otherwise,
82
+ // whether or not the code guards are.
83
+ const danger = policy?.dangerGuard === false ? [] : [join(cwd, '.rsc', 'danger-guard.mjs')];
84
+ if (policy?.codeHooks === false) return [...lifecycle, ...danger, join(cwd, '.rsc', 'suggest-always-on.md')];
85
+ return [...lifecycle, ...danger,
86
+ join(cwd, '.rsc', 'ship-guard.mjs'),
84
87
  join(cwd, '.rsc', 'gitmoji-guard.mjs'), join(cwd, '.rsc', 'userprompt-gate.mjs'),
85
88
  join(cwd, '.rsc', 'sello.mjs')];
86
89
  }
@@ -279,6 +282,7 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
279
282
  baseAgents: policy.baseAgents !== false,
280
283
  alwaysOn: policy.alwaysOn !== false,
281
284
  codeHooks: policy.codeHooks !== false,
285
+ dangerGuard: policy.dangerGuard !== false,
282
286
  gitmojiGuard: policy.gitmojiGuard !== false,
283
287
  memory: policy.memory !== false,
284
288
  context7: policy.context7 === true,
@@ -230,6 +230,13 @@ export function buildOnboardingPlan(record, evidence) {
230
230
  const hooks = practisesSdd;
231
231
  const agents = baseAgents ? resolveAgentNames(skills, []).sort() : [];
232
232
  const gitmojiGuard = hooks && normalized.targets.includes('claude');
233
+ // The danger guard is NOT code machinery, and tying it to `hooks` was the mistake behind #273. The
234
+ // three guards above need a code project with git — a branch to protect, a commit to lint. This one
235
+ // stops recursive deletes, history-rewriting pushes and friends, which are exactly as irreversible
236
+ // in an invoicing workspace, and `init` promises it by technical level: non-technical and mixed get
237
+ // it, whatever the project is. The guard also decides at runtime from the profile, so the plan only
238
+ // chooses whether it is present, never whether it bites.
239
+ const dangerGuard = normalized.technicalLevel !== 'technical';
233
240
  // `sdd` is installed everywhere now, but its DECISION is about practice, not presence — so where
234
241
  // the chain is not practised the explicit `deferred` entry below must be the only one, or the
235
242
  // generic "installed → selected" mapping would shadow it and `reassess` would see nothing to watch.
@@ -276,6 +283,7 @@ export function buildOnboardingPlan(record, evidence) {
276
283
  alwaysOn: true,
277
284
  codeHooks: practisesSdd,
278
285
  gitmojiGuard,
286
+ dangerGuard,
279
287
  memory: true,
280
288
  context7: false,
281
289
  };
@@ -131,22 +131,26 @@ export async function repair({ cwd = process.cwd(), target, home = homedir(), dr
131
131
 
132
132
  const paths = targetPaths(target, home, cwd);
133
133
  const ids = [...new Set([...Object.keys(readState(paths.stateFile).skills || {}), ...(readManifest(cwd)?.skills || [])])];
134
+ // The accepted plan governs a repair exactly as it governs a sync. Without it, `wireHook` saw `{}`,
135
+ // read `codeHooks !== false` as true and wired every guard the plan had declined — and the next
136
+ // `sync`, which does pass the policy, took them away again (#274). Restoring is not deciding.
137
+ const policy = readManifest(cwd)?.onboarding?.plan?.policy;
134
138
  const backup = createBackup({
135
139
  cwd, operation: 'repair', target,
136
- paths: managedPathsForInstall({ skillIds: ids, target, home, cwd }),
140
+ paths: managedPathsForInstall({ skillIds: ids, target, home, cwd, policy }),
137
141
  });
138
142
 
139
143
  for (const f of todo) {
140
144
  if (f.id === 'nested-layout') rmSync(join(paths.root, 'rsc'), { recursive: true, force: true });
141
- if (f.id === 'duplicate-hooks') { unwireHook(target, paths); await applyInstall({ skillIds: ids, target, home, cwd, operation: 'repair' }); }
142
- if (f.id === 'dangling-links') await applyInstall({ skillIds: ids, target, home, cwd, operation: 'repair' });
145
+ if (f.id === 'duplicate-hooks') { unwireHook(target, paths); await applyInstall({ skillIds: ids, target, home, cwd, operation: 'repair', policy }); }
146
+ if (f.id === 'dangling-links') await applyInstall({ skillIds: ids, target, home, cwd, operation: 'repair', policy });
143
147
  if (f.id === 'no-manifest') recordInManifest({ cwd, target, skillIds: ids });
144
148
  if (f.id === 'wrong-target') {
145
149
  // Install where the project actually points, THEN take the old wiring down — in that
146
150
  // order, so a failure halfway leaves a working harness rather than none. Taking the
147
151
  // block out of the old file gives it back byte-identical: in #249 that file was the
148
152
  // project's hand-written constitution, and it had already been written into once.
149
- await applyInstall({ skillIds: ids, target: f.to, home, cwd, operation: 'repair' });
153
+ await applyInstall({ skillIds: ids, target: f.to, home, cwd, operation: 'repair', policy });
150
154
  unwireHook(target, paths);
151
155
  removeManagedSkills(paths);
152
156
  recordInManifest({ cwd, target: f.to, skillIds: ids, dropTarget: target });
package/targets/claude.js CHANGED
@@ -179,18 +179,6 @@ export function wireHook(paths, sourceMd, policy = {}) {
179
179
  );
180
180
  settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: sgCmd }] });
181
181
 
182
- // Danger guard: a PreToolUse(Bash) hook that DENIES irreversible foot-gun commands
183
- // (rm -rf, git push --force, DROP/TRUNCATE, DELETE/UPDATE without WHERE, dd to /dev,
184
- // curl|bash, …) when the user-profile says the user is NON-technical (default-safe
185
- // when no profile exists yet; never guards a fully `technical` user). Materialized +
186
- // node-run (Windows-safe), idempotent, fail-open, opt-out via .rsc/.no-danger-guard.
187
- const dgDest = join(paths.projectRoot, '.rsc', 'danger-guard.mjs');
188
- copyFileSync(join(HERE, 'danger-guard.mjs'), dgDest);
189
- const dgCmd = viaBootstrap('guard', at('.rsc', 'danger-guard.mjs'), `"${P}"`);
190
- settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
191
- (e) => !hookWiringOf(e).includes('.rsc/danger-guard.'),
192
- );
193
- settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: dgCmd }] });
194
182
 
195
183
  // Gitmoji guard: a PreToolUse(Bash) hook that DENIES a `git commit` whose message
196
184
  // carries no gitmoji (gitmoji.dev) in front of the Conventional Commits header. The
@@ -223,23 +211,45 @@ export function wireHook(paths, sourceMd, policy = {}) {
223
211
  (e) => !hookWiringOf(e).includes('.rsc/userprompt-gate.'),
224
212
  );
225
213
  settings.hooks.UserPromptSubmit.push({ hooks: [{ type: 'command', command: fgCmd }] });
226
- written.push(sgDest, dgDest, gmDest, fgDest, join(paths.projectRoot, '.rsc', 'sello.mjs'));
214
+ written.push(sgDest, gmDest, fgDest, join(paths.projectRoot, '.rsc', 'sello.mjs'));
227
215
  } else {
228
216
  for (const event of ['PreToolUse', 'UserPromptSubmit']) {
229
217
  if (!settings.hooks[event]) continue;
230
218
  settings.hooks[event] = settings.hooks[event].filter((entry) => {
231
219
  const body = hookWiringOf(entry);
232
- return !body.includes('.rsc/ship-guard.') && !body.includes('.rsc/danger-guard.') &&
220
+ return !body.includes('.rsc/ship-guard.') &&
233
221
  !body.includes('.rsc/gitmoji-guard.') && !body.includes('.rsc/userprompt-gate.');
234
222
  });
235
223
  if (!settings.hooks[event].length) delete settings.hooks[event];
236
224
  }
237
- for (const name of ['ship-guard.mjs', 'danger-guard.mjs', 'gitmoji-guard.mjs', 'userprompt-gate.mjs', 'sello.mjs']) {
225
+ for (const name of ['ship-guard.mjs', 'gitmoji-guard.mjs', 'userprompt-gate.mjs', 'sello.mjs']) {
238
226
  rmSync(join(paths.projectRoot, '.rsc', name), { force: true });
239
227
  }
240
228
  written.push(operationsSuggest);
241
229
  }
242
230
 
231
+ // Danger guard: its own decision, not part of the code-hook block above (#273). `init` promises it
232
+ // to non-technical and mixed users whatever the project is, and a plan that predates the field is
233
+ // read as "yes": a non-technical operations install gets the guard it was missing on its next sync,
234
+ // and a technical user gets a guard that stands down at runtime.
235
+ settings.hooks.PreToolUse ||= [];
236
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter((e) => !hookWiringOf(e).includes('.rsc/danger-guard.'));
237
+ if (policy.dangerGuard !== false) {
238
+ // Danger guard: a PreToolUse(Bash) hook that DENIES irreversible foot-gun commands
239
+ // (rm -rf, git push --force, DROP/TRUNCATE, DELETE/UPDATE without WHERE, dd to /dev,
240
+ // curl|bash, …) when the user-profile says the user is NON-technical (default-safe
241
+ // when no profile exists yet; never guards a fully `technical` user). Materialized +
242
+ // node-run (Windows-safe), idempotent, fail-open, opt-out via .rsc/.no-danger-guard.
243
+ const dgDest = join(paths.projectRoot, '.rsc', 'danger-guard.mjs');
244
+ copyFileSync(join(HERE, 'danger-guard.mjs'), dgDest);
245
+ const dgCmd = viaBootstrap('guard', at('.rsc', 'danger-guard.mjs'), `"${P}"`);
246
+ settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: dgCmd }] });
247
+ written.push(dgDest);
248
+ } else {
249
+ rmSync(join(paths.projectRoot, '.rsc', 'danger-guard.mjs'), { force: true });
250
+ }
251
+ if (!settings.hooks.PreToolUse.length) delete settings.hooks.PreToolUse;
252
+
243
253
  mkdirSync(dirname(file), { recursive: true });
244
254
  writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
245
255
 
@@ -319,7 +319,20 @@ export function capture(input = {}) {
319
319
  if (input.event === 'start' && !existing) return { record: null, path: null, notice: null, compactionHint: false };
320
320
 
321
321
  const repo = snapshot(here, anchor.baselineHead);
322
- const editCount = Math.max(0, (existing?.editCount || 0) + (finiteOrNull(input.editDelta, true) || 0));
322
+ // Count what CHANGED, not which tool changed it. `editDelta` is +1 per Edit/Write tool event, and
323
+ // on its own it was blind to everything done through the shell — a heredoc, `sed -i`, a script, a
324
+ // formatter — which is how automated sessions work. The session that found this had touched dozens
325
+ // of files and its record said editCount=1, so the compaction hint never reached the long sessions
326
+ // it exists for.
327
+ //
328
+ // `snapshot` already fingerprints every modified file on every event. Comparing those fingerprints
329
+ // with the previous event's counts a change from any source. `max`, not `+`: an Edit that changed a
330
+ // file shows up in BOTH signals, and must count once.
331
+ const previousFingerprints = anchor.lastFingerprints || anchor.baselineFingerprints || {};
332
+ const observed = Object.entries(repo.fingerprints || {})
333
+ .filter(([path, fingerprint]) => fingerprint && previousFingerprints[path] !== fingerprint).length;
334
+ const editDelta = Math.max(finiteOrNull(input.editDelta, true) || 0, observed);
335
+ const editCount = Math.max(0, (existing?.editCount || 0) + editDelta);
323
336
  const baselineFiles = new Set(anchor.baselineFiles || []);
324
337
  const newDirtyPath = repo.files.some((path) => !baselineFiles.has(path));
325
338
  const baselineFingerprints = anchor.baselineFingerprints || {};
@@ -368,7 +381,17 @@ export function capture(input = {}) {
368
381
  // already written and `readRecords` would quietly drop the project's whole history.
369
382
  let compactionHint = Boolean(config.compactionHint && editCount >= config.editThreshold);
370
383
  if (compactionHint && anchor.compactionHintedAt) compactionHint = false;
371
- else if (compactionHint) atomicJson(anchorPath, { ...anchor, compactionHintedAt: now });
384
+ // One anchor write per event at most, carrying both pieces of per-session state: the fingerprints
385
+ // the next event compares against, and the once-per-session hint mark. Written only when either
386
+ // actually changed, so a read-only shell command costs no extra disk.
387
+ const fingerprintsMoved = observed > 0 || !anchor.lastFingerprints;
388
+ if (compactionHint || fingerprintsMoved) {
389
+ atomicJson(anchorPath, {
390
+ ...anchor,
391
+ lastFingerprints: repo.fingerprints || {},
392
+ ...(compactionHint ? { compactionHintedAt: now } : {}),
393
+ });
394
+ }
372
395
  return {
373
396
  record,
374
397
  path: recordPath,