@ericrisco/rsc 2.1.0 → 3.0.0
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 +141 -15
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/install-apply.js +21 -0
- package/scripts/rsc.js +67 -1
- package/skills/ftd/SKILL.md +10 -4
- package/skills/ftd/evals/cases.yaml +1 -1
- package/skills/ftd/evals/routing.json +2 -2
- package/skills/init/SKILL.md +10 -1
- package/skills/ship/SKILL.md +6 -0
- package/skills/suggest/SKILL.md +9 -9
- package/skills/worktrees/SKILL.md +1 -0
- package/targets/_md-block.js +7 -1
- package/targets/auto-update.mjs +200 -0
- package/targets/branch-guard.mjs +191 -0
- package/targets/claude.js +22 -3
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/hook-once.mjs +5 -6
- package/targets/knowledge-sync.mjs +544 -0
- package/targets/knowledge-wiring.js +172 -0
- package/targets/opt-outs.js +4 -0
- package/targets/session-memory-adapter.mjs +20 -2
- package/targets/session-memory-core.mjs +33 -0
- package/targets/session-start.mjs +12 -74
- package/targets/team-safe-start.mjs +115 -0
- package/targets/trunk-policy.mjs +69 -0
- package/targets/update-wiring.js +153 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// rsc update check, shared by every assistant that can run it. Standalone (Node built-ins only),
|
|
3
|
+
// copied to `.rsc/auto-update.mjs` at install, like the other hooks.
|
|
4
|
+
//
|
|
5
|
+
// Three ways in, one rule:
|
|
6
|
+
// - Claude Code: `session-start.mjs` imports `updateNotice` and prints it with the always-on body.
|
|
7
|
+
// - Codex, Gemini CLI, Cursor: a session-start hook runs `node .rsc/auto-update.mjs hook <target>`
|
|
8
|
+
// and gets the notice back in that assistant's own output shape.
|
|
9
|
+
// - OpenCode: its plugin imports `updateNotice` and hands the notice to the model.
|
|
10
|
+
// - Any other assistant has no hook. The always-on body asks the agent to run
|
|
11
|
+
// `node .rsc/auto-update.mjs` on its first turn, and to relay what it prints.
|
|
12
|
+
import { readFileSync, existsSync, writeFileSync, openSync } from 'node:fs';
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { join, resolve } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
// Update check: compare the installed version (.rsc/.version, written at install)
|
|
18
|
+
// against the latest published on npm, and nudge the agent to offer an update.
|
|
19
|
+
// Fail-silent (offline / missing baseline / parse error → nothing). Disable with
|
|
20
|
+
// RSC_NO_UPDATE_CHECK=1. RSC_LATEST overrides the npm lookup (tests / mirrors).
|
|
21
|
+
function isNewer(a, b) {
|
|
22
|
+
const pa = String(a).split('.').map(Number);
|
|
23
|
+
const pb = String(b).split('.').map(Number);
|
|
24
|
+
for (let i = 0; i < 3; i++) {
|
|
25
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return true;
|
|
26
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return false;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// "rsc X is out" was being ignored — the maintainer's own workspace sat on 2.0.4 for days with this
|
|
32
|
+
// notice on every session, and two people in two days reported bugs that were already fixed. A
|
|
33
|
+
// version number names nothing anyone recognises. A symptom does: someone who sees a red error after
|
|
34
|
+
// every turn updates the moment they are told that is what the update fixes (`suggest` §3: say what
|
|
35
|
+
// is wrong as a symptom, not as a cause).
|
|
36
|
+
//
|
|
37
|
+
// The symptoms travel for free: the registry's `/latest` document is the published package.json, so
|
|
38
|
+
// its `rscFixes` field — `{ fixedIn, symptom }` — arrives in the response this check already makes.
|
|
39
|
+
// It is text a model will read, so every entry is reduced to one short plain line or dropped.
|
|
40
|
+
const SEMVER_ONLY = /^\d+\.\d+\.\d+$/;
|
|
41
|
+
function fixesSince(list, installed, latest) {
|
|
42
|
+
if (!Array.isArray(list)) return [];
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const item of list) {
|
|
45
|
+
if (!item || typeof item !== 'object' || typeof item.symptom !== 'string') continue;
|
|
46
|
+
if (!SEMVER_ONLY.test(String(item.fixedIn))) continue;
|
|
47
|
+
if (!isNewer(item.fixedIn, installed) || isNewer(item.fixedIn, latest)) continue;
|
|
48
|
+
const symptom = item.symptom.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
49
|
+
if (symptom) out.push({ fixedIn: item.fixedIn, symptom });
|
|
50
|
+
}
|
|
51
|
+
return out.slice(0, 8);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Auto-update. Asking was not enough: the notice depended on an agent relaying it, and people kept
|
|
55
|
+
// running bugs that were already fixed. So a release in the SAME major installs itself; a new major
|
|
56
|
+
// can change how the harness works, and still asks. On by default; `.rsc/.no-auto-update` (a project
|
|
57
|
+
// decision, travels in `optOuts`) brings back the question for every release.
|
|
58
|
+
//
|
|
59
|
+
// It runs in the background because `npx` can take longer than a hook may, and it changes nothing in
|
|
60
|
+
// this session anyway: the skills are already loaded. The version is the exact one seen here, never
|
|
61
|
+
// `@latest` — what is published in between does not slip in. One attempt per version: a failure falls
|
|
62
|
+
// back to asking, and is retried at most once a day.
|
|
63
|
+
const stateFile = (root) => join(root, '.rsc', 'auto-update.json');
|
|
64
|
+
const logFile = (root) => join(root, '.rsc', 'auto-update.log');
|
|
65
|
+
const AUTO_RUNNING_MS = 15 * 60 * 1000;
|
|
66
|
+
const AUTO_RETRY_MS = 24 * 60 * 60 * 1000;
|
|
67
|
+
const majorOf = (v) => Number(String(v).split('.')[0]) || 0;
|
|
68
|
+
|
|
69
|
+
function readAutoState(root) {
|
|
70
|
+
try { return JSON.parse(readFileSync(stateFile(root), 'utf8')); } catch { return null; }
|
|
71
|
+
}
|
|
72
|
+
function writeAutoState(root, state) {
|
|
73
|
+
try { writeFileSync(stateFile(root), `${JSON.stringify(state, null, 2)}\n`); } catch { /* best effort */ }
|
|
74
|
+
}
|
|
75
|
+
function declaredTargets(root) {
|
|
76
|
+
try {
|
|
77
|
+
const list = JSON.parse(readFileSync(join(root, '.rsc.json'), 'utf8')).targets;
|
|
78
|
+
return Array.isArray(list) ? list.filter((t) => typeof t === 'string' && /^[a-z0-9-]+$/.test(t)) : [];
|
|
79
|
+
} catch { return []; }
|
|
80
|
+
}
|
|
81
|
+
function launchUpdate(root, version, env) {
|
|
82
|
+
// RSC_AUTO_UPDATE_CMD (a JSON argv) replaces npx in tests; the arguments after it are the real ones.
|
|
83
|
+
const base = env.RSC_AUTO_UPDATE_CMD
|
|
84
|
+
? JSON.parse(env.RSC_AUTO_UPDATE_CMD)
|
|
85
|
+
: ['npx', '-y', `@ericrisco/rsc@${version}`];
|
|
86
|
+
const targets = declaredTargets(root);
|
|
87
|
+
const args = [...base.slice(1), 'sync', ...(targets.length ? ['--target', targets.join(',')] : [])];
|
|
88
|
+
const log = openSync(logFile(root), 'w');
|
|
89
|
+
const child = spawn(base[0], args, {
|
|
90
|
+
cwd: root,
|
|
91
|
+
detached: true,
|
|
92
|
+
stdio: ['ignore', log, log],
|
|
93
|
+
windowsHide: true,
|
|
94
|
+
shell: process.platform === 'win32', // npx is npx.cmd there; every argument was validated above
|
|
95
|
+
env: { ...env, RSC_NO_UPDATE_CHECK: '1', RSC_AUTO_UPDATE_TO: version },
|
|
96
|
+
});
|
|
97
|
+
child.on('error', () => {});
|
|
98
|
+
child.unref();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function updateNotice(root, env = process.env) {
|
|
102
|
+
let out = '';
|
|
103
|
+
const say = (text) => { out += text; };
|
|
104
|
+
try {
|
|
105
|
+
const installed = readFileSync(join(root, '.rsc', '.version'), 'utf8').trim();
|
|
106
|
+
// A background update that finished since the last session: say so once, then keep quiet.
|
|
107
|
+
const prior = readAutoState(root);
|
|
108
|
+
if (prior && !prior.reported && SEMVER_ONLY.test(String(prior.to)) && !isNewer(prior.to, installed)) {
|
|
109
|
+
say(`
|
|
110
|
+
===== rsc updated =====
|
|
111
|
+
rsc updated itself in the background, from ${prior.from} to ${installed}. It is active in this session.
|
|
112
|
+
ACTION: tell the user in one line. Files of the harness may show as changed in git.
|
|
113
|
+
=======================
|
|
114
|
+
`);
|
|
115
|
+
writeAutoState(root, { ...prior, reported: true });
|
|
116
|
+
}
|
|
117
|
+
let doc = null;
|
|
118
|
+
if (env.RSC_LATEST_JSON) doc = JSON.parse(env.RSC_LATEST_JSON);
|
|
119
|
+
else if (env.RSC_LATEST) doc = { version: env.RSC_LATEST };
|
|
120
|
+
else {
|
|
121
|
+
const ctrl = new AbortController();
|
|
122
|
+
const timer = setTimeout(() => ctrl.abort(), 1500);
|
|
123
|
+
const res = await fetch('https://registry.npmjs.org/@ericrisco%2frsc/latest', { signal: ctrl.signal });
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
doc = await res.json();
|
|
126
|
+
}
|
|
127
|
+
const latest = doc?.version;
|
|
128
|
+
if (installed && SEMVER_ONLY.test(String(latest)) && isNewer(latest, installed)) {
|
|
129
|
+
const newMajor = majorOf(latest) !== majorOf(installed);
|
|
130
|
+
const auto = !existsSync(join(root, '.rsc', '.no-auto-update')) && !newMajor;
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
const since = prior && prior.to === latest ? now - new Date(prior.startedAt).getTime() : Infinity;
|
|
133
|
+
if (auto && since >= AUTO_RETRY_MS) {
|
|
134
|
+
launchUpdate(root, latest, env);
|
|
135
|
+
writeAutoState(root, { from: installed, to: latest, startedAt: new Date(now).toISOString() });
|
|
136
|
+
say(`
|
|
137
|
+
===== rsc updating =====
|
|
138
|
+
rsc ${latest} is out — you have ${installed}. It is installing itself in the background now and
|
|
139
|
+
takes effect in the next session. Nothing to do.
|
|
140
|
+
ACTION: tell the user in one line, then continue with the task.
|
|
141
|
+
========================
|
|
142
|
+
`);
|
|
143
|
+
} else if (auto && since < AUTO_RUNNING_MS) {
|
|
144
|
+
say(`
|
|
145
|
+
===== rsc updating =====
|
|
146
|
+
rsc ${latest} is still installing itself in the background. It takes effect in the next session.
|
|
147
|
+
========================
|
|
148
|
+
`);
|
|
149
|
+
} else {
|
|
150
|
+
// Asking: auto-update is off here, the release is a new major, or the background attempt failed.
|
|
151
|
+
let why = '';
|
|
152
|
+
if (auto) why = `The automatic update did not finish (log: .rsc/auto-update.log); it retries once a day.\n`;
|
|
153
|
+
else if (newMajor) why = 'It is a new major version: it can change how the harness works, so it asks first.\n';
|
|
154
|
+
let fixes = [];
|
|
155
|
+
try { fixes = fixesSince(doc.rscFixes, installed, latest); } catch { fixes = []; }
|
|
156
|
+
if (fixes.length) {
|
|
157
|
+
say(`
|
|
158
|
+
===== rsc update available =====
|
|
159
|
+
rsc ${latest} is out — you have ${installed}. It fixes problems this install still has:
|
|
160
|
+
${fixes.map((f) => ` - ${f.symptom} (fixed in ${f.fixedIn})`).join('\n')}
|
|
161
|
+
${why}ACTION: before starting the task, tell the user in one line that the update fixes what they
|
|
162
|
+
may be seeing, and if they say yes, run it in this project:
|
|
163
|
+
npx @ericrisco/rsc@latest
|
|
164
|
+
================================
|
|
165
|
+
`);
|
|
166
|
+
} else {
|
|
167
|
+
say(`
|
|
168
|
+
===== rsc update available =====
|
|
169
|
+
rsc ${latest} is out — you have ${installed}.
|
|
170
|
+
${why}ACTION: tell the user a new version is available and, if they say yes, run:
|
|
171
|
+
npx @ericrisco/rsc@latest
|
|
172
|
+
(That reinstalls and refreshes the skill content to the latest.)
|
|
173
|
+
================================
|
|
174
|
+
`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} catch { /* offline / no baseline / parse error → stay silent */ }
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// What each assistant's session-start hook accepts. Codex and Gemini take Claude's shape; Cursor has
|
|
183
|
+
// its own field names. Plain text for an agent that runs this by hand.
|
|
184
|
+
export function hookOutput(target, notice) {
|
|
185
|
+
if (!notice) return target === 'agent' ? '' : '{}';
|
|
186
|
+
if (target === 'agent') return notice;
|
|
187
|
+
if (target === 'cursor') return JSON.stringify({ additional_context: notice });
|
|
188
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: notice } });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Run as a script: `node auto-update.mjs [hook <target>] [root]`. Never fails a session.
|
|
192
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
193
|
+
const hook = process.argv[2] === 'hook';
|
|
194
|
+
const target = hook ? String(process.argv[3] || 'agent') : 'agent';
|
|
195
|
+
const root = (hook ? process.argv[4] : process.argv[2]) || process.cwd();
|
|
196
|
+
let notice = '';
|
|
197
|
+
try { if (!process.env.RSC_NO_UPDATE_CHECK) notice = (await updateNotice(root)).trim(); } catch { /* fail open */ }
|
|
198
|
+
if (!hook && !notice) notice = 'rsc is up to date.';
|
|
199
|
+
process.stdout.write(`${hookOutput(target, notice)}\n`);
|
|
200
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// rsc Branch guard (claude). Wired by targets/claude.js onto PreToolUse (matcher Bash) as
|
|
3
|
+
// `node ...`, like ship-guard. argv[2] = project root · stdin = PreToolUse hook JSON.
|
|
4
|
+
//
|
|
5
|
+
// Two rules from team-safe-default, each the deterministic floor of something the skills decide:
|
|
6
|
+
//
|
|
7
|
+
// A · the default branch is closed for the agent where the project shows it is complex or in
|
|
8
|
+
// production (trunk-policy.mjs). A commit or a merge there is denied, and the message says
|
|
9
|
+
// how to carry on: a branch, or — if the project is in fact simple — the person unlocking it.
|
|
10
|
+
// B · another session is working in this same checkout (session-memory-core.mjs). Switching
|
|
11
|
+
// branches here would change its files under it, so it is denied, and the message says how to
|
|
12
|
+
// carry on: a worktree under `.worktrees/`.
|
|
13
|
+
//
|
|
14
|
+
// Every denial carries its way out (constitution P6). Only what the AGENT runs is seen: a person
|
|
15
|
+
// committing in their own terminal is never touched (spec, P3). FAIL-OPEN: no repo, no git, a module
|
|
16
|
+
// missing, anything unclear → allow. Opt-outs, both PROJECT switches: `.rsc/.no-trunk-guard`
|
|
17
|
+
// («desbloquea main» / `rsc main unlock`) and `.rsc/.no-worktree-isolation` (`rsc isolation off`).
|
|
18
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
19
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
function allow() { process.exit(0); }
|
|
24
|
+
function deny(reason) {
|
|
25
|
+
process.stdout.write(JSON.stringify({
|
|
26
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason },
|
|
27
|
+
}));
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const GIT_OPTS = String.raw`(?:(?:-C\s+\S+|-c\s+\S+|--(?:work-tree|git-dir|namespace)\s+\S+|--\S+)\s+)*`;
|
|
32
|
+
export const COMMITS_HERE = new RegExp(String.raw`\bgit\s+${GIT_OPTS}(?:commit|merge|cherry-pick|revert|am)(?![\w-])`);
|
|
33
|
+
// A branch move: switch, or checkout of something that is not a path (`checkout -- f`, `checkout HEAD -- f`).
|
|
34
|
+
export const MOVES_BRANCH = new RegExp(String.raw`\bgit\s+${GIT_OPTS}(?:switch(?![\w-])|checkout(?![\w-])(?![^;&|\n]*\s--(?:\s|$)))`);
|
|
35
|
+
|
|
36
|
+
export const unquoted = (command) => command.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""');
|
|
37
|
+
|
|
38
|
+
/** Where the git command actually runs: `git -C <dir>`, or a leading `cd <dir> &&`, else the cwd. */
|
|
39
|
+
export function effectiveDir(command, base) {
|
|
40
|
+
const c = command.match(/\bgit\s+(?:-c\s+\S+\s+)*-C\s+("[^"]+"|'[^']+'|\S+)/);
|
|
41
|
+
const d = command.match(/^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*&&/);
|
|
42
|
+
const raw = (c || d)?.[1]?.replace(/^["']|["']$/g, '');
|
|
43
|
+
if (!raw) return base;
|
|
44
|
+
return isAbsolute(raw) ? raw : resolve(base, raw);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const gitAt = (dir) => (...args) => {
|
|
48
|
+
const r = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
|
|
49
|
+
return r.status === 0 ? (r.stdout || '').trim() : null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const ago = (iso) => {
|
|
53
|
+
const min = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60000));
|
|
54
|
+
return min <= 1 ? 'just now' : `${min} min ago`;
|
|
55
|
+
};
|
|
56
|
+
|
|
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
|
+
}
|
|
78
|
+
const tokens = (s) => s.trim().split(/\s+/).filter(Boolean);
|
|
79
|
+
|
|
80
|
+
/** What a branch-moving segment moves to, or null if it is a path checkout after all (`checkout .`). */
|
|
81
|
+
function moveTarget(seg, dir) {
|
|
82
|
+
const t = tokens(seg.replace(/^.*?\bgit\s+/, '').replace(/^(?:(?:-C|-c)\s+\S+\s+|--\S+\s+)*/, ''));
|
|
83
|
+
const verb = t.shift();
|
|
84
|
+
const create = t.findIndex((x) => /^-[cCbB]$/.test(x));
|
|
85
|
+
if (create >= 0) return { name: t[create + 1] || null, created: true };
|
|
86
|
+
const name = t.find((x) => !x.startsWith('-'));
|
|
87
|
+
if (!name) return { name: null, created: false };
|
|
88
|
+
if (verb === 'checkout' && (name === '.' || (existsSync(resolve(dir, name)) && !gitAt(dir)('rev-parse', '--verify', '--quiet', `refs/heads/${name}`)))) return null;
|
|
89
|
+
return { name, created: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Rule A and rule B, judged segment by segment: a chain is followed the way the shell runs it, so
|
|
94
|
+
* `git switch -c feat/x && git commit` is a commit on feat/x (allowed) and `git switch main && git
|
|
95
|
+
* merge feat/x` is a merge on main (judged as one). `cd` and `git -C` move the folder being judged.
|
|
96
|
+
*/
|
|
97
|
+
export async function evaluate({ root, command, cwd, sessionId }) {
|
|
98
|
+
if (typeof command !== 'string' || !command) return null;
|
|
99
|
+
// 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));
|
|
104
|
+
const segs = raw.map(unquoted);
|
|
105
|
+
let dir = cwd || root;
|
|
106
|
+
const branchAt = new Map(); // toplevel → branch the chain has moved it to
|
|
107
|
+
|
|
108
|
+
for (let i = 0; i < segs.length; i++) {
|
|
109
|
+
const seg = segs[i];
|
|
110
|
+
const cd = (raw[i] || seg).match(/^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*$/);
|
|
111
|
+
if (cd) { const d = cd[1].replace(/^["']|["']$/g, ''); dir = isAbsolute(d) ? d : resolve(dir, d); continue; }
|
|
112
|
+
const commits = COMMITS_HERE.test(seg);
|
|
113
|
+
const moves = MOVES_BRANCH.test(seg);
|
|
114
|
+
if (!commits && !moves) continue;
|
|
115
|
+
const at = effectiveDir(raw[i] || seg, dir);
|
|
116
|
+
const git = gitAt(at);
|
|
117
|
+
const top = git('rev-parse', '--show-toplevel');
|
|
118
|
+
if (!top) continue;
|
|
119
|
+
const here = (() => { try { return realpathSync(top); } catch { return top; } })();
|
|
120
|
+
|
|
121
|
+
if (moves) {
|
|
122
|
+
const target = moveTarget(seg, at);
|
|
123
|
+
if (target) {
|
|
124
|
+
if (!existsSync(join(root, '.rsc', '.no-worktree-isolation'))) {
|
|
125
|
+
const reason = await isolationDenial({ root, here, sessionId, self });
|
|
126
|
+
if (reason) return reason;
|
|
127
|
+
}
|
|
128
|
+
if (target.name) branchAt.set(here, target.name);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (commits && !existsSync(join(root, '.rsc', '.no-trunk-guard'))) {
|
|
133
|
+
try {
|
|
134
|
+
const { trunkPolicy, defaultBranchName } = await import(self('./trunk-policy.mjs'));
|
|
135
|
+
const branch = branchAt.get(here) ?? git('symbolic-ref', '--quiet', '--short', 'HEAD');
|
|
136
|
+
const trunk = defaultBranchName(here);
|
|
137
|
+
if (branch && trunk && branch === trunk) {
|
|
138
|
+
const policy = trunkPolicy(here);
|
|
139
|
+
if (policy.closed) return await trunkDenial({ root, here, sessionId, self, trunk, policy });
|
|
140
|
+
}
|
|
141
|
+
} catch { /* policy module missing → nothing to enforce */ }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function othersHere({ root, here, sessionId, self }) {
|
|
148
|
+
try {
|
|
149
|
+
const { otherActiveSessions } = await import(self('./session-memory-core.mjs'));
|
|
150
|
+
return otherActiveSessions({ cwd: root, worktreeCwd: here, sessionId, target: 'claude' });
|
|
151
|
+
} catch { return []; } // no memory module → no detection
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function trunkDenial({ root, here, sessionId, self, trunk, policy }) {
|
|
155
|
+
// Where the branch goes depends on rule B: alone → this same folder; with company → .worktrees/.
|
|
156
|
+
const others = existsSync(join(root, '.rsc', '.no-worktree-isolation')) ? [] : await othersHere({ root, here, sessionId, self });
|
|
157
|
+
const where = others.length
|
|
158
|
+
? 'Another session is working in this folder, so open it as a worktree inside the project: `git worktree add .worktrees/<branch> -b feat/<what-you-are-doing>` and commit inside `.worktrees/<branch>/`'
|
|
159
|
+
: 'You are the only session here, so stay in this same folder, no worktree: `git switch -c feat/<what-you-are-doing>` (or fix/…, docs/…) and commit there';
|
|
160
|
+
return `This project keeps its default branch "${trunk}" closed for the agent (it looks complex or in production: ${policy.signals.join(', ')}). ` +
|
|
161
|
+
`Do this work on a branch instead. ${where}; it reaches "${trunk}" through a pull request. ` +
|
|
162
|
+
'If this project is actually simple and the person asks to unlock it, run `npx @ericrisco/rsc main unlock` (a project decision, saved in .rsc.json).';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function isolationDenial({ root, here, sessionId, self }) {
|
|
166
|
+
const others = await othersHere({ root, here, sessionId, self });
|
|
167
|
+
if (!others.length) return null;
|
|
168
|
+
const who = others.map((o) => `${o.target}${o.branch ? ` on "${o.branch}"` : ''}, ${ago(o.updatedAt)}`).join('; ');
|
|
169
|
+
return `Another session is working in this same folder (${who}). Switching branches here would change its files under it. ` +
|
|
170
|
+
'Work in a worktree instead, inside the project: `git worktree add .worktrees/<branch> -b <branch>` (or without -b for an existing branch) and run everything inside `.worktrees/<branch>/`; tell the person that is where the project now runs. ' +
|
|
171
|
+
'To turn this rule off for the project: `npx @ericrisco/rsc isolation off`.';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isMain(metaUrl) {
|
|
175
|
+
const invoked = process.argv[1];
|
|
176
|
+
if (!invoked) return false;
|
|
177
|
+
const me = fileURLToPath(metaUrl);
|
|
178
|
+
try { return realpathSync(me) === realpathSync(invoked); } catch { return me === invoked; }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (isMain(import.meta.url)) {
|
|
182
|
+
const root = process.argv[2] || process.cwd();
|
|
183
|
+
if (!existsSync(join(root, '.git'))) allow();
|
|
184
|
+
let input = {};
|
|
185
|
+
try { input = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch { allow(); }
|
|
186
|
+
if ((input.tool_name || input.toolName) !== 'Bash') allow();
|
|
187
|
+
const command = input.tool_input?.command || input.toolInput?.command || '';
|
|
188
|
+
const reason = await evaluate({ root, command, cwd: input.cwd || root, sessionId: input.session_id });
|
|
189
|
+
if (reason) deny(reason);
|
|
190
|
+
allow();
|
|
191
|
+
}
|
package/targets/claude.js
CHANGED
|
@@ -118,6 +118,11 @@ export function wireHook(paths, sourceMd, policy = {}) {
|
|
|
118
118
|
// Same reason: session-start imports the reaper for the landed-worktree sweep, so it travels as a
|
|
119
119
|
// sibling too. Missing it does not break startup — the sweep is wrapped — it just goes silent.
|
|
120
120
|
copyFileSync(join(HERE, 'worktree-reaper.mjs'), join(paths.projectRoot, '.rsc', 'worktree-reaper.mjs'));
|
|
121
|
+
// session-start imports the shared update check, so it travels as a sibling too.
|
|
122
|
+
copyFileSync(join(HERE, 'auto-update.mjs'), join(paths.projectRoot, '.rsc', 'auto-update.mjs'));
|
|
123
|
+
// team-safe-default: the 3.0 start-up duties and the trunk policy they read, as siblings.
|
|
124
|
+
copyFileSync(join(HERE, 'team-safe-start.mjs'), join(paths.projectRoot, '.rsc', 'team-safe-start.mjs'));
|
|
125
|
+
copyFileSync(join(HERE, 'trunk-policy.mjs'), join(paths.projectRoot, '.rsc', 'trunk-policy.mjs'));
|
|
121
126
|
copyFileSync(join(HERE, 'session-start.mjs'), scriptDest);
|
|
122
127
|
// The one harness file that belongs in the committed tree rather than in `.rsc/`: it is the only
|
|
123
128
|
// thing standing between a clone and seven stack traces, so it has to survive `git clone`.
|
|
@@ -179,6 +184,20 @@ export function wireHook(paths, sourceMd, policy = {}) {
|
|
|
179
184
|
);
|
|
180
185
|
settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: sgCmd }] });
|
|
181
186
|
|
|
187
|
+
// Branch guard (team-safe-default): the default branch closed for the agent where the project shows
|
|
188
|
+
// it is complex or in production, and no branch switching under another session working in the same
|
|
189
|
+
// checkout. trunk-policy.mjs is its sibling import (and knowledge-sync's); session-memory-core.mjs
|
|
190
|
+
// comes with the memory wiring, and without it the isolation rule simply sees nobody. Fail-open,
|
|
191
|
+
// opt-outs `.rsc/.no-trunk-guard` and `.rsc/.no-worktree-isolation`.
|
|
192
|
+
const bgDest = join(paths.projectRoot, '.rsc', 'branch-guard.mjs');
|
|
193
|
+
copyFileSync(join(HERE, 'branch-guard.mjs'), bgDest);
|
|
194
|
+
copyFileSync(join(HERE, 'trunk-policy.mjs'), join(paths.projectRoot, '.rsc', 'trunk-policy.mjs'));
|
|
195
|
+
const bgCmd = viaBootstrap('guard', at('.rsc', 'branch-guard.mjs'), `"${P}"`);
|
|
196
|
+
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
197
|
+
(e) => !hookWiringOf(e).includes('.rsc/branch-guard.'),
|
|
198
|
+
);
|
|
199
|
+
settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: bgCmd }] });
|
|
200
|
+
|
|
182
201
|
|
|
183
202
|
// Gitmoji guard: a PreToolUse(Bash) hook that DENIES a `git commit` whose message
|
|
184
203
|
// carries no gitmoji (gitmoji.dev) in front of the Conventional Commits header. The
|
|
@@ -211,18 +230,18 @@ export function wireHook(paths, sourceMd, policy = {}) {
|
|
|
211
230
|
(e) => !hookWiringOf(e).includes('.rsc/userprompt-gate.'),
|
|
212
231
|
);
|
|
213
232
|
settings.hooks.UserPromptSubmit.push({ hooks: [{ type: 'command', command: fgCmd }] });
|
|
214
|
-
written.push(sgDest, gmDest, fgDest, join(paths.projectRoot, '.rsc', 'sello.mjs'));
|
|
233
|
+
written.push(sgDest, gmDest, fgDest, bgDest, join(paths.projectRoot, '.rsc', 'sello.mjs'), join(paths.projectRoot, '.rsc', 'trunk-policy.mjs'));
|
|
215
234
|
} else {
|
|
216
235
|
for (const event of ['PreToolUse', 'UserPromptSubmit']) {
|
|
217
236
|
if (!settings.hooks[event]) continue;
|
|
218
237
|
settings.hooks[event] = settings.hooks[event].filter((entry) => {
|
|
219
238
|
const body = hookWiringOf(entry);
|
|
220
|
-
return !body.includes('.rsc/ship-guard.') &&
|
|
239
|
+
return !body.includes('.rsc/ship-guard.') && !body.includes('.rsc/branch-guard.') &&
|
|
221
240
|
!body.includes('.rsc/gitmoji-guard.') && !body.includes('.rsc/userprompt-gate.');
|
|
222
241
|
});
|
|
223
242
|
if (!settings.hooks[event].length) delete settings.hooks[event];
|
|
224
243
|
}
|
|
225
|
-
for (const name of ['ship-guard.mjs', 'gitmoji-guard.mjs', 'userprompt-gate.mjs', 'sello.mjs']) {
|
|
244
|
+
for (const name of ['ship-guard.mjs', 'branch-guard.mjs', 'trunk-policy.mjs', 'gitmoji-guard.mjs', 'userprompt-gate.mjs', 'sello.mjs']) {
|
|
226
245
|
rmSync(join(paths.projectRoot, '.rsc', name), { force: true });
|
|
227
246
|
}
|
|
228
247
|
written.push(operationsSuggest);
|
|
@@ -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', 'claudemd-check', 'danger-guard', 'feature-gate', 'gitmoji', 'ship-guard', 'worktree-cleanup',
|
|
46
|
+
'audit', 'auto-update', 'claudemd-check', 'danger-guard', 'feature-gate', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'worktree-cleanup', 'worktree-isolation',
|
|
47
47
|
];
|
|
48
48
|
|
|
49
49
|
/**
|
package/targets/hook-once.mjs
CHANGED
|
@@ -94,12 +94,11 @@ Classify this turn before acting, and name the lane in one line.
|
|
|
94
94
|
- Asks for information — explain, compare, audit, review, propose? -> Answer.
|
|
95
95
|
Write nothing and create no artifact. Change intent unclear -> ask one
|
|
96
96
|
question and stay read-only; ambiguity slows the lane, it never raises it.
|
|
97
|
-
- Authorises a change?
|
|
98
|
-
tasks checked off
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
count and risk never select it.
|
|
97
|
+
- Authorises a change? YOU pick the lane, say it and why: simple -> FTD (one
|
|
98
|
+
feature document, tasks checked off against observed proof); big or
|
|
99
|
+
interlocking decisions -> SDD (spec + clarify approved, then autopilot or not).
|
|
100
|
+
- Complex/production: no commits on the default branch, branch + PR. Simple:
|
|
101
|
+
default branch. Another session here: .worktrees/<branch>.
|
|
103
102
|
Method: \`ftd\` · chain: \`sdd\` · full decisor in the always-on \`suggest\` body.
|
|
104
103
|
=================================================
|
|
105
104
|
`;
|