jev-affected 0.1.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Add a repository-owned `jev-affected.yml` for dogfooding this project.
6
+ - Analyze committed, working-tree, or staged changes with explicit input modes.
7
+ - Detect pull-request base branches across GitHub Actions, GitLab CI, Buildkite,
8
+ and mapped CircleCI pipeline values.
9
+ - Inspect and safely clear the Git-local semantic decision cache.
10
+ - Avoid treating source text that mentions Git submodule markers as an actual
11
+ submodule diff.
12
+
13
+ ## 0.2.0
14
+
15
+ - Read API credentials from the process environment only.
16
+ - Reject literal API keys in YAML configuration.
17
+ - Accept `TYPESAFEAI_API_KEY` as a compatibility fallback.
18
+
3
19
  ## 0.1.0
4
20
 
5
21
  - YAML semantic task conditions, deterministic globs and protected tasks.
package/EVALUATION.md CHANGED
@@ -34,10 +34,10 @@ Run on 2026-09-21 with `jev-1.13.0` and `skipBelow: 0.10`:
34
34
  | False skips | 0 |
35
35
  | Invalid cases | 0 |
36
36
  | Cases with reduction | 6 |
37
- | Task reduction | 46.43% |
38
- | Unnecessary-run rate | 45.83% |
37
+ | Task reduction | 42.86% |
38
+ | Unnecessary-run rate | 50.00% |
39
39
  | API calls | 7 |
40
- | Total latency | 2,139 ms |
40
+ | Total latency | 2,126 ms |
41
41
  | Input tokens | 2,882 |
42
42
  | Output tokens | 476 |
43
43
 
@@ -47,7 +47,7 @@ Selected tasks by fixture:
47
47
  | --- | ---: |
48
48
  | API field | 3 / 4 |
49
49
  | Authentication | 4 / 4 |
50
- | Comment | 1 / 4 |
50
+ | Comment | 2 / 4 |
51
51
  | Database schema | 3 / 4 |
52
52
  | Documentation | 0 / 4 |
53
53
  | Logging | 1 / 4 |
@@ -65,8 +65,8 @@ pnpm eval:live
65
65
 
66
66
  `pnpm eval` uses deterministic synthetic answers and verifies evaluation and
67
67
  decision plumbing without making a network request. `pnpm eval:live` reads
68
- `TYPESAFE_API_KEY` from the process environment or a local `.env` file and sends
69
- the fixture inputs to Jev.
68
+ `TYPESAFE_API_KEY` from the process environment and sends the fixture inputs to
69
+ Jev. Dotenv files are not loaded.
70
70
 
71
71
  ## Limits
72
72
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  <p align="center"><strong>Semantic task routing for software development.</strong></p>
4
4
  <p align="center">Run tasks based on what changed, not where it changed.</p>
5
- <p align="center">v0.1.0 Public Beta · Node.js 20+ · TypeScript · MIT · Jev-powered</p>
5
+ <p align="center">v0.3.0 Public Beta · Node.js 20+ · TypeScript · MIT · Jev-powered</p>
6
6
 
7
7
  ![Illustrative offline fixture demo, not live Jev output](assets/demo.gif)
8
8
 
@@ -30,13 +30,7 @@ npm install -D jev-affected
30
30
  npx jev-affected init
31
31
  ```
32
32
 
33
- Create `.env` in the repository root. The CLI loads it automatically:
34
-
35
- ```dotenv
36
- TYPESAFE_API_KEY=your_api_key_here
37
- ```
38
-
39
- `.env` is ignored by this repository's generated `.gitignore`. Never put the key in `jev-affected.yml` or commit it. An existing process environment value takes precedence over `.env`. You can also set it for the current shell:
33
+ Set the API key in the process environment. The CLI does not read dotenv files or accept API keys in `jev-affected.yml`.
40
34
 
41
35
  ```powershell
42
36
  $env:TYPESAFE_API_KEY = "your_api_key_here"
@@ -46,6 +40,8 @@ $env:TYPESAFE_API_KEY = "your_api_key_here"
46
40
  export TYPESAFE_API_KEY="your_api_key_here"
47
41
  ```
48
42
 
43
+ For persistent local use, configure the variable in your shell profile or operating-system credential environment. Open a new terminal after changing persistent environment variables. `TYPESAFEAI_API_KEY` is accepted as a compatibility fallback.
44
+
49
45
  Configure your own trusted commands, commit your changes on a feature branch, then:
50
46
 
51
47
  ```sh
@@ -54,7 +50,14 @@ npx jev-affected plan --base main
54
50
  npx jev-affected run --base main
