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.
@@ -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
+ }
@@ -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 plugin row into the profile.
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 - session.events (or any event array)
18
- * @param callId - the approval request's callId
19
- * @returns the parsed arguments object, or null when unrecoverable.
20
- */
21
- export function findToolCallArgs(events, callId) {
22
- if (!Array.isArray(events) || callId === undefined) return null;
23
- for (let i = events.length - 1; i >= 0; i -= 1) {
24
- const event = events[i];
25
- if (event === null || typeof event !== "object" || event.type !== "assistant/message") continue;
26
- const content = event.data?.message?.content;
27
- if (!Array.isArray(content)) continue;
28
- for (let j = content.length - 1; j >= 0; j -= 1) {
29
- const part = content[j];
30
- if (part === null || typeof part !== "object" || part.type !== "tool-call") continue;
31
- if (part.id !== callId) continue;
32
- try {
33
- return JSON.parse(part.arguments ?? "null");
34
- } catch {
35
- return null;
36
- }
37
- }
38
- }
39
- return null;
40
- }
41
-
42
- /**
43
- * Build the Codex-style args preview used for rule matching and the AI
44
- * prompt: the raw command for bash/pwsh, compact JSON otherwise.
45
- * @param args - parsed tool arguments (or null)
46
- * @param toolName - the tool that was called
47
- * @param maxChars - preview length cap
48
- */
49
- export function argsPreview(args, toolName, maxChars) {
50
- let preview;
51
- if (args !== null && typeof args === "object") {
52
- if ((toolName === "bash" || toolName === "pwsh") && typeof args.command === "string") {
53
- preview = args.command;
54
- } else {
55
- try {
56
- preview = JSON.stringify(args);
57
- } catch {
58
- preview = String(args);
59
- }
60
- }
61
- } else if (args === undefined || args === null) {
62
- preview = "";
63
- } else {
64
- preview = String(args);
65
- }
66
- if (preview.length > maxChars) preview = `${preview.slice(0, maxChars)}…`;
67
- return preview;
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
- rationale: (text) => `评审理由:${text}`,
93
- rationaleMissing: "未提供评审理由。",
94
- directive: "不要通过变通手段或间接执行绕开该操作;请改用实质更安全的替代方案,或停下来询问用户。"
95
- },
96
- en: {
97
- deniedByReviewer: (cmd) => `[auto-review] The previous action ${cmd} was denied by the automatic approval reviewer — this was NOT a user rejection.`,
98
- unknownCommand: "(unknown command)",
99
- sourceLine: (src, risk) => `Source: ${src}${risk !== void 0 ? `; risk: ${risk}` : ""}`,
100
- viaAsk: " (denied by the auto mode default: \"ask\" resolved to \"deny\" without a human)",
101
- rationale: (text) => `Review rationale: ${text}`,
102
- rationaleMissing: "No rationale was provided.",
103
- directive: "Do not pursue this action via workaround or indirect execution. Continue with a materially safer alternative, or stop and ask the user."
104
- }
105
- };
106
-
107
- /**
108
- * Render one staged denial record into a corrective paragraph.
109
- * @param record - { command, source, match?, risk?, aiReason?, viaAsk? }
110
- * @param t - the locale's NOTICE table
111
- * @returns the paragraph text (no trailing newline).
112
- */
113
- function renderNoticeOne(record, t, locale) {
114
- const command = record.command === undefined || record.command === ""
115
- ? t.unknownCommand
116
- : `\`${record.command}\``;
117
- const src = (SOURCE_LABELS[locale] ?? {})[record.source] ?? record.source;
118
- const lines = [
119
- t.deniedByReviewer(command),
120
- t.sourceLine(src, record.risk) + (record.viaAsk === true ? t.viaAsk : ""),
121
- record.aiReason !== undefined
122
- ? t.rationale(record.aiReason)
123
- : t.rationaleMissing
124
- ];
125
- return lines.join("\n");
126
- }
127
-
128
- /**
129
- * Render staged denials into the single corrective message injected before
130
- * the next model step. Multiple denials render in order, separated by a blank
131
- * line, each prefixed; the closing directive is emitted once at the end.
132
- * @param queue - staged denial records (1..denyFeedbackMax).
133
- * @param locale - "zh" | "en".
134
- * @returns the full message text.
135
- */
136
- export function renderDenialNotice(queue, locale) {
137
- const t = NOTICE[locale] ?? NOTICE.en;
138
- const body = queue.map((record) => renderNoticeOne(record, t, locale)).join("\n\n");
139
- return `${body}\n${t.directive}`;
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
+ }