howtofixcli 0.7.24 → 0.7.26
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/dist/account-credential.js +136 -0
- package/dist/acp.js +234 -0
- package/dist/activity.js +124 -0
- package/dist/agent-defs.js +237 -0
- package/dist/agent-limiter.js +68 -0
- package/dist/agents.js +554 -0
- package/dist/anim.js +361 -0
- package/dist/args.js +250 -0
- package/dist/at-file.js +28 -0
- package/dist/brand-extension.js +87 -0
- package/dist/brand.js +76 -0
- package/dist/browser.js +58 -0
- package/dist/cache-report.js +139 -0
- package/dist/checkpoint.js +437 -0
- package/dist/claims.js +167 -0
- package/dist/cli-auth.js +142 -0
- package/dist/cli-checkin.js +121 -0
- package/dist/cli-credentials.js +254 -0
- package/dist/clipboard.js +149 -0
- package/dist/collapse.js +129 -0
- package/dist/commands.js +381 -0
- package/dist/compaction-guard.js +81 -0
- package/dist/connect-prompt.js +22 -0
- package/dist/context-extension.js +17 -0
- package/dist/dcp/commands.js +172 -0
- package/dist/dcp/compress-state.js +139 -0
- package/dist/dcp/compress.js +329 -0
- package/dist/dcp/dcp-extension.js +295 -0
- package/dist/dcp/dedup.js +93 -0
- package/dist/dcp/ids.js +189 -0
- package/dist/dcp/index.js +25 -0
- package/dist/dcp/middle-prune.js +76 -0
- package/dist/dcp/prompts.js +68 -0
- package/dist/dcp/protected-content.js +221 -0
- package/dist/dcp/protected.js +161 -0
- package/dist/dcp/prune.js +418 -0
- package/dist/dcp/state.js +176 -0
- package/dist/errors.js +63 -0
- package/dist/extensions.js +48 -0
- package/dist/fatal.js +35 -0
- package/dist/feed.js +498 -0
- package/dist/fs-atomic.js +54 -0
- package/dist/gateway-errors.js +107 -0
- package/dist/git.js +230 -0
- package/dist/goal-extension.js +88 -0
- package/dist/goal.js +222 -0
- package/dist/hooks-extension.js +176 -0
- package/dist/hooks.js +443 -0
- package/dist/howtofix-account.js +834 -0
- package/dist/hud-line.js +158 -0
- package/dist/hwid.js +130 -0
- package/dist/interrupt.js +60 -0
- package/dist/json-events.js +50 -0
- package/dist/level-style.js +112 -0
- package/dist/loop-guard.js +95 -0
- package/dist/lsp-tools.js +32 -0
- package/dist/lsp.js +429 -0
- package/dist/main.js +573 -98
- package/dist/mcp-config.js +85 -0
- package/dist/mcp-lifecycle.js +147 -0
- package/dist/model-catalog.js +217 -0
- package/dist/model-facts.js +35 -0
- package/dist/model-resolve.js +65 -0
- package/dist/notes.js +78 -0
- package/dist/oauth-login.js +568 -0
- package/dist/paths.js +61 -0
- package/dist/permissions.js +298 -0
- package/dist/plan-mode.js +363 -0
- package/dist/preset-command.js +227 -0
- package/dist/preset-extension.js +35 -0
- package/dist/preset-mcp.js +292 -0
- package/dist/presets/mcp/rea/preset.json +28 -0
- package/dist/presets/mcp/rea/prompt.md +5 -0
- package/dist/presets/mcp/rea/skills/mcp-rea/SKILL.md +6 -0
- package/dist/presets/mcp/remotion/preset.json +27 -0
- package/dist/presets/mcp/remotion/prompt.md +7 -0
- package/dist/presets/mcp/remotion/skills/mcp-remotion/SKILL.md +6 -0
- package/dist/presets.js +727 -0
- package/dist/prompt-files.js +103 -0
- package/dist/prompt-text.js +41 -0
- package/dist/proxy.js +137 -0
- package/dist/quality/ast.js +196 -0
- package/dist/quality/cognitive.js +83 -0
- package/dist/quality/cyclomatic.js +30 -0
- package/dist/quality/halstead.js +83 -0
- package/dist/quality/index.js +18 -0
- package/dist/quality/length.js +3 -0
- package/dist/quality/maintainability.js +15 -0
- package/dist/quality/nesting.js +57 -0
- package/dist/quality/report.js +803 -0
- package/dist/quality/smells.js +67 -0
- package/dist/quality-tool.js +63 -0
- package/dist/read-only-commands.js +597 -0
- package/dist/repo-map.js +292 -0
- package/dist/roblox-connect.js +125 -0
- package/dist/rpc.js +91 -0
- package/dist/rtk.js +193 -0
- package/dist/sandbox.js +105 -0
- package/dist/schema.js +214 -0
- package/dist/serve.js +50 -0
- package/dist/session-lifecycle.js +19 -0
- package/dist/session-retention.js +111 -0
- package/dist/session.js +460 -0
- package/dist/sessions.js +100 -0
- package/dist/settings.js +148 -0
- package/dist/skills.js +35 -0
- package/dist/slop-tool.js +96 -0
- package/dist/spend-cap.js +43 -0
- package/dist/subagent.js +171 -0
- package/dist/tar.js +146 -0
- package/dist/theme.js +79 -0
- package/dist/thinking.js +45 -0
- package/dist/todo.js +114 -0
- package/dist/tool-loadout.js +102 -0
- package/dist/tool-render.js +769 -0
- package/dist/tools.js +176 -0
- package/dist/ui.js +4931 -0
- package/dist/update.js +551 -0
- package/dist/usage-segment.js +74 -0
- package/dist/web-tools.js +46 -0
- package/dist/web.js +265 -0
- package/dist/welcome.js +179 -0
- package/dist/workflow-engine.js +246 -0
- package/dist/workflow-progress.js +446 -0
- package/dist/workflow-view.js +195 -0
- package/dist/workflow.js +349 -0
- package/package.json +62 -15
- package/dist/chunk-27TRKD65.js +0 -2
- package/dist/chunk-3FYCFLNK.js +0 -1
- package/dist/chunk-3LZR7OEU.js +0 -1
- package/dist/chunk-56SEAGAQ.js +0 -1
- package/dist/chunk-5AWTL34S.js +0 -1
- package/dist/chunk-75P2SCOT.js +0 -1
- package/dist/chunk-7CHDNTH6.js +0 -1
- package/dist/chunk-7PHRNXLN.js +0 -34
- package/dist/chunk-AGGIYXN7.js +0 -2
- package/dist/chunk-AUDMRGWF.js +0 -189
- package/dist/chunk-C5E3SFOQ.js +0 -2
- package/dist/chunk-CMTKCUL5.js +0 -1
- package/dist/chunk-DAPXGSGD.js +0 -12
- package/dist/chunk-DOD7MUGX.js +0 -1
- package/dist/chunk-FPXVDP32.js +0 -8
- package/dist/chunk-GXB7RZHH.js +0 -1
- package/dist/chunk-I7KVLBMF.js +0 -2
- package/dist/chunk-ILDDDLRC.js +0 -1
- package/dist/chunk-JFYVRWJI.js +0 -2
- package/dist/chunk-LAHVHRCS.js +0 -2
- package/dist/chunk-LSJC73DG.js +0 -1
- package/dist/chunk-MNDKS6UH.js +0 -6
- package/dist/chunk-N345KWWK.js +0 -1
- package/dist/chunk-N3J2J7EQ.js +0 -5
- package/dist/chunk-ONZLNFQ2.js +0 -3
- package/dist/chunk-ORJ2QZBG.js +0 -3
- package/dist/chunk-PCALBZOO.js +0 -2
- package/dist/chunk-QPHSHL7H.js +0 -2
- package/dist/chunk-TKROS3BE.js +0 -5
- package/dist/chunk-TSEKPGK4.js +0 -1
- package/dist/chunk-UP7ENLEL.js +0 -117
- package/dist/chunk-UTYFLLSM.js +0 -1
- package/dist/chunk-V3R4FYTJ.js +0 -1
- package/dist/chunk-V656XVXV.js +0 -8
- package/dist/chunk-WKUJ7CFO.js +0 -1
- package/dist/chunk-WOT6VMZA.js +0 -1
- package/dist/chunk-WUPU6LTD.js +0 -4
- package/dist/chunk-WX3V5P7U.js +0 -5
- package/dist/chunk-XW6LMQGX.js +0 -1
- package/dist/chunk-YW7NORH3.js +0 -2
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { noteToolCall, repeatRefusal } from "./loop-guard.js";
|
|
2
|
+
import { isReadOnlyCommand } from "./read-only-commands.js";
|
|
3
|
+
export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
|
|
4
|
+
export const APPROVAL_LABELS = {
|
|
5
|
+
suggest: "suggest: ask before every edit and command",
|
|
6
|
+
"auto-edit": "auto-edit: edits run, commands ask",
|
|
7
|
+
"full-auto": "full-auto: nothing asks",
|
|
8
|
+
};
|
|
9
|
+
export function isApprovalMode(value) {
|
|
10
|
+
return APPROVAL_MODES.includes(value);
|
|
11
|
+
}
|
|
12
|
+
/** Ordering of capability tiers, least to most privileged. */
|
|
13
|
+
export const TIER_RANK = { read: 0, write: 1, exec: 2 };
|
|
14
|
+
/** The highest tier a mode runs without asking. */
|
|
15
|
+
export const MODE_MAX_TIER = {
|
|
16
|
+
suggest: "read",
|
|
17
|
+
"auto-edit": "write",
|
|
18
|
+
"full-auto": "exec",
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The SDK's built-in tools, by tier. It declares no approval metadata of its own, so
|
|
22
|
+
* the built-ins have to be listed here; everything else falls back to a tier
|
|
23
|
+
* chosen by {@link resolveTier}.
|
|
24
|
+
*/
|
|
25
|
+
const BUILTIN_TIERS = {
|
|
26
|
+
read: "read",
|
|
27
|
+
grep: "read",
|
|
28
|
+
find: "read",
|
|
29
|
+
ls: "read",
|
|
30
|
+
think: "read",
|
|
31
|
+
ask: "read",
|
|
32
|
+
todo: "read",
|
|
33
|
+
context: "read",
|
|
34
|
+
// Lookup only: the URL policy in `web.ts` refuses private targets before the
|
|
35
|
+
// network is touched, so neither tool prompts in `suggest`.
|
|
36
|
+
web_search: "read",
|
|
37
|
+
web_fetch: "read",
|
|
38
|
+
// A language-server query reads a file and answers; it has no write verb.
|
|
39
|
+
lsp: "read",
|
|
40
|
+
quality: "read",
|
|
41
|
+
// A workflow runs agents, which run tools — including shell. Its own tier is
|
|
42
|
+
// the strongest one so a run cannot start in a mode that would silently allow
|
|
43
|
+
// what the child agents then do.
|
|
44
|
+
workflow: "exec",
|
|
45
|
+
// The goal tool only writes session state; it runs nothing.
|
|
46
|
+
goal: "read",
|
|
47
|
+
// Plan mode tools only flip session state and read the plan file. The real
|
|
48
|
+
// enforcement is the plan gate, which is registered ahead of this one.
|
|
49
|
+
enter_plan_mode: "read",
|
|
50
|
+
exit_plan_mode: "read",
|
|
51
|
+
edit: "write",
|
|
52
|
+
write: "write",
|
|
53
|
+
bash: "exec",
|
|
54
|
+
powershell: "exec",
|
|
55
|
+
task: "exec",
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Tiers for the tools this project registers as custom tools. `task` spawns
|
|
59
|
+
* headless agents, so it sits at `exec`; `think` and `ask` only produce text.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveTier(toolName) {
|
|
62
|
+
const builtin = BUILTIN_TIERS[toolName];
|
|
63
|
+
if (builtin)
|
|
64
|
+
return builtin;
|
|
65
|
+
// An MCP server tool can do anything its server exposes, and its own
|
|
66
|
+
// annotations are unverified. Every MCP tool is pinned to `write`; a
|
|
67
|
+
// tool that turns out to be read-only costs one extra prompt.
|
|
68
|
+
if (toolName.startsWith("mcp__"))
|
|
69
|
+
return "write";
|
|
70
|
+
// Safe default for unknown custom tools: `exec`.
|
|
71
|
+
return "exec";
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Commands that only read. They never ask, in any mode.
|
|
75
|
+
*
|
|
76
|
+
* The decision lives in `read-only-commands.ts`, which ports Claude Code's
|
|
77
|
+
* classifier: a compound command is read-only only if *every* segment is, and a
|
|
78
|
+
* segment only if its flags are all in a per-subcommand allowlist. The old
|
|
79
|
+
* single regex got both directions wrong — it prompted on `ls | head`, which is
|
|
80
|
+
* how you read a codebase, and it auto-allowed `git branch -D`.
|
|
81
|
+
*/
|
|
82
|
+
export { isReadOnlyCommand } from "./read-only-commands.js";
|
|
83
|
+
/**
|
|
84
|
+
* Commands that are refused in every mode, including `full-auto`. These are the
|
|
85
|
+
* patterns treated as "critical": an approval prompt is the wrong
|
|
86
|
+
* answer when the command is a fork bomb or a wipe of the root filesystem.
|
|
87
|
+
*/
|
|
88
|
+
const CRITICAL_COMMANDS = [
|
|
89
|
+
{ pattern: /\brm\s+(?:-[a-zA-Z]+\s+)*(?:\/|\/\*|~|\$HOME)(?:\s|$)/, reason: "recursive delete of the filesystem root or home" },
|
|
90
|
+
{ pattern: /\bmkfs(?:\.[a-z0-9]+)?\b/, reason: "filesystem format" },
|
|
91
|
+
{ pattern: /\bdd\b[^|;]*\bof=\/dev\/(?:sd|nvme|hd|vd)/, reason: "raw write to a block device" },
|
|
92
|
+
{ pattern: /:\s*\(\s*\)\s*\{.*\}\s*;\s*:/, reason: "fork bomb" },
|
|
93
|
+
{ pattern: />\s*\/etc\/(?:passwd|shadow|sudoers)/, reason: "overwrite of a system account file" },
|
|
94
|
+
{ pattern: /\b(?:shutdown|reboot|halt|poweroff)\b/, reason: "host shutdown" },
|
|
95
|
+
];
|
|
96
|
+
/**
|
|
97
|
+
* The critical pattern a command matches, if any.
|
|
98
|
+
*
|
|
99
|
+
* Only the **command being run** is matched, never the whole line. A line-wide
|
|
100
|
+
* regex made every mention of the word fatal: `grep -rn shutdown src/`,
|
|
101
|
+
* `cat reboot-notes.md`, `git log --grep=reboot` and `echo "reboot later"` were
|
|
102
|
+
* all refused as "host shutdown", so an agent could not even search its own
|
|
103
|
+
* codebase for the word. The list exists to stop the action, not the noun, so
|
|
104
|
+
* the head of the command is the only part that counts.
|
|
105
|
+
*
|
|
106
|
+
* `sudo shutdown` and `env reboot` are still caught: any leading word that is
|
|
107
|
+
* itself a launcher (`sudo`, `env`, `nohup`, `time`, `exec`, …) is skipped
|
|
108
|
+
* before the match, so the check reads the command that will actually run.
|
|
109
|
+
*/
|
|
110
|
+
const LAUNCHER_WORDS = new Set(["sudo", "doas", "env", "nohup", "time", "exec", "command", "nice", "setsid", "stdbuf"]);
|
|
111
|
+
/** Launcher flags that consume the token after them, so `sudo -u root shutdown`
|
|
112
|
+
* does not leave `root` looking like the command. */
|
|
113
|
+
const LAUNCHER_VALUE_FLAGS = new Set(["-u", "-g", "-p", "-C", "-S", "-U", "--user", "--group", "--prompt", "--close-from", "--chdir", "--setenv", "--unset"]);
|
|
114
|
+
export function criticalCommand(command) {
|
|
115
|
+
// Quote-aware tokenization: `grep -rn shutdown src/` splits into words, and
|
|
116
|
+
// the argument is simply not the first one.
|
|
117
|
+
const words = (command.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((word) => word.replace(/^['"]|['"]$/g, ""));
|
|
118
|
+
let head = 0;
|
|
119
|
+
// Skip leading `VAR=value` assignments, then the launcher prefix — both the
|
|
120
|
+
// launcher word and its own flags, so `sudo -n reboot` still reads as
|
|
121
|
+
// `reboot`. A value-taking launcher flag takes the next token with it.
|
|
122
|
+
while (head < words.length) {
|
|
123
|
+
const current = words[head] ?? "";
|
|
124
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(current) || LAUNCHER_WORDS.has(current)) {
|
|
125
|
+
head++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (current.startsWith("-")) {
|
|
129
|
+
head += LAUNCHER_VALUE_FLAGS.has(current) ? 2 : 1;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
const token = words[head];
|
|
135
|
+
if (token === undefined)
|
|
136
|
+
return undefined;
|
|
137
|
+
// The command word, without its directory: `/sbin/shutdown` is `shutdown`.
|
|
138
|
+
const word = token.split("/").pop() ?? token;
|
|
139
|
+
// `rm` is only critical when it is recursive *and* aimed at the root, home or
|
|
140
|
+
// a wildcard — `rm -rf build/` and `rm -rf /tmp/x` are ordinary work.
|
|
141
|
+
if (word === "rm") {
|
|
142
|
+
const args = words.slice(head + 1);
|
|
143
|
+
const recursive = args.some((arg) => /^-[a-zA-Z]*r[a-zA-Z]*$/i.test(arg) || arg === "--recursive");
|
|
144
|
+
const target = args.find((arg) => !arg.startsWith("-"));
|
|
145
|
+
if (recursive && (target === undefined || /^(?:\/|\/\*|~|~\/\*|\$HOME|\$HOME\/\*|\*)$/.test(target))) {
|
|
146
|
+
return "recursive delete of the filesystem root or home";
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
// `dd … of=/dev/sdX` is the raw-device write; a bare `dd` is not.
|
|
151
|
+
if (word === "dd") {
|
|
152
|
+
return words.slice(head + 1).some((arg) => /^of=\/dev\/(?:sd|nvme|hd|vd)/.test(arg)) ? "raw write to a block device" : undefined;
|
|
153
|
+
}
|
|
154
|
+
if (/^mkfs(?:\.[a-z0-9]+)?$/.test(word))
|
|
155
|
+
return "filesystem format";
|
|
156
|
+
if (/:\(\)\s*\{.*\}\s*;:/.test(command))
|
|
157
|
+
return "fork bomb";
|
|
158
|
+
if (/^(?:shutdown|reboot|halt|poweroff)$/.test(word))
|
|
159
|
+
return "host shutdown";
|
|
160
|
+
// The redirect target is not the command word, so it is checked separately.
|
|
161
|
+
if (/>\s*\/etc\/(?:passwd|shadow|sudoers)/.test(command))
|
|
162
|
+
return "overwrite of a system account file";
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
let currentMode = "full-auto";
|
|
166
|
+
let currentPrompt = async () => false;
|
|
167
|
+
/**
|
|
168
|
+
* When set, a tool call runs without consulting the mode, the critical-command
|
|
169
|
+
* list, or the loop guard. `--dangerously-skip-permissions` sets it for print
|
|
170
|
+
* mode, where a refusal has no human to answer it.
|
|
171
|
+
*
|
|
172
|
+
* What it does NOT turn off: an extension's own `tool_call` handler (this gate
|
|
173
|
+
* is one of several), and the provider's own limits. It is the same trade
|
|
174
|
+
* Claude Code makes with `--dangerously-skip-permissions`, and it is named the
|
|
175
|
+
* same way on purpose — the name is the warning.
|
|
176
|
+
*/
|
|
177
|
+
let dangerousBypass = false;
|
|
178
|
+
/** Change the policy. Read at tool-call time, so it applies immediately. */
|
|
179
|
+
export function setApprovalMode(mode) {
|
|
180
|
+
currentMode = mode;
|
|
181
|
+
}
|
|
182
|
+
export function getApprovalMode() {
|
|
183
|
+
return currentMode;
|
|
184
|
+
}
|
|
185
|
+
export function setDangerousBypass(on) {
|
|
186
|
+
dangerousBypass = on;
|
|
187
|
+
}
|
|
188
|
+
export function isDangerousBypass() {
|
|
189
|
+
return dangerousBypass;
|
|
190
|
+
}
|
|
191
|
+
/** Install the dialog used when a call needs approval. Print mode leaves the deny default. */
|
|
192
|
+
export function setApprovalPrompt(prompt) {
|
|
193
|
+
currentPrompt = prompt;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The whole policy in one place, so it can be exercised without a session.
|
|
197
|
+
*
|
|
198
|
+
* `full-auto` runs everything except a critical command; `auto-edit` adds the
|
|
199
|
+
* `write` tier (file edits); `suggest` runs only `read`. Read-only shell
|
|
200
|
+
* commands run in every mode, as the README promises.
|
|
201
|
+
*/
|
|
202
|
+
export function decideToolCall(mode, toolName, input) {
|
|
203
|
+
// The bypass short-circuits the whole policy, including the critical list:
|
|
204
|
+
// that is the point of the flag, and it is why the flag is named the way it is.
|
|
205
|
+
if (dangerousBypass)
|
|
206
|
+
return { action: "run" };
|
|
207
|
+
const command = typeof input?.command === "string" ? input.command : "";
|
|
208
|
+
if (toolName === "bash" || toolName === "powershell") {
|
|
209
|
+
const critical = criticalCommand(command);
|
|
210
|
+
if (critical)
|
|
211
|
+
return { action: "deny", reason: `Refused in every mode: ${critical}.` };
|
|
212
|
+
if (isReadOnlyCommand(command))
|
|
213
|
+
return { action: "run" };
|
|
214
|
+
}
|
|
215
|
+
const tier = resolveTier(toolName);
|
|
216
|
+
if (TIER_RANK[tier] <= TIER_RANK[MODE_MAX_TIER[mode]])
|
|
217
|
+
return { action: "run" };
|
|
218
|
+
return { action: "prompt", reason: `this is a ${tier}-tier tool` };
|
|
219
|
+
}
|
|
220
|
+
/** Human summary of a tool call, used in the approval dialog. */
|
|
221
|
+
export function describeToolCall(toolName, input) {
|
|
222
|
+
if (input && typeof input === "object") {
|
|
223
|
+
const record = input;
|
|
224
|
+
if ((toolName === "bash" || toolName === "powershell") && typeof record.command === "string")
|
|
225
|
+
return record.command;
|
|
226
|
+
if ((toolName === "edit" || toolName === "write") && typeof record.path === "string") {
|
|
227
|
+
const edits = Array.isArray(record.edits) ? ` (${record.edits.length} edits)` : "";
|
|
228
|
+
return `${record.path}${edits}`;
|
|
229
|
+
}
|
|
230
|
+
// A tool we do not know by name (MCP, another extension): show its
|
|
231
|
+
// arguments, so the choice is informed instead of a bare tool name.
|
|
232
|
+
try {
|
|
233
|
+
const args = JSON.stringify(record);
|
|
234
|
+
if (args && args !== "{}")
|
|
235
|
+
return args.length > 300 ? `${args.slice(0, 297)}...` : args;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// Unserializable input falls through to the tool name.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return toolName;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The approval prompt body: the tool, where it came
|
|
245
|
+
* from, why it needs approval, then the concrete arguments.
|
|
246
|
+
*/
|
|
247
|
+
export function formatApprovalPrompt(toolName, input, reason) {
|
|
248
|
+
const lines = [`Allow tool: ${toolName}`];
|
|
249
|
+
if (toolName.startsWith("mcp__"))
|
|
250
|
+
lines.push("Origin: MCP server tool");
|
|
251
|
+
lines.push(`Reason: ${reason}`);
|
|
252
|
+
const details = describeToolCall(toolName, input);
|
|
253
|
+
if (details !== toolName)
|
|
254
|
+
lines.push(details);
|
|
255
|
+
return lines.join("\n");
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Gate tool calls behind user approval, by declared capability tier. With no
|
|
259
|
+
* interactive prompt installed (print mode), anything above the mode's tier is
|
|
260
|
+
* denied; a critical command is denied outright.
|
|
261
|
+
*
|
|
262
|
+
* The loop guard runs before the approval check, because a call the model has
|
|
263
|
+
* already issued identically three times needs no human decision — approving it
|
|
264
|
+
* again would just spend the budget on the same non-progress. It blocks in every
|
|
265
|
+
* mode, `full-auto` included.
|
|
266
|
+
*/
|
|
267
|
+
export function createPermissionExtension(options = {}) {
|
|
268
|
+
// A subagent has no UI, so it must never reach for the shared approval dialog:
|
|
269
|
+
// the dialog belongs to the parent's screen, and a child that awaits it while
|
|
270
|
+
// the parent is mid-turn deadlocks both — measured, the child's prompt never
|
|
271
|
+
// even reached the provider. A non-interactive gate decides with the same
|
|
272
|
+
// policy and denies what it cannot run, which is the fail-closed behaviour the
|
|
273
|
+
// parent's print mode already has.
|
|
274
|
+
return (hxf) => {
|
|
275
|
+
hxf.on("tool_call", async (event) => {
|
|
276
|
+
// The bypass skips the loop guard too: it exists to stop a model spinning
|
|
277
|
+
// on a call a human would have to approve, and there is no human here.
|
|
278
|
+
if (dangerousBypass)
|
|
279
|
+
return undefined;
|
|
280
|
+
const repeat = noteToolCall(event.toolName, event.input);
|
|
281
|
+
if (repeat)
|
|
282
|
+
return { block: true, reason: repeatRefusal(event.toolName, repeat.repeats) };
|
|
283
|
+
const mode = currentMode;
|
|
284
|
+
const decision = decideToolCall(mode, event.toolName, event.input);
|
|
285
|
+
if (decision.action === "run")
|
|
286
|
+
return undefined;
|
|
287
|
+
if (decision.action === "deny")
|
|
288
|
+
return { block: true, reason: decision.reason };
|
|
289
|
+
if (options.interactive === false) {
|
|
290
|
+
return { block: true, reason: `Blocked: this is a ${resolveTier(event.toolName)}-tier tool and no UI can approve it here (approval mode: ${mode}).` };
|
|
291
|
+
}
|
|
292
|
+
const allowed = await currentPrompt(`${event.toolName} wants to run`, formatApprovalPrompt(event.toolName, event.input, decision.reason));
|
|
293
|
+
if (allowed)
|
|
294
|
+
return undefined;
|
|
295
|
+
return { block: true, reason: `Blocked by the user (approval mode: ${mode}).` };
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan mode: a read-only planning phase, ported from xAI's Grok Build.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: planning is a phase, not a message. Both Grok Build
|
|
5
|
+
* (`crates/codegen/xai-grok-shell/src/session/plan_mode.rs`) and Claude Code
|
|
6
|
+
* ship it as a first-class mode, and the reason is the same in both — an agent
|
|
7
|
+
* that starts editing before the approach is agreed wastes the whole task when
|
|
8
|
+
* the approach is wrong. The mode is the harness enforcing "design first",
|
|
9
|
+
* rather than asking the model nicely in a prompt it can forget after a
|
|
10
|
+
* compaction.
|
|
11
|
+
*
|
|
12
|
+
* Three properties are load-bearing, and all three are copied from the
|
|
13
|
+
* reference rather than invented:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Plan mode blocks edits to everything except the plan file, in every
|
|
16
|
+
* approval mode.** Grok documents this explicitly: "This holds in every
|
|
17
|
+
* permission mode, including always-approve." The edit gate is registered
|
|
18
|
+
* *before* the approval gate, and the SDK returns on the first `block`, so
|
|
19
|
+
* `full-auto` cannot punch through it.
|
|
20
|
+
* 2. **The plan is read from disk, not from the model.** `exit_plan_mode`
|
|
21
|
+
* takes no plan content as input; it reads the plan file and presents what
|
|
22
|
+
* is actually on disk. Grok's own comment is the justification: it prevents
|
|
23
|
+
* "divergence between the model's in-context plan and the actual file
|
|
24
|
+
* content" — the user reviews what will be built, not a summary of it.
|
|
25
|
+
* 3. **The state machine is pure.** Every transition is a function over a
|
|
26
|
+
* small snapshot, so the whole lifecycle is testable with no session, no
|
|
27
|
+
* model, and no terminal. Grok's tracker is built the same way.
|
|
28
|
+
*
|
|
29
|
+
* One deliberate divergence from the reference: Grok alternates between a full
|
|
30
|
+
* reminder and a one-line sparse one, to save tokens. That is a cache hazard
|
|
31
|
+
* here. Our reminder is appended to the system message, which is the cacheable
|
|
32
|
+
* prefix of every request, so alternating the text would invalidate the prefix
|
|
33
|
+
* on every other turn — the same class of mistake that cost Claude Code 10.2% of
|
|
34
|
+
* fleet-wide cache-creation tokens. A single stable block costs ~80 tokens a
|
|
35
|
+
* turn and never invalidates anything, so the alternation is dropped and the
|
|
36
|
+
* state machine carries only what it needs.
|
|
37
|
+
*
|
|
38
|
+
* Honest limits, both inherited from the reference and stated here so nobody
|
|
39
|
+
* assumes more isolation than exists:
|
|
40
|
+
* - Shell commands are not inspected for writes. Plan mode blocks the edit
|
|
41
|
+
* tools, not `bash > file`. Grok documents the same gap.
|
|
42
|
+
* - A subagent starts with its own tracker (inactive), so a write-capable
|
|
43
|
+
* subagent is not covered by the parent's gate. Grok documents this too.
|
|
44
|
+
*/
|
|
45
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
46
|
+
import { dirname, join } from "node:path";
|
|
47
|
+
import { defineTool } from "@netrodev/hxf-coding-agent";
|
|
48
|
+
import { Type } from "typebox";
|
|
49
|
+
import { writeFileAtomicSync } from "./fs-atomic.js";
|
|
50
|
+
export function emptyPlanSnapshot() {
|
|
51
|
+
return { state: "inactive", wasActive: false };
|
|
52
|
+
}
|
|
53
|
+
/** Collapse transient state, the way a restore must. */
|
|
54
|
+
export function restorePlanSnapshot(snapshot) {
|
|
55
|
+
if (!snapshot)
|
|
56
|
+
return emptyPlanSnapshot();
|
|
57
|
+
if (snapshot.state === "pending")
|
|
58
|
+
return { ...snapshot, state: "inactive" };
|
|
59
|
+
return { ...snapshot };
|
|
60
|
+
}
|
|
61
|
+
/** User toggled the mode on. */
|
|
62
|
+
export function enterPending(snapshot) {
|
|
63
|
+
if (snapshot.state !== "inactive")
|
|
64
|
+
return snapshot;
|
|
65
|
+
return { ...snapshot, state: "pending" };
|
|
66
|
+
}
|
|
67
|
+
/** The first prompt while pending activates the mode. */
|
|
68
|
+
export function activate(snapshot) {
|
|
69
|
+
if (snapshot.state !== "pending")
|
|
70
|
+
return snapshot;
|
|
71
|
+
return { state: "active", wasActive: true };
|
|
72
|
+
}
|
|
73
|
+
/** The agent asked to enter and the user approved: straight to active. */
|
|
74
|
+
export function activateFromTool(snapshot) {
|
|
75
|
+
if (snapshot.state !== "inactive")
|
|
76
|
+
return snapshot;
|
|
77
|
+
return { state: "active", wasActive: true };
|
|
78
|
+
}
|
|
79
|
+
/** The plan was approved, so the mode ends. */
|
|
80
|
+
export function deactivateApproved(snapshot) {
|
|
81
|
+
if (snapshot.state !== "active")
|
|
82
|
+
return snapshot;
|
|
83
|
+
return { ...snapshot, state: "inactive" };
|
|
84
|
+
}
|
|
85
|
+
/** The user toggled the mode off. */
|
|
86
|
+
export function userExit(snapshot) {
|
|
87
|
+
if (snapshot.state === "inactive")
|
|
88
|
+
return snapshot;
|
|
89
|
+
return { ...snapshot, state: "inactive" };
|
|
90
|
+
}
|
|
91
|
+
export function isPlanActive(snapshot) {
|
|
92
|
+
return snapshot.state === "active";
|
|
93
|
+
}
|
|
94
|
+
/** Whether a write target is the plan file. Exact match, as in the reference. */
|
|
95
|
+
export function isPlanFileWrite(target, planFile) {
|
|
96
|
+
return target === planFile;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The per-turn reminder, appended to the system message.
|
|
100
|
+
*
|
|
101
|
+
* Stable on purpose: it changes only when the plan file gains its first content,
|
|
102
|
+
* which happens once per planning session. Nothing volatile may enter it, or the
|
|
103
|
+
* cached prefix is lost on every turn.
|
|
104
|
+
*/
|
|
105
|
+
export function planReminder(planFile, hasContent) {
|
|
106
|
+
const where = hasContent ? `A plan file exists at ${planFile}. Read it and refine it with the \`edit\` tool.` : `No plan is written yet. Write yours to ${planFile} with the \`write\` tool.`;
|
|
107
|
+
return [
|
|
108
|
+
"<plan-mode>",
|
|
109
|
+
"Plan mode is active. Do not make any edits or writes to the system.",
|
|
110
|
+
where,
|
|
111
|
+
"That file is the only path you may edit.",
|
|
112
|
+
"Explore first: read, search, and run read-only commands until the approach is clear.",
|
|
113
|
+
"End your turn with either `ask` to clarify a requirement, or `exit_plan_mode` to present",
|
|
114
|
+
"the plan for approval. Do not start implementing before the plan is approved.",
|
|
115
|
+
"</plan-mode>",
|
|
116
|
+
].join("\n");
|
|
117
|
+
}
|
|
118
|
+
/** What the model is told when it tries to edit outside the plan file. */
|
|
119
|
+
export function planEditRefusal(planFile) {
|
|
120
|
+
return `Rejected: file edits are not allowed in plan mode. The only editable file is the plan file (${planFile}). Present the plan with exit_plan_mode first.`;
|
|
121
|
+
}
|
|
122
|
+
/** The plan file for the current session, or undefined before one is known. */
|
|
123
|
+
let planFilePath;
|
|
124
|
+
let snapshot = emptyPlanSnapshot();
|
|
125
|
+
let sessionDir;
|
|
126
|
+
export function currentPlanSnapshot() {
|
|
127
|
+
return snapshot;
|
|
128
|
+
}
|
|
129
|
+
export function currentPlanFile() {
|
|
130
|
+
return planFilePath;
|
|
131
|
+
}
|
|
132
|
+
export function planActive() {
|
|
133
|
+
return isPlanActive(snapshot);
|
|
134
|
+
}
|
|
135
|
+
/** Read the plan from disk. Empty or missing reads as absent. */
|
|
136
|
+
export function readPlan() {
|
|
137
|
+
if (!planFilePath || !existsSync(planFilePath))
|
|
138
|
+
return undefined;
|
|
139
|
+
try {
|
|
140
|
+
const text = readFileSync(planFilePath, "utf8");
|
|
141
|
+
return text.trim() ? text : undefined;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export function planHasContent() {
|
|
148
|
+
return readPlan() !== undefined;
|
|
149
|
+
}
|
|
150
|
+
/** The short form for the HUD. Empty when the mode is off. */
|
|
151
|
+
export function planSegment() {
|
|
152
|
+
if (snapshot.state === "inactive")
|
|
153
|
+
return "";
|
|
154
|
+
if (snapshot.state === "pending")
|
|
155
|
+
return "plan pending";
|
|
156
|
+
return planHasContent() ? "plan ● ready" : "plan ● planning";
|
|
157
|
+
}
|
|
158
|
+
/** Path of the small state file that survives a resume, beside the plan. */
|
|
159
|
+
function statePath() {
|
|
160
|
+
return sessionDir ? join(sessionDir, "plan_mode.json") : undefined;
|
|
161
|
+
}
|
|
162
|
+
function persistSnapshot() {
|
|
163
|
+
const path = statePath();
|
|
164
|
+
if (!path)
|
|
165
|
+
return;
|
|
166
|
+
try {
|
|
167
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
168
|
+
writeFileAtomicSync(path, JSON.stringify(snapshot));
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// A session directory that cannot be written is not worth failing a turn
|
|
172
|
+
// over: the mode still works for this process.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function loadSnapshot() {
|
|
176
|
+
const path = statePath();
|
|
177
|
+
if (!path || !existsSync(path))
|
|
178
|
+
return emptyPlanSnapshot();
|
|
179
|
+
try {
|
|
180
|
+
return restorePlanSnapshot(JSON.parse(readFileSync(path, "utf8")));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return emptyPlanSnapshot();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Point the mode at this session's directory and restore what a previous run
|
|
188
|
+
* left there. Plan mode is session state, so a resume must not silently drop it.
|
|
189
|
+
*/
|
|
190
|
+
export function openPlanSession(dir) {
|
|
191
|
+
sessionDir = dir;
|
|
192
|
+
planFilePath = join(dir, "plan.md");
|
|
193
|
+
snapshot = loadSnapshot();
|
|
194
|
+
}
|
|
195
|
+
/** Apply a transition and persist it. Every mutation goes through here. */
|
|
196
|
+
export function transition(next) {
|
|
197
|
+
snapshot = next;
|
|
198
|
+
persistSnapshot();
|
|
199
|
+
return snapshot;
|
|
200
|
+
}
|
|
201
|
+
/** Test seam: forget the session, the mode, and the file. Test-only; see check-wiring.mjs. */
|
|
202
|
+
export function resetPlanMode() {
|
|
203
|
+
planFilePath = undefined;
|
|
204
|
+
sessionDir = undefined;
|
|
205
|
+
snapshot = emptyPlanSnapshot();
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The edit gate. Registered before the approval gate, and it blocks in every
|
|
209
|
+
* approval mode — the SDK returns on the first `block`, so `full-auto` cannot
|
|
210
|
+
* override it. `bash` is deliberately not inspected: see the module comment.
|
|
211
|
+
*/
|
|
212
|
+
export function createPlanGateExtension() {
|
|
213
|
+
return (hxf) => {
|
|
214
|
+
hxf.on("tool_call", async (event) => {
|
|
215
|
+
if (!isPlanActive(snapshot))
|
|
216
|
+
return undefined;
|
|
217
|
+
if (event.toolName !== "edit" && event.toolName !== "write")
|
|
218
|
+
return undefined;
|
|
219
|
+
const path = event.input?.path;
|
|
220
|
+
if (typeof path !== "string")
|
|
221
|
+
return undefined;
|
|
222
|
+
if (!planFilePath || isPlanFileWrite(path, planFilePath))
|
|
223
|
+
return undefined;
|
|
224
|
+
return { block: true, reason: planEditRefusal(planFilePath) };
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Keeps the reminder in front of the model, and the mode alive across a resume.
|
|
230
|
+
*
|
|
231
|
+
* The reminder is appended to the system message, following the pattern the
|
|
232
|
+
* shared-notes extension already proved in this codebase: it is the leading
|
|
233
|
+
* message the provider reads the prompt from, and it is the only place a
|
|
234
|
+
* per-request instruction can live without becoming a fake user turn.
|
|
235
|
+
*/
|
|
236
|
+
export function createPlanPromptExtension() {
|
|
237
|
+
return (hxf) => {
|
|
238
|
+
hxf.on("session_start", async (_event, ctx) => {
|
|
239
|
+
openPlanSession(ctx.sessionManager.getSessionDir());
|
|
240
|
+
});
|
|
241
|
+
// The first prompt after `/plan` is what activates the mode: the model must
|
|
242
|
+
// not be told about plan mode until it is about to run under it.
|
|
243
|
+
hxf.on("before_agent_start", async () => {
|
|
244
|
+
if (snapshot.state === "pending")
|
|
245
|
+
transition(activate(snapshot));
|
|
246
|
+
});
|
|
247
|
+
hxf.on("context_with_system", async (event) => {
|
|
248
|
+
if (!isPlanActive(snapshot) || !planFilePath)
|
|
249
|
+
return undefined;
|
|
250
|
+
const head = event.messages[0];
|
|
251
|
+
if (!head || head.role !== "system")
|
|
252
|
+
return undefined;
|
|
253
|
+
const reminder = planReminder(planFilePath, planHasContent());
|
|
254
|
+
const combined = `${typeof head.content === "string" ? head.content : ""}\n\n${reminder}`;
|
|
255
|
+
const messages = event.messages.map((message) => (message === head ? { ...head, content: combined } : message));
|
|
256
|
+
return { messages };
|
|
257
|
+
});
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/** Longest plan the exit tool will present, so a runaway file cannot flood the UI. */
|
|
261
|
+
export const MAX_PLAN_CHARS = 20_000;
|
|
262
|
+
function ensurePlanFile() {
|
|
263
|
+
if (!planFilePath)
|
|
264
|
+
return undefined;
|
|
265
|
+
try {
|
|
266
|
+
mkdirSync(dirname(planFilePath), { recursive: true });
|
|
267
|
+
if (!existsSync(planFilePath))
|
|
268
|
+
writeFileAtomicSync(planFilePath, "");
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
return planFilePath;
|
|
274
|
+
}
|
|
275
|
+
export const enterPlanModeTool = defineTool({
|
|
276
|
+
name: "enter_plan_mode",
|
|
277
|
+
label: "enter_plan_mode",
|
|
278
|
+
description: "Enter plan mode to design an approach before writing code. Use it when a task has genuine ambiguity; several reasonable designs, or a restructuring where the wrong approach wastes significant effort. Do not use it for a clear fix, a rename, or a task that follows existing conventions. The user must approve entering.",
|
|
279
|
+
promptSnippet: "enter_plan_mode: design first when the approach is genuinely ambiguous",
|
|
280
|
+
parameters: Type.Object({}),
|
|
281
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
282
|
+
if (snapshot.state !== "inactive") {
|
|
283
|
+
return { content: [{ type: "text", text: `Plan mode is already ${snapshot.state}.` }], details: {} };
|
|
284
|
+
}
|
|
285
|
+
if (!ctx.hasUI) {
|
|
286
|
+
return {
|
|
287
|
+
content: [{ type: "text", text: "Plan mode needs an interactive session: there is no one to approve the plan. Proceed normally." }],
|
|
288
|
+
details: {},
|
|
289
|
+
isError: true,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const approved = await ctx.ui.confirm("Enter plan mode?", "The agent will explore and design an approach before editing anything.");
|
|
293
|
+
if (!approved) {
|
|
294
|
+
return { content: [{ type: "text", text: "The user declined plan mode. Proceed normally." }], details: {} };
|
|
295
|
+
}
|
|
296
|
+
transition(activateFromTool(snapshot));
|
|
297
|
+
const file = ensurePlanFile();
|
|
298
|
+
return {
|
|
299
|
+
content: [
|
|
300
|
+
{
|
|
301
|
+
type: "text",
|
|
302
|
+
text: ["Plan mode is active. Edits are blocked except for the plan file.", file ? `Write your plan to ${file}.` : "Write your plan to the session plan file."].join("\n"),
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
details: { planFile: file },
|
|
306
|
+
};
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
export const exitPlanModeTool = defineTool({
|
|
310
|
+
name: "exit_plan_mode",
|
|
311
|
+
label: "exit_plan_mode",
|
|
312
|
+
description: "Present your plan for approval when you have finished planning. The plan is read from the plan file on disk, so write the file first. If the user asks for changes, stay in plan mode and revise it.",
|
|
313
|
+
promptSnippet: "exit_plan_mode: present the written plan for approval",
|
|
314
|
+
parameters: Type.Object({}),
|
|
315
|
+
// Sequential: the plan is read from disk, so a `write` to the plan file issued
|
|
316
|
+
// in the same batch must have finished first. Run in parallel, this tool reads
|
|
317
|
+
// the file before the write lands and reports an empty plan — observed in a
|
|
318
|
+
// real run. A tool that depends on a sibling's file effect must not race it.
|
|
319
|
+
executionMode: "sequential",
|
|
320
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
321
|
+
if (!isPlanActive(snapshot)) {
|
|
322
|
+
return { content: [{ type: "text", text: "Plan mode is not active." }], details: {}, isError: true };
|
|
323
|
+
}
|
|
324
|
+
const plan = readPlan();
|
|
325
|
+
if (!plan) {
|
|
326
|
+
return {
|
|
327
|
+
content: [
|
|
328
|
+
{
|
|
329
|
+
type: "text",
|
|
330
|
+
text: `No plan has been written yet. Write it to ${planFilePath ?? "the plan file"} before exiting plan mode.`,
|
|
331
|
+
},
|
|
332
|
+
],
|
|
333
|
+
details: {},
|
|
334
|
+
isError: true,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
if (!ctx.hasUI) {
|
|
338
|
+
return {
|
|
339
|
+
content: [{ type: "text", text: "Plan approval needs an interactive session." }],
|
|
340
|
+
details: {},
|
|
341
|
+
isError: true,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
const shown = plan.length > MAX_PLAN_CHARS ? `${plan.slice(0, MAX_PLAN_CHARS)}\n… (${plan.length - MAX_PLAN_CHARS} more characters)` : plan;
|
|
345
|
+
const approved = await ctx.ui.confirm("Approve this plan?", shown);
|
|
346
|
+
if (!approved) {
|
|
347
|
+
return {
|
|
348
|
+
content: [
|
|
349
|
+
{
|
|
350
|
+
type: "text",
|
|
351
|
+
text: "The user did not approve the plan. Stay in plan mode, revise the plan file with the feedback, and present it again.",
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
details: { approved: false },
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
transition(deactivateApproved(snapshot));
|
|
358
|
+
return {
|
|
359
|
+
content: [{ type: "text", text: "Plan approved. You may now implement it." }],
|
|
360
|
+
details: { approved: true, planFile: planFilePath },
|
|
361
|
+
};
|
|
362
|
+
},
|
|
363
|
+
});
|