@ericrisco/rsc 2.2.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 CHANGED
@@ -56,6 +56,58 @@ not a pile of whatever an agent happened to install.
56
56
 
57
57
  ---
58
58
 
59
+ ## 🛡️ New in 3.0: safe for teams by default
60
+
61
+ Day one with agents is easy. Day ninety is where harnesses break, especially with several people,
62
+ several assistants and several sessions on one project:
63
+
64
+ - every machine drifts, and what one person teaches their agent never reaches anyone else;
65
+ - two agents end up working in the same folder and switch branches under each other;
66
+ - someone's agent commits straight to `main` on a project in production;
67
+ - every request gets the same process, whether it is a typo or a new subsystem.
68
+
69
+ rsc 3.0 fixes all four, by default, with no new commands to learn:
70
+
71
+ | | What happens | Turn it off |
72
+ | --- | --- | --- |
73
+ | **Main is closed where it matters** | In a project that is complex or in production, the agent never commits on the default branch. It opens a branch on its own (`feat/…`, `fix/…`) and finishes with a pull request. In a project of scripts or research it works on `main`, because a branch there is only ceremony. | Say **«unlock main»**, or `rsc main unlock` |
74
+ | **One workspace per agent** | If another assistant session is working in the same folder, new work goes to a worktree under `.worktrees/<branch>/`. It stays inside the project, is never committed, and is removed once that branch is merged. | `rsc isolation off` |
75
+ | **The agent picks the method** | A simple change takes Fast-Track (one document, proof for every task). Something big or complex takes the spec-driven chain. The agent decides and says why. You still approve *what* gets built: the spec and the clarifications, then step by step or autopilot. | Ask for the other lane |
76
+ | **Knowledge reaches everyone** | `01-TOOLS/` and `02-DOCS/` travel through one exchange branch, `rsc/knowledge`. They go up when your turn ends and come down into whatever branch each person is on. They reach `main` inside your normal pull requests. | `rsc knowledge-sync off` |
77
+
78
+ **How does it know a project is "complex or in production"?** There are two layers. A hook counts
79
+ what anyone can check: a CI setup, a deployment file (`Dockerfile`, `vercel.json`, `fly.toml`…) or
80
+ at least two people in the last 50 commits. If any of those is there, a commit on the default branch
81
+ is refused before it runs. If none is, the agent decides change by change, using the same rule from
82
+ its skills. If the hook gets it wrong and the project really is simple, «unlock main» opens it for
83
+ the project. That decision is saved in `.rsc.json`, so the whole team gets it.
84
+
85
+ Every refusal says how to carry on: which branch to open, how to create the worktree, or how to
86
+ unlock. An agent is never left stuck. The rules only see what the **agent** runs; a person
87
+ committing in their own terminal is never touched.
88
+
89
+ **The first session after upgrading** does three things, once:
90
+
91
+ - It explains these changes in a few lines.
92
+ - If an agent left commits on a closed `main` that never reached the remote, it moves them to a
93
+ `rescue/main-<date>` branch and puts `main` back to what the remote has. Nothing is lost.
94
+ - It moves worktrees older versions created next to the project into `.worktrees/`.
95
+
96
+ ```bash
97
+ rsc main status # open or closed for the agent, and the signals behind it
98
+ rsc main unlock # this project is simple: the agent may work on main (saved in .rsc.json)
99
+ rsc main lock # back to the default
100
+ rsc isolation status # whether another session would send new work to a worktree
101
+ rsc isolation off # never use worktrees for parallel sessions in this project
102
+ ```
103
+
104
+ > **Limit, stated plainly.** The hook that refuses the commit or the branch switch runs in
105
+ > **Claude Code**, like the other rsc guards. In Codex, Gemini, Cursor and OpenCode the same rules
106
+ > are carried by the skills and by the session memory, which tells the agent that another session is
107
+ > active. They are followed, but no hook enforces them. Knowledge sync works in all five.
108
+
109
+ ---
110
+
59
111
  ## 🤖 For agents: "install this harness"
60
112
 
61
113
  If you're an AI coding agent (Claude Code, Cursor, Copilot, Codex, Gemini…) and your user
@@ -98,9 +150,9 @@ before anything is written.
98
150
 
99
151
  ---
100
152
 
101
- ## 🛣️ Three lanes, and the harness never picks the expensive one for you
153
+ ## 🛣️ Three lanes, and the agent picks the right one for you
102
154
 
