@mortz.prk/opencode-auto-mode 0.1.3 → 1.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 +151 -107
- package/package.json +7 -6
- package/src/audit.ts +61 -0
- package/src/classifier.ts +18 -29
- package/src/config.ts +76 -59
- package/src/decisions.ts +213 -0
- package/src/index.ts +67 -24
- package/src/otlp.ts +74 -0
- package/src/policy.ts +4 -10
- package/src/reviewer.ts +8 -0
- package/src/shell.ts +311 -13
- package/src/state.ts +75 -0
- package/src/tiers.ts +3 -1
- package/src/traces.ts +84 -0
- package/src/transcript.ts +150 -13
- package/src/tui-state.ts +7 -0
- package/src/tui.tsx +30 -302
- package/src/providers.ts +0 -103
- package/src/rules-worker.ts +0 -38
- package/src/rules.ts +0 -79
package/README.md
CHANGED
|
@@ -1,162 +1,206 @@
|
|
|
1
1
|
# OpenCode Auto Mode
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Reviews OpenCode permission prompts automatically. OpenCode decides what needs
|
|
4
|
+
approval; the plugin reviews only `ask` actions.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
## Install and configure
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
Add this to `~/.config/opencode/opencode.jsonc`. It asks for permission on every
|
|
9
|
+
action and reviews those requests using built-in checks. No model or API key is
|
|
10
|
+
needed:
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
```jsonc
|
|
13
|
+
{
|
|
14
|
+
"$schema": "https://opencode.ai/config.json",
|
|
15
|
+
"permissions": [
|
|
16
|
+
{ "action": "*", "resource": "*", "effect": "ask" }
|
|
17
|
+
],
|
|
18
|
+
"plugins": [
|
|
19
|
+
{
|
|
20
|
+
"package": "@mortz.prk/opencode-auto-mode",
|
|
21
|
+
"options": {
|
|
22
|
+
"classifiers": [
|
|
23
|
+
{ "type": "static" }
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
```
|
|
12
30
|
|
|
13
|
-
|
|
31
|
+
Restart OpenCode after saving:
|
|
14
32
|
|
|
15
33
|
```sh
|
|
16
34
|
opencode service restart
|
|
17
35
|
```
|
|
18
36
|
|
|
19
|
-
|
|
37
|
+
There is no default review pipeline, and empty options are invalid. Add at least
|
|
38
|
+
one review entry. Built-in checks deny dangerous shell commands, allow common
|
|
39
|
+
read-only commands, and send other parsed commands to the configured reviewers.
|
|
40
|
+
Dynamic syntax such as command substitution, nested shells, and stateful builtins
|
|
41
|
+
still requires manual approval.
|
|
20
42
|
|
|
21
|
-
|
|
43
|
+
The snippets below change parts of that configuration; keep your `classifiers`
|
|
44
|
+
list when adding other options.
|
|
22
45
|
|
|
23
|
-
|
|
46
|
+
## Add a typed decision model
|
|
24
47
|
|
|
25
|
-
```
|
|
48
|
+
```jsonc
|
|
26
49
|
{
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
]
|
|
50
|
+
"options": {
|
|
51
|
+
"classifiers": [
|
|
52
|
+
{ "type": "static" },
|
|
53
|
+
{ "type": "decision", "model": "typesafe-ai/jev-latest" }
|
|
54
|
+
]
|
|
55
|
+
}
|
|
35
56
|
}
|
|
36
57
|
```
|
|
37
58
|
|
|
38
|
-
|
|
59
|
+
| Provider | Setup |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| `typesafe-ai` | Set `TYPESAFE_API_KEY`. |
|
|
62
|
+
| `openai` | Set `OPENAI_API_KEY`. |
|
|
63
|
+
| `laya` | Run a loopback `/v1/systemone` service. `LAYA_API_KEY` is optional. |
|
|
64
|
+
|
|
65
|
+
Decision entries accept `minConfidence` (default `0.9`), `timeoutMs`, and
|
|
66
|
+
`apiKeyEnv`. `laya` also accepts `endpoint`.
|
|
39
67
|
|
|
40
|
-
|
|
68
|
+
## Add an LLM
|
|
41
69
|
|
|
42
|
-
|
|
70
|
+
Add an `llm` entry after the built-in checks:
|
|
43
71
|
|
|
44
|
-
```
|
|
72
|
+
```jsonc
|
|
45
73
|
{
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"stages": [
|
|
53
|
-
{ "type": "static" },
|
|
54
|
-
{
|
|
55
|
-
"type": "rules",
|
|
56
|
-
"rules": [
|
|
57
|
-
{
|
|
58
|
-
"expression": "action == 'shell' && command == 'git' && args == ['status', '--short']",
|
|
59
|
-
"decision": "allow"
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
"expression": "action == 'shell' && command == 'curl'",
|
|
63
|
-
"decision": "deny"
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
"expression": "action in ['edit', 'write']",
|
|
67
|
-
"decision": "ask"
|
|
68
|
-
}
|
|
69
|
-
]
|
|
70
|
-
}
|
|
71
|
-
]
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
]
|
|
74
|
+
"options": {
|
|
75
|
+
"classifiers": [
|
|
76
|
+
{ "type": "static" },
|
|
77
|
+
{ "type": "llm" }
|
|
78
|
+
]
|
|
79
|
+
}
|
|
76
80
|
}
|
|
77
81
|
```
|
|
78
82
|
|
|
79
|
-
|
|
83
|
+
When an `llm` entry has no `model`, the **session model** is used. To choose a
|
|
84
|
+
different model:
|
|
80
85
|
|
|
81
|
-
|
|
86
|
+
```jsonc
|
|
87
|
+
{ "type": "llm", "model": "anthropic/claude-sonnet-4-5" }
|
|
88
|
+
```
|
|
82
89
|
|
|
83
|
-
|
|
90
|
+
## Combine reviewers
|
|
84
91
|
|
|
85
|
-
|
|
92
|
+
Entries run in order. Add as many as you need:
|
|
86
93
|
|
|
87
|
-
```
|
|
94
|
+
```jsonc
|
|
88
95
|
{
|
|
89
|
-
"
|
|
90
|
-
|
|
91
|
-
"
|
|
92
|
-
"
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
{ "type": "static" },
|
|
97
|
-
{ "type": "jev", "minConfidence": 0.9 },
|
|
98
|
-
{ "type": "openai-decision", "minConfidence": 0.9 },
|
|
99
|
-
{ "type": "laya", "endpoint": "http://127.0.0.1:8000/v1/systemone" },
|
|
100
|
-
{ "type": "llm" }
|
|
101
|
-
]
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
]
|
|
96
|
+
"options": {
|
|
97
|
+
"classifiers": [
|
|
98
|
+
{ "type": "static" },
|
|
99
|
+
{ "type": "decision", "model": "typesafe-ai/jev-latest" },
|
|
100
|
+
{ "type": "llm" }
|
|
101
|
+
]
|
|
102
|
+
}
|
|
106
103
|
}
|
|
107
104
|
```
|
|
108
105
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
| `jev` | Set `TYPESAFE_API_KEY`; default model is `jev-latest`. |
|
|
112
|
-
| `openai-decision` | Set `OPENAI_API_KEY`; default model is `gpt-6-luna`. |
|
|
113
|
-
| `laya` | Run a compatible local `/v1/systemone` service; optionally set `LAYA_API_KEY`. Only loopback endpoints are accepted. |
|
|
114
|
-
| `llm` | Uses OpenCode's default model, or the configured reviewer model. |
|
|
115
|
-
|
|
116
|
-
A valid provider allow/deny result below `minConfidence` continues to the next stage. An explicit ask, provider error, timeout, or invalid response requires manual approval and stops the pipeline. Export API keys in the environment of the OpenCode service. Provider stages also accept `model`, `apiKeyEnv`, and `timeoutMs`.
|
|
106
|
+
An entry can allow, deny, or ask, which stops the pipeline. If it abstains or
|
|
107
|
+
fails, the next entry runs. If nothing decides, the action remains `ask`.
|
|
117
108
|
|
|
118
|
-
##
|
|
109
|
+
## Use it for specific agents
|
|
119
110
|
|
|
120
|
-
|
|
111
|
+
OpenCode allows everything by default. To review only the `build` agent, set its
|
|
112
|
+
permissions and leave global permissions unchanged:
|
|
121
113
|
|
|
122
|
-
```
|
|
114
|
+
```jsonc
|
|
123
115
|
{
|
|
124
116
|
"agents": {
|
|
125
|
-
"
|
|
126
|
-
"
|
|
127
|
-
|
|
117
|
+
"build": {
|
|
118
|
+
"permissions": [
|
|
119
|
+
{ "action": "*", "resource": "*", "effect": "ask" }
|
|
120
|
+
]
|
|
128
121
|
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## TUI indicator
|
|
127
|
+
|
|
128
|
+
A passive indicator shows when the plugin is reviewing the current session.
|
|
129
|
+
It is enabled by default. Set `tui: false` in plugin options to hide it.
|
|
130
|
+
Invalid options show `auto-mode: invalid config` in the same place and an error
|
|
131
|
+
toast at startup. OpenCode's own permission dialog handles manual approvals.
|
|
132
|
+
|
|
133
|
+
## Review options
|
|
134
|
+
|
|
135
|
+
Each LLM or typed decision reviewer can have its own policy:
|
|
136
|
+
|
|
137
|
+
```jsonc
|
|
138
|
+
{
|
|
139
|
+
"options": {
|
|
140
|
+
"classifiers": [
|
|
141
|
+
{
|
|
142
|
+
"type": "llm",
|
|
143
|
+
"policy": {
|
|
144
|
+
"trusted": ["the repository and its CI configuration"],
|
|
145
|
+
"blocks": ["publish packages", "change production infrastructure"],
|
|
146
|
+
"exceptions": ["run the repository's test and lint scripts"]
|
|
147
|
+
}
|
|
136
148
|
}
|
|
137
|
-
|
|
138
|
-
|
|
149
|
+
]
|
|
150
|
+
}
|
|
139
151
|
}
|
|
140
152
|
```
|
|
141
153
|
|
|
142
|
-
|
|
154
|
+
`trusted` describes the trusted boundary, `blocks` describes actions to deny, and
|
|
155
|
+
`exceptions` describes narrow acceptable cases. Omitted lists use the built-in
|
|
156
|
+
policy; an empty list clears that list. Policies are independent between
|
|
157
|
+
reviewers and do not apply to static checks or replace OpenCode permissions.
|
|
143
158
|
|
|
144
|
-
|
|
159
|
+
## Auditing
|
|
145
160
|
|
|
146
|
-
|
|
161
|
+
Enable the audit log in plugin options:
|
|
147
162
|
|
|
148
|
-
|
|
163
|
+
```jsonc
|
|
164
|
+
{
|
|
165
|
+
"options": {
|
|
166
|
+
"auditLog": true
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
149
170
|
|
|
150
|
-
|
|
171
|
+
Decisions are appended as JSON lines to
|
|
172
|
+
`$XDG_STATE_HOME/opencode-auto-mode/decisions.jsonl`. Replay recorded tool calls
|
|
173
|
+
without executing anything:
|
|
151
174
|
|
|
152
175
|
```sh
|
|
153
|
-
|
|
176
|
+
bun scripts/replay.ts [--days 14] [--session ID] [--agent build|any] [--json]
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
To send decisions through OpenCode's OTLP telemetry, configure OTLP for OpenCode
|
|
180
|
+
with its standard environment variables, then enable the plugin flag. This sends
|
|
181
|
+
correlated decision logs and review traces, including model reviewer attempts:
|
|
182
|
+
|
|
183
|
+
```jsonc
|
|
184
|
+
{
|
|
185
|
+
"options": {
|
|
186
|
+
"otlp": true
|
|
187
|
+
}
|
|
188
|
+
}
|
|
154
189
|
```
|
|
155
190
|
|
|
156
|
-
|
|
191
|
+
## Development
|
|
157
192
|
|
|
158
|
-
|
|
193
|
+
```sh
|
|
194
|
+
bun install
|
|
195
|
+
bun test
|
|
196
|
+
bun run lint
|
|
197
|
+
bun run typecheck
|
|
198
|
+
bun run test:integration
|
|
199
|
+
```
|
|
159
200
|
|
|
160
201
|
## Credit
|
|
161
202
|
|
|
162
|
-
Created originally by [Andrew Mellen (mellena1)](https://github.com/mellena1) in
|
|
203
|
+
Created originally by [Andrew Mellen (mellena1)](https://github.com/mellena1) in
|
|
204
|
+
[mellena1/opencode-auto-mode](https://github.com/mellena1/opencode-auto-mode),
|
|
205
|
+
also credited as the author in [package.json](package.json). This independent
|
|
206
|
+
project retains the original [MIT license](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mortz.prk/opencode-auto-mode",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Configurable automatic permission review for OpenCode v2.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Andrew Mellen",
|
|
@@ -36,9 +36,10 @@
|
|
|
36
36
|
"test:integration": "bun run integration/opencode.ts"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@
|
|
40
|
-
"@bufbuild/protobuf": "2.16.0",
|
|
39
|
+
"@opencode/ai": "2.0.30",
|
|
41
40
|
"@opencode/plugin": "^2.0.14",
|
|
41
|
+
"@opencode/schema": "^2.0.30",
|
|
42
|
+
"effect": "4.0.1",
|
|
42
43
|
"shell-quote": "1.12.0"
|
|
43
44
|
},
|
|
44
45
|
"peerDependencies": {
|
|
@@ -49,9 +50,9 @@
|
|
|
49
50
|
"devDependencies": {
|
|
50
51
|
"@eslint/js": "^10.0.1",
|
|
51
52
|
"@opencode/theme": "^2.0.14",
|
|
52
|
-
"@opentui/core": "0.
|
|
53
|
-
"@opentui/keymap": "^0.
|
|
54
|
-
"@opentui/solid": "0.
|
|
53
|
+
"@opentui/core": "0.6.6",
|
|
54
|
+
"@opentui/keymap": "^0.6.6",
|
|
55
|
+
"@opentui/solid": "0.6.6",
|
|
55
56
|
"@types/node": "^26.6.4",
|
|
56
57
|
"@types/shell-quote": "1.7.5",
|
|
57
58
|
"@typescript/native": "npm:typescript@^7.0.2",
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface AuditRecord {
|
|
6
|
+
ts: string;
|
|
7
|
+
reviewID: string;
|
|
8
|
+
sessionID: string;
|
|
9
|
+
agent?: string;
|
|
10
|
+
action: string;
|
|
11
|
+
resources: string[];
|
|
12
|
+
hostEffect: string;
|
|
13
|
+
decision: string;
|
|
14
|
+
stage: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
durationMs: number;
|
|
17
|
+
error?: boolean;
|
|
18
|
+
errorDetail?: string;
|
|
19
|
+
traceID?: string;
|
|
20
|
+
spanID?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AuditLog { write(record: AuditRecord): void }
|
|
24
|
+
|
|
25
|
+
const MAX_BYTES = 10 * 1024 * 1024;
|
|
26
|
+
const MAX_RESOURCES = 16;
|
|
27
|
+
const MAX_RESOURCE_CHARS = 512;
|
|
28
|
+
const MAX_REASON_CHARS = 512;
|
|
29
|
+
|
|
30
|
+
export function defaultAuditPath(): string {
|
|
31
|
+
const base = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
|
|
32
|
+
return path.join(base, "opencode-auto-mode", "decisions.jsonl");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function clip(value: string, max: number): string {
|
|
36
|
+
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createAuditLog(setting: boolean | string | undefined): AuditLog | undefined {
|
|
40
|
+
if (setting === undefined || setting === false) return undefined;
|
|
41
|
+
const file = typeof setting === "string" ? setting : defaultAuditPath();
|
|
42
|
+
return {
|
|
43
|
+
write(record) {
|
|
44
|
+
let fd: number | undefined;
|
|
45
|
+
try {
|
|
46
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
47
|
+
try { if (fs.statSync(file).size > MAX_BYTES) fs.renameSync(file, `${file}.1`); } catch { /* no file yet */ }
|
|
48
|
+
fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW, 0o600);
|
|
49
|
+
const safe: AuditRecord = {
|
|
50
|
+
...record,
|
|
51
|
+
resources: record.resources.slice(0, MAX_RESOURCES).map((resource) => clip(resource, MAX_RESOURCE_CHARS)),
|
|
52
|
+
reason: clip(record.reason, MAX_REASON_CHARS),
|
|
53
|
+
...(record.errorDetail === undefined ? {} : { errorDetail: clip(record.errorDetail, MAX_REASON_CHARS) }),
|
|
54
|
+
};
|
|
55
|
+
fs.writeSync(fd, `${JSON.stringify(safe)}\n`);
|
|
56
|
+
} catch {
|
|
57
|
+
// Auditing is best effort and must not interrupt permission review.
|
|
58
|
+
} finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
package/src/classifier.ts
CHANGED
|
@@ -1,53 +1,42 @@
|
|
|
1
1
|
import { classify } from "./tiers.js";
|
|
2
2
|
import type { ClassifierStage } from "./config.js";
|
|
3
|
-
import type { PermissionRequest, ReviewResult } from "./reviewer.js";
|
|
4
|
-
import { evaluateRules, type RuleMatch } from "./rules.js";
|
|
3
|
+
import type { PermissionRequest, ReviewResult, StageResult } from "./reviewer.js";
|
|
5
4
|
import { guardShell } from "./shell.js";
|
|
6
5
|
|
|
7
|
-
function ruleDecision(matches: RuleMatch[]): ReviewResult | undefined {
|
|
8
|
-
for (const decision of ["deny", "ask"] as const) {
|
|
9
|
-
if (matches.some((match) => match.rule.decision === decision && match.any))
|
|
10
|
-
return { decision, reason: `user rule: ${decision}` };
|
|
11
|
-
}
|
|
12
|
-
if (matches[0]?.coverage.length && matches[0].coverage.every((_, index) =>
|
|
13
|
-
matches.some((match) => match.rule.decision === "allow" && match.coverage[index])))
|
|
14
|
-
return { decision: "allow", reason: "all resources match CEL allow rules" };
|
|
15
|
-
return undefined;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
6
|
export async function compositeDecision(
|
|
19
7
|
stages: ClassifierStage[],
|
|
20
8
|
request: PermissionRequest,
|
|
21
|
-
review: (stage: Exclude<ClassifierStage, { type: "static" }
|
|
9
|
+
review: (stage: Exclude<ClassifierStage, { type: "static" }>) => Promise<StageResult>,
|
|
22
10
|
): Promise<ReviewResult> {
|
|
23
11
|
const shellResult = request.action === "shell" ? guardShell(request.resources) : undefined;
|
|
24
|
-
if (shellResult?.decision === "deny") return shellResult;
|
|
12
|
+
if (shellResult?.decision === "deny") return { ...shellResult, stage: "shell-guard" };
|
|
25
13
|
for (const stage of stages) {
|
|
26
14
|
if (stage.type !== "static") continue;
|
|
27
15
|
const result = classify(request.action, request.resources);
|
|
28
|
-
if (result.tier === "auto-deny") return { decision: "deny", reason: result.reason };
|
|
16
|
+
if (result.tier === "auto-deny") return { decision: "deny", reason: result.reason, stage: "static" };
|
|
29
17
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (result) return result;
|
|
18
|
+
if (shellResult) {
|
|
19
|
+
// Parsed read-only results feed the static stage, but never bypass a model-only pipeline.
|
|
20
|
+
const hasStatic = stages.some((stage) => stage.type === "static");
|
|
21
|
+
if (shellResult.decision !== "allow" || hasStatic)
|
|
22
|
+
return { ...shellResult, stage: shellResult.decision === "allow" ? "static" : "shell-guard" };
|
|
36
23
|
}
|
|
37
|
-
|
|
24
|
+
let abstained: string | undefined;
|
|
38
25
|
for (const stage of stages) {
|
|
39
26
|
if (stage.type === "static") {
|
|
40
27
|
const result = classify(request.action, request.resources, {
|
|
41
28
|
projectDir: request.projectDir, allowInProjectEdits: stage.allowInProjectEdits,
|
|
42
29
|
});
|
|
43
|
-
if (result.tier === "auto-allow") return { decision: "allow", reason: result.reason };
|
|
44
|
-
} else if (stage.type === "rules") {
|
|
45
|
-
const result = ruleDecision(matches.filter((match) => stage.rules.includes(match.rule)));
|
|
46
|
-
if (result) return result;
|
|
30
|
+
if (result.tier === "auto-allow") return { decision: "allow", reason: result.reason, stage: "static" };
|
|
47
31
|
} else {
|
|
48
32
|
const result = await review(stage);
|
|
49
|
-
if (result)
|
|
33
|
+
if (result.decision === "abstain") { abstained = result.reason; continue; }
|
|
34
|
+
return { decision: result.decision, reason: result.reason, stage: result.stage ?? stage.type };
|
|
50
35
|
}
|
|
51
36
|
}
|
|
52
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
decision: "ask",
|
|
39
|
+
reason: abstained ? `No classifier made a decision (${abstained})` : "No classifier made a decision",
|
|
40
|
+
stage: "none",
|
|
41
|
+
};
|
|
53
42
|
}
|