@ericrisco/rsc 3.0.1 → 3.0.3
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 +21 -0
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/doctor.js +23 -1
- package/scripts/install-apply.js +14 -2
- package/scripts/lib/manifest-file.js +3 -1
- package/scripts/rsc.js +31 -1
- package/targets/git-permissions.js +151 -0
- package/targets/knowledge-wiring.js +33 -9
- package/targets/opencode-memory-plugin.js +67 -22
- package/targets/update-wiring.js +18 -3
package/README.md
CHANGED
|
@@ -75,6 +75,27 @@ rsc 3.0 fixes all four, by default, with no new commands to learn:
|
|
|
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
|
package/manifest.json
CHANGED
package/package.json
CHANGED
package/scripts/doctor.js
CHANGED
|
@@ -256,16 +256,38 @@ export function doctor({ target, home, cwd }) {
|
|
|
256
256
|
missingCommands,
|
|
257
257
|
commandOrphans,
|
|
258
258
|
commandCollisions: state.commandCollisions || [],
|
|
259
|
+
// #289 — OpenCode 2.x only loads a plugin from a default export with an id and setup(ctx); the
|
|
260
|
+
// V1-only files older versions wrote fail to load and nothing on rsc's side noticed. A plugin
|
|
261
|
+
// without the V2 entry point is a problem, and `sync` rewrites it.
|
|
262
|
+
opencodePluginsOutdated: target === 'opencode' ? opencodePluginsOutdated(root) : [],
|
|
259
263
|
};
|
|
260
264
|
// #277 — the three "missing" lists share one shape, { id, path, action }, so whoever builds on top
|
|
261
265
|
// of doctor reads them the same way; and `healthy` is what the exit code says.
|
|
262
266
|
for (const [id, e] of Object.entries(state.skills)) {
|
|
263
267
|
for (const f of e.files) if (!existsSync(f)) report.missing.push({ id, path: f, action: 'Run `npx @ericrisco/rsc sync` to restore this managed skill.' });
|
|
264
268
|
}
|
|
265
|
-
report.healthy = report.hookWired && !report.missing.length && !missingAgents.length && !missingCommands.length
|
|
269
|
+
report.healthy = report.hookWired && !report.missing.length && !missingAgents.length && !missingCommands.length
|
|
270
|
+
&& !report.opencodePluginsOutdated.length;
|
|
266
271
|
return report;
|
|
267
272
|
}
|
|
268
273
|
|
|
274
|
+
/** rsc's OpenCode plugins that OpenCode 2.x cannot load (no default export with id + setup). */
|
|
275
|
+
export function opencodePluginsOutdated(root) {
|
|
276
|
+
const dir = join(root, '.opencode', 'plugins');
|
|
277
|
+
const out = [];
|
|
278
|
+
for (const name of ['rsc-update.js', 'rsc-knowledge.js', 'rsc-memory.js']) {
|
|
279
|
+
const path = join(dir, name);
|
|
280
|
+
if (!existsSync(path)) continue;
|
|
281
|
+
let body = '';
|
|
282
|
+
try { body = readFileSync(path, 'utf8'); } catch { continue; }
|
|
283
|
+
if (!/:managed/.test(body)) continue; // hand-written: not ours to judge
|
|
284
|
+
if (!/export default\s*\{/.test(body) || !/\bsetup\s*\(/.test(body)) {
|
|
285
|
+
out.push({ path, action: 'OpenCode 2.x cannot load this plugin (V1 API only). Run `npx @ericrisco/rsc@latest sync` to rewrite it.' });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
269
291
|
// ---------------------------------------------------------------------------------------
|
|
270
292
|
// Context budget — what the harness costs in tokens before the user has typed anything.
|
|
271
293
|
//
|
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();
|
|
@@ -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
|
+
}
|
|
@@ -83,24 +83,48 @@ const PLUGIN = `// ${PLUGIN_MARKER}
|
|
|
83
83
|
// rsc knowledge sync for OpenCode. OpenCode has no prompt event and no channel to the person, so what
|
|
84
84
|
// sync has to say goes to the model, to be relayed. Cheap on every call: the fetch is throttled and
|
|
85
85
|
// every notice is said once. Turn it off with \`rsc knowledge-sync off\`.
|
|
86
|
+
// One file for both plugin APIs (issue #289): V1 calls the named export or server(); V2 reads id + setup.
|
|
86
87
|
import { onRequest, onTurn } from '../../.rsc/knowledge-sync.mjs';
|
|
87
88
|
|
|
89
|
+
const local = () => process.env.RSC_REMOTE_AGENT !== '1' && process.env.OPENCODE_REMOTE !== '1';
|
|
90
|
+
const tell = (cwd) => {
|
|
91
|
+
if (!local()) return '';
|
|
92
|
+
try { const said = onRequest(cwd); return said ? \`Cuéntale esto al usuario en una línea:\\n\${said}\` : ''; } catch { return ''; }
|
|
93
|
+
};
|
|
94
|
+
const turn = (cwd) => { if (local()) { try { onTurn(cwd); } catch { /* fail open */ } } };
|
|
95
|
+
|
|
88
96
|
export const RscKnowledgePlugin = async ({ directory, worktree }) => {
|
|
89
97
|
const cwd = worktree || directory;
|
|
90
|
-
const local = () => process.env.RSC_REMOTE_AGENT !== '1' && process.env.OPENCODE_REMOTE !== '1';
|
|
91
98
|
return {
|
|
92
99
|
'experimental.chat.system.transform': async (_input, output) => {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const said = onRequest(cwd);
|
|
96
|
-
if (said && Array.isArray(output?.system)) output.system.push(\`Cuéntale esto al usuario en una línea:\\n\${said}\`);
|
|
97
|
-
} catch { /* fail open */ }
|
|
98
|
-
},
|
|
99
|
-
event: async ({ event }) => {
|
|
100
|
-
if (event?.type === 'session.idle' && local()) { try { onTurn(cwd); } catch { /* fail open */ } }
|
|
100
|
+
const text = tell(cwd);
|
|
101
|
+
if (text && Array.isArray(output?.system)) output.system.push(text);
|
|
101
102
|
},
|
|
103
|
+
event: async ({ event }) => { if (event?.type === 'session.idle') turn(cwd); },
|
|
102
104
|
};
|
|
103
105
|
};
|
|
106
|
+
|
|
107
|
+
// V2 has no session.idle: a turn ends with session.execution.succeeded (or .failed).
|
|
108
|
+
export default {
|
|
109
|
+
id: 'rsc.knowledge',
|
|
110
|
+
server: RscKnowledgePlugin,
|
|
111
|
+
async setup(ctx) {
|
|
112
|
+
const cwd = ctx.location?.project?.directory || ctx.location?.directory || process.cwd();
|
|
113
|
+
await ctx.session.hook('context', async (event) => {
|
|
114
|
+
const text = tell(cwd);
|
|
115
|
+
if (text && Array.isArray(event?.system)) event.system.push({ type: 'text', text });
|
|
116
|
+
});
|
|
117
|
+
const stop = new AbortController();
|
|
118
|
+
(async () => {
|
|
119
|
+
try {
|
|
120
|
+
for await (const event of ctx.event.subscribe({ signal: stop.signal })) {
|
|
121
|
+
if (event?.type === 'session.execution.succeeded' || event?.type === 'session.execution.failed') turn(cwd);
|
|
122
|
+
}
|
|
123
|
+
} catch { /* unloading, or the stream ended: fail open */ }
|
|
124
|
+
})();
|
|
125
|
+
return () => stop.abort();
|
|
126
|
+
},
|
|
127
|
+
};
|
|
104
128
|
`;
|
|
105
129
|
|
|
106
130
|
export function knowledgeManagedPaths(target, cwd = process.cwd()) {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// rsc-memory:managed
|
|
2
|
+
// One file for both OpenCode plugin APIs (issue #289). V1 (1.x) calls the named export, or the default
|
|
3
|
+
// export's server(); V2 (2.x) reads the default export's id + setup(ctx) and ignores the rest.
|
|
2
4
|
import { readFileSync } from 'node:fs';
|
|
3
5
|
import { join } from 'node:path';
|
|
4
6
|
import { capture, resume } from '../../.rsc/session-memory-core.mjs';
|
|
@@ -15,51 +17,94 @@ function memorySettings(cwd) {
|
|
|
15
17
|
|
|
16
18
|
function sessionId(input) {
|
|
17
19
|
return input?.sessionID || input?.session_id || input?.event?.properties?.info?.id
|
|
18
|
-
|| input?.event?.properties?.sessionID || null;
|
|
20
|
+
|| input?.event?.properties?.sessionID || input?.event?.data?.sessionID || null;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
/** What the plugin does, whichever API delivers the moment. `put(text)` adds to the system prompt. */
|
|
24
|
+
function memory(cwd) {
|
|
23
25
|
const injected = new Set();
|
|
24
26
|
const local = () => process.env.RSC_REMOTE_AGENT !== '1' && process.env.OPENCODE_REMOTE !== '1';
|
|
25
27
|
const save = (id, event, editDelta = 0) => {
|
|
26
28
|
if (!id || !local()) return;
|
|
27
29
|
try { capture({ cwd, sessionId: id, target: 'opencode', event, editDelta, settings: memorySettings(cwd) }); } catch { /* fail open */ }
|
|
28
30
|
};
|
|
29
|
-
|
|
30
31
|
return {
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
save,
|
|
33
|
+
context(id, put) {
|
|
33
34
|
if (!id || injected.has(id) || !local()) return;
|
|
34
35
|
injected.add(id);
|
|
35
36
|
try {
|
|
36
|
-
const config = memorySettings(cwd);
|
|
37
37
|
save(id, 'start');
|
|
38
|
-
const result = resume({ cwd, target: 'opencode', settings:
|
|
39
|
-
if (
|
|
40
|
-
if (output.system.length === 0) output.system.push(result.context);
|
|
41
|
-
else output.system[0] = `${output.system[0]}\n\n${result.context}`;
|
|
38
|
+
const result = resume({ cwd, target: 'opencode', settings: memorySettings(cwd) });
|
|
39
|
+
if (result.context) put(result.context);
|
|
42
40
|
} catch { /* fail open */ }
|
|
43
41
|
},
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
save(sessionId(input), 'boundary', /write|edit|patch/u.test(tool) ? 1 : 0);
|
|
42
|
+
tool(id, name) {
|
|
43
|
+
save(id, 'boundary', /write|edit|patch/u.test(String(name || '').toLowerCase()) ? 1 : 0);
|
|
47
44
|
},
|
|
48
|
-
|
|
49
|
-
const id = sessionId(input);
|
|
45
|
+
compact(id, put) {
|
|
50
46
|
save(id, 'compact');
|
|
51
47
|
try {
|
|
52
48
|
const result = resume({ cwd, target: 'opencode', settings: memorySettings(cwd) });
|
|
53
|
-
if (result.context
|
|
49
|
+
if (result.context) put(result.context);
|
|
54
50
|
} catch { /* fail open */ }
|
|
55
51
|
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------- OpenCode 1.x (V1 plugin API)
|
|
56
|
+
|
|
57
|
+
export const RscMemoryPlugin = async ({ directory, worktree }) => {
|
|
58
|
+
const m = memory(worktree || directory);
|
|
59
|
+
return {
|
|
60
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
61
|
+
if (!Array.isArray(output?.system)) return;
|
|
62
|
+
m.context(sessionId(input), (text) => {
|
|
63
|
+
if (output.system.length === 0) output.system.push(text);
|
|
64
|
+
else output.system[0] = `${output.system[0]}\n\n${text}`;
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
'tool.execute.after': async (input) => m.tool(sessionId(input), input?.tool),
|
|
68
|
+
'experimental.session.compacting': async (input, output) => {
|
|
69
|
+
m.compact(sessionId(input), (text) => { if (Array.isArray(output?.context)) output.context.push(text); });
|
|
70
|
+
},
|
|
56
71
|
event: async ({ event }) => {
|
|
57
72
|
const id = sessionId({ event });
|
|
58
|
-
if (event?.type === 'session.created') save(id, 'start');
|
|
59
|
-
else if (event?.type === 'file.edited') save(id, 'edit', 1);
|
|
60
|
-
else if (event?.type === 'session.compacted') save(id, 'compact');
|
|
61
|
-
else if (event?.type === 'session.idle') save(id, 'turn');
|
|
62
|
-
else if (event?.type === 'session.deleted') save(id, 'sessionEnd');
|
|
73
|
+
if (event?.type === 'session.created') m.save(id, 'start');
|
|
74
|
+
else if (event?.type === 'file.edited') m.save(id, 'edit', 1);
|
|
75
|
+
else if (event?.type === 'session.compacted') m.save(id, 'compact');
|
|
76
|
+
else if (event?.type === 'session.idle') m.save(id, 'turn');
|
|
77
|
+
else if (event?.type === 'session.deleted') m.save(id, 'sessionEnd');
|
|
63
78
|
},
|
|
64
79
|
};
|
|
65
80
|
};
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------- OpenCode 2.x (V2 plugin API)
|
|
83
|
+
// Hooks register through the context; the end of a turn is `session.execution.*` (there is no
|
|
84
|
+
// `session.idle`), and every event carries its session in `data.sessionID`.
|
|
85
|
+
|
|
86
|
+
const TURN_END = new Set(['session.execution.succeeded', 'session.execution.failed']);
|
|
87
|
+
|
|
88
|
+
export default {
|
|
89
|
+
id: 'rsc.memory',
|
|
90
|
+
server: RscMemoryPlugin,
|
|
91
|
+
async setup(ctx) {
|
|
92
|
+
const m = memory(ctx.location?.project?.directory || ctx.location?.directory || process.cwd());
|
|
93
|
+
const part = (event) => (text) => { if (Array.isArray(event?.system)) event.system.push({ type: 'text', text }); };
|
|
94
|
+
await ctx.session.hook('context', async (event) => m.context(event?.sessionID, part(event)));
|
|
95
|
+
await ctx.session.hook('compaction', async (event) => m.compact(event?.sessionID, part(event)));
|
|
96
|
+
await ctx.tool.hook('execute.after', async (event) => m.tool(event?.sessionID, event?.tool));
|
|
97
|
+
const stop = new AbortController();
|
|
98
|
+
(async () => {
|
|
99
|
+
try {
|
|
100
|
+
for await (const event of ctx.event.subscribe({ signal: stop.signal })) {
|
|
101
|
+
const id = event?.data?.sessionID;
|
|
102
|
+
if (event?.type === 'session.created') m.save(id, 'start');
|
|
103
|
+
else if (TURN_END.has(event?.type)) m.save(id, 'turn');
|
|
104
|
+
else if (event?.type === 'session.deleted') m.save(id, 'sessionEnd');
|
|
105
|
+
}
|
|
106
|
+
} catch { /* unloading, or the stream ended: fail open */ }
|
|
107
|
+
})();
|
|
108
|
+
return () => stop.abort();
|
|
109
|
+
},
|
|
110
|
+
};
|
package/targets/update-wiring.js
CHANGED
|
@@ -78,19 +78,34 @@ const PLUGIN = `// ${PLUGIN_MARKER}
|
|
|
78
78
|
// rsc update check for OpenCode: checked once per OpenCode run, and the notice goes to the model to be
|
|
79
79
|
// relayed. It rides on EVERY system prompt of the run, not just the first: OpenCode's first call is
|
|
80
80
|
// its title generator, which would swallow a once-only notice before the agent ever sees it.
|
|
81
|
+
// One file for both plugin APIs (issue #289): V1 calls the named export or server(); V2 reads id + setup.
|
|
81
82
|
// Turn auto-update off with .rsc/.no-auto-update.
|
|
82
83
|
import { updateNotice } from '../../.rsc/auto-update.mjs';
|
|
83
84
|
|
|
85
|
+
const NOTE = '\\nMention this only in your first reply of the session.';
|
|
86
|
+
const check = (cwd) => (process.env.RSC_NO_UPDATE_CHECK ? Promise.resolve('') : updateNotice(cwd).catch(() => ''));
|
|
87
|
+
|
|
84
88
|
export const RscUpdatePlugin = async ({ directory, worktree }) => {
|
|
85
|
-
const
|
|
86
|
-
const pending = process.env.RSC_NO_UPDATE_CHECK ? Promise.resolve('') : updateNotice(cwd).catch(() => '');
|
|
89
|
+
const pending = check(worktree || directory);
|
|
87
90
|
return {
|
|
88
91
|
'experimental.chat.system.transform': async (_input, output) => {
|
|
89
92
|
const said = (await pending).trim();
|
|
90
|
-
if (said && Array.isArray(output?.system)) output.system.push(said +
|
|
93
|
+
if (said && Array.isArray(output?.system)) output.system.push(said + NOTE);
|
|
91
94
|
},
|
|
92
95
|
};
|
|
93
96
|
};
|
|
97
|
+
|
|
98
|
+
export default {
|
|
99
|
+
id: 'rsc.update',
|
|
100
|
+
server: RscUpdatePlugin,
|
|
101
|
+
async setup(ctx) {
|
|
102
|
+
const pending = check(ctx.location?.project?.directory || ctx.location?.directory || process.cwd());
|
|
103
|
+
await ctx.session.hook('context', async (event) => {
|
|
104
|
+
const said = (await pending).trim();
|
|
105
|
+
if (said && Array.isArray(event?.system)) event.system.push({ type: 'text', text: said + NOTE });
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
94
109
|
`;
|
|
95
110
|
|
|
96
111
|
export function updateManagedPaths(target, cwd = process.cwd()) {
|