55
51
  ```
56
52
 
57
- `plan` performs analysis but never executes task commands. `run` executes configured commands. **Only committed changes are included**: default head is `HEAD`, not the working tree. Commit agent edits before planning.
53
+ `plan` performs analysis but never executes task commands. `run` executes configured commands. The default mode compares committed changes. Use `--working-tree` to include branch commits, staged changes, unstaged changes and untracked files, or `--staged` to analyze only the Git index.
54
+
55
+ ```sh
56
+ npx jev-affected plan --working-tree --base main
57
+ npx jev-affected plan --staged
58
+ ```
59
+
60
+ `--working-tree` accepts `--base` but not `--head`. `--staged` compares the index with `HEAD` and cannot be combined with `--base` or `--head`.
58
61
 
59
62
  ## How it works
60
63
 
@@ -125,12 +128,13 @@ Low model probabilities are estimates, not a guarantee that a task is unnecessar
125
128
  | `inspect` | Preview sanitized analysis inputs locally; no request |
126
129
  | `doctor` | Check Node, config, Git base, API key and models endpoint |
127
130
  | `eval [--live]` | Evaluate fixture decisions and false-skip rate |
131
+ | `cache [status\|clear]` | Inspect or remove the Git-local decision cache |
128
132
 
129
- Common flags: `--base`, `--head`, `--config`, `--no-cache`, `--json`, `--help`, `--version`. JSON task output includes a stable `version: 1`. During `run --json`, child output goes to stderr, keeping stdout parseable.
133
+ Common flags: `--base`, `--head`, `--working-tree`, `--staged`, `--config`, `--no-cache`, `--json`, `--help`, `--version`. JSON task output includes a stable `version: 1`. During `run --json`, child output goes to stderr, keeping stdout parseable.
130
134
 
131
135
  Exit codes: `0` success; `1` failed executed task or failed evaluation gate; `2` config/argument/base or doctor check error; `3` internal/I/O failure. API failures do not fail a normal plan or run by themselves.
132
136
 
133
- Base priority: CLI → config → GitHub PR base branch → `origin/main` → `main` → `master`. The comparison starts at the merge-base with head. Explicit invalid bases fail instead of silently choosing another branch. Fetch full history in CI.
137
+ Base priority: CLI → config → detected CI target `origin/HEAD` → `origin/main` → `main` → `master`. The comparison starts at the merge-base with head. Explicit invalid bases fail instead of silently choosing another branch. Fetch full history in CI.
134
138
 
135
139
  ## CI
136
140
 
@@ -145,9 +149,18 @@ Base priority: CLI → config → GitHub PR base branch → `origin/main` → `m
145
149
 
146
150
  Install dependencies first. Do not expose credentials to untrusted PR code. Nx, Turbo and other runners can be invoked as configured commands; no plugins are required.
147
151
 
152
+ Target branches are detected from GitHub Actions, GitLab CI and Buildkite variables. CircleCI exposes the PR base as a pipeline value rather than a legacy job environment variable; map it to `CIRCLE_PR_BASE_BRANCH` in the job when using automatic detection. `--base` and `base` in configuration remain the explicit overrides.
153
+
154
+ | CI | Base input |
155
+ | --- | --- |
156
+ | GitHub Actions | `GITHUB_BASE_REF` |
157
+ | GitLab CI | merge-request diff base SHA, target branch, then default branch |
158
+ | Buildkite | `BUILDKITE_PULL_REQUEST_BASE_BRANCH` |
159
+ | CircleCI | `CIRCLE_PR_BASE_BRANCH` supplied from the PR base pipeline value |
160
+
148
161
  ## Agents
149
162
 
150
- After committing edits, run `jev-affected plan --json` and execute all tasks whose `decision` is `run`, or use `jev-affected run`. `inspect` lets you review what could leave the repository first.
163
+ Run `jev-affected plan --json --working-tree` while editing, or use the default committed mode after committing. Execute all tasks whose `decision` is `run`, or use `jev-affected run` with the same mode. `inspect` lets you review what could leave the repository first.
151
164
 
152
165
  The package includes a reusable agent skill at [`skills/jev-affected`](skills/jev-affected). Point a compatible coding agent at that directory, or copy it into the agent's skills directory, to give it the safe planning and execution workflow.
153
166
 
@@ -163,6 +176,8 @@ const plan = await createPlan({ config, base: 'main' });
163
176
 
164
177
  Automatic caches store probabilities and model metadata in `.git/jev-affected/cache` (including Git worktree support). Keys include state, questions, configuration and pinned actual model version. Corrupt cache entries fall back to RUN. **Moving aliases such as `jev-latest` bypass caching**, because an alias cannot safely establish its current actual version without another request. Pin an actual `jev-x.y.z` version to enable reuse. Neither patches nor API keys are stored in cache payloads. Cache writes are the only local side effect of `plan`; `--no-cache` disables them.
165
178
 
179
+ Use `jev-affected cache status` to inspect entry count and size, and `jev-affected cache clear` to remove only the cache directory resolved inside Git metadata. Both commands support `--json` and do not require a project configuration file.
180
+
166
181
  ## Evaluation
167
182
 
