@bilalimamoglu/sift 0.1.0 → 0.2.1

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/cli.js CHANGED
@@ -51,22 +51,23 @@ function loadRawConfig(explicitPath) {
51
51
  // src/config/defaults.ts
52
52
  var defaultConfig = {
53
53
  provider: {
54
- provider: "openai-compatible",
55
- model: "gpt-4.1-mini",
54
+ provider: "openai",
55
+ model: "gpt-5-nano",
56
56
  baseUrl: "https://api.openai.com/v1",
57
57
  apiKey: "",
58
+ jsonResponseFormat: "auto",
58
59
  timeoutMs: 2e4,
59
60
  temperature: 0.1,
60
- maxOutputTokens: 220
61
+ maxOutputTokens: 400
61
62
  },
62
63
  input: {
63
64
  stripAnsi: true,
64
65
  redact: false,
65
66
  redactStrict: false,
66
- maxCaptureChars: 25e4,
67
- maxInputChars: 2e4,
68
- headChars: 6e3,
69
- tailChars: 6e3
67
+ maxCaptureChars: 4e5,
68
+ maxInputChars: 6e4,
69
+ headChars: 2e4,
70
+ tailChars: 2e4
70
71
  },
71
72
  runtime: {
72
73
  rawFallback: true,
@@ -100,6 +101,16 @@ var defaultConfig = {
100
101
  format: "bullets",
101
102
  policy: "log-errors"
102
103
  },
104
+ "typecheck-summary": {
105
+ question: "Summarize the blocking typecheck failures. Group repeated errors by root cause and point to the first files or symbols to fix.",
106
+ format: "bullets",
107
+ policy: "typecheck-summary"
108
+ },
109
+ "lint-failures": {
110
+ question: "Summarize the blocking lint failures. Group repeated rules, highlight the top offending files, and call out only failures that matter for fixing the run.",
111
+ format: "bullets",
112
+ policy: "lint-failures"
113
+ },
103
114
  "infra-risk": {
104
115
  question: "Assess whether the infrastructure changes are risky and whether they look safe to apply.",
105
116
  format: "verdict",
@@ -108,9 +119,76 @@ var defaultConfig = {
108
119
  }
109
120
  };
110
121
 
122
+ // src/config/provider-api-key.ts
123
+ var OPENAI_COMPATIBLE_BASE_URL_ENV = [
124
+ { prefix: "https://api.openai.com/", envName: "OPENAI_API_KEY" },
125
+ { prefix: "https://openrouter.ai/api/", envName: "OPENROUTER_API_KEY" },
126
+ { prefix: "https://api.together.xyz/", envName: "TOGETHER_API_KEY" },
127
+ { prefix: "https://api.groq.com/openai/", envName: "GROQ_API_KEY" }
128
+ ];
129
+ var PROVIDER_API_KEY_ENV = {
130
+ anthropic: "ANTHROPIC_API_KEY",
131
+ claude: "ANTHROPIC_API_KEY",
132
+ groq: "GROQ_API_KEY",
133
+ openai: "OPENAI_API_KEY",
134
+ openrouter: "OPENROUTER_API_KEY",
135
+ together: "TOGETHER_API_KEY"
136
+ };
137
+ function normalizeBaseUrl(baseUrl) {
138
+ if (!baseUrl) {
139
+ return void 0;
140
+ }
141
+ return `${baseUrl.replace(/\/+$/, "")}/`.toLowerCase();
142
+ }
143
+ function resolveCompatibleEnvName(baseUrl) {
144
+ const normalized = normalizeBaseUrl(baseUrl);
145
+ if (!normalized) {
146
+ return void 0;
147
+ }
148
+ const match = OPENAI_COMPATIBLE_BASE_URL_ENV.find(
149
+ (entry) => normalized.startsWith(entry.prefix)
150
+ );
151
+ return match?.envName;
152
+ }
153
+ function resolveProviderApiKey(provider, baseUrl, env) {
154
+ if (provider === "openai-compatible") {
155
+ if (env.SIFT_PROVIDER_API_KEY) {
156
+ return env.SIFT_PROVIDER_API_KEY;
157
+ }
158
+ const envName2 = resolveCompatibleEnvName(baseUrl);
159
+ return envName2 ? env[envName2] : void 0;
160
+ }
161
+ if (!provider) {
162
+ return env.SIFT_PROVIDER_API_KEY;
163
+ }
164
+ const envName = PROVIDER_API_KEY_ENV[provider];
165
+ if (envName && env[envName]) {
166
+ return env[envName];
167
+ }
168
+ return env.SIFT_PROVIDER_API_KEY;
169
+ }
170
+ function getProviderApiKeyEnvNames(provider, baseUrl) {
171
+ const envNames = ["SIFT_PROVIDER_API_KEY"];
172
+ if (provider === "openai-compatible") {
173
+ const envName2 = resolveCompatibleEnvName(baseUrl);
174
+ if (envName2) {
175
+ envNames.push(envName2);
176
+ }
177
+ return envNames;
178
+ }
179
+ if (!provider) {
180
+ return envNames;
181
+ }
182
+ const envName = PROVIDER_API_KEY_ENV[provider];
183
+ if (envName) {
184
+ return [envName, ...envNames];
185
+ }
186
+ return envNames;
187
+ }
188
+
111
189
  // src/config/schema.ts
112
190
  import { z } from "zod";
113
- var providerNameSchema = z.enum(["openai-compatible"]);
191
+ var providerNameSchema = z.enum(["openai", "openai-compatible"]);
114
192
  var outputFormatSchema = z.enum([
115
193
  "brief",
116
194
  "bullets",
@@ -118,19 +196,23 @@ var outputFormatSchema = z.enum([
118
196
  "verdict"
119
197
  ]);
120
198
  var responseModeSchema = z.enum(["text", "json"]);
199
+ var jsonResponseFormatModeSchema = z.enum(["auto", "on", "off"]);
121
200
  var promptPolicyNameSchema = z.enum([
122
201
  "test-status",
123
202
  "audit-critical",
124
203
  "diff-summary",
125
204
  "build-failure",
126
205
  "log-errors",
127
- "infra-risk"
206
+ "infra-risk",
207
+ "typecheck-summary",
208
+ "lint-failures"
128
209
  ]);
129
210
  var providerConfigSchema = z.object({
130
211
  provider: providerNameSchema,
131
212
  model: z.string().min(1),
132
213
  baseUrl: z.string().url(),
133
214
  apiKey: z.string().optional(),
215
+ jsonResponseFormat: jsonResponseFormatModeSchema,
134
216
  timeoutMs: z.number().int().positive(),
135
217
  temperature: z.number().min(0).max(2),
136
218
  maxOutputTokens: z.number().int().positive()
@@ -184,14 +266,25 @@ function mergeDefined(base, override) {
184
266
  }
185
267
  return result;
186
268
  }
187
- function buildEnvOverrides(env) {
269
+ function stripApiKey(overrides) {
270
+ if (!overrides?.provider || overrides.provider.apiKey === void 0) {
271
+ return overrides;
272
+ }
273
+ return {
274
+ ...overrides,
275
+ provider: {
276
+ ...overrides.provider,
277
+ apiKey: void 0
278
+ }
279
+ };
280
+ }
281
+ function buildNonCredentialEnvOverrides(env) {
188
282
  const overrides = {};
189
- if (env.SIFT_PROVIDER || env.SIFT_MODEL || env.SIFT_BASE_URL || env.SIFT_API_KEY || env.SIFT_TIMEOUT_MS) {
283
+ if (env.SIFT_PROVIDER || env.SIFT_MODEL || env.SIFT_BASE_URL || env.SIFT_TIMEOUT_MS) {
190
284
  overrides.provider = {
191
285
  provider: env.SIFT_PROVIDER,
192
286
  model: env.SIFT_MODEL,
193
287
  baseUrl: env.SIFT_BASE_URL,
194
- apiKey: env.SIFT_API_KEY,
195
288
  timeoutMs: env.SIFT_TIMEOUT_MS ? Number(env.SIFT_TIMEOUT_MS) : void 0
196
289
  };
197
290
  }
@@ -203,12 +296,40 @@ function buildEnvOverrides(env) {
203
296
  }
204
297
  return overrides;
205
298
  }
299
+ function buildCredentialEnvOverrides(env, context) {
300
+ const apiKey = resolveProviderApiKey(context.provider, context.baseUrl, env);
301
+ if (apiKey === void 0) {
302
+ return {};
303
+ }
304
+ return {
305
+ provider: {
306
+ apiKey
307
+ }
308
+ };
309
+ }
206
310
  function resolveConfig(options = {}) {
207
311
  const env = options.env ?? process.env;
208
312
  const fileConfig = loadRawConfig(options.configPath);
209
- const envConfig = buildEnvOverrides(env);
313
+ const nonCredentialEnvConfig = buildNonCredentialEnvOverrides(env);
314
+ const contextConfig = mergeDefined(
315
+ mergeDefined(
316
+ mergeDefined(defaultConfig, fileConfig),
317
+ nonCredentialEnvConfig
318
+ ),
319
+ stripApiKey(options.cliOverrides) ?? {}
320
+ );
321
+ const credentialEnvConfig = buildCredentialEnvOverrides(env, {
322
+ provider: contextConfig.provider.provider,
323
+ baseUrl: contextConfig.provider.baseUrl
324
+ });
210
325
  const merged = mergeDefined(
211
- mergeDefined(mergeDefined(defaultConfig, fileConfig), envConfig),
326
+ mergeDefined(
327
+ mergeDefined(
328
+ mergeDefined(defaultConfig, fileConfig),
329
+ nonCredentialEnvConfig
330
+ ),
331
+ credentialEnvConfig
332
+ ),
212
333
  options.cliOverrides ?? {}
213
334
  );
214
335
  return siftConfigSchema.parse(merged);
@@ -269,7 +390,7 @@ function configValidate(configPath) {
269
390
  });
270
391
  const resolvedPath = findConfigPath(configPath);
271
392
  process.stdout.write(
272
- `Config is valid${resolvedPath ? ` (${resolvedPath})` : " (using defaults)"}.
393
+ `Resolved config is valid${resolvedPath ? ` (${resolvedPath})` : " (using defaults)"}.
273
394
  `
274
395
  );
275
396
  }
@@ -278,6 +399,7 @@ function configValidate(configPath) {
278
399
  function runDoctor(config) {
279
400
  const lines = [
280
401
  "sift doctor",
402
+ "mode: local config completeness check",
281
403
  `provider: ${config.provider.provider}`,
282
404
  `model: ${config.provider.model}`,
283
405
  `baseUrl: ${config.provider.baseUrl}`,
@@ -295,8 +417,14 @@ function runDoctor(config) {
295
417
  if (!config.provider.model) {
296
418
  problems.push("Missing provider.model");
297
419
  }
298
- if (config.provider.provider === "openai-compatible" && !config.provider.apiKey) {
420
+ if ((config.provider.provider === "openai" || config.provider.provider === "openai-compatible") && !config.provider.apiKey) {
299
421
  problems.push("Missing provider.apiKey");
422
+ problems.push(
423
+ `Set one of: ${getProviderApiKeyEnvNames(
424
+ config.provider.provider,
425
+ config.provider.baseUrl
426
+ ).join(", ")}`
427
+ );
300
428
  }
301
429
  if (problems.length > 0) {
302
430
  process.stderr.write(`${problems.join("\n")}
@@ -331,10 +459,160 @@ import { spawn } from "child_process";
331
459
  import { constants as osConstants } from "os";
332
460
  import pc2 from "picocolors";
333
461
 
462
+ // src/core/gate.ts
463
+ var FAIL_ON_SUPPORTED_PRESETS = /* @__PURE__ */ new Set(["infra-risk", "audit-critical"]);
464
+ function parseJson(output) {
465
+ try {
466
+ return JSON.parse(output);
467
+ } catch {
468
+ return null;
469
+ }
470
+ }
471
+ function supportsFailOnPreset(presetName) {
472
+ return typeof presetName === "string" && FAIL_ON_SUPPORTED_PRESETS.has(presetName);
473
+ }
474
+ function assertSupportedFailOnPreset(presetName) {
475
+ if (!supportsFailOnPreset(presetName)) {
476
+ throw new Error(
477
+ "--fail-on is supported only for built-in presets: infra-risk, audit-critical."
478
+ );
479
+ }
480
+ }
481
+ function assertSupportedFailOnFormat(args) {
482
+ const expectedFormat = args.presetName === "infra-risk" ? "verdict" : "json";
483
+ if (args.format !== expectedFormat) {
484
+ throw new Error(
485
+ `--fail-on requires the default ${expectedFormat} format for preset ${args.presetName}.`
486
+ );
487
+ }
488
+ }
489
+ function evaluateGate(args) {
490
+ const parsed = parseJson(args.output);
491
+ if (!parsed || typeof parsed !== "object") {
492
+ return { shouldFail: false };
493
+ }
494
+ if (args.presetName === "infra-risk") {
495
+ return {
496
+ shouldFail: parsed["verdict"] === "fail"
497
+ };
498
+ }
499
+ if (args.presetName === "audit-critical") {
500
+ const status = parsed["status"];
501
+ const vulnerabilities = parsed["vulnerabilities"];
502
+ return {
503
+ shouldFail: status === "ok" && Array.isArray(vulnerabilities) && vulnerabilities.length > 0
504
+ };
505
+ }
506
+ return { shouldFail: false };
507
+ }
508
+
334
509
  // src/core/run.ts
335
510
  import pc from "picocolors";
336
511
 
512
+ // src/providers/systemInstruction.ts
513
+ var REDUCTION_SYSTEM_INSTRUCTION = "You reduce noisy command output into compact answers for agents and automation.";
514
+
515
+ // src/providers/openai.ts
516
+ function usesNativeJsonResponseFormat(mode) {
517
+ return mode !== "off";
518
+ }
519
+ function extractResponseText(payload) {
520
+ if (typeof payload?.output_text === "string") {
521
+ return payload.output_text.trim();
522
+ }
523
+ if (!Array.isArray(payload?.output)) {
524
+ return "";
525
+ }
526
+ return payload.output.flatMap((item) => Array.isArray(item?.content) ? item.content : []).map((item) => item?.type === "output_text" ? item.text : "").filter((text) => typeof text === "string" && text.trim().length > 0).join("").trim();
527
+ }
528
+ async function buildOpenAIError(response) {
529
+ let detail = `Provider returned HTTP ${response.status}`;
530
+ try {
531
+ const data = await response.json();
532
+ const message = data?.error?.message;
533
+ if (typeof message === "string" && message.trim().length > 0) {
534
+ detail = `${detail}: ${message.trim()}`;
535
+ }
536
+ } catch {
537
+ }
538
+ return new Error(detail);
539
+ }
540
+ var OpenAIProvider = class {
541
+ name = "openai";
542
+ baseUrl;
543
+ apiKey;
544
+ constructor(options) {
545
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
546
+ this.apiKey = options.apiKey;
547
+ }
548
+ async generate(input) {
549
+ const controller = new AbortController();
550
+ const timeout = setTimeout(() => controller.abort(), input.timeoutMs);
551
+ try {
552
+ const url = new URL("responses", `${this.baseUrl}/`);
553
+ const response = await fetch(url, {
554
+ method: "POST",
555
+ signal: controller.signal,
556
+ headers: {
557
+ "content-type": "application/json",
558
+ ...this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}
559
+ },
560
+ body: JSON.stringify({
561
+ model: input.model,
562
+ instructions: REDUCTION_SYSTEM_INSTRUCTION,
563
+ input: input.prompt,
564
+ reasoning: {
565
+ effort: "minimal"
566
+ },
567
+ text: {
568
+ verbosity: "low",
569
+ ...input.responseMode === "json" && usesNativeJsonResponseFormat(input.jsonResponseFormat) ? {
570
+ format: {
571
+ type: "json_object"
572
+ }
573
+ } : {}
574
+ },
575
+ max_output_tokens: input.maxOutputTokens
576
+ })
577
+ });
578
+ if (!response.ok) {
579
+ throw await buildOpenAIError(response);
580
+ }
581
+ const data = await response.json();
582
+ const text = extractResponseText(data);
583
+ if (!text) {
584
+ throw new Error("Provider returned an empty response");
585
+ }
586
+ return {
587
+ text,
588
+ usage: data?.usage ? {
589
+ inputTokens: data.usage.input_tokens,
590
+ outputTokens: data.usage.output_tokens,
591
+ totalTokens: data.usage.total_tokens
592
+ } : void 0,
593
+ raw: data
594
+ };
595
+ } catch (error) {
596
+ if (error.name === "AbortError") {
597
+ throw new Error("Provider request timed out");
598
+ }
599
+ throw error;
600
+ } finally {
601
+ clearTimeout(timeout);
602
+ }
603
+ }
604
+ };
605
+
337
606
  // src/providers/openaiCompatible.ts
607
+ function supportsNativeJsonResponseFormat(baseUrl, mode) {
608
+ if (mode === "off") {
609
+ return false;
610
+ }
611
+ if (mode === "on") {
612
+ return true;
613
+ }
614
+ return /^https:\/\/api\.openai\.com(?:\/|$)/i.test(baseUrl);
615
+ }
338
616
  function extractMessageText(payload) {
339
617
  const content = payload?.choices?.[0]?.message?.content;
340
618
  if (typeof content === "string") {
@@ -345,6 +623,18 @@ function extractMessageText(payload) {
345
623
  }
346
624
  return "";
347
625
  }
626
+ async function buildOpenAICompatibleError(response) {
627
+ let detail = `Provider returned HTTP ${response.status}`;
628
+ try {
629
+ const data = await response.json();
630
+ const message = data?.error?.message;
631
+ if (typeof message === "string" && message.trim().length > 0) {
632
+ detail = `${detail}: ${message.trim()}`;
633
+ }
634
+ } catch {
635
+ }
636
+ return new Error(detail);
637
+ }
348
638
  var OpenAICompatibleProvider = class {
349
639
  name = "openai-compatible";
350
640
  baseUrl;
@@ -369,10 +659,11 @@ var OpenAICompatibleProvider = class {
369
659
  model: input.model,
370
660
  temperature: input.temperature,
371
661
  max_tokens: input.maxOutputTokens,
662
+ ...input.responseMode === "json" && supportsNativeJsonResponseFormat(this.baseUrl, input.jsonResponseFormat) ? { response_format: { type: "json_object" } } : {},
372
663
  messages: [
373
664
  {
374
665
  role: "system",
375
- content: "You reduce noisy command output into compact answers for agents and automation."
666
+ content: REDUCTION_SYSTEM_INSTRUCTION
376
667
  },
377
668
  {
378
669
  role: "user",
@@ -382,7 +673,7 @@ var OpenAICompatibleProvider = class {
382
673
  })
383
674
  });
384
675
  if (!response.ok) {
385
- throw new Error(`Provider returned HTTP ${response.status}`);
676
+ throw await buildOpenAICompatibleError(response);
386
677
  }
387
678
  const data = await response.json();
388
679
  const text = extractMessageText(data);
@@ -411,6 +702,12 @@ var OpenAICompatibleProvider = class {
411
702
 
412
703
  // src/providers/factory.ts
413
704
  function createProvider(config) {
705
+ if (config.provider.provider === "openai") {
706
+ return new OpenAIProvider({
707
+ baseUrl: config.provider.baseUrl,
708
+ apiKey: config.provider.apiKey
709
+ });
710
+ }
414
711
  if (config.provider.provider === "openai-compatible") {
415
712
  return new OpenAICompatibleProvider({
416
713
  baseUrl: config.provider.baseUrl,
@@ -536,6 +833,33 @@ var BUILT_IN_POLICIES = {
536
833
  `If there is no clear error signal, reply exactly with: ${INSUFFICIENT_SIGNAL_TEXT}`
537
834
  ]
538
835
  },
836
+ "typecheck-summary": {
837
+ name: "typecheck-summary",
838
+ responseMode: "text",
839
+ taskRules: [
840
+ "Return at most 5 short bullet points.",
841
+ "Determine whether the typecheck failed or passed.",
842
+ "Group repeated diagnostics into root-cause buckets instead of echoing many duplicate lines.",
843
+ "Mention the first concrete files, symbols, or error categories to fix when they are visible.",
844
+ "Prefer compiler or type-system errors over timing, progress, or summary noise.",
845
+ "If the output clearly indicates success, say that briefly and do not add extra bullets.",
846
+ `If you cannot tell whether the typecheck failed, reply exactly with: ${INSUFFICIENT_SIGNAL_TEXT}`
847
+ ]
848
+ },
849
+ "lint-failures": {
850
+ name: "lint-failures",
851
+ responseMode: "text",
852
+ taskRules: [
853
+ "Return at most 5 short bullet points.",
854
+ "Determine whether lint failed or whether there are no blocking lint failures.",
855
+ "Group repeated rule violations instead of listing the same rule many times.",
856
+ "Mention the top offending files and rule names when they are visible.",
857
+ "Distinguish blocking failures from warnings only when that distinction is clearly visible in the input.",
858
+ "Do not invent autofixability; only mention autofix or --fix support when the tool output explicitly says so.",
859
+ "If the output clearly indicates success or no blocking failures, say that briefly and stop.",
860
+ `If there is not enough evidence to determine the lint result, reply exactly with: ${INSUFFICIENT_SIGNAL_TEXT}`
861
+ ]
862
+ },
539
863
  "infra-risk": {
540
864
  name: "infra-risk",
541
865
  responseMode: "json",
@@ -879,6 +1203,7 @@ function prepareInput(raw, config) {
879
1203
  }
880
1204
 
881
1205
  // src/core/run.ts
1206
+ var RETRY_DELAY_MS = 300;
882
1207
  function normalizeOutput(text, responseMode) {
883
1208
  if (responseMode !== "json") {
884
1209
  return text.trim();
@@ -890,6 +1215,68 @@ function normalizeOutput(text, responseMode) {
890
1215
  throw new Error("Provider returned invalid JSON");
891
1216
  }
892
1217
  }
1218
+ function buildDryRunOutput(args) {
1219
+ return JSON.stringify(
1220
+ {
1221
+ status: "dry-run",
1222
+ strategy: args.heuristicOutput ? "heuristic" : "provider",
1223
+ provider: {
1224
+ name: args.providerName,
1225
+ model: args.request.config.provider.model,
1226
+ baseUrl: args.request.config.provider.baseUrl,
1227
+ jsonResponseFormat: args.request.config.provider.jsonResponseFormat
1228
+ },
1229
+ question: args.request.question,
1230
+ format: args.request.format,
1231
+ responseMode: args.responseMode,
1232
+ policy: args.request.policyName ?? null,
1233
+ heuristicOutput: args.heuristicOutput ?? null,
1234
+ input: {
1235
+ originalLength: args.prepared.meta.originalLength,
1236
+ finalLength: args.prepared.meta.finalLength,
1237
+ redactionApplied: args.prepared.meta.redactionApplied,
1238
+ truncatedApplied: args.prepared.meta.truncatedApplied,
1239
+ text: args.prepared.truncated
1240
+ },
1241
+ prompt: args.prompt
1242
+ },
1243
+ null,
1244
+ 2
1245
+ );
1246
+ }
1247
+ async function delay(ms) {
1248
+ await new Promise((resolve) => setTimeout(resolve, ms));
1249
+ }
1250
+ async function generateWithRetry(args) {
1251
+ let lastError;
1252
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1253
+ try {
1254
+ return await args.provider.generate({
1255
+ model: args.request.config.provider.model,
1256
+ prompt: args.prompt,
1257
+ temperature: args.request.config.provider.temperature,
1258
+ maxOutputTokens: args.request.config.provider.maxOutputTokens,
1259
+ timeoutMs: args.request.config.provider.timeoutMs,
1260
+ responseMode: args.responseMode,
1261
+ jsonResponseFormat: args.request.config.provider.jsonResponseFormat
1262
+ });
1263
+ } catch (error) {
1264
+ lastError = error;
1265
+ const reason = error instanceof Error ? error.message : "unknown_error";
1266
+ if (attempt > 0 || !isRetriableReason(reason)) {
1267
+ throw error;
1268
+ }
1269
+ if (args.request.config.runtime.verbose) {
1270
+ process.stderr.write(
1271
+ `${pc.dim("sift")} retry=1 reason=${reason} delay_ms=${RETRY_DELAY_MS}
1272
+ `
1273
+ );
1274
+ }
1275
+ await delay(RETRY_DELAY_MS);
1276
+ }
1277
+ }
1278
+ throw lastError instanceof Error ? lastError : new Error("unknown_error");
1279
+ }
893
1280
  async function runSift(request) {
894
1281
  const prepared = prepareInput(request.stdin, request.config.input);
895
1282
  const { prompt, responseMode } = buildPrompt({
@@ -915,15 +1302,33 @@ async function runSift(request) {
915
1302
  process.stderr.write(`${pc.dim("sift")} heuristic=${request.policyName}
916
1303
  `);
917
1304
  }
1305
+ if (request.dryRun) {
1306
+ return buildDryRunOutput({
1307
+ request,
1308
+ providerName: provider.name,
1309
+ prompt,
1310
+ responseMode,
1311
+ prepared,
1312
+ heuristicOutput
1313
+ });
1314
+ }
918
1315
  return heuristicOutput;
919
1316
  }
1317
+ if (request.dryRun) {
1318
+ return buildDryRunOutput({
1319
+ request,
1320
+ providerName: provider.name,
1321
+ prompt,
1322
+ responseMode,
1323
+ prepared,
1324
+ heuristicOutput: null
1325
+ });
1326
+ }
920
1327
  try {
921
- const result = await provider.generate({
922
- model: request.config.provider.model,
1328
+ const result = await generateWithRetry({
1329
+ provider,
1330
+ request,
923
1331
  prompt,
924
- temperature: request.config.provider.temperature,
925
- maxOutputTokens: request.config.provider.maxOutputTokens,
926
- timeoutMs: request.config.provider.timeoutMs,
927
1332
  responseMode
928
1333
  });
929
1334
  if (looksLikeRejectedModelOutput({
@@ -1101,6 +1506,12 @@ async function runExec(request) {
1101
1506
  });
1102
1507
  process.stdout.write(`${output}
1103
1508
  `);
1509
+ if (request.failOn && !request.dryRun && exitCode === 0 && supportsFailOnPreset(request.presetName) && evaluateGate({
1510
+ presetName: request.presetName,
1511
+ output
1512
+ }).shouldFail) {
1513
+ return 1;
1514
+ }
1104
1515
  }
1105
1516
  return exitCode;
1106
1517
  }
@@ -1138,12 +1549,13 @@ function toNumber(value) {
1138
1549
  }
1139
1550
  function buildCliOverrides(options) {
1140
1551
  const overrides = {};
1141
- if (options.provider !== void 0 || options.model !== void 0 || options.baseUrl !== void 0 || options.apiKey !== void 0 || options.timeoutMs !== void 0) {
1552
+ if (options.provider !== void 0 || options.model !== void 0 || options.baseUrl !== void 0 || options.apiKey !== void 0 || options.jsonResponseFormat !== void 0 || options.timeoutMs !== void 0) {
1142
1553
  overrides.provider = {
1143
1554
  provider: options.provider,
1144
1555
  model: options.model,
1145
1556
  baseUrl: options.baseUrl,
1146
1557
  apiKey: options.apiKey,
1558
+ jsonResponseFormat: options.jsonResponseFormat,
1147
1559
  timeoutMs: toNumber(options.timeoutMs)
1148
1560
  };
1149
1561
  }
@@ -1167,9 +1579,25 @@ function buildCliOverrides(options) {
1167
1579
  return overrides;
1168
1580
  }
1169
1581
  function applySharedOptions(command) {
1170
- return command.option("--provider <provider>", "Provider: openai-compatible").option("--model <model>", "Model name").option("--base-url <url>", "Provider base URL").option("--api-key <key>", "Provider API key").option("--timeout-ms <ms>", "Request timeout in milliseconds").option("--format <format>", "brief | bullets | json | verdict").option("--max-capture-chars <n>", "Maximum raw child output chars kept in memory").option("--max-input-chars <n>", "Maximum input chars sent to the model").option("--head-chars <n>", "Head chars to preserve during truncation").option("--tail-chars <n>", "Tail chars to preserve during truncation").option("--strip-ansi", "Force ANSI stripping").option("--redact", "Enable standard redaction").option("--redact-strict", "Enable strict redaction").option("--raw-fallback", "Enable raw fallback text output").option("--config <path>", "Path to config file").option("--verbose", "Enable verbose stderr logging");
1582
+ return command.option("--provider <provider>", "Provider: openai | openai-compatible").option("--model <model>", "Model name").option("--base-url <url>", "Provider base URL").option(
1583
+ "--api-key <key>",
1584
+ "Provider API key (or set OPENAI_API_KEY for provider=openai; use SIFT_PROVIDER_API_KEY or endpoint-native envs for openai-compatible)"
1585
+ ).option(
1586
+ "--json-response-format <mode>",
1587
+ "JSON response format mode: auto | on | off"
1588
+ ).option("--timeout-ms <ms>", "Request timeout in milliseconds").option("--format <format>", "brief | bullets | json | verdict").option("--max-capture-chars <n>", "Maximum raw child output chars kept in memory").option("--max-input-chars <n>", "Maximum input chars sent to the model").option("--head-chars <n>", "Head chars to preserve during truncation").option("--tail-chars <n>", "Tail chars to preserve during truncation").option("--strip-ansi", "Force ANSI stripping").option("--redact", "Enable standard redaction").option("--redact-strict", "Enable strict redaction").option("--raw-fallback", "Enable raw fallback text output").option("--dry-run", "Show the reduced input and prompt without calling the provider").option(
1589
+ "--fail-on",
1590
+ "Fail with exit code 1 when a supported built-in preset produces a blocking result"
1591
+ ).option("--config <path>", "Path to config file").option("--verbose", "Enable verbose stderr logging");
1171
1592
  }
1172
1593
  async function executeRun(args) {
1594
+ if (Boolean(args.options.failOn)) {
1595
+ assertSupportedFailOnPreset(args.presetName);
1596
+ assertSupportedFailOnFormat({
1597
+ presetName: args.presetName,
1598
+ format: args.format
1599
+ });
1600
+ }
1173
1601
  const config = resolveConfig({
1174
1602
  configPath: args.options.config,
1175
1603
  env: process.env,
@@ -1181,12 +1609,20 @@ async function executeRun(args) {
1181
1609
  format: args.format,
1182
1610
  stdin,
1183
1611
  config,
1612
+ dryRun: Boolean(args.options.dryRun),
1613
+ presetName: args.presetName,
1184
1614
  policyName: args.policyName,
1185
1615
  outputContract: args.outputContract,
1186
1616
  fallbackJson: args.fallbackJson
1187
1617
  });
1188
1618
  process.stdout.write(`${output}
1189
1619
  `);
1620
+ if (Boolean(args.options.failOn) && !Boolean(args.options.dryRun) && args.presetName && evaluateGate({
1621
+ presetName: args.presetName,
1622
+ output
1623
+ }).shouldFail) {
1624
+ process.exitCode = 1;
1625
+ }
1190
1626
  }
1191
1627
  function extractExecCommand(options) {
1192
1628
  const passthrough = Array.isArray(options["--"]) ? options["--"].map((value) => String(value)) : [];
@@ -1203,6 +1639,13 @@ function extractExecCommand(options) {
1203
1639
  };
1204
1640
  }
1205
1641
  async function executeExec(args) {
1642
+ if (Boolean(args.options.failOn)) {
1643
+ assertSupportedFailOnPreset(args.presetName);
1644
+ assertSupportedFailOnFormat({
1645
+ presetName: args.presetName,
1646
+ format: args.format
1647
+ });
1648
+ }
1206
1649
  const config = resolveConfig({
1207
1650
  configPath: args.options.config,
1208
1651
  env: process.env,
@@ -1213,6 +1656,9 @@ async function executeExec(args) {
1213
1656
  question: args.question,
1214
1657
  format: args.format,
1215
1658
  config,
1659
+ dryRun: Boolean(args.options.dryRun),
1660
+ failOn: Boolean(args.options.failOn),
1661
+ presetName: args.presetName,
1216
1662
  policyName: args.policyName,
1217
1663
  outputContract: args.outputContract,
1218
1664
  fallbackJson: args.fallbackJson,
@@ -1221,7 +1667,7 @@ async function executeExec(args) {
1221
1667
  }
1222
1668
  applySharedOptions(
1223
1669
  cli.command("preset <name>", "Run a named preset against piped CLI output")
1224
- ).action(async (name, options) => {
1670
+ ).usage("preset <name> [options]").example("preset test-status < test-output.txt").action(async (name, options) => {
1225
1671
  const config = resolveConfig({
1226
1672
  configPath: options.config,
1227
1673
  env: process.env,
@@ -1231,6 +1677,7 @@ applySharedOptions(
1231
1677
  await executeRun({
1232
1678
  question: preset.question,
1233
1679
  format: options.format ?? preset.format,
1680
+ presetName: name,
1234
1681
  policyName: options.format === void 0 || options.format === preset.format ? preset.policy : void 0,
1235
1682
  options,
1236
1683
  outputContract: preset.outputContract,
@@ -1239,7 +1686,7 @@ applySharedOptions(
1239
1686
  });
1240
1687
  applySharedOptions(
1241
1688
  cli.command("exec [question]", "Run a command and reduce its output").allowUnknownOptions()
1242
- ).option("--shell <command>", "Execute a shell command string instead of argv mode").option("--preset <name>", "Run a named preset in exec mode").action(async (question, options) => {
1689
+ ).usage("exec [question] [options] -- <program> [args...]").example('exec "what changed?" -- git diff').example("exec --preset test-status -- pytest").example('exec --preset infra-risk --shell "terraform plan"').option("--shell <command>", "Execute a shell command string instead of argv mode").option("--preset <name>", "Run a named preset in exec mode").action(async (question, options) => {
1243
1690
  if (question === "preset") {
1244
1691
  throw new Error("Use 'sift exec --preset <name> -- <program> ...' instead.");
1245
1692
  }
@@ -1259,6 +1706,7 @@ applySharedOptions(
1259
1706
  await executeExec({
1260
1707
  question: preset.question,
1261
1708
  format: options.format ?? preset.format,
1709
+ presetName,
1262
1710
  policyName: options.format === void 0 || options.format === preset.format ? preset.policy : void 0,
1263
1711
  options,
1264
1712
  outputContract: preset.outputContract,
@@ -1276,7 +1724,10 @@ applySharedOptions(
1276
1724
  options
1277
1725
  });
1278
1726
  });
1279
- cli.command("config <action>", "Config commands: init | show | validate").option("--path <path>", "Target config path for init").option("--config <path>", "Path to config file").option("--show-secrets", "Show secret values in config show").action((action, options) => {
1727
+ cli.command(
1728
+ "config <action>",
1729
+ "Config commands: init | show | validate (show/validate use resolved runtime config)"
1730
+ ).usage("config <init|show|validate> [options]").example("config init").example("config show").example("config validate --config ./sift.config.yaml").option("--path <path>", "Target config path for init").option("--config <path>", "Path to config file").option("--show-secrets", "Show secret values in config show").action((action, options) => {
1280
1731
  if (action === "init") {
1281
1732
  configInit(options.path);
1282
1733
  return;
@@ -1294,14 +1745,14 @@ cli.command("config <action>", "Config commands: init | show | validate").option
1294
1745
  }
1295
1746
  throw new Error(`Unknown config action: ${action}`);
1296
1747
  });
1297
- cli.command("doctor", "Validate runtime configuration").option("--config <path>", "Path to config file").action((options) => {
1748
+ cli.command("doctor", "Check local runtime config completeness").usage("doctor [options]").option("--config <path>", "Path to config file").action((options) => {
1298
1749
  const config = resolveConfig({
1299
1750
  configPath: options.config,
1300
1751
  env: process.env
1301
1752
  });
1302
1753
  process.exitCode = runDoctor(config);
1303
1754
  });
1304
- cli.command("presets <action> [name]", "Preset commands: list | show").option("--config <path>", "Path to config file").option("--internal", "Show internal preset fields in presets show").action((action, name, options) => {
1755
+ cli.command("presets <action> [name]", "Preset commands: list | show").usage("presets <list|show> [name] [options]").example("presets list").example("presets show infra-risk").option("--config <path>", "Path to config file").option("--internal", "Show internal preset fields in presets show").action((action, name, options) => {
1305
1756
  const config = resolveConfig({
1306
1757
  configPath: options.config,
1307
1758
  env: process.env