@ericrisco/rsc 3.0.7 → 3.0.9

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.
@@ -77,7 +77,7 @@ When a request lands, do not start typing code. Place it on the map first, then
77
77
  > **The new-feature gate (hard).** The moment the user is thinking about a new feature or change — "add…", "build…", "it should also…", "quiero añadir…" — it goes to `specify` first, *even if a stack skill (nextjs/fastapi/flutter/react…) also fired and could just build it*. **No feature code is written — by any skill — until a spec AND a plan exist and the user has approved them.** A stack skill about to build an unspec'd, non-trivial feature must stop and route here. The only exception is a genuinely one-line, low-risk change; name it and skip.
78
78
 
79
79
  1. **No `02-DOCS/wiki/sdd/config.yaml` and this is non-trivial?** → `sdd-init`.
80
- 2. **No constitution yet AND this project will grow?** → `constitution` once, then come back to the chain.
80
+ 2. **No constitution yet AND this project will grow — or its frontmatter says `status: draft` (the one `rsc onboard` writes)?** → `constitution` once, then come back to the chain.
81
81
  3. **Ambiguous / architectural / risky change before spec?** → optional proposal artifact via `specify`.
82
82
  4. **A new idea, fuzzy, no spec on disk?** → `specify`.
83
83
  5. **A spec exists but feels risky / has open questions?** → `clarify`.
@@ -93,7 +93,7 @@ If you genuinely cannot tell which phase you are in, ask the user one question:
93
93
  ### Skip rules (be honest about them)
94
94
 
95
95
  - A **one-line, low-risk change** (typo, copy tweak, config bump) does not need the full chain. Say so, do it, and verify. The method serves shipping, not ceremony.
96
- - `constitution` runs **once per project**, not per feature. If `02-DOCS/wiki/sdd/constitution.md` exists, read it as guardrails and move on.
96
+ - `constitution` runs **once per project**, not per feature. If `02-DOCS/wiki/sdd/constitution.md` exists, read it as guardrails and move on — unless it is still `status: draft`: then complete it first.
97
97
  - `clarify` and `analyze` are **gates, not paperwork**. If a spec is genuinely unambiguous and tiny, name that out loud and pass through — but the bias is to run them, because skipped gates are where drift hides.
98
98
 
99
99
  ## Autopilot mode — run the whole chain on one up-front yes
@@ -341,7 +341,7 @@ repeated here as prohibitions: naming a behaviour makes it more available, not l
341
341
  ## Project grounding (02-DOCS + CLAUDE.md)
342
342
 
343
343
  - Read `02-DOCS/wiki/sdd/config.yaml` if present. If it is missing and the change is non-trivial, recommend `sdd-init` before proceeding; if the user asks to continue, record the missing config as a risk.
344
- - Read `02-DOCS/wiki/sdd/constitution.md` first — its principles are inherited constraints, not things to re-decide. If it's missing, note that the project has no constitution yet and suggest the `constitution` phase before continuing (you can still draft a spec, but flag the absence).
344
+ - Read `02-DOCS/wiki/sdd/constitution.md` first — its principles are inherited constraints, not things to re-decide. If it's missing, note that the project has no constitution yet and suggest the `constitution` phase before continuing (you can still draft a spec, but flag the absence). If its frontmatter says `status: draft` (written by `rsc onboard`, unratified), route to `constitution` first and come back: a draft has no principles to inherit.
345
345
  - **No constitution yet?** Still write the spec, but inherit nothing — lean harder on the wiki and the user's answers, and record every constraint you would have inherited as a *point to clarify* instead of assuming it.
346
346
  - Write the spec to `02-DOCS/wiki/sdd/specs/<slug>.md`. Create the directory if absent.
347
347
  - Add a row in `02-DOCS/wiki/index.md` (the Knowledge map; root `CLAUDE.md` keeps only a short pointer) linking the new spec under the `sdd/specs` topic (additive only — never delete existing rows). Create the index if absent.
@@ -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-close', 'ftd-nudge', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'trunk-open', 'worktree-cleanup', 'worktree-isolation',
47
47
  ];
48
48
 
