@indigoai-us/hq-cli 5.345.203 → 5.345.204
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/CHANGELOG.md +9 -0
- package/dist/commands/bot.js +7 -2
- package/dist/commands/cloud.d.ts +14 -2
- package/dist/commands/cloud.js +25 -16
- package/dist/commands/sync-core.js +2 -0
- package/dist/lib/bot/claude-login.d.ts +7 -1
- package/dist/lib/bot/claude-login.js +56 -16
- package/dist/lib/capacity/collectors/claude-oauth.d.ts +53 -5
- package/dist/lib/capacity/collectors/claude-oauth.js +251 -71
- package/dist/lib/daemon/catalog.js +2 -2
- package/dist/lib/daemon/run.js +2 -2
- package/dist/lib/lanes/config.js +13 -0
- package/dist/lib/lanes/throttle.d.ts +12 -1
- package/dist/lib/lanes/throttle.js +27 -8
- package/dist/lib/lanes/types.d.ts +5 -0
- package/dist/lib/signals/steps/extract.d.ts +110 -4
- package/dist/lib/signals/steps/extract.js +520 -39
- package/dist/lib/sync-core/engine-home.d.ts +2 -1
- package/dist/lib/sync-core/engine-home.js +25 -8
- package/dist/lib/sync-core/reconcile.d.ts +3 -1
- package/dist/lib/sync-core/reconcile.js +21 -8
- package/dist/lib/sync-core/release.d.ts +14 -5
- package/dist/lib/sync-core/release.js +58 -14
- package/package.json +7 -7
|
@@ -49,8 +49,60 @@
|
|
|
49
49
|
* --ignore-user-config drops ~/.codex/config.toml (its MCP servers);
|
|
50
50
|
* --ignore-rules drops execpolicy allow rules. Codex has no turn cap flag;
|
|
51
51
|
* the per-item timeout is its only limit, and it reports no cost.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
52
|
+
*
|
|
53
|
+
* grok, checked 2026-11-05 against `grok --help` and the user guide grok
|
|
54
|
+
* 1.0.70 ships (its embedded 22-permissions-and-safety.md and 18-sandbox.md)
|
|
55
|
+
* on Shawon's Mac:
|
|
56
|
+
* grok -p <prompt> --output-format streaming-json --cwd <turn dir>
|
|
57
|
+
* --permission-mode dontAsk --tools read_file
|
|
58
|
+
* --disallowed-tools search_tool,use_tool,run_terminal_cmd --disable-web-search
|
|
59
|
+
* --no-subagents --max-turns <n> --sandbox hq_signals_extract
|
|
60
|
+
* --allow "Read(<channel dir>/**)" --deny Bash
|
|
61
|
+
* --deny MCPTool --deny WebFetch --deny WebSearch --deny Edit --deny Write
|
|
62
|
+
* --deny Grep --deny "Read(~/.grok/**)"
|
|
63
|
+
* --deny "Read(<turn home>/.grok/**)" --deny "Read(<real home>/.grok/**)"
|
|
64
|
+
* --deny "Bash(ls)" --deny "Bash(cat)" ... (GROK_READ_ONLY_COMMANDS)
|
|
65
|
+
* dontAsk denies every call no allow rule covers, but it still auto-approves
|
|
66
|
+
* grok's own read-only tools and read-only shell list (ls, cat, head, git,
|
|
67
|
+
* rg, ...), so those commands are denied by name (a Bash rule without `*`
|
|
68
|
+
* is a prefix match). A `~` path in a rule is matched literally, before grok
|
|
69
|
+
* expands `~`, so the login deny lists that spelling, the absolute isolated
|
|
70
|
+
* home and the real home. The isolated auth.json stays readable by the grok
|
|
71
|
+
* process itself (strict reads ~/.grok, and the process loads the login at
|
|
72
|
+
* startup). Those Read denies are what keep it out of the model tool.
|
|
73
|
+
* Grok gets only read_file; Bash and the terminal tool are disabled.
|
|
74
|
+
* It returns candidate commands as JSON data. HQ validates the whole batch,
|
|
75
|
+
* checks the pinned wrapper still matches the script it generated, then
|
|
76
|
+
* executes that wrapper with fixed argv (never shell -c).
|
|
77
|
+
* --disallowed-tools also drops the MCP bridge (search_tool, use_tool).
|
|
78
|
+
* --sandbox names a profile the step writes to <turn dir>/.grok/sandbox.toml
|
|
79
|
+
* for the turn: it extends grok's built-in `strict` (reads: cwd, system
|
|
80
|
+
* paths, ~/.grok; writes: cwd, temp dirs, ~/.grok/sessions) with read-only
|
|
81
|
+
* grants for bash's install prefix, core/scripts, the company's knowledge
|
|
82
|
+
* preferences and the item's channel folder, and restrict_network = true.
|
|
83
|
+
* The company's _candidates folders are not granted. The host writes them
|
|
84
|
+
* after the turn, outside the sandbox. The profile is
|
|
85
|
+
* kernel-enforced (Seatbelt on macOS, Landlock on Linux) for read_file and
|
|
86
|
+
* every child process; grok refuses to start when it cannot be applied.
|
|
87
|
+
* grok's own docs say child-process network blocking is Linux-only, so
|
|
88
|
+
* extract does not select grok except on linux: it falls through to claude,
|
|
89
|
+
* then codex. grok has no switch that skips user config: it always loads
|
|
90
|
+
* ~/.grok/config.toml and the Claude-compatible permission rules in
|
|
91
|
+
* ~/.claude/settings*.json. The turn therefore runs with HOME,
|
|
92
|
+
* XDG_CONFIG_HOME, XDG_DATA_HOME and CLAUDE_CONFIG_DIR pointed at a fresh
|
|
93
|
+
* isolated directory carrying only a private auth.json login copy, removed
|
|
94
|
+
* when the turn ends, and with an env
|
|
95
|
+
* allowlist instead of the parent process environment. GROK_SANDBOX,
|
|
96
|
+
* GROK_SANDBOX_AUTO_ALLOW_BASH, GROK_CONFIG and GROK_CONFIG_PATH are not on
|
|
97
|
+
* that allowlist, and the GROK_{CLAUDE,CURSOR}_*_ENABLED scans are off.
|
|
98
|
+
* A refused command ends the grok turn (stopReason "cancelled") instead of
|
|
99
|
+
* returning an error to the model. The `end` event carries num_turns and
|
|
100
|
+
* total_cost_usd (cost may be absent; then 0, as for codex).
|
|
101
|
+
* `grok --help` is probed once per grok binary path. A grok without every flag above
|
|
102
|
+
* is never run: the step falls back to claude, then codex, when that tool
|
|
103
|
+
* is installed and signed in, and otherwise fails the run naming the grok
|
|
104
|
+
* version it needs. A main bot on grok with no grok installed falls back the
|
|
105
|
+
* same way.
|
|
54
106
|
*/
|
|
55
107
|
import * as fs from "node:fs";
|
|
56
108
|
import * as os from "node:os";
|
|
@@ -61,6 +113,9 @@ import { splitFrontmatter } from "../../hot-channels/signals-pass.js";
|
|
|
61
113
|
import { audienceKey } from "../../hot-channels/audience.js";
|
|
62
114
|
import { readBotConfig, effectiveBotKind, isBotRuntimeId } from "../../bot/config.js";
|
|
63
115
|
import { botsRoot } from "../../bot/paths.js";
|
|
116
|
+
import { parseClaudeAuthStatus } from "../../bot/runtime/claude.js";
|
|
117
|
+
import { parseCodexLoginStatus } from "../../bot/runtime/codex.js";
|
|
118
|
+
import { SIGN_IN_CHECK_TIMEOUT_MS } from "../../bot/runtime-sign-in.js";
|
|
64
119
|
import { CLAUDE_LOGGED_OUT_RE, CLAUDE_ISOLATION_ARGS, parseClaudeSignals } from "../../hot-channels/signals-pass.js";
|
|
65
120
|
import { assertAllowedPath, assertNoEscapingLinks, guardedRemove, guardedWrite, realRelPath } from "../write-guard.js";
|
|
66
121
|
export const EXTRACT_SIGNAL_TYPES = [
|
|
@@ -88,6 +143,40 @@ export function worklistTimeoutMs(limit) {
|
|
|
88
143
|
}
|
|
89
144
|
export const CODEX_PERMISSION_PROFILE = "hq_signals_extract";
|
|
90
145
|
export const CANDIDATE_WRAPPER_NAME = "write-candidate.sh";
|
|
146
|
+
/** The sandbox profile the step writes to <turn dir>/.grok/sandbox.toml for a grok turn. */
|
|
147
|
+
export const GROK_SANDBOX_PROFILE = "hq_signals_extract";
|
|
148
|
+
export const GROK_SANDBOX_FILE = path.join(".grok", "sandbox.toml");
|
|
149
|
+
/** The oldest grok whose `--help` lists every flag in GROK_EXTRACT_FLAGS. */
|
|
150
|
+
export const GROK_MIN_VERSION = "1.0.70";
|
|
151
|
+
/** Flags a grok turn needs; one missing from `--help` means the installed grok cannot be locked down. */
|
|
152
|
+
export const GROK_EXTRACT_FLAGS = [
|
|
153
|
+
"--permission-mode",
|
|
154
|
+
"--tools",
|
|
155
|
+
"--disallowed-tools",
|
|
156
|
+
"--allow",
|
|
157
|
+
"--deny",
|
|
158
|
+
"--sandbox",
|
|
159
|
+
"--disable-web-search",
|
|
160
|
+
"--no-subagents",
|
|
161
|
+
"--max-turns",
|
|
162
|
+
"--cwd",
|
|
163
|
+
"--output-format",
|
|
164
|
+
];
|
|
165
|
+
/**
|
|
166
|
+
* grok's built-in read-only shell list (its permissions guide, "Read-Only
|
|
167
|
+
* Shell Commands"): auto-approved even under dontAsk, so each is denied by
|
|
168
|
+
* name. A Bash rule without `*` is a prefix match, so `Bash(cat)` also covers
|
|
169
|
+
* `cat` with any arguments.
|
|
170
|
+
*/
|
|
171
|
+
export const GROK_READ_ONLY_COMMANDS = [
|
|
172
|
+
"ls", "cat", "pwd", "date", "whoami", "hostname", "uptime", "ps",
|
|
173
|
+
"head", "tail", "wc", "sort", "uniq", "tr", "cut",
|
|
174
|
+
"mkdir", "touch", "git", "grep", "rg", "kubectl",
|
|
175
|
+
];
|
|
176
|
+
/** Environment keys that could widen or relocate a grok turn's configuration. */
|
|
177
|
+
const GROK_ENV_DROP = ["GROK_SANDBOX", "GROK_SANDBOX_AUTO_ALLOW_BASH", "GROK_CONFIG", "GROK_CONFIG_PATH"];
|
|
178
|
+
/** The Claude and Cursor configuration scans grok runs at start, turned off for a turn. */
|
|
179
|
+
const GROK_COMPAT_SCANS = ["SKILLS", "RULES", "AGENTS", "MCPS", "HOOKS"].flatMap((s) => [`GROK_CLAUDE_${s}_ENABLED`, `GROK_CURSOR_${s}_ENABLED`]);
|
|
91
180
|
/** The only tools a claude turn may use: Read inside the channel folder and the pinned wrapper. */
|
|
92
181
|
export function claudeAllowedTools(channelDir, wrapperPath) {
|
|
93
182
|
// A rule path that starts with `//` is absolute.
|
|
@@ -217,14 +306,21 @@ export function buildExtractPrompt(input) {
|
|
|
217
306
|
"Rules:",
|
|
218
307
|
input.runtime === "codex"
|
|
219
308
|
? "- Read the item with a shell command such as cat. Only files in the item's channel folder are readable."
|
|
220
|
-
:
|
|
309
|
+
: input.runtime === "grok"
|
|
310
|
+
? "- Read the item with the read_file tool. Only files in the item's channel folder are readable."
|
|
311
|
+
: "- Read the item with the Read tool. Only files in the item's channel folder are readable.",
|
|
221
312
|
`- Write candidates ONLY with: bash ${input.wrapperPath} --kind signal|entity --type <type> --body "<text>"`,
|
|
222
313
|
"- That command already fixes the company, audience and source ref for this item. Do not pass --company, --audience, --source-ref or --body-file; it refuses them.",
|
|
314
|
+
...(input.runtime === "grok"
|
|
315
|
+
? ['- You have no terminal tool. Return JSON only: {"commands":["bash ..."]}. HQ executes validated commands after your turn. At most 8 commands; use an empty array for no candidates.', "- Use exactly the flags above in that order; quote the body as a literal. No pipes, no &&, no ;, no substitutions, redirections or other commands outside quoted text."]
|
|
316
|
+
: []),
|
|
223
317
|
`- Signal types (exactly these): ${EXTRACT_SIGNAL_TYPES.join(", ")}.`,
|
|
224
318
|
`- Entity types: ${ENTITY_TYPES.join(", ")}.`,
|
|
225
319
|
"- Every signal body must end with a citation: a one-sentence quote from the item or its timestamp, for example: (cite: \"we ship Friday\") or (cite: 00:12:31).",
|
|
226
320
|
"- No other command can run, and no other file can be written.",
|
|
227
|
-
|
|
321
|
+
input.runtime === "grok"
|
|
322
|
+
? '- Return only the JSON commands object, without markdown fences. This overrides the skill instructions about executing commands.'
|
|
323
|
+
: "- When finished, reply with one line: the number of candidates written.",
|
|
228
324
|
"",
|
|
229
325
|
...(input.priorSignals?.length ? [
|
|
230
326
|
"Already extracted for this source; do not repeat; return `update: <id>` to amend one",
|
|
@@ -290,11 +386,22 @@ export function buildCandidateWrapper(input) {
|
|
|
290
386
|
"",
|
|
291
387
|
].join("\n");
|
|
292
388
|
}
|
|
293
|
-
/**
|
|
389
|
+
/**
|
|
390
|
+
* Parse a stream-json transcript (claude), a codex --json event stream, or a
|
|
391
|
+
* grok streaming-json event stream.
|
|
392
|
+
*
|
|
393
|
+
* grok writes `{"type":"text","data":…}` deltas and one terminal
|
|
394
|
+
* `{"type":"end","stopReason":…,"num_turns":n,"total_cost_usd":x}`; a refused
|
|
395
|
+
* tool call ends the turn with stopReason "cancelled", and an API failure is
|
|
396
|
+
* `{"type":"error","message":…}`. Both are mapped to an error result so the
|
|
397
|
+
* item fails and is retried; only "end_turn" is success.
|
|
398
|
+
*/
|
|
294
399
|
export function parseTurnOutput(stdout) {
|
|
295
400
|
let result = null;
|
|
296
401
|
let codexTurns = 0;
|
|
297
402
|
let codexDone = false;
|
|
403
|
+
let grokText = "";
|
|
404
|
+
let grokError;
|
|
298
405
|
for (const line of stdout.split("\n")) {
|
|
299
406
|
const t = line.trim();
|
|
300
407
|
if (!t.startsWith("{"))
|
|
@@ -317,6 +424,25 @@ export function parseTurnOutput(stdout) {
|
|
|
317
424
|
}
|
|
318
425
|
if (o.type === "turn.failed")
|
|
319
426
|
codexTurns += 1;
|
|
427
|
+
if (o.type === "text" && typeof o.data === "string")
|
|
428
|
+
grokText += o.data;
|
|
429
|
+
if (o.type === "error" && typeof o.message === "string")
|
|
430
|
+
grokError = o.message;
|
|
431
|
+
if (o.type === "end") {
|
|
432
|
+
const stop = typeof o.stopReason === "string" ? o.stopReason : "";
|
|
433
|
+
const ok = /^end_?turn$/i.test(stop);
|
|
434
|
+
result = {
|
|
435
|
+
type: "result",
|
|
436
|
+
subtype: ok ? "success" : stop || "no stop reason",
|
|
437
|
+
is_error: !ok,
|
|
438
|
+
result: ok ? grokText : `grok stopped: ${stop || "no stop reason"}`,
|
|
439
|
+
...(typeof o.num_turns === "number" ? { num_turns: o.num_turns } : {}),
|
|
440
|
+
...(typeof o.total_cost_usd === "number" ? { total_cost_usd: o.total_cost_usd } : {}),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (!result && grokError !== undefined) {
|
|
445
|
+
result = { type: "result", subtype: "error", is_error: true, result: grokError };
|
|
320
446
|
}
|
|
321
447
|
if (result) {
|
|
322
448
|
const turns = typeof result.num_turns === "number" ? result.num_turns : 1;
|
|
@@ -564,24 +690,315 @@ function toolPrefixRoots(bins, envPath, hqRoot) {
|
|
|
564
690
|
}
|
|
565
691
|
return [...new Set(roots)];
|
|
566
692
|
}
|
|
567
|
-
/**
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const
|
|
693
|
+
/**
|
|
694
|
+
* The HQ paths a sandboxed turn may touch, beyond the turn dir and the tool
|
|
695
|
+
* install prefixes: core/scripts and the knowledge preferences the candidate
|
|
696
|
+
* script reads, the item's channel folder, and the company's _candidates
|
|
697
|
+
* folders it writes.
|
|
698
|
+
*/
|
|
699
|
+
function hqTurnGrants(scope) {
|
|
700
|
+
const hq = fs.realpathSync(scope.hqRoot);
|
|
701
|
+
const entries = [[path.join(hq, "core", "scripts"), "read"]];
|
|
702
|
+
const prefs = path.join(hq, "companies", scope.company, "settings", "knowledge");
|
|
575
703
|
if (fs.existsSync(prefs))
|
|
576
704
|
entries.push([prefs, "read"]);
|
|
577
|
-
const personalPrefs = path.join(
|
|
705
|
+
const personalPrefs = path.join(hq, "personal", "settings", "knowledge-preferences.yaml");
|
|
578
706
|
if (fs.existsSync(personalPrefs))
|
|
579
707
|
entries.push([personalPrefs, "read"]);
|
|
580
708
|
entries.push([scope.channelDir, "read"]);
|
|
581
709
|
for (const d of scope.candidateDirs)
|
|
582
710
|
entries.push([d, "write"]);
|
|
711
|
+
return entries;
|
|
712
|
+
}
|
|
713
|
+
/** TOML inline table for the codex permission profile's filesystem entries. */
|
|
714
|
+
export function codexFilesystemProfile(scope, codexBin, envPath) {
|
|
715
|
+
const entries = [[":minimal", "read"]];
|
|
716
|
+
for (const r of toolPrefixRoots([codexBin, "bash"], envPath, scope.hqRoot))
|
|
717
|
+
entries.push([r, "read"]);
|
|
718
|
+
entries.push([scope.turnDir, "read"]);
|
|
719
|
+
entries.push(...hqTurnGrants(scope));
|
|
583
720
|
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}=${JSON.stringify(v)}`).join(",")}}`;
|
|
584
721
|
}
|
|
722
|
+
/**
|
|
723
|
+
* The sandbox profile for a grok turn, written to <turn dir>/.grok/sandbox.toml.
|
|
724
|
+
* grok's `strict` already covers the turn dir (cwd), system paths and ~/.grok;
|
|
725
|
+
* the grants add bash's install prefix (Homebrew bash loads from it) and the
|
|
726
|
+
* read-only HQ paths of hqTurnGrants. Candidate folders are not granted:
|
|
727
|
+
* the host writes them after the turn, outside this sandbox.
|
|
728
|
+
*/
|
|
729
|
+
export function grokSandboxProfile(scope, envPath) {
|
|
730
|
+
const reads = [...toolPrefixRoots(["bash"], envPath, scope.hqRoot)];
|
|
731
|
+
for (const [p, mode] of hqTurnGrants(scope))
|
|
732
|
+
if (mode === "read")
|
|
733
|
+
reads.push(p);
|
|
734
|
+
const list = (items) => `[${items.map((p) => JSON.stringify(p)).join(", ")}]`;
|
|
735
|
+
return [
|
|
736
|
+
`[profiles.${GROK_SANDBOX_PROFILE}]`,
|
|
737
|
+
'extends = "strict"',
|
|
738
|
+
"restrict_network = true",
|
|
739
|
+
`read_only = ${list(reads)}`,
|
|
740
|
+
"read_write = []",
|
|
741
|
+
"",
|
|
742
|
+
].join("\n");
|
|
743
|
+
}
|
|
744
|
+
/** Grok can read the source; candidate writing belongs to the host. */
|
|
745
|
+
export function grokAllowRules(channelDir) {
|
|
746
|
+
return [`Read(${channelDir}/**)`];
|
|
747
|
+
}
|
|
748
|
+
/** Parse literal arguments only. No shell ever interprets model output. */
|
|
749
|
+
export function grokCandidateArgv(command, wrapperPath) {
|
|
750
|
+
const words = [];
|
|
751
|
+
let i = 0;
|
|
752
|
+
while (i < command.length) {
|
|
753
|
+
while (command[i] === " " || command[i] === "\t")
|
|
754
|
+
i++;
|
|
755
|
+
if (i === command.length)
|
|
756
|
+
break;
|
|
757
|
+
let word = "";
|
|
758
|
+
while (i < command.length && command[i] !== " " && command[i] !== "\t") {
|
|
759
|
+
const ch = command[i++];
|
|
760
|
+
if (ch === "'" || ch === '"') {
|
|
761
|
+
const quote = ch;
|
|
762
|
+
let closed = false;
|
|
763
|
+
while (i < command.length) {
|
|
764
|
+
const c = command[i++];
|
|
765
|
+
if (c === quote) {
|
|
766
|
+
closed = true;
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
if (quote === '"' && (c === "$" || c === "`"))
|
|
770
|
+
return null;
|
|
771
|
+
if (quote === '"' && c === "\\") {
|
|
772
|
+
const next = command[i++];
|
|
773
|
+
if (next !== '"' && next !== "\\")
|
|
774
|
+
return null;
|
|
775
|
+
word += next;
|
|
776
|
+
}
|
|
777
|
+
else
|
|
778
|
+
word += c;
|
|
779
|
+
}
|
|
780
|
+
if (!closed)
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
else if (ch === "\\" && command[i] === "'") {
|
|
784
|
+
word += command[i++];
|
|
785
|
+
}
|
|
786
|
+
else if (/^[a-zA-Z0-9_./:-]$/.test(ch))
|
|
787
|
+
word += ch;
|
|
788
|
+
else
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
words.push(word);
|
|
792
|
+
}
|
|
793
|
+
if (words.length !== 8 || words[0] !== "bash" || words[1] !== wrapperPath ||
|
|
794
|
+
words[2] !== "--kind" || !["signal", "entity"].includes(words[3]) ||
|
|
795
|
+
words[4] !== "--type" || !/^[a-z][a-z0-9_-]*$/.test(words[5]) ||
|
|
796
|
+
words[6] !== "--body" || !words[7] || words.some((w) => w.includes("\0")))
|
|
797
|
+
return null;
|
|
798
|
+
return words.slice(1);
|
|
799
|
+
}
|
|
800
|
+
/** True when the writer on disk is still the script this turn generated. */
|
|
801
|
+
function wrapperMatchesGenerated(wrapperPath, expected) {
|
|
802
|
+
try {
|
|
803
|
+
return fs.readFileSync(wrapperPath, "utf8") === expected;
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
return false;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Validate the whole batch before writing. Only the host executes the pinned
|
|
811
|
+
* writer. When `expectedWrapper` is set, the file is compared to that text
|
|
812
|
+
* immediately before each exec. Strict can write the turn directory, so a
|
|
813
|
+
* writer that no longer matches is not run.
|
|
814
|
+
*/
|
|
815
|
+
export function executeGrokCandidates(text, wrapperPath, spawn, opts, expectedWrapper) {
|
|
816
|
+
let data;
|
|
817
|
+
try {
|
|
818
|
+
data = JSON.parse(text);
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
return "grok must return a JSON commands array";
|
|
822
|
+
}
|
|
823
|
+
if (!data || typeof data !== "object" || !("commands" in data) || !Array.isArray(data.commands) || data.commands.length > 8) {
|
|
824
|
+
return "grok must return at most 8 candidate commands";
|
|
825
|
+
}
|
|
826
|
+
const batch = [];
|
|
827
|
+
for (const command of data.commands) {
|
|
828
|
+
const argv = typeof command === "string" ? grokCandidateArgv(command, wrapperPath) : null;
|
|
829
|
+
if (!argv)
|
|
830
|
+
return "refused: command is not the pinned candidate wrapper";
|
|
831
|
+
batch.push(argv);
|
|
832
|
+
}
|
|
833
|
+
for (const argv of batch) {
|
|
834
|
+
if (expectedWrapper !== undefined && !wrapperMatchesGenerated(wrapperPath, expectedWrapper)) {
|
|
835
|
+
return "pinned candidate writer does not match the generated script";
|
|
836
|
+
}
|
|
837
|
+
const result = spawn("/bin/bash", argv, opts);
|
|
838
|
+
if (result.error || result.status !== 0)
|
|
839
|
+
return "pinned candidate writer failed";
|
|
840
|
+
}
|
|
841
|
+
return undefined;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Read denies for the grok login. Grok matches a `~` tool path literally and
|
|
845
|
+
* expands `~` only after that check, so the absolute temp home is not enough.
|
|
846
|
+
* `realHomes` is every home the original login might live under (the process
|
|
847
|
+
* HOME we copied from, and os.homedir() when that differs).
|
|
848
|
+
*/
|
|
849
|
+
export function grokAuthReadDenies(tempHome, realHomes) {
|
|
850
|
+
// `**` crosses `/`. The unrooted pattern matches auth.json under any
|
|
851
|
+
// directory, including a spelling the three homes do not list. The exact
|
|
852
|
+
// `~/.grok/auth.json` is the path the model can name without the temp dir.
|
|
853
|
+
const spellings = ["~/.grok/**", "~/.grok/auth.json", "**/.grok/auth.json"];
|
|
854
|
+
const add = (home) => {
|
|
855
|
+
for (const suffix of [".grok/**", path.join(".grok", "auth.json")]) {
|
|
856
|
+
const spelling = path.join(home, suffix);
|
|
857
|
+
if (!spellings.includes(spelling))
|
|
858
|
+
spellings.push(spelling);
|
|
859
|
+
}
|
|
860
|
+
try {
|
|
861
|
+
const real = fs.realpathSync(home);
|
|
862
|
+
if (real !== home)
|
|
863
|
+
add(real);
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
// The home may be created and removed around the turn. The path we were
|
|
867
|
+
// given is still denied above.
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
add(tempHome);
|
|
871
|
+
for (const home of realHomes)
|
|
872
|
+
add(home);
|
|
873
|
+
return spellings.map((p) => `Read(${p})`);
|
|
874
|
+
}
|
|
875
|
+
/** Deny rules of a grok turn: every other tool class, the login file, and the read-only shell list. */
|
|
876
|
+
export function grokDenyRules(tempHome, realHomes) {
|
|
877
|
+
return [
|
|
878
|
+
"Bash",
|
|
879
|
+
"MCPTool",
|
|
880
|
+
"WebFetch",
|
|
881
|
+
"WebSearch",
|
|
882
|
+
"Edit",
|
|
883
|
+
"Write",
|
|
884
|
+
"Grep",
|
|
885
|
+
...grokAuthReadDenies(tempHome, realHomes),
|
|
886
|
+
...GROK_READ_ONLY_COMMANDS.map((c) => `Bash(${c})`),
|
|
887
|
+
];
|
|
888
|
+
}
|
|
889
|
+
export function grokTurnArgs(prompt, scope, maxTurns, tempHome, realHomes) {
|
|
890
|
+
return [
|
|
891
|
+
"-p",
|
|
892
|
+
prompt,
|
|
893
|
+
"--output-format",
|
|
894
|
+
"streaming-json",
|
|
895
|
+
"--cwd",
|
|
896
|
+
scope.turnDir,
|
|
897
|
+
"--permission-mode",
|
|
898
|
+
"dontAsk",
|
|
899
|
+
"--tools",
|
|
900
|
+
"read_file",
|
|
901
|
+
"--disallowed-tools",
|
|
902
|
+
"search_tool,use_tool,run_terminal_cmd",
|
|
903
|
+
"--disable-web-search",
|
|
904
|
+
"--no-subagents",
|
|
905
|
+
"--max-turns",
|
|
906
|
+
String(maxTurns),
|
|
907
|
+
"--sandbox",
|
|
908
|
+
GROK_SANDBOX_PROFILE,
|
|
909
|
+
...grokAllowRules(scope.channelDir).flatMap((r) => ["--allow", r]),
|
|
910
|
+
...grokDenyRules(tempHome, realHomes).flatMap((r) => ["--deny", r]),
|
|
911
|
+
];
|
|
912
|
+
}
|
|
913
|
+
/** Parent env keys copied into a grok turn. Everything else, including credentials, stays out. */
|
|
914
|
+
export const GROK_ENV_ALLOW = [
|
|
915
|
+
"PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "LC_CTYPE",
|
|
916
|
+
"USER", "LOGNAME", "SHELL", "SystemRoot", "COMSPEC", "PATHEXT", "WINDIR",
|
|
917
|
+
"TERM", "TZ", "HQ_ROOT",
|
|
918
|
+
];
|
|
919
|
+
/** The turn's environment for grok: an allowlist, no sandbox or config overrides, no Claude or Cursor configuration scans. */
|
|
920
|
+
export function grokTurnEnv(base) {
|
|
921
|
+
const env = {};
|
|
922
|
+
for (const k of GROK_ENV_ALLOW)
|
|
923
|
+
if (base[k] !== undefined)
|
|
924
|
+
env[k] = base[k];
|
|
925
|
+
for (const k of GROK_ENV_DROP)
|
|
926
|
+
delete env[k];
|
|
927
|
+
for (const k of GROK_COMPAT_SCANS)
|
|
928
|
+
env[k] = "0";
|
|
929
|
+
return env;
|
|
930
|
+
}
|
|
931
|
+
/** Whether `grok --help` lists every flag a locked-down turn needs, and streaming-json output. */
|
|
932
|
+
export function grokExtractSupportFromHelp(helpText) {
|
|
933
|
+
const missing = [];
|
|
934
|
+
for (const flag of GROK_EXTRACT_FLAGS) {
|
|
935
|
+
if (!new RegExp(`(^|\\s)${flag}(\\s|$|<|=)`).test(helpText))
|
|
936
|
+
missing.push(flag);
|
|
937
|
+
}
|
|
938
|
+
if (!missing.includes("--output-format") && !/\bstreaming-json\b/.test(helpText))
|
|
939
|
+
missing.push("--output-format streaming-json");
|
|
940
|
+
return missing.length ? { ok: false, missing } : { ok: true };
|
|
941
|
+
}
|
|
942
|
+
const cachedGrokSupport = new Map();
|
|
943
|
+
export function resetGrokExtractProbeCache() {
|
|
944
|
+
cachedGrokSupport.clear();
|
|
945
|
+
}
|
|
946
|
+
/** Probe `grok --help` once per grok binary path (empty or failed output counts as every flag missing). */
|
|
947
|
+
export function probeGrokExtractSupport(grokBin, readHelp) {
|
|
948
|
+
const hit = cachedGrokSupport.get(grokBin);
|
|
949
|
+
if (hit)
|
|
950
|
+
return hit;
|
|
951
|
+
let help;
|
|
952
|
+
try {
|
|
953
|
+
help = readHelp();
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
help = "";
|
|
957
|
+
}
|
|
958
|
+
const support = grokExtractSupportFromHelp(help);
|
|
959
|
+
cachedGrokSupport.set(grokBin, support);
|
|
960
|
+
return support;
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Which runtime runs the turns. The main bot's runtime, except that a grok
|
|
964
|
+
* that is missing or cannot be locked down is never run: the step uses
|
|
965
|
+
* claude, then codex, when that tool is installed and signed in, and
|
|
966
|
+
* otherwise fails. The claude and codex checks run only when needed.
|
|
967
|
+
*/
|
|
968
|
+
export function chooseExtractRuntime(main, avail, platform = process.platform) {
|
|
969
|
+
if (main !== "grok")
|
|
970
|
+
return { runtime: main };
|
|
971
|
+
if (platform !== "linux") {
|
|
972
|
+
const reason = `grok only blocks child-process network on linux, so extract will not use it on ${platform}`;
|
|
973
|
+
for (const other of ["claude", "codex"]) {
|
|
974
|
+
const t = avail[other]();
|
|
975
|
+
if (t.installed && t.signedIn === true)
|
|
976
|
+
return { runtime: other, fallback: `${reason}; using ${other}` };
|
|
977
|
+
}
|
|
978
|
+
return { error: `${reason}; claude and codex are not installed or not signed in. Install grok ${GROK_MIN_VERSION} or newer on linux, or sign in to claude or codex, then run again.` };
|
|
979
|
+
}
|
|
980
|
+
const grok = avail.grok();
|
|
981
|
+
if (grok.installed && grok.support.ok)
|
|
982
|
+
return { runtime: "grok" };
|
|
983
|
+
const reason = !grok.installed
|
|
984
|
+
? "the main bot runs on grok but grok is not installed"
|
|
985
|
+
: `the installed grok lacks ${grok.support.ok ? "" : grok.support.missing.join(", ")} (extract needs grok ${GROK_MIN_VERSION} or newer)`;
|
|
986
|
+
for (const other of ["claude", "codex"]) {
|
|
987
|
+
const t = avail[other]();
|
|
988
|
+
if (t.installed && t.signedIn === true)
|
|
989
|
+
return { runtime: other, fallback: `${reason}; using ${other}` };
|
|
990
|
+
}
|
|
991
|
+
return { error: `${reason}; claude and codex are not installed or not signed in. Install grok ${GROK_MIN_VERSION} or newer, or sign in to claude or codex, then run again.` };
|
|
992
|
+
}
|
|
993
|
+
/** Installed-and-signed-in check for a fallback tool, through the step's spawn. */
|
|
994
|
+
function toolAvailability(ctx, bin, statusArgs, parse) {
|
|
995
|
+
if (!resolveOnPath(bin, process.env.PATH))
|
|
996
|
+
return { installed: false, signedIn: null };
|
|
997
|
+
const res = ctx.spawn(bin, statusArgs, { env: { ...process.env, HQ_NO_ENSURE_HQ_DESKTOP: "1" }, timeoutMs: SIGN_IN_CHECK_TIMEOUT_MS });
|
|
998
|
+
if (res.error)
|
|
999
|
+
return { installed: true, signedIn: null };
|
|
1000
|
+
return { installed: true, signedIn: parse({ code: res.status, signal: null, stdout: res.stdout, stderr: res.stderr }) };
|
|
1001
|
+
}
|
|
585
1002
|
export function claudeTurnArgs(prompt, scope, maxTurns, budgetUsd) {
|
|
586
1003
|
return [
|
|
587
1004
|
"-p",
|
|
@@ -635,11 +1052,49 @@ export function codexTurnArgs(prompt, scope, codexBin, envPath) {
|
|
|
635
1052
|
}
|
|
636
1053
|
function runTurn(ctx, runtime, prompt, scope, budgetUsd, opts) {
|
|
637
1054
|
const cwd = scope.turnDir;
|
|
638
|
-
const cmd = runtime === "claude" ? opts.claudeBin : opts.codexBin;
|
|
639
|
-
|
|
1055
|
+
const cmd = runtime === "claude" ? opts.claudeBin : runtime === "grok" ? opts.grokBin : opts.codexBin;
|
|
1056
|
+
let args = runtime === "claude"
|
|
640
1057
|
? claudeTurnArgs(prompt, scope, ctx.maxTurns, budgetUsd)
|
|
641
|
-
:
|
|
642
|
-
|
|
1058
|
+
: runtime === "codex"
|
|
1059
|
+
? codexTurnArgs(prompt, scope, opts.codexBin, process.env.PATH)
|
|
1060
|
+
: [];
|
|
1061
|
+
let env = { ...process.env, HQ_ROOT: ctx.hqRoot };
|
|
1062
|
+
let grokHome;
|
|
1063
|
+
let res;
|
|
1064
|
+
try {
|
|
1065
|
+
if (runtime === "grok") {
|
|
1066
|
+
const sourceHome = env.HOME ?? os.homedir();
|
|
1067
|
+
const realHomes = [...new Set([sourceHome, os.homedir()])];
|
|
1068
|
+
const sourceAuth = path.join(sourceHome, ".grok", "auth.json");
|
|
1069
|
+
grokHome = fs.mkdtempSync(path.join(os.tmpdir(), "hq-grok-home-"));
|
|
1070
|
+
// Preserve login without inheriting configuration, hooks, or permissions.
|
|
1071
|
+
const authDir = path.join(grokHome, ".grok");
|
|
1072
|
+
fs.mkdirSync(authDir, { mode: 0o700 });
|
|
1073
|
+
if (fs.existsSync(sourceAuth)) {
|
|
1074
|
+
const authCopy = path.join(authDir, "auth.json");
|
|
1075
|
+
fs.copyFileSync(sourceAuth, authCopy, fs.constants.COPYFILE_EXCL);
|
|
1076
|
+
fs.chmodSync(authCopy, 0o600);
|
|
1077
|
+
}
|
|
1078
|
+
// The profile named by --sandbox; grok refuses to start without it.
|
|
1079
|
+
const profile = path.join(scope.turnDir, GROK_SANDBOX_FILE);
|
|
1080
|
+
fs.mkdirSync(path.dirname(profile), { recursive: true });
|
|
1081
|
+
fs.writeFileSync(profile, grokSandboxProfile(scope, process.env.PATH), { mode: 0o600 });
|
|
1082
|
+
env = grokTurnEnv(env);
|
|
1083
|
+
env.HOME = grokHome;
|
|
1084
|
+
env.XDG_CONFIG_HOME = path.join(grokHome, "xdg");
|
|
1085
|
+
env.XDG_DATA_HOME = path.join(grokHome, "xdg-data");
|
|
1086
|
+
env.CLAUDE_CONFIG_DIR = path.join(grokHome, "claude");
|
|
1087
|
+
env.HQ_ROOT = ctx.hqRoot;
|
|
1088
|
+
for (const dir of [env.XDG_CONFIG_HOME, env.XDG_DATA_HOME, env.CLAUDE_CONFIG_DIR])
|
|
1089
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1090
|
+
args = grokTurnArgs(prompt, scope, ctx.maxTurns, grokHome, realHomes);
|
|
1091
|
+
}
|
|
1092
|
+
res = ctx.spawn(cmd, args, { cwd, env, timeoutMs: opts.turnTimeoutMs });
|
|
1093
|
+
}
|
|
1094
|
+
finally {
|
|
1095
|
+
if (grokHome)
|
|
1096
|
+
fs.rmSync(grokHome, { recursive: true, force: true });
|
|
1097
|
+
}
|
|
643
1098
|
const parsed = parseTurnOutput(res.stdout);
|
|
644
1099
|
const all = `${res.stderr}\n${res.stdout}`;
|
|
645
1100
|
if (res.error) {
|
|
@@ -656,9 +1111,22 @@ function runTurn(ctx, runtime, prompt, scope, budgetUsd, opts) {
|
|
|
656
1111
|
return { ok: false, error: `${runtime} printed no result event`, errorKind: "other", turns: 0, costUsd: 0 };
|
|
657
1112
|
if (parsed.result.is_error === true || (typeof parsed.result.subtype === "string" && parsed.result.subtype !== "success")) {
|
|
658
1113
|
const text = typeof parsed.result.result === "string" ? parsed.result.result : String(parsed.result.subtype ?? "");
|
|
659
|
-
|
|
1114
|
+
// grok names the refused command on stderr, not in the stream.
|
|
1115
|
+
const refused = runtime === "grok" ? /^permission required: .*$/m.exec(res.stderr)?.[0] : undefined;
|
|
1116
|
+
const detail = refused ? `${text} (${refused})` : text;
|
|
1117
|
+
const c = classifyFailure(detail, `${runtime} turn failed: ${detail.slice(0, 300) || "error result"}`);
|
|
660
1118
|
return { ok: false, ...c, turns: parsed.turns, costUsd: parsed.costUsd };
|
|
661
1119
|
}
|
|
1120
|
+
if (runtime === "grok") {
|
|
1121
|
+
if (scope.wrapperBody === undefined) {
|
|
1122
|
+
return { ok: false, error: "pinned candidate writer is missing", errorKind: "other", turns: parsed.turns, costUsd: parsed.costUsd };
|
|
1123
|
+
}
|
|
1124
|
+
const error = executeGrokCandidates(String(parsed.result.result ?? ""), scope.wrapperPath, ctx.spawn, {
|
|
1125
|
+
cwd, env: grokTurnEnv({ ...process.env, HQ_ROOT: ctx.hqRoot }), timeoutMs: SCRIPT_TIMEOUT_MS,
|
|
1126
|
+
}, scope.wrapperBody);
|
|
1127
|
+
if (error)
|
|
1128
|
+
return { ok: false, error, errorKind: "other", turns: parsed.turns, costUsd: parsed.costUsd };
|
|
1129
|
+
}
|
|
662
1130
|
return { ok: true, turns: parsed.turns, costUsd: parsed.costUsd };
|
|
663
1131
|
}
|
|
664
1132
|
export async function extractPartialDocument(ctx, doc, prior) {
|
|
@@ -695,17 +1163,34 @@ export function createExtractStep(options = {}) {
|
|
|
695
1163
|
if (channels.length === 0 && unrunRedo.length === 0) {
|
|
696
1164
|
return { status: "skipped", itemsIn: 0, itemsOut: 0, skippedReason: "no-sources", ...empty };
|
|
697
1165
|
}
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
1166
|
+
const turnOpts = {
|
|
1167
|
+
claudeBin: options.claudeBin ?? "claude",
|
|
1168
|
+
codexBin: options.codexBin ?? "codex",
|
|
1169
|
+
grokBin: options.grokBin ?? "grok",
|
|
1170
|
+
turnTimeoutMs: options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS,
|
|
1171
|
+
};
|
|
1172
|
+
const mainRuntime = (options.resolveBotRuntime ?? defaultResolveBotRuntime)() ?? options.runtime ?? "claude";
|
|
1173
|
+
const choice = chooseExtractRuntime(mainRuntime, {
|
|
1174
|
+
grok: () => {
|
|
1175
|
+
const resolved = resolveOnPath(turnOpts.grokBin, process.env.PATH);
|
|
1176
|
+
if (!resolved)
|
|
1177
|
+
return { installed: false };
|
|
1178
|
+
turnOpts.grokBin = fs.realpathSync(resolved);
|
|
1179
|
+
const support = probeGrokExtractSupport(turnOpts.grokBin, () => {
|
|
1180
|
+
const r = ctx.spawn(turnOpts.grokBin, ["--help"], { env: grokTurnEnv(process.env), timeoutMs: SIGN_IN_CHECK_TIMEOUT_MS });
|
|
1181
|
+
if (r.error)
|
|
1182
|
+
throw r.error;
|
|
1183
|
+
return `${r.stdout}\n${r.stderr}`;
|
|
1184
|
+
});
|
|
1185
|
+
return { installed: true, support };
|
|
1186
|
+
},
|
|
1187
|
+
claude: () => toolAvailability(ctx, turnOpts.claudeBin, ["auth", "status", "--json"], parseClaudeAuthStatus),
|
|
1188
|
+
codex: () => toolAvailability(ctx, turnOpts.codexBin, ["login", "status"], parseCodexLoginStatus),
|
|
1189
|
+
}, options.platform ?? process.platform);
|
|
1190
|
+
if ("error" in choice) {
|
|
1191
|
+
return { status: "failed", itemsIn: 0, itemsOut: 0, error: choice.error, errorKind: "config", ...empty };
|
|
708
1192
|
}
|
|
1193
|
+
const runtime = choice.runtime;
|
|
709
1194
|
const scripts = path.join(ctx.hqRoot, "core", "scripts");
|
|
710
1195
|
const worklist = path.join(scripts, "ontology-source-worklist.sh");
|
|
711
1196
|
const skillPath = path.join(ctx.hqRoot, "core", "workers", "public", "ontology", "skills", "process-source.md");
|
|
@@ -713,11 +1198,6 @@ export function createExtractStep(options = {}) {
|
|
|
713
1198
|
return { status: "skipped", itemsIn: 0, itemsOut: 0, skippedReason: "ontology-worker-missing", ...empty };
|
|
714
1199
|
}
|
|
715
1200
|
const skillText = fs.readFileSync(skillPath, "utf8");
|
|
716
|
-
const turnOpts = {
|
|
717
|
-
claudeBin: options.claudeBin ?? "claude",
|
|
718
|
-
codexBin: options.codexBin ?? "codex",
|
|
719
|
-
turnTimeoutMs: options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS,
|
|
720
|
-
};
|
|
721
1201
|
const env = { ...process.env, HQ_ROOT: ctx.hqRoot };
|
|
722
1202
|
const limit = options.worklistLimit ?? DEFAULT_WORKLIST_LIMIT;
|
|
723
1203
|
let itemsIn = 0;
|
|
@@ -764,7 +1244,7 @@ export function createExtractStep(options = {}) {
|
|
|
764
1244
|
errorKind = "other";
|
|
765
1245
|
continue;
|
|
766
1246
|
}
|
|
767
|
-
if (runtime === "codex") {
|
|
1247
|
+
if (runtime === "codex" || runtime === "grok") {
|
|
768
1248
|
try {
|
|
769
1249
|
candidateDirs = ["signals", "ontology"].map((store) => {
|
|
770
1250
|
const dir = assertAllowedPath(ctx.hqRoot, `companies/${ctx.company}/${store}/_candidates`, { company: ctx.company });
|
|
@@ -842,8 +1322,9 @@ export function createExtractStep(options = {}) {
|
|
|
842
1322
|
const before = allCandidateFiles(ctx.hqRoot);
|
|
843
1323
|
let turn;
|
|
844
1324
|
try {
|
|
845
|
-
|
|
846
|
-
|
|
1325
|
+
const wrapperBody = buildCandidateWrapper({ hqRoot: ctx.hqRoot, company: ctx.company, audience: (item.audience ?? []).join(","), sourceRef });
|
|
1326
|
+
fs.writeFileSync(wrapperPath, wrapperBody, { mode: 0o700 });
|
|
1327
|
+
const scope = { hqRoot: ctx.hqRoot, company: ctx.company, channelDir, turnDir, wrapperPath, candidateDirs, wrapperBody };
|
|
847
1328
|
const prompt = buildExtractPrompt({
|
|
848
1329
|
skillText,
|
|
849
1330
|
sourceRef,
|
|
@@ -853,7 +1334,7 @@ export function createExtractStep(options = {}) {
|
|
|
853
1334
|
item,
|
|
854
1335
|
itemPath: path.join(channelDir, path.basename(item.file)),
|
|
855
1336
|
wrapperPath,
|
|
856
|
-
runtime
|
|
1337
|
+
runtime,
|
|
857
1338
|
});
|
|
858
1339
|
turn = runTurn(ctx, runtime, prompt, scope, Math.max(0, remaining), turnOpts);
|
|
859
1340
|
}
|
|
@@ -914,7 +1395,7 @@ export function createExtractStep(options = {}) {
|
|
|
914
1395
|
...(failures.length ? { failures } : {}),
|
|
915
1396
|
...(skippedItems.length ? { skippedItems } : {}),
|
|
916
1397
|
...(rejected ? { rejectedCandidates: rejected } : {}),
|
|
917
|
-
detail: { new: itemsOut, updated, skipped },
|
|
1398
|
+
detail: { new: itemsOut, updated, skipped, runtime, ...(choice.fallback ? { fallback: choice.fallback } : {}) },
|
|
918
1399
|
};
|
|
919
1400
|
if (budgetStop) {
|
|
920
1401
|
result.status = "partial";
|