168
183
  ```sh
@@ -176,7 +191,7 @@ See [EVALUATION.md](EVALUATION.md) for the methodology, release gates, latest li
176
191
 
177
192
  ## Privacy
178
193
 
179
- No backend and no telemetry. Data is read locally; semantic inputs go directly to TypeSafe through its SDK. By default, `.env*`, PEM/key files, and names containing `credentials`, `secret` or `token` are withheld. Custom exclusions add to these defaults. Filename exclusions cannot find every secret embedded in arbitrary source files: inspect inputs and use appropriate repository practices. SDK logging is explicitly disabled.
194
+ No backend and no telemetry. Data is read locally; semantic inputs go directly to TypeSafe through its SDK. API keys are read from the process environment only. By default, `.env*`, PEM/key files, and names containing `credentials`, `secret` or `token` are withheld. Custom exclusions add to these defaults. Filename exclusions cannot find every secret embedded in arbitrary source files: inspect inputs and use appropriate repository practices. SDK logging is explicitly disabled.
180
195
 
181
196
  `TYPESAFE_BASE_URL` is honored by the official SDK; inspect this environment setting before use. Review [TypeSafe's data-processing terms](https://typesafe.ai/legal/mca) for your organization.
182
197
 
@@ -188,11 +203,11 @@ No backend and no telemetry. Data is read locally; semantic inputs go directly t
188
203
 
189
204
  **What if Jev is down?** Candidate tasks run. Deterministic rules and protected tasks still apply.
190
205
 
191
- **Are uncommitted files included?** No, v0.1 compares commits. Explicit refs make the input reproducible.
206
+ **Are uncommitted files included?** Only with `--working-tree` or `--staged`. Default mode compares commits so explicit refs remain reproducible.
192
207
 
193
208
  ## Roadmap
194
209
 
195
- v0.1 focuses on inspectable semantic dependencies and safe task plans. Later candidates: working-tree mode, historical evaluations, watch mode, task groups and Nx/Turbo adapters. No GUI, SaaS, MCP server or autonomous command generation is included.
210
+ The current release focuses on inspectable semantic dependencies and safe plans for committed, working-tree and staged changes. Later candidates include historical evaluations, watch mode, task groups and Nx/Turbo adapters. No GUI, SaaS, MCP server or autonomous command generation is included.
196
211
 
197
212
  ## Contributing and license
198
213
 
package/SECURITY.md CHANGED
@@ -10,7 +10,7 @@ Do not post secrets or exploit details in a public issue. Once the public reposi
10
10
 
11
11
  ## Secret handling
12
12
 
13
- Set TYPESAFE_API_KEY through your environment or CI secret store. The application disables SDK logging and does not emit provider error bodies. Exclusion globs are additive; they cannot detect every embedded secret. Inspect your inputs before enabling remote analysis.
13
+ Set TYPESAFE_API_KEY through the process environment or CI secret store. The CLI does not load dotenv files and rejects literal API keys in its YAML configuration. The application disables SDK logging and does not emit provider error bodies. Exclusion globs are additive; they cannot detect every embedded secret. Inspect your inputs before enabling remote analysis.
14
14
 
15
15
  ## External API calls
16
16
 
@@ -0,0 +1,8 @@
1
+ export interface CacheInfo {
2
+ path: string;
3
+ entries: number;
4
+ bytes: number;
5
+ }
6
+ export declare function cacheDirectory(cwd?: string): Promise<string>;
7
+ export declare function inspectCache(cwd?: string): Promise<CacheInfo>;
8
+ export declare function clearCache(cwd?: string): Promise<CacheInfo>;
package/dist/cache.js ADDED
@@ -0,0 +1,35 @@
1
+ import { readdir, rm, stat } from "node:fs/promises";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+ import { ConfigError } from "./config.js";
4
+ import { git } from "./git.js";
5
+ export async function cacheDirectory(cwd = process.cwd()) {
6
+ const common = resolve(cwd, (await git(cwd, "rev-parse", "--git-common-dir")).trim());
7
+ const directory = resolve(cwd, (await git(cwd, "rev-parse", "--git-path", "jev-affected/cache")).trim());
8
+ const fromCommon = relative(common, directory);
9
+ if (!fromCommon || fromCommon.startsWith("..") || isAbsolute(fromCommon))
10
+ throw new ConfigError("Cannot resolve a safe Git cache directory.");
11
+ return directory;
12
+ }
13
+ export async function inspectCache(cwd = process.cwd()) {
14
+ const path = await cacheDirectory(cwd);
15
+ let names;
16
+ try {
17
+ names = (await readdir(path)).filter((name) => name.endsWith(".json"));
18
+ }
19
+ catch (error) {
20
+ if (error.code === "ENOENT")
21
+ return { path, entries: 0, bytes: 0 };
22
+ throw error;
23
+ }
24
+ const sizes = await Promise.all(names.map(async (name) => (await stat(resolve(path, name))).size));
25
+ return {
26
+ path,
27
+ entries: names.length,
28
+ bytes: sizes.reduce((total, size) => total + size, 0),
29
+ };
30
+ }
31
+ export async function clearCache(cwd = process.cwd()) {
32
+ const info = await inspectCache(cwd);
33
+ await rm(info.path, { recursive: true, force: true });
34
+ return info;
35
+ }
package/dist/cli.js CHANGED
@@ -4,13 +4,12 @@ import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { parseArgs } from "node:util";
6
6
  import { TypeSafeClient } from "@typesafe-ai/sdk";
7
- import { config as loadEnvFile } from "dotenv";
8
- import { ConfigError, loadConfig, template } from "./config.js";
7
+ import { clearCache, inspectCache } from "./cache.js";
8
+ import { ConfigError, loadConfig, resolveApiKey, template } from "./config.js";
9
9
  import { evaluate } from "./eval.js";
10
10
  import { executePlan } from "./executor.js";
11
11
  import { collectChanges, matches } from "./git.js";
12
12
  import { createPlan } from "./planner.js";
13
- loadEnvFile({ quiet: true });
14
13
  function render(plan) {
15
14
  return [
16
15
  `jev-affected\n\nBase ${plan.base}\nHead ${plan.head}\nModel ${plan.model ?? "not analyzed"}`,
@@ -36,6 +35,8 @@ async function main() {
36
35
  help: { type: "boolean", short: "h" },
37
36
  version: { type: "boolean", short: "v" },
38
37
  "no-cache": { type: "boolean" },
38
+ "working-tree": { type: "boolean" },
39
+ staged: { type: "boolean" },
39
40
  fixtures: { type: "string" },
40
41
  live: { type: "boolean" },
41
42
  },
@@ -54,13 +55,15 @@ async function main() {
54
55
  console.log(`jev-affected — Semantic task routing, powered by Jev.