103
- <img src="https://raw.githubusercontent.com/ericrisco/rsc-harness/main/site/lanes.svg" alt="The rsc lane decisor: a request is classified into answer (read-only), FTD (the default lane, one feature document) or SDD (the ten-phase chain, which only the user selects)." width="960">
155
+ <img src="https://raw.githubusercontent.com/ericrisco/rsc-harness/main/site/lanes.svg" alt="The rsc lane decisor: the agent classifies each request into answer (read-only), FTD (simple change, one feature document) or SDD (the ten-phase chain for big or complex work, entered by the agent; you approve the spec)." width="960">
104
156
 
105
157
  Every turn takes exactly one lane, and your agent names the one it took in a line.
106
158
 
@@ -111,11 +163,14 @@ Every turn takes exactly one lane, and your agent names the one it took in a lin
111
163
  2. **FTD — Fast-Track Development.** The request authorises a change. This is the default for
112
164
  ordinary work and needs no ceremony. One feature document per feature holds intent, scope, a
113
165
  checklist, the evidence and the next step. Tasks are checked off against observed proof, never
114
- against intention. A branch if the work writes code; nothing if it only touches docs or config.
115
- 3. **SDD — the ten-phase chain.** Unchanged, and **the harness never enters it alone.** It proposes
116
- the chain only when a durable spec, plan and task list would remove a *substantial* ambiguity —
117
- a test of usefulness, not of size — and enters it only when you ask for it or accept that
118
- proposal. Size, file count and perceived risk never select it on their own.
166
+ against intention. For the simple changes.
167
+ 3. **SDD — the ten-phase chain.** For the big or complex: several decisions that affect each other,
168
+ or doubts that change the result. **The agent enters it on its own** — you never have to decide
169
+ which method to use. Inside, you still decide *what* gets built: you approve the spec and answer
170
+ the clarifying questions, then choose to review every phase yourself or let it run on autopilot
171
+ to verified code. Publishing always asks.
172
+
173
+ The agent says which lane it took and why, in one line. Ask for the other one and it switches.
119
174
 
120
175
  **Isolation cleans itself up.** When a lane opens a branch or a worktree, you no longer have to
121
176
  remember to retire it: a `post-merge` hook does it the moment the work lands on the trunk, on both
@@ -124,10 +179,9 @@ landed and holds nothing unsaved; anything it refuses tells you why. The previou
124
179
  agent to run the cleanup at the end of a long phase, and that step was skipped on both features that
125
180
  reached it.
126
181
 
127
- Earlier versions routed everything that sounded like building into the chain. That contradicted the
128
- project's own first rule — friction is proportional to risk — and a harness that interrupts the 80%
129
- that is harmless gets switched off, which protects nothing. Both lanes are still here; what changed
130
- is which one you land in by default, and who decides.
182
+ Earlier versions asked you to accept a proposal before entering the chain. People do not want to
183
+ choose a method; they want the work done right. Since 3.0 the agent chooses, and you keep the part
184
+ that is genuinely yours: deciding what is built.
131
185
 
132
186
  ---
133
187
 
@@ -370,6 +424,59 @@ list somebody has to remember to update is a list that goes quietly stale. Decla
370
424
  `ownSkills` still works and still reports when someone is missing it.
371
425
 
372
426
 
