iterate-plugin 3.5.6 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -20
- package/README.zh-CN.md +53 -17
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +509 -34
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +165 -0
- package/dist/index.js +54 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +52 -13
- package/dist/meta-review.js +132 -28
- package/dist/method-scope.js +31 -8
- package/dist/review-scope.js +32 -9
- package/dist/review.js +96 -17
- package/dist/session-hooks.js +55 -13
- package/dist/skill-prompt.js +417 -67
- package/dist/tools/checkpoint.js +85 -19
- package/dist/tools/config.js +44 -2
- package/dist/tools/context.js +22 -5
- package/dist/tools/decision-log.js +122 -27
- package/dist/tools/defense-events.js +90 -31
- package/dist/tools/defense-store.js +28 -3
- package/dist/tools/experience-bank.js +108 -43
- package/dist/tools/experience-store.js +54 -4
- package/dist/tools/fix.js +347 -119
- package/dist/tools/history.js +68 -14
- package/dist/tools/present.js +80 -0
- package/dist/tools/prune.js +156 -53
- package/dist/tools/quality-gate.js +52 -2
- package/dist/tools/quality-store.js +17 -1
- package/dist/tools/review.js +158 -7
- package/dist/tools/transcript.js +173 -36
- package/dist/tools/triage.js +67 -12
- package/dist/tools/validate.js +115 -24
- package/dist/transcript.js +267 -4
- package/lib/client.js +945 -181
- package/lib/parse.js +760 -57
- package/package.json +15 -12
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/index.ts +600 -129
- package/src/config-loader.ts +542 -35
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +184 -0
- package/src/index.ts +53 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +48 -11
- package/src/meta-review.ts +151 -34
- package/src/method-scope.ts +31 -8
- package/src/review-scope.ts +33 -10
- package/src/review.ts +96 -17
- package/src/session-hooks.ts +54 -12
- package/src/skill-prompt.ts +417 -67
- package/src/tools/checkpoint.ts +81 -19
- package/src/tools/config.ts +44 -2
- package/src/tools/context.ts +25 -5
- package/src/tools/decision-log.ts +127 -22
- package/src/tools/defense-events.ts +87 -31
- package/src/tools/defense-store.ts +30 -3
- package/src/tools/experience-bank.ts +112 -44
- package/src/tools/experience-store.ts +54 -5
- package/src/tools/fix.ts +258 -20
- package/src/tools/history.ts +74 -15
- package/src/tools/present.ts +89 -0
- package/src/tools/prune.ts +160 -51
- package/src/tools/quality-gate.ts +57 -2
- package/src/tools/quality-store.ts +18 -1
- package/src/tools/review.ts +155 -6
- package/src/tools/transcript.ts +178 -36
- package/src/tools/triage.ts +70 -13
- package/src/tools/validate.ts +138 -32
- package/src/transcript.ts +279 -4
- package/src/types.ts +29 -1
package/src/tools/triage.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
|
5
5
|
import yaml from 'js-yaml'
|
|
6
6
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
7
7
|
import { writeTextAtomic } from '../atomic-fs.ts'
|
|
8
|
+
import { configBackupSuffix } from '../config-write.ts'
|
|
9
|
+
import { WHOLE_FILE_LINE } from '../evidence.ts'
|
|
8
10
|
import type { KnownIntentional } from '../types.ts'
|
|
9
11
|
|
|
10
12
|
const CONFIG_FILE = 'iterate.config.yaml'
|
|
@@ -16,11 +18,23 @@ const KNOWN_INTENTIONAL_KEY = 'known_intentional'
|
|
|
16
18
|
/** Max entries per single `apply` call. */
|
|
17
19
|
const MAX_ENTRIES = 500
|
|
18
20
|
|
|
21
|
+
/**
|
|
22
|
+
* TOTAL cap on `personalization.known_intentional`.
|
|
23
|
+
*
|
|
24
|
+
* `MAX_ENTRIES` only bounds one `apply` payload, so the list itself grew
|
|
25
|
+
* without limit across sessions — every review round then ran
|
|
26
|
+
* `filterKnownIntentional` as O(findings × entries) over an ever-longer list,
|
|
27
|
+
* and the config file (backed up on every write) grew with it. Merges now
|
|
28
|
+
* evict the OLDEST entries (list order is write order — incoming entries are
|
|
29
|
+
* appended) beyond this bound and report how many were dropped.
|
|
30
|
+
*/
|
|
31
|
+
export const MAX_TOTAL_KNOWN_INTENTIONAL = 1000
|
|
32
|
+
|
|
19
33
|
/** How many timestamped config backups are retained (older ones are removed). */
|
|
20
34
|
export const MAX_TRIAGE_BACKUPS = 5
|
|
21
35
|
|
|
22
|
-
/** Whole-file marker line
|
|
23
|
-
|
|
36
|
+
/** Whole-file marker line — the shared constant from evidence.ts (also used by
|
|
37
|
+
* review-scope/filterKnownIntentional), so every consumer agrees on `0`. */
|
|
24
38
|
|
|
25
39
|
// ─── Pure helpers (exported for unit tests) ─────────────────────────────────
|
|
26
40
|
|
|
@@ -96,16 +110,20 @@ export function entryKey(entry: KnownIntentional): string {
|
|
|
96
110
|
/**
|
|
97
111
|
* Merge incoming entries into the existing known-intentional list.
|
|
98
112
|
* Existing entries are never mutated; incoming entries whose key already
|
|
99
|
-
* exists are skipped.
|
|
113
|
+
* exists are skipped. The result is then bounded by
|
|
114
|
+
* {@link MAX_TOTAL_KNOWN_INTENTIONAL}: the list is append-ordered, so when a
|
|
115
|
+
* merge overflows, the entries at the FRONT (the oldest ones) are evicted and
|
|
116
|
+
* counted in `dropped` — the caller reports that number instead of silently
|
|
117
|
+
* losing verdicts.
|
|
100
118
|
*
|
|
101
119
|
* @param {KnownIntentional[]} existing
|
|
102
120
|
* @param {KnownIntentional[]} incoming
|
|
103
|
-
* @returns {{ merged: KnownIntentional[], added: number, skipped: number }}
|
|
121
|
+
* @returns {{ merged: KnownIntentional[], added: number, skipped: number, dropped: number }}
|
|
104
122
|
*/
|
|
105
123
|
export function mergeKnownIntentional(
|
|
106
124
|
existing: KnownIntentional[],
|
|
107
125
|
incoming: KnownIntentional[],
|
|
108
|
-
): { merged: KnownIntentional[]; added: number; skipped: number } {
|
|
126
|
+
): { merged: KnownIntentional[]; added: number; skipped: number; dropped: number } {
|
|
109
127
|
const seen = new Set<string>()
|
|
110
128
|
const merged: KnownIntentional[] = []
|
|
111
129
|
for (const entry of existing) {
|
|
@@ -127,7 +145,16 @@ export function mergeKnownIntentional(
|
|
|
127
145
|
merged.push(entry)
|
|
128
146
|
added++
|
|
129
147
|
}
|
|
130
|
-
|
|
148
|
+
// Total bound: evict oldest-first. Incoming entries sit at the END of
|
|
149
|
+
// `merged`, so they survive — a session cannot evict its own fresh verdicts
|
|
150
|
+
// by overflowing the list, and one apply adds at most MAX_ENTRIES entries,
|
|
151
|
+
// which bounds how far a single merge can overshoot the cap.
|
|
152
|
+
let dropped = 0
|
|
153
|
+
if (merged.length > MAX_TOTAL_KNOWN_INTENTIONAL) {
|
|
154
|
+
dropped = merged.length - MAX_TOTAL_KNOWN_INTENTIONAL
|
|
155
|
+
merged.splice(0, dropped)
|
|
156
|
+
}
|
|
157
|
+
return { merged, added, skipped, dropped }
|
|
131
158
|
}
|
|
132
159
|
|
|
133
160
|
/**
|
|
@@ -169,10 +196,15 @@ export function readKnownIntentional(
|
|
|
169
196
|
)
|
|
170
197
|
}
|
|
171
198
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Build a filesystem-safe backup suffix from the current time.
|
|
201
|
+
*
|
|
202
|
+
* Re-exported from config-write: BOTH writers back up `iterate.config.yaml`,
|
|
203
|
+
* and the shared implementation carries the same-millisecond collision guard
|
|
204
|
+
* (two backups created in the same ms used to produce the identical
|
|
205
|
+
* `config.bak-…` path, so the second silently overwrote the first).
|
|
206
|
+
*/
|
|
207
|
+
export const backupSuffix = configBackupSuffix
|
|
176
208
|
|
|
177
209
|
/**
|
|
178
210
|
* Bound the timestamped config backups: after a fresh one is written, delete
|
|
@@ -226,6 +258,8 @@ function applyEntries(
|
|
|
226
258
|
ok: true
|
|
227
259
|
added: number
|
|
228
260
|
skipped: number
|
|
261
|
+
/** Entries evicted from the FRONT of the list because the total cap was hit. */
|
|
262
|
+
dropped: number
|
|
229
263
|
count: number
|
|
230
264
|
configPath: string
|
|
231
265
|
backupPath: string | null
|
|
@@ -242,7 +276,7 @@ function applyEntries(
|
|
|
242
276
|
return { ok: false, error: `Failed to read config: ${String(err)}` }
|
|
243
277
|
}
|
|
244
278
|
const existing = readKnownIntentional(config)
|
|
245
|
-
const { merged, added, skipped } = mergeKnownIntentional(existing, incoming)
|
|
279
|
+
const { merged, added, skipped, dropped } = mergeKnownIntentional(existing, incoming)
|
|
246
280
|
const nextConfig = buildConfigWithKnownIntentional(config, merged)
|
|
247
281
|
|
|
248
282
|
const hadFile = existsSync(configPath)
|
|
@@ -283,7 +317,7 @@ function applyEntries(
|
|
|
283
317
|
// project never collects an unbounded pile of config snapshots.
|
|
284
318
|
if (backupPath) pruneOldConfigBackups(configPath)
|
|
285
319
|
|
|
286
|
-
return { ok: true, added, skipped, count: merged.length, configPath, backupPath }
|
|
320
|
+
return { ok: true, added, skipped, dropped, count: merged.length, configPath, backupPath }
|
|
287
321
|
}
|
|
288
322
|
|
|
289
323
|
/**
|
|
@@ -304,6 +338,22 @@ export function registerTriageTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
304
338
|
ctx.tools.register(
|
|
305
339
|
defineTool({
|
|
306
340
|
name: 'iterate_triage',
|
|
341
|
+
// Pending-call card (#12): `apply` rewrites iterate.config.yaml (with
|
|
342
|
+
// backup + rollback) — it must read as a config write, not a read, and
|
|
343
|
+
// name how many entries it will merge. `list` is a plain read and keeps
|
|
344
|
+
// the default presentation. Pure: derived from args only.
|
|
345
|
+
presentCall: (args) => {
|
|
346
|
+
const a = args as { operation?: unknown; entries?: unknown }
|
|
347
|
+
if (a.operation !== 'apply') return undefined
|
|
348
|
+
const count = Array.isArray(a.entries) ? a.entries.length : 0
|
|
349
|
+
return {
|
|
350
|
+
card: 'generic',
|
|
351
|
+
title: count > 0
|
|
352
|
+
? `Apply ${count} known_intentional ${count === 1 ? 'entry' : 'entries'} to iterate.config.yaml`
|
|
353
|
+
: 'Apply known_intentional entries to iterate.config.yaml',
|
|
354
|
+
kind: 'edit',
|
|
355
|
+
}
|
|
356
|
+
},
|
|
307
357
|
// `list` is a pure config read; `apply` rewrites iterate.config.yaml →
|
|
308
358
|
// only list joins a parallel dispatch group.
|
|
309
359
|
isConcurrencySafe: (args) => (args as { operation?: unknown }).operation === 'list',
|
|
@@ -311,7 +361,8 @@ export function registerTriageTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
311
361
|
'Manage `personalization.known_intentional` entries in iterate.config.yaml. ' +
|
|
312
362
|
'Use `apply` to write back triage verdicts (entries where the reviewer said "known intentional") so ' +
|
|
313
363
|
'future review rounds filter them out. Entries are deduped by file|dimension|line and the config is ' +
|
|
314
|
-
'backed up before writing.
|
|
364
|
+
'backed up before writing. The list is capped at 1000 entries — when a merge overflows it, the OLDEST ' +
|
|
365
|
+
'entries are evicted and the result reports `dropped`. Use `list` to read the current entries. ' +
|
|
315
366
|
'The client browser cannot write files, so this tool is the write-back channel for the triage panel.',
|
|
316
367
|
parameters: {
|
|
317
368
|
operation: {
|
|
@@ -342,6 +393,8 @@ export function registerTriageTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
342
393
|
operation: { type: 'string', required: true },
|
|
343
394
|
added: { type: 'integer' },
|
|
344
395
|
skipped: { type: 'integer' },
|
|
396
|
+
/** Entries evicted from the FRONT of the list (total cap hit). */
|
|
397
|
+
dropped: { type: 'integer' },
|
|
345
398
|
count: { type: 'integer' },
|
|
346
399
|
path: { type: 'string' },
|
|
347
400
|
backupPath: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
@@ -403,6 +456,10 @@ export function registerTriageTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
403
456
|
operation: 'apply',
|
|
404
457
|
added: result.added,
|
|
405
458
|
skipped: result.skipped,
|
|
459
|
+
// Surface the overflow count: a merge that hit the total cap
|
|
460
|
+
// evicted the OLDEST entries, and the caller must be able to see
|
|
461
|
+
// how many verdicts were dropped instead of silently losing them.
|
|
462
|
+
dropped: result.dropped,
|
|
406
463
|
count: result.count,
|
|
407
464
|
path: result.configPath,
|
|
408
465
|
backupPath: result.backupPath ?? null,
|
package/src/tools/validate.ts
CHANGED
|
@@ -13,6 +13,9 @@ const DEFAULT_TIMEOUT_MS = 120_000
|
|
|
13
13
|
* pin the tool open indefinitely via an unbounded `timeout` argument. */
|
|
14
14
|
const MAX_TIMEOUT_MS = 600_000
|
|
15
15
|
|
|
16
|
+
/** Default cap on a validation command's captured stdout+stderr (10 MB). */
|
|
17
|
+
const DEFAULT_MAX_BUFFER = 10 * 1024 * 1024
|
|
18
|
+
|
|
16
19
|
/**
|
|
17
20
|
* Clamp a caller-supplied timeout (ms) to a sane range.
|
|
18
21
|
* Non-finite / non-positive values fall back to the default; any value above
|
|
@@ -25,6 +28,20 @@ export function clampTimeout(ms: number | undefined): number {
|
|
|
25
28
|
return Math.min(ms, MAX_TIMEOUT_MS)
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Result of {@link runCommand}: a plain {@link ValidationResult} plus two run
|
|
33
|
+
* anomalies the tool layer must surface but the shared `ValidationResult`
|
|
34
|
+
* DTO (src/types.ts) has no field for.
|
|
35
|
+
* - `truncated` — output hit the `maxBuffer` cap; stdout/stderr were cut.
|
|
36
|
+
* - `startError` — `exec()` refused to spawn the command (it throws
|
|
37
|
+
* SYNCHRONOUSLY for e.g. a NUL byte in the command string); the command
|
|
38
|
+
* never ran at all.
|
|
39
|
+
*/
|
|
40
|
+
export type RunCommandResult = ValidationResult & {
|
|
41
|
+
truncated?: boolean
|
|
42
|
+
startError?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
/**
|
|
29
46
|
* Run a single shell command with timeout and return structured results.
|
|
30
47
|
* Pure function (no side effects beyond the exec call).
|
|
@@ -34,44 +51,79 @@ export function clampTimeout(ms: number | undefined): number {
|
|
|
34
51
|
* instead of pinning the dispatch open until the timeout elapses. The result
|
|
35
52
|
* distinguishes "cancelled by the caller" (`canceled: true`) from "ran past
|
|
36
53
|
* `timeoutMs`" (`timedOut: true`).
|
|
54
|
+
*
|
|
55
|
+
* `maxBuffer` is injectable for tests (defaults to the production 10 MB cap);
|
|
56
|
+
* hitting it sets `truncated: true` so callers can report the cut output
|
|
57
|
+
* instead of silently returning a partial stream.
|
|
58
|
+
*
|
|
59
|
+
* Never rejects: `exec()` validates its arguments synchronously and can THROW
|
|
60
|
+
* before registering its callback (a NUL byte in the command raises
|
|
61
|
+
* ERR_INVALID_ARG_VALUE). Inside a promise executor that throw would reject
|
|
62
|
+
* the promise and escape `execute` unguarded, so it is caught and resolved as
|
|
63
|
+
* a `startError` result instead.
|
|
37
64
|
*/
|
|
38
65
|
export async function runCommand(
|
|
39
66
|
command: string,
|
|
40
67
|
cwd: string,
|
|
41
68
|
timeoutMs: number,
|
|
42
69
|
signal?: AbortSignal,
|
|
43
|
-
|
|
70
|
+
maxBuffer: number = DEFAULT_MAX_BUFFER,
|
|
71
|
+
): Promise<RunCommandResult> {
|
|
44
72
|
const start = performance.now()
|
|
45
|
-
return new Promise<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
return new Promise<RunCommandResult>((resolve) => {
|
|
74
|
+
try {
|
|
75
|
+
exec(
|
|
76
|
+
command,
|
|
77
|
+
{
|
|
78
|
+
cwd,
|
|
79
|
+
timeout: timeoutMs,
|
|
80
|
+
maxBuffer,
|
|
81
|
+
env: { ...process.env, PAGER: 'cat' },
|
|
82
|
+
signal,
|
|
83
|
+
},
|
|
84
|
+
(error, stdout, stderr) => {
|
|
85
|
+
const durationMs = Math.round(performance.now() - start)
|
|
86
|
+
// error.code is the exit code when the command ran; when the binary
|
|
87
|
+
// cannot be spawned Node sets error.code to a STRING ('ENOENT' etc).
|
|
88
|
+
// Coerce to a number so the integer output schema is never violated.
|
|
89
|
+
const exitCode = typeof error?.code === 'number' ? error.code : (error ? 1 : 0)
|
|
90
|
+
const canceled = signal?.aborted === true
|
|
91
|
+
// maxBuffer overflow: error.code is the STRING
|
|
92
|
+
// 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' and stdout/stderr were cut
|
|
93
|
+
// short — flag it instead of reporting a plain exit-1 with silently
|
|
94
|
+
// truncated output. @types/node types ExecException.code as
|
|
95
|
+
// number|undefined, so widen to unknown for the runtime comparison.
|
|
96
|
+
const errorCode: unknown = error?.code
|
|
97
|
+
const truncated = errorCode === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
|
|
98
|
+
resolve({
|
|
99
|
+
command,
|
|
100
|
+
exitCode,
|
|
101
|
+
stdout: stdout ?? '',
|
|
102
|
+
stderr: stderr ?? '',
|
|
103
|
+
// A caller cancellation also kills the child (killed === true), but
|
|
104
|
+
// that is not a timeout — only report timedOut for the deadline path.
|
|
105
|
+
timedOut: error?.killed === true && !canceled,
|
|
106
|
+
canceled,
|
|
107
|
+
durationMs,
|
|
108
|
+
truncated,
|
|
109
|
+
})
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// Synchronous exec() throw (invalid command string, e.g. a NUL byte):
|
|
114
|
+
// resolve a structured "never started" failure rather than rejecting.
|
|
115
|
+
resolve({
|
|
116
|
+
command,
|
|
117
|
+
exitCode: 1,
|
|
118
|
+
stdout: '',
|
|
119
|
+
stderr: String(err),
|
|
120
|
+
timedOut: false,
|
|
121
|
+
canceled: false,
|
|
122
|
+
durationMs: Math.round(performance.now() - start),
|
|
123
|
+
truncated: false,
|
|
124
|
+
startError: String((err as Error)?.message ?? err),
|
|
125
|
+
})
|
|
126
|
+
}
|
|
75
127
|
})
|
|
76
128
|
}
|
|
77
129
|
|
|
@@ -131,6 +183,10 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
131
183
|
timedOut: { type: 'boolean', required: true },
|
|
132
184
|
canceled: { type: 'boolean', required: true },
|
|
133
185
|
durationMs: { type: 'integer', required: true },
|
|
186
|
+
truncated: {
|
|
187
|
+
type: 'boolean',
|
|
188
|
+
description: 'true when command output exceeded the maxBuffer cap and was cut short.',
|
|
189
|
+
},
|
|
134
190
|
rejectReason: { type: 'string' },
|
|
135
191
|
},
|
|
136
192
|
},
|
|
@@ -144,6 +200,12 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
144
200
|
`Duration: ${value.durationMs}ms`,
|
|
145
201
|
value.timedOut ? '⚠ Timed out' : '',
|
|
146
202
|
value.canceled ? '⚠ Cancelled before completion' : '',
|
|
203
|
+
// A truncated run still reports its (partial) output — the
|
|
204
|
+
// reader must be told the stream was cut, otherwise the
|
|
205
|
+
// missing tail reads as "no output".
|
|
206
|
+
value.truncated
|
|
207
|
+
? `⚠ ${value.rejectReason ?? 'Output truncated: maxBuffer limit exceeded.'}`
|
|
208
|
+
: '',
|
|
147
209
|
'',
|
|
148
210
|
value.stdout ? `[stdout]\n${value.stdout}` : '',
|
|
149
211
|
value.stderr ? `[stderr]\n${value.stderr}` : '',
|
|
@@ -213,7 +275,51 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
213
275
|
}
|
|
214
276
|
}
|
|
215
277
|
|
|
216
|
-
const result = await
|
|
278
|
+
const result = await (async (): Promise<RunCommandResult> => {
|
|
279
|
+
try {
|
|
280
|
+
return await runCommand(args.command, projectRoot, timeout, exec.signal)
|
|
281
|
+
} catch (err) {
|
|
282
|
+
// runCommand resolves its own start failures; this guard exists so
|
|
283
|
+
// NO unexpected rejection can escape `execute` as an unhandled tool
|
|
284
|
+
// crash (the tool contract is a structured result, always).
|
|
285
|
+
return {
|
|
286
|
+
command: args.command,
|
|
287
|
+
exitCode: 1,
|
|
288
|
+
stdout: '',
|
|
289
|
+
stderr: String(err),
|
|
290
|
+
timedOut: false,
|
|
291
|
+
canceled: false,
|
|
292
|
+
durationMs: 0,
|
|
293
|
+
truncated: false,
|
|
294
|
+
startError: String((err as Error)?.message ?? err),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
})()
|
|
298
|
+
|
|
299
|
+
// exec() refused the command before it ever ran (e.g. a NUL byte in a
|
|
300
|
+
// whitelisted command string): the command never started, so this is a
|
|
301
|
+
// structured failure — exit 1 + a rejectReason naming the cause — not a
|
|
302
|
+
// run that merely exited non-zero.
|
|
303
|
+
if (result.startError !== undefined) {
|
|
304
|
+
return {
|
|
305
|
+
allowed: false,
|
|
306
|
+
command: args.command,
|
|
307
|
+
exitCode: 1,
|
|
308
|
+
stdout: '',
|
|
309
|
+
stderr: result.stderr,
|
|
310
|
+
timedOut: false,
|
|
311
|
+
canceled: false,
|
|
312
|
+
durationMs: result.durationMs,
|
|
313
|
+
rejectReason: `command failed to start: ${result.startError}`,
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (result.truncated === true) {
|
|
317
|
+
return {
|
|
318
|
+
allowed: true,
|
|
319
|
+
...result,
|
|
320
|
+
rejectReason: `command output exceeded the ${Math.round(DEFAULT_MAX_BUFFER / (1024 * 1024))} MB maxBuffer limit and was truncated`,
|
|
321
|
+
}
|
|
322
|
+
}
|
|
217
323
|
return {
|
|
218
324
|
allowed: true,
|
|
219
325
|
...result,
|