opencode-resolve 0.2.0 → 0.3.0
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.ko.md +29 -0
- package/README.md +29 -0
- package/dist/checkpoint.d.ts +22 -0
- package/dist/checkpoint.js +72 -0
- package/dist/config.d.ts +3 -1
- package/dist/config.js +20 -0
- package/dist/hooks/index.js +86 -38
- package/dist/messages.d.ts +1 -1
- package/dist/messages.js +20 -8
- package/dist/tools/index.js +4 -0
- package/dist/types.d.ts +9 -0
- package/dist/utils.d.ts +11 -2
- package/dist/utils.js +47 -6
- package/opencode-resolve.reference.jsonc +38 -0
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -197,6 +197,33 @@ irm https://raw.githubusercontent.com/jshsakura/awesome-opencode-skills/main/ins
|
|
|
197
197
|
| `language` | `auto` / `en` / `ko` | `auto` | 프롬프트 언어 선호. |
|
|
198
198
|
| `maxParallelSubagents` | positive integer | 미설정 | 동시 coder 디스패치에 대한 선택적 프롬프트 수준 soft limit. |
|
|
199
199
|
| `singleAgentMode` | boolean | `false` | `true`면 resolver가 `coder` 서브에이전트를 디스패치하지 않고 모든 편집을 직접 수행합니다. 단순 작업에서 지연과 토큰 비용을 절약합니다. 정보/진단용 서브에이전트(explorer/debugger)는 여전히 사용 가능합니다. |
|
|
200
|
+
| `permissions` | object | `{}` | 옵트인 롤백 권한. 아래 참조. |
|
|
201
|
+
|
|
202
|
+
### 롤백 권한
|
|
203
|
+
|
|
204
|
+
`git reset --hard` 와 `git clean -f` 는 기본적으로 차단됩니다. 커밋하지 않은 작업을 보호하지만, 동시에 디버깅 도중 작업트리가 꼬인 에이전트가 깨끗한 상태로 되돌아갈 방법이 없다는 뜻이기도 합니다 — 그대로 고립됩니다. 두 개의 옵트인 플래그가 이를 풀어주며, resolve 에이전트에만 적용됩니다.
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{
|
|
208
|
+
"permissions": {
|
|
209
|
+
"allowGitReset": true,
|
|
210
|
+
"allowGitClean": true
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
두 명령이 실행되기 전에, 플러그인은 **작업트리 전체** — 추적 중인 수정과 추적되지 않은 파일 모두 — 를 `refs/resolve-checkpoint/<timestamp>-<reset|clean>` 이라는 git ref 로 스냅샷합니다. 스냅샷은 임시 인덱스를 통해 기록되므로 실제 인덱스, 작업트리, 브랜치, `HEAD` 는 전혀 건드리지 않습니다. 스테이징도 커밋도 일어나지 않습니다. 스냅샷을 남기지 못하면 파괴적 명령은 무방비로 실행되는 대신 차단됩니다.
|
|
216
|
+
|
|
217
|
+
되돌린 것을 후회할 때 복구하려면:
|
|
218
|
+
|
|
219
|
+
```sh
|
|
220
|
+
git for-each-ref refs/resolve-checkpoint # 체크포인트 목록
|
|
221
|
+
git restore --source=<ref> -- . # 전부 복원
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`git clean -x` 와 `-X` 는 이 플래그와 무관하게 계속 차단됩니다. gitignore 대상 파일을 삭제하는데, 체크포인트는 `.gitignore` 를 존중하는 `git add -A` 로 스냅샷하기 때문입니다 — 즉 `-x` clean 은 체크포인트가 되살릴 수 없는 파일(`.env`, 로컬 시크릿)을 파괴합니다.
|
|
225
|
+
|
|
226
|
+
체크포인트는 resolve 에이전트가 해당 명령을 실행할 때마다 생성됩니다 — 두 플래그를 `false` 로 둔 채 권한 프롬프트에서 직접 승인한 경우에도 마찬가지입니다. OpenCode 기본 에이전트(`build`/`plan`/chat)는 무조건 차단이 유지되며 이 플래그의 영향을 받지 않습니다.
|
|
200
227
|
|
|
201
228
|
### 에이전트 override
|
|
202
229
|
|
|
@@ -273,6 +300,8 @@ resolve 에이전트의 bash는 기본적으로 `ask`입니다. 플러그인의
|
|
|
273
300
|
|
|
274
301
|
`autoApprove`는 오래된 설정과의 호환을 위해 허용되지만, 현재 동작은 명시적 에이전트 권한과 명령 분류기가 제어합니다.
|
|
275
302
|
|
|
303
|
+
`git reset --hard` 와 `git clean -f` 는 옵트인하지 않으면 차단됩니다 — [롤백 권한](#롤백-권한) 참조. 허용된 경우 플러그인이 먼저 작업트리를 체크포인트합니다.
|
|
304
|
+
|
|
276
305
|
신뢰할 수 없는 저장소에서는 샌드박스나 VM을 사용하세요.
|
|
277
306
|
|
|
278
307
|
## 병렬 서브에이전트
|
package/README.md
CHANGED
|
@@ -197,6 +197,33 @@ Full commented reference: [opencode-resolve.reference.jsonc](./opencode-resolve.
|
|
|
197
197
|
| `language` | `auto` / `en` / `ko` | `auto` | Prompt language preference. |
|
|
198
198
|
| `maxParallelSubagents` | positive integer | unset | Optional prompt-level soft limit for concurrent coder dispatch. |
|
|
199
199
|
| `singleAgentMode` | boolean | `false` | When `true`, the resolver makes all edits directly instead of dispatching a `coder` subagent — lower latency and token cost on simple tasks. Information/diagnostic subagents (explorer/debugger) are still available. |
|
|
200
|
+
| `permissions` | object | `{}` | Opt-in rollback permissions. See below. |
|
|
201
|
+
|
|
202
|
+
### Rollback Permissions
|
|
203
|
+
|
|
204
|
+
`git reset --hard` and `git clean -f` are denied by default. That protects your uncommitted work, but it also means an agent that tangles the worktree mid-debug cannot get back to a clean state — it stalls. Two opt-in flags un-gate them, for resolve agents only:
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{
|
|
208
|
+
"permissions": {
|
|
209
|
+
"allowGitReset": true,
|
|
210
|
+
"allowGitClean": true
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Before either command runs, the plugin snapshots the **entire worktree** — tracked edits *and* untracked files — into a git ref named `refs/resolve-checkpoint/<timestamp>-<reset|clean>`. The snapshot is written through a throwaway index, so your real index, working tree, branches, and `HEAD` are never touched: nothing is staged, nothing is committed. If the snapshot cannot be written, the destructive command is blocked instead of run unprotected.
|
|
216
|
+
|
|
217
|
+
To recover from a rollback you regret:
|
|
218
|
+
|
|
219
|
+
```sh
|
|
220
|
+
git for-each-ref refs/resolve-checkpoint # list checkpoints
|
|
221
|
+
git restore --source=<ref> -- . # bring everything back
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`git clean -x` and `-X` stay denied regardless of these flags. They delete gitignored files, and the checkpoint snapshots via `git add -A`, which honours `.gitignore` — so a `-x` clean would destroy files (`.env`, local secrets) the checkpoint cannot bring back.
|
|
225
|
+
|
|
226
|
+
Checkpoints are taken whenever one of these commands executes under a resolve agent — including when you approve it yourself at the permission prompt with both flags left `false`. Native OpenCode agents (`build`/`plan`/chat) keep the unconditional deny and are unaffected by these flags.
|
|
200
227
|
|
|
201
228
|
### Agent Overrides
|
|
202
229
|
|
|
@@ -273,6 +300,8 @@ Resolve agents keep bash at `ask` by default. The plugin's permission hook auto-
|
|
|
273
300
|
|
|
274
301
|
`autoApprove` is accepted for compatibility with older configs, but current behavior is controlled by explicit agent permissions and the command classifier.
|
|
275
302
|
|
|
303
|
+
`git reset --hard` and `git clean -f` are denied unless you opt in — see [Rollback Permissions](#rollback-permissions). When permitted, the plugin checkpoints the worktree first.
|
|
304
|
+
|
|
276
305
|
Use a sandbox or VM for untrusted repositories.
|
|
277
306
|
|
|
278
307
|
## Parallel Subagents
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RollbackKind } from "./types.js";
|
|
2
|
+
/** Namespace for checkpoint refs. Outside refs/heads, so it never shows up as a branch. */
|
|
3
|
+
export declare const CHECKPOINT_REF_NAMESPACE = "refs/resolve-checkpoint";
|
|
4
|
+
export type Checkpoint = {
|
|
5
|
+
/** Full ref name, e.g. `refs/resolve-checkpoint/1730000000000-reset`. */
|
|
6
|
+
ref: string;
|
|
7
|
+
/** Copy-pasteable command that puts the worktree back the way it was. */
|
|
8
|
+
restoreCommand: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function isGitRepository(directory: string): Promise<boolean>;
|
|
11
|
+
/**
|
|
12
|
+
* Snapshot the worktree into `refs/resolve-checkpoint/<timestamp>-<kind>`.
|
|
13
|
+
* Throws with the underlying git error if the snapshot cannot be written — the
|
|
14
|
+
* caller must abort the destructive command rather than run it unprotected.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createRollbackCheckpoint(directory: string, kind: RollbackKind): Promise<Checkpoint>;
|
|
17
|
+
/**
|
|
18
|
+
* Restores every path recorded in the checkpoint, including files a later
|
|
19
|
+
* `git clean` deleted. Files created after the checkpoint are left alone —
|
|
20
|
+
* recovery is additive, never destructive.
|
|
21
|
+
*/
|
|
22
|
+
export declare function restoreCommandFor(ref: string): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Rollback checkpoints.
|
|
2
|
+
//
|
|
3
|
+
// `git reset --hard` and `git clean -f` are the two recovery commands an agent
|
|
4
|
+
// needs to escape a tangled worktree, and they are also the two that can destroy
|
|
5
|
+
// uncommitted work. Before either runs, we snapshot the entire worktree —
|
|
6
|
+
// tracked modifications *and* untracked files — into a git ref that nothing else
|
|
7
|
+
// points at.
|
|
8
|
+
//
|
|
9
|
+
// The snapshot uses a throwaway index (`GIT_INDEX_FILE`) so the real index and
|
|
10
|
+
// the working tree are never touched: add -A → write-tree → commit-tree →
|
|
11
|
+
// update-ref. Nothing is staged, nothing is committed to any branch, no HEAD
|
|
12
|
+
// move. The ref keeps the objects alive against `git gc`.
|
|
13
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { runCommand } from "./utils.js";
|
|
17
|
+
/** Namespace for checkpoint refs. Outside refs/heads, so it never shows up as a branch. */
|
|
18
|
+
export const CHECKPOINT_REF_NAMESPACE = "refs/resolve-checkpoint";
|
|
19
|
+
const GIT_TIMEOUT_MS = 15_000;
|
|
20
|
+
export async function isGitRepository(directory) {
|
|
21
|
+
const { exitCode } = await runCommand("git rev-parse --is-inside-work-tree", directory, GIT_TIMEOUT_MS);
|
|
22
|
+
return exitCode === 0;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Snapshot the worktree into `refs/resolve-checkpoint/<timestamp>-<kind>`.
|
|
26
|
+
* Throws with the underlying git error if the snapshot cannot be written — the
|
|
27
|
+
* caller must abort the destructive command rather than run it unprotected.
|
|
28
|
+
*/
|
|
29
|
+
export async function createRollbackCheckpoint(directory, kind) {
|
|
30
|
+
const ref = `${CHECKPOINT_REF_NAMESPACE}/${Date.now()}-${kind}`;
|
|
31
|
+
const indexDirectory = await mkdtemp(join(tmpdir(), "opencode-resolve-ckpt-"));
|
|
32
|
+
const indexFile = join(indexDirectory, "index");
|
|
33
|
+
// One shell invocation: a failure anywhere aborts (set -e) and we surface it.
|
|
34
|
+
// `git add -A` against an empty throwaway index stages the whole worktree,
|
|
35
|
+
// honouring .gitignore. `commit-tree` parents on HEAD when a HEAD exists (a
|
|
36
|
+
// freshly-initialised repo has none).
|
|
37
|
+
const script = [
|
|
38
|
+
"set -e",
|
|
39
|
+
`export GIT_INDEX_FILE=${JSON.stringify(indexFile)}`,
|
|
40
|
+
// commit-tree refuses to run without a committer identity; a repo with no
|
|
41
|
+
// user.email configured must still be able to checkpoint.
|
|
42
|
+
"export GIT_AUTHOR_NAME=opencode-resolve GIT_AUTHOR_EMAIL=checkpoint@opencode-resolve.local",
|
|
43
|
+
"export GIT_COMMITTER_NAME=opencode-resolve GIT_COMMITTER_EMAIL=checkpoint@opencode-resolve.local",
|
|
44
|
+
"git add -A",
|
|
45
|
+
"tree=$(git write-tree)",
|
|
46
|
+
`message=${JSON.stringify(`opencode-resolve checkpoint before git ${kind}`)}`,
|
|
47
|
+
'if git rev-parse -q --verify HEAD >/dev/null 2>&1; then',
|
|
48
|
+
' commit=$(git commit-tree "$tree" -p HEAD -m "$message")',
|
|
49
|
+
"else",
|
|
50
|
+
' commit=$(git commit-tree "$tree" -m "$message")',
|
|
51
|
+
"fi",
|
|
52
|
+
`git update-ref ${JSON.stringify(ref)} "$commit"`,
|
|
53
|
+
].join("\n");
|
|
54
|
+
try {
|
|
55
|
+
const { stderr, exitCode } = await runCommand(script, directory, GIT_TIMEOUT_MS);
|
|
56
|
+
if (exitCode !== 0) {
|
|
57
|
+
throw new Error(`checkpoint failed (git exit ${exitCode}): ${stderr.trim() || "no stderr"}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await rm(indexDirectory, { recursive: true, force: true }).catch(() => { });
|
|
62
|
+
}
|
|
63
|
+
return { ref, restoreCommand: restoreCommandFor(ref) };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Restores every path recorded in the checkpoint, including files a later
|
|
67
|
+
* `git clean` deleted. Files created after the checkpoint are left alone —
|
|
68
|
+
* recovery is additive, never destructive.
|
|
69
|
+
*/
|
|
70
|
+
export function restoreCommandFor(ref) {
|
|
71
|
+
return `git restore --source=${ref} -- .`;
|
|
72
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Config } from "@opencode-ai/plugin";
|
|
2
|
-
import { ResolveConfig, ProjectContext, ResolveAgentName, UnknownRecord, ResolvePluginOptions, ResolveAgentConfig, PermissionValue } from "./types.js";
|
|
2
|
+
import { ResolveConfig, ProjectContext, ResolveAgentName, UnknownRecord, ResolvePluginOptions, ResolveAgentConfig, PermissionValue, ResolvePermissions } from "./types.js";
|
|
3
3
|
export declare function applyResolveConfig(config: Config, resolveConfig: ResolveConfig, projectContext: ProjectContext): void;
|
|
4
4
|
export declare function buildContextInjection(ctx: ProjectContext): string;
|
|
5
5
|
export declare function defaultResolveConfig(): ResolveConfig;
|
|
@@ -11,6 +11,7 @@ export declare function getPluginOptions(config: Config): unknown;
|
|
|
11
11
|
export declare function isResolvePluginEntry(entry: string): boolean;
|
|
12
12
|
export declare function resolvePath(path: string, directory: string): string;
|
|
13
13
|
export declare function normalizeResolveConfig(value: unknown, source: string): ResolvePluginOptions;
|
|
14
|
+
export declare function normalizePermissions(value: unknown, source: string): ResolvePermissions;
|
|
14
15
|
export declare function normalizeAgentConfig(value: unknown, source: string): ResolveAgentConfig;
|
|
15
16
|
export declare function normalizeTools(value: unknown, source: string): Record<string, boolean>;
|
|
16
17
|
export declare function normalizePermission(value: unknown, source: string): ResolveAgentConfig["permission"];
|
|
@@ -24,6 +25,7 @@ export declare function expectNumber(value: unknown, source: string): number;
|
|
|
24
25
|
export declare function isObject(value: unknown): value is UnknownRecord;
|
|
25
26
|
export declare function loadResolveConfig(directory: string, opencodeConfig: Config, options: unknown): Promise<ResolveConfig>;
|
|
26
27
|
export declare const VALID_TOP_LEVEL_KEYS: Set<string>;
|
|
28
|
+
export declare const VALID_PERMISSIONS_KEYS: Set<string>;
|
|
27
29
|
export declare const VALID_AGENT_KEYS: Set<string>;
|
|
28
30
|
export declare const VALID_MODES: Set<string>;
|
|
29
31
|
export declare const VALID_PERMISSION_VALUES: Set<string>;
|
package/dist/config.js
CHANGED
|
@@ -94,6 +94,7 @@ export function defaultResolveConfig() {
|
|
|
94
94
|
commands: false,
|
|
95
95
|
autoApprove: true,
|
|
96
96
|
autoUpdate: true,
|
|
97
|
+
permissions: {},
|
|
97
98
|
};
|
|
98
99
|
}
|
|
99
100
|
export function mergeResolveConfig(...configs) {
|
|
@@ -110,6 +111,7 @@ export function mergeResolveConfig(...configs) {
|
|
|
110
111
|
result.autoUpdate = config.autoUpdate ?? result.autoUpdate;
|
|
111
112
|
result.language = config.language ?? result.language;
|
|
112
113
|
result.singleAgentMode = config.singleAgentMode ?? result.singleAgentMode;
|
|
114
|
+
result.permissions = { ...result.permissions, ...config.permissions };
|
|
113
115
|
result.models = { ...result.models, ...config.models };
|
|
114
116
|
result.agents = mergeAgents(result.agents, config.agents);
|
|
115
117
|
}
|
|
@@ -223,10 +225,26 @@ export function normalizeResolveConfig(value, source) {
|
|
|
223
225
|
}
|
|
224
226
|
result.maxParallelSubagents = limit;
|
|
225
227
|
}
|
|
228
|
+
if (config.permissions !== undefined)
|
|
229
|
+
result.permissions = normalizePermissions(config.permissions, `${source}.permissions`);
|
|
226
230
|
if (config.config !== undefined)
|
|
227
231
|
result.config = expectString(config.config, `${source}.config`);
|
|
228
232
|
return result;
|
|
229
233
|
}
|
|
234
|
+
export function normalizePermissions(value, source) {
|
|
235
|
+
const permissions = expectObject(value, source);
|
|
236
|
+
const result = {};
|
|
237
|
+
for (const key of Object.keys(permissions)) {
|
|
238
|
+
if (!VALID_PERMISSIONS_KEYS.has(key)) {
|
|
239
|
+
throw new Error(`Unknown permissions key "${key}" in ${source}. Valid: ${[...VALID_PERMISSIONS_KEYS].join(", ")}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (permissions.allowGitReset !== undefined)
|
|
243
|
+
result.allowGitReset = expectBoolean(permissions.allowGitReset, `${source}.allowGitReset`);
|
|
244
|
+
if (permissions.allowGitClean !== undefined)
|
|
245
|
+
result.allowGitClean = expectBoolean(permissions.allowGitClean, `${source}.allowGitClean`);
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
230
248
|
export function normalizeAgentConfig(value, source) {
|
|
231
249
|
const config = expectObject(value, source);
|
|
232
250
|
for (const key of Object.keys(config)) {
|
|
@@ -359,8 +377,10 @@ export const VALID_TOP_LEVEL_KEYS = new Set([
|
|
|
359
377
|
"autoUpdate",
|
|
360
378
|
"language",
|
|
361
379
|
"singleAgentMode",
|
|
380
|
+
"permissions",
|
|
362
381
|
"config",
|
|
363
382
|
]);
|
|
383
|
+
export const VALID_PERMISSIONS_KEYS = new Set(["allowGitReset", "allowGitClean"]);
|
|
364
384
|
export const VALID_AGENT_KEYS = new Set([
|
|
365
385
|
"enabled",
|
|
366
386
|
"model",
|
package/dist/hooks/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DIAGNOSTICS_TTL_MS, FAILURE_PATTERN_TTL_MS, FAILURE_THRESHOLD, STRATEGY_PIVOT_THRESHOLD, EDIT_HOTSPOT_THRESHOLD, DISPATCH_STOP_THRESHOLD, DISPATCH_PIVOT_THRESHOLD } from "../state.js";
|
|
2
|
-
import { classifyBashCommand, detectProjectContext } from "../utils.js";
|
|
2
|
+
import { classifyBashCommand, detectRollbackCommand, formatError, detectProjectContext } from "../utils.js";
|
|
3
|
+
import { createRollbackCheckpoint, isGitRepository } from "../checkpoint.js";
|
|
3
4
|
import { loadResolveConfig, applyResolveConfig } from "../config.js";
|
|
4
5
|
import { contextMessage, narrate, resolveLocale, t, agentDisplayName, brand, PLUGIN_BRAND } from "../messages.js";
|
|
5
6
|
import { VALID_AGENT_NAMES } from "../agents.js";
|
|
@@ -37,6 +38,43 @@ function isCodeFile(filePath) {
|
|
|
37
38
|
return true; // no extension — treat as code
|
|
38
39
|
return CODE_EXTENSIONS.includes(filePath.slice(dot + 1).toLowerCase());
|
|
39
40
|
}
|
|
41
|
+
// Harness boilerplate — not the model's words, so rewriting them wholesale
|
|
42
|
+
// costs nothing and buys a sharper instruction.
|
|
43
|
+
const HARNESS_BOILERPLATE_REWRITES = [
|
|
44
|
+
["Summarize the task tool output above and continue with your task.",
|
|
45
|
+
"Analyze the subtask result above. If it succeeded, continue. If it failed, diagnose and retry. Report completion only when verified."],
|
|
46
|
+
[/Summarize the .+ output above and continue/i,
|
|
47
|
+
"Analyze the result above. If it succeeded, continue to the next step. If it failed, diagnose root cause and retry with a fix."],
|
|
48
|
+
[/continue with your task\.$/i,
|
|
49
|
+
"continue driving toward verified completion."],
|
|
50
|
+
];
|
|
51
|
+
// Marker on every appended nudge. Also the idempotency key: a part that already
|
|
52
|
+
// carries it is never nudged again.
|
|
53
|
+
const NUDGE_MARKER = `[${PLUGIN_BRAND}] self-check:`;
|
|
54
|
+
// The model's own words — uncertainty, low confidence, retry intent. Overwriting
|
|
55
|
+
// these erases the reasoning trace the model needs to notice it is going in
|
|
56
|
+
// circles, so the original text stays and a nudge is appended after it.
|
|
57
|
+
const SELF_REVIEW_NUDGES = [
|
|
58
|
+
[/I('ve| have) (completed|finished|done) (the )?.*\.$/i,
|
|
59
|
+
"Before this counts as complete: did typecheck/lint/test actually run and pass? Verify, then report."],
|
|
60
|
+
[/I('ll| will) (try again|retry|attempt again|redo)/i,
|
|
61
|
+
"Name the ROOT CAUSE before retrying — a retry that repeats the same fix fails the same way."],
|
|
62
|
+
[/I('m| am) (not sure|unsure|uncertain) .*/i,
|
|
63
|
+
"The uncertainty is worth stating. Now settle it with evidence: read the code, check resolve-diagnostics, or search."],
|
|
64
|
+
[/this (might|should|could|may) work/i,
|
|
65
|
+
"CONFIRM it works by running verification before moving on. Do not assume."],
|
|
66
|
+
[/it (seems|appears|looks) to (be )?(working|fine|correct)/i,
|
|
67
|
+
"VERIFY with typecheck/lint/test — 'seems to work' is not evidence."],
|
|
68
|
+
];
|
|
69
|
+
// Edit-hotspot feedback. Repeatedly editing one file is often the *correct*
|
|
70
|
+
// path through a hard bug — the failure mode is not the repetition itself but
|
|
71
|
+
// re-applying the same change blind. So the nudge asks the model to inspect its
|
|
72
|
+
// own diff, never to go edit some other file (that spreads the damage).
|
|
73
|
+
function hotspotDiffPrompt(filePath, count) {
|
|
74
|
+
return `'${filePath}' has been edited ${count} times. Before the next edit, run \`git diff -- ${filePath}\` ` +
|
|
75
|
+
`and check whether recent edits repeat the same substitution or undo each other. ` +
|
|
76
|
+
`If the root cause is genuinely in this file, keep working here.`;
|
|
77
|
+
}
|
|
40
78
|
function capTemperature(current, cap) {
|
|
41
79
|
return typeof current === "number" && Number.isFinite(current)
|
|
42
80
|
? Math.min(current, cap)
|
|
@@ -262,7 +300,11 @@ export function getHooks(directory, options, sessionState) {
|
|
|
262
300
|
: Array.isArray(input.pattern)
|
|
263
301
|
? input.pattern.join(" ")
|
|
264
302
|
: "";
|
|
265
|
-
|
|
303
|
+
// `permissions.allowGitReset` / `allowGitClean` only relax the policy for
|
|
304
|
+
// resolve agents — they are the ones `tool.execute.before` checkpoints.
|
|
305
|
+
// Native agents keep the universal deny.
|
|
306
|
+
const permissions = isActiveResolve() ? sessionState.storedConfig?.permissions : undefined;
|
|
307
|
+
const action = classifyBashCommand(cmd, permissions);
|
|
266
308
|
// Banned/dangerous commands are denied universally (protect everyone).
|
|
267
309
|
if (action === "deny") {
|
|
268
310
|
output.status = "deny";
|
|
@@ -344,6 +386,30 @@ export function getHooks(directory, options, sessionState) {
|
|
|
344
386
|
if (typeof cmd === "string" && cmd.includes("git commit") && !cmd.includes("-m")) {
|
|
345
387
|
output.args = { ...output.args, _resolve_hint: "Use 'git commit -m \"message\"' — interactive commit is blocked." };
|
|
346
388
|
}
|
|
389
|
+
// Rollback safety net: snapshot the worktree before `git reset --hard` /
|
|
390
|
+
// `git clean -f` destroys uncommitted work. Runs whenever the command is
|
|
391
|
+
// about to execute — whether it was un-gated by `permissions.allow*` or
|
|
392
|
+
// approved by the user at the prompt. If the snapshot cannot be written
|
|
393
|
+
// in a git repo, abort rather than run the command unprotected.
|
|
394
|
+
if (typeof cmd === "string") {
|
|
395
|
+
const rollback = detectRollbackCommand(cmd);
|
|
396
|
+
if (rollback && await isGitRepository(directory)) {
|
|
397
|
+
let checkpoint;
|
|
398
|
+
try {
|
|
399
|
+
checkpoint = await createRollbackCheckpoint(directory, rollback);
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
throw new Error(`[${PLUGIN_BRAND}] refused to run '${cmd}': could not create a rollback checkpoint (${formatError(error)}). ` +
|
|
403
|
+
`Commit or stash your work first.`);
|
|
404
|
+
}
|
|
405
|
+
narrate(sessionState, "narration.checkpoint");
|
|
406
|
+
output.args = {
|
|
407
|
+
...output.args,
|
|
408
|
+
_resolve_checkpoint_ref: checkpoint.ref,
|
|
409
|
+
_resolve_checkpoint_restore: checkpoint.restoreCommand,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
}
|
|
347
413
|
}
|
|
348
414
|
// For write: warn about overwriting existing files
|
|
349
415
|
if (input.tool === "write" && output.args && typeof output.args === "object") {
|
|
@@ -406,7 +472,7 @@ export function getHooks(directory, options, sessionState) {
|
|
|
406
472
|
// Ralph Loop: inject loop warning into metadata
|
|
407
473
|
const hotspot = sessionState.editHotspots.get(editedPath);
|
|
408
474
|
if (hotspot && hotspot.count >= EDIT_HOTSPOT_THRESHOLD) {
|
|
409
|
-
meta._resolve_loop_warning =
|
|
475
|
+
meta._resolve_loop_warning = hotspotDiffPrompt(editedPath, hotspot.count);
|
|
410
476
|
}
|
|
411
477
|
}
|
|
412
478
|
output.metadata = meta;
|
|
@@ -423,7 +489,7 @@ export function getHooks(directory, options, sessionState) {
|
|
|
423
489
|
sessionState.loopWarnings = [];
|
|
424
490
|
for (const [file, data] of sessionState.editHotspots) {
|
|
425
491
|
if (data.count >= EDIT_HOTSPOT_THRESHOLD) {
|
|
426
|
-
sessionState.loopWarnings.push(
|
|
492
|
+
sessionState.loopWarnings.push(hotspotDiffPrompt(file, data.count));
|
|
427
493
|
}
|
|
428
494
|
}
|
|
429
495
|
}
|
|
@@ -533,47 +599,26 @@ export function getHooks(directory, options, sessionState) {
|
|
|
533
599
|
// Rewrite passive language only within resolve turns — native chat is verbatim.
|
|
534
600
|
if (!isActiveResolve())
|
|
535
601
|
return;
|
|
536
|
-
const replacements = [
|
|
537
|
-
// Exact: default OpenCode "continue" prompt
|
|
538
|
-
["Summarize the task tool output above and continue with your task.",
|
|
539
|
-
"Analyze the subtask result above. If it succeeded, continue. If it failed, diagnose and retry. Report completion only when verified."],
|
|
540
|
-
// Regex: any "Summarize ... and continue" variant
|
|
541
|
-
[/Summarize the .+ output above and continue/i,
|
|
542
|
-
"Analyze the result above. If it succeeded, continue to the next step. If it failed, diagnose root cause and retry with a fix."],
|
|
543
|
-
// Regex: generic "continue with your task" ending
|
|
544
|
-
[/continue with your task\.$/i,
|
|
545
|
-
"continue driving toward verified completion."],
|
|
546
|
-
// Regex: "I've completed..." without verification
|
|
547
|
-
[/I('ve| have) (completed|finished|done) (the )?.*\.$/i,
|
|
548
|
-
"Verify your changes pass typecheck/lint/test before reporting completion."],
|
|
549
|
-
// Ralph Loop: detect "I'll try again" — encourage different approach, don't stop
|
|
550
|
-
[/I('ll| will) (try again|retry|attempt again|redo)/i,
|
|
551
|
-
"Diagnose the ROOT CAUSE of the failure, then apply a DIFFERENT fix. The Ralph Loop keeps going."],
|
|
552
|
-
// Regex: "I'm not sure" — uncertainty without action
|
|
553
|
-
[/I('m| am) (not sure|unsure|uncertain) .*/i,
|
|
554
|
-
"Resolve uncertainty by reading the code, checking diagnostics, or using resolve-search. Keep driving."],
|
|
555
|
-
// Regex: "This might work" — low confidence
|
|
556
|
-
[/this (might|should|could|may) work/i,
|
|
557
|
-
"CONFIRM it works by running verification. Do not assume."],
|
|
558
|
-
// Regex: "It seems to be working" — unverified claim
|
|
559
|
-
[/it (seems|appears|looks) to (be )?(working|fine|correct)/i,
|
|
560
|
-
"VERIFY with typecheck/lint/test. 'Seems to work' is not evidence."],
|
|
561
|
-
];
|
|
562
602
|
for (const msg of output.messages) {
|
|
563
603
|
for (const part of msg.parts) {
|
|
564
604
|
if (part.type !== "text")
|
|
565
605
|
continue;
|
|
566
|
-
// Preserve collaborative handoffs/questions — never
|
|
567
|
-
//
|
|
568
|
-
// ("this might work", "seems fine", unverified "completed", ...).
|
|
606
|
+
// Preserve collaborative handoffs/questions — never touch a turn that
|
|
607
|
+
// asks the user something.
|
|
569
608
|
if (HANDOFF_PATTERNS.some((p) => p.test(part.text.trim())))
|
|
570
609
|
continue;
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
}
|
|
610
|
+
const boilerplate = HARNESS_BOILERPLATE_REWRITES.find(([pattern]) => typeof pattern === "string" ? part.text === pattern : pattern.test(part.text));
|
|
611
|
+
if (boilerplate) {
|
|
612
|
+
part.text = boilerplate[1];
|
|
613
|
+
continue;
|
|
576
614
|
}
|
|
615
|
+
// transform() sees the whole message list every turn, so an appended
|
|
616
|
+
// nudge would compound. One nudge per part, ever.
|
|
617
|
+
if (part.text.includes(NUDGE_MARKER))
|
|
618
|
+
continue;
|
|
619
|
+
const nudge = SELF_REVIEW_NUDGES.find(([pattern]) => pattern.test(part.text));
|
|
620
|
+
if (nudge)
|
|
621
|
+
part.text = `${part.text}\n\n${NUDGE_MARKER} ${nudge[1]}`;
|
|
577
622
|
}
|
|
578
623
|
}
|
|
579
624
|
},
|
|
@@ -631,7 +676,10 @@ export function getHooks(directory, options, sessionState) {
|
|
|
631
676
|
for (const w of sessionState.loopWarnings.slice(0, 3)) {
|
|
632
677
|
lines.push(` - ${w}`);
|
|
633
678
|
}
|
|
679
|
+
// Self-observation first — reading your own diff is the cheapest way out
|
|
680
|
+
// of a hotspot, and the only one that cannot pollute an unrelated file.
|
|
634
681
|
const strategyKeys = [
|
|
682
|
+
"strategy.reviewDiff",
|
|
635
683
|
"strategy.rereadFile",
|
|
636
684
|
"strategy.tryDifferent",
|
|
637
685
|
"strategy.useDiagnostics",
|
package/dist/messages.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export declare function resolveLocale(configured: string | undefined, envLang: s
|
|
|
6
6
|
export declare function brand(agent: string | undefined): string;
|
|
7
7
|
/** Friendly display name per agent (used in role-play narration). */
|
|
8
8
|
export declare function agentDisplayName(agent: string | undefined, locale: Locale): string;
|
|
9
|
-
export type MessageKey = "reminder.verify" | "reminder.ralphLoopText" | "system.driveResolution" | "system.projectKnowledge" | "system.contextDocs" | "system.verifyCommands" | "system.typescriptMandatory" | "system.failuresHeader" | "system.failuresFooter" | "system.strategyPivotHeader" | "system.strategyPivotBody" | "system.strategyPivotTail" | "system.ralphHeader" | "system.ralphKeepGoing" | "system.sessionStats" | "system.iterationWarning" | "system.dispatchEscalate" | "system.dispatchStop" | "system.dispatchPivot" | "system.awaitingVerify" | "compaction.contextHeader" | "tool.edit" | "tool.write" | "tool.bash" | "tool.task" | "tool.glob" | "tool.grep" | "tool.read" | "tool.webfetch" | "tool.todowrite" | "dispatch.toSubagent" | "dispatch.fromResolver" | "dispatch.coder" | "dispatch.reviewer" | "dispatch.deepReviewer" | "dispatch.explorer" | "dispatch.planner" | "dispatch.architect" | "dispatch.researcher" | "dispatch.debugger" | "dispatch.completed" | "dispatch.failed" | "narration.editing" | "narration.searching" | "narration.reading" | "narration.thinking" | "narration.bashing" | "narration.compacting" | "narration.writing" | "narration.testing" | "narration.typechecking" | "narration.linting" | "narration.git" | "narration.fetch" | "narration.todo" | "narration.diagnostics" | "narration.context" | "narration.verifyPass" | "narration.verifyFail" | "narration.idle" | "strategy.smallerPieces" | "strategy.differentFile" | "strategy.readTest" | "strategy.checkImports" | "strategy.searchSimilar" | "strategy.rereadFile" | "strategy.tryDifferent" | "strategy.useDiagnostics" | "strategy.suggestionLabel";
|
|
9
|
+
export type MessageKey = "reminder.verify" | "reminder.ralphLoopText" | "system.driveResolution" | "system.projectKnowledge" | "system.contextDocs" | "system.verifyCommands" | "system.typescriptMandatory" | "system.failuresHeader" | "system.failuresFooter" | "system.strategyPivotHeader" | "system.strategyPivotBody" | "system.strategyPivotTail" | "system.ralphHeader" | "system.ralphKeepGoing" | "system.sessionStats" | "system.iterationWarning" | "system.dispatchEscalate" | "system.dispatchStop" | "system.dispatchPivot" | "system.awaitingVerify" | "compaction.contextHeader" | "tool.edit" | "tool.write" | "tool.bash" | "tool.task" | "tool.glob" | "tool.grep" | "tool.read" | "tool.webfetch" | "tool.todowrite" | "dispatch.toSubagent" | "dispatch.fromResolver" | "dispatch.coder" | "dispatch.reviewer" | "dispatch.deepReviewer" | "dispatch.explorer" | "dispatch.planner" | "dispatch.architect" | "dispatch.researcher" | "dispatch.debugger" | "dispatch.completed" | "dispatch.failed" | "narration.editing" | "narration.searching" | "narration.reading" | "narration.thinking" | "narration.bashing" | "narration.compacting" | "narration.writing" | "narration.testing" | "narration.typechecking" | "narration.linting" | "narration.git" | "narration.checkpoint" | "narration.fetch" | "narration.todo" | "narration.diagnostics" | "narration.context" | "narration.verifyPass" | "narration.verifyFail" | "narration.idle" | "strategy.smallerPieces" | "strategy.differentFile" | "strategy.readTest" | "strategy.checkImports" | "strategy.searchSimilar" | "strategy.rereadFile" | "strategy.tryDifferent" | "strategy.useDiagnostics" | "strategy.reviewDiff" | "strategy.suggestionLabel";
|
|
10
10
|
type Params = Record<string, string | number>;
|
|
11
11
|
/** Render a message in the requested locale. Picks a random variant if multiple are defined. */
|
|
12
12
|
export declare function t(key: MessageKey, locale: Locale, params?: Params): string;
|
package/dist/messages.js
CHANGED
|
@@ -71,8 +71,8 @@ const MESSAGES = {
|
|
|
71
71
|
"system.strategyPivotHeader": ({ count }) => `🔀 STRATEGY PIVOT: ${count} total failures detected.`,
|
|
72
72
|
"system.strategyPivotBody": "The current approach is not working. Dispatch ARCHITECT to analyze the problem from scratch and propose a fundamentally different strategy.",
|
|
73
73
|
"system.strategyPivotTail": "Then apply the new strategy. Do NOT keep retrying the same approach.",
|
|
74
|
-
"system.ralphHeader": "🔄 Ralph Loop: heavy editing detected on same file(s):",
|
|
75
|
-
"system.ralphKeepGoing": "
|
|
74
|
+
"system.ralphHeader": "🔄 Ralph Loop: heavy editing detected on same file(s). Repetition is not itself the problem — repeating the *same* edit is:",
|
|
75
|
+
"system.ralphKeepGoing": "Read the diff before the next edit. If the root cause really is in this file, stay here — moving to an unrelated file to break the count only spreads the damage. Keep driving until verified.",
|
|
76
76
|
"system.sessionStats": ({ edits, calls, elapsed }) => `📊 Session stats: ${edits} edits, ${calls} tool calls, ${elapsed}s elapsed.`,
|
|
77
77
|
"system.iterationWarning": "Significant iteration with failures. Consider a fundamentally different approach — but keep going.",
|
|
78
78
|
"system.dispatchEscalate": ({ count, agent }) => `🔄 Ralph Loop: ${agent ?? "subagent"} dispatch failed ${count} time(s) in a row. Do NOT retry the same way. Diagnose the ROOT CAUSE first (read the error, use resolve-diagnostics), then re-dispatch with a precise fix.`,
|
|
@@ -345,6 +345,11 @@ const MESSAGES = {
|
|
|
345
345
|
"🌳 inspecting the diff",
|
|
346
346
|
"🌳 listening to git",
|
|
347
347
|
],
|
|
348
|
+
"narration.checkpoint": [
|
|
349
|
+
"🛟 snapshotting the worktree before rollback",
|
|
350
|
+
"🛟 saving a checkpoint you can restore from",
|
|
351
|
+
"🛟 stashing a safety copy first",
|
|
352
|
+
],
|
|
348
353
|
"narration.fetch": [
|
|
349
354
|
"🌐 fetching from the web",
|
|
350
355
|
"🌐 grabbing the docs",
|
|
@@ -402,13 +407,14 @@ const MESSAGES = {
|
|
|
402
407
|
"🪑 brief regroup",
|
|
403
408
|
],
|
|
404
409
|
"strategy.smallerPieces": "Break the problem into smaller pieces. Edit one function at a time, verify between each.",
|
|
405
|
-
"strategy.differentFile": "
|
|
410
|
+
"strategy.differentFile": "Follow the evidence: if diagnostics point at a different file, the real cause may be upstream. Confirm that before editing anything else.",
|
|
406
411
|
"strategy.readTest": "Read the test file if it exists — the test often reveals the expected behavior.",
|
|
407
412
|
"strategy.checkImports": "Check imports — missing or wrong imports are a common cause of cascading errors.",
|
|
408
413
|
"strategy.searchSimilar": "Use resolve-search to find similar patterns elsewhere in the codebase.",
|
|
409
414
|
"strategy.rereadFile": "Re-read the file carefully. You may be missing existing code that conflicts with your edit.",
|
|
410
|
-
"strategy.tryDifferent": "
|
|
415
|
+
"strategy.tryDifferent": "The fix, not the file, is what should change. Try a different fix for the same root cause.",
|
|
411
416
|
"strategy.useDiagnostics": "Use resolve-diagnostics to check current LSP errors before the next edit.",
|
|
417
|
+
"strategy.reviewDiff": "Run `git diff` on the hotspot file. Look for edits that repeat the same substitution or cancel each other out.",
|
|
412
418
|
"strategy.suggestionLabel": "Strategy suggestion",
|
|
413
419
|
},
|
|
414
420
|
ko: {
|
|
@@ -435,8 +441,8 @@ const MESSAGES = {
|
|
|
435
441
|
"system.strategyPivotHeader": ({ count }) => `🔀 전략 전환: 총 ${count}회의 실패가 감지됐어요.`,
|
|
436
442
|
"system.strategyPivotBody": "지금 접근은 통하지 않아요. ARCHITECT 를 위임해서 문제를 처음부터 분석하고 근본적으로 다른 전략을 제안받으세요.",
|
|
437
443
|
"system.strategyPivotTail": "그 다음 새 전략을 적용하세요. 같은 접근을 다시 시도하지 마세요.",
|
|
438
|
-
"system.ralphHeader": "🔄 Ralph Loop: 같은 파일을
|
|
439
|
-
"system.ralphKeepGoing": "계속
|
|
444
|
+
"system.ralphHeader": "🔄 Ralph Loop: 같은 파일을 반복 수정 중이에요. 반복 자체가 문제가 아니라, *같은 수정* 을 반복하는 게 문제입니다:",
|
|
445
|
+
"system.ralphKeepGoing": "다음 편집 전에 diff 를 읽으세요. 근본 원인이 정말 이 파일에 있다면 계속 여기서 작업하세요 — 횟수를 피하려고 무관한 파일로 옮겨가면 오염만 번집니다. 검증될 때까지 계속.",
|
|
440
446
|
"system.sessionStats": ({ edits, calls, elapsed }) => `📊 세션 통계: ${edits}회 편집, ${calls}회 도구 호출, ${elapsed}초 경과.`,
|
|
441
447
|
"system.iterationWarning": "실패와 함께 반복이 많아졌어요. 근본적으로 다른 접근을 고려하세요 — 하지만 멈추지는 마세요.",
|
|
442
448
|
"system.dispatchEscalate": ({ count, agent }) => `🔄 Ralph Loop: 서브에이전트(${agent ?? "subagent"})가 ${count}회 연속 실패했습니다. 같은 방식으로 재시도 금지. 먼저 루트 코즈를 진단하고, 정확한 수정으로 다시 위임하세요.`,
|
|
@@ -722,6 +728,11 @@ const MESSAGES = {
|
|
|
722
728
|
"🌳 diff 점검 중",
|
|
723
729
|
"🌳 git 말 듣는 중",
|
|
724
730
|
],
|
|
731
|
+
"narration.checkpoint": [
|
|
732
|
+
"🛟 롤백 전에 작업트리 스냅샷 뜨는 중",
|
|
733
|
+
"🛟 복구용 체크포인트 저장 중",
|
|
734
|
+
"🛟 만약을 위해 안전 사본 남기는 중",
|
|
735
|
+
],
|
|
725
736
|
"narration.fetch": [
|
|
726
737
|
"🌐 웹에서 가져오는 중",
|
|
727
738
|
"🌐 문서 끌어오는 중",
|
|
@@ -779,13 +790,14 @@ const MESSAGES = {
|
|
|
779
790
|
"🪑 잠깐 정비",
|
|
780
791
|
],
|
|
781
792
|
"strategy.smallerPieces": "문제를 더 작은 단위로 쪼개세요. 한 번에 함수 하나씩 편집하고, 각 사이에 검증하세요.",
|
|
782
|
-
"strategy.differentFile": "
|
|
793
|
+
"strategy.differentFile": "증거를 따라가세요: 진단이 다른 파일을 가리킨다면 진짜 원인은 상류일 수 있습니다. 다른 파일을 건드리기 전에 먼저 확인하세요.",
|
|
783
794
|
"strategy.readTest": "테스트 파일이 있으면 먼저 읽으세요 — 기대 동작이 거기에 드러나 있을 때가 많습니다.",
|
|
784
795
|
"strategy.checkImports": "import 를 확인하세요 — 누락되거나 잘못된 import 가 연쇄 에러의 흔한 원인입니다.",
|
|
785
796
|
"strategy.searchSimilar": "resolve-search 로 코드베이스 내 유사 패턴을 찾으세요.",
|
|
786
797
|
"strategy.rereadFile": "파일을 다시 차분히 읽으세요. 편집과 충돌하는 기존 코드를 놓쳤을 수 있습니다.",
|
|
787
|
-
"strategy.tryDifferent": "
|
|
798
|
+
"strategy.tryDifferent": "바꿔야 할 건 파일이 아니라 해법입니다. 같은 근본 원인에 대해 다른 수정을 시도하세요.",
|
|
788
799
|
"strategy.useDiagnostics": "다음 편집 전에 resolve-diagnostics 로 현재 LSP 에러를 확인하세요.",
|
|
800
|
+
"strategy.reviewDiff": "핫스팟 파일에 `git diff` 를 실행하세요. 같은 치환을 반복했거나 서로 상쇄되는 편집이 있는지 확인하세요.",
|
|
789
801
|
"strategy.suggestionLabel": "전략 제안",
|
|
790
802
|
},
|
|
791
803
|
};
|
package/dist/tools/index.js
CHANGED
|
@@ -15,6 +15,10 @@ function readOnlyToolWriteDenied(ctx, action) {
|
|
|
15
15
|
return `Permission denied: agent '${ctx.agent ?? "unknown"}' is read-only and cannot ${action}. Dispatch resolver/coder for workspace writes.`;
|
|
16
16
|
}
|
|
17
17
|
function commandExecutionDenied(command) {
|
|
18
|
+
// Deliberately called without `permissions` — this path executes directly and
|
|
19
|
+
// never reaches `tool.execute.before`, so a rollback here would run without a
|
|
20
|
+
// checkpoint. `permissions.allowGitReset`/`allowGitClean` only relax the
|
|
21
|
+
// OpenCode bash tool, which is checkpointed.
|
|
18
22
|
const action = classifyBashCommand(command);
|
|
19
23
|
if (action === "allow")
|
|
20
24
|
return undefined;
|
package/dist/types.d.ts
CHANGED
|
@@ -21,6 +21,14 @@ export type ResolveAgentConfig = {
|
|
|
21
21
|
};
|
|
22
22
|
export type TierName = "bronze" | "silver" | "gold";
|
|
23
23
|
export type LanguageSetting = "auto" | "en" | "ko";
|
|
24
|
+
/** Opt-in relaxations of the universal bash safety policy. Off by default. */
|
|
25
|
+
export type ResolvePermissions = {
|
|
26
|
+
/** Let resolve agents run `git reset --hard` (a checkpoint ref is written first). */
|
|
27
|
+
allowGitReset?: boolean;
|
|
28
|
+
/** Let resolve agents run `git clean -f...` (a checkpoint ref is written first). */
|
|
29
|
+
allowGitClean?: boolean;
|
|
30
|
+
};
|
|
31
|
+
export type RollbackKind = "reset" | "clean";
|
|
24
32
|
export type ResolveConfig = {
|
|
25
33
|
tier?: TierName;
|
|
26
34
|
enabled?: ResolveAgentName[];
|
|
@@ -33,6 +41,7 @@ export type ResolveConfig = {
|
|
|
33
41
|
autoUpdate?: boolean;
|
|
34
42
|
language?: LanguageSetting;
|
|
35
43
|
singleAgentMode?: boolean;
|
|
44
|
+
permissions?: ResolvePermissions;
|
|
36
45
|
};
|
|
37
46
|
export type ResolvePluginOptions = ResolveConfig & {
|
|
38
47
|
config?: string;
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ProjectContext, ResolveConfig } from "./types.js";
|
|
1
|
+
import { ProjectContext, ResolveConfig, ResolvePermissions, RollbackKind } from "./types.js";
|
|
2
2
|
export declare const PLUGIN_VERSION: string;
|
|
3
3
|
export declare function runCommand(command: string, cwd: string, timeoutMs: number): Promise<{
|
|
4
4
|
stdout: string;
|
|
@@ -10,7 +10,13 @@ export declare function truncateOutput(text: string, maxLen: number): string;
|
|
|
10
10
|
export declare function sanitizeShellArg(input: string): string;
|
|
11
11
|
export declare function isMissingFileError(error: unknown): boolean;
|
|
12
12
|
export declare function formatError(error: unknown): string;
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Which rollback command, if any, this bash line runs. Both are destructive and
|
|
15
|
+
* denied by default; `permissions.allowGitReset` / `allowGitClean` un-gate them,
|
|
16
|
+
* and the caller writes a checkpoint ref before execution.
|
|
17
|
+
*/
|
|
18
|
+
export declare function detectRollbackCommand(pattern: string): RollbackKind | undefined;
|
|
19
|
+
export declare function classifyBashCommand(pattern: string, permissions?: ResolvePermissions): "allow" | "deny" | "ask";
|
|
14
20
|
export declare function existsFile(path: string): Promise<boolean>;
|
|
15
21
|
export declare function existsPath(path: string): Promise<boolean>;
|
|
16
22
|
export declare function existsDirectory(path: string): Promise<boolean>;
|
|
@@ -18,6 +24,9 @@ export declare function detectProjectContext(directory: string): Promise<Project
|
|
|
18
24
|
export declare function collectContextFiles(rootDirectory: string, relativeDirectory: string, maxFiles?: number): Promise<string[]>;
|
|
19
25
|
export declare function readPluginVersion(): string;
|
|
20
26
|
export declare function readFirstJson(paths: string[]): Promise<ResolveConfig | undefined>;
|
|
27
|
+
export declare const GIT_HARD_RESET_PATTERN: RegExp;
|
|
28
|
+
export declare const GIT_FORCE_CLEAN_PATTERN: RegExp;
|
|
29
|
+
export declare const GIT_CLEAN_IGNORED_PATTERN: RegExp;
|
|
21
30
|
export declare const BANNED_COMMANDS: ReadonlyArray<RegExp>;
|
|
22
31
|
export declare const DANGEROUS_BASH_PATTERNS: ReadonlyArray<RegExp>;
|
|
23
32
|
export declare const ALWAYS_SAFE_COMMANDS: ReadonlyArray<string>;
|
package/dist/utils.js
CHANGED
|
@@ -45,16 +45,45 @@ export function isMissingFileError(error) {
|
|
|
45
45
|
export function formatError(error) {
|
|
46
46
|
return error instanceof Error ? error.message : String(error);
|
|
47
47
|
}
|
|
48
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Which rollback command, if any, this bash line runs. Both are destructive and
|
|
50
|
+
* denied by default; `permissions.allowGitReset` / `allowGitClean` un-gate them,
|
|
51
|
+
* and the caller writes a checkpoint ref before execution.
|
|
52
|
+
*/
|
|
53
|
+
export function detectRollbackCommand(pattern) {
|
|
49
54
|
const cmd = pattern.trim();
|
|
55
|
+
if (GIT_HARD_RESET_PATTERN.test(cmd))
|
|
56
|
+
return "reset";
|
|
57
|
+
if (GIT_FORCE_CLEAN_PATTERN.test(cmd))
|
|
58
|
+
return "clean";
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
function isRollbackPermitted(kind, permissions) {
|
|
62
|
+
if (kind === "reset")
|
|
63
|
+
return permissions?.allowGitReset === true;
|
|
64
|
+
return permissions?.allowGitClean === true;
|
|
65
|
+
}
|
|
66
|
+
export function classifyBashCommand(pattern, permissions) {
|
|
67
|
+
const cmd = pattern.trim();
|
|
68
|
+
// A permitted rollback stops being a deny-pattern, but every *other* deny
|
|
69
|
+
// pattern in the same command line still applies — `git reset --hard && rm -rf /`
|
|
70
|
+
// must not slip through on the strength of the exemption.
|
|
71
|
+
const exempt = new Set();
|
|
72
|
+
if (permissions?.allowGitReset)
|
|
73
|
+
exempt.add(GIT_HARD_RESET_PATTERN);
|
|
74
|
+
if (permissions?.allowGitClean)
|
|
75
|
+
exempt.add(GIT_FORCE_CLEAN_PATTERN);
|
|
50
76
|
for (const re of BANNED_COMMANDS) {
|
|
51
|
-
if (re.test(cmd))
|
|
77
|
+
if (!exempt.has(re) && re.test(cmd))
|
|
52
78
|
return "deny";
|
|
53
79
|
}
|
|
54
80
|
for (const re of DANGEROUS_BASH_PATTERNS) {
|
|
55
|
-
if (re.test(cmd))
|
|
81
|
+
if (!exempt.has(re) && re.test(cmd))
|
|
56
82
|
return "deny";
|
|
57
83
|
}
|
|
84
|
+
const rollback = detectRollbackCommand(cmd);
|
|
85
|
+
if (rollback && isRollbackPermitted(rollback, permissions))
|
|
86
|
+
return "allow";
|
|
58
87
|
const firstToken = cmd.split(/\s+/)[0];
|
|
59
88
|
if (ALWAYS_SAFE_COMMANDS.includes(firstToken))
|
|
60
89
|
return "allow";
|
|
@@ -235,6 +264,17 @@ export async function readFirstJson(paths) {
|
|
|
235
264
|
}
|
|
236
265
|
return undefined;
|
|
237
266
|
}
|
|
267
|
+
// The two destructive-but-recoverable rollback commands. Shared regex identities
|
|
268
|
+
// so `classifyBashCommand` can exempt them from the deny lists by reference when
|
|
269
|
+
// the matching `permissions.allow*` flag is set.
|
|
270
|
+
export const GIT_HARD_RESET_PATTERN = /\bgit\s+reset\s+--hard/;
|
|
271
|
+
export const GIT_FORCE_CLEAN_PATTERN = /\bgit\s+clean\s+-[a-zA-Z]*f/;
|
|
272
|
+
// `git clean -x` also deletes gitignored files. Checkpoints snapshot via
|
|
273
|
+
// `git add -A`, which honours .gitignore — so a `-x` clean destroys files
|
|
274
|
+
// (.env, local secrets) the checkpoint cannot restore. Never exempted.
|
|
275
|
+
// Matches `-x` in any flag position (`-xdf`, `-f -x`, `-Xf`), but does not
|
|
276
|
+
// reach past a command separator into the next command.
|
|
277
|
+
export const GIT_CLEAN_IGNORED_PATTERN = /\bgit\s+clean\b[^;&|]*\s-[a-zA-Z]*[xX]/;
|
|
238
278
|
export const BANNED_COMMANDS = [
|
|
239
279
|
/\b(vim?|nano|emacs|pico|ed)\b/, // interactive editors
|
|
240
280
|
/\b(less|more|most|pg)\b/, // pagers
|
|
@@ -264,7 +304,7 @@ export const BANNED_COMMANDS = [
|
|
|
264
304
|
/\bchown\s+-R\s+/, // recursive chown
|
|
265
305
|
/\bsudo\s+(rm|chmod|chown|dd|mkfs)/, // sudo + destructive
|
|
266
306
|
/\bgit\s+push\s+--force/, // force push
|
|
267
|
-
|
|
307
|
+
GIT_HARD_RESET_PATTERN, // hard reset (gated by permissions.allowGitReset)
|
|
268
308
|
/\brm\s+(-rf?|-fr?)\s+[^.]/, // rm -rf (not dotfiles)
|
|
269
309
|
/\bdd\s+if=/, // dd can destroy disks
|
|
270
310
|
/\b(mkfs|format)\b/, // filesystem format
|
|
@@ -272,8 +312,9 @@ export const BANNED_COMMANDS = [
|
|
|
272
312
|
export const DANGEROUS_BASH_PATTERNS = [
|
|
273
313
|
/\brm\s+.*-[rR].*[fF].*\s+\//, // rm -rf /... (absolute path)
|
|
274
314
|
/\bgit\s+push\s+.*(--force|-f\b)/, // force push
|
|
275
|
-
|
|
276
|
-
|
|
315
|
+
GIT_HARD_RESET_PATTERN, // hard reset (gated by permissions.allowGitReset)
|
|
316
|
+
GIT_FORCE_CLEAN_PATTERN, // clean untracked files (gated by permissions.allowGitClean)
|
|
317
|
+
GIT_CLEAN_IGNORED_PATTERN, // clean gitignored files — unrecoverable, never exempted
|
|
277
318
|
/\bsudo\s+rm\b/, // sudo rm
|
|
278
319
|
/\bdd\s+.*of=\/dev\//, // dd to device
|
|
279
320
|
/\bchmod\s+-R\s+777\s+\//, // chmod everything
|
|
@@ -106,6 +106,44 @@
|
|
|
106
106
|
// -----------------------------------------------------------------------
|
|
107
107
|
// "singleAgentMode": false,
|
|
108
108
|
|
|
109
|
+
// -----------------------------------------------------------------------
|
|
110
|
+
// permissions (object)
|
|
111
|
+
// Opt-in relaxations of the universal bash safety policy. Both default to
|
|
112
|
+
// false, and both apply ONLY to opencode-resolve's own agents — native
|
|
113
|
+
// opencode agents (build/plan/chat) keep the unconditional deny.
|
|
114
|
+
//
|
|
115
|
+
// allowGitReset — permits `git reset --hard`
|
|
116
|
+
// allowGitClean — permits `git clean -f` (and -fd, -df, ...)
|
|
117
|
+
//
|
|
118
|
+
// `git clean -x` / `-X` stays denied no matter what: it deletes gitignored
|
|
119
|
+
// files, and the checkpoint below snapshots via `git add -A`, which honours
|
|
120
|
+
// .gitignore. A `-x` clean would destroy files (.env, local secrets) the
|
|
121
|
+
// checkpoint cannot bring back.
|
|
122
|
+
//
|
|
123
|
+
// Without these, an agent that tangles the worktree mid-debug has no way to
|
|
124
|
+
// return to a clean state and stalls. With them, the agent can recover.
|
|
125
|
+
//
|
|
126
|
+
// Before either command runs, the plugin snapshots the ENTIRE worktree —
|
|
127
|
+
// tracked edits and untracked files — into a git ref:
|
|
128
|
+
//
|
|
129
|
+
// refs/resolve-checkpoint/<timestamp>-<reset|clean>
|
|
130
|
+
//
|
|
131
|
+
// The snapshot uses a throwaway index, so your real index, working tree,
|
|
132
|
+
// branches and HEAD are never touched. Nothing is staged or committed.
|
|
133
|
+
// If the snapshot cannot be written, the destructive command is blocked.
|
|
134
|
+
//
|
|
135
|
+
// To recover after a rollback you regret:
|
|
136
|
+
// git for-each-ref refs/resolve-checkpoint # list checkpoints
|
|
137
|
+
// git restore --source=<ref> -- . # bring everything back
|
|
138
|
+
//
|
|
139
|
+
// Checkpoints are also taken when you approve one of these commands at the
|
|
140
|
+
// permission prompt, even with both flags left false.
|
|
141
|
+
// -----------------------------------------------------------------------
|
|
142
|
+
// "permissions": {
|
|
143
|
+
// "allowGitReset": true,
|
|
144
|
+
// "allowGitClean": true
|
|
145
|
+
// },
|
|
146
|
+
|
|
109
147
|
// -----------------------------------------------------------------------
|
|
110
148
|
// models (object)
|
|
111
149
|
// Alias map. Keys are either an agent name (coder, reviewer, resolver,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-resolve",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "OpenCode plugin that adds a lightweight resolver/coder harness for continuous agentic coding.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://jshsakura.github.io/opencode-resolve/",
|