dsh-codex-approval 0.3.0 → 0.4.2
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/LICENSE +21 -21
- package/README.md +251 -172
- package/client-card-style.js +79 -0
- package/client-model-picker.js +122 -0
- package/client-remote.js +39 -0
- package/cordis.patch.yml +4 -4
- package/enrich.js +89 -68
- package/i18n.js +144 -140
- package/index.js +794 -479
- package/judge.js +198 -164
- package/lib/client.js +429 -0
- package/modes.js +62 -62
- package/package.json +76 -42
- package/rules.js +90 -90
- package/scripts/build-client.mjs +48 -0
- package/transcript.js +234 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-codex-approval — client-model-picker.js
|
|
3
|
+
*
|
|
4
|
+
* Pure, JSX-free helpers for the settings card's judge-model picker: turn the
|
|
5
|
+
* host's model catalog into selectable options, order them so dead providers
|
|
6
|
+
* sink to the bottom, and validate the fallback chain before it is saved.
|
|
7
|
+
*
|
|
8
|
+
* Kept out of the .tsx so it can be unit-tested with `node --test` (the card
|
|
9
|
+
* itself needs a browser React runtime; this module needs nothing).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Upper bound on the fallback chain — must match index.js `MAX_FALLBACKS`. */
|
|
13
|
+
export const MAX_FALLBACKS = 4;
|
|
14
|
+
|
|
15
|
+
/** Stable key for one provider/model pair (NUL-joined, matching the card's values). */
|
|
16
|
+
export function optionKey(provider, model) {
|
|
17
|
+
return `${provider}\u0000${model}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The selectable judge models for a catalog snapshot.
|
|
22
|
+
*
|
|
23
|
+
* A provider is unavailable when its catalog lookup failed (`failures`) or the
|
|
24
|
+
* host does not list it as currently routable. Unavailable providers stay
|
|
25
|
+
* selectable — they are marked and sorted last, never hidden, so a route that
|
|
26
|
+
* is merely cooling down can still be picked deliberately.
|
|
27
|
+
*
|
|
28
|
+
* @param catalog - host `session.modelCatalog()` value, or null while loading
|
|
29
|
+
* @param fallbackModels - static list used when the catalog is empty/unavailable
|
|
30
|
+
* @returns Array<{ provider, model, label, available, note? }>
|
|
31
|
+
*/
|
|
32
|
+
export function buildModelOptions(catalog, fallbackModels = []) {
|
|
33
|
+
const groups = Array.isArray(catalog?.groups) ? catalog.groups : [];
|
|
34
|
+
const failures = new Map((Array.isArray(catalog?.failures) ? catalog.failures : []).map((failure) => [failure.id, failure.message]));
|
|
35
|
+
// The field is authoritative when present — an empty array means no provider
|
|
36
|
+
// can serve right now. A missing/`undefined` field states nothing, so no
|
|
37
|
+
// option is penalized for it.
|
|
38
|
+
const routable = Array.isArray(catalog?.routableProviders) ? new Set(catalog.routableProviders) : undefined;
|
|
39
|
+
const options = [];
|
|
40
|
+
for (const group of groups) {
|
|
41
|
+
if (group === null || typeof group !== "object") continue;
|
|
42
|
+
const failure = failures.get(group.id);
|
|
43
|
+
const available = failure === undefined && (routable === undefined || routable.has(group.id));
|
|
44
|
+
for (const model of Array.isArray(group.models) ? group.models : []) {
|
|
45
|
+
if (model === null || typeof model !== "object") continue;
|
|
46
|
+
options.push({
|
|
47
|
+
provider: group.id,
|
|
48
|
+
model: model.id,
|
|
49
|
+
label: `${group.name ?? group.id} / ${model.name ?? model.id}`,
|
|
50
|
+
available,
|
|
51
|
+
...failure === undefined ? {} : { note: failure }
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (options.length === 0) {
|
|
56
|
+
for (const item of fallbackModels) {
|
|
57
|
+
options.push({ provider: item.provider, model: item.model, label: `${item.provider} / ${item.model}`, available: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return options;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Available options first, unavailable ones last; catalog order preserved within each part. */
|
|
64
|
+
export function splitByAvailability(options) {
|
|
65
|
+
return {
|
|
66
|
+
available: options.filter((option) => option.available),
|
|
67
|
+
unavailable: options.filter((option) => !option.available)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The option matching a provider/model pair, when the catalog has it. */
|
|
72
|
+
export function findOption(options, provider, model) {
|
|
73
|
+
if (provider === undefined || model === undefined) return undefined;
|
|
74
|
+
return options.find((option) => option.provider === provider && option.model === model);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The fallback chain as stored in settings, tolerating hand-edited values:
|
|
79
|
+
* malformed entries are dropped and the list is capped.
|
|
80
|
+
*/
|
|
81
|
+
export function readChain(settings, max = MAX_FALLBACKS) {
|
|
82
|
+
if (!Array.isArray(settings)) return [];
|
|
83
|
+
const chain = [];
|
|
84
|
+
for (const entry of settings) {
|
|
85
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
86
|
+
if (typeof entry.provider !== "string" || entry.provider === "") continue;
|
|
87
|
+
if (typeof entry.model !== "string" || entry.model === "") continue;
|
|
88
|
+
chain.push({ provider: entry.provider, model: entry.model });
|
|
89
|
+
if (chain.length === max) break;
|
|
90
|
+
}
|
|
91
|
+
return chain;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Why the chain cannot be saved, or "" when it can. The primary model is part
|
|
96
|
+
* of the duplicate check because an entry equal to it would never be reached.
|
|
97
|
+
*/
|
|
98
|
+
export function validateChain(chain, primary) {
|
|
99
|
+
if (chain.length > MAX_FALLBACKS) return `兜底最多 ${MAX_FALLBACKS} 项`;
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
if (primary?.provider !== undefined && primary?.model !== undefined) seen.add(optionKey(primary.provider, primary.model));
|
|
102
|
+
for (const entry of chain) {
|
|
103
|
+
if (typeof entry?.provider !== "string" || entry.provider === "" || typeof entry?.model !== "string" || entry.model === "") {
|
|
104
|
+
return "每个兜底条目都要选 provider 与 model";
|
|
105
|
+
}
|
|
106
|
+
const key = optionKey(entry.provider, entry.model);
|
|
107
|
+
if (seen.has(key)) return `重复的候选:${entry.provider} / ${entry.model}`;
|
|
108
|
+
seen.add(key);
|
|
109
|
+
}
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The effective judge order: primary first, then the chain (primary dropped). */
|
|
114
|
+
export function buildChainSummary(primary, chain) {
|
|
115
|
+
const parts = [];
|
|
116
|
+
if (primary?.provider !== undefined && primary?.model !== undefined) parts.push(`${primary.provider} / ${primary.model}`);
|
|
117
|
+
for (const entry of chain) {
|
|
118
|
+
const label = `${entry.provider} / ${entry.model}`;
|
|
119
|
+
if (!parts.includes(label)) parts.push(label);
|
|
120
|
+
}
|
|
121
|
+
return parts;
|
|
122
|
+
}
|
package/client-remote.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-codex-approval — client-remote.js
|
|
3
|
+
*
|
|
4
|
+
* Resolve the host model-catalog call against the *owning plugin's* fiber.
|
|
5
|
+
*
|
|
6
|
+
* The client runtime hands out services through a cordis context proxy: a
|
|
7
|
+
* property that is not declared in the plugin's `inject` throws
|
|
8
|
+
* `cannot get property "remote.session" without inject`. The catalog lives on
|
|
9
|
+
* the dotted service `remote.session`, and slot rendering happens in the tab's
|
|
10
|
+
* fiber — not ours — so the card must never touch that proxy. This helper is
|
|
11
|
+
* called once inside our own `ctx.inject` callback and returns either a plain
|
|
12
|
+
* bound function or a safe no-op.
|
|
13
|
+
*
|
|
14
|
+
* Plain JS (no JSX/TS) so `node --test` can cover it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param scope - the injected client context (declares `remote.session`)
|
|
19
|
+
* @returns a bound `modelCatalog()` call, or undefined when the service is absent
|
|
20
|
+
*/
|
|
21
|
+
export function resolveModelCatalogLoader(scope) {
|
|
22
|
+
const candidates = [
|
|
23
|
+
() => scope?.remote?.session,
|
|
24
|
+
() => scope?.["remote.session"]
|
|
25
|
+
];
|
|
26
|
+
for (const read of candidates) {
|
|
27
|
+
let namespace;
|
|
28
|
+
try {
|
|
29
|
+
namespace = read();
|
|
30
|
+
} catch {
|
|
31
|
+
// The proxy refuses undeclared services; try the next spelling.
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (namespace !== undefined && namespace !== null && typeof namespace.modelCatalog === "function") {
|
|
35
|
+
return () => namespace.modelCatalog();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
package/cordis.patch.yml
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# dsh-codex-approval bundle layer: insert the
|
|
2
|
-
- insert:
|
|
3
|
-
- id: dsh-codex-approval
|
|
4
|
-
name: dsh-codex-approval
|
|
1
|
+
# dsh-codex-approval bundle layer: insert the host + browser plugin row.
|
|
2
|
+
- insert:
|
|
3
|
+
- id: dsh-codex-approval
|
|
4
|
+
name: dsh-codex-approval
|
package/enrich.js
CHANGED
|
@@ -1,68 +1,89 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-codex-approval — enrich.js
|
|
3
|
-
*
|
|
4
|
-
* Best-effort recovery of the full tool-call arguments behind an approval
|
|
5
|
-
* request. The approval seam hands answerers only `{ toolName, callId,
|
|
6
|
-
* reason }`, but the session log's latest `assistant/message` contains the
|
|
7
|
-
* complete `tool-call` content part (id, name, arguments JSON) — so by
|
|
8
|
-
* `callId` we can recover e.g. the exact bash command that triggered a
|
|
9
|
-
* sandbox escalation, which is what rule matching and the AI judge see.
|
|
10
|
-
*
|
|
11
|
-
* Everything here is defensive: any shape drift or missing data returns
|
|
12
|
-
* null / a degraded preview, never throws.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Find the parsed tool-call arguments for a callId in a session event list.
|
|
17
|
-
* @param events -
|
|
18
|
-
* @param callId - the approval request's callId
|
|
19
|
-
* @returns the parsed arguments object, or null when unrecoverable.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-codex-approval — enrich.js
|
|
3
|
+
*
|
|
4
|
+
* Best-effort recovery of the full tool-call arguments behind an approval
|
|
5
|
+
* request. The approval seam hands answerers only `{ toolName, callId,
|
|
6
|
+
* reason }`, but the session log's latest `assistant/message` contains the
|
|
7
|
+
* complete `tool-call` content part (id, name, arguments JSON) — so by
|
|
8
|
+
* `callId` we can recover e.g. the exact bash command that triggered a
|
|
9
|
+
* sandbox escalation, which is what rule matching and the AI judge see.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is defensive: any shape drift or missing data returns
|
|
12
|
+
* null / a degraded preview, never throws.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Find the parsed tool-call arguments for a callId in a session event list.
|
|
17
|
+
* @param events - a Session-like object (`snapshotEvents()`/`ownEvents()`) or any event array
|
|
18
|
+
* @param callId - the approval request's callId
|
|
19
|
+
* @returns the parsed arguments object, or null when unrecoverable.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Read session events across the legacy and current DSH Session APIs.
|
|
23
|
+
* @param sessionOrEvents - a Session-like object or an event array
|
|
24
|
+
* @returns an event array; never throws
|
|
25
|
+
*/
|
|
26
|
+
export function getSessionEvents(sessionOrEvents) {
|
|
27
|
+
try {
|
|
28
|
+
if (Array.isArray(sessionOrEvents)) return sessionOrEvents;
|
|
29
|
+
const candidate = typeof sessionOrEvents?.snapshotEvents === "function"
|
|
30
|
+
? sessionOrEvents.snapshotEvents()
|
|
31
|
+
: typeof sessionOrEvents?.ownEvents === "function"
|
|
32
|
+
? sessionOrEvents.ownEvents()
|
|
33
|
+
: sessionOrEvents?.events;
|
|
34
|
+
return Array.isArray(candidate) ? candidate : [];
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function findToolCallArgs(events, callId) {
|
|
41
|
+
if (callId === undefined || callId === null) return null;
|
|
42
|
+
const list = getSessionEvents(events);
|
|
43
|
+
if (list.length === 0) return null;
|
|
44
|
+
for (let i = list.length - 1; i >= 0; i -= 1) {
|
|
45
|
+
const event = list[i];
|
|
46
|
+
if (event === null || typeof event !== "object" || event.type !== "assistant/message") continue;
|
|
47
|
+
const content = event.data?.message?.content;
|
|
48
|
+
if (!Array.isArray(content)) continue;
|
|
49
|
+
for (let j = content.length - 1; j >= 0; j -= 1) {
|
|
50
|
+
const part = content[j];
|
|
51
|
+
if (part === null || typeof part !== "object" || part.type !== "tool-call") continue;
|
|
52
|
+
if (part.id !== callId) continue;
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(part.arguments ?? "null");
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the Codex-style args preview used for rule matching and the AI
|
|
65
|
+
* prompt: the raw command for bash/pwsh, compact JSON otherwise.
|
|
66
|
+
* @param args - parsed tool arguments (or null)
|
|
67
|
+
* @param toolName - the tool that was called
|
|
68
|
+
* @param maxChars - preview length cap
|
|
69
|
+
*/
|
|
70
|
+
export function argsPreview(args, toolName, maxChars) {
|
|
71
|
+
let preview;
|
|
72
|
+
if (args !== null && typeof args === "object") {
|
|
73
|
+
if ((toolName === "bash" || toolName === "pwsh") && typeof args.command === "string") {
|
|
74
|
+
preview = args.command;
|
|
75
|
+
} else {
|
|
76
|
+
try {
|
|
77
|
+
preview = JSON.stringify(args);
|
|
78
|
+
} catch {
|
|
79
|
+
preview = String(args);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} else if (args === undefined || args === null) {
|
|
83
|
+
preview = "";
|
|
84
|
+
} else {
|
|
85
|
+
preview = String(args);
|
|
86
|
+
}
|
|
87
|
+
if (preview.length > maxChars) preview = `${preview.slice(0, maxChars)}…`;
|
|
88
|
+
return preview;
|
|
89
|
+
}
|
package/i18n.js
CHANGED
|
@@ -1,140 +1,144 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-codex-approval — i18n.js
|
|
3
|
-
*
|
|
4
|
-
* zh/en copy for the /approval-mode command. The host reads the user's
|
|
5
|
-
* locale preference from the dsh settings service (`locale.preference`,
|
|
6
|
-
* owned by dsh-client-locale, persisted in settings.yaml); without a value
|
|
7
|
-
* (or without settings at all) the copy falls back to English.
|
|
8
|
-
*
|
|
9
|
-
* Pure functions only: pick the locale, render command texts.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
export const LOCALES = ["zh", "en"];
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The full message table. Keys are identical across locales so a missing
|
|
16
|
-
* translation fails loudly in tests rather than silently at runtime.
|
|
17
|
-
*/
|
|
18
|
-
export const T = {
|
|
19
|
-
zh: {
|
|
20
|
-
showWithOverride: (effective, override, configDefault) =>
|
|
21
|
-
`当前模式:${effective}(会话覆盖:${override},配置默认:${configDefault})`,
|
|
22
|
-
showNoOverride: (effective, configDefault) =>
|
|
23
|
-
`当前模式:${effective}(配置默认:${configDefault},无会话覆盖)`,
|
|
24
|
-
cleared: (configDefault) => `已清除会话覆盖 → 回落 ${configDefault}(配置默认)`,
|
|
25
|
-
clearedMemoryOnly: (configDefault) => `已清除会话覆盖 → 回落 ${configDefault}(配置默认;settings 不可用,仅内存)`,
|
|
26
|
-
switched: (mode) => `已切换 → ${mode}(本会话)`,
|
|
27
|
-
switchedMemoryOnly: (mode) => `已切换 → ${mode}(本会话;settings 不可用,重启后丢失)`,
|
|
28
|
-
unknown: (input) => `未知模式 "${input}" — 用 manual | ai | ai-auto(或 1/2/3),或用 default 清除覆盖`
|
|
29
|
-
},
|
|
30
|
-
en: {
|
|
31
|
-
showWithOverride: (effective, override, configDefault) =>
|
|
32
|
-
`mode: ${effective} (session override: ${override}, config default: ${configDefault})`,
|
|
33
|
-
showNoOverride: (effective, configDefault) =>
|
|
34
|
-
`mode: ${effective} (config default: ${configDefault}, no session override)`,
|
|
35
|
-
cleared: (configDefault) => `override cleared → ${configDefault} (config default)`,
|
|
36
|
-
clearedMemoryOnly: (configDefault) => `override cleared → ${configDefault} (config default; memory-only: settings unavailable)`,
|
|
37
|
-
switched: (mode) => `switched → ${mode} (this session)`,
|
|
38
|
-
switchedMemoryOnly: (mode) => `switched → ${mode} (this session; memory-only: settings unavailable, lost on restart)`,
|
|
39
|
-
unknown: (input) => `unknown mode "${input}" — use manual | ai | ai-auto (or 1/2/3), or "default" to clear the override`
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Pick the command copy locale from a raw preference value.
|
|
45
|
-
* @param pref - settings `locale.preference` (e.g. "zh", "en", or undefined)
|
|
46
|
-
* @returns "zh" | "en" — valid values pass through; anything else → "en"
|
|
47
|
-
*/
|
|
48
|
-
export function pickLocale(pref) {
|
|
49
|
-
return pref === "zh" ? "zh" : "en";
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** The command description for the given locale (registered once at boot). */
|
|
53
|
-
export function commandDescription(locale) {
|
|
54
|
-
return locale === "zh"
|
|
55
|
-
? "显示或切换审批模式(manual | ai | ai-auto,或 1/2/3)"
|
|
56
|
-
: "Show or switch the approval mode (manual | ai | ai-auto, or 1/2/3)";
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Human-readable denial-source labels, keyed per locale. Every source the
|
|
61
|
-
* plugin can stage in a denial record must have a label in both locales
|
|
62
|
-
* (rule / ai / ai-error / fallback) so renderDenialNotice never leaks a raw
|
|
63
|
-
* internal kind to the model.
|
|
64
|
-
*/
|
|
65
|
-
const SOURCE_LABELS = {
|
|
66
|
-
zh: {
|
|
67
|
-
rule: "确定性规则",
|
|
68
|
-
ai: "AI 评审",
|
|
69
|
-
"ai-error": "AI 评审故障兜底(failOpen)",
|
|
70
|
-
fallback: "兜底策略"
|
|
71
|
-
},
|
|
72
|
-
en: {
|
|
73
|
-
rule: "deterministic rule",
|
|
74
|
-
ai: "AI judge",
|
|
75
|
-
"ai-error": "AI judge failure fallback (failOpen)",
|
|
76
|
-
fallback: "fallback policy"
|
|
77
|
-
}
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* The denial-feedback copy table. Renders one staged denial into the exact
|
|
82
|
-
* corrective message the `agent/pre-step` injector appends to the next model
|
|
83
|
-
* request. Keys are identical across locales so a missing translation fails
|
|
84
|
-
* loudly in tests rather than silently at runtime.
|
|
85
|
-
*/
|
|
86
|
-
const NOTICE = {
|
|
87
|
-
zh: {
|
|
88
|
-
deniedByReviewer: (cmd) => `[auto-review] 上一个操作 ${cmd} 被自动审批评审拒绝——这不是用户的拒绝。`,
|
|
89
|
-
unknownCommand: "(未知命令)",
|
|
90
|
-
sourceLine: (src, risk) => `来源:${src}${risk !== void 0 ? `;风险:${risk}` : ""}`,
|
|
91
|
-
viaAsk: "(全自动模式下 \"ask\" 被解析为 \"deny\",未经过人类确认)",
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
*
|
|
111
|
-
* @
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-codex-approval — i18n.js
|
|
3
|
+
*
|
|
4
|
+
* zh/en copy for the /approval-mode command. The host reads the user's
|
|
5
|
+
* locale preference from the dsh settings service (`locale.preference`,
|
|
6
|
+
* owned by dsh-client-locale, persisted in settings.yaml); without a value
|
|
7
|
+
* (or without settings at all) the copy falls back to English.
|
|
8
|
+
*
|
|
9
|
+
* Pure functions only: pick the locale, render command texts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const LOCALES = ["zh", "en"];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The full message table. Keys are identical across locales so a missing
|
|
16
|
+
* translation fails loudly in tests rather than silently at runtime.
|
|
17
|
+
*/
|
|
18
|
+
export const T = {
|
|
19
|
+
zh: {
|
|
20
|
+
showWithOverride: (effective, override, configDefault) =>
|
|
21
|
+
`当前模式:${effective}(会话覆盖:${override},配置默认:${configDefault})`,
|
|
22
|
+
showNoOverride: (effective, configDefault) =>
|
|
23
|
+
`当前模式:${effective}(配置默认:${configDefault},无会话覆盖)`,
|
|
24
|
+
cleared: (configDefault) => `已清除会话覆盖 → 回落 ${configDefault}(配置默认)`,
|
|
25
|
+
clearedMemoryOnly: (configDefault) => `已清除会话覆盖 → 回落 ${configDefault}(配置默认;settings 不可用,仅内存)`,
|
|
26
|
+
switched: (mode) => `已切换 → ${mode}(本会话)`,
|
|
27
|
+
switchedMemoryOnly: (mode) => `已切换 → ${mode}(本会话;settings 不可用,重启后丢失)`,
|
|
28
|
+
unknown: (input) => `未知模式 "${input}" — 用 manual | ai | ai-auto(或 1/2/3),或用 default 清除覆盖`
|
|
29
|
+
},
|
|
30
|
+
en: {
|
|
31
|
+
showWithOverride: (effective, override, configDefault) =>
|
|
32
|
+
`mode: ${effective} (session override: ${override}, config default: ${configDefault})`,
|
|
33
|
+
showNoOverride: (effective, configDefault) =>
|
|
34
|
+
`mode: ${effective} (config default: ${configDefault}, no session override)`,
|
|
35
|
+
cleared: (configDefault) => `override cleared → ${configDefault} (config default)`,
|
|
36
|
+
clearedMemoryOnly: (configDefault) => `override cleared → ${configDefault} (config default; memory-only: settings unavailable)`,
|
|
37
|
+
switched: (mode) => `switched → ${mode} (this session)`,
|
|
38
|
+
switchedMemoryOnly: (mode) => `switched → ${mode} (this session; memory-only: settings unavailable, lost on restart)`,
|
|
39
|
+
unknown: (input) => `unknown mode "${input}" — use manual | ai | ai-auto (or 1/2/3), or "default" to clear the override`
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Pick the command copy locale from a raw preference value.
|
|
45
|
+
* @param pref - settings `locale.preference` (e.g. "zh", "en", or undefined)
|
|
46
|
+
* @returns "zh" | "en" — valid values pass through; anything else → "en"
|
|
47
|
+
*/
|
|
48
|
+
export function pickLocale(pref) {
|
|
49
|
+
return pref === "zh" ? "zh" : "en";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The command description for the given locale (registered once at boot). */
|
|
53
|
+
export function commandDescription(locale) {
|
|
54
|
+
return locale === "zh"
|
|
55
|
+
? "显示或切换审批模式(manual | ai | ai-auto,或 1/2/3)"
|
|
56
|
+
: "Show or switch the approval mode (manual | ai | ai-auto, or 1/2/3)";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Human-readable denial-source labels, keyed per locale. Every source the
|
|
61
|
+
* plugin can stage in a denial record must have a label in both locales
|
|
62
|
+
* (rule / ai / ai-error / fallback) so renderDenialNotice never leaks a raw
|
|
63
|
+
* internal kind to the model.
|
|
64
|
+
*/
|
|
65
|
+
const SOURCE_LABELS = {
|
|
66
|
+
zh: {
|
|
67
|
+
rule: "确定性规则",
|
|
68
|
+
ai: "AI 评审",
|
|
69
|
+
"ai-error": "AI 评审故障兜底(failOpen)",
|
|
70
|
+
fallback: "兜底策略"
|
|
71
|
+
},
|
|
72
|
+
en: {
|
|
73
|
+
rule: "deterministic rule",
|
|
74
|
+
ai: "AI judge",
|
|
75
|
+
"ai-error": "AI judge failure fallback (failOpen)",
|
|
76
|
+
fallback: "fallback policy"
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The denial-feedback copy table. Renders one staged denial into the exact
|
|
82
|
+
* corrective message the `agent/pre-step` injector appends to the next model
|
|
83
|
+
* request. Keys are identical across locales so a missing translation fails
|
|
84
|
+
* loudly in tests rather than silently at runtime.
|
|
85
|
+
*/
|
|
86
|
+
const NOTICE = {
|
|
87
|
+
zh: {
|
|
88
|
+
deniedByReviewer: (cmd) => `[auto-review] 上一个操作 ${cmd} 被自动审批评审拒绝——这不是用户的拒绝。`,
|
|
89
|
+
unknownCommand: "(未知命令)",
|
|
90
|
+
sourceLine: (src, risk) => `来源:${src}${risk !== void 0 ? `;风险:${risk}` : ""}`,
|
|
91
|
+
viaAsk: "(全自动模式下 \"ask\" 被解析为 \"deny\",未经过人类确认)",
|
|
92
|
+
failure: (kind, failure) => `评审调用失败:${kind ?? "unknown"}${failure?.code !== undefined ? `(${failure.code})` : ""}${failure?.message !== undefined ? `:${failure.message}` : ""}`,
|
|
93
|
+
rationale: (text) => `评审理由:${text}`,
|
|
94
|
+
rationaleMissing: "未提供评审理由。",
|
|
95
|
+
directive: "不要通过变通手段或间接执行绕开该操作;请改用实质更安全的替代方案,或停下来询问用户。"
|
|
96
|
+
},
|
|
97
|
+
en: {
|
|
98
|
+
deniedByReviewer: (cmd) => `[auto-review] The previous action ${cmd} was denied by the automatic approval reviewer — this was NOT a user rejection.`,
|
|
99
|
+
unknownCommand: "(unknown command)",
|
|
100
|
+
sourceLine: (src, risk) => `Source: ${src}${risk !== void 0 ? `; risk: ${risk}` : ""}`,
|
|
101
|
+
viaAsk: " (denied by the auto mode default: \"ask\" resolved to \"deny\" without a human)",
|
|
102
|
+
failure: (kind, failure) => `Review call failed: ${kind ?? "unknown"}${failure?.code !== undefined ? ` (${failure.code})` : ""}${failure?.message !== undefined ? `: ${failure.message}` : ""}`,
|
|
103
|
+
rationale: (text) => `Review rationale: ${text}`,
|
|
104
|
+
rationaleMissing: "No rationale was provided.",
|
|
105
|
+
directive: "Do not pursue this action via workaround or indirect execution. Continue with a materially safer alternative, or stop and ask the user."
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Render one staged denial record into a corrective paragraph.
|
|
111
|
+
* @param record - { command, source, match?, risk?, aiReason?, finishKind?, failure?, viaAsk? }
|
|
112
|
+
* @param t - the locale's NOTICE table
|
|
113
|
+
* @returns the paragraph text (no trailing newline).
|
|
114
|
+
*/
|
|
115
|
+
function renderNoticeOne(record, t, locale) {
|
|
116
|
+
const command = record.command === undefined || record.command === ""
|
|
117
|
+
? t.unknownCommand
|
|
118
|
+
: `\`${record.command}\``;
|
|
119
|
+
const src = (SOURCE_LABELS[locale] ?? {})[record.source] ?? record.source;
|
|
120
|
+
const lines = [
|
|
121
|
+
t.deniedByReviewer(command),
|
|
122
|
+
t.sourceLine(src, record.risk) + (record.viaAsk === true ? t.viaAsk : ""),
|
|
123
|
+
record.failure !== undefined
|
|
124
|
+
? t.failure(record.finishKind, record.failure)
|
|
125
|
+
: record.aiReason !== undefined
|
|
126
|
+
? t.rationale(record.aiReason)
|
|
127
|
+
: t.rationaleMissing
|
|
128
|
+
];
|
|
129
|
+
return lines.join("\n");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Render staged denials into the single corrective message injected before
|
|
134
|
+
* the next model step. Multiple denials render in order, separated by a blank
|
|
135
|
+
* line, each prefixed; the closing directive is emitted once at the end.
|
|
136
|
+
* @param queue - staged denial records (1..denyFeedbackMax).
|
|
137
|
+
* @param locale - "zh" | "en".
|
|
138
|
+
* @returns the full message text.
|
|
139
|
+
*/
|
|
140
|
+
export function renderDenialNotice(queue, locale) {
|
|
141
|
+
const t = NOTICE[locale] ?? NOTICE.en;
|
|
142
|
+
const body = queue.map((record) => renderNoticeOne(record, t, locale)).join("\n\n");
|
|
143
|
+
return `${body}\n${t.directive}`;
|
|
144
|
+
}
|