@ericrisco/rsc 3.0.9 → 3.0.10
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 +57 -5
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/doctor.js +15 -1
- package/scripts/install-apply.js +41 -4
- package/scripts/lib/manifest-file.js +10 -1
- package/scripts/lib/onboarding-apply.js +58 -4
- package/scripts/rsc.js +129 -12
- package/targets/agents.js +144 -18
- package/targets/git-permissions.js +254 -16
package/README.md
CHANGED
|
@@ -98,8 +98,8 @@ rsc 3.0 fixes all four, by default, with no new commands to learn:
|
|
|
98
98
|
|
|
99
99
|
**Commit, push and pull request without a prompt each time.** A harness installed from scratch lets
|
|
100
100
|
the agent run `git commit`, `git push` and `gh pr create` without asking, because those are the steps
|
|
101
|
-
that close every lane. A force-push still asks.
|
|
102
|
-
refused whatever the permission says. It is a project decision, saved as `gitPermissions` in
|
|
101
|
+
that close every lane. A force-push still asks. In Claude Code, rsc's guards still decide: a commit on a
|
|
102
|
+
closed `main` is refused whatever the permission says. No other assistant runs those guards. It is a project decision, saved as `gitPermissions` in
|
|
103
103
|
`.rsc.json`. A clone gets the same setting. A project adopted before this is left as it was until
|
|
104
104
|
someone runs `rsc git-permissions on`.
|
|
105
105
|
|
|
@@ -108,16 +108,32 @@ someone runs `rsc git-permissions on`.
|
|
|
108
108
|
| Claude Code | `.claude/settings.json` → `permissions.allow` (force-push under `ask`). Claude Code applies it once the folder is trusted. |
|
|
109
109
|
| Codex | `.codex/rules/rsc-git.rules` (`prefix_rule`; force-push → `prompt`). Loaded when the project is trusted. |
|
|
110
110
|
| Gemini | `.gemini/settings.json` → `tools.allowed` |
|
|
111
|
-
| OpenCode | `opencode.json`
|
|
111
|
+
| OpenCode | `opencode.json`. With OpenCode 2 installed: the top-level `permissions` list (`shell` rules; force-push → `ask`). rsc's rules go before yours, so any rule of yours that matches later still wins; only a leading `"*"` allow/ask default stays in front. With OpenCode 1.x, or when `opencode --version` cannot be read: `permission.bash`, which both versions read. Moving to 2 moves rsc's entries over. An `opencode.jsonc` is read but never rewritten (its comments would be lost): rsc prints the rules to paste. |
|
|
112
112
|
| Cursor | Not covered: its CLI matches only the first word, so allowing a push would allow every git command. |
|
|
113
113
|
| DeepSeek Harness | Not covered: dsh has no per-command allow list. Its own Permissions selector (`/permission`) sets approval for every command at once. |
|
|
114
114
|
|
|
115
115
|
```bash
|
|
116
|
-
rsc git-permissions status #
|
|
117
|
-
rsc git-permissions off #
|
|
116
|
+
rsc git-permissions status # per assistant: wired or not, and in which format (--json for scripts)
|
|
117
|
+
rsc git-permissions off # remove rsc's rules (saved in .rsc.json)
|
|
118
118
|
rsc git-permissions on # turn it on, also for a project adopted before
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
+
**What `off` turns off.** Only rsc's allow rules. Each command says, per assistant, what rsc controls
|
|
122
|
+
there, what stops when it is off, and what is left to the assistant. `off` does not hand rsc's
|
|
123
|
+
protections to the assistant. Claude Code keeps rsc's guards (branch-guard, danger-guard), because
|
|
124
|
+
they do not depend on this switch. OpenCode, Codex and Gemini run no rsc guard. There, your own
|
|
125
|
+
settings decide. With no rule matching, OpenCode 2 asks and OpenCode 1.x runs the command. To refuse a
|
|
126
|
+
force-push in OpenCode 2, add this at the end of `permissions` (use `"ask"` to get a human approval):
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{ "action": "shell", "resource": "git push --force*", "effect": "deny" },
|
|
130
|
+
{ "action": "shell", "resource": "git push -f*", "effect": "deny" },
|
|
131
|
+
{ "action": "shell", "resource": "git push * --force*", "effect": "deny" },
|
|
132
|
+
{ "action": "shell", "resource": "git push * -f*", "effect": "deny" }
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
In OpenCode 1.x the same patterns go at the end of `permission.bash` (`"git push --force*": "deny"`, …).
|
|
136
|
+
|
|
121
137
|
**How does it know a project is "complex or in production"?** There are two layers. A hook counts
|
|
122
138
|
what anyone can check: a CI setup, a deployment file (`Dockerfile`, `vercel.json`, `fly.toml`…) or
|
|
123
139
|
at least two people in the last 50 commits. If any of those is there, a commit on the default branch
|
|
@@ -403,6 +419,10 @@ rsc memory resume # print this branch/worktree continuation
|
|
|
403
419
|
rsc memory learn --text "…" --evidence "…" --confidence 0.8 --approve
|
|
404
420
|
rsc memory off # disable hooks, commands and injection project-wide
|
|
405
421
|
rsc sync --target claude,codex # refresh managed skills/hooks from the current package version
|
|
422
|
+
rsc agent-model opencode openai/gpt-5 # pin the model the generated agents carry (saved in .rsc.json)
|
|
423
|
+
rsc agent-model opencode inherit # back to the session's model (OpenCode's default)
|
|
424
|
+
rsc agents status # installed agents, and which ones you edited
|
|
425
|
+
rsc agents reset developer # take rsc's version of an agent you edited (yours is backed up first)
|
|
406
426
|
rsc backups # list project-local snapshots
|
|
407
427
|
rsc restore latest --dry-run # preview restoring the newest snapshot
|
|
408
428
|
rsc restore <snapshot-id> # restore a project-local snapshot
|
|
@@ -410,6 +430,29 @@ rsc upgrade --dry-run # show npm upgrade + sync commands
|
|
|
410
430
|
rsc uninstall postgresdb --dry-run # preview a removal
|
|
411
431
|
```
|
|
412
432
|
|
|
433
|
+
### `rsc doctor`: harness health vs onboarding readiness
|
|
434
|
+
|
|
435
|
+
`doctor` answers two different questions, and prints both first, on separate lines:
|
|
436
|
+
|
|
437
|
+
```text
|
|
438
|
+
Harness health: healthy
|
|
439
|
+
Onboarding readiness: pending
|
|
440
|
+
Pending: 02-DOCS/wiki/sdd/constitution.md (draft)
|
|
441
|
+
Next: Complete 02-DOCS/wiki/sdd/constitution.md with the `constitution` phase …
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
- **Harness health** (`healthy` in `--json`) says whether what is installed works: skills, agents,
|
|
445
|
+
commands and hook scripts are on disk. It is the only thing the exit code follows (1 when
|
|
446
|
+
unhealthy), so CI and editor extensions can rely on it.
|
|
447
|
+
- **Onboarding readiness** (`onboarding` in `--json`: `status`, `missing`, `pending`, `action`) says
|
|
448
|
+
whether onboarding is finished. It checks the accepted plan's floor, the same check that prints
|
|
449
|
+
`RSC_ONBOARDING_READY`, plus drafts. The status is `ready`; `pending` when a draft such as the
|
|
450
|
+
onboarding constitution still has to be completed; `incomplete` when part of the floor is missing,
|
|
451
|
+
for example a deleted constitution in an SDD plan, with the exact phase or command that fixes it;
|
|
452
|
+
or `not onboarded` when there is no onboarding record, as in a manual `rsc add` install. Readiness
|
|
453
|
+
never changes `healthy` or the exit code. `Missing:` and `Pending:` appear only when they have
|
|
454
|
+
entries.
|
|
455
|
+
|
|
413
456
|
---
|
|
414
457
|
|
|
415
458
|
|
|
@@ -846,6 +889,15 @@ selective specialists) and 53 command entries (20 fixed + 33 stack aliases).
|
|
|
846
889
|
Unsupported means rsc writes nothing for that surface; it does not emulate a
|
|
847
890
|
provider feature with an unverified file.
|
|
848
891
|
|
|
892
|
+
**Generated agents follow your project, not rsc's defaults.** OpenCode agents carry no `model:`, so
|
|
893
|
+
they use whatever model the session runs (a local one, OpenAI, Anthropic). To pin one for the whole
|
|
894
|
+
team, run `rsc agent-model opencode <model>`: it is saved as `agentModels` in `.rsc.json` and works
|
|
895
|
+
for any target with agents (`inherit` drops the pin). OpenCode agents never grant a tool either: a
|
|
896
|
+
read-only reviewer only gets `edit`, `write`, `patch` and `bash` set to `false`, and an agent that may
|
|
897
|
+
edit or run commands gets no `tools` block, so your project's permission policy decides. If you edit
|
|
898
|
+
a generated agent, `rsc sync` keeps your file and says so. `rsc agents reset <name>` (or `--all`)
|
|
899
|
+
takes rsc's version back after a backup in `.rsc/backups/`.
|
|
900
|
+
|
|
849
901
|
---
|
|
850
902
|
|
|
851
903
|
## Skill format
|
package/manifest.json
CHANGED
package/package.json
CHANGED
package/scripts/doctor.js
CHANGED
|
@@ -17,10 +17,11 @@ import { countGaps, listSkills, listAgents } from './lib/capabilities.js';
|
|
|
17
17
|
import { resolveCommands, commandPath, targetHasCommands } from '../targets/commands.js';
|
|
18
18
|
import { inspectMemoryWiring } from '../targets/memory.js';
|
|
19
19
|
import { metricsSummary } from '../targets/session-memory-core.mjs';
|
|
20
|
-
import { agentPath } from '../targets/agents.js';
|
|
20
|
+
import { agentPath, targetHasAgents, effectiveAgentModel } from '../targets/agents.js';
|
|
21
21
|
import { projectOptOuts } from '../targets/opt-outs.js';
|
|
22
22
|
import { versionReport } from './lib/versions.js';
|
|
23
23
|
import { CONSTITUTION_PATH, constitutionIsDraft } from './lib/constitution-draft.js';
|
|
24
|
+
import { onboardingReadiness } from './lib/onboarding-apply.js';
|
|
24
25
|
|
|
25
26
|
// Gates the committed manifest says the team disarmed, still armed here. Read through the same
|
|
26
27
|
// partition the installer uses, so a machine-only switch somebody's older rsc wrote into the
|
|
@@ -298,6 +299,11 @@ export function doctor({ target, home, cwd }) {
|
|
|
298
299
|
memory,
|
|
299
300
|
missingAgents,
|
|
300
301
|
agentCollisions: state.agentCollisions || [],
|
|
302
|
+
// #298 — generated agents the user edited and sync kept as they are. Not unhealthy: theirs on
|
|
303
|
+
// purpose. Named with the way back, so a stale edit is one command from rsc's version.
|
|
304
|
+
agentsEditedByYou: (state.agentsKept || []).map((k) => ({ ...k, action: `\`npx @ericrisco/rsc agents reset ${k.name}\` takes rsc's version (yours is backed up first).` })),
|
|
305
|
+
// Which model the agents carry: the session's (inherit) or a project pin (`rsc agent-model`).
|
|
306
|
+
agentModel: targetHasAgents(target) ? (effectiveAgentModel(target, root) || 'inherit') : null,
|
|
301
307
|
missingCommands,
|
|
302
308
|
commandOrphans,
|
|
303
309
|
commandCollisions: state.commandCollisions || [],
|
|
@@ -316,6 +322,14 @@ export function doctor({ target, home, cwd }) {
|
|
|
316
322
|
report.pending = constitutionIsDraft(root)
|
|
317
323
|
? [{ id: 'constitution-draft', path: CONSTITUTION_PATH, action: 'Complete it with the `constitution` phase before the first SDD feature; ratifying removes `status: draft`.' }]
|
|
318
324
|
: [];
|
|
325
|
+
// #298 — onboarding readiness is a SEPARATE question from health: is the accepted plan's floor
|
|
326
|
+
// on disk (the same check that prints RSC_ONBOARDING_READY), and is anything still a draft. It is
|
|
327
|
+
// reported, never folded into `healthy`: `healthy` and the exit code keep meaning "the installed
|
|
328
|
+
// harness works", which is what CI and editor extensions built on #277 read. Wrapped because a
|
|
329
|
+
// report must never fail for asking an extra question.
|
|
330
|
+
try { report.onboarding = onboardingReadiness(root); } catch (error) {
|
|
331
|
+
report.onboarding = { status: 'incomplete', missing: ['<onboarding readiness could not be read>'], pending: [], action: String(error?.message || error).slice(0, 200) };
|
|
332
|
+
}
|
|
319
333
|
report.healthy = report.hookWired && !report.missing.length && !missingAgents.length && !missingCommands.length
|
|
320
334
|
&& !report.opencodePluginsOutdated.length;
|
|
321
335
|
return report;
|
package/scripts/install-apply.js
CHANGED
|
@@ -7,7 +7,7 @@ import { planInstall } from './install-plan.js';
|
|
|
7
7
|
import { targetPaths, writeSkill, wireHook, unwireHook, baseDir, TARGET_IDS } from '../targets/index.js';
|
|
8
8
|
import { shadowTarget } from '../targets/agents-md-shadow.js';
|
|
9
9
|
import {
|
|
10
|
-
targetHasAgents, reconcileAgents, agentPath, agentNames,
|
|
10
|
+
targetHasAgents, reconcileAgents, agentPath, agentNames, writeAgentsDetailed,
|
|
11
11
|
resolveAgentNames, agentByName, allAgentNames, readDeveloperTier, writeDeveloperTier,
|
|
12
12
|
} from '../targets/agents.js';
|
|
13
13
|
import { projectOptOuts } from '../targets/opt-outs.js';
|
|
@@ -223,6 +223,7 @@ export function recordInManifest({ cwd, target, skillIds, agentIds = [], catalog
|
|
|
223
223
|
optOuts: optOuts ?? projectOptOuts(prev.optOuts),
|
|
224
224
|
memory: prev.memory,
|
|
225
225
|
gitPermissions: gitPermissions ?? prev.gitPermissions,
|
|
226
|
+
agentModels: prev.agentModels,
|
|
226
227
|
onboarding: onboarding ?? prev.onboarding,
|
|
227
228
|
});
|
|
228
229
|
}
|
|
@@ -306,13 +307,19 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
|
|
|
306
307
|
? explicit
|
|
307
308
|
: resolveAgentNames(Object.keys(state.skills || {}), explicit);
|
|
308
309
|
const previousAgents = state.agents || [];
|
|
310
|
+
let keptAgents = [];
|
|
309
311
|
if (targetHasAgents(target)) {
|
|
310
312
|
const agentResult = reconcileAgents(target, cwd, readDeveloperTier(cwd), previousAgents, desiredAgents);
|
|
311
313
|
state.agents = agentResult.names;
|
|
312
314
|
state.agentCollisions = agentResult.collisions;
|
|
315
|
+
// #298 — agents the user edited and rsc left alone. Recorded so `doctor` can say it on any run,
|
|
316
|
+
// and returned so the command that just ran says it now.
|
|
317
|
+
keptAgents = agentResult.kept;
|
|
318
|
+
state.agentsKept = keptAgents.map(({ name, path }) => ({ name, path }));
|
|
313
319
|
} else {
|
|
314
320
|
state.agents = [];
|
|
315
321
|
state.agentCollisions = [];
|
|
322
|
+
state.agentsKept = [];
|
|
316
323
|
}
|
|
317
324
|
state.explicitAgents = explicit;
|
|
318
325
|
const memoryResult = policy?.memory === false
|
|
@@ -381,7 +388,7 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
|
|
|
381
388
|
if (existsSync(join(cwd, '.git'))) mergeHook = installMergeHook(cwd);
|
|
382
389
|
} catch (err) { mergeHook = { installed: false, state: 'skipped', reason: err.message }; }
|
|
383
390
|
ignoreLocalState(cwd, target);
|
|
384
|
-
return { ...state, backup, mergeHook };
|
|
391
|
+
return { ...state, backup, mergeHook, keptAgents };
|
|
385
392
|
}
|
|
386
393
|
|
|
387
394
|
// `.rsc/` holds local machine state — hook scripts, install markers, the sello's
|
|
@@ -551,9 +558,16 @@ export async function uninstall({ skillIds = [], agentIds = [], target, home, cw
|
|
|
551
558
|
delete state.skills[id];
|
|
552
559
|
}
|
|
553
560
|
const agentResult = reconcileAgents(target, cwd, readDeveloperTier(cwd), state.agents || [], desiredAgents);
|
|
554
|
-
|
|
561
|
+
// An edited agent no longer wanted stays on disk (#298) and stays recorded as rsc-managed, so a
|
|
562
|
+
// later reset or uninstall can still find it.
|
|
563
|
+
state.agents = [...new Set([...agentResult.names, ...agentResult.kept.map((k) => k.name)])];
|
|
555
564
|
state.agentCollisions = agentResult.collisions;
|
|
565
|
+
state.agentsKept = agentResult.kept.map(({ name, path }) => ({ name, path }));
|
|
556
566
|
state.explicitAgents = nextExplicit;
|
|
567
|
+
for (const k of agentResult.kept) {
|
|
568
|
+
const i = removed.indexOf(k.path);
|
|
569
|
+
if (i >= 0) removed.splice(i, 1);
|
|
570
|
+
}
|
|
557
571
|
const desiredCommands = resolveCommands({
|
|
558
572
|
target,
|
|
559
573
|
skills: remainingSkillIds,
|
|
@@ -712,7 +726,30 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
|
|
|
712
726
|
// question sat untracked, one `git add -A` from the team repo (team simulation D13). Sync is what a
|
|
713
727
|
// clone runs, so it writes the same exclude. Lazy import: onboarding-apply imports this module.
|
|
714
728
|
try { (await import('./lib/onboarding-apply.js')).excludePersonalProfile(cwd); } catch { /* not a repo → nothing */ }
|
|
715
|
-
return { synced: declared, syncedAgents: declaredAgents, backup: nextState.backup };
|
|
729
|
+
return { synced: declared, syncedAgents: declaredAgents, backup: nextState.backup, keptAgents: nextState.keptAgents || [] };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* #298 — `rsc agents reset <name|--all>`: take rsc's version of agents the user edited. The user's
|
|
734
|
+
* file is snapshotted first (a backup under .rsc/backups/), and the answer says where, so taking rsc's
|
|
735
|
+
* version is never the loss the protection exists to prevent.
|
|
736
|
+
*/
|
|
737
|
+
export function resetAgents({ target, home, cwd = process.cwd(), names = [], all = false }) {
|
|
738
|
+
if (!targetHasAgents(target)) return { reset: [], backup: null, kept: [] };
|
|
739
|
+
const paths = targetPaths(target, home, cwd);
|
|
740
|
+
const state = readState(paths.stateFile);
|
|
741
|
+
const installed = state.agents || [];
|
|
742
|
+
const chosen = all ? installed : names;
|
|
743
|
+
const unknown = chosen.filter((name) => !installed.includes(name));
|
|
744
|
+
if (unknown.length) throw new Error(`${unknown.join(', ')}: not an agent rsc installed for ${target} (installed: ${installed.join(', ') || 'none'})`);
|
|
745
|
+
if (!chosen.length) return { reset: [], backup: null, kept: [] };
|
|
746
|
+
const files = chosen.map((name) => agentPath(target, cwd, name));
|
|
747
|
+
const backup = createBackup({ cwd, operation: 'agents-reset', target, paths: files.filter((f) => existsSync(f)), cliVersion: CLI_VERSION });
|
|
748
|
+
const { written } = writeAgentsDetailed(target, cwd, readDeveloperTier(cwd), chosen, { force: true });
|
|
749
|
+
state.agentsKept = (state.agentsKept || []).filter((k) => !chosen.includes(k.name));
|
|
750
|
+
writeState(paths.stateFile, state);
|
|
751
|
+
const rel = (p) => relative(cwd, p).split(sep).join('/');
|
|
752
|
+
return { reset: chosen, written, backup: backup ? rel(join(cwd, '.rsc', 'backups', backup.id)) : null };
|
|
716
753
|
}
|
|
717
754
|
|
|
718
755
|
// Remove EVERYTHING rsc put in this project: installed skills across all targets,
|
|
@@ -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', 'gitPermissions', 'onboarding'];
|
|
18
|
+
const KEYS = ['version', 'targets', 'skills', 'agents', 'ownSkills', 'catalogVersion', 'tier', 'optOuts', 'memory', 'gitPermissions', 'agentModels', 'onboarding'];
|
|
19
19
|
|
|
20
20
|
export function readManifest(cwd = process.cwd()) {
|
|
21
21
|
const file = manifestPath(cwd);
|
|
@@ -40,10 +40,19 @@ export function readManifest(cwd = process.cwd()) {
|
|
|
40
40
|
memory: raw.memory ?? undefined,
|
|
41
41
|
// true on a project installed from scratch since 3.0.3; absent on older ones; false once turned off.
|
|
42
42
|
gitPermissions: typeof raw.gitPermissions === 'boolean' ? raw.gitPermissions : undefined,
|
|
43
|
+
// #298 — per-target agent model: { opencode: 'openai/gpt-5' | 'inherit' }. Only plain objects of
|
|
44
|
+
// strings survive the read; what each value may contain is checked where it is rendered.
|
|
45
|
+
agentModels: agentModelsFrom(raw.agentModels),
|
|
43
46
|
onboarding: raw.onboarding ?? undefined,
|
|
44
47
|
};
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
function agentModelsFrom(value) {
|
|
51
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
52
|
+
const entries = Object.entries(value).filter(([, v]) => typeof v === 'string').sort(([a], [b]) => a.localeCompare(b));
|
|
53
|
+
return entries.length ? Object.fromEntries(entries) : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
47
56
|
// Stable output: fixed key order, arrays one entry per line, order as given. Rewriting an
|
|
48
57
|
// unchanged manifest must produce identical bytes, or every install shows up as a diff.
|
|
49
58
|
export function writeManifest(cwd, manifest) {
|
|
@@ -10,7 +10,7 @@ import { readManifest, writeManifest } from './manifest-file.js';
|
|
|
10
10
|
import { encodeGoal, identifyPlan } from './onboarding.js';
|
|
11
11
|
import { createBackup, restoreBackup } from './backups.js';
|
|
12
12
|
import { RETIRED_SKILLS, replaceRetired } from './retired-skills.js';
|
|
13
|
-
import { CONSTITUTION_PATH } from './constitution-draft.js';
|
|
13
|
+
import { CONSTITUTION_PATH, constitutionIsDraft } from './constitution-draft.js';
|
|
14
14
|
import { LAYER_IGNORE, TEMPLATE_SOURCE, targetName, templateAssets } from './tools-skeleton.js';
|
|
15
15
|
|
|
16
16
|
const sameSet = (a = [], b = []) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
|
|
@@ -445,7 +445,10 @@ function floorSatisfied(cwd, path) {
|
|
|
445
445
|
// conflictos de merge, y que un repo clonado puede traer preparado. Así que se valida la forma antes
|
|
446
446
|
// de mirar el disco: un `..` daba el suelo por satisfecho MIRANDO FUERA de la raíz, y un `null` o una
|
|
447
447
|
// cadena en lugar de un array tumbaban `rsc install` después de haber instalado.
|
|
448
|
-
|
|
448
|
+
//
|
|
449
|
+
// Devuelve los CAMINOS que faltan (como se muestran). `missingHarnessFloor` los redacta para el
|
|
450
|
+
// instalador y `onboardingReadiness` los entrega tal cual a `doctor`: una comprobación, dos lectores.
|
|
451
|
+
export function harnessFloorGaps(cwd = process.cwd(), plan = {}) {
|
|
449
452
|
const declared = Array.isArray(plan?.floorPaths) ? plan.floorPaths : [];
|
|
450
453
|
const missing = [];
|
|
451
454
|
for (const path of declared) {
|
|
@@ -453,15 +456,19 @@ export function missingHarnessFloor(cwd = process.cwd(), plan = {}) {
|
|
|
453
456
|
const usable = segments.length > 0 && !segments.includes('..') && !String(path).startsWith('/');
|
|
454
457
|
// Un camino que no se puede usar se reporta como NO satisfecho, nunca como satisfecho, y sin
|
|
455
458
|
// repetir su contenido: es dato ajeno y va a un canal que lee un agente.
|
|
456
|
-
if (!usable) { missing.push('
|
|
459
|
+
if (!usable) { missing.push('<invalid declaration>'); continue; }
|
|
457
460
|
// La barra sólo si el camino declarado la traía: el suelo condicional es un FICHERO, y decir
|
|
458
461
|
// `constitution.md/` invita a buscar un directorio que no existe.
|
|
459
462
|
const shown = String(path).endsWith('/') ? `${floorKey(path)}/` : floorKey(path);
|
|
460
|
-
if (!floorSatisfied(cwd, path)) missing.push(
|
|
463
|
+
if (!floorSatisfied(cwd, path)) missing.push(shown);
|
|
461
464
|
}
|
|
462
465
|
return missing;
|
|
463
466
|
}
|
|
464
467
|
|
|
468
|
+
export function missingHarnessFloor(cwd = process.cwd(), plan = {}) {
|
|
469
|
+
return harnessFloorGaps(cwd, plan).map((shown) => `missing harness floor ${shown}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
465
472
|
/**
|
|
466
473
|
* Lo que el instalador puede afirmar honestamente, y qué hacer si no.
|
|
467
474
|
*
|
|
@@ -479,3 +486,50 @@ export function harnessReadiness(cwd = process.cwd(), plan = {}) {
|
|
|
479
486
|
: '',
|
|
480
487
|
};
|
|
481
488
|
}
|
|
489
|
+
|
|
490
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
491
|
+
// Preparación del onboarding, como pregunta PROPIA (#298 punto 5).
|
|
492
|
+
//
|
|
493
|
+
// `doctor` respondía sólo «¿el arnés instalado funciona?» (`healthy`, y el exit code con él) y un
|
|
494
|
+
// usuario leía ese `true` como «el onboarding está terminado» con la constitución sin escribir. Son
|
|
495
|
+
// dos preguntas: ésta se calcula con el MISMO suelo que decide RSC_ONBOARDING_READY/INCOMPLETE
|
|
496
|
+
// (`harnessFloorGaps`) más los borradores, y nunca toca `healthy`.
|
|
497
|
+
//
|
|
498
|
+
// ready suelo completo, nada en borrador
|
|
499
|
+
// pending suelo completo, pero algo es un borrador que el usuario debe completar
|
|
500
|
+
// incomplete falta parte del suelo del plan aceptado (o el recibo no cuadra con su id)
|
|
501
|
+
// not-onboarded no hay recibo: instalación manual (`rsc add`) o anterior al onboarding
|
|
502
|
+
//
|
|
503
|
+
// Un recibo de una versión que no declaraba suelo queda exento por construcción (P3), igual que en
|
|
504
|
+
// el instalador: aquí no se inventa un suelo que el plan aceptado no traía.
|
|
505
|
+
const ONBOARD_ACTION = 'Run `npx @ericrisco/rsc@latest onboard` to record a plan for this harness (optional: what is installed keeps working).';
|
|
506
|
+
const draftAction = (path) => `Complete ${path} with the \`constitution\` phase (tell your agent "constitution") before the first SDD feature; ratifying removes \`status: draft\`.`;
|
|
507
|
+
|
|
508
|
+
export function onboardingReadiness(cwd = process.cwd(), manifest = readManifest(cwd)) {
|
|
509
|
+
const pending = constitutionIsDraft(cwd) ? [CONSTITUTION_PATH] : [];
|
|
510
|
+
const receipt = manifest?.onboarding;
|
|
511
|
+
if (!receipt?.plan) {
|
|
512
|
+
return { status: 'not-onboarded', missing: [], pending, action: ONBOARD_ACTION };
|
|
513
|
+
}
|
|
514
|
+
let identical = false;
|
|
515
|
+
try { identical = identifyPlan(receipt.plan) === receipt.acceptedPlanId; } catch { /* ilegible: no cuadra */ }
|
|
516
|
+
if (!identical) {
|
|
517
|
+
return {
|
|
518
|
+
status: 'incomplete', missing: ['<onboarding receipt does not match its accepted plan id>'], pending,
|
|
519
|
+
action: 'Do not trust the receipt. Re-run `npx @ericrisco/rsc@latest onboard` and accept a fresh plan.',
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
const missing = harnessFloorGaps(cwd, receipt.plan);
|
|
523
|
+
if (missing.length) {
|
|
524
|
+
const actions = [];
|
|
525
|
+
if (missing.includes(CONSTITUTION_PATH)) {
|
|
526
|
+
actions.push(`Write ${CONSTITUTION_PATH} with the \`constitution\` phase (tell your agent "constitution"); the SDD plan you accepted requires it.`);
|
|
527
|
+
}
|
|
528
|
+
if (missing.some((path) => path !== CONSTITUTION_PATH)) {
|
|
529
|
+
actions.push('Invoke the `harness` skill to scaffold 01-TOOLS/ + 02-DOCS/.');
|
|
530
|
+
}
|
|
531
|
+
return { status: 'incomplete', missing, pending, action: actions.join(' ') };
|
|
532
|
+
}
|
|
533
|
+
if (pending.length) return { status: 'pending', missing, pending, action: draftAction(CONSTITUTION_PATH) };
|
|
534
|
+
return { status: 'ready', missing, pending, action: '' };
|
|
535
|
+
}
|
package/scripts/rsc.js
CHANGED
|
@@ -4,9 +4,12 @@ import { detectTarget, installedTargets, resolveTargets, TARGETS } from '../targ
|
|
|
4
4
|
import { detectRepo } from './detect-repo.js';
|
|
5
5
|
import { rank } from './consult.js';
|
|
6
6
|
import { expandRecommends, toOutcomes, hasOutcome } from './lib/recommend.js';
|
|
7
|
-
import { applyInstall, listInstalled, listInstalledAgents, listInstalledCommands, uninstall, syncInstalled, purgeReport, collisions, ownSkillPaths } from './install-apply.js';
|
|
7
|
+
import { applyInstall, listInstalled, listInstalledAgents, listInstalledCommands, uninstall, syncInstalled, purgeReport, collisions, ownSkillPaths, resetAgents } from './install-apply.js';
|
|
8
8
|
import { isAbsolute, relative, sep } from 'node:path';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
stackAgentNames, targetHasAgents, isValidAgentModel, agentModelSetting, targetInheritsByDefault,
|
|
11
|
+
effectiveAgentModel, writeAgentsDetailed, readDeveloperTier, AGENT_MODEL_INHERIT, AGENT_TARGET_IDS,
|
|
12
|
+
} from '../targets/agents.js';
|
|
10
13
|
import { doctor } from './doctor.js';
|
|
11
14
|
import { ask, say, select, pickFrom, banner, confirm, isInteractive } from './lib/ui.js';
|
|
12
15
|
import { refreshRegistry, registryStatus } from './lib/registry.js';
|
|
@@ -43,7 +46,7 @@ if (['--version', '-v', 'version'].includes(rawArgv[0])) {
|
|
|
43
46
|
process.exit(0);
|
|
44
47
|
}
|
|
45
48
|
const GENERAL_HELP = [
|
|
46
|
-
'Use: npx @ericrisco/rsc onboard | reassess | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | memory <on|off|status|save|resume|learn|metrics> | sello <on|off|status|…> | worktrees [reap [path] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id...> | purge | help',
|
|
49
|
+
'Use: npx @ericrisco/rsc onboard | reassess | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | agent-model <target> <model|inherit|status> | agents [status|reset <name|--all>] | memory <on|off|status|save|resume|learn|metrics> | sello <on|off|status|…> | worktrees [reap [path] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id...> | purge | help',
|
|
47
50
|
'Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)',
|
|
48
51
|
' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.',
|
|
49
52
|
].join('\n');
|
|
@@ -74,7 +77,7 @@ const PURGE_HELP = [
|
|
|
74
77
|
const GLOBAL_VALUE_FLAGS = new Set([
|
|
75
78
|
'--target', '--technical-level', '--accompaniment', '--project-kind', '--goal', '--goal-base64', '--software-scope', '--workflow', '--accept-plan',
|
|
76
79
|
]);
|
|
77
|
-
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']);
|
|
80
|
+
const COMMANDS = new Set(['onboard', 'reassess', 'add', 'install', 'consult', 'catalog', 'audit', 'list', 'doctor', 'memory', 'knowledge-sync', 'git-permissions', 'agent-model', 'agents', 'main', 'isolation', 'sync', 'backups', 'restore', 'upgrade', 'registry', 'worktrees', 'capabilities', 'sello', 'repair', 'uninstall', 'purge']);
|
|
78
81
|
function positionalTokens(input) {
|
|
79
82
|
const out = [];
|
|
80
83
|
for (let i = 0; i < input.length; i++) {
|
|
@@ -416,7 +419,7 @@ function reportUnknown(ids) {
|
|
|
416
419
|
// flag says which: `purge` sweeps every target by design, and the catalog/consult
|
|
417
420
|
// side barely touches one, so blocking them would be a regression, not a safeguard.
|
|
418
421
|
const NEEDS_TARGET = new Set([
|
|
419
|
-
'onboard', 'add', 'install', 'list', 'doctor', 'sync', 'uninstall', 'capabilities', 'catalog', 'registry',
|
|
422
|
+
'onboard', 'add', 'install', 'list', 'doctor', 'sync', 'uninstall', 'capabilities', 'catalog', 'registry', 'agents',
|
|
420
423
|
]);
|
|
421
424
|
|
|
422
425
|
function flag(name) {
|
|
@@ -573,6 +576,11 @@ function printAgentHandoff() {
|
|
|
573
576
|
}
|
|
574
577
|
if (readiness.ready) {
|
|
575
578
|
say(' 3. Tell the user rsc is ready; they can start in their own words.');
|
|
579
|
+
// #298 — ready is not «nothing pending»: the onboarding draft is named here too, not only in
|
|
580
|
+
// RSC_ONBOARDING_READY, so an agent arriving through install/sync tells the user before an SDD feature.
|
|
581
|
+
let draft = false;
|
|
582
|
+
try { draft = constitutionIsDraft(process.cwd()); } catch { /* unreadable is not a draft */ }
|
|
583
|
+
if (draft) say(` Tell them too: ${CONSTITUTION_PATH} is a draft — complete it with the \`constitution\` phase before the first SDD feature.`);
|
|
576
584
|
say(' Do NOT auto-start a task — wait for the user.');
|
|
577
585
|
} else {
|
|
578
586
|
// El otro emisor. Decía «ready» desde el wizard y desde `install` sin mirar el suelo, así que
|
|
@@ -585,6 +593,19 @@ function printAgentHandoff() {
|
|
|
585
593
|
say('════════════════════════════════════════════════');
|
|
586
594
|
}
|
|
587
595
|
|
|
596
|
+
// #298 — the two answers a person looks for first, before any figure or JSON: does the installed
|
|
597
|
+
// harness work (`healthy`, the one the exit code follows) and is onboarding finished (the accepted
|
|
598
|
+
// plan's floor plus drafts). Kept apart on purpose — readiness never changes health nor the exit code.
|
|
599
|
+
const READINESS_LABEL = { ready: 'ready', pending: 'pending', incomplete: 'incomplete', 'not-onboarded': 'not onboarded' };
|
|
600
|
+
function printDoctorHeadline(report) {
|
|
601
|
+
const o = report.onboarding || {};
|
|
602
|
+
say(`Harness health: ${report.healthy ? 'healthy' : 'unhealthy'}`);
|
|
603
|
+
say(`Onboarding readiness: ${READINESS_LABEL[o.status] || 'unknown'}`);
|
|
604
|
+
if (o.missing?.length) say(`Missing: ${safeLines(o.missing).join(', ')}`);
|
|
605
|
+
if (o.pending?.length) say(`Pending: ${safeLines(o.pending).map((p) => (p === CONSTITUTION_PATH ? `${p} (draft)` : p)).join(', ')}`);
|
|
606
|
+
if (o.action) say(`Next: ${o.action}`);
|
|
607
|
+
}
|
|
608
|
+
|
|
588
609
|
// What the harness costs in context before the user types anything. Printed ahead of the raw
|
|
589
610
|
// health JSON because it is the part a human acts on: the figures say whether the harness is
|
|
590
611
|
// paying its way, and the findings say what to do about it.
|
|
@@ -624,6 +645,7 @@ async function syncDeclared(targets, dry = false) {
|
|
|
624
645
|
const result = await syncInstalled({ target: t, dryRun: dry });
|
|
625
646
|
const verb = dry ? 'Would sync' : 'Synced';
|
|
626
647
|
say(`${verb} ${t}: ${result.synced.length ? result.synced.join(', ') : '(nothing to sync)'}`);
|
|
648
|
+
for (const k of result.keptAgents || []) say(` ${k.message}`);
|
|
627
649
|
if (dry && result.paths?.length) {
|
|
628
650
|
for (const p of result.paths) say(` ${p}`);
|
|
629
651
|
}
|
|
@@ -870,6 +892,7 @@ async function main() {
|
|
|
870
892
|
// #277 — an automation on top of rsc reads the exit code, not the prose.
|
|
871
893
|
if (!report.healthy) process.exitCode = 1;
|
|
872
894
|
if (argv.includes('--json')) return void say(JSON.stringify(report, null, 2));
|
|
895
|
+
printDoctorHeadline(report);
|
|
873
896
|
printContextBudget(report.contextBudget);
|
|
874
897
|
return void say(JSON.stringify({ ...report, contextBudget: undefined }, null, 2));
|
|
875
898
|
}
|
|
@@ -952,26 +975,28 @@ async function main() {
|
|
|
952
975
|
const sub = argv[1] || 'status';
|
|
953
976
|
const root = process.cwd();
|
|
954
977
|
const { readManifest, writeManifest } = await import('./lib/manifest-file.js');
|
|
955
|
-
const {
|
|
978
|
+
const { gitPermissionsState, explainGitPermissions, GIT_PERMISSION_TARGETS } = await import('../targets/git-permissions.js');
|
|
956
979
|
if (sub === 'on' || sub === 'off') {
|
|
957
980
|
const current = readManifest(root);
|
|
958
981
|
if (!current) { say('No .rsc.json here: install the harness first (`npx @ericrisco/rsc onboard`).'); process.exitCode = 2; return; }
|
|
959
982
|
writeManifest(root, { ...current, gitPermissions: sub === 'on' });
|
|
960
983
|
for (const t of targets) await syncInstalled({ target: t, cwd: root });
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
: '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.');
|
|
964
|
-
return;
|
|
984
|
+
// What rsc controls, what stops when off, and what is the assistant's own job (#298).
|
|
985
|
+
return void say(explainGitPermissions({ targets, cwd: root, mode: sub }));
|
|
965
986
|
}
|
|
966
987
|
if (sub === 'status') {
|
|
967
988
|
const declared = readManifest(root)?.gitPermissions;
|
|
989
|
+
if (!flag('json')) return void say(explainGitPermissions({ targets, cwd: root, mode: 'status', declared }));
|
|
990
|
+
const covered = targets.filter((t) => GIT_PERMISSION_TARGETS.includes(t));
|
|
991
|
+
const states = Object.fromEntries(covered.map((t) => [t, gitPermissionsState(t, root)]));
|
|
968
992
|
return void say(JSON.stringify({
|
|
969
993
|
declared: declared === undefined ? 'undecided' : declared,
|
|
970
|
-
wired: Object.fromEntries(
|
|
994
|
+
wired: Object.fromEntries(covered.map((t) => [t, states[t].wired])),
|
|
995
|
+
format: Object.fromEntries(covered.map((t) => [t, states[t].format])),
|
|
971
996
|
notCovered: targets.filter((t) => !GIT_PERMISSION_TARGETS.includes(t)),
|
|
972
997
|
}, null, 2));
|
|
973
998
|
}
|
|
974
|
-
say('Use: npx @ericrisco/rsc git-permissions on|off|status');
|
|
999
|
+
say('Use: npx @ericrisco/rsc git-permissions on|off|status [--json]');
|
|
975
1000
|
process.exitCode = 2;
|
|
976
1001
|
return;
|
|
977
1002
|
}
|
|
@@ -1023,6 +1048,98 @@ async function main() {
|
|
|
1023
1048
|
process.exitCode = 1;
|
|
1024
1049
|
return;
|
|
1025
1050
|
}
|
|
1051
|
+
case 'agent-model': {
|
|
1052
|
+
// #298 — which model the generated agents carry: the session's (`inherit`, OpenCode's default)
|
|
1053
|
+
// or one the project pins. A PROJECT decision, saved as `agentModels` in .rsc.json, so a clone
|
|
1054
|
+
// renders the same agents; the files of that target are re-rendered at once.
|
|
1055
|
+
const root = process.cwd();
|
|
1056
|
+
const t = argv[1];
|
|
1057
|
+
const value = argv[2] || 'status';
|
|
1058
|
+
const describe = (id) => {
|
|
1059
|
+
const setting = agentModelSetting(root, id);
|
|
1060
|
+
const effective = effectiveAgentModel(id, root);
|
|
1061
|
+
return {
|
|
1062
|
+
target: id,
|
|
1063
|
+
declared: setting || (targetInheritsByDefault(id) ? 'default (inherit)' : 'default (tier model)'),
|
|
1064
|
+
effective: effective || 'inherit — the agent uses the session model',
|
|
1065
|
+
};
|
|
1066
|
+
};
|
|
1067
|
+
if (!t || t === 'status') {
|
|
1068
|
+
return void say(JSON.stringify(targets.filter(targetHasAgents).map(describe), null, 2));
|
|
1069
|
+
}
|
|
1070
|
+
if (!targetHasAgents(t)) {
|
|
1071
|
+
console.error(`rsc: ${t} has no file-based agents. Targets with agents: ${AGENT_TARGET_IDS.join(', ')}`);
|
|
1072
|
+
process.exitCode = 2;
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
if (value === 'status') return void say(JSON.stringify(describe(t), null, 2));
|
|
1076
|
+
if (!isValidAgentModel(value)) {
|
|
1077
|
+
console.error(`rsc: '${value}' is not a model id (letters, digits and . _ : / @ + - only), nor 'inherit'.\nUse: npx @ericrisco/rsc agent-model <target> <model|inherit|status>`);
|
|
1078
|
+
process.exitCode = 2;
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
const current = readManifest(root);
|
|
1082
|
+
if (!current) { say('No .rsc.json here: install the harness first (`npx @ericrisco/rsc onboard`).'); process.exitCode = 2; return; }
|
|
1083
|
+
const next = { ...(current.agentModels || {}) };
|
|
1084
|
+
// `inherit` on a target whose default is inherit stores nothing: the manifest stays as small as the decision.
|
|
1085
|
+
if (value === AGENT_MODEL_INHERIT && targetInheritsByDefault(t)) delete next[t];
|
|
1086
|
+
else next[t] = value;
|
|
1087
|
+
writeManifest(root, { ...current, agentModels: Object.keys(next).length ? next : undefined });
|
|
1088
|
+
const { targetPaths: pathsOf } = await import('../targets/index.js');
|
|
1089
|
+
const { readState } = await import('./lib/state.js');
|
|
1090
|
+
const installedAgents = readState(pathsOf(t, undefined, root).stateFile).agents || [];
|
|
1091
|
+
const { written, kept } = writeAgentsDetailed(t, root, readDeveloperTier(root), installedAgents);
|
|
1092
|
+
say(value === AGENT_MODEL_INHERIT
|
|
1093
|
+
? `rsc agent-model ${t} inherit: the agents carry no model and use the session's.`
|
|
1094
|
+
: `rsc agent-model ${t} ${value}: the agents are pinned to ${value}.`);
|
|
1095
|
+
say(` Re-rendered ${written.length} agent file(s). Commit .rsc.json so the team gets the same decision.`);
|
|
1096
|
+
for (const k of kept) say(` ${k.message}`);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
case 'agents': {
|
|
1100
|
+
const sub = argv[1] || 'status';
|
|
1101
|
+
const root = process.cwd();
|
|
1102
|
+
if (sub === 'reset') {
|
|
1103
|
+
const all = argv.includes('--all');
|
|
1104
|
+
const names = requestedIds(2);
|
|
1105
|
+
if (!all && !names.length) {
|
|
1106
|
+
console.error('Use: npx @ericrisco/rsc agents reset <name...|--all> [--target <assistant>]');
|
|
1107
|
+
process.exitCode = 2;
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
const { targetPaths: pathsOf } = await import('../targets/index.js');
|
|
1111
|
+
const { readState } = await import('./lib/state.js');
|
|
1112
|
+
const withAgents = targets.filter(targetHasAgents);
|
|
1113
|
+
let done = 0;
|
|
1114
|
+
for (const t of withAgents) {
|
|
1115
|
+
const installed = readState(pathsOf(t, undefined, root).stateFile).agents || [];
|
|
1116
|
+
const mine = all ? [] : names.filter((n) => installed.includes(n));
|
|
1117
|
+
if (!all && !mine.length) continue;
|
|
1118
|
+
const out = resetAgents({ target: t, cwd: root, names: mine, all });
|
|
1119
|
+
done += out.reset.length;
|
|
1120
|
+
if (out.reset.length) {
|
|
1121
|
+
say(`Reset ${t}: ${out.reset.join(', ')} — rsc's version is back.`);
|
|
1122
|
+
if (out.backup) say(` Your previous files are kept in ${out.backup}/files/ (\`rsc restore\` lists the snapshot).`);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
if (!all && !done) {
|
|
1126
|
+
console.error(`rsc: ${names.join(', ')}: not an agent rsc installed here (${withAgents.join(', ') || 'no target with agents'}).`);
|
|
1127
|
+
process.exitCode = 1;
|
|
1128
|
+
}
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
if (sub === 'status') {
|
|
1132
|
+
const { targetPaths: pathsOf } = await import('../targets/index.js');
|
|
1133
|
+
const { readState } = await import('./lib/state.js');
|
|
1134
|
+
return void say(JSON.stringify(targets.filter(targetHasAgents).map((t) => {
|
|
1135
|
+
const state = readState(pathsOf(t, undefined, root).stateFile);
|
|
1136
|
+
return { target: t, agents: state.agents || [], editedByYou: (state.agentsKept || []).map((k) => k.name) };
|
|
1137
|
+
}), null, 2));
|
|
1138
|
+
}
|
|
1139
|
+
say('Use: npx @ericrisco/rsc agents status | agents reset <name...|--all>');
|
|
1140
|
+
process.exitCode = 2;
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1026
1143
|
case 'sync':
|
|
1027
1144
|
return syncDeclared(targets, argv.includes('--dry-run'));
|
|
1028
1145
|
case 'backups': {
|
package/targets/agents.js
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
// elsewhere. The chosen tier (balanced default, or heavy) lives in `.rsc/developer.json`,
|
|
10
10
|
// written by `init` at onboarding and read here so re-syncs honor it.
|
|
11
11
|
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
|
|
12
|
-
import { join, dirname } from 'node:path';
|
|
12
|
+
import { join, dirname, relative, sep } from 'node:path';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { readManifest } from '../scripts/lib/manifest-file.js';
|
|
13
15
|
import {
|
|
14
16
|
stackAgents, stackAgentNames, stackAgentByName,
|
|
15
17
|
resolveStackAgentNames, validateAgentCatalog,
|
|
@@ -32,7 +34,12 @@ const AGENT_TARGETS = {
|
|
|
32
34
|
claude: { dir: '.claude/agents', ext: '.md', format: 'md', model: (t) => (t === 'heavy' ? 'opus' : 'sonnet') },
|
|
33
35
|
junie: { dir: '.junie/agents', ext: '.md', format: 'md', model: (t) => (t === 'heavy' ? 'opus' : 'sonnet') },
|
|
34
36
|
cursor: { dir: '.cursor/agents', ext: '.md', format: 'md', model: (t) => TIER_MODEL.anthropic[t] },
|
|
35
|
-
|
|
37
|
+
// #298 — OpenCode is the one tool people point at a local model or another provider per session, so
|
|
38
|
+
// its agents INHERIT the session model by default (`model: null` → no `model:` line) and only carry
|
|
39
|
+
// one when the project pins it (`agentModels.opencode` in .rsc.json). And its `tools` map is
|
|
40
|
+
// restrict-only: rsc never grants a tool there, it can only take away what a role never declared,
|
|
41
|
+
// so a stricter project permission policy is never loosened by an rsc agent.
|
|
42
|
+
opencode: { dir: '.opencode/agents', ext: '.md', format: 'md', mode: 'subagent', toolsFormat: 'restrict', model: () => null, legacyModel: (t) => `anthropic/${TIER_MODEL.anthropic[t]}` },
|
|
36
43
|
gemini: { dir: '.gemini/agents', ext: '.md', format: 'md', model: (t) => TIER_MODEL.google[t] },
|
|
37
44
|
copilot: { dir: '.github/agents', ext: '.agent.md', format: 'md', model: (t) => TIER_MODEL.anthropic[t] },
|
|
38
45
|
kiro: { dir: '.kiro/agents', ext: '.json', format: 'json', model: (t) => (t === 'heavy' ? 'claude-opus-4' : 'claude-sonnet-4') },
|
|
@@ -157,11 +164,26 @@ export function writeDeveloperTier(cwd, tier) {
|
|
|
157
164
|
return t;
|
|
158
165
|
}
|
|
159
166
|
|
|
160
|
-
|
|
161
|
-
|
|
167
|
+
// OpenCode V1 tool names a declared capability maps to. `read`/`search` need nothing: what the agent
|
|
168
|
+
// may read is the project's policy to decide, and saying `read: true` would be a grant.
|
|
169
|
+
const RESTRICT_TOOLS = { edit: ['edit', 'write', 'patch'], shell: ['bash'] };
|
|
170
|
+
function restrictedTools(tools) {
|
|
171
|
+
if (!tools) return [];
|
|
172
|
+
return Object.entries(RESTRICT_TOOLS).filter(([cap]) => !tools.includes(cap)).flatMap(([, names]) => names);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function renderMd(spec, model, agent, toolsFormat = spec.toolsFormat) {
|
|
176
|
+
const fm = ['---', `name: ${agent.name}`, `description: "${agent.desc}"`];
|
|
177
|
+
if (model) fm.push(`model: ${model}`);
|
|
162
178
|
if (spec.mode) fm.push(`mode: ${spec.mode}`);
|
|
163
179
|
if (agent.tools) {
|
|
164
|
-
if (
|
|
180
|
+
if (toolsFormat === 'restrict') {
|
|
181
|
+
const denied = restrictedTools(agent.tools);
|
|
182
|
+
if (denied.length) {
|
|
183
|
+
fm.push('tools:');
|
|
184
|
+
for (const tool of denied) fm.push(` ${tool}: false`);
|
|
185
|
+
}
|
|
186
|
+
} else if (toolsFormat === 'map') {
|
|
165
187
|
fm.push('tools:');
|
|
166
188
|
for (const tool of agent.tools) fm.push(` ${tool}: true`);
|
|
167
189
|
} else {
|
|
@@ -171,44 +193,146 @@ function renderMd(spec, model, agent) {
|
|
|
171
193
|
fm.push('---', '');
|
|
172
194
|
return `${fm.join('\n')}${agent.body}\n`;
|
|
173
195
|
}
|
|
174
|
-
const renderJson = (model, agent) => `${JSON.stringify({ name: agent.name, description: agent.desc, model, ...(agent.tools ? { tools: agent.tools } : {}), prompt: agent.body }, null, 2)}\n`;
|
|
196
|
+
const renderJson = (model, agent) => `${JSON.stringify({ name: agent.name, description: agent.desc, ...(model ? { model } : {}), ...(agent.tools ? { tools: agent.tools } : {}), prompt: agent.body }, null, 2)}\n`;
|
|
175
197
|
function renderToml(model, agent) {
|
|
176
198
|
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
177
199
|
// body as a TOML multiline LITERAL string ('''…''') — no escape processing.
|
|
178
200
|
const tools = agent.tools ? `tools = [${agent.tools.map((tool) => `"${tool}"`).join(', ')}]\n` : '';
|
|
179
|
-
return `name = "${agent.name}"\ndescription = "${esc(agent.desc)}"\
|
|
201
|
+
return `name = "${agent.name}"\ndescription = "${esc(agent.desc)}"\n${model ? `model = "${model}"\n` : ''}${tools}developer_instructions = '''\n${agent.body}\n'''\n`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── #298: the model, decided by the project. `agentModels` in .rsc.json maps a target to a model id
|
|
205
|
+
// or to `inherit`. Absent → the target's default (inherit on OpenCode, the tier model elsewhere). The
|
|
206
|
+
// value lands verbatim in YAML/TOML/JSON from a COMMITTED file, so anything outside a plain model-id
|
|
207
|
+
// alphabet is ignored rather than written: a pulled .rsc.json must not be able to inject frontmatter.
|
|
208
|
+
export const AGENT_MODEL_INHERIT = 'inherit';
|
|
209
|
+
export const isValidAgentModel = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,127}$/.test(value);
|
|
210
|
+
export function agentModelSetting(cwd, target) {
|
|
211
|
+
let value;
|
|
212
|
+
try { value = readManifest(cwd)?.agentModels?.[target]; } catch { value = undefined; }
|
|
213
|
+
return isValidAgentModel(value) ? value : null;
|
|
214
|
+
}
|
|
215
|
+
export const targetInheritsByDefault = (target) => AGENT_TARGETS[target]?.model('balanced') === null;
|
|
216
|
+
function resolveModel(target, cwd, tier) {
|
|
217
|
+
const setting = agentModelSetting(cwd, target);
|
|
218
|
+
if (setting === AGENT_MODEL_INHERIT) return null;
|
|
219
|
+
if (setting) return setting;
|
|
220
|
+
return AGENT_TARGETS[target].model(tier);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export const effectiveAgentModel = (target, cwd, tier = readDeveloperTier(cwd)) => (AGENT_TARGETS[target] ? resolveModel(target, cwd, tier) : null);
|
|
224
|
+
|
|
225
|
+
function renderAgent(target, spec, model, agent, toolsFormat) {
|
|
226
|
+
return spec.format === 'json' ? renderJson(model, agent)
|
|
227
|
+
: spec.format === 'toml' ? renderToml(model, agent)
|
|
228
|
+
: renderMd(spec, model, agent, toolsFormat);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── #298: what rsc wrote, per file, so a user edit is told apart from rsc's own file. Machine-local
|
|
232
|
+
// (.rsc/ is gitignored): a digest says "this machine's rsc wrote these bytes", which is not a team fact.
|
|
233
|
+
const sha = (text) => createHash('sha256').update(text).digest('hex');
|
|
234
|
+
const digestFile = (cwd) => join(cwd, '.rsc', 'agent-digests.json');
|
|
235
|
+
const relKey = (cwd, path) => relative(cwd, path).split(sep).join('/');
|
|
236
|
+
function readDigests(cwd) {
|
|
237
|
+
try { const d = JSON.parse(readFileSync(digestFile(cwd), 'utf8')); return d && typeof d === 'object' ? d : {}; } catch { return {}; }
|
|
238
|
+
}
|
|
239
|
+
function writeDigests(cwd, digests) {
|
|
240
|
+
mkdirSync(dirname(digestFile(cwd)), { recursive: true });
|
|
241
|
+
const ordered = Object.fromEntries(Object.keys(digests).sort().map((k) => [k, digests[k]]));
|
|
242
|
+
writeFileSync(digestFile(cwd), `${JSON.stringify(ordered, null, 2)}\n`);
|
|
180
243
|
}
|
|
181
244
|
|
|
245
|
+
// Every rendering rsc could have produced for this agent on this target: either tier, the current
|
|
246
|
+
// model setting, and — for OpenCode — the pre-#298 shape (pinned Anthropic model, `tool: true` map).
|
|
247
|
+
// A file with no digest that matches one of these is rsc's and may be replaced; anything else is the
|
|
248
|
+
// user's. Bodies from older releases are not reconstructed: such a file is kept and reported, the safe
|
|
249
|
+
// direction, and `rsc agents reset` is one command away.
|
|
250
|
+
function knownRenderings(target, cwd, agent) {
|
|
251
|
+
const spec = AGENT_TARGETS[target];
|
|
252
|
+
const out = new Set();
|
|
253
|
+
for (const tier of ['balanced', 'heavy']) {
|
|
254
|
+
out.add(renderAgent(target, spec, resolveModel(target, cwd, tier), agent));
|
|
255
|
+
out.add(renderAgent(target, spec, spec.model(tier), agent));
|
|
256
|
+
if (spec.legacyModel) out.add(renderAgent(target, spec, spec.legacyModel(tier), agent, 'map'));
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 'rsc' when the file on disk is rsc's to overwrite or remove, 'user' when it carries the user's edit.
|
|
262
|
+
function ownership(target, cwd, agent, path, digests) {
|
|
263
|
+
if (!existsSync(path)) return 'absent';
|
|
264
|
+
const current = readFileSync(path, 'utf8');
|
|
265
|
+
const key = relKey(cwd, path);
|
|
266
|
+
if (digests[key]) return digests[key] === sha(current) ? 'rsc' : 'user';
|
|
267
|
+
return knownRenderings(target, cwd, agent).has(current) ? 'rsc' : 'user';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const keptNotice = (name, path, cwd) => ({
|
|
271
|
+
name,
|
|
272
|
+
path,
|
|
273
|
+
message: `kept your edited agent ${name} (${relKey(cwd, path)}; rsc's version differs; \`rsc agents reset ${name}\` to take it)`,
|
|
274
|
+
});
|
|
275
|
+
|
|
182
276
|
export function agentPath(target, cwd, name = 'developer') {
|
|
183
277
|
const spec = AGENT_TARGETS[target];
|
|
184
278
|
return spec ? join(cwd, ...spec.dir.split('/'), `${name}${spec.ext}`) : null;
|
|
185
279
|
}
|
|
186
280
|
|
|
187
|
-
|
|
281
|
+
/**
|
|
282
|
+
* Write the agents, keeping any the user edited. Returns { written, kept }: `kept` names each agent
|
|
283
|
+
* left as the user has it, with the message to show. `force` takes rsc's version regardless — only
|
|
284
|
+
* `rsc agents reset` passes it, after backing the user's file up.
|
|
285
|
+
*/
|
|
286
|
+
export function writeAgentsDetailed(target, cwd, tier = readDeveloperTier(cwd), names = agentNames(), { force = false } = {}) {
|
|
188
287
|
const spec = AGENT_TARGETS[target];
|
|
189
|
-
if (!spec) return [];
|
|
288
|
+
if (!spec) return { written: [], kept: [] };
|
|
289
|
+
const digests = readDigests(cwd);
|
|
190
290
|
const written = [];
|
|
291
|
+
const kept = [];
|
|
191
292
|
for (const name of names) {
|
|
192
293
|
const agent = byName(name);
|
|
193
294
|
if (!agent) continue;
|
|
194
295
|
const effectiveTier = agent.tier === 'heavy' ? 'heavy' : tier;
|
|
195
|
-
const
|
|
196
|
-
const content = spec.format === 'json' ? renderJson(model, agent)
|
|
197
|
-
: spec.format === 'toml' ? renderToml(model, agent)
|
|
198
|
-
: renderMd(spec, model, agent);
|
|
296
|
+
const content = renderAgent(target, spec, resolveModel(target, cwd, effectiveTier), agent);
|
|
199
297
|
const path = agentPath(target, cwd, agent.name);
|
|
298
|
+
if (!force && ownership(target, cwd, agent, path, digests) === 'user') {
|
|
299
|
+
// Unless it already IS what rsc would write — then there is nothing to protect, only to record.
|
|
300
|
+
if (readFileSync(path, 'utf8') !== content) { kept.push(keptNotice(agent.name, path, cwd)); continue; }
|
|
301
|
+
}
|
|
200
302
|
mkdirSync(dirname(path), { recursive: true });
|
|
201
303
|
writeFileSync(path, content);
|
|
304
|
+
digests[relKey(cwd, path)] = sha(content);
|
|
202
305
|
written.push(path);
|
|
203
306
|
}
|
|
204
|
-
|
|
307
|
+
writeDigests(cwd, digests);
|
|
308
|
+
return { written, kept };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function writeAgents(target, cwd, tier = readDeveloperTier(cwd), names = agentNames(), options) {
|
|
312
|
+
return writeAgentsDetailed(target, cwd, tier, names, options).written;
|
|
205
313
|
}
|
|
206
314
|
|
|
207
315
|
export function reconcileAgents(target, cwd, tier, previousNames = [], desiredNames = []) {
|
|
208
|
-
if (!targetHasAgents(target)) return { written: [], removed: [], collisions: [], names: [] };
|
|
316
|
+
if (!targetHasAgents(target)) return { written: [], removed: [], collisions: [], names: [], kept: [] };
|
|
209
317
|
const previous = new Set(previousNames);
|
|
210
318
|
const desired = new Set(desiredNames);
|
|
211
|
-
const
|
|
319
|
+
const kept = [];
|
|
320
|
+
// An agent no longer wanted is removed only while it is still rsc's file. One the user edited is
|
|
321
|
+
// theirs now: deleting it on an uninstall or a stack change is the same loss sync must not cause.
|
|
322
|
+
const digests = readDigests(cwd);
|
|
323
|
+
const stale = [];
|
|
324
|
+
for (const name of [...previous].filter((n) => !desired.has(n))) {
|
|
325
|
+
const agent = byName(name);
|
|
326
|
+
const path = agentPath(target, cwd, name);
|
|
327
|
+
if (agent && ownership(target, cwd, agent, path, digests) === 'user') {
|
|
328
|
+
kept.push({ name, path, message: `kept your edited agent ${name} (${relKey(cwd, path)}); rsc no longer installs it, delete it yourself if you do not want it` });
|
|
329
|
+
} else stale.push(name);
|
|
330
|
+
}
|
|
331
|
+
const removed = removeAgents(target, cwd, stale);
|
|
332
|
+
if (removed.length) {
|
|
333
|
+
for (const path of removed) delete digests[relKey(cwd, path)];
|
|
334
|
+
writeDigests(cwd, digests);
|
|
335
|
+
}
|
|
212
336
|
const written = [];
|
|
213
337
|
const collisions = [];
|
|
214
338
|
const names = [];
|
|
@@ -218,10 +342,12 @@ export function reconcileAgents(target, cwd, tier, previousNames = [], desiredNa
|
|
|
218
342
|
collisions.push(path);
|
|
219
343
|
continue;
|
|
220
344
|
}
|
|
221
|
-
|
|
345
|
+
const result = writeAgentsDetailed(target, cwd, tier, [name]);
|
|
346
|
+
written.push(...result.written);
|
|
347
|
+
kept.push(...result.kept);
|
|
222
348
|
if (existsSync(path)) names.push(name);
|
|
223
349
|
}
|
|
224
|
-
return { written, removed, collisions, names };
|
|
350
|
+
return { written, removed, collisions, names, kept };
|
|
225
351
|
}
|
|
226
352
|
|
|
227
353
|
/**
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Git permissions: on a harness installed from scratch, the agent may `git commit`, `git push` and
|
|
2
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.
|
|
4
|
-
// on a closed default branch is still denied by branch-guard, whatever the
|
|
3
|
+
// PR). A force-push still asks wherever the assistant can say so. In Claude Code the guards are
|
|
4
|
+
// untouched: a commit on a closed default branch is still denied by branch-guard, whatever the
|
|
5
|
+
// permission says. No other assistant runs rsc's guards, and `on|off|status` says so (#298).
|
|
5
6
|
//
|
|
6
7
|
// A PROJECT decision, recorded as `gitPermissions` in `.rsc.json`: true on a brand-new project, absent
|
|
7
8
|
// on one adopted before this existed (nothing changes there until `rsc git-permissions on`), false
|
|
@@ -25,6 +26,9 @@ const OPENCODE_BASH = [
|
|
|
25
26
|
['git commit', 'allow'], ['git commit *', 'allow'], ['git push', 'allow'], ['git push *', 'allow'], ['gh pr create *', 'allow'],
|
|
26
27
|
['git push --force*', 'ask'], ['git push -f*', 'ask'], ['git push * --force*', 'ask'], ['git push * -f*', 'ask'],
|
|
27
28
|
];
|
|
29
|
+
// OpenCode 2 reads an ordered top-level `permissions` array (bash → `shell`); same rules, same order.
|
|
30
|
+
const OPENCODE_V2 = OPENCODE_BASH.map(([resource, effect]) => ({ action: 'shell', resource, effect }));
|
|
31
|
+
const FORCE_PATTERNS = OPENCODE_BASH.filter(([, a]) => a === 'ask').map(([p]) => p);
|
|
28
32
|
const CODEX_RULES = `# rsc-git-permissions:managed — written by rsc; \`rsc git-permissions off\` removes it.
|
|
29
33
|
# Commit, push and open a PR without asking; a force-push still asks (the strictest match wins).
|
|
30
34
|
prefix_rule(pattern = ["git", "commit"], decision = "allow", justification = "rsc: close the lane — commit")
|
|
@@ -66,6 +70,28 @@ function readJson(path) {
|
|
|
66
70
|
} catch { return null; }
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
/** JSONC → JSON, for READING only: drops comments and trailing commas outside strings. */
|
|
74
|
+
export function parseJsonc(text) {
|
|
75
|
+
let out = '';
|
|
76
|
+
for (let i = 0; i < text.length; i++) {
|
|
77
|
+
const ch = text[i];
|
|
78
|
+
if (ch === '"') {
|
|
79
|
+
let j = i + 1;
|
|
80
|
+
while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
|
|
81
|
+
out += text.slice(i, j + 1); i = j;
|
|
82
|
+
} else if (ch === '/' && text[i + 1] === '/') {
|
|
83
|
+
while (i < text.length && text[i] !== '\n') i++;
|
|
84
|
+
out += '\n';
|
|
85
|
+
} else if (ch === '/' && text[i + 1] === '*') {
|
|
86
|
+
const end = text.indexOf('*/', i + 2);
|
|
87
|
+
i = end < 0 ? text.length : end + 1;
|
|
88
|
+
} else out += ch;
|
|
89
|
+
}
|
|
90
|
+
// Trailing commas: only outside strings matter, and strings were copied verbatim — so strip them
|
|
91
|
+
// with a pass that also skips strings.
|
|
92
|
+
return JSON.parse(out.replace(/("(?:[^"\\]|\\.)*")|,(\s*[}\]])/g, (m, str, close) => str ?? close));
|
|
93
|
+
}
|
|
94
|
+
|
|
69
95
|
const writeJson = (path, value) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); };
|
|
70
96
|
const addAll = (list, items) => [...(Array.isArray(list) ? list : []), ...items.filter((i) => !(list || []).includes(i))];
|
|
71
97
|
const dropAll = (list, items) => (Array.isArray(list) ? list.filter((i) => !items.includes(i)) : list);
|
|
@@ -74,10 +100,125 @@ export function gitPermissionsPath(target, cwd = process.cwd()) {
|
|
|
74
100
|
return FILES[target] ? join(cwd, ...FILES[target].split('/')) : null;
|
|
75
101
|
}
|
|
76
102
|
|
|
77
|
-
|
|
103
|
+
// ── OpenCode: V1 (`permission.bash` map) or V2 (`permissions` array) ────────────────────────────
|
|
104
|
+
// OpenCode 2 still reads the V1 map; OpenCode 1.x does not know `permissions`. So V2 is written only
|
|
105
|
+
// when OpenCode 2 is seen on this machine (or the file is already V2); otherwise the V1 map, which
|
|
106
|
+
// both read. `RSC_OPENCODE_MAJOR` pins the version (tests, or a machine where the probe cannot run).
|
|
107
|
+
|
|
108
|
+
/** Installed OpenCode major version, or null when it cannot be told. */
|
|
109
|
+
export function opencodeMajor() {
|
|
110
|
+
const pinned = process.env.RSC_OPENCODE_MAJOR;
|
|
111
|
+
if (pinned !== undefined) return /^\d+$/.test(pinned.trim()) ? Number(pinned.trim()) : null;
|
|
112
|
+
try {
|
|
113
|
+
// `opencode` is an npm .cmd shim on Windows, which only a shell resolves.
|
|
114
|
+
const win = process.platform === 'win32';
|
|
115
|
+
const out = execFileSync(win ? 'opencode --version' : 'opencode', win ? [] : ['--version'], {
|
|
116
|
+
windowsHide: true, timeout: 3000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: win,
|
|
117
|
+
});
|
|
118
|
+
const m = /(\d+)\.\d+/.exec(out);
|
|
119
|
+
return m ? Number(m[1]) : null;
|
|
120
|
+
} catch { return null; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const sameRule = (a, b) => a && typeof a === 'object' && a.action === b.action && a.resource === b.resource && a.effect === b.effect;
|
|
124
|
+
const isRscV2 = (rule) => OPENCODE_V2.some((r) => sameRule(rule, r));
|
|
125
|
+
const hasV1 = (config) => OPENCODE_BASH.every(([p, a]) => config?.permission?.bash?.[p] === a);
|
|
126
|
+
const hasV2 = (config) => Array.isArray(config?.permissions) && OPENCODE_V2.every((r) => config.permissions.some((x) => sameRule(x, r)));
|
|
127
|
+
const jsoncOnly = (cwd) => !existsSync(join(cwd, 'opencode.json')) && existsSync(join(cwd, 'opencode.jsonc'));
|
|
128
|
+
|
|
129
|
+
function opencodeFormat(config) {
|
|
130
|
+
const major = opencodeMajor();
|
|
131
|
+
if (major !== null) return major >= 2 ? 'v2' : 'v1';
|
|
132
|
+
return Array.isArray(config?.permissions) ? 'v2' : 'v1';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function dropV1(config) {
|
|
136
|
+
const bash = config.permission?.bash;
|
|
137
|
+
if (!bash || typeof bash !== 'object') return;
|
|
138
|
+
for (const [pattern, action] of OPENCODE_BASH) if (bash[pattern] === action) delete bash[pattern];
|
|
139
|
+
if (!Object.keys(bash).length) delete config.permission.bash;
|
|
140
|
+
if (!Object.keys(config.permission).length) delete config.permission;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function dropV2(config) {
|
|
144
|
+
if (!Array.isArray(config.permissions)) return;
|
|
145
|
+
config.permissions = config.permissions.filter((r) => !isRscV2(r));
|
|
146
|
+
if (!config.permissions.length) delete config.permissions;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Where rsc's V2 block goes. The last matching rule wins, so a rule placed AFTER rsc's beats it. rsc
|
|
150
|
+
// must never loosen what the person decided, so its block goes BEFORE their rules: any rule of theirs
|
|
151
|
+
// that matches the same command later (a `deny` on `git push *`, an `ask` on `git *`) still wins.
|
|
152
|
+
// The one exception is a leading catch-all baseline (`resource: "*"` with `allow` or `ask`, the usual
|
|
153
|
+
// "ask for everything" default): rsc goes after it, or that default would make rsc's allows dead —
|
|
154
|
+
// what the V1 map has always done too. A catch-all `deny` is never stepped over.
|
|
155
|
+
function v2InsertAt(list) {
|
|
156
|
+
let i = 0;
|
|
157
|
+
while (i < list.length && list[i]?.resource === '*' && ['*', 'shell'].includes(list[i]?.action) && list[i]?.effect !== 'deny') i++;
|
|
158
|
+
return i;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function wireOpencode(config, format) {
|
|
162
|
+
if (format === 'v2') {
|
|
163
|
+
if (config.permissions !== undefined && !Array.isArray(config.permissions)) return false;
|
|
164
|
+
dropV1(config); // migrating: rsc's old V1 entries go, the person's stay
|
|
165
|
+
const list = (config.permissions || []).filter((r) => !isRscV2(r));
|
|
166
|
+
const at = v2InsertAt(list);
|
|
167
|
+
config.permissions = [...list.slice(0, at), ...OPENCODE_V2.map((r) => ({ ...r })), ...list.slice(at)];
|
|
168
|
+
} else {
|
|
169
|
+
if (config.permission !== undefined && (typeof config.permission !== 'object' || config.permission === null)) return false;
|
|
170
|
+
config.permission ||= {};
|
|
171
|
+
if (typeof config.permission.bash === 'string') return false; // a blanket rule is the person's call
|
|
172
|
+
dropV2(config); // back on 1.x (or unknown on a V1 file): one block, the one this OpenCode reads
|
|
173
|
+
const bash = config.permission.bash && typeof config.permission.bash === 'object' ? config.permission.bash : {};
|
|
174
|
+
// Re-inserted so the asks stay after the allows (OpenCode: the last matching rule wins).
|
|
175
|
+
for (const [pattern] of OPENCODE_BASH) delete bash[pattern];
|
|
176
|
+
for (const [pattern, action] of OPENCODE_BASH) bash[pattern] = action;
|
|
177
|
+
config.permission.bash = bash;
|
|
178
|
+
}
|
|
179
|
+
config.$schema ||= 'https://opencode.ai/config.json';
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// One rule per line: short enough to read in a terminal, valid JSON to paste.
|
|
184
|
+
function snippet(format, pairs) {
|
|
185
|
+
if (format === 'v2') {
|
|
186
|
+
const rows = pairs.map(([resource, effect]) => ` ${JSON.stringify({ action: 'shell', resource, effect })}`);
|
|
187
|
+
return `"permissions": [\n${rows.join(',\n')}\n]`;
|
|
188
|
+
}
|
|
189
|
+
const rows = pairs.map(([p, a]) => ` ${JSON.stringify(p)}: ${JSON.stringify(a)}`);
|
|
190
|
+
return `"permission": {\n "bash": {\n${rows.join(',\n')}\n }\n}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The exact rules rsc would write, to paste into an OpenCode config. */
|
|
194
|
+
export const opencodeRulesSnippet = (format) => snippet(format, OPENCODE_BASH);
|
|
195
|
+
|
|
196
|
+
/** A stricter policy only OpenCode itself can enforce: refuse every force-push (`ask` also works). */
|
|
197
|
+
export const opencodeForcePushSnippet = (format) => snippet(format, FORCE_PATTERNS.map((p) => [p, 'deny']));
|
|
198
|
+
|
|
199
|
+
function readJsonc(path) {
|
|
200
|
+
try {
|
|
201
|
+
const value = parseJsonc(readFileSync(path, 'utf8'));
|
|
202
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
203
|
+
} catch { return null; }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Add rsc's entries. `{ mode: 'wired' | 'manual' | 'unsupported' | 'config-invalid', path }`; OpenCode
|
|
208
|
+
* adds `format` ('v1' | 'v2'), and on 'manual' (an opencode.jsonc rsc will not rewrite) the `snippet`.
|
|
209
|
+
*/
|
|
78
210
|
export function wireGitPermissions(target, cwd = process.cwd()) {
|
|
79
211
|
const path = gitPermissionsPath(target, cwd);
|
|
80
212
|
if (!path) return { mode: 'unsupported', path: null };
|
|
213
|
+
if (target === 'opencode' && jsoncOnly(cwd)) {
|
|
214
|
+
// Rewriting a JSONC file would drop the person's comments: read it, never write it.
|
|
215
|
+
const jsonc = join(cwd, 'opencode.jsonc');
|
|
216
|
+
const config = readJsonc(jsonc);
|
|
217
|
+
if (config && hasV2(config)) return { mode: 'wired', path: jsonc, format: 'v2' };
|
|
218
|
+
if (config && hasV1(config)) return { mode: 'wired', path: jsonc, format: 'v1' };
|
|
219
|
+
const format = opencodeFormat(config);
|
|
220
|
+
return { mode: 'manual', path: jsonc, format, snippet: opencodeRulesSnippet(format) };
|
|
221
|
+
}
|
|
81
222
|
if (target === 'codex') {
|
|
82
223
|
mkdirSync(dirname(path), { recursive: true });
|
|
83
224
|
writeFileSync(path, CODEX_RULES);
|
|
@@ -95,14 +236,11 @@ export function wireGitPermissions(target, cwd = process.cwd()) {
|
|
|
95
236
|
config.tools ||= {};
|
|
96
237
|
config.tools.allowed = addAll(config.tools.allowed, GEMINI_ALLOW);
|
|
97
238
|
} else if (target === 'opencode') {
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
for (const [pattern, action] of OPENCODE_BASH) bash[pattern] = action;
|
|
104
|
-
config.permission.bash = bash;
|
|
105
|
-
config.$schema ||= 'https://opencode.ai/config.json';
|
|
239
|
+
const format = opencodeFormat(config);
|
|
240
|
+
if (!wireOpencode(config, format)) return { mode: 'config-invalid', path, format };
|
|
241
|
+
writeJson(path, config);
|
|
242
|
+
if (!existed) excludeIfNew(cwd, path);
|
|
243
|
+
return { mode: 'wired', path, format };
|
|
106
244
|
}
|
|
107
245
|
writeJson(path, config);
|
|
108
246
|
if (!existed && target !== 'claude') excludeIfNew(cwd, path);
|
|
@@ -129,10 +267,11 @@ export function unwireGitPermissions(target, cwd = process.cwd()) {
|
|
|
129
267
|
config.tools.allowed = dropAll(config.tools.allowed, GEMINI_ALLOW);
|
|
130
268
|
if (Array.isArray(config.tools.allowed) && !config.tools.allowed.length) delete config.tools.allowed;
|
|
131
269
|
if (!Object.keys(config.tools).length) delete config.tools;
|
|
132
|
-
} else if (target === 'opencode'
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
270
|
+
} else if (target === 'opencode') {
|
|
271
|
+
const before = JSON.stringify(config);
|
|
272
|
+
dropV1(config); // both formats: machines on OpenCode 1.x and 2 may share this file
|
|
273
|
+
dropV2(config);
|
|
274
|
+
if (JSON.stringify(config) === before) return [];
|
|
136
275
|
} else return [];
|
|
137
276
|
writeJson(path, config);
|
|
138
277
|
return [path];
|
|
@@ -146,6 +285,105 @@ export function gitPermissionsWired(target, cwd = process.cwd()) {
|
|
|
146
285
|
const config = readJson(path) || {};
|
|
147
286
|
if (target === 'claude') return CLAUDE_ALLOW.every((r) => config.permissions?.allow?.includes(r));
|
|
148
287
|
if (target === 'gemini') return GEMINI_ALLOW.every((r) => config.tools?.allowed?.includes(r));
|
|
149
|
-
if (target === 'opencode') return
|
|
288
|
+
if (target === 'opencode') return gitPermissionsState(target, cwd).wired;
|
|
150
289
|
return false;
|
|
151
290
|
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* `{ wired, format, path }` for `status`. format: Claude/Codex/Gemini → 'native'; OpenCode → 'v1' or
|
|
294
|
+
* 'v2' (where rsc's rules are, or where they would go) or 'manual' (opencode.jsonc, rules not pasted).
|
|
295
|
+
*/
|
|
296
|
+
export function gitPermissionsState(target, cwd = process.cwd()) {
|
|
297
|
+
const path = gitPermissionsPath(target, cwd);
|
|
298
|
+
if (!path) return { wired: false, format: null, path: null };
|
|
299
|
+
if (target !== 'opencode') return { wired: gitPermissionsWired(target, cwd), format: 'native', path };
|
|
300
|
+
if (jsoncOnly(cwd)) {
|
|
301
|
+
const jsonc = join(cwd, 'opencode.jsonc');
|
|
302
|
+
const config = readJsonc(jsonc);
|
|
303
|
+
if (config && hasV2(config)) return { wired: true, format: 'v2', path: jsonc };
|
|
304
|
+
if (config && hasV1(config)) return { wired: true, format: 'v1', path: jsonc };
|
|
305
|
+
return { wired: false, format: 'manual', path: jsonc };
|
|
306
|
+
}
|
|
307
|
+
const config = (existsSync(path) && readJson(path)) || {};
|
|
308
|
+
if (hasV2(config)) return { wired: true, format: 'v2', path };
|
|
309
|
+
if (hasV1(config)) return { wired: true, format: 'v1', path };
|
|
310
|
+
return { wired: false, format: opencodeFormat(config), path };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── What `rsc git-permissions on|off|status` says, per assistant (#298) ─────────────────────────
|
|
314
|
+
// Three things, always: what rsc controls there, what stops when it is off, and what is left to the
|
|
315
|
+
// assistant itself. The guards are named only where they exist: rsc's PreToolUse guards
|
|
316
|
+
// (branch-guard, danger-guard) run in Claude Code alone; no other assistant runs them.
|
|
317
|
+
|
|
318
|
+
const LABEL = { claude: 'Claude Code', codex: 'Codex', gemini: 'Gemini CLI', opencode: 'OpenCode', cursor: 'Cursor', deepseek: 'DeepSeek Harness' };
|
|
319
|
+
const RULES = 'allow git commit, git push and gh pr create without asking, and ask before a force-push';
|
|
320
|
+
|
|
321
|
+
const WHEN_OFF = {
|
|
322
|
+
claude: 'rsc removes its rules; Claude Code asks again for commit, push and PR, unless your own permissions allow them.',
|
|
323
|
+
codex: 'rsc deletes its rules file; Codex applies its own approval policy and any rules of yours in .codex/rules.',
|
|
324
|
+
gemini: 'rsc removes its entries; Gemini CLI asks before those commands unless your own tools.allowed lists them.',
|
|
325
|
+
opencode: 'rsc removes its rules; only your own OpenCode rules apply. With no rule matching, OpenCode 2 asks and OpenCode 1.x runs the command.',
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const indent = (text, pad = ' ') => text.split('\n').map((l) => pad + l).join('\n');
|
|
329
|
+
|
|
330
|
+
function claudeGuards(cwd) {
|
|
331
|
+
const rsc = join(cwd, '.rsc');
|
|
332
|
+
const out = [];
|
|
333
|
+
if (existsSync(join(rsc, 'branch-guard.mjs'))) out.push('branch-guard (refuses a commit on a closed main)');
|
|
334
|
+
if (existsSync(join(rsc, 'danger-guard.mjs')) && !existsSync(join(rsc, '.no-danger-guard'))) out.push('danger-guard (stops a force-push, reset --hard and similar)');
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// The OpenCode format the snippets should use: where rsc's rules are, or where they would go.
|
|
339
|
+
function opencodeSnippetFormat(state) {
|
|
340
|
+
return state.format === 'manual' ? opencodeFormat(readJsonc(state.path)) : state.format;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function stateLine(target, cwd, s) {
|
|
344
|
+
const where = s.path ? rel(cwd, s.path) : '';
|
|
345
|
+
if (s.format === 'manual') return ` Now: not written. ${where} has comments rsc will not rewrite; paste these rules into it:\n${indent(opencodeRulesSnippet(opencodeSnippetFormat(s)))}`;
|
|
346
|
+
const fmt = target === 'opencode' ? ` (${s.format === 'v2' ? 'OpenCode 2 `permissions`' : 'OpenCode 1.x `permission.bash`, read by 2 too'})` : '';
|
|
347
|
+
return ` Now: ${s.wired ? 'on' : 'off'} in ${where}${fmt}.`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function block(target, cwd) {
|
|
351
|
+
const label = LABEL[target] || target;
|
|
352
|
+
const path = gitPermissionsPath(target, cwd);
|
|
353
|
+
if (!path) {
|
|
354
|
+
return `${label}\n rsc controls: nothing here. ${target === 'cursor'
|
|
355
|
+
? 'Its CLI matches only the first word, so allowing a push would allow every git command.'
|
|
356
|
+
: 'It has no per-command allow list.'}\n ${label}'s job: every git permission.`;
|
|
357
|
+
}
|
|
358
|
+
const state = gitPermissionsState(target, cwd);
|
|
359
|
+
const lines = [label, stateLine(target, cwd, state), ` rsc controls: rules in ${rel(cwd, state.path)} that ${RULES}.`];
|
|
360
|
+
if (target === 'claude') {
|
|
361
|
+
const guards = claudeGuards(cwd);
|
|
362
|
+
lines.push(guards.length
|
|
363
|
+
? ` Also rsc, whatever this switch says: ${guards.join('; ')}.`
|
|
364
|
+
: ' rsc\'s guards (branch-guard, danger-guard) are not installed in this project.');
|
|
365
|
+
}
|
|
366
|
+
lines.push(` When off: ${state.path.endsWith('.jsonc')
|
|
367
|
+
? 'rsc never edits opencode.jsonc, so remove the pasted rules yourself; then only your own OpenCode rules apply.'
|
|
368
|
+
: WHEN_OFF[target]}`);
|
|
369
|
+
if (target === 'claude') lines.push(` ${label}'s job: anything its own permissions decide beyond rsc's rules and guards.`);
|
|
370
|
+
else {
|
|
371
|
+
lines.push(` ${label}'s job: there is no rsc guard in ${label}, so nothing from rsc stops a commit on main or a force-push there. A stricter policy (refuse a force-push, a human approval) goes in ${label}'s own configuration.`);
|
|
372
|
+
if (target === 'opencode') {
|
|
373
|
+
const fmt = opencodeSnippetFormat(state);
|
|
374
|
+
lines.push(` To refuse every force-push in OpenCode ${fmt === 'v2' ? '2' : '1.x'} (use "ask" for a human approval), add at the END of ${fmt === 'v2' ? '`permissions`' : '`permission.bash`'} (the last matching rule wins):`);
|
|
375
|
+
lines.push(indent(opencodeForcePushSnippet(fmt)));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return lines.join('\n');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The human explanation for `on`, `off` and `status`, one block per assistant in `targets`. */
|
|
382
|
+
export function explainGitPermissions({ targets, cwd = process.cwd(), mode = 'status', declared } = {}) {
|
|
383
|
+
const head = {
|
|
384
|
+
on: 'rsc git-permissions on. Saved in .rsc.json: commit it so the team gets the same decision.',
|
|
385
|
+
off: 'rsc git-permissions off. Saved in .rsc.json: commit it so the team gets the same decision.',
|
|
386
|
+
status: `rsc git-permissions: ${declared === undefined ? 'undecided (this project was adopted before; nothing is changed until `rsc git-permissions on`)' : declared ? 'on' : 'off'}.`,
|
|
387
|
+
}[mode];
|
|
388
|
+
return [head, ...targets.map((t) => block(t, cwd))].join('\n\n');
|
|
389
|
+
}
|