@ericrisco/rsc 3.0.2 → 3.0.4
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 +28 -5
- package/manifest.json +2 -2
- package/package.json +1 -1
- package/scripts/install-apply.js +14 -2
- package/scripts/lib/manifest-file.js +3 -1
- package/scripts/rsc.js +31 -1
- package/skills/ftd/SKILL.md +9 -7
- package/skills/implement/SKILL.md +4 -4
- package/skills/suggest/SKILL.md +3 -3
- package/skills/worktrees/SKILL.md +6 -5
- package/targets/branch-guard.mjs +9 -7
- package/targets/git-permissions.js +151 -0
- package/targets/hook-once.mjs +2 -2
- package/targets/knowledge-sync.mjs +22 -13
- package/targets/team-safe-start.mjs +3 -2
package/README.md
CHANGED
|
@@ -70,20 +70,41 @@ rsc 3.0 fixes all four, by default, with no new commands to learn:
|
|
|
70
70
|
|
|
71
71
|
| | What happens | Turn it off |
|
|
72
72
|
| --- | --- | --- |
|
|
73
|
-
| **
|
|
73
|
+
| **You decide where each change goes** | Before every change that writes code, the agent asks: a new branch, or straight on `main`? It never opens a branch on its own. In a project that is complex or in production (CI, a deployment, several people), a commit on `main` is refused, and the agent asks whether to open a branch or unlock `main`. | Say **«unlock main»**, or `rsc main unlock` |
|
|
74
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
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
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
77
|
|
|
78
|
+
**Commit, push and pull request without a prompt each time.** A harness installed from scratch lets
|
|
79
|
+
the agent run `git commit`, `git push` and `gh pr create` without asking, because those are the steps
|
|
80
|
+
that close every lane. A force-push still asks. The guards still decide: a commit on a closed `main` is
|
|
81
|
+
refused whatever the permission says. It is a project decision, saved as `gitPermissions` in
|
|
82
|
+
`.rsc.json`. A clone gets the same setting. A project adopted before this is left as it was until
|
|
83
|
+
someone runs `rsc git-permissions on`.
|
|
84
|
+
|
|
85
|
+
| Assistant | Where it goes |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| Claude Code | `.claude/settings.json` → `permissions.allow` (force-push under `ask`). Claude Code applies it once the folder is trusted. |
|
|
88
|
+
| Codex | `.codex/rules/rsc-git.rules` (`prefix_rule`; force-push → `prompt`). Loaded when the project is trusted. |
|
|
89
|
+
| Gemini | `.gemini/settings.json` → `tools.allowed` |
|
|
90
|
+
| OpenCode | `opencode.json` → `permission.bash` (force-push → `ask`) |
|
|
91
|
+
| Cursor | Not covered: its CLI matches only the first word, so allowing a push would allow every git command. |
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
rsc git-permissions status # decided or not, and wired per assistant
|
|
95
|
+
rsc git-permissions off # ask again for commit, push and PR (saved in .rsc.json)
|
|
96
|
+
rsc git-permissions on # turn it on, also for a project adopted before
|
|
97
|
+
```
|
|
98
|
+
|
|
78
99
|
**How does it know a project is "complex or in production"?** There are two layers. A hook counts
|
|
79
100
|
what anyone can check: a CI setup, a deployment file (`Dockerfile`, `vercel.json`, `fly.toml`…) or
|
|
80
101
|
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
|
|
82
|
-
|
|
102
|
+
is refused before it runs, and the agent asks you whether to open a branch or unlock it. If none is,
|
|
103
|
+
`main` is open, and the agent still asks before each change whether you want a branch. If the hook gets it wrong and the project really is simple, «unlock main» opens it for
|
|
83
104
|
the project. That decision is saved in `.rsc.json`, so the whole team gets it.
|
|
84
105
|
|
|
85
|
-
Every refusal says how to carry on
|
|
86
|
-
|
|
106
|
+
Every refusal says how to carry on, and the choice between a branch and unlocking goes back to you.
|
|
107
|
+
An agent is never left stuck. The rules only see what the **agent** runs; a person
|
|
87
108
|
committing in their own terminal is never touched.
|
|
88
109
|
|
|
89
110
|
**The first session after upgrading** does three things, once:
|
|
@@ -446,6 +467,8 @@ new · clones main has it all after the first message
|
|
|
446
467
|
only its `01-TOOLS/` and `02-DOCS/` part, under a `docs(auto) … [skip ci]` message.
|
|
447
468
|
- **Before each message**, what teammates sent is brought into **the branch you are on**, and you are
|
|
448
469
|
told in one line. Only the knowledge folders are touched.
|
|
470
|
+
A branch you open later catches up on its first message: everything on `rsc/knowledge` since it
|
|
471
|
+
left `main` comes down, including what you wrote yourself on another branch.
|
|
449
472
|
- **It reaches `main` the normal way.** Your feature branch now carries the team's knowledge, so it
|
|
450
473
|
arrives in `main` inside the pull request you were going to open anyway. There is no extra pull
|
|
451
474
|
request from `rsc/knowledge`, and nobody has to merge it.
|
package/manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "3.0.
|
|
2
|
+
"version": "3.0.4",
|
|
3
3
|
"counts": {
|
|
4
4
|
"skills": 270,
|
|
5
5
|
"agents": 33,
|
|
@@ -1911,7 +1911,7 @@
|
|
|
1911
1911
|
},
|
|
1912
1912
|
{
|
|
1913
1913
|
"id": "ftd",
|
|
1914
|
-
"description": "Use for ordinary authorised change — the default lane once the decisor has ruled out a question and not selected the chain. Keeps one feature document per feature in 02-DOCS holding intent, scope, checklist, evidence and next step, checks tasks off only against observed proof, and
|
|
1914
|
+
"description": "Use for ordinary authorised change — the default lane once the decisor has ruled out a question and not selected the chain. Keeps one feature document per feature in 02-DOCS holding intent, scope, checklist, evidence and next step, checks tasks off only against observed proof, and asks whether to branch before the work writes code. NOT the ten-phase chain (that is `sdd`), NOT the classification itself (that is the decisor in `suggest`), NOT a request for information, which writes nothing.",
|
|
1915
1915
|
"tags": [
|
|
1916
1916
|
"ftd",
|
|
1917
1917
|
"lane",
|
package/package.json
CHANGED
package/scripts/install-apply.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from '../targets/memory.js';
|
|
25
25
|
import { wireUpdate, unwireUpdate, updateManagedPaths, updateArtifactsPresent } from '../targets/update-wiring.js';
|
|
26
26
|
import { wireKnowledge, unwireKnowledge, knowledgeManagedPaths, knowledgeArtifactsPresent } from '../targets/knowledge-wiring.js';
|
|
27
|
+
import { wireGitPermissions, unwireGitPermissions, gitPermissionsPath } from '../targets/git-permissions.js';
|
|
27
28
|
|
|
28
29
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
30
|
const CLI_VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
|
|
@@ -104,6 +105,7 @@ export function managedPathsForInstall({ skillIds, agentIds = [], target, home,
|
|
|
104
105
|
if (policy?.memory !== false) out.push(...memoryManagedPaths(target, cwd));
|
|
105
106
|
out.push(...updateManagedPaths(target, cwd));
|
|
106
107
|
out.push(...knowledgeManagedPaths(target, cwd));
|
|
108
|
+
if (gitPermissionsPath(target, cwd)) out.push(gitPermissionsPath(target, cwd)); // backed up before it is merged into
|
|
107
109
|
if (targetHasAgents(target)) {
|
|
108
110
|
const state = readState(paths.stateFile);
|
|
109
111
|
const explicit = [...new Set([...(state.explicitAgents || readManifest(cwd)?.agents || []), ...agentIds])];
|
|
@@ -196,7 +198,7 @@ export function hydrateLocalDecisions(cwd, manifest) {
|
|
|
196
198
|
// into codex on a machine that already had claude is adding, not replacing. Union, sorted
|
|
197
199
|
// where order carries no meaning, so the file stays diffable and a merge conflict stays
|
|
198
200
|
// readable.
|
|
199
|
-
export function recordInManifest({ cwd, target, skillIds, agentIds = [], catalogVersion = CLI_VERSION, dropTarget, onboarding }) {
|
|
201
|
+
export function recordInManifest({ cwd, target, skillIds, agentIds = [], catalogVersion = CLI_VERSION, dropTarget, onboarding, gitPermissions }) {
|
|
200
202
|
const prev = readManifest(cwd) || { targets: [], skills: [], agents: [], ownSkills: [], optOuts: [] };
|
|
201
203
|
const { optOuts, tier } = localDecisions(cwd);
|
|
202
204
|
const union = (a, b) => [...new Set([...(a || []), ...(b || [])])].sort();
|
|
@@ -215,6 +217,7 @@ export function recordInManifest({ cwd, target, skillIds, agentIds = [], catalog
|
|
|
215
217
|
tier: tier ?? prev.tier ?? null,
|
|
216
218
|
optOuts: optOuts ?? projectOptOuts(prev.optOuts),
|
|
217
219
|
memory: prev.memory,
|
|
220
|
+
gitPermissions: gitPermissions ?? prev.gitPermissions,
|
|
218
221
|
onboarding: onboarding ?? prev.onboarding,
|
|
219
222
|
});
|
|
220
223
|
}
|
|
@@ -225,6 +228,11 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
|
|
|
225
228
|
// declaration is the only thing that knows what the team decided) from a built machine (the
|
|
226
229
|
// local markers are the authority, and a pull must not overwrite them).
|
|
227
230
|
const fromScratch = !existsSync(join(cwd, '.rsc'));
|
|
231
|
+
// Git permissions are on for a project adopted from scratch (no manifest yet); an older project
|
|
232
|
+
// keeps what it declared, and absent means "never decided": nothing is added or removed there.
|
|
233
|
+
const declaredGit = readManifest(cwd)?.gitPermissions;
|
|
234
|
+
const newProject = !existsSync(join(cwd, '.rsc.json'));
|
|
235
|
+
const gitPermissions = newProject ? true : declaredGit;
|
|
228
236
|
const paths = targetPaths(target, home, cwd);
|
|
229
237
|
const plan = planInstall({ skillIds, target, home, cwd, hooks: policy?.alwaysOn !== false });
|
|
230
238
|
// What this pass prunes, decided BEFORE the backup so the backup holds it: a skill link this
|
|
@@ -312,6 +320,8 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
|
|
|
312
320
|
// the memory, so the memory's policy does not decide it: wired wherever the assistant can run it.
|
|
313
321
|
const knowledgeResult = wireKnowledge(target, cwd);
|
|
314
322
|
state.knowledge = { mode: knowledgeResult.mode, reason: knowledgeResult.reason || null, paths: knowledgeResult.paths };
|
|
323
|
+
if (gitPermissions === true) state.gitPermissions = wireGitPermissions(target, cwd);
|
|
324
|
+
else if (gitPermissions === false) { unwireGitPermissions(target, cwd); state.gitPermissions = { mode: 'off' }; }
|
|
315
325
|
const context7OptOut = join(cwd, '.rsc', '.no-context7');
|
|
316
326
|
if (policy?.context7 === false) {
|
|
317
327
|
mkdirSync(dirname(context7OptOut), { recursive: true });
|
|
@@ -339,7 +349,7 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
|
|
|
339
349
|
writeState(paths.stateFile, state);
|
|
340
350
|
mkdirSync(dirname(versionFile(cwd)), { recursive: true });
|
|
341
351
|
writeFileSync(versionFile(cwd), CLI_VERSION + '\n');
|
|
342
|
-
recordInManifest({ cwd, target, skillIds, agentIds, onboarding });
|
|
352
|
+
recordInManifest({ cwd, target, skillIds, agentIds, onboarding, gitPermissions });
|
|
343
353
|
// The worktree cleanup's trigger. It lives in `.git/hooks/`, which is NOT cloned, so it has to be
|
|
344
354
|
// (re)written by every operation that touches a user's repo — install, sync and repair all land
|
|
345
355
|
// here. Target-agnostic on purpose: it is a git hook, not an assistant hook, and a cleanup that
|
|
@@ -613,6 +623,7 @@ export function removeTargetInstall({ target, home, cwd = process.cwd() }) {
|
|
|
613
623
|
unwireMemory(target, cwd);
|
|
614
624
|
unwireUpdate(target, cwd);
|
|
615
625
|
unwireKnowledge(target, cwd);
|
|
626
|
+
unwireGitPermissions(target, cwd);
|
|
616
627
|
rmSync(paths.stateFile, { force: true });
|
|
617
628
|
const gitignore = join(cwd, '.gitignore');
|
|
618
629
|
if (existsSync(gitignore)) {
|
|
@@ -723,6 +734,7 @@ export async function purge({ home, cwd = process.cwd(), withDocs = false, dryRu
|
|
|
723
734
|
removed.push(...unwireMemory(target, cwd));
|
|
724
735
|
removed.push(...unwireUpdate(target, cwd));
|
|
725
736
|
removed.push(...unwireKnowledge(target, cwd));
|
|
737
|
+
removed.push(...unwireGitPermissions(target, cwd));
|
|
726
738
|
} else {
|
|
727
739
|
removed.push(...memoryArtifactsPresent(target, cwd));
|
|
728
740
|
removed.push(...updateArtifactsPresent(target, cwd));
|
|
@@ -15,7 +15,7 @@ import { join } from 'node:path';
|
|
|
15
15
|
export const MANIFEST = '.rsc.json';
|
|
16
16
|
export const manifestPath = (cwd = process.cwd()) => join(cwd, MANIFEST);
|
|
17
17
|
|
|
18
|
-
const KEYS = ['version', 'targets', 'skills', 'agents', 'ownSkills', 'catalogVersion', 'tier', 'optOuts', 'memory', 'onboarding'];
|
|
18
|
+
const KEYS = ['version', 'targets', 'skills', 'agents', 'ownSkills', 'catalogVersion', 'tier', 'optOuts', 'memory', 'gitPermissions', 'onboarding'];
|
|
19
19
|
|
|
20
20
|
export function readManifest(cwd = process.cwd()) {
|
|
21
21
|
const file = manifestPath(cwd);
|
|
@@ -38,6 +38,8 @@ export function readManifest(cwd = process.cwd()) {
|
|
|
38
38
|
tier: raw.tier ?? null,
|
|
39
39
|
optOuts: raw.optOuts || [],
|
|
40
40
|
memory: raw.memory ?? undefined,
|
|
41
|
+
// true on a project installed from scratch since 3.0.3; absent on older ones; false once turned off.
|
|
42
|
+
gitPermissions: typeof raw.gitPermissions === 'boolean' ? raw.gitPermissions : undefined,
|
|
41
43
|
onboarding: raw.onboarding ?? undefined,
|
|
42
44
|
};
|
|
43
45
|
}
|
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', 'knowledge-sync', 'main', 'isolation', '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', 'git-permissions', '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++) {
|
|
@@ -832,6 +832,36 @@ async function main() {
|
|
|
832
832
|
process.exitCode = 2;
|
|
833
833
|
return;
|
|
834
834
|
}
|
|
835
|
+
case 'git-permissions': {
|
|
836
|
+
// On for a project installed from scratch: the agent commits, pushes and opens the PR without a
|
|
837
|
+
// prompt each time (a force-push still asks; the guards still apply). A PROJECT decision, saved as
|
|
838
|
+
// `gitPermissions` in `.rsc.json` so a clone gets the same; an older project starts undecided.
|
|
839
|
+
const sub = argv[1] || 'status';
|
|
840
|
+
const root = process.cwd();
|
|
841
|
+
const { readManifest, writeManifest } = await import('./lib/manifest-file.js');
|
|
842
|
+
const { gitPermissionsWired, GIT_PERMISSION_TARGETS } = await import('../targets/git-permissions.js');
|
|
843
|
+
if (sub === 'on' || sub === 'off') {
|
|
844
|
+
const current = readManifest(root);
|
|
845
|
+
if (!current) { say('No .rsc.json here: install the harness first (`npx @ericrisco/rsc onboard`).'); process.exitCode = 2; return; }
|
|
846
|
+
writeManifest(root, { ...current, gitPermissions: sub === 'on' });
|
|
847
|
+
for (const t of targets) await syncInstalled({ target: t, cwd: root });
|
|
848
|
+
say(sub === 'on'
|
|
849
|
+
? 'rsc git-permissions on: the agent may git commit, git push and gh pr create without asking (a force-push still asks; Cursor is not covered). Commit .rsc.json so the team gets the same decision.'
|
|
850
|
+
: 'rsc git-permissions off: commit, push and PR ask again, as the assistant does by default. Commit .rsc.json so the team gets the same decision.');
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (sub === 'status') {
|
|
854
|
+
const declared = readManifest(root)?.gitPermissions;
|
|
855
|
+
return void say(JSON.stringify({
|
|
856
|
+
declared: declared === undefined ? 'undecided' : declared,
|
|
857
|
+
wired: Object.fromEntries(targets.filter((t) => GIT_PERMISSION_TARGETS.includes(t)).map((t) => [t, gitPermissionsWired(t, root)])),
|
|
858
|
+
notCovered: targets.filter((t) => !GIT_PERMISSION_TARGETS.includes(t)),
|
|
859
|
+
}, null, 2));
|
|
860
|
+
}
|
|
861
|
+
say('Use: npx @ericrisco/rsc git-permissions on|off|status');
|
|
862
|
+
process.exitCode = 2;
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
835
865
|
case 'memory': {
|
|
836
866
|
const sub = argv[1] || 'status';
|
|
837
867
|
const root = process.cwd();
|
package/skills/ftd/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ftd
|
|
3
|
-
description: "Use for ordinary authorised change — the default lane once the decisor has ruled out a question and not selected the chain. Keeps one feature document per feature in 02-DOCS holding intent, scope, checklist, evidence and next step, checks tasks off only against observed proof, and
|
|
3
|
+
description: "Use for ordinary authorised change — the default lane once the decisor has ruled out a question and not selected the chain. Keeps one feature document per feature in 02-DOCS holding intent, scope, checklist, evidence and next step, checks tasks off only against observed proof, and asks whether to branch before the work writes code. NOT the ten-phase chain (that is `sdd`), NOT the classification itself (that is the decisor in `suggest`), NOT a request for information, which writes nothing."
|
|
4
4
|
tags: [ftd, lane, default, feature-document, evidence]
|
|
5
5
|
recommends: [worktrees, debug, verify]
|
|
6
6
|
profiles: [minimal, core, full]
|
|
@@ -53,14 +53,16 @@ the chain: a result asserted without evidence is treated as not done.
|
|
|
53
53
|
|
|
54
54
|
## Isolation
|
|
55
55
|
|
|
56
|
-
Where the work runs
|
|
56
|
+
Where the work runs is the person's call, asked change by change — never a branch opened on your own:
|
|
57
57
|
|
|
58
|
-
- **
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
- **Before each change that writes code**, ask in one line: «¿Lo hago en una rama nueva o directamente
|
|
59
|
+
en `<default branch>`?». A branch is named by intent and closes with a pull request.
|
|
60
|
+
- **Default branch closed for the agent** (rsc refuses the commit there: CI, a deployment or several
|
|
61
|
+
people committing) → ask: «¿Abro una rama o desbloqueo `<default branch>`?». Unlocking is
|
|
62
|
+
`rsc main unlock`, a project decision; never run it without that answer.
|
|
63
|
+
- **Docs, wiki or config only** → no question, no branch.
|
|
62
64
|
- **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.
|
|
65
|
+
branch switch under the other session. Not a question: rsc tells you when that is the case.
|
|
64
66
|
|
|
65
67
|
A worktree only when another session is there: alone, setting one up costs more than it protects.
|
|
66
68
|
|
|
@@ -45,9 +45,9 @@ back — you do not write feature code anyway.
|
|
|
45
45
|
4. **Read the register.** Open `02-DOCS/wiki/harness/user-profile.md` and read `technical_level`.
|
|
46
46
|
It picks technical terms or plain words with analogies at each checkpoint (see "Checkpoints"
|
|
47
47
|
below). No profile yet → use analogies, and ask before any irreversible step.
|
|
48
|
-
5. **Confirm isolation.**
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
5. **Confirm isolation.** On `main`/`master`, stop before the first commit and ask the person: a
|
|
49
|
+
feature branch, or straight on the default branch (`worktrees`)? Never branch on your own; another
|
|
50
|
+
session in this folder makes the worktree mandatory.
|
|
51
51
|
6. **Scan the tasks for independence.** Mark which tasks share files/state and which are disjoint.
|
|
52
52
|
Disjoint clusters are candidates for `parallel`; everything else runs in order.
|
|
53
53
|
|
|
@@ -314,7 +314,7 @@ outranks the plan, and the plan outranks your in-the-moment preference.
|
|
|
314
314
|
- **Unresolved `analyze` findings** (contradiction between constitution ↔ spec ↔ plan ↔ tasks) →
|
|
315
315
|
resolve them before any code; they will only get more expensive after the diff exists.
|
|
316
316
|
- **A task forces a constitution violation** → surface it, stop, kick it back to `analyze`/`plan`.
|
|
317
|
-
- **You're on the default branch** → stop before the first commit
|
|
317
|
+
- **You're on the default branch** → stop before the first commit and ask: branch or default branch (`worktrees`).
|
|
318
318
|
- **A test won't go red** (wrong reason — import error, syntax) → fix the test before trusting it.
|
|
319
319
|
- **The combined suite is red after a parallel merge** → do not checkpoint as done; debug with
|
|
320
320
|
`debug` before continuing.
|
package/skills/suggest/SKILL.md
CHANGED
|
@@ -32,9 +32,9 @@ Big, or decisions that affect each other → **SDD**: enter the chain; the perso
|
|
|
32
32
|
clarify, then picks manual or autopilot. Say which and why in one line; switch if asked.
|
|
33
33
|
→ `../ftd/SKILL.md` · `../sdd/SKILL.md`.
|
|
34
34
|
|
|
35
|
-
**Where.**
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
**Where.** Before each change that writes code, ask: a branch, or straight on the default branch? Never
|
|
36
|
+
branch on your own. Default branch closed (CI, deploy, team) → ask: a branch, or `rsc main unlock`?
|
|
37
|
+
Another session in this checkout → `.worktrees/<branch>`; «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.
|
|
@@ -27,11 +27,12 @@ constitution → specify → clarify → plan → tasks → analyze → [ worktr
|
|
|
27
27
|
|
|
28
28
|
## The one rule that defines this step
|
|
29
29
|
|
|
30
|
-
**Never run `implement` on the default branch.** The moment a plan is about to become
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
**Never run `implement` on the default branch without asking.** The moment a plan is about to become
|
|
31
|
+
commits, if `git rev-parse --abbrev-ref HEAD` says `main` or `master`, you stop and ask the person
|
|
32
|
+
*first* — «¿rama nueva o directamente en `main`?» — before the first edit, not after the diff already
|
|
33
|
+
exists. Never branch on your own; if rsc refuses commits there (closed default branch), the question
|
|
34
|
+
is «¿abro una rama o desbloqueo `main`?». The one exception is another session in this folder: then
|
|
35
|
+
the worktree is mandatory, not a question.
|
|
35
36
|
|
|
36
37
|
## The decision: branch vs. worktree
|
|
37
38
|
|
package/targets/branch-guard.mjs
CHANGED
|
@@ -152,14 +152,16 @@ async function othersHere({ root, here, sessionId, self }) {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
async function trunkDenial({ root, here, sessionId, self, trunk, policy }) {
|
|
155
|
-
// Where
|
|
155
|
+
// Where a branch would go depends on rule B: alone → this same folder; with company → .worktrees/.
|
|
156
|
+
// Whether to branch at all is the person's call: the agent asks, it never branches on its own.
|
|
156
157
|
const others = existsSync(join(root, '.rsc', '.no-worktree-isolation')) ? [] : await othersHere({ root, here, sessionId, self });
|
|
157
|
-
const
|
|
158
|
-
? '
|
|
159
|
-
: '
|
|
160
|
-
return `This project keeps its default branch "${trunk}" closed for the agent (it looks complex or in production: ${policy.signals.join(', ')}). ` +
|
|
161
|
-
`Do
|
|
162
|
-
|
|
158
|
+
const branch = others.length
|
|
159
|
+
? '`git worktree add .worktrees/<branch> -b feat/<what>` and commit inside `.worktrees/<branch>/` (another session is working in this folder)'
|
|
160
|
+
: '`git switch -c feat/<what>` in this same folder, no worktree';
|
|
161
|
+
return `This project keeps its default branch "${trunk}" closed for the agent (it looks complex or in production: ${policy.signals.join(', ')}), so this commit was not made. ` +
|
|
162
|
+
`Do not choose for the person: ask them in one line whether to open a branch for this change or to unlock "${trunk}". ` +
|
|
163
|
+
`Branch → ${branch}; it reaches "${trunk}" through a pull request. ` +
|
|
164
|
+
'Unlock → only on their explicit answer, `npx @ericrisco/rsc main unlock` (a project decision, saved in .rsc.json), then commit again.';
|
|
163
165
|
}
|
|
164
166
|
|
|
165
167
|
async function isolationDenial({ root, here, sessionId, self }) {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Git permissions: on a harness installed from scratch, the agent may `git commit`, `git push` and
|
|
2
|
+
// `gh pr create` without asking — the three steps that close every lane (branch → commit → push →
|
|
3
|
+
// PR). A force-push still asks wherever the assistant can say so. The guards are untouched: a commit
|
|
4
|
+
// on a closed default branch is still denied by branch-guard, whatever the permission says.
|
|
5
|
+
//
|
|
6
|
+
// A PROJECT decision, recorded as `gitPermissions` in `.rsc.json`: true on a brand-new project, absent
|
|
7
|
+
// on one adopted before this existed (nothing changes there until `rsc git-permissions on`), false
|
|
8
|
+
// after `rsc git-permissions off`. Only rsc's own entries are ever added or removed; whatever the
|
|
9
|
+
// person allowed or denied stays exactly as it was.
|
|
10
|
+
//
|
|
11
|
+
// Cursor is left out on purpose: its CLI matches only the first word (`Shell(git)`), so allowing a
|
|
12
|
+
// push there would allow every git and gh command, `reset --hard` and `repo delete` included.
|
|
13
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
16
|
+
|
|
17
|
+
export const GIT_PERMISSION_TARGETS = Object.freeze(['claude', 'codex', 'gemini', 'opencode']);
|
|
18
|
+
|
|
19
|
+
const CLAUDE_ALLOW = ['Bash(git commit *)', 'Bash(git push *)', 'Bash(gh pr create *)'];
|
|
20
|
+
// `ask` beats `allow` in Claude Code, and applies to any subcommand of a chain.
|
|
21
|
+
const CLAUDE_ASK = ['Bash(git push --force*)', 'Bash(git push -f*)', 'Bash(git push * --force*)', 'Bash(git push * -f*)'];
|
|
22
|
+
const GEMINI_ALLOW = ['run_shell_command(git commit)', 'run_shell_command(git push)', 'run_shell_command(gh pr create)'];
|
|
23
|
+
// OpenCode: the last matching rule wins, so the force-push asks come after the allows.
|
|
24
|
+
const OPENCODE_BASH = [
|
|
25
|
+
['git commit', 'allow'], ['git commit *', 'allow'], ['git push', 'allow'], ['git push *', 'allow'], ['gh pr create *', 'allow'],
|
|
26
|
+
['git push --force*', 'ask'], ['git push -f*', 'ask'], ['git push * --force*', 'ask'], ['git push * -f*', 'ask'],
|
|
27
|
+
];
|
|
28
|
+
const CODEX_RULES = `# rsc-git-permissions:managed — written by rsc; \`rsc git-permissions off\` removes it.
|
|
29
|
+
# Commit, push and open a PR without asking; a force-push still asks (the strictest match wins).
|
|
30
|
+
prefix_rule(pattern = ["git", "commit"], decision = "allow", justification = "rsc: close the lane — commit")
|
|
31
|
+
prefix_rule(pattern = ["git", "push"], decision = "allow", justification = "rsc: close the lane — push the branch")
|
|
32
|
+
prefix_rule(pattern = ["gh", "pr", "create"], decision = "allow", justification = "rsc: close the lane — open the PR")
|
|
33
|
+
prefix_rule(pattern = ["git", "push", ["--force", "-f", "--force-with-lease"]], decision = "prompt", justification = "rsc: a force-push rewrites the remote")
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const FILES = Object.freeze({
|
|
37
|
+
claude: '.claude/settings.json',
|
|
38
|
+
codex: '.codex/rules/rsc-git.rules',
|
|
39
|
+
gemini: '.gemini/settings.json',
|
|
40
|
+
opencode: 'opencode.json',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const git = (cwd, args) => {
|
|
44
|
+
try { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
|
|
45
|
+
};
|
|
46
|
+
const rel = (cwd, path) => relative(cwd, path).split(sep).join('/');
|
|
47
|
+
|
|
48
|
+
/** A file rsc created is kept out of git with the local exclude; one the project tracks is merged into, never hidden. */
|
|
49
|
+
function excludeIfNew(cwd, path) {
|
|
50
|
+
if (git(cwd, ['rev-parse', '--is-inside-work-tree']) !== 'true') return;
|
|
51
|
+
if (git(cwd, ['ls-files', '--', rel(cwd, path)])) return;
|
|
52
|
+
const value = git(cwd, ['rev-parse', '--git-path', 'info/exclude']);
|
|
53
|
+
if (!value) return;
|
|
54
|
+
const file = isAbsolute(value) ? value : resolve(cwd, value);
|
|
55
|
+
const pattern = `/${rel(cwd, path)}`;
|
|
56
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
57
|
+
const body = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
58
|
+
if (!body.split('\n').includes(pattern)) appendFileSync(file, `${body && !body.endsWith('\n') ? '\n' : ''}${pattern}\n`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readJson(path) {
|
|
62
|
+
if (!existsSync(path)) return {};
|
|
63
|
+
try {
|
|
64
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
65
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
66
|
+
} catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const writeJson = (path, value) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); };
|
|
70
|
+
const addAll = (list, items) => [...(Array.isArray(list) ? list : []), ...items.filter((i) => !(list || []).includes(i))];
|
|
71
|
+
const dropAll = (list, items) => (Array.isArray(list) ? list.filter((i) => !items.includes(i)) : list);
|
|
72
|
+
|
|
73
|
+
export function gitPermissionsPath(target, cwd = process.cwd()) {
|
|
74
|
+
return FILES[target] ? join(cwd, ...FILES[target].split('/')) : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Add rsc's entries. `{ mode: 'wired' | 'unsupported' | 'config-invalid', path }`. */
|
|
78
|
+
export function wireGitPermissions(target, cwd = process.cwd()) {
|
|
79
|
+
const path = gitPermissionsPath(target, cwd);
|
|
80
|
+
if (!path) return { mode: 'unsupported', path: null };
|
|
81
|
+
if (target === 'codex') {
|
|
82
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
83
|
+
writeFileSync(path, CODEX_RULES);
|
|
84
|
+
excludeIfNew(cwd, path);
|
|
85
|
+
return { mode: 'wired', path };
|
|
86
|
+
}
|
|
87
|
+
const existed = existsSync(path);
|
|
88
|
+
const config = readJson(path);
|
|
89
|
+
if (!config) return { mode: 'config-invalid', path }; // never overwrite a file we cannot read
|
|
90
|
+
if (target === 'claude') {
|
|
91
|
+
config.permissions ||= {};
|
|
92
|
+
config.permissions.allow = addAll(config.permissions.allow, CLAUDE_ALLOW);
|
|
93
|
+
config.permissions.ask = addAll(config.permissions.ask, CLAUDE_ASK);
|
|
94
|
+
} else if (target === 'gemini') {
|
|
95
|
+
config.tools ||= {};
|
|
96
|
+
config.tools.allowed = addAll(config.tools.allowed, GEMINI_ALLOW);
|
|
97
|
+
} else if (target === 'opencode') {
|
|
98
|
+
config.permission ||= {};
|
|
99
|
+
if (typeof config.permission.bash === 'string') return { mode: 'config-invalid', path }; // a blanket rule is the person's call
|
|
100
|
+
const bash = config.permission.bash && typeof config.permission.bash === 'object' ? config.permission.bash : {};
|
|
101
|
+
// Re-inserted so the asks stay after the allows (OpenCode: the last matching rule wins).
|
|
102
|
+
for (const [pattern] of OPENCODE_BASH) delete bash[pattern];
|
|
103
|
+
for (const [pattern, action] of OPENCODE_BASH) bash[pattern] = action;
|
|
104
|
+
config.permission.bash = bash;
|
|
105
|
+
config.$schema ||= 'https://opencode.ai/config.json';
|
|
106
|
+
}
|
|
107
|
+
writeJson(path, config);
|
|
108
|
+
if (!existed && target !== 'claude') excludeIfNew(cwd, path);
|
|
109
|
+
return { mode: 'wired', path };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Remove rsc's entries, and nothing else. Returns the paths it touched. */
|
|
113
|
+
export function unwireGitPermissions(target, cwd = process.cwd()) {
|
|
114
|
+
const path = gitPermissionsPath(target, cwd);
|
|
115
|
+
if (!path || !existsSync(path)) return [];
|
|
116
|
+
if (target === 'codex') {
|
|
117
|
+
if (!readFileSync(path, 'utf8').includes('rsc-git-permissions:managed')) return [];
|
|
118
|
+
rmSync(path, { force: true });
|
|
119
|
+
return [path];
|
|
120
|
+
}
|
|
121
|
+
const config = readJson(path);
|
|
122
|
+
if (!config) return [];
|
|
123
|
+
if (target === 'claude' && config.permissions) {
|
|
124
|
+
config.permissions.allow = dropAll(config.permissions.allow, CLAUDE_ALLOW);
|
|
125
|
+
config.permissions.ask = dropAll(config.permissions.ask, CLAUDE_ASK);
|
|
126
|
+
for (const k of ['allow', 'ask']) if (Array.isArray(config.permissions[k]) && !config.permissions[k].length) delete config.permissions[k];
|
|
127
|
+
if (!Object.keys(config.permissions).length) delete config.permissions;
|
|
128
|
+
} else if (target === 'gemini' && config.tools) {
|
|
129
|
+
config.tools.allowed = dropAll(config.tools.allowed, GEMINI_ALLOW);
|
|
130
|
+
if (Array.isArray(config.tools.allowed) && !config.tools.allowed.length) delete config.tools.allowed;
|
|
131
|
+
if (!Object.keys(config.tools).length) delete config.tools;
|
|
132
|
+
} else if (target === 'opencode' && config.permission?.bash && typeof config.permission.bash === 'object') {
|
|
133
|
+
for (const [pattern, action] of OPENCODE_BASH) if (config.permission.bash[pattern] === action) delete config.permission.bash[pattern];
|
|
134
|
+
if (!Object.keys(config.permission.bash).length) delete config.permission.bash;
|
|
135
|
+
if (!Object.keys(config.permission).length) delete config.permission;
|
|
136
|
+
} else return [];
|
|
137
|
+
writeJson(path, config);
|
|
138
|
+
return [path];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Whether rsc's entries are all there, for `status` and `doctor`. */
|
|
142
|
+
export function gitPermissionsWired(target, cwd = process.cwd()) {
|
|
143
|
+
const path = gitPermissionsPath(target, cwd);
|
|
144
|
+
if (!path || !existsSync(path)) return false;
|
|
145
|
+
if (target === 'codex') return readFileSync(path, 'utf8').includes('rsc-git-permissions:managed');
|
|
146
|
+
const config = readJson(path) || {};
|
|
147
|
+
if (target === 'claude') return CLAUDE_ALLOW.every((r) => config.permissions?.allow?.includes(r));
|
|
148
|
+
if (target === 'gemini') return GEMINI_ALLOW.every((r) => config.tools?.allowed?.includes(r));
|
|
149
|
+
if (target === 'opencode') return OPENCODE_BASH.every(([p, a]) => config.permission?.bash?.[p] === a);
|
|
150
|
+
return false;
|
|
151
|
+
}
|
package/targets/hook-once.mjs
CHANGED
|
@@ -97,8 +97,8 @@ Classify this turn before acting, and name the lane in one line.
|
|
|
97
97
|
- Authorises a change? YOU pick the lane, say it and why: simple -> FTD (one
|
|
98
98
|
feature document, tasks checked off against observed proof); big or
|
|
99
99
|
interlocking decisions -> SDD (spec + clarify approved, then autopilot or not).
|
|
100
|
-
-
|
|
101
|
-
|
|
100
|
+
- Code change? Ask first, every time: a branch or the default branch? Never
|
|
101
|
+
branch on your own. Another session here: .worktrees/<branch>.
|
|
102
102
|
Method: \`ftd\` · chain: \`sdd\` · full decisor in the always-on \`suggest\` body.
|
|
103
103
|
=================================================
|
|
104
104
|
`;
|
|
@@ -104,7 +104,7 @@ const worktreeBlob = (root, path) => (existsSync(join(root, path)) ? line(root,
|
|
|
104
104
|
function readState(root) {
|
|
105
105
|
let s = {};
|
|
106
106
|
try { s = JSON.parse(readFileSync(join(root, '.rsc', STATE), 'utf8')); } catch { /* first run */ }
|
|
107
|
-
return { announced: false,
|
|
107
|
+
return { announced: false, seenBy: {}, queue: [], ours: [], notices: [], lastFetch: 0, snap: null, head: null, ...s };
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
function writeState(root, s) {
|
|
@@ -375,30 +375,35 @@ export function applyIncoming(root, s) {
|
|
|
375
375
|
if (!branch || branch === 'HEAD') return said;
|
|
376
376
|
const tip = line(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${KNOWLEDGE_BRANCH}`]);
|
|
377
377
|
if (!tip) return said;
|
|
378
|
-
|
|
379
|
-
|
|
378
|
+
// Per branch, not per repo: a branch opened from main after the last sync starts behind what
|
|
379
|
+
// rsc/knowledge already has, and a single "last seen" made it look up to date (2026-10-06). A branch
|
|
380
|
+
// seen for the first time catches up from where it left rsc/knowledge.
|
|
381
|
+
s.seenBy = s.seenBy && typeof s.seenBy === 'object' ? s.seenBy : {};
|
|
382
|
+
let seen = s.seenBy[branch];
|
|
383
|
+
if (!seen || !line(root, ['rev-parse', '--verify', '--quiet', `${seen}^{commit}`])) {
|
|
384
|
+
seen = line(root, ['merge-base', 'HEAD', tip]) || tip;
|
|
380
385
|
}
|
|
381
|
-
if (
|
|
386
|
+
if (seen === tip) { s.seenBy[branch] = tip; return said; }
|
|
382
387
|
const paths = [...KNOWLEDGE];
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
if (!theirs.length) { s.seen = tip; return said; }
|
|
388
|
+
const commits = git(root, ['rev-list', `${seen}..${tip}`, '--', ...paths]).split('\n').filter(Boolean);
|
|
389
|
+
if (!commits.length) { s.seenBy[branch] = tip; return said; }
|
|
386
390
|
|
|
387
|
-
const know = zlist(git(root, ['diff', '--name-only', '--no-renames', '-z',
|
|
391
|
+
const know = zlist(git(root, ['diff', '--name-only', '--no-renames', '-z', seen, tip, '--', ...paths]))
|
|
388
392
|
.filter(isKnowledge).slice(0, MAX_FILES);
|
|
389
|
-
const who = authorsOf(root, theirs);
|
|
390
393
|
const closed = branch === defaultBranch(root) && trunkClosed(root);
|
|
394
|
+
const touchedBy = (f) => git(root, ['rev-list', `${seen}..${tip}`, '--', f]).split('\n').filter(Boolean);
|
|
391
395
|
const take = [];
|
|
392
396
|
const clash = [];
|
|
393
397
|
for (const f of know) {
|
|
394
398
|
const incoming = blob(root, tip, f);
|
|
395
399
|
const mine = worktreeBlob(root, f);
|
|
396
400
|
if (mine === incoming) continue;
|
|
397
|
-
const base = blob(root,
|
|
401
|
+
const base = blob(root, seen, f);
|
|
398
402
|
// On a closed trunk HEAD lags behind on purpose (what came in was not committed), so only the
|
|
399
403
|
// working tree says whether you touched it (review H2).
|
|
400
404
|
if (mine === base && (closed || blob(root, 'HEAD', f) === base)) take.push(f);
|
|
401
|
-
|
|
405
|
+
// Touched here and changed only by your own uploads: your newer version, not a clash.
|
|
406
|
+
else if (touchedBy(f).some((c) => !s.ours.includes(c))) clash.push(f);
|
|
402
407
|
}
|
|
403
408
|
|
|
404
409
|
if (take.length) {
|
|
@@ -414,12 +419,16 @@ export function applyIncoming(root, s) {
|
|
|
414
419
|
} else {
|
|
415
420
|
git(root, ['--literal-pathspecs', 'reset', '--quiet', '--', ...take]); // updated, not staged
|
|
416
421
|
}
|
|
417
|
-
|
|
422
|
+
const from = [...new Set(take.flatMap(touchedBy))];
|
|
423
|
+
const theirs = from.filter((c) => !s.ours.includes(c));
|
|
424
|
+
const who = theirs.length ? authorsOf(root, theirs) : 'ti, desde otra rama';
|
|
425
|
+
said.push(`📥 Traídos ${from.length} cambio(s) de conocimiento de ${who}: ${listed(take)}.`);
|
|
418
426
|
}
|
|
419
427
|
if (clash.length) {
|
|
428
|
+
const who = authorsOf(root, [...new Set(clash.flatMap(touchedBy))].filter((c) => !s.ours.includes(c)));
|
|
420
429
|
said.push(`${who} cambió ${listed(clash)}, que tú también has tocado. No lo traigo solo: tu versión sigue intacta.`);
|
|
421
430
|
}
|
|
422
|
-
s.
|
|
431
|
+
s.seenBy[branch] = tip;
|
|
423
432
|
return said;
|
|
424
433
|
}
|
|
425
434
|
|
|
@@ -101,8 +101,9 @@ export function teamSafeAnnouncement(root) {
|
|
|
101
101
|
return `
|
|
102
102
|
===== rsc 3.0 · equipo seguro por defecto =====
|
|
103
103
|
ACTION: díselo a la persona en pocas líneas, una vez, antes de su petición:
|
|
104
|
-
1.
|
|
105
|
-
|
|
104
|
+
1. Antes de cada cambio de código, el agente pregunta: ¿rama nueva o directamente en la principal?
|
|
105
|
+
Nunca abre una rama por su cuenta. Si la principal está cerrada (CI, despliegue o equipo), pregunta
|
|
106
|
+
si abre una rama o la desbloquea («desbloquea main»).
|
|
106
107
|
2. Si otra sesión de un asistente está trabajando en esta misma carpeta, el trabajo nuevo va a un
|
|
107
108
|
worktree en .worktrees/<rama>/. Para no hacerlo: «no uses worktrees».
|
|
108
109
|
3. El agente elige solo entre FTD (lo sencillo) y SDD (lo grande o complejo) y lo dice; se puede
|