@ericrisco/rsc 3.0.6 → 3.0.8

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/scripts/rsc.js CHANGED
@@ -4,12 +4,13 @@ import { detectTarget, installedTargets, resolveTargets, TARGETS } from '../targ
4
4
  import { detectRepo } from './detect-repo.js';
5
5
  import { rank } from './consult.js';
6
6
  import { expandRecommends, toOutcomes, hasOutcome } from './lib/recommend.js';
7
- import { applyInstall, listInstalled, listInstalledAgents, listInstalledCommands, uninstall, syncInstalled, purge, collisions, ownSkillPaths } from './install-apply.js';
7
+ import { applyInstall, listInstalled, listInstalledAgents, listInstalledCommands, uninstall, syncInstalled, purgeReport, collisions, ownSkillPaths } from './install-apply.js';
8
+ import { isAbsolute, relative, sep } from 'node:path';
8
9
  import { stackAgentNames } from '../targets/agents.js';
9
10
  import { doctor } from './doctor.js';
10
11
  import { ask, say, select, pickFrom, banner, confirm, isInteractive } from './lib/ui.js';
11
12
  import { refreshRegistry, registryStatus } from './lib/registry.js';
12
- import { audit, writeAuditReport } from './audit.js';
13
+ import { audit, writeAuditReport, recordAuditBaseline } from './audit.js';
13
14
  import { DOMAINS } from './lib/domains.js';
14
15
  import { listBackups, restoreBackup, whereKept } from './lib/backups.js';
15
16
  import { runUpgrade } from './lib/upgrade.js';
@@ -18,6 +19,7 @@ import { DEFAULT_SKILL_FLOOR, withDefaultSkillFloor } from './lib/default-skill-
18
19
  import { RETIRED_SKILLS, replaceRetired } from './lib/retired-skills.js';
19
20
  import { readManifest, writeManifest } from './lib/manifest-file.js';
20
21
  import { versionReport } from './lib/versions.js';
