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