@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.
- package/README.md +27 -12
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/audit.js +11 -0
- package/scripts/doctor.js +69 -11
- package/scripts/install-apply.js +221 -18
- package/scripts/lib/constitution-draft.js +156 -0
- package/scripts/lib/onboarding-apply.js +31 -25
- package/scripts/lib/onboarding.js +26 -4
- package/scripts/lib/tools-skeleton.js +76 -0
- package/scripts/rsc.js +109 -14
- package/skills/constitution/SKILL.md +2 -0
- package/skills/ftd/SKILL.md +9 -0
- package/skills/sdd/SKILL.md +2 -2
- package/skills/specify/SKILL.md +1 -1
- 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 +490 -48
- package/targets/opt-outs.js +2 -0
- package/targets/sello.mjs +9 -2
- package/targets/session-memory-adapter.mjs +27 -3
- package/targets/session-memory-core.mjs +219 -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 +114 -4
package/skills/sdd/SKILL.md
CHANGED
|
@@ -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
|
package/skills/specify/SKILL.md
CHANGED
|
@@ -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.
|
package/skills/suggest/SKILL.md
CHANGED
|
@@ -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` →
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
-
|
|
115
|
+
- With a profile, this gate is inert. Never re-onboard.
|
|
115
116
|
|
|
116
117
|
## Explain without assuming
|
|
117
118
|
|
package/targets/branch-guard.mjs
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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-close', '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
|
+
'(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
|
+
}
|