@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.
@@ -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
  /**
@@ -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? -> FTD (Fast-Track Development). One feature document,
98
- tasks checked off only against observed proof, a branch if it writes code.
99
- - SDD, the ten-phase chain, is never entered by the harness alone. Propose it
100
- only when durable spec/plan/tasks would remove a substantial ambiguity, and
101
- enter it only on an explicit request or an accepted proposal. Size, file
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
  `;