49
49
  /**
@@ -5,8 +5,14 @@
5
5
  //
6
6
  // For a NON-TECHNICAL user (per 02-DOCS/wiki/harness/user-profile.md → technical_level),
7
7
  // it DENIES irreversible, foot-gun Bash commands and tells the agent to find a safer,
8
- // scoped alternative. A fully `technical` user is never guarded. Default-safe: if there
9
- // is no profile yet, the harness convention is "assume non-technical", so the guard is ON.
8
+ // scoped alternative. Default-safe: if there is no profile yet, the harness convention is
9
+ // "assume non-technical", so the guard is ON.
10
+ //
11
+ // A `technical` user used to get nothing at all, and the E2E of 2026-10-07 showed what that means:
12
+ // a small project with `main` open had no guard whatsoever, and `git reset --hard` would have wiped
13
+ // uncommitted work with only the model's judgment in the way. So a technical user now gets the
14
+ // short list of commands that destroy WORK OR SHARED HISTORY with no undo, as an `ask`: Claude Code
15
+ // stops and the person confirms. Never a deny — a technical person who wants it gets it in one click.
10
16
  //
11
17
  // Disable per project with .rsc/.no-danger-guard — but only when the USER explicitly asks
12
18
  // for it (the deny message says so). Fail-open on any internal error (never brick a shell).
@@ -16,6 +22,16 @@ import { join } from 'node:path';
16
22
  const root = process.argv[2] || process.cwd();
17
23
 
18
24
  function allow() { process.exit(0); }
25
+ function ask(why) {
26
+ process.stdout.write(JSON.stringify({
27
+ hookSpecificOutput: {
28
+ hookEventName: 'PreToolUse',
29
+ permissionDecision: 'ask',
30
+ permissionDecisionReason: `rsc: this command ${why}. It cannot be undone, so the person confirms it.`,
31
+ },
32
+ }));
33
+ process.exit(0);
34
+ }
19
35
  function deny(why) {
20
36
  process.stdout.write(JSON.stringify({
21
37
  hookSpecificOutput: {
@@ -23,8 +39,9 @@ function deny(why) {
23
39
  permissionDecision: 'deny',
24
40
  permissionDecisionReason:
25
41
  `BLOCKED for a non-technical user — this command ${why}. ` +
26
- `Do NOT run it: explain the risk in plain language and propose a safer, scoped alternative ` +
27
- `(name exact paths, add a WHERE clause, back up first, etc.). ` +
42
+ `Do NOT run it, and do NOT reach the same result another way (a softer reset plus a delete is the ` +
43
+ `same loss). Explain the risk in plain language, let the person decide, and propose a safer, scoped ` +
44
+ `alternative that keeps their work (name exact paths, add a WHERE clause, back up first, etc.). ` +
28
45
  `Only if the USER explicitly insists on allowing dangerous commands here, create .rsc/.no-danger-guard to disable this guard.`,
29
46
  },
30
47
  }));
@@ -34,14 +51,22 @@ function deny(why) {
34
51
  if (existsSync(join(root, '.rsc', '.no-danger-guard'))) allow();
35
52
 
36
53
  // technical_level === 'technical' → not guarded. non-technical / mixed / missing → guarded.
54
+ // The person's own profile first. It is personal and kept out of commits, so a teammate's clone has
55
+ // none — and falling straight to "non-technical" turned every clone into a wall of denials (field
56
+ // test 3.0.8). Next comes the level the harness was installed with, recorded in `.rsc.json`, which
57
+ // the team commits. Only with neither is the safe default applied.
37
58
  function technicalLevel() {
38
59
  try {
39
60
  const txt = readFileSync(join(root, '02-DOCS', 'wiki', 'harness', 'user-profile.md'), 'utf8');
40
61
  const m = txt.match(/technical_level:\s*([a-z-]+)/i);
41
- return m ? m[1].toLowerCase() : null;
62
+ if (m) return m[1].toLowerCase();
63
+ } catch { /* no profile here */ }
64
+ try {
65
+ const level = JSON.parse(readFileSync(join(root, '.rsc.json'), 'utf8'))?.onboarding?.plan?.record?.technicalLevel;
66
+ return typeof level === 'string' ? level.toLowerCase() : null;
42
67
  } catch { return null; }
43
68
  }
44
- if (technicalLevel() === 'technical') allow();
69
+ const technical = technicalLevel() === 'technical';
45
70
 
46
71
  // Only Bash commands can be dangerous here.
47
72
  let input = {};