22
+ import { templateAssets, targetName } from './lib/tools-skeleton.js';
21
23
  import {
22
24
  normalizeOnboarding, missingOnboardingFields, scanProject,
23
25
  buildOnboardingPlan, decodeGoal, encodeGoal, identifyPlan, recommendDeferredComponents,
@@ -39,6 +41,33 @@ if (['--version', '-v', 'version'].includes(rawArgv[0])) {
39
41
  process.stdout.write(`${lines.join('\n')}\n`);
40
42
  process.exit(0);
41
43
  }
44
+ const GENERAL_HELP = [
45
+ 'Use: npx @ericrisco/rsc onboard | reassess | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | memory <on|off|status|save|resume|learn|metrics> | sello <on|off|status|…> | worktrees [reap [path] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id...> | purge | help',
46
+ 'Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)',
47
+ ' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.',
48
+ ].join('\n');
49
+ const UNINSTALL_HELP = [
50
+ 'Use: npx @ericrisco/rsc uninstall <id...> [--dry-run] [--target <assistant>]',
51
+ ' Removes the named skills or agents from this project.',
52
+ ' To remove everything rsc installed here, run: npx @ericrisco/rsc purge (keeps 02-DOCS/ unless --with-docs)',
53
+ ].join('\n');
54
+ const PURGE_HELP = [
55
+ 'Use: npx @ericrisco/rsc purge [--dry-run] [--with-docs]',
56
+ ' Removes everything rsc installed in this project and reports what was removed, cleaned and kept.',
57
+ ' --dry-run shows the report without touching anything. 02-DOCS/ is kept unless --with-docs.',
58
+ ].join('\n');
59
+
60
+ // Asking for help must never RUN the command. `uninstall --help` used to be read as an empty id list
61
+ // and report "Removed: (nothing)"; `purge --help` would have purged. Answered before anything else.
62
+ {
63
+ const helpAsked = rawArgv.some((a) => a === '--help' || a === '-h');
64
+ if (rawArgv[0] === 'help' || helpAsked) {
65
+ const about = rawArgv[0] === 'help' ? rawArgv[1] : rawArgv.find((a) => !a.startsWith('-'));
66
+ const text = about === 'uninstall' ? UNINSTALL_HELP : about === 'purge' ? PURGE_HELP : GENERAL_HELP;
67
+ process.stdout.write(`${text}\n`);
68
+ process.exit(0);
69
+ }
70
+ }
42
71
  // `--accompaniment` stays listed although the dial is retired: old scripts and agents still pass it,
43
72
  // and its value must keep being skipped rather than read as a command or a skill id.
44
73
  const GLOBAL_VALUE_FLAGS = new Set([
@@ -159,6 +188,12 @@ function renderPlan(plan, planId) {
159
188
  for (const decision of plan.decisions.filter((d) => d.state === 'excluded')) say(` × ${decision.kind}/${decision.id} — ${decision.reason}`);
160
189
  say('Managed paths:');
161
190
  for (const path of plan.governedPaths) say(` ${path}`);
191
+ // The scaffolding is written once and then belongs to the person, so it is not a managed (digested)
192
+ // path — but it IS written by this plan, and a plan that does not show it is not the whole plan.
193
+ if ((plan.floorPaths || []).includes('01-TOOLS/_TEMPLATE/')) {
194
+ say('Scaffolded once (yours to edit; never overwritten; purge removes only untouched files):');
195
+ for (const path of ['01-TOOLS/.gitignore', ...templateAssets().map((a) => `01-TOOLS/_TEMPLATE/${targetName(a)}`)].sort()) say(` ${path}`);
196
+ }
162
197
  if (plan.replacesUserSkills?.length) {
163
198
  say('Your own skills this plan replaces:');
164
199
  for (const path of plan.replacesUserSkills) say(` ! ${path} — already here and not put by rsc; the catalog skill of the same name takes its place, and yours is kept in .rsc/backups/`);
@@ -166,8 +201,11 @@ function renderPlan(plan, planId) {
166
201
  say(' AGENT: name each of these to the user before accepting, and ask whether to replace or rename.');
167
202
  }
168
203
  if (plan.evidence.parentHarness) say(`Parent harness detected at ${plan.evidence.parentHarness}; it is not inherited by this plan.`);
204
+ // Pinned to the version that computed this plan. The id is a hash of the plan, and the plan
205
+ // depends on the catalog of the running version: `@latest` would accept it with whatever version
206
+ // npm resolves next, which answers RSC_PLAN_CHANGED the day a release lands in between.
169
207
  const pieces = [
170
- 'npx @ericrisco/rsc@latest onboard',
208
+ `npx @ericrisco/rsc@${versionReport(process.cwd()).cli} onboard`,
171
209
  `--technical-level ${plan.record.technicalLevel}`,
172
210
  `--project-kind ${plan.record.projectKind}`,
173
211
  `--goal-base64 ${encodeGoal(plan.record.goal)}`,
@@ -260,6 +298,9 @@ async function runOnboarding(targets) {
260
298
  } catch (error) {
261
299
  say(`RSC_SKELETON_FAILED ${error.message}`);
262
300
  }
301
+ // The audit cadence starts at install. Without a baseline the very first session after onboarding
302
+ // announced "a skill audit is due" about a set chosen minutes earlier. Never overwrites a real run.
303
+ try { recordAuditBaseline(process.cwd()); } catch { /* a missing baseline only means an early nudge */ }
263
304
  for (const path of plan.replacesUserSkills || []) {
264
305
  const kept = whereKept({ cwd: process.cwd(), relPath: path });
265
306
  if (kept) say(`Your previous ${path} is kept in ${kept}`);
@@ -370,10 +411,34 @@ function flag(name) {
370
411
  // Remove everything rsc installed in this project (skills, hooks, .rsc/), across
371
412
  // every assistant. Keeps 02-DOCS/ unless --with-docs. `purge` / `uninstall --all`.
372
413
  async function runPurge(dryRun, withDocs) {
373
- const removed = await purge({ cwd: process.cwd(), withDocs, dryRun });
374
- say(`${dryRun ? 'Would remove' : 'Removed'} ${removed.length} path(s):`);
375
- for (const r of removed) say(` - ${r}`);
376
- if (!withDocs) say('\nKept 02-DOCS/ (your knowledge base). Add --with-docs to remove it too.');
414
+ const cwd = process.cwd();
415
+ const report = await purgeReport({ cwd, withDocs, dryRun });
416
+ // Relative to the project: the report is read by a person, and every path is inside it.
417
+ const shown = (p) => {
418
+ const rel = relative(cwd, p);
419
+ return !rel || rel.startsWith('..') || isAbsolute(rel) ? p : rel.split(sep).join('/');
420
+ };
421
+ say(`${dryRun ? 'Would remove' : 'Removed'} ${report.removed.length} path(s):`);
422
+ for (const r of report.removed.map(shown).sort()) say(` - ${r}`);
423
+ if (report.cleaned.length) {
424
+ say(`\n${dryRun ? 'Would clean' : 'Cleaned'} ${report.cleaned.length} file(s) — rsc's entries ${dryRun ? 'would be' : 'were'} taken out; ${dryRun ? 'each is deleted if nothing of yours remains' : 'the rest of the file is yours and stays'}:`);
425
+ for (const c of report.cleaned.map(shown).sort()) say(` - ${c}`);
426
+ }
427
+ if (report.kept.length) {
428
+ say(`\nKept ${report.kept.length}:`);
429
+ for (const k of report.kept) say(` - ${k.path} — ${k.reason}`);
430
+ }
431
+ // On a clone most of what purge deletes is committed. Deleting it here is local; committing the
432
+ // deletions uninstalls rsc for the whole team (team simulation D15). Said before anyone commits.
433
+ if (report.tracked?.length) {
434
+ const quote = (p) => (/^[\w./@+-]+$/.test(p) ? p : `'${p.replace(/'/g, `'\\''`)}'`);
435
+ say(`\nWARNING: ${report.tracked.length} of these file(s) are committed in git and ${dryRun ? 'would show' : 'now show'} as deleted or changed:`);
436
+ for (const f of report.tracked.slice(0, 12)) say(` - ${f}`);
437
+ if (report.tracked.length > 12) say(` … and ${report.tracked.length - 12} more`);
438
+ say('Committing these changes uninstalls rsc for the whole team, on their next pull. To remove rsc only on this machine, do not commit them.');
439
+ if (!dryRun) say(`To undo locally: git restore -- ${report.tracked.map(quote).join(' ')}`);
440
+ else say('To undo after a real purge: git restore -- <those files> (purge prints the exact command).');
441
+ }
377
442
  }
378
443
 
379
444
  async function recommendIds(query, { labeledOnly = false } = {}) {
@@ -450,6 +515,7 @@ function printNextSteps(targets, ids) {
450
515
  say(' Browse the catalog / get picks: npx @ericrisco/rsc consult "whatever you need"');
451
516
  say('────────────────────────────────────────────────────────');
452
517
  if (targets.includes('codex')) say(' Codex: review and trust the project lifecycle hook once with `/hooks`; until then memory is reported as requiring trust.');
518
+ if (targets.includes('deepseek')) say(' DeepSeek Harness: hooks run through rsc\'s bridge in ~/.dsh/cordis.patch.yml (one block, every profile); restart dsh once to load it.');
453
519
  if (targets.includes('cursor')) say(' Cursor: startup memory is assisted because its sessionStart hook is fire-and-forget; the installed always-on rule performs the read-before-action fallback.');
454
520
  printAgentHandoff();
455
521
  }
@@ -514,7 +580,7 @@ function printContextBudget(b) {
514
580
  if (b.notApplicable.length) {
515
581
  say(` ${b.note}`);
516
582
  } else {
517
- say(` Per session start : ${kb(b.sessionStartBytes)} (always-on body, per wired scope)`);
583
+ say(` Per session start : ${kb(b.sessionStartBytes)} (always-on body, per wired scope; one-time and conditional banners not counted)`);
518
584
  say(` Per user turn : ${kb(b.perTurnBytes)}`);
519
585
  }
520
586
  say(` Skill descriptions: ${kb(b.descriptionsBytes)} (${b.installedSkills} installed, always in context)`);
@@ -1005,7 +1071,15 @@ async function main() {
1005
1071
  // defeated by an argument the sweep itself told the agent to type.
1006
1072
  const confirmed = argv.includes('--confirm');
1007
1073
  const targets = one ? [one] : candidates.filter((c) => c.verdict === 'safe').map((c) => c.path);
1008
- if (!targets.length) { say('nothing to clean up.'); return; }
1074
+ // The bulk reap also deletes plain local branches that have landed (`branch -d`, never -D):
1075
+ // the worktree is not the only thing a merge leaves behind (E2E defect 14).
1076
+ const reapBranches = () => {
1077
+ if (one) return 0;
1078
+ const { deleted } = W.reapMergedBranches(root);
1079
+ for (const b of deleted) say(`✅ deleted merged branch ${b}`);
1080
+ return deleted.length;
1081
+ };
1082
+ if (!targets.length) { if (!reapBranches()) say('nothing to clean up.'); return; }
1009
1083
  for (const t of targets) {
1010
1084
  const out = W.reapWorktree(root, t, { confirmed });
1011
1085
  if (out.removed) {
@@ -1017,6 +1091,7 @@ async function main() {
1017
1091
  process.exitCode = 1;
1018
1092
  }
1019
1093
  }
1094
+ reapBranches();
1020
1095
  return;
1021
1096
  }
1022
1097
 
@@ -1271,7 +1346,14 @@ async function main() {
1271
1346
  const dry = argv.includes('--dry-run');
1272
1347
  // `uninstall --all` is an alias for a full purge.
1273
1348
  if (argv.includes('--all')) return void (await runPurge(dry, argv.includes('--with-docs')));
1274
- const selected = classifyRequested(requestedIds());
1349
+ const ids = requestedIds();
1350
+ // No id is not "nothing to remove": it is a command that was not given what it needs.
1351
+ if (!ids.length) {
1352
+ console.error(`rsc: uninstall needs at least one skill or agent id.\n${UNINSTALL_HELP}`);
1353
+ process.exitCode = 1;
1354
+ return;
1355
+ }
1356
+ const selected = classifyRequested(ids);
1275
1357
  // A retired skill an old install still holds is not unknown: the state file proves it is ours.
1276
1358
  const held = new Set(listInstalled({ target }));
1277
1359
  selected.skills.push(...selected.unknown.filter((id) => Object.hasOwn(RETIRED_SKILLS, id) && held.has(id)));
@@ -1305,9 +1387,7 @@ async function main() {
1305
1387
  return void (await runPurge(argv.includes('--dry-run'), argv.includes('--with-docs')));
1306
1388
  default:
1307
1389
  say(`rsc: unknown command '${cmd}'.`);
1308
- say('Use: npx @ericrisco/rsc onboard | reassess | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | memory <on|off|status|save|resume|learn|metrics> | sello <on|off|status|…> | worktrees [reap [path] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id> | purge');
1309
- say('Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)');
1310
- say(' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.');
1390
+ say(GENERAL_HELP);
1311
1391
  process.exitCode = 1;
1312
1392
  }
1313
1393
  }
@@ -106,12 +106,13 @@ Offer once per session. Never mention any of it when the harness is healthy.
106
106
 
107
107
  Before handling the first request of a session, check the workspace:
108
108
 
109
- - No `02-DOCS/wiki/harness/user-profile.md` **and** no `.rsc/.no-harness` → the harness has never
110
- been set up here. Invoke `init` first; it opens with one question: technical terms or analogies.
111
- Do not start the user's task before first contact is done.
109
+ - No `02-DOCS/wiki/harness/user-profile.md` **and** no `.rsc/.no-harness` → invoke `init` first
110
+ (one question: technical terms or analogies) before the task.
111
+ - A clone (`.rsc.json` committed) is not first contact: ask that once, in one line, then continue
112
+ with their task; with `.rsc/.profile-offered`, never again.
112
113
  - The user declines a harness here ("sin harness", "solo código") → create an empty
113
114
  `.rsc/.no-harness`, confirm in one line, and never auto-start `init` in this repo again.
114
- - Once the profile exists, this gate is inert. Never re-onboard.
115
+ - With a profile, this gate is inert. Never re-onboard.
115
116
 
116
117
  ## Explain without assuming
117
118
 
@@ -33,6 +33,29 @@ export const COMMITS_HERE = new RegExp(String.raw`\bgit\s+${GIT_OPTS}(?:commit|m
33
33
  // A branch move: switch, or checkout of something that is not a path (`checkout -- f`, `checkout HEAD -- f`).
34
34
  export const MOVES_BRANCH = new RegExp(String.raw`\bgit\s+${GIT_OPTS}(?:switch(?![\w-])|checkout(?![\w-])(?![^;&|\n]*\s--(?:\s|$)))`);
35
35
 
36
+ // A move of a branch REF without a commit: `git update-ref <ref> …` or a forced `git branch`
37
+ // (-f/--force, -M, -C). Which ref it moves is read by refMoveTarget().
38
+ export const MOVES_REF = new RegExp(String.raw`\bgit\s+${GIT_OPTS}(?:update-ref(?![\w-])|branch\s+(?:\S+\s+)*?(?:-[A-Za-z]*[fMC][A-Za-z]*|--force)(?=\s|$))`);
39
+
40
+ // A push whose DESTINATION is named: `git push origin HEAD:main`, `feat/x:refs/heads/main`, `--mirror`.
41
+ export const PUSHES = new RegExp(String.raw`\bgit\s+${GIT_OPTS}push(?![\w-])`);
42
+
43
+ /** True when this push segment writes to branch `trunk` on the remote by an explicit refspec. */
44
+ export function pushesTo(seg, trunk) {
45
+ const t = tokens(seg.replace(/^.*?\bgit\s+/, '').replace(/^(?:(?:-C|-c)\s+\S+\s+|--\S+\s+)*/, ''));
46
+ if (t.shift() !== 'push') return false;
47
+ const args = [];
48
+ for (let i = 0; i < t.length; i++) {
49
+ if (t[i] === '--mirror') return true; // rewrites every ref on the remote, the default branch included
50
+ if (/^(-o|--push-option|--repo|--receive-pack|--exec)$/.test(t[i])) { i++; continue; }
51
+ if (!t[i].startsWith('-')) args.push(t[i]);
52
+ }
53
+ return args.slice(1).some((spec) => {
54
+ const dst = spec.replace(/^\+/, '').split(':')[1];
55
+ return dst !== undefined && dst.replace(/^refs\/heads\//, '') === trunk;
56
+ });
57
+ }
58
+
36
59
  export const unquoted = (command) => command.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""');
37
60
 
38
61
  /** Where the git command actually runs: `git -C <dir>`, or a leading `cd <dir> &&`, else the cwd. */
@@ -54,27 +77,10 @@ const ago = (iso) => {
54
77
  return min <= 1 ? 'just now' : `${min} min ago`;
55
78
  };
56
79
 
57
- /** Heredoc bodies are text being written, not commands: `cat > notes.md <<EOF … git commit … EOF`. */
58
- export const withoutHeredocs = (command) => command.replace(/<<-?\s*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n[\s\S]*?\n\s*\2\s*(?=\n|$)/g, '');
59
-
60
- /** The command split where the shell would run one thing after another, never inside quotes. */
61
- export function segments(command) {
62
- const out = [];
63
- let cur = '';
64
- let q = null;
65
- for (let i = 0; i < command.length; i++) {
66
- const c = command[i];
67
- if (q) { cur += c; if (c === '\\' && q === '"') cur += command[++i] ?? ''; else if (c === q) q = null; continue; }
68
- if (c === '"' || c === "'") { q = c; cur += c; continue; }
69
- if (c === ';' || c === '\n' || c === '|' || c === '&') {
70
- if ((c === '&' || c === '|') && command[i + 1] === c) i++;
71
- out.push(cur); cur = ''; continue;
72
- }
73
- cur += c;
74
- }
75
- out.push(cur);
76
- return out.filter((s) => s.trim());
77
- }
80
+ // The quote-aware split, the heredoc strip and the shell unwrapping (`bash -c "…"`, `eval`, `env`…)
81
+ // live in shell-unwrap.mjs, shared with the other guards (E2E defect 15). It is a sibling import
82
+ // like trunk-policy.mjs; without it the guard cannot read a command and fails open.
83
+ const SH = await import(new URL('./shell-unwrap.mjs', import.meta.url)).catch(() => null);
78
84
  const tokens = (s) => s.trim().split(/\s+/).filter(Boolean);
79
85
 
80
86
  /** What a branch-moving segment moves to, or null if it is a path checkout after all (`checkout .`). */
@@ -89,6 +95,31 @@ function moveTarget(seg, dir) {
89
95
  return { name, created: false };
90
96
  }
91
97
 
98
+ /** The branch a ref-moving segment points somewhere else (`update-ref refs/heads/x`, `branch -f x`), or null. */
99
+ export function refMoveTarget(seg) {
100
+ const t = tokens(seg.replace(/^.*?\bgit\s+/, '').replace(/^(?:(?:-C|-c)\s+\S+\s+|--\S+\s+)*/, ''));
101
+ const verb = t.shift();
102
+ if (verb === 'update-ref') {
103
+ const args = [];
104
+ for (let i = 0; i < t.length; i++) {
105
+ if (t[i] === '-m') { i++; continue; }
106
+ if (t[i] === '--stdin') return null; // refs come from stdin: unreadable here
107
+ if (!t[i].startsWith('-')) args.push(t[i]);
108
+ }
109
+ const m = (args[0] || '').match(/^refs\/heads\/(.+)$/);
110
+ return m ? m[1] : null;
111
+ }
112
+ if (verb !== 'branch') return null;
113
+ const flags = t.filter((x) => x.startsWith('-'));
114
+ const names = t.filter((x) => !x.startsWith('-'));
115
+ const short = flags.filter((f) => /^-[A-Za-z]+$/.test(f)).join('');
116
+ const renames = /[mMcC]/.test(short) || flags.some((f) => /^--(move|copy)$/.test(f));
117
+ const forced = /[fMC]/.test(short) || flags.includes('--force');
118
+ if (!forced || /[dD]/.test(short)) return null;
119
+ // `branch -M [old] new` / `-C [old] new`: the last name is the one overwritten; `-f name [start]`: the first.
120
+ return (renames ? names[names.length - 1] : names[0]) || null;
121
+ }
122
+
92
123
  /**
93
124
  * Rule A and rule B, judged segment by segment: a chain is followed the way the shell runs it, so
94
125
  * `git switch -c feat/x && git commit` is a commit on feat/x (allowed) and `git switch main && git
@@ -96,12 +127,14 @@ function moveTarget(seg, dir) {
96
127
  */
97
128
  export async function evaluate({ root, command, cwd, sessionId }) {
98
129
  if (typeof command !== 'string' || !command) return null;
130
+ if (!SH) return null;
99
131
  // Words inside quotes or heredocs are text, not commands: `grep -rn "git commit" docs/` is a search.
100
- const bare = unquoted(withoutHeredocs(command));
101
- if (!COMMITS_HERE.test(bare) && !MOVES_BRANCH.test(bare)) return null;
102
- const self = (rel) => new URL(rel, import.meta.url);
103
- const raw = segments(withoutHeredocs(command));
132
+ // Except where a wrapper hands the string to a shell: `bash -c "git commit"` runs the commit, so
133
+ // the segments judged are the ones the shell would really run (shell-unwrap.mjs).
134
+ const raw = SH.expand(command);
104
135
  const segs = raw.map(unquoted);
136
+ if (!segs.some((s) => COMMITS_HERE.test(s) || MOVES_BRANCH.test(s) || MOVES_REF.test(s) || PUSHES.test(s))) return null;
137
+ const self = (rel) => new URL(rel, import.meta.url);
105
138
  let dir = cwd || root;
106
139
  const branchAt = new Map(); // toplevel → branch the chain has moved it to
107
140
 
@@ -111,7 +144,9 @@ export async function evaluate({ root, command, cwd, sessionId }) {
111
144
  if (cd) { const d = cd[1].replace(/^["']|["']$/g, ''); dir = isAbsolute(d) ? d : resolve(dir, d); continue; }
112
145
  const commits = COMMITS_HERE.test(seg);
113
146
  const moves = MOVES_BRANCH.test(seg);
114
- if (!commits && !moves) continue;
147
+ const movesRef = MOVES_REF.test(seg);
148
+ const pushes = PUSHES.test(seg);
149
+ if (!commits && !moves && !movesRef && !pushes) continue;
115
150
  const at = effectiveDir(raw[i] || seg, dir);
116
151
  const git = gitAt(at);
117
152
  const top = git('rev-parse', '--show-toplevel');
@@ -129,6 +164,32 @@ export async function evaluate({ root, command, cwd, sessionId }) {
129
164
  }
130
165
  }
131
166
 
167
+ // Rule A, the other way onto the default branch: pointing it somewhere else without a commit.
168
+ if (movesRef && !existsSync(join(root, '.rsc', '.no-trunk-guard'))) {
169
+ try {
170
+ const { trunkPolicy, defaultBranchName } = await import(self('./trunk-policy.mjs'));
171
+ const trunk = defaultBranchName(here);
172
+ if (trunk && refMoveTarget(seg) === trunk) {
173
+ const policy = trunkPolicy(here);
174
+ if (policy.closed) return refDenial({ trunk, policy });
175
+ }
176
+ } catch { /* policy module missing → nothing to enforce */ }
177
+ }
178
+
179
+ // And by pushing straight into it: `git push origin HEAD:main` from a feature branch. Branch
180
+ // protection on the server would refuse it, but a repo without one would take it (second team
181
+ // simulation) — so the closed trunk is enforced here, where the agent runs it.
182
+ if (pushes && !existsSync(join(root, '.rsc', '.no-trunk-guard'))) {
183
+ try {
184
+ const { trunkPolicy, defaultBranchName } = await import(self('./trunk-policy.mjs'));
185
+ const trunk = defaultBranchName(here);
186
+ if (trunk && pushesTo(seg, trunk)) {
187
+ const policy = trunkPolicy(here);
188
+ if (policy.closed) return refDenial({ trunk, policy, how: '`git push <remote> <branch>:' + trunk + '`' });
189
+ }
190
+ } catch { /* policy module missing → nothing to enforce */ }
191
+ }
192
+
132
193
  if (commits && !existsSync(join(root, '.rsc', '.no-trunk-guard'))) {
133
194
  try {
134
195
  const { trunkPolicy, defaultBranchName } = await import(self('./trunk-policy.mjs'));
@@ -164,6 +225,13 @@ async function trunkDenial({ root, here, sessionId, self, trunk, policy }) {
164
225
  'Unlock → only on their explicit answer, `npx @ericrisco/rsc main unlock` (a project decision, saved in .rsc.json), then commit again.';
165
226
  }
166
227
 
228
+ function refDenial({ trunk, policy, how = '`git update-ref` and a forced `git branch`' }) {
229
+ return `This project keeps its default branch "${trunk}" closed for the agent (it looks complex or in production: ${policy.signals.join(', ')}), so "${trunk}" was not moved — ` +
230
+ `${how} land${how.includes(' and ') ? '' : 's'} work on it with no review. ` +
231
+ `Work reaches "${trunk}" through a pull request: push the branch and open one (the \`ship\` skill). ` +
232
+ `Do not choose for the person: if they want "${trunk}" moved by hand, ask them in one line; only on their explicit answer, \`npx @ericrisco/rsc main unlock\` (a project decision, saved in .rsc.json).`;
233
+ }
234
+
167
235
  async function isolationDenial({ root, here, sessionId, self }) {
168
236
  const others = await othersHere({ root, here, sessionId, self });
169
237
  if (!others.length) return null;
package/targets/claude.js CHANGED
@@ -115,6 +115,9 @@ export function wireHook(paths, sourceMd, policy = {}) {
115
115
  // Shared by session-start + userprompt-gate. Hooks are materialized file by file, so a module
116
116
  // they import must be copied as their SIBLING — a subdirectory import would break once copied.
117
117
  copyFileSync(join(HERE, 'hook-once.mjs'), join(paths.projectRoot, '.rsc', 'hook-once.mjs'));
118
+ // Shared by every PreToolUse guard (and the sello): what a Bash command really runs, `bash -c` and
119
+ // `eval` unwrapped. A sibling for the same reason, and here because the danger guard needs it too.
120
+ copyFileSync(join(HERE, 'shell-unwrap.mjs'), join(paths.projectRoot, '.rsc', 'shell-unwrap.mjs'));
118
121
  // Same reason: session-start imports the reaper for the landed-worktree sweep, so it travels as a
119
122
  // sibling too. Missing it does not break startup — the sweep is wrapped — it just goes silent.
120
123
  copyFileSync(join(HERE, 'worktree-reaper.mjs'), join(paths.projectRoot, '.rsc', 'worktree-reaper.mjs'));
@@ -132,7 +135,12 @@ export function wireHook(paths, sourceMd, policy = {}) {
132
135
 
133
136
  let suggestRel = at(relative(paths.projectRoot, paths.skillDir('suggest')).split(sep).join('/'), 'SKILL.md');
134
137
  const operationsSuggest = join(paths.projectRoot, '.rsc', 'suggest-always-on.md');
135
- if (policy.codeHooks === false) {
138
+ // The lane decision (FTD or SDD) belongs to every SOFTWARE project, small ones included. Tying it to
139
+ // the code gates was the cause behind the E2E of 2026-10-07: a small project got a three-line stub
140
+ // with no lanes in it, no per-turn gate, and so never wrote a feature document. Plans from before
141
+ // the field keep their old meaning (`laneGate` follows `codeHooks`) until onboard runs again.
142
+ const laneGate = policy.laneGate ?? policy.codeHooks !== false;
143
+ if (policy.codeHooks === false && !laneGate) {
136
144
  writeFileSync(operationsSuggest, '# rsc-suggest — always-on operations layer\n\nRead `02-DOCS/wiki/harness/user-profile.md` before acting. Use `orient` to keep the user situated and `suggest` to offer a missing skill only when the current task needs it. Close with the configured orientation block.\n');
137
145
  suggestRel = at('.rsc', 'suggest-always-on.md');
138
146
  } else if (existsSync(operationsSuggest)) rmSync(operationsSuggest, { force: true });
@@ -237,16 +245,38 @@ export function wireHook(paths, sourceMd, policy = {}) {
237
245
  settings.hooks[event] = settings.hooks[event].filter((entry) => {
238
246
  const body = hookWiringOf(entry);
239
247
  return !body.includes('.rsc/ship-guard.') && !body.includes('.rsc/branch-guard.') &&
240
- !body.includes('.rsc/gitmoji-guard.') && !body.includes('.rsc/userprompt-gate.');
248
+ !body.includes('.rsc/gitmoji-guard.') && (laneGate || !body.includes('.rsc/userprompt-gate.'));
241
249
  });
242
250
  if (!settings.hooks[event].length) delete settings.hooks[event];
243
251
  }
244
- for (const name of ['ship-guard.mjs', 'branch-guard.mjs', 'trunk-policy.mjs', 'gitmoji-guard.mjs', 'userprompt-gate.mjs', 'sello.mjs']) {
245
- rmSync(join(paths.projectRoot, '.rsc', name), { force: true });
246
- }
247
- written.push(operationsSuggest);
252
+ const gone = ['ship-guard.mjs', 'branch-guard.mjs', 'gitmoji-guard.mjs', 'sello.mjs', ...(laneGate ? [] : ['trunk-policy.mjs', 'userprompt-gate.mjs'])];
253
+ for (const name of gone) rmSync(join(paths.projectRoot, '.rsc', name), { force: true });
254
+ if (laneGate) {
255
+ // Same per-turn gate as a governed project: the lane, and where code changes go here.
256
+ const fgDest = join(paths.projectRoot, '.rsc', 'userprompt-gate.mjs');
257
+ copyFileSync(join(HERE, 'userprompt-gate.mjs'), fgDest);
258
+ copyFileSync(join(HERE, 'trunk-policy.mjs'), join(paths.projectRoot, '.rsc', 'trunk-policy.mjs'));
259
+ const fgCmd = viaBootstrap('quiet', at('.rsc', 'userprompt-gate.mjs'), `"${P}"`);
260
+ settings.hooks.UserPromptSubmit ||= [];
261
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter((e) => !hookWiringOf(e).includes('.rsc/userprompt-gate.'));
262
+ settings.hooks.UserPromptSubmit.push({ hooks: [{ type: 'command', command: fgCmd }] });
263
+ written.push(fgDest, join(paths.projectRoot, '.rsc', 'trunk-policy.mjs'));
264
+ } else written.push(operationsSuggest);
248
265
  }
249
266
 
267
+ // FTD nudge: once per session, right after the first code edit with no feature document touched.
268
+ // Wherever the lane decision is installed; context only, never blocks; .rsc/.no-ftd-nudge.
269
+ settings.hooks.PostToolUse ||= [];
270
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter((e) => !hookWiringOf(e).includes('.rsc/ftd-nudge.'));
271
+ if (laneGate) {
272
+ const fnDest = join(paths.projectRoot, '.rsc', 'ftd-nudge.mjs');
273
+ copyFileSync(join(HERE, 'ftd-nudge.mjs'), fnDest);
274
+ const fnCmd = viaBootstrap('quiet', at('.rsc', 'ftd-nudge.mjs'), `"${P}"`);
275
+ settings.hooks.PostToolUse.push({ matcher: 'Edit|Write|MultiEdit|NotebookEdit', hooks: [{ type: 'command', command: fnCmd }] });
276
+ written.push(fnDest);
277
+ } else rmSync(join(paths.projectRoot, '.rsc', 'ftd-nudge.mjs'), { force: true });
278
+ if (!settings.hooks.PostToolUse.length) delete settings.hooks.PostToolUse;
279
+
250
280
  // Danger guard: its own decision, not part of the code-hook block above (#273). `init` promises it
251
281
  // to non-technical and mixed users whatever the project is, and a plan that predates the field is
252
282
  // read as "yes": a non-technical operations install gets the guard it was missing on its next sync,
@@ -43,7 +43,7 @@ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?$/i;
43
43
  * nothing checks is just drift with a head start.
44
44
  */
45
45
  export const PROJECT_OPT_OUTS = [
46
- 'audit', 'auto-update', 'claudemd-check', 'danger-guard', 'feature-gate', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'trunk-open', 'worktree-cleanup', 'worktree-isolation',
46
+ 'audit', 'auto-update', 'claudemd-check', 'danger-guard', 'feature-gate', 'ftd-nudge', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'trunk-open', 'worktree-cleanup', 'worktree-isolation',
47
47
  ];
48
48
 
49
49
  /**