@bilalimamoglu/sift 0.4.2 → 0.4.4

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 CHANGED
@@ -1,22 +1,45 @@
1
+ <div align="center">
2
+
3
+ <img src="assets/brand/sift-logo-minimal-teal-default.svg" alt="sift logo" width="220" />
4
+
1
5
  # sift
2
6
 
7
+ ### Turn noisy command output into actionable diagnoses for your coding agent
8
+
9
+ **Benchmark-backed test triage - Heuristic-first reductions - Agent-ready terminal workflows**
10
+
3
11
  [![npm version](https://img.shields.io/npm/v/@bilalimamoglu/sift)](https://www.npmjs.com/package/@bilalimamoglu/sift)
4
12
  [![license](https://img.shields.io/github/license/bilalimamoglu/sift)](LICENSE)
5
13
  [![CI](https://img.shields.io/github/actions/workflow/status/bilalimamoglu/sift/ci.yml?branch=main&label=CI)](https://github.com/bilalimamoglu/sift/actions/workflows/ci.yml)
14
+ [![Node.js](https://img.shields.io/badge/Node.js-20+-green.svg)](https://nodejs.org/)
6
15
 
7
- Turn 13,000 lines of test output into 2 root causes.
16
+ <br />
17
+
18
+ ### Get Started
19
+
20
+ ```bash
21
+ npm install -g @bilalimamoglu/sift
22
+ ```
23
+
24
+ <sub>Works with pytest, vitest, jest, tsc, ESLint, webpack, Cargo, terraform, npm audit, and more.</sub>
25
+
26
+ </div>
27
+
28
+ ---
8
29
 
9
- Your agent reads a diagnosis, not a log file.
30
+ ## Why Sift?
31
+
32
+ When an agent hits noisy output, it burns budget reading logs instead of fixing the problem.
33
+
34
+ `sift` sits in front of that output and reduces it into a small, actionable first pass. Your agent reads the diagnosis, not the wall of text.
35
+
36
+ Turn 13,000 lines of test output into 2 root causes.
10
37
 
11
38
  <p align="center">
12
39
  <img src="assets/readme/test-status-demo.gif" alt="sift turning a pytest failure wall into a short diagnosis" width="960" />
13
40
  </p>
14
41
 
15
- ## Before / After
16
-
17
- 128 test failures. 13,000 lines of logs. The agent reads all of it.
18
-
19
- With `sift`, it reads this instead:
42
+ With `sift`, the same run becomes:
20
43
 
21
44
  ```text
22
45
  - Tests did not pass.
@@ -30,20 +53,118 @@ With `sift`, it reads this instead:
30
53
  - Decision: stop and act.
31
54
  ```
32
55
 
33
- Same diagnosis. One run compressed from 198,000 tokens to 129.
56
+ In the largest benchmark fixture, sift compressed 198,026 raw output tokens to 129. That is what the agent reads instead of the full log.
57
+
58
+ ---
59
+
60
+ ## Benchmark Results
61
+
62
+ The output reduction above measures a single command's raw output. The table below measures the full end-to-end debug session: how many tokens, tool calls, and seconds the agent spends to reach the same diagnosis.
63
+
64
+ Real debug loop on a 640-test Python backend with 124 repeated setup errors, 3 contract failures, and 511 passing tests:
65
+
66
+ | Metric | Without sift | With sift | Reduction |
67
+ |--------|-------------:|----------:|----------:|
68
+ | Tokens | 52,944 | 20,049 | 62% fewer |
69
+ | Tool calls | 40.8 | 12 | 71% fewer |
70
+ | Wall-clock time | 244s | 85s | 65% faster |
71
+ | Commands | 15.5 | 6 | 61% fewer |
72
+ | Diagnosis | Same | Same | Same outcome |
73
+
74
+ Same diagnosis, less agent thrash.
75
+
76
+ Methodology and caveats: [BENCHMARK_NOTES.md](BENCHMARK_NOTES.md)
77
+
78
+ ---
79
+
80
+ ## How It Works
81
+
82
+ `sift` keeps the explanation simple:
83
+
84
+ 1. **Capture output.** Run the noisy command or accept already-existing piped output.
85
+ 2. **Run local heuristics.** Detect known failure shapes first so common cases stay cheap and deterministic.
86
+ 3. **Return the diagnosis.** When heuristics are confident, `sift` gives the agent the root cause, anchor, and next step.
87
+ 4. **Fall back only when needed.** If heuristics are not enough, `sift` uses a cheaper model instead of spending your main agent budget.
88
+
89
+ Your agent spends tokens fixing, not reading.
90
+
91
+ ---
92
+
93
+ ## Key Features
94
+
95
+ <table>
96
+ <tr>
97
+ <td width="33%" valign="top">
98
+
99
+ ### Test Failure Triage
100
+ Collapse repeated pytest, vitest, and jest failures into a short diagnosis with root-cause buckets, anchors, and fix hints.
101
+
102
+ </td>
103
+ <td width="33%" valign="top">
104
+
105
+ ### Typecheck and Lint Reduction
106
+ Group noisy `tsc` and ESLint output into the few issues that actually matter instead of dumping the whole log back into the model.
107
+
108
+ </td>
109
+ <td width="33%" valign="top">
110
+
111
+ ### Build Failure Extraction
112
+ Pull out the first concrete error from webpack, esbuild/Vite, Cargo, Go, GCC/Clang, and similar build output.
113
+
114
+ </td>
115
+ </tr>
116
+ <tr>
117
+ <td width="33%" valign="top">
118
+
119
+ ### Audit and Infra Risk
120
+ Surface high-impact `npm audit` findings and destructive `terraform plan` signals without making the agent read everything.
121
+
122
+ </td>
123
+ <td width="33%" valign="top">
124
+
125
+ ### Heuristic-First by Default
126
+ Every built-in preset tries local parsing first. When the heuristic handles the output, no provider call is needed.
127
+
128
+ </td>
129
+ <td width="33%" valign="top">
130
+
131
+ ### Agent and Automation Friendly
132
+ Use `sift` in Codex, Claude, CI, hooks, or shell scripts so downstream tooling gets short, structured answers instead of raw noise.
133
+
134
+ </td>
135
+ </tr>
136
+ </table>
137
+
138
+ ---
139
+
140
+ ## Setup and Agent Integration
141
+
142
+ Most built-in presets run entirely on local heuristics with no API key needed. For presets that fall back to a model (`diff-summary`, `log-errors`, or when heuristics are not confident enough), sift supports OpenAI-compatible and OpenRouter-compatible endpoints.
143
+
144
+ Set up the provider first, then install the managed instruction block for the agent you want to steer:
145
+
146
+ ```bash
147
+ sift config setup
148
+ sift doctor
149
+ sift agent install codex
150
+ sift agent install claude
151
+ ```
152
+
153
+ You can also preview, inspect, or remove those blocks:
154
+
155
+ ```bash
156
+ sift agent show codex
157
+ sift agent status
158
+ sift agent remove codex
159
+ ```
34
160
 
35
- ## Not just tests
161
+ Command-first details live in [docs/cli-reference.md](docs/cli-reference.md).
36
162
 
37
- The same idea applies across noisy dev workflows:
163
+ ---
38
164
 
39
- - **Type errors** → grouped by error code, no model call
40
- - **Lint output** → grouped by rule, no model call
41
- - **Build failures** → first real error from webpack, esbuild/Vite, Cargo, Go, GCC/Clang
42
- - **`npm audit`** → high/critical vulnerabilities only, no model call
43
- - **`terraform plan`** → destructive risk detection, no model call
44
- - **Diffs and logs** → compressed through a cheaper model before reaching your agent
165
+ ## Quick Start
45
166
 
46
- ## Install
167
+ ### 1. Install
47
168
 
48
169
  ```bash
49
170
  npm install -g @bilalimamoglu/sift
@@ -51,109 +172,115 @@ npm install -g @bilalimamoglu/sift
51
172
 
52
173
  Requires Node.js 20+.
53
174
 
54
- ## Try it
175
+ ### 2. Run Sift in front of a noisy command
55
176
 
56
177
  ```bash
57
178
  sift exec --preset test-status -- pytest -q
58
- sift exec --preset test-status -- npx vitest run
59
- sift exec --preset test-status -- npx jest
60
179
  ```
61
180
 
62
- Other workflows:
181
+ Other common entry points:
63
182
 
64
183
  ```bash
65
- sift exec --preset typecheck-summary -- npx tsc --noEmit
66
- sift exec --preset lint-failures -- npx eslint src/
67
- sift exec --preset build-failure -- npm run build
68
- sift exec --preset audit-critical -- npm audit
69
- sift exec --preset infra-risk -- terraform plan
184
+ sift exec --preset test-status -- npx vitest run
185
+ sift exec --preset test-status -- npx jest
70
186
  sift exec "what changed?" -- git diff
71
187
  ```
72
188
 
73
- ## How it works
74
-
75
- `sift` sits between a noisy command and a coding agent.
189
+ ### 3. Zoom only if needed
76
190
 
77
- 1. Capture output.
78
- 2. Run local heuristics for known failure shapes.
79
- 3. If heuristics are confident, return the diagnosis. No model call.
80
- 4. If not, call a cheaper model — not your agent's.
191
+ Think of the workflow like this:
81
192
 
82
- The agent gets the root cause, where it happens, and what to do next.
193
+ - `standard` = map
194
+ - `focused` = zoom
195
+ - raw traceback = last resort
83
196
 
84
- So your agent spends tokens fixing, not reading.
197
+ ```bash
198
+ sift rerun
199
+ sift rerun --remaining --detail focused
200
+ ```
85
201
 
86
- ## Built-in presets
202
+ If `standard` already gives you the root cause, anchor, and fix, stop there and act.
87
203
 
88
- Every preset runs local heuristics first. When the heuristic handles the output, the provider is never called.
204
+ ---
89
205
 
90
- | Preset | What it does |
91
- |--------|-------------|
92
- | `test-status` | Groups pytest, vitest, jest failures into root-cause buckets with anchors and fix suggestions. 30+ failure patterns. |
93
- | `typecheck-summary` | Parses `tsc` output, groups by error code, returns max 5 bullets. No model call. |
94
- | `lint-failures` | Parses ESLint output, groups by rule, detects fixable hints. No model call. |
95
- | `build-failure` | Extracts first concrete error from webpack, esbuild/Vite, Cargo, Go, GCC/Clang, `tsc --build`. Falls back to model for unsupported formats. |
96
- | `audit-critical` | Extracts high/critical vulnerabilities from `npm audit`. No model call. |
97
- | `infra-risk` | Detects destructive signals in `terraform plan`. No model call. |
98
- | `diff-summary` | Summarizes changes and risks in diff output. |
99
- | `log-errors` | Extracts top error signals from log output. |
206
+ ## Presets
100
207
 
101
- ## Benchmark
208
+ | Preset | What it does | Needs provider? |
209
+ |--------|--------------|:---------------:|
210
+ | `test-status` | Groups pytest, vitest, and jest failures into root-cause buckets with anchors and fix suggestions. | No |
211
+ | `typecheck-summary` | Parses `tsc` output and groups issues by error code. | No |
212
+ | `lint-failures` | Parses ESLint output and groups failures by rule. | No |
213
+ | `build-failure` | Extracts the first concrete build error from common toolchains. | Fallback only |
214
+ | `audit-critical` | Pulls high and critical `npm audit` findings. | No |
215
+ | `infra-risk` | Detects destructive signals in `terraform plan`. | No |
216
+ | `diff-summary` | Summarizes change sets and likely risks in diff output. | Yes |
217
+ | `log-errors` | Extracts the strongest error signals from noisy logs. | Fallback only |
102
218
 
103
- End-to-end debug loop on a real 640-test Python backend (125 repeated setup errors, 3 contract failures, 510 passing tests):
219
+ When output already exists in a pipeline, use pipe mode instead of `exec`:
104
220
 
105
- | Metric | Without sift | With sift | Reduction |
106
- |--------|-------------|-----------|-----------|
107
- | Tokens | 52,944 | 20,049 | 62% fewer |
108
- | Tool calls | 40.8 | 12 | 71% fewer |
109
- | Wall-clock time | 244s | 85s | 65% faster |
110
- | Commands | 15.5 | 6 | 61% fewer |
111
- | Diagnosis | Same | Same | — |
221
+ ```bash
222
+ pytest -q 2>&1 | sift preset test-status
223
+ npm audit 2>&1 | sift preset audit-critical
224
+ ```
112
225
 
113
- Methodology and caveats: [BENCHMARK_NOTES.md](BENCHMARK_NOTES.md)
226
+ ---
114
227
 
115
- ## Test debugging workflow
228
+ ## Test Debugging Workflow
116
229
 
117
- Think of it like this:
118
- - `standard` = map
119
- - `focused` = zoom
120
- - raw traceback = last resort
230
+ For noisy test failures, start with the `test-status` preset and let `standard` be the default stop point.
121
231
 
122
232
  ```bash
123
233
  sift exec --preset test-status -- <test command>
124
234
  sift rerun
125
235
  sift rerun --remaining --detail focused
236
+ sift rerun --remaining --detail verbose --show-raw
126
237
  ```
127
238
 
128
- If `standard` already gives you the root cause, anchor, and fix — stop and act.
129
-
130
- `sift rerun --remaining` narrows automatically for cached `pytest` runs. For `vitest` and `jest`, it reruns the full command and keeps diagnosis focused on what still fails.
239
+ Useful rules of thumb:
131
240
 
132
- ## Setup
241
+ - If `standard` ends with `Decision: stop and act`, go read source and fix the issue.
242
+ - Use `sift rerun` after a change to refresh the same test command at `standard`.
243
+ - Use `sift rerun --remaining` to zoom into what still fails after the first pass.
244
+ - Treat raw traceback as the last resort, not the starting point.
133
245
 
134
- Guided setup writes a config, verifies the provider, and makes daily use easier:
246
+ For machine branching or automation, `test-status` also supports diagnose JSON:
135
247
 
136
248
  ```bash
137
- sift config setup
138
- sift doctor
249
+ sift exec --preset test-status --goal diagnose --format json -- pytest -q
250
+ sift rerun --goal diagnose --format json
139
251
  ```
140
252
 
141
- To wire `sift` into your coding agent automatically:
253
+ ---
142
254
 
143
- ```bash
144
- sift agent install claude
145
- sift agent install codex
146
- ```
255
+ ## Limitations
256
+
257
+ - sift adds the most value when output is long, repetitive, and shaped by a small number of root causes. For short, obvious failures it may not save much.
258
+ - The deepest local heuristic coverage is in test debugging (pytest, vitest, jest). Other presets have solid heuristics but less depth.
259
+ - sift does not help with interactive or TUI-based commands.
260
+ - When heuristics cannot explain the output confidently, sift falls back to a provider. If no provider is configured, it returns what the heuristics could extract and signals that raw output may still be needed.
147
261
 
148
- Config details: [docs/cli-reference.md](docs/cli-reference.md)
262
+ ---
149
263
 
150
264
  ## Docs
151
265
 
152
266
  - CLI reference: [docs/cli-reference.md](docs/cli-reference.md)
153
267
  - Worked examples: [docs/examples](docs/examples)
154
268
  - Benchmark methodology: [BENCHMARK_NOTES.md](BENCHMARK_NOTES.md)
269
+ - Contributing and development notes: [CONTRIBUTING.md](CONTRIBUTING.md)
155
270
  - Release notes: [release-notes](release-notes)
156
271
 
272
+ ---
273
+
157
274
  ## License
158
275
 
159
276
  MIT
277
+
278
+ ---
279
+
280
+ <div align="center">
281
+
282
+ Built for agent-first terminal workflows.
283
+
284
+ [Report Bug](https://github.com/bilalimamoglu/sift/issues) | [Request Feature](https://github.com/bilalimamoglu/sift/issues)
285
+
286
+ </div>
package/dist/cli.js CHANGED
@@ -5,13 +5,15 @@ import { createRequire } from "module";
5
5
  import { cac } from "cac";
6
6
 
7
7
  // src/config/load.ts
8
- import fs from "fs";
8
+ import fs2 from "fs";
9
9
  import path2 from "path";
10
10
  import YAML from "yaml";
11
11
 
12
12
  // src/constants.ts
13
+ import fs from "fs";
13
14
  import os from "os";
14
15
  import path from "path";
16
+ import crypto from "crypto";
15
17
  var DEFAULT_CONFIG_FILENAME = "sift.config.yaml";
16
18
  function getDefaultCodexGlobalInstructionsPath(homeDir = os.homedir()) {
17
19
  return path.join(homeDir, ".codex", "AGENTS.md");
@@ -28,6 +30,26 @@ function getDefaultGlobalStateDir(homeDir = os.homedir()) {
28
30
  function getDefaultTestStatusStatePath(homeDir = os.homedir()) {
29
31
  return path.join(getDefaultGlobalStateDir(homeDir), "last-test-status.json");
30
32
  }
33
+ function getDefaultScopedTestStatusStateDir(homeDir = os.homedir()) {
34
+ return path.join(getDefaultGlobalStateDir(homeDir), "test-status", "by-cwd");
35
+ }
36
+ function getScopedTestStatusStatePath(cwd, homeDir = os.homedir()) {
37
+ const normalizedCwd = normalizeScopedCacheCwd(cwd);
38
+ const baseName = slugCachePathSegment(path.basename(normalizedCwd)) || "root";
39
+ const shortHash = crypto.createHash("sha256").update(normalizedCwd).digest("hex").slice(0, 10);
40
+ return path.join(getDefaultScopedTestStatusStateDir(homeDir), `${baseName}-${shortHash}.json`);
41
+ }
42
+ function slugCachePathSegment(value) {
43
+ return value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
44
+ }
45
+ function normalizeScopedCacheCwd(cwd) {
46
+ const absoluteCwd = path.resolve(cwd);
47
+ try {
48
+ return fs.realpathSync.native(absoluteCwd);
49
+ } catch {
50
+ return absoluteCwd;
51
+ }
52
+ }
31
53
  function getDefaultConfigSearchPaths() {
32
54
  return [
33
55
  path.resolve(process.cwd(), "sift.config.yaml"),
@@ -44,13 +66,13 @@ var CAPTURE_OMITTED_MARKER = "\n...[captured output omitted]...\n";
44
66
  function findConfigPath(explicitPath) {
45
67
  if (explicitPath) {
46
68
  const resolved = path2.resolve(explicitPath);
47
- if (!fs.existsSync(resolved)) {
69
+ if (!fs2.existsSync(resolved)) {
48
70
  throw new Error(`Config file not found: ${resolved}`);
49
71
  }
50
72
  return resolved;
51
73
  }
52
74
  for (const candidate of getDefaultConfigSearchPaths()) {
53
- if (fs.existsSync(candidate)) {
75
+ if (fs2.existsSync(candidate)) {
54
76
  return candidate;
55
77
  }
56
78
  }
@@ -61,7 +83,7 @@ function loadRawConfig(explicitPath) {
61
83
  if (!configPath) {
62
84
  return {};
63
85
  }
64
- const content = fs.readFileSync(configPath, "utf8");
86
+ const content = fs2.readFileSync(configPath, "utf8");
65
87
  return YAML.parse(content) ?? {};
66
88
  }
67
89
 
@@ -472,7 +494,7 @@ function resolveConfig(options = {}) {
472
494
  }
473
495
 
474
496
  // src/config/write.ts
475
- import fs2 from "fs";
497
+ import fs3 from "fs";
476
498
  import path3 from "path";
477
499
  import YAML2 from "yaml";
478
500
  function writeExampleConfig(options = {}) {
@@ -480,34 +502,41 @@ function writeExampleConfig(options = {}) {
480
502
  throw new Error("Use either --path <path> or --global, not both.");
481
503
  }
482
504
  const resolved = options.global ? getDefaultGlobalConfigPath() : path3.resolve(options.targetPath ?? DEFAULT_CONFIG_FILENAME);
483
- if (fs2.existsSync(resolved)) {
505
+ if (fs3.existsSync(resolved)) {
484
506
  throw new Error(`Config file already exists at ${resolved}`);
485
507
  }
486
508
  const yaml = YAML2.stringify(defaultConfig);
487
- fs2.mkdirSync(path3.dirname(resolved), { recursive: true });
488
- fs2.writeFileSync(resolved, yaml, "utf8");
509
+ fs3.mkdirSync(path3.dirname(resolved), { recursive: true });
510
+ fs3.writeFileSync(resolved, yaml, {
511
+ encoding: "utf8",
512
+ mode: 384
513
+ });
514
+ try {
515
+ fs3.chmodSync(resolved, 384);
516
+ } catch {
517
+ }
489
518
  return resolved;
490
519
  }
491
520
  function writeConfigFile(options) {
492
521
  const resolved = path3.resolve(options.targetPath);
493
- if (!options.overwrite && fs2.existsSync(resolved)) {
522
+ if (!options.overwrite && fs3.existsSync(resolved)) {
494
523
  throw new Error(`Config file already exists at ${resolved}`);
495
524
  }
496
525
  const yaml = YAML2.stringify(options.config);
497
- fs2.mkdirSync(path3.dirname(resolved), { recursive: true });
498
- fs2.writeFileSync(resolved, yaml, {
526
+ fs3.mkdirSync(path3.dirname(resolved), { recursive: true });
527
+ fs3.writeFileSync(resolved, yaml, {
499
528
  encoding: "utf8",
500
529
  mode: 384
501
530
  });
502
531
  try {
503
- fs2.chmodSync(resolved, 384);
532
+ fs3.chmodSync(resolved, 384);
504
533
  } catch {
505
534
  }
506
535
  return resolved;
507
536
  }
508
537
 
509
538
  // src/config/editable.ts
510
- import fs3 from "fs";
539
+ import fs4 from "fs";
511
540
  import path4 from "path";
512
541
  function isRecord2(value) {
513
542
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -520,7 +549,7 @@ function resolveEditableConfigPath(explicitPath) {
520
549
  }
521
550
  function loadEditableConfig(explicitPath) {
522
551
  const resolvedPath = resolveEditableConfigPath(explicitPath);
523
- const existed = fs3.existsSync(resolvedPath);
552
+ const existed = fs4.existsSync(resolvedPath);
524
553
  const rawConfig = existed ? loadRawConfig(resolvedPath) : {};
525
554
  const config = siftConfigSchema.parse(
526
555
  mergeDefined(defaultConfig, isRecord2(rawConfig) ? rawConfig : {})
@@ -1157,7 +1186,7 @@ function configUse(provider, configPath, env = process.env) {
1157
1186
  }
1158
1187
 
1159
1188
  // src/commands/agent.ts
1160
- import fs4 from "fs";
1189
+ import fs5 from "fs";
1161
1190
  import os2 from "os";
1162
1191
  import path6 from "path";
1163
1192
  import { createInterface as createInterface2 } from "readline/promises";
@@ -1787,28 +1816,49 @@ function joinAroundRemoval(before, after, eol) {
1787
1816
  return `${left}${eol}${eol}${right}`;
1788
1817
  }
1789
1818
  function readOptionalFile(targetPath) {
1790
- if (!fs4.existsSync(targetPath)) {
1819
+ if (!fs5.existsSync(targetPath)) {
1791
1820
  return void 0;
1792
1821
  }
1793
- const stats = fs4.statSync(targetPath);
1822
+ const stats = fs5.statSync(targetPath);
1794
1823
  if (!stats.isFile()) {
1795
1824
  throw new Error(`${targetPath} exists but is not a file.`);
1796
1825
  }
1797
- return fs4.readFileSync(targetPath, "utf8");
1826
+ return fs5.readFileSync(targetPath, "utf8");
1798
1827
  }
1799
1828
  function writeTextFileAtomic(targetPath, content) {
1800
- fs4.mkdirSync(path6.dirname(targetPath), { recursive: true });
1829
+ fs5.mkdirSync(path6.dirname(targetPath), { recursive: true });
1801
1830
  const tempPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
1802
- fs4.writeFileSync(tempPath, content, "utf8");
1803
- fs4.renameSync(tempPath, targetPath);
1831
+ fs5.writeFileSync(tempPath, content, "utf8");
1832
+ fs5.renameSync(tempPath, targetPath);
1804
1833
  }
1805
1834
  function escapeRegExp(value) {
1806
1835
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1807
1836
  }
1808
1837
 
1809
1838
  // src/commands/doctor.ts
1839
+ var PLACEHOLDER_API_KEYS = [
1840
+ "YOUR_API_KEY",
1841
+ "your_api_key",
1842
+ "your-api-key",
1843
+ "sk-xxx",
1844
+ "sk-placeholder",
1845
+ "CHANGE_ME",
1846
+ "change_me",
1847
+ "TODO",
1848
+ "todo",
1849
+ "xxx",
1850
+ "XXX"
1851
+ ];
1852
+ function isPlaceholderApiKey(key) {
1853
+ if (!key) return false;
1854
+ return PLACEHOLDER_API_KEYS.includes(key.trim());
1855
+ }
1856
+ function isRealApiKey(key) {
1857
+ return Boolean(key) && !isPlaceholderApiKey(key);
1858
+ }
1810
1859
  function runDoctor(config, configPath) {
1811
1860
  const ui = createPresentation(Boolean(process.stdout.isTTY));
1861
+ const apiKeyStatus = isRealApiKey(config.provider.apiKey) ? "set" : isPlaceholderApiKey(config.provider.apiKey) ? "placeholder (not a real key)" : "not set";
1812
1862
  const lines = [
1813
1863
  "sift doctor",
1814
1864
  "A quick check for your local setup.",
@@ -1817,7 +1867,7 @@ function runDoctor(config, configPath) {
1817
1867
  ui.labelValue("provider", config.provider.provider),
1818
1868
  ui.labelValue("model", config.provider.model),
1819
1869
  ui.labelValue("baseUrl", config.provider.baseUrl),
1820
- ui.labelValue("apiKey", config.provider.apiKey ? "set" : "not set"),
1870
+ ui.labelValue("apiKey", apiKeyStatus),
1821
1871
  ui.labelValue("maxCaptureChars", String(config.input.maxCaptureChars)),
1822
1872
  ui.labelValue("maxInputChars", String(config.input.maxInputChars)),
1823
1873
  ui.labelValue("rawFallback", String(config.runtime.rawFallback))
@@ -1831,8 +1881,12 @@ function runDoctor(config, configPath) {
1831
1881
  if (!config.provider.model) {
1832
1882
  problems.push("Missing provider.model");
1833
1883
  }
1834
- if ((config.provider.provider === "openai" || config.provider.provider === "openai-compatible" || config.provider.provider === "openrouter") && !config.provider.apiKey) {
1835
- problems.push("Missing provider.apiKey");
1884
+ if ((config.provider.provider === "openai" || config.provider.provider === "openai-compatible" || config.provider.provider === "openrouter") && !isRealApiKey(config.provider.apiKey)) {
1885
+ if (isPlaceholderApiKey(config.provider.apiKey)) {
1886
+ problems.push(`provider.apiKey looks like a placeholder: "${config.provider.apiKey}"`);
1887
+ } else {
1888
+ problems.push("Missing provider.apiKey");
1889
+ }
1836
1890
  problems.push(
1837
1891
  `Set one of: ${getProviderApiKeyEnvNames(
1838
1892
  config.provider.provider,
@@ -1951,6 +2005,7 @@ var OpenAIProvider = class {
1951
2005
  signal: controller.signal,
1952
2006
  headers: {
1953
2007
  "content-type": "application/json",
2008
+ connection: "close",
1954
2009
  ...this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}
1955
2010
  },
1956
2011
  body: JSON.stringify({
@@ -2051,6 +2106,7 @@ var OpenAICompatibleProvider = class {
2051
2106
  signal: controller.signal,
2052
2107
  headers: {
2053
2108
  "content-type": "application/json",
2109
+ connection: "close",
2054
2110
  ...this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}
2055
2111
  },
2056
2112
  body: JSON.stringify({
@@ -8233,7 +8289,7 @@ function emitStatsFooter(args) {
8233
8289
  }
8234
8290
 
8235
8291
  // src/core/testStatusState.ts
8236
- import fs5 from "fs";
8292
+ import fs6 from "fs";
8237
8293
  import path7 from "path";
8238
8294
  import { z as z3 } from "zod";
8239
8295
  var detailSchema = z3.enum(["standard", "focused", "verbose"]);
@@ -8683,7 +8739,7 @@ function migrateCachedTestStatusRun(state) {
8683
8739
  function readCachedTestStatusRun(statePath = getDefaultTestStatusStatePath()) {
8684
8740
  let raw = "";
8685
8741
  try {
8686
- raw = fs5.readFileSync(statePath, "utf8");
8742
+ raw = fs6.readFileSync(statePath, "utf8");
8687
8743
  } catch (error) {
8688
8744
  if (error.code === "ENOENT") {
8689
8745
  throw new MissingCachedTestStatusRunError();
@@ -8704,10 +8760,10 @@ function tryReadCachedTestStatusRun(statePath = getDefaultTestStatusStatePath())
8704
8760
  }
8705
8761
  }
8706
8762
  function writeCachedTestStatusRun(state, statePath = getDefaultTestStatusStatePath()) {
8707
- fs5.mkdirSync(path7.dirname(statePath), {
8763
+ fs6.mkdirSync(path7.dirname(statePath), {
8708
8764
  recursive: true
8709
8765
  });
8710
- fs5.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}
8766
+ fs6.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}
8711
8767
  `, "utf8");
8712
8768
  }
8713
8769
  function getNextEscalationDetail(detail) {
@@ -8869,7 +8925,8 @@ function resolveEscalationDetail(state, requested, showRaw = false) {
8869
8925
  return nextDetail;
8870
8926
  }
8871
8927
  async function runEscalate(request) {
8872
- const state = readCachedTestStatusRun();
8928
+ const scopedStatePath = getScopedTestStatusStatePath(process.cwd());
8929
+ const state = readCachedTestStatusRun(scopedStatePath);
8873
8930
  const detail = resolveEscalationDetail(state, request.detail, request.showRaw);
8874
8931
  if (request.verbose) {
8875
8932
  process.stderr.write(
@@ -8917,10 +8974,13 @@ async function runEscalate(request) {
8917
8974
  quiet: Boolean(request.quiet)
8918
8975
  });
8919
8976
  try {
8920
- writeCachedTestStatusRun({
8921
- ...state,
8922
- detail
8923
- });
8977
+ writeCachedTestStatusRun(
8978
+ {
8979
+ ...state,
8980
+ detail
8981
+ },
8982
+ scopedStatePath
8983
+ );
8924
8984
  } catch (error) {
8925
8985
  if (request.verbose) {
8926
8986
  const reason = error instanceof Error ? error.message : "unknown_error";
@@ -9261,10 +9321,11 @@ async function runExec(request) {
9261
9321
  const shellPath = process.env.SHELL || "/bin/bash";
9262
9322
  const commandPreview = buildCommandPreview(request);
9263
9323
  const commandCwd = request.cwd ?? process.cwd();
9324
+ const scopedStatePath = getScopedTestStatusStatePath(commandCwd);
9264
9325
  const isTestStatusPreset = request.presetName === "test-status";
9265
9326
  const readCachedBaseline = isTestStatusPreset && (request.readCachedBaseline ?? true);
9266
9327
  const writeCachedBaselineRequested = isTestStatusPreset && (request.writeCachedBaseline ?? (request.skipCacheWrite ? false : true));
9267
- const previousCachedRun = readCachedBaseline ? tryReadCachedTestStatusRun() : null;
9328
+ const previousCachedRun = readCachedBaseline ? tryReadCachedTestStatusRun(scopedStatePath) : null;
9268
9329
  if (request.config.runtime.verbose) {
9269
9330
  process.stderr.write(
9270
9331
  `${pc5.dim("sift")} exec mode=${hasShellCommand ? "shell" : "argv"} command=${commandPreview}
@@ -9467,7 +9528,7 @@ ${output}`;
9467
9528
  }
9468
9529
  if (currentCachedRun && shouldWriteCachedBaseline) {
9469
9530
  try {
9470
- writeCachedTestStatusRun(currentCachedRun);
9531
+ writeCachedTestStatusRun(currentCachedRun, scopedStatePath);
9471
9532
  } catch (error) {
9472
9533
  if (request.config.runtime.verbose) {
9473
9534
  const reason = error instanceof Error ? error.message : "unknown_error";
@@ -9503,7 +9564,7 @@ ${output}`;
9503
9564
 
9504
9565
  // src/core/rerun.ts
9505
9566
  async function runRerun(request) {
9506
- const state = readCachedTestStatusRun();
9567
+ const state = readCachedTestStatusRun(getScopedTestStatusStatePath(process.cwd()));
9507
9568
  if (!request.remaining) {
9508
9569
  return runExec({
9509
9570
  ...request,
package/dist/index.js CHANGED
@@ -4,8 +4,10 @@ import { constants as osConstants } from "os";
4
4
  import pc3 from "picocolors";
5
5
 
6
6
  // src/constants.ts
7
+ import fs from "fs";
7
8
  import os from "os";
8
9
  import path from "path";
10
+ import crypto from "crypto";
9
11
  function getDefaultGlobalConfigPath(homeDir = os.homedir()) {
10
12
  return path.join(homeDir, ".config", "sift", "config.yaml");
11
13
  }
@@ -15,6 +17,26 @@ function getDefaultGlobalStateDir(homeDir = os.homedir()) {
15
17
  function getDefaultTestStatusStatePath(homeDir = os.homedir()) {
16
18
  return path.join(getDefaultGlobalStateDir(homeDir), "last-test-status.json");
17
19
  }
20
+ function getDefaultScopedTestStatusStateDir(homeDir = os.homedir()) {
21
+ return path.join(getDefaultGlobalStateDir(homeDir), "test-status", "by-cwd");
22
+ }
23
+ function getScopedTestStatusStatePath(cwd, homeDir = os.homedir()) {
24
+ const normalizedCwd = normalizeScopedCacheCwd(cwd);
25
+ const baseName = slugCachePathSegment(path.basename(normalizedCwd)) || "root";
26
+ const shortHash = crypto.createHash("sha256").update(normalizedCwd).digest("hex").slice(0, 10);
27
+ return path.join(getDefaultScopedTestStatusStateDir(homeDir), `${baseName}-${shortHash}.json`);
28
+ }
29
+ function slugCachePathSegment(value) {
30
+ return value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
31
+ }
32
+ function normalizeScopedCacheCwd(cwd) {
33
+ const absoluteCwd = path.resolve(cwd);
34
+ try {
35
+ return fs.realpathSync.native(absoluteCwd);
36
+ } catch {
37
+ return absoluteCwd;
38
+ }
39
+ }
18
40
  function getDefaultConfigSearchPaths() {
19
41
  return [
20
42
  path.resolve(process.cwd(), "sift.config.yaml"),
@@ -4741,6 +4763,7 @@ var OpenAIProvider = class {
4741
4763
  signal: controller.signal,
4742
4764
  headers: {
4743
4765
  "content-type": "application/json",
4766
+ connection: "close",
4744
4767
  ...this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}
4745
4768
  },
4746
4769
  body: JSON.stringify({
@@ -4841,6 +4864,7 @@ var OpenAICompatibleProvider = class {
4841
4864
  signal: controller.signal,
4842
4865
  headers: {
4843
4866
  "content-type": "application/json",
4867
+ connection: "close",
4844
4868
  ...this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}
4845
4869
  },
4846
4870
  body: JSON.stringify({
@@ -6413,7 +6437,7 @@ function emitStatsFooter(args) {
6413
6437
  }
6414
6438
 
6415
6439
  // src/core/testStatusState.ts
6416
- import fs from "fs";
6440
+ import fs2 from "fs";
6417
6441
  import path2 from "path";
6418
6442
  import { z as z2 } from "zod";
6419
6443
  var detailSchema = z2.enum(["standard", "focused", "verbose"]);
@@ -6863,7 +6887,7 @@ function migrateCachedTestStatusRun(state) {
6863
6887
  function readCachedTestStatusRun(statePath = getDefaultTestStatusStatePath()) {
6864
6888
  let raw = "";
6865
6889
  try {
6866
- raw = fs.readFileSync(statePath, "utf8");
6890
+ raw = fs2.readFileSync(statePath, "utf8");
6867
6891
  } catch (error) {
6868
6892
  if (error.code === "ENOENT") {
6869
6893
  throw new MissingCachedTestStatusRunError();
@@ -6884,10 +6908,10 @@ function tryReadCachedTestStatusRun(statePath = getDefaultTestStatusStatePath())
6884
6908
  }
6885
6909
  }
6886
6910
  function writeCachedTestStatusRun(state, statePath = getDefaultTestStatusStatePath()) {
6887
- fs.mkdirSync(path2.dirname(statePath), {
6911
+ fs2.mkdirSync(path2.dirname(statePath), {
6888
6912
  recursive: true
6889
6913
  });
6890
- fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}
6914
+ fs2.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}
6891
6915
  `, "utf8");
6892
6916
  }
6893
6917
  function buildTargetDelta(args) {
@@ -7273,10 +7297,11 @@ async function runExec(request) {
7273
7297
  const shellPath = process.env.SHELL || "/bin/bash";
7274
7298
  const commandPreview = buildCommandPreview(request);
7275
7299
  const commandCwd = request.cwd ?? process.cwd();
7300
+ const scopedStatePath = getScopedTestStatusStatePath(commandCwd);
7276
7301
  const isTestStatusPreset = request.presetName === "test-status";
7277
7302
  const readCachedBaseline = isTestStatusPreset && (request.readCachedBaseline ?? true);
7278
7303
  const writeCachedBaselineRequested = isTestStatusPreset && (request.writeCachedBaseline ?? (request.skipCacheWrite ? false : true));
7279
- const previousCachedRun = readCachedBaseline ? tryReadCachedTestStatusRun() : null;
7304
+ const previousCachedRun = readCachedBaseline ? tryReadCachedTestStatusRun(scopedStatePath) : null;
7280
7305
  if (request.config.runtime.verbose) {
7281
7306
  process.stderr.write(
7282
7307
  `${pc3.dim("sift")} exec mode=${hasShellCommand ? "shell" : "argv"} command=${commandPreview}
@@ -7479,7 +7504,7 @@ ${output}`;
7479
7504
  }
7480
7505
  if (currentCachedRun && shouldWriteCachedBaseline) {
7481
7506
  try {
7482
- writeCachedTestStatusRun(currentCachedRun);
7507
+ writeCachedTestStatusRun(currentCachedRun, scopedStatePath);
7483
7508
  } catch (error) {
7484
7509
  if (request.config.runtime.verbose) {
7485
7510
  const reason = error instanceof Error ? error.message : "unknown_error";
@@ -7585,19 +7610,19 @@ var defaultConfig = {
7585
7610
  };
7586
7611
 
7587
7612
  // src/config/load.ts
7588
- import fs2 from "fs";
7613
+ import fs3 from "fs";
7589
7614
  import path3 from "path";
7590
7615
  import YAML from "yaml";
7591
7616
  function findConfigPath(explicitPath) {
7592
7617
  if (explicitPath) {
7593
7618
  const resolved = path3.resolve(explicitPath);
7594
- if (!fs2.existsSync(resolved)) {
7619
+ if (!fs3.existsSync(resolved)) {
7595
7620
  throw new Error(`Config file not found: ${resolved}`);
7596
7621
  }
7597
7622
  return resolved;
7598
7623
  }
7599
7624
  for (const candidate of getDefaultConfigSearchPaths()) {
7600
- if (fs2.existsSync(candidate)) {
7625
+ if (fs3.existsSync(candidate)) {
7601
7626
  return candidate;
7602
7627
  }
7603
7628
  }
@@ -7608,7 +7633,7 @@ function loadRawConfig(explicitPath) {
7608
7633
  if (!configPath) {
7609
7634
  return {};
7610
7635
  }
7611
- const content = fs2.readFileSync(configPath, "utf8");
7636
+ const content = fs3.readFileSync(configPath, "utf8");
7612
7637
  return YAML.parse(content) ?? {};
7613
7638
  }
7614
7639
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bilalimamoglu/sift",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Agent-first command-output reduction layer for agents, CI, and automation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,8 +25,10 @@
25
25
  "bench:report": "node --import tsx scripts/bench/generate-progress-report.ts",
26
26
  "dev": "tsx src/cli.ts",
27
27
  "typecheck": "tsc --noEmit",
28
- "test": "vitest run",
29
- "test:coverage": "vitest run --coverage",
28
+ "test": "vitest run --config vitest.config.ts",
29
+ "test:smoke": "vitest run --config vitest.config.ts test/*.smoke.test.ts",
30
+ "test:e2e": "vitest run --config vitest.e2e.config.ts test/*.e2e.test.ts",
31
+ "test:coverage": "vitest run --config vitest.config.ts --coverage --exclude=\"test/**/*.smoke.test.ts\" --exclude=\"test/**/*.e2e.test.ts\"",
30
32
  "test:watch": "vitest",
31
33
  "prepublishOnly": "npm run typecheck && npm run test:coverage && npm run build"
32
34
  },