@@ -84,7 +109,9 @@ const SHELLS = /^(bash|sh|zsh|dash|ksh)$/i;
84
109
  // this, `echo "never run rm -rf /"` was denied — the guard blocking the conversation about a
85
110
  // command instead of the command, exactly the defect the gitmoji guard had to fix with its own
86
111
  // RUNS_GIT check. A guard that argues with you about a sentence is a guard you turn off.
87
- function runsRm(segment) {
112
+ function runsRm(segment) { return runs(segment, 'rm'); }
113
+
114
+ function runs(segment, bin) {
88
115
  const tokens = segment.trim().split(/\s+/).filter(Boolean);
89
116
  let i = 0;
90
117
  while (i < tokens.length) {
@@ -95,14 +122,28 @@ function runsRm(segment) {
95
122
  while (i < tokens.length && /^-/.test(tokens[i])) i += 1; // skip -c and friends
96
123
  continue;
97
124
  }
98
- return bare === 'rm' || bare.endsWith('/rm');
125
+ return bare === bin || bare.endsWith(`/${bin}`);
99
126
  }
100
127
  return false;
101
128
  }
102
129
 
130
+ // The shared unwrapper (E2E defect 15) adds the segments the shell really runs — `eval "rm …"`,
131
+ // `exec rm …`, `FOO=1 rm …`, a `cd x && rm …` inside `bash -c` — on top of the plain split, so it
132
+ // can only ever see MORE, never fewer. Sibling import under `.rsc/`; without it, as before.
133
+ const SH = await import(new URL('./shell-unwrap.mjs', import.meta.url)).catch(() => null);
134
+ const runSegments = () => {
135
+ const unwrapped = (() => { try { return SH ? SH.expand(cmd) : []; } catch { return []; } })();
136
+ return [...cmd.split(/\|\||&&|[|;&]/), ...unwrapped];
137
+ };
138
+
139
+ // A git rule judges the segment that RUNS git, so `echo 'git reset --hard'` is a sentence, not a reset.
140
+ function gitSegment(re) {
141
+ return runSegments().some((segment) => runs(segment, 'git') && re.test(segment));
142
+ }
143
+
103
144
  function isRmRecursiveForce() {
104
145
  if (!/\brm\b/.test(cmd)) return false;
105
- for (const segment of cmd.split(/\|\||&&|[|;&]/)) {
146
+ for (const segment of runSegments()) {
106
147
  if (!runsRm(segment)) continue;
107
148
  const flags = rmFlagsIn(segment);
108
149
  const hasR = flags.some((f) => /^--recursive$/i.test(f) || /^-[A-Za-z]*r/i.test(f));
@@ -112,6 +153,9 @@ function isRmRecursiveForce() {
112
153
  return false;
113
154
  }
114
155
 
156
+ // `git push`, with global options allowed before the subcommand (`git -C . push`, `git -c k=v push`).
157
+ const GIT_PUSH = String.raw`\bgit\s+(?:(?:-[Cc]\s+\S+|--\S+)\s+)*push\b`;
158
+
115
159
  const RULES = [
116
160
  { id: 'rm-rf', why: 'deletes whole files/folders irreversibly (rm with -r and -f)', match: isRmRecursiveForce },
117
161
  { id: 'find-delete', why: 'mass-deletes matched files (find … -delete / -exec rm)', match: () => /\bfind\b[^|;&]*(-delete\b|-exec\s+rm\b)/i.test(cmd) },
@@ -119,10 +163,17 @@ const RULES = [
119
163
  { id: 'mkfs', why: 'formats a filesystem, erasing everything on it (mkfs)', match: () => /\bmkfs(\.\w+)?\b/i.test(cmd) },
120
164
  { id: 'curl-pipe-shell', why: 'pipes a downloaded script straight into a shell (curl|bash) — runs untrusted code', match: () => /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh)\b/i.test(cmd) },
121
165
 
122
- { id: 'git-push-force', why: 'force-pushes and can overwrite shared history for everyone (git push --force)', match: () => /\bgit\s+push\b[^|;&]*(--force(?!-with-lease)\b|\s-f\b)/i.test(cmd) },
123
- { id: 'git-reset-hard', why: 'throws away all uncommitted work with no undo (git reset --hard)', match: () => /\bgit\s+reset\b[^|;&]*--hard\b/i.test(cmd) },
124
- { id: 'git-clean', why: 'permanently deletes untracked files (git clean -f)', match: () => /\bgit\s+clean\b[^|;&]*-[a-z]*f/i.test(cmd) },
125
- { id: 'git-branch-D', why: 'force-deletes a branch even if its work was never merged (git branch -D)', match: () => /\bgit\s+branch\b[^|;&]*\s-D\b/.test(cmd) },
166
+ // A `+` in front of a refspec forces that ref exactly like the flag (team simulation D7).
167
+ { id: 'git-push-force', why: 'force-pushes and can overwrite shared history for everyone (git push --force, or a +refspec)', match: () => gitSegment(new RegExp(String.raw`${GIT_PUSH}[^|;&]*(--force(?!-with-lease)\b|\s-f\b|\s\+[^\s+])`, 'i')) },
168
+ // The "safer" force: it refuses only when the remote moved since the last fetch, and a fetch that
169
+ // already happened (an IDE, an earlier pull) satisfies it. What it rewrites is still history the
170
+ // team may have pulled, so it is asked / denied like the plain flag (team simulation D7).
171
+ { id: 'git-push-lease', why: 'force-pushes with a lease and still rewrites history the team may have pulled (git push --force-with-lease)', match: () => gitSegment(new RegExp(String.raw`${GIT_PUSH}[^|;&]*--force-with-lease\b`, 'i')) },
172
+ { id: 'git-reset-hard', why: 'throws away uncommitted work, and drops commits when it moves back, with no undo (git reset --hard)', match: () => gitSegment(/\bgit\s+reset\b[^|;&]*--hard\b/i) },
173
+ { id: 'git-clean', why: 'permanently deletes untracked files (git clean -f)', match: () => gitSegment(/\bgit\s+clean\b[^|;&]*-[a-z]*f/i) },
174
+ { id: 'git-discard', why: 'discards every uncommitted change in the tree with no undo (git checkout -- . / git restore .)', match: () => gitSegment(/\bgit\s+(checkout\s+(--\s+)?\.|restore\s+(--(staged|worktree)\s+)*\.)(\s|$|[|;&])/i) },
175
+ { id: 'git-no-verify', why: 'skips the project\'s own git hooks (--no-verify)', match: () => gitSegment(/\bgit\s+(commit|push|merge|rebase|am|cherry-pick)\b.*\s--no-verify\b/i) },
176
+ { id: 'git-branch-D', why: 'force-deletes a branch even if its work was never merged (git branch -D)', match: () => gitSegment(/\bgit\s+branch\b[^|;&]*\s-D\b/) },
126
177
 
127
178
  { id: 'sql-drop', why: 'drops an entire database/schema/table (DROP …)', match: () => /\bdrop\s+(database|schema|table)\b/i.test(cmd) },
128
179
  { id: 'sql-truncate', why: 'empties an entire table (TRUNCATE)', match: () => /\btruncate\s+(table\s+)?\S/i.test(cmd) },
@@ -131,8 +182,23 @@ const RULES = [
131
182
  { id: 'mongo-wipe', why: 'drops a collection/database or deletes all documents (drop()/dropDatabase/deleteMany({}))', match: () => /\.drop\(\s*\)|dropdatabase\s*\(|deletemany\(\s*\{\s*\}\s*\)|\.remove\(\s*\{\s*\}\s*\)/i.test(cmd) },
132
183
  ];
133
184
 
185
+ // What a technical person is asked about: work or shared history gone with no undo. Everything else
186
+ // on the list (a scoped rm -rf, SQL, curl|bash) is ordinary for them and stays silent (P7).
187
+ const FOR_EVERYONE = new Set(['git-push-force', 'git-push-lease','git-reset-hard', 'git-clean', 'git-discard', 'git-no-verify', 'git-branch-D', 'dd-disk', 'mkfs', 'rm-rf-root']);
188
+
189
+ // rm -rf aimed at the project itself, the home or the filesystem root is a wipe for anybody.
190
+ function isRmRfRoot() {
191
+ if (!isRmRecursiveForce()) return false;
192
+ return /\brm\b[^|;&]*\s(\/|~\/?|\$HOME\/?|\.\/?|\.\.\/?|\*|\.git\/?)(\s|$|[|;&])/.test(cmd);
193
+ }
194
+ RULES.push({ id: 'rm-rf-root', why: 'recursively deletes the project, the home folder or the filesystem root', match: isRmRfRoot });
195
+
134
196
  for (const rule of RULES) {
135
- try { if (rule.match()) deny(rule.why); } catch { /* a rule erroring must never block */ }
197
+ try {
198
+ if (!rule.match()) continue;
199
+ if (!technical) deny(rule.why);
200
+ if (FOR_EVERYONE.has(rule.id)) ask(rule.why);
201
+ } catch { /* a rule erroring must never block */ }
136
202
  }
137
203
 
138
204
  allow();
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ // rsc FTD nudge (claude). PostToolUse on Edit|Write|MultiEdit|NotebookEdit.
3
+ // argv[2] = absolute project root stdin = PostToolUse hook JSON
4
+ //
5
+ // The lane rule ("simple → FTD: one feature document before the first change") lived only in prose,
6
+ // and the E2E of 2026-10-07 measured what prose gets: five sessions of code changes, zero feature
7
+ // documents, zero skill calls. Prose asks; this decides the one observable thing — code is being
8
+ // changed and no feature document has been touched in this session — and says so ONCE per session,
9
+ // right after the first code edit, when it is still cheap to write the document.
10
+ //
11
+ // A session is "documented" the moment any edit touches 02-DOCS/wiki/ftd/ or 02-DOCS/wiki/sdd/.
12
+ // Docs, wiki, harness files and markdown are never "code". Never blocks: context only.
13
+ // Paths inside `.worktrees/<branch>/` are classified as that worktree sees them, and a branch whose
14
+ // own commits already touch a feature document counts as documented.
15
+ // Off for a project with .rsc/.no-ftd-nudge. Fail-open on every error.
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { spawnSync } from 'node:child_process';
20
+
21
+ const STATE = ['.rsc', 'memory', 'ftd-nudge.json'];
22
+ const KEEP = 50;
23
+
24
+ export const NUDGE = 'rsc · lane: this session is changing code and has no feature document yet. ' +
25
+ 'Before going on, decide the lane and say it in one line: simple → write 02-DOCS/wiki/ftd/<slug>.md now ' +
26
+ '(frontmatter author/branch/status: in-progress; Intent, Scope, Checklist with how each item is proven, Evidence, Next) — one document per feature, ' +
27
+ 'a box ticked only with its observed proof under Evidence, Next kept current; big or ' +
28
+ 'interlocking → SDD (spec first). A one-line, zero-risk change needs no document: say so instead.';
29
+
30
+ const posix = (p) => p.split(sep).join('/');
31
+
32
+ // `.worktrees/<branch>/rest` → { worktree: '.worktrees/<branch>', rest }. A branch may hold slashes
33
+ // (`feat/busqueda` lives two levels down), so the worktree is the deepest prefix with a `.git` entry
34
+ // on disk; with nothing on disk to ask, the first segment is the branch.
35
+ function splitWorktree(root, rel) {
36
+ const parts = rel.split('/');
37
+ if (parts[0] !== '.worktrees' || parts.length < 3) return null;
38
+ for (let end = parts.length - 1; end >= 2; end -= 1) {
39
+ if (existsSync(join(root, ...parts.slice(0, end), '.git'))) return { worktree: parts.slice(0, end).join('/'), rest: parts.slice(end).join('/') };
40
+ }
41
+ return { worktree: parts.slice(0, 2).join('/'), rest: parts.slice(2).join('/') };
42
+ }
43
+
44
+ export function classify(root, filePath) {
45
+ if (!filePath || typeof filePath !== 'string') return 'other';
46
+ let rel = posix(isAbsolute(filePath) ? relative(root, filePath) : filePath);
47
+ if (rel.startsWith('..')) return 'other';
48
+ // A session isolated in `.worktrees/<branch>/` edits the same project; classify what it edits as
49
+ // that worktree sees it. Every path there used to read as "other", so the isolated sessions — the
50
+ // whole team workflow — were never nudged (team simulation D11).
51
+ const nested = splitWorktree(root, rel);
52
+ if (nested) rel = nested.rest;
53
+ if (/^02-DOCS\/wiki\/(ftd|sdd)\//.test(rel)) return 'document';
54
+ if (/^(02-DOCS|01-TOOLS|\.rsc|\.claude|\.git|\.github|\.worktrees)\//.test(rel)) return 'other';
55
+ if (/\.(md|mdx|txt|rst)$/i.test(rel)) return 'other';
56
+ return 'code';
57
+ }
58
+
59
+ function readState(root) {
60
+ try { return JSON.parse(readFileSync(join(root, ...STATE), 'utf8')); } catch { return {}; }
61
+ }
62
+
63
+ function writeState(root, state) {
64
+ const entries = Object.entries(state).sort((a, b) => (b[1].at || 0) - (a[1].at || 0)).slice(0, KEEP);
65
+ mkdirSync(join(root, '.rsc', 'memory'), { recursive: true });
66
+ writeFileSync(join(root, ...STATE), `${JSON.stringify(Object.fromEntries(entries))}\n`);
67
+ }
68
+
69
+ // The checkout the edited file belongs to: its worktree under `.worktrees/`, or the project itself.
70
+ function checkoutOf(root, filePath) {
71
+ if (typeof filePath !== 'string') return root;
72
+ const rel = posix(isAbsolute(filePath) ? relative(root, filePath) : filePath);
73
+ const nested = rel.startsWith('..') ? null : splitWorktree(root, rel);
74
+ return nested ? join(root, ...nested.worktree.split('/')) : root;
75
+ }
76
+
77
+ function gitOut(cwd, args) {
78
+ const r = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', windowsHide: true, timeout: 5000 });
79
+ return r.status === 0 ? (r.stdout || '').trim() : null;
80
+ }
81
+
82
+ /**
83
+ * Is this branch already documented — does a commit of its own, not on the default branch, touch a
84
+ * feature document? Then the session is continuing documented work (resolving a merge conflict, a
85
+ * fix-up after review) and the nudge would ask for a document that exists (team simulation D11).
86
+ * Asked only at the moment of nudging, once per session. Any doubt answers "no": nudging once too
87
+ * often is the cheap mistake.
88
+ */
89
+ export function branchHasFeatureDoc(checkout) {
90
+ try {
91
+ const head = gitOut(checkout, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
92
+ if (!head) return false;
93
+ const remoteHead = gitOut(checkout, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
94
+ const trunk = [remoteHead, 'origin/main', 'origin/master', 'main', 'master']
95
+ .filter(Boolean)
96
+ .find((ref) => ref !== head && gitOut(checkout, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]) !== null);
97
+ if (!trunk) return false;
98
+ const touched = gitOut(checkout, ['log', '--format=', '--name-only', `${trunk}..HEAD`, '--', '02-DOCS/wiki/ftd/']);
99
+ return Boolean(touched && touched.split('\n').some((line) => /^02-DOCS\/wiki\/ftd\/[^/]+\.md$/.test(line.trim())));
100
+ } catch { return false; }
101
+ }
102
+
103
+ /** The decision, pure enough to test: returns the context to inject, or ''. */
104
+ export function decide(root, input, now = Date.now()) {
105
+ if (existsSync(join(root, '.rsc', '.no-ftd-nudge'))) return '';
106
+ const session = String(input.session_id || input.sessionId || '');
107
+ if (!session) return '';
108
+ const tool = input.tool_input || input.toolInput || {};
109
+ const file = tool.file_path || tool.notebook_path || tool.path;
110
+ const kind = classify(root, file);
111
+ if (kind === 'other') return '';
112
+ const state = readState(root);
113
+ const entry = state[session] || {};
114
+ if (kind === 'document') {
115
+ if (!entry.documented) { state[session] = { ...entry, documented: true, at: now }; writeState(root, state); }
116
+ return '';
117
+ }
118
+ if (entry.documented || entry.nudged) return '';
119
+ if (branchHasFeatureDoc(checkoutOf(root, file))) {
120
+ state[session] = { ...entry, documented: true, at: now };
121
+ writeState(root, state);
122
+ return '';
123
+ }
124
+ state[session] = { ...entry, nudged: true, at: now };
125
+ writeState(root, state);
126
+ return NUDGE;
127
+ }
128
+
129
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
130
+ try {
131
+ const root = process.argv[2] || process.cwd();
132
+ const input = JSON.parse(readFileSync(0, 'utf8') || '{}');
133
+ const text = decide(root, input);
134
+ if (text) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: text } }));
135
+ } catch { /* fail open */ }
136
+ process.exit(0);
137
+ }