55
56
 
56
57
  Usage: jev-affected <command> [options]
57
- Commands: init, plan, run, why <task>, inspect, doctor, eval
58
+ Commands: init, plan, run, why <task>, inspect, doctor, eval, cache [status|clear]
58
59
  Options:
59
60
  --base <ref> Compare from merge-base(ref, head)
60
61
  --head <ref> Head commit (default HEAD; committed changes only)
61
62
  --config <file> YAML configuration
62
63
  --json Machine-readable output
63
64
  --no-cache Bypass the local decision cache
65
+ --working-tree Include committed, staged, unstaged and untracked changes
66
+ --staged Analyze only changes staged against HEAD
64
67
  --parallel Execute selected commands concurrently
65
68
  --concurrency <n> Maximum parallel commands
66
69
  --fixtures <dir> Evaluation fixtures (default evals/fixtures)
@@ -70,11 +73,39 @@ Options:
70
73
  inspect never sends requests. plan never executes task commands.`);
71
74
  return;
72
75
  }
73
- if (!["init", "plan", "run", "why", "inspect", "doctor", "eval"].includes(command))
76
+ if (![
77
+ "init",
78
+ "plan",
79
+ "run",
80
+ "why",
81
+ "inspect",
82
+ "doctor",
83
+ "eval",
84
+ "cache",
85
+ ].includes(command))
74
86
  throw new ConfigError("Unknown command. See --help.");
75
- if (positionals.length > (command === "why" ? 2 : 1))
87
+ if (positionals.length > (["why", "cache"].includes(command) ? 2 : 1))
76
88
  throw new ConfigError("Unexpected positional arguments.");
89
+ if (v["working-tree"] && v.staged)
90
+ throw new ConfigError("Use --working-tree or --staged, not both.");
91
+ if (v["working-tree"] && v.head)
92
+ throw new ConfigError("--working-tree cannot be combined with --head.");
93
+ if (v.staged && (v.base || v.head))
94
+ throw new ConfigError("--staged cannot be combined with --base or --head.");
77
95
  const emit = (x) => console.log(JSON.stringify(x, null, 2));
96
+ if (command === "cache") {
97
+ const action = positionals[1] ?? "status";
98
+ if (!["status", "clear"].includes(action))
99
+ throw new ConfigError("Use cache status or cache clear.");
100
+ const info = action === "clear" ? await clearCache() : await inspectCache();
101
+ if (v.json)
102
+ emit({ action, ...info });
103
+ else if (action === "clear")
104
+ console.log(`Cleared ${info.entries} cache entries (${info.bytes} bytes).`);
105
+ else
106
+ console.log(`Cache: ${info.entries} entries, ${info.bytes} bytes\n${info.path}`);
107
+ return;
108
+ }
78
109
  if (command === "init") {
79
110
  try {
80
111
  await writeFile(String(v.config ?? "jev-affected.yml"), template, {
@@ -105,6 +136,8 @@ inspect never sends requests. plan never executes task commands.`);
105
136
  base: typeof v.base === "string" ? v.base : undefined,
106
137
  head: typeof v.head === "string" ? v.head : undefined,
107
138
  cache: !v["no-cache"],
139
+ workingTree: !!v["working-tree"],
140
+ staged: !!v.staged,
108
141
  };
