@ericrisco/rsc 3.0.7 → 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/README.md +22 -11
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/audit.js +11 -0
- package/scripts/doctor.js +63 -11
- package/scripts/install-apply.js +221 -18
- package/scripts/lib/onboarding-apply.js +27 -24
- package/scripts/lib/onboarding.js +19 -4
- package/scripts/lib/tools-skeleton.js +76 -0
- package/scripts/rsc.js +92 -13
- package/skills/suggest/SKILL.md +5 -4
- package/targets/branch-guard.mjs +94 -26
- package/targets/claude.js +36 -6
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/danger-guard.mjs +80 -14
- package/targets/ftd-nudge.mjs +137 -0
- package/targets/gitmoji-guard.mjs +14 -1
- package/targets/knowledge-sync.mjs +203 -45
- package/targets/opt-outs.js +1 -0
- package/targets/sello.mjs +9 -2
- package/targets/session-memory-adapter.mjs +27 -3
- package/targets/session-memory-core.mjs +211 -36
- package/targets/session-start.mjs +40 -5
- package/targets/shell-unwrap.mjs +145 -0
- package/targets/ship-guard.mjs +40 -9
- package/targets/team-safe-start.mjs +76 -10
- package/targets/worktree-reaper.mjs +109 -3
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
/**
|
package/targets/danger-guard.mjs
CHANGED
|
@@ -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.
|
|
9
|
-
//
|
|
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
|
|
27
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
123
|
-
{ id: 'git-
|
|
124
|
-
|
|
125
|
-
|
|
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 {
|
|
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
|
+
'(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
|
+
}
|
|
@@ -143,7 +143,11 @@ const HAS_INLINE_M = /(?:^|\s)(?:-[a-zA-Z]*m|--message)(?:=|\s|$)/;
|
|
|
143
143
|
// `git` must be the command being RUN, not text inside someone else's argument —
|
|
144
144
|
// otherwise `echo "git commit -m x"` or `grep "git commit -m"` gets denied, and a guard
|
|
145
145
|
// that blocks the discussion of commits is exactly the kind that gets switched off.
|
|
146
|
-
const RUNS_GIT = /^\s*(?:sudo\s
|
|
146
|
+
const RUNS_GIT = /^\s*(?:(?:sudo|env|command|exec|nohup)\s+|[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*git\b/;
|
|
147
|
+
|
|
148
|
+
// A commit handed to a shell as a string (`bash -c "git commit -m …"`, `eval …`) is read inside
|
|
149
|
+
// that string (E2E defect 15). shell-unwrap.mjs is a sibling under `.rsc/`; without it, as before.
|
|
150
|
+
const SH = await import(new URL('./shell-unwrap.mjs', import.meta.url)).catch(() => null);
|
|
147
151
|
|
|
148
152
|
const SEPARATORS = /&&|\|\||[;\n]/;
|
|
149
153
|
|
|
@@ -174,6 +178,15 @@ function inlineMessage(segment) {
|
|
|
174
178
|
export function commitMessages(command) {
|
|
175
179
|
const cmd = String(command ?? '');
|
|
176
180
|
if (!COMMIT.test(cmd)) return [];
|
|
181
|
+
const inner = SH ? SH.innerScripts(cmd) : [];
|
|
182
|
+
if (!inner.length) return directMessages(cmd);
|
|
183
|
+
// The wrapped strings are read on their own; the rest of the chain without them, so a commit is
|
|
184
|
+
// never read twice (once naively through the quotes, once unwrapped).
|
|
185
|
+
const rest = SH.segments(SH.withoutHeredocs(cmd)).filter((seg) => SH.unwrap(seg)?.inner === undefined).join('\n');
|
|
186
|
+
return [...directMessages(rest), ...inner.flatMap(commitMessages)];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function directMessages(cmd) {
|
|
177
190
|
|
|
178
191
|
// The heredoc form must be handled before any splitting: its body is the message and
|
|
179
192
|
// may legitimately contain && or ;. The command that owns it is whatever runs just
|