@ericrisco/rsc 3.0.5 → 3.0.7
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 +39 -6
- package/manifest.json +1 -1
- package/package.json +3 -1
- package/scripts/install-apply.js +3 -0
- package/scripts/rsc.js +1 -0
- package/targets/auto-update.mjs +12 -3
- package/targets/branch-guard.mjs +1 -1
- package/targets/deepseek-bridge.js +86 -0
- package/targets/deepseek-bridge.mjs +90 -0
- package/targets/git-permissions.js +1 -1
- package/targets/index.js +4 -0
- package/targets/knowledge-sync.mjs +1 -0
- package/targets/knowledge-wiring.js +3 -1
- package/targets/memory.js +8 -1
- package/targets/sello.mjs +2 -2
- package/targets/session-memory-adapter.mjs +1 -1
- package/targets/session-memory-core.mjs +1 -1
- package/targets/ship-guard.mjs +1 -1
- package/targets/team-safe-start.mjs +1 -1
- package/targets/trunk-policy.mjs +1 -1
- package/targets/update-wiring.js +3 -2
- package/targets/worktree-reaper.mjs +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,27 @@ npx @ericrisco/rsc@latest onboard
|
|
|
31
31
|
|
|
32
32
|
</div>
|
|
33
33
|
|
|
34
|
+
## 🐋 New: DeepSeek Harness
|
|
35
|
+
|
|
36
|
+
[DeepSeek Harness](https://www.npmjs.com/package/@deepseek-ai/dsh) (`dsh`) is now a supported
|
|
37
|
+
assistant, the 18th. Pick it in the wizard or pass `--target deepseek`:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx @ericrisco/rsc@latest onboard --target deepseek
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- **Skills** go to `.dsh/skills/`, where dsh finds them on its own.
|
|
44
|
+
- **Instructions** go in the rsc block of `AGENTS.md`, which dsh already reads.
|
|
45
|
+
- **Session memory, knowledge sync and the update notice** run from dsh's own hooks. dsh has no
|
|
46
|
+
hooks per project, so rsc adds one marked block to `~/.dsh/cordis.patch.yml` once per machine. That
|
|
47
|
+
block runs the hooks of whichever project the session is in. It works in `dsh web` with several
|
|
48
|
+
projects open, and in a project without rsc it does nothing. Restart dsh once after the first
|
|
49
|
+
install. To turn it off, delete the block.
|
|
50
|
+
|
|
51
|
+
Details: [targets table](#multi-target).
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
34
55
|
## Your agent needs a harness. It should not improvise one.
|
|
35
56
|
|
|
36
57
|
A model is only the brain. Real work also needs project memory, tools, domain knowledge, rules and
|
|
@@ -89,6 +110,7 @@ someone runs `rsc git-permissions on`.
|
|
|
89
110
|
| Gemini | `.gemini/settings.json` → `tools.allowed` |
|
|
90
111
|
| OpenCode | `opencode.json` → `permission.bash` (force-push → `ask`) |
|
|
91
112
|
| Cursor | Not covered: its CLI matches only the first word, so allowing a push would allow every git command. |
|
|
113
|
+
| DeepSeek Harness | Not covered: dsh has no per-command allow list. Its own Permissions selector (`/permission`) sets approval for every command at once. |
|
|
92
114
|
|
|
93
115
|
```bash
|
|
94
116
|
rsc git-permissions status # decided or not, and wired per assistant
|
|
@@ -241,8 +263,8 @@ no method deciding whether each piece belongs. rsc-harness keeps that constructi
|
|
|
241
263
|
- **Specialists follow the stack.** The four base agents stay small; installing a
|
|
242
264
|
supported stack adds only its reviewer and build resolver. `rsc add go`, for
|
|
243
265
|
example, adds the Go pair without pulling reviewers for every other language.
|
|
244
|
-
- **A new local session continues the old one.** Claude Code, Codex, Gemini CLI
|
|
245
|
-
and
|
|
266
|
+
- **A new local session continues the old one.** Claude Code, Codex, Gemini CLI,
|
|
267
|
+
OpenCode and DeepSeek Harness load a bounded checkpoint for the current branch and worktree at
|
|
246
268
|
session start. Cursor desktop uses an assisted read-before-action fallback.
|
|
247
269
|
- **Honestly good.** Every skill was built by a research → spec → implement →
|
|
248
270
|
*adversarial review* pipeline and had to clear an objective rubric
|
|
@@ -262,7 +284,8 @@ When a new session opens in the same checkout, that state is injected before the
|
|
|
262
284
|
first agent action. A completed edit is preserved even if the previous client
|
|
263
285
|
closed before its normal session-end event.
|
|
264
286
|
|
|
265
|
-
- **Full:** Claude Code, Codex, Gemini CLI and
|
|
287
|
+
- **Full:** Claude Code, Codex, Gemini CLI, OpenCode and DeepSeek Harness (through rsc's machine
|
|
288
|
+
bridge, see [targets](#multi-target)). Codex asks you to inspect
|
|
266
289
|
and trust the project hook once with `/hooks`; until then `doctor` reports that
|
|
267
290
|
trust is still required.
|
|
268
291
|
- **Assisted:** Cursor desktop. Its start hook is fire-and-forget, so rsc also
|
|
@@ -489,7 +512,7 @@ What it will not do, by design:
|
|
|
489
512
|
| Run where it has no business | No `origin`, no knowledge folders, a rebase in progress, a cloud agent: it stays quiet. |
|
|
490
513
|
|
|
491
514
|
It is wired into the assistant's own turn hooks (end of turn, new message) in Claude Code, Codex,
|
|
492
|
-
Gemini, Cursor and
|
|
515
|
+
Gemini, Cursor, OpenCode and DeepSeek Harness (tested end to end in Claude Code, Codex and OpenCode). The network part runs in the background, so a turn never waits for
|
|
493
516
|
it. It is kept apart from the [local session memory](#new-sessions-pick-up-the-latest-local-work),
|
|
494
517
|
which promises never to touch the network. The first turn says it is on. To turn it off for the
|
|
495
518
|
project:
|
|
@@ -550,7 +573,7 @@ A new install asks whether you want this (yes by default). To turn it off later,
|
|
|
550
573
|
`.rsc.json` like the other switches. The update can leave harness files changed in git; rsc never
|
|
551
574
|
commits them for you.
|
|
552
575
|
|
|
553
|
-
Where it runs on its own: **Claude Code, Codex, Gemini CLI, Cursor and
|
|
576
|
+
Where it runs on its own: **Claude Code, Codex, Gemini CLI, Cursor, OpenCode and DeepSeek Harness**, from a
|
|
554
577
|
session-start hook. Every other assistant has no such hook, so the always-on instructions ask the
|
|
555
578
|
agent to run `node .rsc/auto-update.mjs` on its first turn — same rules, but it depends on the agent
|
|
556
579
|
doing it (and some assistants ask your permission before running a command). Codex asks you to
|
|
@@ -772,14 +795,23 @@ duplicated. The wizard asks which ones; `--target a,b` does it non-interactively
|
|
|
772
795
|
| `roo` | `.roo/rsc/<id>/` → symlink | rule in `.roo/rules/rsc-suggest.md` |
|
|
773
796
|
| `amp` | `.amp/rsc/<id>/` → symlink | block in `AGENTS.md` |
|
|
774
797
|
| `opencode` | `.opencode/rsc/<id>/` → symlink | block in `AGENTS.md` |
|
|
798
|
+
| `deepseek` | `.dsh/skills/<id>/` → symlink (DeepSeek Harness discovers it natively) | block in `AGENTS.md` |
|
|
775
799
|
| `jules` | `.jules/rsc/<id>/` → symlink | block in `AGENTS.md` |
|
|
776
800
|
| `junie` | `.junie/rsc/<id>/` → symlink | block in `.junie/guidelines.md` |
|
|
777
801
|
| `kiro` | `.kiro/rsc/<id>/` → symlink | doc in `.kiro/steering/rsc-suggest.md` |
|
|
778
802
|
| `aider` | `.aider/rsc/<id>/` → symlink | block in `CONVENTIONS.md` |
|
|
779
803
|
|
|
780
|
-
> `codex`, `zed`, `amp`, `opencode` and `jules` all share the one root
|
|
804
|
+
> `codex`, `zed`, `amp`, `opencode`, `deepseek` and `jules` all share the one root
|
|
781
805
|
> `AGENTS.md`; the block is idempotent, so picking several writes it once.
|
|
782
806
|
|
|
807
|
+
> **DeepSeek Harness** (`dsh`) has no project-level hook config: its plugins are per machine. So
|
|
808
|
+
> the `deepseek` target writes the project hooks to `.dsh/hooks.json` (Codex format, untracked) and,
|
|
809
|
+
> once per machine, adds one marked block to `~/.dsh/cordis.patch.yml` (or `$DSH_HOME`) that mounts
|
|
810
|
+
> dsh's Codex-hooks bridge on rsc's dispatcher in `~/.dsh/rsc/`. The dispatcher runs the hooks of
|
|
811
|
+
> whichever project the session is in, so `dsh web` serving several workspaces works too, and a
|
|
812
|
+
> project without rsc runs nothing. Restart dsh once after the first install. Delete the block to
|
|
813
|
+
> turn it off.
|
|
814
|
+
|
|
783
815
|
The richer surfaces are intentionally narrower than skill support:
|
|
784
816
|
|
|
785
817
|
| Targets | Stack agents | Native commands | Local session continuation |
|
|
@@ -788,6 +820,7 @@ The richer surfaces are intentionally narrower than skill support:
|
|
|
788
820
|
| Codex | yes | no separate project-command surface | full after `/hooks` trust |
|
|
789
821
|
| Cursor desktop | yes | yes | assisted |
|
|
790
822
|
| Gemini CLI, OpenCode | yes | yes | full |
|
|
823
|
+
| DeepSeek Harness | unsupported | unsupported | full (through the machine bridge) |
|
|
791
824
|
| GitHub Copilot | yes | yes | unsupported |
|
|
792
825
|
| Junie, Kiro | yes | unsupported | unsupported |
|
|
793
826
|
| Windsurf, Cline, Roo | unsupported | yes | unsupported |
|
package/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ericrisco/rsc",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.7",
|
|
4
4
|
"description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -46,6 +46,8 @@
|
|
|
46
46
|
"claude-code",
|
|
47
47
|
"cursor",
|
|
48
48
|
"codex",
|
|
49
|
+
"deepseek",
|
|
50
|
+
"deepseek-harness",
|
|
49
51
|
"gemini",
|
|
50
52
|
"antigravity",
|
|
51
53
|
"cli",
|
package/scripts/install-apply.js
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { wireUpdate, unwireUpdate, updateManagedPaths, updateArtifactsPresent } from '../targets/update-wiring.js';
|
|
26
26
|
import { wireKnowledge, unwireKnowledge, knowledgeManagedPaths, knowledgeArtifactsPresent } from '../targets/knowledge-wiring.js';
|
|
27
27
|
import { wireGitPermissions, unwireGitPermissions, gitPermissionsPath } from '../targets/git-permissions.js';
|
|
28
|
+
import { wireDeepseekBridge } from '../targets/deepseek-bridge.js';
|
|
28
29
|
|
|
29
30
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
31
|
const CLI_VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
|
|
@@ -320,6 +321,8 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
|
|
|
320
321
|
// the memory, so the memory's policy does not decide it: wired wherever the assistant can run it.
|
|
321
322
|
const knowledgeResult = wireKnowledge(target, cwd);
|
|
322
323
|
state.knowledge = { mode: knowledgeResult.mode, reason: knowledgeResult.reason || null, paths: knowledgeResult.paths };
|
|
324
|
+
// DeepSeek Harness only reads hooks through a machine-level bridge (targets/deepseek-bridge.js).
|
|
325
|
+
if (target === 'deepseek') state.deepseekBridge = wireDeepseekBridge();
|
|
323
326
|
if (gitPermissions === true) state.gitPermissions = wireGitPermissions(target, cwd);
|
|
324
327
|
else if (gitPermissions === false) { unwireGitPermissions(target, cwd); state.gitPermissions = { mode: 'off' }; }
|
|
325
328
|
const context7OptOut = join(cwd, '.rsc', '.no-context7');
|
package/scripts/rsc.js
CHANGED
|
@@ -450,6 +450,7 @@ function printNextSteps(targets, ids) {
|
|
|
450
450
|
say(' Browse the catalog / get picks: npx @ericrisco/rsc consult "whatever you need"');
|
|
451
451
|
say('────────────────────────────────────────────────────────');
|
|
452
452
|
if (targets.includes('codex')) say(' Codex: review and trust the project lifecycle hook once with `/hooks`; until then memory is reported as requiring trust.');
|
|
453
|
+
if (targets.includes('deepseek')) say(' DeepSeek Harness: hooks run through rsc\'s bridge in ~/.dsh/cordis.patch.yml (one block, every profile); restart dsh once to load it.');
|
|
453
454
|
if (targets.includes('cursor')) say(' Cursor: startup memory is assisted because its sessionStart hook is fire-and-forget; the installed always-on rule performs the read-before-action fallback.');
|
|
454
455
|
printAgentHandoff();
|
|
455
456
|
}
|
package/targets/auto-update.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// `node .rsc/auto-update.mjs` on its first turn, and to relay what it prints.
|
|
12
12
|
import { readFileSync, existsSync, writeFileSync, openSync } from 'node:fs';
|
|
13
13
|
import { spawn } from 'node:child_process';
|
|
14
|
-
import { join, resolve } from 'node:path';
|
|
14
|
+
import { dirname, join, resolve } from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
|
|
17
17
|
// Update check: compare the installed version (.rsc/.version, written at install)
|
|
@@ -86,12 +86,21 @@ function launchUpdate(root, version, env) {
|
|
|
86
86
|
const targets = declaredTargets(root);
|
|
87
87
|
const args = [...base.slice(1), 'sync', ...(targets.length ? ['--target', targets.join(',')] : [])];
|
|
88
88
|
const log = openSync(logFile(root), 'w');
|
|
89
|
-
|
|
89
|
+
// #293 — on Windows npx is npx.cmd, which Node only starts through a shell, and a shell is one more
|
|
90
|
+
// console that can flash. So npx runs as what it is, a script of the npm that ships with Node,
|
|
91
|
+
// started by node itself. Only when that script cannot be found (a runtime that is not Node, e.g.
|
|
92
|
+
// OpenCode's Bun) does it fall back to the shell — still hidden.
|
|
93
|
+
const npxCli = !env.RSC_AUTO_UPDATE_CMD && process.platform === 'win32' && !process.versions.bun
|
|
94
|
+
? [join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npx-cli.js')].find((p) => existsSync(p))
|
|
95
|
+
: null;
|
|
96
|
+
const [command, argv] = npxCli ? [process.execPath, [npxCli, ...args]] : [base[0], args];
|
|
97
|
+
const viaShell = !npxCli && process.platform === 'win32';
|
|
98
|
+
const child = spawn(command, argv, {
|
|
90
99
|
cwd: root,
|
|
91
100
|
detached: true,
|
|
92
101
|
stdio: ['ignore', log, log],
|
|
93
102
|
windowsHide: true,
|
|
94
|
-
shell:
|
|
103
|
+
shell: viaShell, // fallback only (see above); every argument was validated above
|
|
95
104
|
env: { ...env, RSC_NO_UPDATE_CHECK: '1', RSC_AUTO_UPDATE_TO: version },
|
|
96
105
|
});
|
|
97
106
|
child.on('error', () => {});
|
package/targets/branch-guard.mjs
CHANGED
|
@@ -45,7 +45,7 @@ export function effectiveDir(command, base) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const gitAt = (dir) => (...args) => {
|
|
48
|
-
const r = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
|
|
48
|
+
const r = spawnSync('git', ['-C', dir, ...args], { windowsHide: true, encoding: 'utf8' });
|
|
49
49
|
return r.status === 0 ? (r.stdout || '').trim() : null;
|
|
50
50
|
};
|
|
51
51
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// DeepSeek Harness (dsh) has no project-level hook or plugin config: plugins are per machine, and
|
|
2
|
+
// its Codex-hooks bridge reads one file named at boot. So the deepseek target needs one machine-level
|
|
3
|
+
// step besides the project files: put rsc's bridge in $DSH_HOME/rsc/ and add one insert to the
|
|
4
|
+
// home patch layer ($DSH_HOME/cordis.patch.yml, applied over every profile). Idempotent, and it only
|
|
5
|
+
// ever touches the block between its own markers. It is never removed on uninstall: other projects on
|
|
6
|
+
// the machine may use it, and with no `.dsh/hooks.json` in a project it does nothing at all.
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const BEGIN = '# rsc-deepseek-bridge:begin';
|
|
14
|
+
const END = '# rsc-deepseek-bridge:end';
|
|
15
|
+
// The points rsc uses. PreToolUse is left out: nothing of rsc's runs there yet, and every tool call
|
|
16
|
+
// would pay a process spawn for nothing.
|
|
17
|
+
const EVENTS = ['SessionStart', 'UserPromptSubmit', 'PostToolUse', 'Stop'];
|
|
18
|
+
|
|
19
|
+
export function dshHome(env = process.env) {
|
|
20
|
+
const value = (env.DSH_HOME || '').trim();
|
|
21
|
+
return value || join(homedir(), '.dsh');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function deepseekBridgePaths(env = process.env) {
|
|
25
|
+
const home = dshHome(env);
|
|
26
|
+
return {
|
|
27
|
+
home,
|
|
28
|
+
bridge: join(home, 'rsc', 'bridge.mjs'),
|
|
29
|
+
hooks: join(home, 'rsc', 'hooks.json'),
|
|
30
|
+
patch: join(home, 'cordis.patch.yml'),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const quote = (value) => `'${String(value).replaceAll("'", "''")}'`;
|
|
35
|
+
|
|
36
|
+
function block(hooksPath) {
|
|
37
|
+
return [
|
|
38
|
+
`${BEGIN} — rsc hooks for DeepSeek Harness (delete this block to turn them off)`,
|
|
39
|
+
'- insert:',
|
|
40
|
+
' - id: rsc-hooks',
|
|
41
|
+
" name: '@deepseek-ai/dsh-hooks-codex'",
|
|
42
|
+
' config:',
|
|
43
|
+
` configPath: ${quote(hooksPath)}`,
|
|
44
|
+
END,
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Where the block goes, or null when the file is in a shape we must not guess at. */
|
|
49
|
+
export function patchWithBlock(body, hooksPath) {
|
|
50
|
+
const ours = block(hooksPath);
|
|
51
|
+
const start = body.indexOf(BEGIN);
|
|
52
|
+
const stop = body.indexOf(END);
|
|
53
|
+
if (start !== -1 && stop > start) return body.slice(0, start) + ours + body.slice(stop + END.length);
|
|
54
|
+
const lines = body.split('\n');
|
|
55
|
+
const content = lines.filter((line) => line.trim() && !line.trim().startsWith('#'));
|
|
56
|
+
if (content.length === 1 && content[0].trim() === '[]') {
|
|
57
|
+
const kept = lines.filter((line) => line.trim() !== '[]').join('\n').replace(/\n*$/, '');
|
|
58
|
+
return `${kept ? `${kept}\n` : ''}${ours}\n`;
|
|
59
|
+
}
|
|
60
|
+
if (!content.length) return `${body.replace(/\n*$/, '')}${body.trim() ? '\n' : ''}${ours}\n`;
|
|
61
|
+
if (content[0].startsWith('- ')) return `${body.replace(/\n*$/, '')}\n${ours}\n`;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function wireDeepseekBridge(env = process.env) {
|
|
66
|
+
const paths = deepseekBridgePaths(env);
|
|
67
|
+
mkdirSync(dirname(paths.bridge), { recursive: true });
|
|
68
|
+
copyFileSync(join(HERE, 'deepseek-bridge.mjs'), paths.bridge);
|
|
69
|
+
const hooks = {};
|
|
70
|
+
for (const event of EVENTS) {
|
|
71
|
+
hooks[event] = [{ hooks: [{ type: 'command', command: `node "${paths.bridge}" ${event}`, timeout: 30 }] }];
|
|
72
|
+
}
|
|
73
|
+
writeFileSync(paths.hooks, `${JSON.stringify({ hooks }, null, 2)}\n`);
|
|
74
|
+
const body = existsSync(paths.patch) ? readFileSync(paths.patch, 'utf8') : '';
|
|
75
|
+
const next = patchWithBlock(body, paths.hooks);
|
|
76
|
+
if (next === null) return { mode: 'manual', reason: 'patch-shape-unknown', paths: [paths.bridge, paths.hooks] };
|
|
77
|
+
if (next !== body) writeFileSync(paths.patch, next);
|
|
78
|
+
return { mode: 'wired', paths: [paths.bridge, paths.hooks, paths.patch] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function inspectDeepseekBridge(env = process.env) {
|
|
82
|
+
const paths = deepseekBridgePaths(env);
|
|
83
|
+
const patched = existsSync(paths.patch) && readFileSync(paths.patch, 'utf8').includes(BEGIN);
|
|
84
|
+
const ready = patched && existsSync(paths.bridge) && existsSync(paths.hooks);
|
|
85
|
+
return { status: ready ? 'ready' : 'missing', ...paths };
|
|
86
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// rsc-deepseek-bridge:managed
|
|
2
|
+
// DeepSeek Harness runs Codex-format hooks only from ONE file named at boot, and a boot is per
|
|
3
|
+
// machine, not per project (`dsh web` serves every workspace from one process). So the machine gets
|
|
4
|
+
// one hooks file whose every entry calls this script, and this script runs the hooks of whichever
|
|
5
|
+
// project the session is in: the nearest ancestor of the session's cwd holding `.dsh/hooks.json`.
|
|
6
|
+
// No project there → nothing runs, nothing is printed.
|
|
7
|
+
//
|
|
8
|
+
// Output is folded back into the Codex shape the bridge reads. The bridge drops `systemMessage`
|
|
9
|
+
// (not surfaced yet), so a message for the person travels as model context with an instruction to
|
|
10
|
+
// relay it. Usage: node bridge.mjs <SessionStart|UserPromptSubmit|PostToolUse|Stop> (payload on stdin)
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, join, resolve } from 'node:path';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const CONTEXT_EVENTS = new Set(['SessionStart', 'UserPromptSubmit', 'PostToolUse']);
|
|
17
|
+
|
|
18
|
+
export function projectOf(dir) {
|
|
19
|
+
for (let current = resolve(dir); ; current = dirname(current)) {
|
|
20
|
+
if (existsSync(join(current, '.dsh', 'hooks.json'))) return current;
|
|
21
|
+
if (dirname(current) === current) return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The hooks rsc writes are plain argv (`node ".rsc/x.mjs" args`), so no shell is needed — and none is
|
|
26
|
+
// used, which also keeps Windows from flashing a console (#293). Quotes group, nothing else is special.
|
|
27
|
+
export function argv(command) {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const m of command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) out.push(m[1] ?? m[2] ?? m[3]);
|
|
30
|
+
if (out[0] === 'node') out[0] = process.execPath;
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function matches(matcher, subject) {
|
|
35
|
+
if (!matcher) return true;
|
|
36
|
+
try { return new RegExp(`^(?:${matcher})$`).test(subject); } catch { return false; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function runBridge(event, raw = '', env = process.env) {
|
|
40
|
+
let payload = {};
|
|
41
|
+
try { payload = raw.trim() ? JSON.parse(raw) : {}; } catch { payload = {}; }
|
|
42
|
+
const root = projectOf(payload.cwd || process.cwd());
|
|
43
|
+
if (!root) return {};
|
|
44
|
+
let config;
|
|
45
|
+
try { config = JSON.parse(readFileSync(join(root, '.dsh', 'hooks.json'), 'utf8')); } catch { return {}; }
|
|
46
|
+
const groups = Array.isArray(config?.hooks?.[event]) ? config.hooks[event] : [];
|
|
47
|
+
const context = [];
|
|
48
|
+
let deny = null;
|
|
49
|
+
for (const group of groups) {
|
|
50
|
+
if (!matches(group?.matcher, String(payload.tool_name || ''))) continue;
|
|
51
|
+
for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
|
|
52
|
+
if (typeof hook?.command !== 'string') continue;
|
|
53
|
+
const seconds = typeof hook.timeout === 'number' ? hook.timeout : 10;
|
|
54
|
+
const [file, ...args] = argv(hook.command);
|
|
55
|
+
if (!file) continue;
|
|
56
|
+
const result = spawnSync(file, args, {
|
|
57
|
+
cwd: root, input: raw, encoding: 'utf8', timeout: seconds * 1000, windowsHide: true, env,
|
|
58
|
+
});
|
|
59
|
+
if (result.status === 2) { deny = (result.stderr || '').trim() || 'blocked by an rsc hook'; continue; }
|
|
60
|
+
if (result.status !== 0) continue;
|
|
61
|
+
const out = (result.stdout || '').trim();
|
|
62
|
+
if (!out) continue;
|
|
63
|
+
let parsed = null;
|
|
64
|
+
try { parsed = JSON.parse(out); } catch { parsed = null; }
|
|
65
|
+
if (!parsed || typeof parsed !== 'object') { context.push(out); continue; }
|
|
66
|
+
const specific = parsed.hookSpecificOutput || {};
|
|
67
|
+
if (parsed.decision === 'block' || parsed.decision === 'deny' || specific.permissionDecision === 'deny') {
|
|
68
|
+
deny = parsed.reason || specific.permissionDecisionReason || 'blocked by an rsc hook';
|
|
69
|
+
}
|
|
70
|
+
if (typeof specific.additionalContext === 'string' && specific.additionalContext) context.push(specific.additionalContext);
|
|
71
|
+
if (typeof parsed.systemMessage === 'string' && parsed.systemMessage) {
|
|
72
|
+
context.push(`Tell the person this in one line, in their language:\n${parsed.systemMessage}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const out = {};
|
|
77
|
+
if (deny) { out.decision = 'block'; out.reason = deny; }
|
|
78
|
+
if (context.length && CONTEXT_EVENTS.has(event)) {
|
|
79
|
+
out.hookSpecificOutput = { hookEventName: event, additionalContext: context.join('\n\n') };
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
85
|
+
let raw = '';
|
|
86
|
+
try { raw = readFileSync(0, 'utf8'); } catch { raw = ''; }
|
|
87
|
+
let out = {};
|
|
88
|
+
try { out = runBridge(String(process.argv[2] || ''), raw); } catch { out = {}; }
|
|
89
|
+
if (Object.keys(out).length) process.stdout.write(`${JSON.stringify(out)}\n`);
|
|
90
|
+
}
|
|
@@ -41,7 +41,7 @@ const FILES = Object.freeze({
|
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
const git = (cwd, args) => {
|
|
44
|
-
try { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
|
|
44
|
+
try { return execFileSync('git', args, { windowsHide: true, cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
|
|
45
45
|
};
|
|
46
46
|
const rel = (cwd, path) => relative(cwd, path).split(sep).join('/');
|
|
47
47
|
|
package/targets/index.js
CHANGED
|
@@ -44,6 +44,8 @@ const SPEC = {
|
|
|
44
44
|
// AGENTS.md family — all read the same root AGENTS.md
|
|
45
45
|
codex: { root: '.codex/rsc', hook: 'AGENTS.md', adapter: 'md' },
|
|
46
46
|
opencode: { root: '.opencode/rsc', hook: 'AGENTS.md', adapter: 'md' },
|
|
47
|
+
// DeepSeek Harness discovers <root>/.dsh/skills/<name>/SKILL.md natively.
|
|
48
|
+
deepseek: { root: '.dsh/skills', hook: 'AGENTS.md', adapter: 'md' },
|
|
47
49
|
amp: { root: '.amp/rsc', hook: 'AGENTS.md', adapter: 'md' },
|
|
48
50
|
jules: { root: '.jules/rsc', hook: 'AGENTS.md', adapter: 'md' },
|
|
49
51
|
zed: { root: '.zed/rsc', hook: 'AGENTS.md', adapter: 'md' },
|
|
@@ -78,6 +80,7 @@ export const TARGETS = [
|
|
|
78
80
|
{ id: 'roo', label: 'Roo Code', hint: '.roo/rules/' },
|
|
79
81
|
{ id: 'amp', label: 'Amp', hint: 'AGENTS.md' },
|
|
80
82
|
{ id: 'opencode', label: 'opencode', hint: 'AGENTS.md' },
|
|
83
|
+
{ id: 'deepseek', label: 'DeepSeek Harness', hint: '.dsh/skills/' },
|
|
81
84
|
{ id: 'jules', label: 'Jules', hint: 'AGENTS.md' },
|
|
82
85
|
{ id: 'junie', label: 'JetBrains Junie', hint: '.junie/guidelines.md' },
|
|
83
86
|
{ id: 'kiro', label: 'Kiro', hint: '.kiro/steering/' },
|
|
@@ -118,6 +121,7 @@ export function detectTarget(cwd = process.cwd()) {
|
|
|
118
121
|
if (has('.kiro')) return 'kiro';
|
|
119
122
|
if (has('.zed')) return 'zed';
|
|
120
123
|
if (has('.opencode')) return 'opencode';
|
|
124
|
+
if (has('.dsh')) return 'deepseek';
|
|
121
125
|
if (has('.amp')) return 'amp';
|
|
122
126
|
if (has('.jules')) return 'jules';
|
|
123
127
|
if (has('.antigravity')) return 'antigravity';
|
|
@@ -65,6 +65,7 @@ export const isKnowledge = (path) => KNOWLEDGE.some((k) => path.startsWith(k)) &
|
|
|
65
65
|
function run(root, args, { timeout = LOCAL_TIMEOUT_MS, input, env } = {}) {
|
|
66
66
|
try {
|
|
67
67
|
const out = execFileSync('git', args, {
|
|
68
|
+
windowsHide: true,
|
|
68
69
|
cwd: root, encoding: 'utf8', timeout, input, maxBuffer: 16 * 1024 * 1024,
|
|
69
70
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
70
71
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C', ...env },
|
|
@@ -18,6 +18,7 @@ const PLUGIN_MARKER = 'rsc-knowledge:managed';
|
|
|
18
18
|
const CONFIG = Object.freeze({
|
|
19
19
|
claude: '.claude/settings.local.json',
|
|
20
20
|
codex: '.codex/hooks.json',
|
|
21
|
+
deepseek: '.dsh/hooks.json',
|
|
21
22
|
cursor: '.cursor/hooks.json',
|
|
22
23
|
gemini: '.gemini/settings.json',
|
|
23
24
|
opencode: '.opencode/plugins/rsc-knowledge.js',
|
|
@@ -26,6 +27,7 @@ const CONFIG = Object.freeze({
|
|
|
26
27
|
const EVENTS = Object.freeze({
|
|
27
28
|
claude: [['UserPromptSubmit', 'request'], ['Stop', 'turn']],
|
|
28
29
|
codex: [['UserPromptSubmit', 'request'], ['Stop', 'turn']],
|
|
30
|
+
deepseek: [['UserPromptSubmit', 'request'], ['Stop', 'turn']],
|
|
29
31
|
gemini: [['BeforeAgent', 'request'], ['AfterAgent', 'turn']],
|
|
30
32
|
cursor: [['beforeSubmitPrompt', 'request'], ['afterAgentResponse', 'turn']],
|
|
31
33
|
});
|
|
@@ -33,7 +35,7 @@ const EVENTS = Object.freeze({
|
|
|
33
35
|
export const KNOWLEDGE_TARGETS = Object.freeze(Object.keys(CONFIG));
|
|
34
36
|
|
|
35
37
|
const git = (cwd, args) => {
|
|
36
|
-
try { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
|
|
38
|
+
try { return execFileSync('git', args, { windowsHide: true, cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
|
|
37
39
|
};
|
|
38
40
|
const rel = (cwd, path) => relative(cwd, path).split(sep).join('/');
|
|
39
41
|
const tracked = (cwd, path) => Boolean(git(cwd, ['ls-files', '--', rel(cwd, path)]));
|
package/targets/memory.js
CHANGED
|
@@ -13,6 +13,7 @@ const PLUGIN_MARKER = 'rsc-memory:managed';
|
|
|
13
13
|
export const MEMORY_TARGETS = Object.freeze({
|
|
14
14
|
claude: 'full',
|
|
15
15
|
codex: 'full',
|
|
16
|
+
deepseek: 'full',
|
|
16
17
|
// Cursor documents sessionStart as fire-and-forget. Its best-effort hook is useful,
|
|
17
18
|
// but only the always-on rule can make the read-before-action obligation explicit.
|
|
18
19
|
cursor: 'assisted',
|
|
@@ -23,6 +24,7 @@ export const MEMORY_TARGETS = Object.freeze({
|
|
|
23
24
|
const CONFIG = Object.freeze({
|
|
24
25
|
claude: '.claude/settings.local.json',
|
|
25
26
|
codex: '.codex/hooks.json',
|
|
27
|
+
deepseek: '.dsh/hooks.json',
|
|
26
28
|
cursor: '.cursor/hooks.json',
|
|
27
29
|
gemini: '.gemini/settings.json',
|
|
28
30
|
opencode: '.opencode/plugins/rsc-memory.js',
|
|
@@ -35,7 +37,7 @@ export function memoryEnabledForProject(cwd = process.cwd()) {
|
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
function git(cwd, args) {
|
|
38
|
-
try { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
|
|
40
|
+
try { return execFileSync('git', args, { windowsHide: true, cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
function rel(cwd, path) {
|
|
@@ -99,6 +101,11 @@ const nestedEvents = {
|
|
|
99
101
|
['PostToolUse', 'edit', 'apply_patch|Edit|Write'], ['PostToolUse', 'boundary', 'Bash'],
|
|
100
102
|
['Stop', 'turn'], ['PreCompact', 'compact'], ['SessionEnd', 'end'],
|
|
101
103
|
],
|
|
104
|
+
// DeepSeek Harness runs Codex-format hooks through rsc's bridge: only these five points exist there.
|
|
105
|
+
deepseek: [
|
|
106
|
+
['SessionStart', 'start'], ['UserPromptSubmit', 'request'],
|
|
107
|
+
['PostToolUse', 'edit', 'edit|write|str_replace_editor'], ['PostToolUse', 'boundary', 'bash'], ['Stop', 'turn'],
|
|
108
|
+
],
|
|
102
109
|
gemini: [
|
|
103
110
|
['SessionStart', 'start'], ['BeforeAgent', 'request'], ['AfterTool', 'edit', 'write_file|replace'],
|
|
104
111
|
['AfterTool', 'boundary', 'run_shell_command'], ['AfterAgent', 'turn'], ['PreCompress', 'compact'], ['SessionEnd', 'end'],
|
package/targets/sello.mjs
CHANGED
|
@@ -103,7 +103,7 @@ export function isEnabled(root) {
|
|
|
103
103
|
// The CLI can run from any subdirectory; the guard always runs from the project
|
|
104
104
|
// root. Both must compute the same candidate, so both resolve to the git toplevel.
|
|
105
105
|
export function resolveRoot(root) {
|
|
106
|
-
const r = spawnSync('git', ['-C', root, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
106
|
+
const r = spawnSync('git', ['-C', root, 'rev-parse', '--show-toplevel'], { windowsHide: true, encoding: 'utf8' });
|
|
107
107
|
return r.status === 0 ? (r.stdout || '').trim() || root : root;
|
|
108
108
|
}
|
|
109
109
|
|
|
@@ -211,7 +211,7 @@ export function lensesRequired(tier, cfg = null) {
|
|
|
211
211
|
// name matches no file on disk, so the file would hash as deleted and any later
|
|
212
212
|
// mutation of it would sail through the gate. -z gives raw NUL-separated paths.
|
|
213
213
|
function git(root, ...args) {
|
|
214
|
-
const r = spawnSync('git', ['-C', root, '-c', 'core.quotePath=false', ...args], {
|
|
214
|
+
const r = spawnSync('git', ['-C', root, '-c', 'core.quotePath=false', ...args], { windowsHide: true,
|
|
215
215
|
encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
|
216
216
|
});
|
|
217
217
|
return r.status === 0 ? (r.stdout || '') : null;
|
|
@@ -4,7 +4,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
|
4
4
|
import { resolve, join, dirname } from 'node:path';
|
|
5
5
|
import { capture, otherActiveSessions, resume } from './session-memory-core.mjs';
|
|
6
6
|
|
|
7
|
-
const LOCAL_TARGETS = new Set(['claude', 'codex', 'cursor', 'gemini', 'opencode']);
|
|
7
|
+
const LOCAL_TARGETS = new Set(['claude', 'codex', 'cursor', 'deepseek', 'gemini', 'opencode']);
|
|
8
8
|
|
|
9
9
|
function projectSettings(cwd) {
|
|
10
10
|
try {
|
|
@@ -48,7 +48,7 @@ function cleanId(value, prefix = 'session') {
|
|
|
48
48
|
|
|
49
49
|
function git(cwd, args) {
|
|
50
50
|
try {
|
|
51
|
-
return { ok: true, out: execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 2 * 1024 * 1024 }).trim() };
|
|
51
|
+
return { ok: true, out: execFileSync('git', args, { windowsHide: true, cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 2 * 1024 * 1024 }).trim() };
|
|
52
52
|
} catch {
|
|
53
53
|
return { ok: false, out: '' };
|
|
54
54
|
}
|
package/targets/ship-guard.mjs
CHANGED
|
@@ -67,7 +67,7 @@ const MERGE = /\bgit\s+merge\b/;
|
|
|
67
67
|
if (!TRUNK.test(command) && !MERGE.test(command)) allow();
|
|
68
68
|
|
|
69
69
|
const git = (...args) => {
|
|
70
|
-
const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
70
|
+
const r = spawnSync('git', ['-C', root, ...args], { windowsHide: true, encoding: 'utf8' });
|
|
71
71
|
return r.status === 0 ? (r.stdout || '').trim() : null;
|
|
72
72
|
};
|
|
73
73
|
|
|
@@ -16,7 +16,7 @@ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
|
16
16
|
import { basename, join, sep } from 'node:path';
|
|
17
17
|
|
|
18
18
|
const git = (root, args) => {
|
|
19
|
-
try { return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 8000 }).trim(); }
|
|
19
|
+
try { return execFileSync('git', args, { windowsHide: true, cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 8000 }).trim(); }
|
|
20
20
|
catch { return null; }
|
|
21
21
|
};
|
|
22
22
|
const ok = (root, args) => git(root, args) !== null;
|
package/targets/trunk-policy.mjs
CHANGED
|
@@ -28,7 +28,7 @@ const TEAM_COMMITS = 50;
|
|
|
28
28
|
|
|
29
29
|
function git(root, args) {
|
|
30
30
|
try {
|
|
31
|
-
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 }).trim();
|
|
31
|
+
return execFileSync('git', args, { windowsHide: true, cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 }).trim();
|
|
32
32
|
} catch { return null; }
|
|
33
33
|
}
|
|
34
34
|
|
package/targets/update-wiring.js
CHANGED
|
@@ -19,18 +19,19 @@ const PLUGIN_MARKER = 'rsc-update:managed';
|
|
|
19
19
|
|
|
20
20
|
const CONFIG = Object.freeze({
|
|
21
21
|
codex: '.codex/hooks.json',
|
|
22
|
+
deepseek: '.dsh/hooks.json',
|
|
22
23
|
cursor: '.cursor/hooks.json',
|
|
23
24
|
gemini: '.gemini/settings.json',
|
|
24
25
|
opencode: '.opencode/plugins/rsc-update.js',
|
|
25
26
|
});
|
|
26
27
|
|
|
27
|
-
const EVENT = Object.freeze({ codex: 'SessionStart', gemini: 'SessionStart', cursor: 'sessionStart' });
|
|
28
|
+
const EVENT = Object.freeze({ codex: 'SessionStart', deepseek: 'SessionStart', gemini: 'SessionStart', cursor: 'sessionStart' });
|
|
28
29
|
|
|
29
30
|
/** Assistants where the update runs on its own. Every other one depends on the agent running it. */
|
|
30
31
|
export const UPDATE_HOOK_TARGETS = Object.freeze(['claude', ...Object.keys(CONFIG)].sort());
|
|
31
32
|
|
|
32
33
|
const git = (cwd, args) => {
|
|
33
|
-
try { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
|
|
34
|
+
try { return execFileSync('git', args, { windowsHide: true, cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
|
|
34
35
|
};
|
|
35
36
|
const rel = (cwd, path) => relative(cwd, path).split(sep).join('/');
|
|
36
37
|
const tracked = (cwd, path) => Boolean(git(cwd, ['ls-files', '--', rel(cwd, path)]));
|
|
@@ -72,7 +72,7 @@ function git(cwd, args) {
|
|
|
72
72
|
// 64 MiB, because node's 1 MiB default kills git mid-write on any worktree with a few thousand
|
|
73
73
|
// stray files and hands back a truncated-but-successful-looking result. Measured: in the 1.0-1.15 MB
|
|
74
74
|
// band the same unchanged worktree flipped between verdicts across runs.
|
|
75
|
-
const r = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
75
|
+
const r = spawnSync('git', ['-C', cwd, ...args], { windowsHide: true, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
76
76
|
// `raw` matters for porcelain output: its leading space is a status code, not padding, and
|
|
77
77
|
// trimming it shifted every path by one character — so the message naming the file about to be
|
|
78
78
|
// lost named a file that does not exist. A refusal has to be true to be actionable (P6).
|