109
142
  if (command === "why" &&
110
143
  (!positionals[1] || !Object.hasOwn(config.tasks, positionals[1])))
@@ -140,11 +173,12 @@ inspect never sends requests. plan never executes task commands.`);
140
173
  return;
141
174
  }
142
175
  if (command === "doctor") {
176
+ const apiKey = resolveApiKey();
143
177
  const checks = {
144
178
  node: Number(process.versions.node.split(".")[0]) >= 20,
145
179
  config: true,
146
180
  tasks: Object.keys(config.tasks).length.toString(),
147
- apiKey: !!process.env.TYPESAFE_API_KEY?.trim(),
181
+ apiKey: !!apiKey,
148
182
  };
149
183
  try {
150
184
  await collectChanges(config, opts);
@@ -156,6 +190,7 @@ inspect never sends requests. plan never executes task commands.`);
156
190
  if (checks.apiKey) {
157
191
  try {
158
192
  await new TypeSafeClient({
193
+ apiKey,
159
194
  logLevel: "off",
160
195
  timeout: config.analysis.timeoutMs,
161
196
  retry: { maxRetries: 0 },
package/dist/config.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  export declare class ConfigError extends Error {
3
3
  }
4
+ export declare const API_KEY_VARS: readonly ["TYPESAFE_API_KEY", "TYPESAFEAI_API_KEY"];
4
5
  export declare const secretPatterns: string[];
5
6
  export declare const configSchema: z.ZodObject<{
6
7
  version: z.ZodLiteral<1>;
@@ -37,5 +38,6 @@ export declare const configSchema: z.ZodObject<{
37
38
  }, z.core.$strict>;
38
39
  export type Config = z.infer<typeof configSchema>;
39
40
  export declare function parseConfig(value: unknown): Config;
41
+ export declare function resolveApiKey(env?: NodeJS.ProcessEnv): string | undefined;
40
42
  export declare function loadConfig(path?: string): Promise<Config>;
41
43
  export declare const template = "version: 1\nmodel: jev-latest\ndefaults:\n skipBelow: 0.10\ntasks:\n unit:\n command: npm test\n when: Could this change alter runtime application behavior?\n typecheck:\n command: npm run typecheck\n always: true\n";
package/dist/config.js CHANGED
@@ -3,6 +3,7 @@ import { parse } from "yaml";
3
3
  import { z } from "zod";
4
4
  export class ConfigError extends Error {
5
5
  }
6
+ export const API_KEY_VARS = ["TYPESAFE_API_KEY", "TYPESAFEAI_API_KEY"];
6
7
  const probability = z.number().min(0).max(1);
7
8
  const limits = z
8
9
  .object({
@@ -68,11 +69,23 @@ export const configSchema = z
68
69
  })
69
70
  .strict();
70
71
  export function parseConfig(value) {
72
+ if (value && typeof value === "object" && !Array.isArray(value)) {
73
+ const source = value;
74
+ if (Object.hasOwn(source, "apiKey") || Object.hasOwn(source, "api_key"))
75
+ throw new ConfigError("API keys are not supported in jev-affected.yml. Set TYPESAFE_API_KEY in the process environment or CI secret store.");
76
+ }
71
77
  const r = configSchema.safeParse(value);
72
78
  if (!r.success)
73
79
  throw new ConfigError(r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("\n"));
74
80
  return r.data;
75
81
  }
82
+ export function resolveApiKey(env = process.env) {
83
+ for (const name of API_KEY_VARS) {
84
+ const value = env[name]?.trim();
85
+ if (value)
86
+ return value;
87
+ }
88
+ }
76
89
  export async function loadConfig(path = "jev-affected.yml") {
77
90
  try {
78
91
  return parseConfig(parse(await readFile(path, "utf8")));
package/dist/git.d.ts CHANGED
@@ -14,8 +14,12 @@ export interface ChangeState {
14
14
  incomplete: boolean;
15
15
  warnings: string[];
16
16
  }
17
- export declare function collectChanges(config: Config, options?: {
17
+ export interface ChangeOptions {
18
18
  cwd?: string;
19
19
  base?: string;
20
20
  head?: string;
21
- }): Promise<ChangeState>;
21
+ workingTree?: boolean;
22
+ staged?: boolean;
23
+ }
24
+ export declare function detectCiBaseCandidates(env?: NodeJS.ProcessEnv): string[];
25
+ export declare function collectChanges(config: Config, options?: ChangeOptions): Promise<ChangeState>;
package/dist/git.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { stat } from "node:fs/promises";
3
+ import { resolve as resolvePath } from "node:path";
2
4
  import { promisify } from "node:util";
3
5
  import picomatch from "picomatch";
4
6
  import { ConfigError, secretPatterns } from "./config.js";
@@ -12,38 +14,88 @@ export async function git(cwd, ...args) {
12
14
  })).stdout;
13
15
  }
14
16
  export const matches = (path, patterns) => patterns.length > 0 && picomatch(patterns, { dot: true })(path);
17
+ const branchRefs = (value) => {
18
+ const branch = value?.trim().replace(/^refs\/heads\//, "");
19
+ if (!branch || branch === "false")
20
+ return [];
21
+ if (branch.startsWith("origin/") || /^[0-9a-f]{7,40}$/.test(branch))
22
+ return [branch];
23
+ return [`origin/${branch}`, branch];
24
+ };
25
+ export function detectCiBaseCandidates(env = process.env) {
26
+ const refs = [
27
+ ...branchRefs(env.GITHUB_BASE_REF),
28
+ ...(env.GITLAB_CI === "true"
29
+ ? [
30
+ ...branchRefs(env.CI_MERGE_REQUEST_DIFF_BASE_SHA),
31
+ ...branchRefs(env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME),
32
+ ...branchRefs(env.CI_DEFAULT_BRANCH),
33
+ ]
34
+ : []),
35
+ ...(env.BUILDKITE === "true"
36
+ ? branchRefs(env.BUILDKITE_PULL_REQUEST_BASE_BRANCH)
37
+ : []),
38
+ ...(env.CIRCLECI === "true" ? branchRefs(env.CIRCLE_PR_BASE_BRANCH) : []),
39
+ ];
40
+ return [...new Set(refs)];
41
+ }
42
+ const hasUnsupportedPatch = (patch) => /^Binary files .+ differ$/m.test(patch) ||
43
+ /^[+-]Subproject commit [0-9a-f]{7,}(?:-dirty)?$/m.test(patch);
44
+ async function untrackedPatch(cwd, path) {
45
+ try {
46
+ return await git(cwd, "diff", "--no-index", "--no-ext-diff", "--no-textconv", "--unified=3", "--", "/dev/null", path);
47
+ }
48
+ catch (error) {
49
+ const result = error;
50
+ if (Number(result.code) === 1 && typeof result.stdout === "string")
51
+ return result.stdout;
52
+ throw error;
53
+ }
54
+ }
15
55
  export async function collectChanges(config, options = {}) {
16
56
  const cwd = options.cwd ?? process.cwd();
57
+ if (options.workingTree && options.staged)
58
+ throw new ConfigError("Use --working-tree or --staged, not both.");
59
+ if (options.workingTree && options.head)
60
+ throw new ConfigError("--working-tree cannot be combined with --head.");
61
+ if (options.staged && (options.base || options.head))
62
+ throw new ConfigError("--staged cannot be combined with --base or --head.");
17
63
  const resolve = async (ref) => (await git(cwd, "rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`)).trim();
18
- let head;
64
+ let headCommit;
19
65
  try {
20
- head = await resolve(options.head ?? "HEAD");
66
+ headCommit = await resolve(options.head ?? "HEAD");
21
67
  }
22
68
  catch {
23
69
  throw new ConfigError("Cannot resolve head commit.");
24
70
  }
25
- const explicit = options.base ?? config.base;
71
+ const explicit = options.staged ? "HEAD" : (options.base ?? config.base);
26
72
  const candidates = explicit
27
73
  ? [explicit]
28
74
  : [
29
- process.env.GITHUB_BASE_REF
30
- ? `origin/${process.env.GITHUB_BASE_REF}`
31
- : undefined,
75
+ ...detectCiBaseCandidates(),
76
+ "origin/HEAD",
32
77
  "origin/main",
33
78
  "main",
34
79
  "master",
35
- ].filter((x) => !!x);
80
+ ];
36
81
  let base;
37
82
  for (const ref of candidates) {
38
83
  try {
39
- base = (await git(cwd, "merge-base", await resolve(ref), head)).trim();
84
+ base = options.staged
85
+ ? await resolve("HEAD")
86
+ : (await git(cwd, "merge-base", await resolve(ref), headCommit)).trim();
40
87
  break;
41
88
  }
42
89
  catch { }
43
90
  }
44
91
  if (!base)
45
92
  throw new ConfigError("Cannot resolve Git base. Fetch the base branch or pass --base.");
46
- const parts = (await git(cwd, "diff", "--name-status", "-z", "--find-renames", base, head, "--")).split("\0");
93
+ const comparison = options.staged
94
+ ? ["--cached", base]
95
+ : options.workingTree
96
+ ? [base]
97
+ : [base, headCommit];
98
+ const parts = (await git(cwd, "diff", "--name-status", "-z", "--find-renames", ...comparison, "--")).split("\0");
47
99
  const files = [];
48
100
  for (let i = 0; i < parts.length && parts[i];) {
49
101
  const status = parts[i++];
@@ -59,9 +111,23 @@ export async function collectChanges(config, options = {}) {
59
111
  else
60
112
  files.push({ status, path });
61
113
  }
114
+ if (options.workingTree) {
115
+ const tracked = new Set(files.flatMap((file) => [
116
+ file.path,
117
+ ...(file.oldPath ? [file.oldPath] : []),
118
+ ]));
119
+ for (const path of (await git(cwd, "ls-files", "--others", "--exclude-standard", "-z", "--")).split("\0")) {
120
+ if (path && !tracked.has(path))
121
+ files.push({ status: "A", path });
122
+ }
123
+ }
62
124
  const state = {
63
125
  base,
64
- head,
126
+ head: options.staged
127
+ ? "INDEX"
128
+ : options.workingTree
129
+ ? "WORKTREE"
130
+ : headCommit,
65
131
  files: [],
66
132
  diff: "",
67
133
  incomplete: false,
@@ -78,9 +144,19 @@ export async function collectChanges(config, options = {}) {
78
144
  continue;
79
145
  state.files.push(file);
80
146
  try {
81
- const patch = await git(cwd, "diff", "--no-ext-diff", "--no-textconv", "--find-renames", "--unified=3", base, head, "--", ...paths);
82
- if (patch.includes("Binary files ") ||
83
- patch.includes("Subproject commit ")) {
147
+ if (options.workingTree && file.status === "A") {
148
+ const size = (await stat(resolvePath(cwd, file.path))).size;
149
+ if (Buffer.byteLength(state.diff) + size >
150
+ config.analysis.maxDiffBytes) {
151
+ state.incomplete = true;
152
+ state.warnings.push("Diff exceeds analysis limit; running all tasks.");
153
+ continue;
154
+ }
155
+ }
156
+ const patch = options.workingTree && file.status === "A"
157
+ ? await untrackedPatch(cwd, file.path)
158
+ : await git(cwd, "diff", "--no-ext-diff", "--no-textconv", "--find-renames", "--unified=3", ...(options.staged ? ["--cached", base] : comparison), "--", ...paths);
159
+ if (hasUnsupportedPatch(patch)) {
84
160
  state.incomplete = true;
85
161
  state.warnings.push("Binary or submodule change; running all tasks.");
86
162
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- export { type Config, ConfigError, loadConfig, parseConfig } from "./config.js";
1
+ export { type CacheInfo, cacheDirectory, clearCache, inspectCache, } from "./cache.js";
2
+ export { API_KEY_VARS, type Config, ConfigError, loadConfig, parseConfig, resolveApiKey, } from "./config.js";
2
3
  export { executePlan } from "./executor.js";
3
- export { type ChangeState, collectChanges } from "./git.js";
4
+ export { type ChangedFile, type ChangeOptions, type ChangeState, collectChanges, detectCiBaseCandidates, } from "./git.js";
4
5
  export { createPlan, type Plan, type TaskDecision } from "./planner.js";
5
6
  export { type AnalysisInput, type AnalysisResult, type DecisionProvider, JevProvider, } from "./provider.js";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
- export { ConfigError, loadConfig, parseConfig } from "./config.js";
1
+ export { cacheDirectory, clearCache, inspectCache, } from "./cache.js";
2
+ export { API_KEY_VARS, ConfigError, loadConfig, parseConfig, resolveApiKey, } from "./config.js";
2
3
  export { executePlan } from "./executor.js";
3
- export { collectChanges } from "./git.js";
4
+ export { collectChanges, detectCiBaseCandidates, } from "./git.js";
4
5
  export { createPlan } from "./planner.js";
5
6
  export { JevProvider, } from "./provider.js";
package/dist/planner.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Config } from "./config.js";
2
- import { type ChangeState } from "./git.js";
2
+ import { type ChangeOptions, type ChangeState } from "./git.js";
3
3
  import { type AnalysisResult, type DecisionProvider } from "./provider.js";
4
4
  export interface TaskDecision {
5
5
  id: string;
@@ -28,10 +28,7 @@ export interface Plan {
28
28
  }
29
29
  export declare function createPlan(input: {
30
30
  config: Config;
31
- cwd?: string;
32
- base?: string;
33
- head?: string;
34
31
  state?: ChangeState;
35
32
  provider?: DecisionProvider;
36
33
  cache?: boolean;
37
- }): Promise<Plan>;
34
+ } & ChangeOptions): Promise<Plan>;
package/dist/planner.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
- import { parseConfig, secretPatterns } from "./config.js";
5
- import { collectChanges, git, matches } from "./git.js";
4
+ import { cacheDirectory } from "./cache.js";
5
+ import { parseConfig, resolveApiKey, secretPatterns, } from "./config.js";
6
+ import { collectChanges, matches, } from "./git.js";
6
7
  import { JevProvider, } from "./provider.js";
7
8
  export async function createPlan(input) {
8
9
  const start = Date.now(), config = parseConfig(input.config), cwd = input.cwd ?? process.cwd();
@@ -60,7 +61,7 @@ export async function createPlan(input) {
60
61
  const pinned = /^jev-\d+\.\d+\.\d+$/.test(config.model);
61
62
  if (input.cache !== false && !input.provider && pinned) {
62
63
  try {
63
- const dir = resolve(cwd, (await git(cwd, "rev-parse", "--git-path", "jev-affected/cache")).trim());
64
+ const dir = await cacheDirectory(cwd);
64
65
  const key = createHash("sha256")
65
66
  .update(JSON.stringify({
66
67
  version: 1,
@@ -87,6 +88,7 @@ export async function createPlan(input) {
87
88
  questions,
88
89
  model: config.model,
89
90
  timeoutMs: config.analysis.timeoutMs,
91
+ apiKey: resolveApiKey(),
90
92
  });
91
93
  }
92
94
  if (!result.model || !/^jev-\d+\.\d+\.\d+$/.test(result.model))
@@ -4,6 +4,7 @@ export interface AnalysisInput {
4
4
  questions: Record<string, string>;
5
5
  model: string;
6
6
  timeoutMs: number;
7
+ apiKey?: string;
7
8
  }
8
9
  export interface AnalysisResult {
9
10
  model: string;
package/dist/provider.js CHANGED
@@ -2,6 +2,7 @@ import { noul, TypeSafeClient } from "@typesafe-ai/sdk";
2
2
  export class JevProvider {
3
3
  async analyze(input) {
4
4
  const client = new TypeSafeClient({
5
+ apiKey: input.apiKey,
5
6
  logLevel: "off",
6
7
  timeout: input.timeoutMs,
7
8
  retry: { maxRetries: 0 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jev-affected",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Semantic task routing for software development, powered by Jev.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,7 +46,6 @@
46
46
  "SECURITY.md",
47
47
  "CODE_OF_CONDUCT.md",
48
48
  "EVALUATION.md",
49
- ".env.example",
50
49
  "evals",
51
50
  "skills"
52
51
  ],
@@ -65,7 +64,6 @@
65
64
  },
66
65
  "dependencies": {
67
66
  "@typesafe-ai/sdk": "^0.6.0",
68
- "dotenv": "^17.2.2",
69
67
  "picomatch": "^4.0.7",
70
68
  "yaml": "^2.9.1",
71
69
  "zod": "^4.6.5"
@@ -13,12 +13,13 @@ projects and `npx jev-affected` elsewhere.
13
13
  1. Confirm the current directory is a Git repository and locate
14
14
  `jev-affected.yml`. Run `jev-affected init` only when setup is requested and no
15
15
  configuration exists.
16
- 2. Ensure the relevant edits are committed. Version 0.1 compares commits and does
17
- not include working-tree changes.
18
- 3. On first use, or when privacy matters, run
19
- `jev-affected inspect --base <ref>`. This previews the sanitized input locally
20
- without sending a request.
21
- 4. Run `jev-affected plan --json --base <ref>` and use its task decisions as the
16
+ 2. Choose the input mode. Use the default mode for committed branch changes,
17
+ `--working-tree --base <ref>` while editing, or `--staged` for the exact Git
18
+ index before a commit.
19
+ 3. On first use, or when privacy matters, run `jev-affected inspect` with the
20
+ same mode and base flags intended for planning. This previews the sanitized
21
+ input locally without sending a request.
22
+ 4. Run `jev-affected plan --json` with those same flags and use its task decisions as the
22
23
  record of what should run. Do not invent task reasons or commands.
23
24
  5. Run `jev-affected run --base <ref>` only when execution is requested. Commands
24
25
  come from the repository configuration and should be treated as repository
@@ -26,15 +27,18 @@ projects and `npx jev-affected` elsewhere.
26
27
  6. Report the selected and skipped task counts, actual model version, warnings,
27
28
  fallback decisions, and any failed task commands.
28
29
 
30
+ Use `jev-affected cache status` to inspect the Git-local decision cache. Use
31
+ `jev-affected cache clear` when a fresh semantic decision is needed; it removes
32
+ only the cache directory resolved inside Git metadata.
33
+
29
34
  Use an explicit base ref in CI and shallow clones. Fetch enough history for Git to
30
35
  compute the merge base.
31
36
 
32
37
  ## Credentials and data
33
38
 
34
- Set `TYPESAFE_API_KEY` in the process environment or a repository-local `.env`
35
- file. Never print the value, commit `.env`, or store the key in
36
- `jev-affected.yml`. An existing process environment value takes precedence over
37
- `.env`.
39
+ Set `TYPESAFE_API_KEY` in the process environment or CI secret store. Never
40
+ print the value or store the key in `jev-affected.yml`. The CLI does not load
41
+ dotenv files. `TYPESAFEAI_API_KEY` is accepted as a compatibility fallback.
38
42
 
39
43
  `inspect` stays local. `plan`, `why`, and `run` can send the sanitized committed
40
44
  diff and configured semantic questions to the provider. Review `inspect` output
package/.env.example DELETED
@@ -1,3 +0,0 @@
1
- # Copy this file to .env and replace the placeholder.
2
- # .env is ignored by Git. Never commit a real API key.
3
- TYPESAFE_API_KEY=replace_with_your_typesafe_api_key