427
+ ### Your wiki and tools stay in sync — on by default
428
+
429
+ `01-TOOLS/` and `02-DOCS/` are the team's knowledge, so rsc keeps them the same on every machine
430
+ without anybody thinking about git, **and without ever pushing to `main`**. Your `main` is
431
+ protected, or should be; rsc assumes it is.
432
+
433
+ The knowledge travels through one exchange branch, `rsc/knowledge`, that nobody works on by hand:
434
+
435
+ ```text
436
+ ana · feat/login edits 02-DOCS/wiki/api.md ──► rsc/knowledge (when her turn ends)
437
+ eric · feat/payments 📥 1 change from Ana ◄── rsc/knowledge (before his next message)
438
+ main · protected untouched — gets it inside the next merged pull request
439
+ new · clones main has it all after the first message
440
+ ```
441
+
442
+ - **When a turn ends**, your changes in those folders are committed as `📝 docs(auto): … [skip ci]`
443
+ and sent to `rsc/knowledge` on `origin`, from whatever branch you are on. If you are on a closed
444
+ `main`, nothing is committed locally: the snapshot is built aside and only goes to `rsc/knowledge`.
445
+ - **Before each message**, what teammates sent is brought into **the branch you are on**, and you are
446
+ told in one line. Only the knowledge folders are touched.
447
+ - **It reaches `main` the normal way.** Your feature branch now carries the team's knowledge, so it
448
+ arrives in `main` inside the pull request you were going to open anyway. There is no extra pull
449
+ request from `rsc/knowledge`, and nobody has to merge it.
450
+ - The first time, `rsc/knowledge` is created from the remote's default branch. If someone deletes it,
451
+ it is created again.
452
+
453
+ What it will not do, by design:
454
+
455
+ | | |
456
+ | --- | --- |
457
+ | Push to `main` | Never. Only `rsc/knowledge` receives anything. |
458
+ | Push your code or your unpushed commits | Only its own snapshot of the knowledge folders goes up. Your code waits for you. |
459
+ | Trigger CI or a deploy | Every automatic commit says `[skip ci]`. |
460
+ | Bring in anybody else's code | Only knowledge paths come down. Code, config and `.claude/` are never pulled. |
461
+ | Sync `02-DOCS/wiki/harness/user-profile.md` | Those are one person's dials. |
462
+ | Overwrite a file you are editing | It tells you, and your version stays. |
463
+ | Run where it has no business | No `origin`, no knowledge folders, a rebase in progress, a cloud agent: it stays quiet. |
464
+
465
+ It is wired into the assistant's own turn hooks (end of turn, new message) in Claude Code, Codex,
466
+ Gemini, Cursor and OpenCode (tested end to end in Claude Code, Codex and OpenCode). The network part runs in the background, so a turn never waits for
467
+ it. It is kept apart from the [local session memory](#new-sessions-pick-up-the-latest-local-work),
468
+ which promises never to touch the network. The first turn says it is on. To turn it off for the
469
+ project:
470
+
471
+ ```bash
472
+ rsc knowledge-sync off # writes .rsc/.no-knowledge-sync and records it in .rsc.json — commit that
473
+ rsc knowledge-sync status # active or not, and why
474
+ ```
475
+
476
+ It is a **project** switch, not a personal one: if one person stopped sending, the rest of the team
477
+ would stop seeing their work. For the rest of the 3.0 team defaults, see
478
+ [New in 3.0](#️-new-in-30-safe-for-teams-by-default).
479
+
373
480
  ## 🩹 Something's off? One command
374
481
 
375
482
  Recognise any of these? They are all the same fix.
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0",
2
+ "version": "3.0.0",
3
3
  "counts": {
4
4
  "skills": 270,
5
5
  "agents": 33,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "2.2.0",
3
+ "version": "3.0.0",
4
4
  "description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,7 @@ import {
23
23
  wireMemory, unwireMemory, memoryManagedPaths, memoryModeFor, memoryEnabledForProject, memoryArtifactsPresent,
24
24
  } from '../targets/memory.js';
25
25
  import { wireUpdate, unwireUpdate, updateManagedPaths, updateArtifactsPresent } from '../targets/update-wiring.js';
26
+ import { wireKnowledge, unwireKnowledge, knowledgeManagedPaths, knowledgeArtifactsPresent } from '../targets/knowledge-wiring.js';
26
27
 
27
28
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
28
29
  const CLI_VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
@@ -80,6 +81,9 @@ export function generatedHookFiles({ target, cwd, policy }) {
80
81
  join(cwd, '.rsc', 'hook-once.mjs'),
81
82
  join(cwd, '.rsc', 'worktree-reaper.mjs'),
82
83
  join(cwd, '.rsc', 'auto-update.mjs'),
84
+ // team-safe-default: the 3.0 start-up duties and the trunk policy (also read by branch-guard).
85
+ join(cwd, '.rsc', 'team-safe-start.mjs'),
86
+ join(cwd, '.rsc', 'trunk-policy.mjs'),
83
87
  ];
84
88
  // The danger guard is declared on its own terms (#273): present unless the plan says otherwise,
85
89
  // whether or not the code guards are.
@@ -87,6 +91,7 @@ export function generatedHookFiles({ target, cwd, policy }) {
87
91
  if (policy?.codeHooks === false) return [...lifecycle, ...danger, join(cwd, '.rsc', 'suggest-always-on.md')];
88
92
  return [...lifecycle, ...danger,
89
93
  join(cwd, '.rsc', 'ship-guard.mjs'),
94
+ join(cwd, '.rsc', 'branch-guard.mjs'),
90
95
  join(cwd, '.rsc', 'gitmoji-guard.mjs'), join(cwd, '.rsc', 'userprompt-gate.mjs'),
91
96
  join(cwd, '.rsc', 'sello.mjs')];
92
97
  }
@@ -98,6 +103,7 @@ export function managedPathsForInstall({ skillIds, agentIds = [], target, home,
98
103
  if (policy?.context7 === false) out.push(join(cwd, '.rsc', '.no-context7'));
99
104
  if (policy?.memory !== false) out.push(...memoryManagedPaths(target, cwd));
100
105
  out.push(...updateManagedPaths(target, cwd));
106
+ out.push(...knowledgeManagedPaths(target, cwd));
101
107
  if (targetHasAgents(target)) {
102
108
  const state = readState(paths.stateFile);
103
109
  const explicit = [...new Set([...(state.explicitAgents || readManifest(cwd)?.agents || []), ...agentIds])];
@@ -302,6 +308,10 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
302
308
  state.memory = { mode: memoryResult.mode, reason: memoryResult.reason, paths: memoryResult.paths };
303
309
  const updateResult = wireUpdate(target, cwd);
304
310
  state.update = { mode: updateResult.mode, ...(updateResult.reason ? { reason: updateResult.reason } : {}) };
311
+ // Knowledge sync is its own decision (`.no-knowledge-sync`, checked at runtime) and never part of
312
+ // the memory, so the memory's policy does not decide it: wired wherever the assistant can run it.
313
+ const knowledgeResult = wireKnowledge(target, cwd);
314
+ state.knowledge = { mode: knowledgeResult.mode, reason: knowledgeResult.reason || null, paths: knowledgeResult.paths };
305
315
  const context7OptOut = join(cwd, '.rsc', '.no-context7');
306
316
  if (policy?.context7 === false) {
307
317
  mkdirSync(dirname(context7OptOut), { recursive: true });
@@ -602,6 +612,7 @@ export function removeTargetInstall({ target, home, cwd = process.cwd() }) {
602
612
  unwireHook(target, paths);
603
613
  unwireMemory(target, cwd);
604
614
  unwireUpdate(target, cwd);
615
+ unwireKnowledge(target, cwd);
605
616
  rmSync(paths.stateFile, { force: true });
606
617
  const gitignore = join(cwd, '.gitignore');
607
618
  if (existsSync(gitignore)) {
@@ -711,9 +722,11 @@ export async function purge({ home, cwd = process.cwd(), withDocs = false, dryRu
711
722
  removed.push(...unwireHook(target, paths));
712
723
  removed.push(...unwireMemory(target, cwd));
713
724
  removed.push(...unwireUpdate(target, cwd));
725
+ removed.push(...unwireKnowledge(target, cwd));
714
726
  } else {
715
727
  removed.push(...memoryArtifactsPresent(target, cwd));
716
728
  removed.push(...updateArtifactsPresent(target, cwd));
729
+ removed.push(...knowledgeArtifactsPresent(target, cwd));
717
730
  }
718
731
  // A lost state file also loses proof of ownership. Leave same-named user files
719
732
  // behind rather than guessing from a catalog id and deleting their work.
package/scripts/rsc.js CHANGED
@@ -44,7 +44,7 @@ if (['--version', '-v', 'version'].includes(rawArgv[0])) {
44
44
  const GLOBAL_VALUE_FLAGS = new Set([
45
45
  '--target', '--technical-level', '--accompaniment', '--project-kind', '--goal', '--goal-base64', '--software-scope', '--accept-plan',
46
46
  ]);
47
- const COMMANDS = new Set(['onboard', 'reassess', 'add', 'install', 'consult', 'catalog', 'audit', 'list', 'doctor', 'memory', 'sync', 'backups', 'restore', 'upgrade', 'registry', 'worktrees', 'capabilities', 'sello', 'repair', 'uninstall', 'purge']);
47
+ const COMMANDS = new Set(['onboard', 'reassess', 'add', 'install', 'consult', 'catalog', 'audit', 'list', 'doctor', 'memory', 'knowledge-sync', 'main', 'isolation', 'sync', 'backups', 'restore', 'upgrade', 'registry', 'worktrees', 'capabilities', 'sello', 'repair', 'uninstall', 'purge']);
48
48
  function positionalTokens(input) {
49
49
  const out = [];
50
50
  for (let i = 0; i < input.length; i++) {
@@ -766,6 +766,72 @@ async function main() {
766
766
  printContextBudget(report.contextBudget);
767
767
  return void say(JSON.stringify({ ...report, contextBudget: undefined }, null, 2));
768
768
  }
769
+ case 'main':
770
+ case 'isolation': {
771
+ // team-safe-default: the two switches a person asks the agent for in plain words —
772
+ // «desbloquea main» / «bloquea main», «no uses worktrees» / «usa worktrees». Both are PROJECT
773
+ // decisions (recorded in .rsc.json optOuts by the sync below) because one person's answer is
774
+ // the team's: the default branch is open or closed for every agent working on the repo.
775
+ const isMain = cmd === 'main';
776
+ const sub = argv[1] || 'status';
777
+ const root = process.cwd();
778
+ const { existsSync, mkdirSync, writeFileSync, rmSync } = await import('node:fs');
779
+ const { join } = await import('node:path');
780
+ const marker = join(root, '.rsc', isMain ? '.no-trunk-guard' : '.no-worktree-isolation');
781
+ const OFF = isMain ? 'unlock' : 'off';
782
+ const ON = isMain ? 'lock' : 'on';
783
+ if (sub === OFF || sub === ON) {
784
+ mkdirSync(join(root, '.rsc'), { recursive: true });
785
+ if (sub === OFF) writeFileSync(marker, '');
786
+ else rmSync(marker, { force: true });
787
+ for (const t of targets) await syncInstalled({ target: t, cwd: root });
788
+ say(isMain
789
+ ? (sub === OFF
790
+ ? 'rsc main unlock: the agent may now work on the default branch in this project. Commit .rsc.json so the team gets the same decision.'
791
+ : 'rsc main lock: the default branch is closed for the agent again where the project looks complex or in production. Commit .rsc.json so the team gets the same decision.')
792
+ : (sub === OFF
793
+ ? 'rsc isolation off: sessions may share this folder without a worktree. Commit .rsc.json so the team gets the same decision.'
794
+ : 'rsc isolation on: with another session active in this folder, new work goes to .worktrees/<branch>/. Commit .rsc.json so the team gets the same decision.'));
795
+ return;
796
+ }
797
+ if (sub === 'status') {
798
+ if (isMain) {
799
+ const { trunkPolicy } = await import('../targets/trunk-policy.mjs');
800
+ return void say(JSON.stringify(trunkPolicy(root), null, 2));
801
+ }
802
+ const { otherActiveSessions } = await import('../targets/session-memory-core.mjs');
803
+ return void say(JSON.stringify({ isolation: existsSync(marker) ? 'off' : 'on', activeSessions: otherActiveSessions({ cwd: root }) }, null, 2));
804
+ }
805
+ say(`Use: npx @ericrisco/rsc ${cmd} ${ON}|${OFF}|status`);
806
+ process.exitCode = 2;
807
+ return;
808
+ }
809
+ case 'knowledge-sync': {
810
+ // On by default; the switch is a PROJECT decision (`.no-knowledge-sync`, recorded in `.rsc.json`
811
+ // optOuts by the sync below), because one person off means the team stops seeing their work.
812
+ const sub = argv[1] || 'status';
813
+ const root = process.cwd();
814
+ const { mkdirSync, writeFileSync, rmSync } = await import('node:fs');
815
+ const { join } = await import('node:path');
816
+ const marker = join(root, '.rsc', '.no-knowledge-sync');
817
+ if (sub === 'on' || sub === 'off') {
818
+ mkdirSync(join(root, '.rsc'), { recursive: true });
819
+ if (sub === 'off') writeFileSync(marker, '');
820
+ else rmSync(marker, { force: true });
821
+ for (const t of targets) await syncInstalled({ target: t, cwd: root });
822
+ say(sub === 'off'
823
+ ? 'rsc knowledge-sync off: 01-TOOLS/ and 02-DOCS/ no longer sync on their own in this project. Commit .rsc.json so the team gets the same decision.'
824
+ : 'rsc knowledge-sync on: 01-TOOLS/ and 02-DOCS/ go up when a turn ends and come down before each message. Commit .rsc.json so the team gets the same decision.');
825
+ return;
826
+ }
827
+ if (sub === 'status') {
828
+ const { knowledgeStatus } = await import('../targets/knowledge-sync.mjs');
829
+ return void say(JSON.stringify(knowledgeStatus(root), null, 2));
830
+ }
831
+ say('Use: npx @ericrisco/rsc knowledge-sync on|off|status');
832
+ process.exitCode = 2;
833
+ return;
834
+ }
769
835
  case 'memory': {
770
836
  const sub = argv[1] || 'status';
771
837
  const root = process.cwd();
@@ -53,10 +53,16 @@ the chain: a result asserted without evidence is treated as not done.
53
53
 
54
54
  ## Isolation
55
55
 
56
- If the work writes code, it runs on a branch — never on the default branch, so `origin` and the local
57
- tree stay clean. **No worktree**: this lane is short enough that setting one up and tearing it down
58
- costs more than it protects. Work that only touches documentation, the wiki or configuration needs no
59
- branch at all.
56
+ Where the work runs depends on the project, judged change by change (team-safe-default):
57
+
58
+ - **Complex or in production** (deploys, CI, several people committing, a protected default branch)
59
+ → a branch, never the default branch. Open it yourself, named by intent, and close it with a pull
60
+ request. Something unrelated to the current branch gets its own branch from the default one.
61
+ - **Simple** (scripts, research, personal) → the default branch is fine.
62
+ - **Another session working in this same folder** → a worktree in `.worktrees/<branch>/`, never a
63
+ branch switch under the other session. rsc tells you when that is the case.
64
+
65
+ A worktree only when another session is there: alone, setting one up costs more than it protects.
60
66
 
61
67
  `../worktrees/SKILL.md` owns the isolation mechanics if you need them. Once the branch lands, the
62
68
  cleanup is automatic and you do not run anything.
@@ -34,7 +34,7 @@ should_not_trigger:
34
34
 
35
35
  - prompt: "No tengo claro si los permisos van por rol o por recurso, y afecta al esquema, a la API y a la UI."
36
36
  route_to: "specify"
37
- why: "A substantial unresolved ambiguity that durable spec/plan/tasks would settle. The harness proposes the chain here; only an explicit request or an accepted proposal enters it."
37
+ why: "A substantial unresolved ambiguity that durable spec/plan/tasks would settle. Several interlocking decisions: the agent enters the chain itself and says why (team-safe-default)."
38
38
 
39
39
  - prompt: "Quiero especificar bien el sistema de permisos antes de tocar nada."
40
40
  route_to: "specify"
@@ -3,7 +3,7 @@
3
3
  "lanes": {
4
4
  "answer": "information only; writes nothing, creates no artifact",
5
5
  "ftd": "authorised change, ordinary; one feature document",
6
- "sdd": "the ten-phase chain, by explicit request or accepted proposal only"
6
+ "sdd": "the ten-phase chain, entered by the agent for big or complex work"
7
7
  },
8
8
  "cases": [
9
9
  { "request": "¿Por qué el login tarda tanto en producción?", "expect": "answer", "why": "investigation; nothing authorises a change" },
@@ -19,6 +19,6 @@
19
19
  { "request": "Migra los tres endpoints al nuevo formato de respuesta", "expect": "ftd", "why": "many files, but nothing a spec would settle that the code does not" },
20
20
  { "request": "Quiero especificar bien el sistema de permisos antes de tocar nada", "expect": "sdd", "why": "explicit request for the chain" },
21
21
  { "request": "Vamos a hacer una spec de esto", "expect": "sdd", "why": "explicit request, stated in the user's own words" },
22
- { "request": "No tengo claro si los permisos van por rol o por recurso, y afecta al esquema, a la API y a la UI", "expect": "sdd", "why": "propose the chain: durable artifacts would settle a substantial ambiguity, and only an accepted proposal enters it" }
22
+ { "request": "No tengo claro si los permisos van por rol o por recurso, y afecta al esquema, a la API y a la UI", "expect": "sdd", "why": "enter the chain: several interlocking decisions that durable artifacts would settle; the agent enters it and says why" }
23
23
  ]
24
24
  }
@@ -93,6 +93,12 @@ This mirrors the harness "siempre 3 opciones" pattern. Gather the one fact that
93
93
  | **2. Pull request** | Push the branch, open a PR with a spec-linked body, let CI / a human gate the merge | `main` is protected; a team or CI must sign off; you want the change reviewable in the forge even if you self-merge |
94
94
  | **3. Park or discard** | Keep the branch un-merged (park) or delete it (discard) | The approach was superseded, the spike answered its question, or the work is paused — it should not land |
95
95
 
96
+ **When the default branch is closed for the agent** (team-safe-default: the project is complex or in
97
+ production — `npx @ericrisco/rsc main status` says `closed: true`), **option 1 is not offered**: a
98
+ local merge is a commit on the default branch. Present option 2 (the recommendation) and option 3,
99
+ and say in one line why option 1 is absent and that «desbloquea main» (`npx @ericrisco/rsc main
100
+ unlock`) would bring it back. The branch guard denies the local merge anyway.
101
+
96
102
  Recommend based on repo signals: protected `main` or an existing PR culture (look for `.github/`, prior PRs via `gh pr list`) → recommend **option 2**. A solo project with no protection and a passed review → **option 1** is honest and faster. Never default to a PR ceremony the repo doesn't use, and never force-merge a repo that gates `main`.
97
103
 
98
104
  ### Delivery strategy from SDD config
@@ -26,15 +26,15 @@ recommend. Read-only: write nothing, create no artifact, delegate no writer. Ask
26
26
  about* building something is still this lane; only asking to build authorises it. When change
27
27
  intent is ambiguous, ask one question and stay here — ambiguity slows the lane, never raises it.
28
28
 
29
- **FTD — Fast-Track Development** — the request authorises a change. The default for ordinary work,
30
- entered without ceremony. One feature document per feature in `02-DOCS`: intent, scope, checklist,
31
- evidence, next step. Tasks are checked off against observed proof, never intention. A branch if it
32
- writes code, none if it only touches docs, wiki or config; never a worktree. → `../ftd/SKILL.md`.
33
-
34
- **SDD** — the ten-phase chain, unchanged, and **never entered by the harness alone**. Propose it only
35
- when durable spec/plan/tasks would remove a *substantial* ambiguity — a test of usefulness, not of
36
- size — and enter it only on explicit request or an accepted proposal. Size, file count and perceived
37
- risk never select it. → `../sdd/SKILL.md`.
29
+ **You choose the lane; never hand that choice to the person.** Simple → **FTD**: one feature document
30
+ in `02-DOCS` (intent, scope, checklist, evidence, next step), tasks checked off against observed proof.
31
+ Big, or decisions that affect each other → **SDD**: enter the chain; the person approves the spec and
32
+ clarify, then picks manual or autopilot. Say which and why in one line; switch if asked.
33
+ → `../ftd/SKILL.md` · `../sdd/SKILL.md`.
34
+
35
+ **Where.** Complex or in production → never commit on the default branch: branch yourself, close with
36
+ a PR. Simple → the default branch. Another session in this checkout → `.worktrees/<branch>`. «desbloquea
37
+ main» = `rsc main unlock`; «no uses worktrees» = `rsc isolation off`.
38
38
 
39
39
  Judge the **meaning**, not the wording: the trigger is semantic in any language. A bug fix restoring
40
40
  intended behaviour is `debug`. Autopilot consent covers a whole run — advance without re-asking.
@@ -44,6 +44,7 @@ Both isolate history. A worktree also isolates the *filesystem*. Pick by what's
44
44
  | You'll run two streams of work at once (see the parallel pattern) | **worktree** | Each stream gets its own directory; no stash-juggling, no checkout thrash. |
45
45
  | The current tree is dirty and the user wants both the WIP and the new work | **worktree** | Stash-and-switch risks losing the WIP; a worktree sidesteps the stash entirely. |
46
46
  | You're an agent that may keep the main session running elsewhere | **worktree** | Filesystem isolation is the whole point — the parent session keeps its files. |
47
+ | **Another session is working in this same folder** (rsc says so on each message) | **worktree — mandatory** | Switching branches would change its files under it; rsc's branch guard denies the switch. Tell the person that `.worktrees/<branch>/` is where the project now runs. Off for the project: `npx @ericrisco/rsc isolation off`. |
47
48
 
48
49
  When in doubt, prefer a **worktree**: it is the strictly stronger isolation and the cost is one
49
50
  extra directory. The rest of this skill assumes a worktree; the branch path is the degenerate case
@@ -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
+ }