your-cto-jev 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 rainday
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # your-cto-jev
2
+
3
+ **A sharp-tongued CTO that reviews what your AI coding agent is about to do, and says no.**
4
+
5
+ `cto` hooks into `git commit` and into the shell tool of Claude Code, Cursor, Gemini CLI and Codex. Before a commit lands or a command runs, it asks [TypeSafe Jev](https://developers.cloudflare.com/ai/models/typesafe/jev/), a typed decision model, a few yes/no questions. Then it blocks, warns, or stays completely silent.
6
+
7
+ ```text
8
+ $ git commit -m "add payments"
9
+ [cto] Nice one, genius. You almost pushed a secret into Git. Commit BLOCKED. Remove it.
10
+ credential_leak = 0.94 (threshold > 0.5)
11
+
12
+ # Claude Code tries: rm -rf ~
13
+ [cto] Hold it. This command is irreversibly destructive. Not running it.
14
+ destructive_command = 0.96 (threshold > 0.7)
15
+
16
+ # Same build fails twice, agent tries the exact same thing again
17
+ [cto] Same error again. You are looping and burning tokens. This tool call is BLOCKED. Fix the logic first.
18
+ infinite_loop = 0.95 (threshold > 0.85)
19
+ ```
20
+
21
+ Messages come in English or 繁體中文, picked from your locale.
22
+
23
+ ## Why
24
+
25
+ Coding agents move fast and occasionally do something you would never approve: commit an API key, `rm -rf` the wrong directory, force-push over a shared branch, or retry the same failing command until your token budget is gone. Regex scanners catch some of this, but they cannot tell a real key from a placeholder, or a cleanup from a disaster.
26
+
27
+ Jev judges meaning and answers with probabilities, not prose. It costs about $0.00002 per check and answered in about 250 ms (p95 about 330 ms) in our tests. `cto` adds the hooks, the thresholds and the attitude.
28
+
29
+ ## What it checks
30
+
31
+ | Signal | Where | What happens |
32
+ |---|---|---|
33
+ | `credential_leak` | every `git commit` | **Blocks** the commit |
34
+ | `destructive_command` | every agent shell command | **Blocks** the command |
35
+ | `infinite_loop` | agent shell commands, after a recent failure | **Blocks** the command |
36
+ | `architecture_violation` | `git commit`, once you set a sprint goal | Warns only |
37
+ | `code_complexity` | every `git commit` | Warns only |
38
+
39
+ Architecture and complexity only warn on purpose. They are subjective, and a gate that blocks too often teaches people to use `git commit --no-verify`, which switches off the secret check too.
40
+
41
+ When nothing crosses a threshold, `cto` prints nothing at all.
42
+
43
+ ## How it works
44
+
45
+ ```mermaid
46
+ flowchart TD
47
+ A["git commit"] --> B["Staged diff<br/>minus lock files, minified files, maps, binaries<br/>split into ~24k-token chunks"]
48
+ C["Agent shell command<br/>Claude Code · Cursor · Gemini CLI · Codex"] --> D["Command + failures from the last 15 min"]
49
+ E["Agent command failed"] --> F[("Error recorded locally<br/>no API call")]
50
+ F -.-> D
51
+ B --> M["Mask secrets<br/>keys, tokens, .env values"]
52
+ D --> M
53
+ M --> P1{"Cloudflare<br/>Workers AI"}
54
+ P1 -- "answers" --> J{"Jev scores vs<br/>.cto.json thresholds"}
55
+ P1 -- "error, cooling down or no key" --> P2{"OpenRouter"}
56
+ P2 -- "answers" --> J
57
+ P2 -- "error or no key" --> O["Fail open<br/>let it through"]
58
+ J -- "secret, destructive or loop" --> X["BLOCK<br/>commit exit 1 · tool call exit 2"]
59
+ J -- "complex or off the sprint goal" --> W["Warn, then allow"]
60
+ J -- "nothing crosses" --> S["Allow silently"]
61
+ ```
62
+
63
+ A provider that fails is skipped for 5 minutes after a timeout, `429` or `5xx`, or for 1 hour after an auth or billing error, then retried. A `400` means the request itself is wrong, so `cto` fails open without trying the other provider.
64
+
65
+ ## Quick start
66
+
67
+ Requires Node.js 22 or newer.
68
+
69
+ ```sh
70
+ npm install -g your-cto-jev
71
+ cd your-project
72
+ cto setup
73
+ ```
74
+
75
+ Run the same install command again to update.
76
+
77
+ `cto setup` walks you through it in the terminal:
78
+
79
+ ```text
80
+ Which coding agents should get command interception?
81
+ Claude Code (detected) [Y/n]
82
+ Cursor (detected) [Y/n]
83
+ Gemini CLI [y/N]
84
+ Codex CLI (detected) [Y/n]
85
+
86
+ Set up a Jev API key (at least one; press Enter to skip).
87
+ OpenRouter API key: ********
88
+ Verifying OpenRouter with one real request...
89
+ OpenRouter OK
90
+ ```
91
+
92
+ Then restart your agent session so it picks up the new hooks.
93
+
94
+ Other forms:
95
+
96
+ ```sh
97
+ cto setup --agents claude,cursor --yes # non-interactive (scripts, CI)
98
+ cto setup --keys # change keys later
99
+ cto setup --uninstall # remove every cto hook again
100
+ ```
101
+
102
+ `setup` appends marked blocks and merges JSON. It never overwrites your existing hooks or settings, and `--uninstall` puts them back exactly as they were. It never touches your shell rc files.
103
+
104
+ ## Supported agents
105
+
106
+ The git pre-commit check is always installed. It protects every agent and every human who commits from that repo.
107
+
108
+ | Agent | Config written | Command gate | Loop detection |
109
+ |---|---|---|---|
110
+ | Claude Code | `.claude/settings.local.json` | yes | yes |
111
+ | Cursor | `.cursor/hooks.json` | yes | yes |
112
+ | Gemini CLI | `.gemini/settings.json` | yes | yes |
113
+ | Codex CLI | `.codex/hooks.json` | yes | limited: Codex has no failure event |
114
+
115
+ Claude Code is tested end to end inside the agent. The Cursor, Gemini CLI and Codex adapters follow each agent's official hook docs and are tested with their documented payloads, but have not been run inside those agents yet. Reports welcome.
116
+
117
+ Codex only runs project hooks after you trust the project. Teammates who have not installed `cto` are never blocked: the git hook checks for `cto` first, and Claude Code, Cursor and Gemini CLI document a failed hook command (other than exit 2) as non-blocking.
118
+
119
+ ## API keys
120
+
121
+ You need at least one provider:
122
+
123
+ | Provider | Keys | Notes |
124
+ |---|---|---|
125
+ | [OpenRouter](https://openrouter.ai) | `OPENROUTER_API_KEY` | Easiest way to start |
126
+ | [Cloudflare Workers AI](https://developers.cloudflare.com/ai/models/typesafe/jev/) | `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` | Tried first when set. Your account's default AI Gateway must have **Authenticated Gateway** on and Unified Billing credits, or every call returns `403` |
127
+
128
+ If the first provider fails, `cto` switches to the next one and tells you once. It retries the first provider later. A missing key, a timeout or an outage never blocks you: `cto` fails open and lets the action through. It only blocks when Jev answers and the answer crosses a threshold.
129
+
130
+ **Where keys live.** Environment variables win. Otherwise `cto` reads a per-user file that `setup` writes:
131
+
132
+ - Windows: `%APPDATA%\your-cto-jev\credentials.json`
133
+ - macOS and Linux: `~/.config/your-cto-jev/credentials.json` (mode 600)
134
+
135
+ The file sits outside every repository, so it cannot be committed. It exists because agents and git clients started from a GUI often never see variables exported in `.zshrc`, and the hook would silently let everything through.
136
+
137
+ ## What leaves your machine
138
+
139
+ Every check sends text to your chosen provider. `cto` masks it first:
140
+
141
+ - **For commits:** the staged diff, with lock files, minified files, source maps and binaries removed.
142
+ - **For agent commands:** the command, plus up to five failures from the last 15 minutes when checking for loops.
143
+ - **Always masked:** private key blocks, Stripe, OpenAI, AWS, GitHub and npm tokens, and any `NAME=value` whose name ends in `PASSWORD`, `SECRET`, `TOKEN` or `KEY`. Every value in a `.env` file is masked as well.
144
+
145
+ Masking only controls what goes out. Jev still sees labels such as `[REDACTED: STRIPE_KEY]`, which is how it knows a real secret was there.
146
+
147
+ Nothing else is uploaded, and nothing is logged unless you set `CTO_DEBUG=1`. That writes the masked input to `debug_stdin.json`, which `setup` adds to `.gitignore`.
148
+
149
+ ## Configuration
150
+
151
+ `.cto.json` is shared with the team, so commit it:
152
+
153
+ ```json
154
+ {
155
+ "sprint_goal": "",
156
+ "thresholds": {
157
+ "credential_leak": 0.5,
158
+ "destructive_command": 0.7,
159
+ "infinite_loop": 0.85,
160
+ "architecture_violation": 0.85,
161
+ "code_complexity": 2
162
+ }
163
+ }
164
+ ```
165
+
166
+ - **`sprint_goal`**: fill it in to enable the architecture check, for example `"Ship Stripe billing, no new services"`.
167
+ - **Thresholds**: Jev returns a probability from 0 to 1. `code_complexity` is the exception: it is a score from 0 (clean) to 4 (unmaintainable).
168
+ - **Calibration**: in our tests `git push --force` scored about 0.50 for `destructive_command`, so the default lets it through. Lower that threshold to about 0.45 if you want force pushes blocked.
169
+
170
+ `.cto-brain.json` holds personal runtime state and is gitignored.
171
+
172
+ | Env var | Purpose |
173
+ |---|---|
174
+ | `CTO_PROVIDER` | Use only `cloudflare` or only `openrouter` |
175
+ | `CTO_FAILOVER=0` | Never switch providers |
176
+ | `CTO_LANG` | `en` or `zh-TW` (default: your locale) |
177
+ | `CTO_DEBUG=1` | Write masked hook input to `debug_stdin.json` |
178
+
179
+ ## Known limitations
180
+
181
+ - **Git GUIs hide warnings.** VS Code, SourceTree and GitKraken usually hide hook output when the commit succeeds, so the architecture and complexity warnings are invisible there. Blocks still show.
182
+ - **A retry after a fix can look like a loop.** The loop check sees recent failures but not the edits you made since. Failures expire after 15 minutes. Raise `infinite_loop` if it gets in your way.
183
+ - **Each agent shell command waits about half a second** for Node startup plus one Jev request.
184
+
185
+ ## Development
186
+
187
+ ```sh
188
+ git clone https://github.com/rainday/your-cto-jev
189
+ cd your-cto-jev
190
+ npm install
191
+ npm test
192
+ ```
193
+
194
+ The full design, including the measured results behind the defaults, is in [`your-cto-jev-spec.html`](./your-cto-jev-spec.html) (written in 繁體中文).
195
+
196
+ ## License
197
+
198
+ [MIT](./LICENSE)
package/dist/agents.js ADDED
@@ -0,0 +1,90 @@
1
+ // One row per coding agent. The verdict logic is shared; each row only maps the agent's
2
+ // stdin shape, its blocking convention, and where setup writes its hook config.
3
+ const str = (v) => (typeof v === 'string' && v.trim() ? v : undefined);
4
+ const cmdOf = (i) => String(i?.tool_input?.command ?? i?.command ?? '');
5
+ // Claude, Codex and Gemini share this convention: exit 2 + stderr blocks, exit 0 hides stderr so notices go out as systemMessage.
6
+ const exit2Block = (lines) => ({ code: 2, stderr: lines });
7
+ const systemMessage = (msgs) => ({ code: 0, stdout: JSON.stringify({ systemMessage: msgs.join('\n') }), stderr: [] });
8
+ const nestedPre = (i) => ({ command: str(i?.tool_input?.command), sessionId: str(i?.session_id), cwd: str(i?.cwd) });
9
+ /** Pull a non-zero exit out of free-form tool output ("Exit Code: 1", "exit_code": 2, ...). */
10
+ function nonZeroExit(v) {
11
+ if (v && typeof v === 'object') {
12
+ const o = v;
13
+ const code = o.exit_code ?? o.exitCode ?? o['Exit Code'];
14
+ if (typeof code === 'number' && code !== 0)
15
+ return str(o.stderr) ?? str(o.output) ?? str(o.stdout) ?? JSON.stringify(o);
16
+ return undefined;
17
+ }
18
+ const s = str(v);
19
+ return s && /exit[ _]?code:?\s*"?([1-9]\d*)/i.test(s) ? s : undefined;
20
+ }
21
+ export const agents = {
22
+ claude: {
23
+ label: 'Claude Code',
24
+ homeDir: '.claude',
25
+ settings: '.claude/settings.local.json',
26
+ style: 'nested',
27
+ pre: { event: 'PreToolUse', matcher: 'Bash' },
28
+ post: { event: 'PostToolUseFailure', matcher: 'Bash' },
29
+ parsePre: nestedPre,
30
+ parsePost: (i) => (i?.is_interrupt || !str(i?.error) ? null : { command: cmdOf(i), error: i.error, cwd: str(i?.cwd) }),
31
+ block: exit2Block,
32
+ notice: systemMessage,
33
+ },
34
+ cursor: {
35
+ label: 'Cursor',
36
+ homeDir: '.cursor',
37
+ settings: '.cursor/hooks.json',
38
+ style: 'cursor',
39
+ pre: { event: 'beforeShellExecution' },
40
+ post: { event: 'postToolUseFailure' },
41
+ parsePre: (i) => ({ command: str(i?.command), sessionId: str(i?.conversation_id), cwd: str(i?.cwd) ?? str(i?.workspace_roots?.[0]) }),
42
+ parsePost: (i) => i?.tool_name !== 'Shell' || i?.is_interrupt || !str(i?.error_message)
43
+ ? null
44
+ : { command: cmdOf(i), error: i.error_message, cwd: str(i?.cwd) ?? str(i?.workspace_roots?.[0]) },
45
+ // Both documented block paths at once: exit 2 and permission "deny".
46
+ block: (lines) => ({
47
+ code: 2,
48
+ stdout: JSON.stringify({ permission: 'deny', user_message: lines.join('\n'), agent_message: lines.join('\n') }),
49
+ stderr: lines,
50
+ }),
51
+ notice: (msgs) => ({ code: 0, stdout: JSON.stringify({ permission: 'allow', user_message: msgs.join('\n') }), stderr: [] }),
52
+ },
53
+ gemini: {
54
+ label: 'Gemini CLI',
55
+ homeDir: '.gemini',
56
+ settings: '.gemini/settings.json',
57
+ style: 'nested',
58
+ pre: { event: 'BeforeTool', matcher: 'run_shell_command' },
59
+ post: { event: 'AfterTool', matcher: 'run_shell_command' },
60
+ parsePre: nestedPre,
61
+ parsePost: (i) => {
62
+ if (i?.tool_name !== 'run_shell_command')
63
+ return null;
64
+ const r = i?.tool_response;
65
+ const err = str(r?.error) ?? str(r?.error?.message) ?? nonZeroExit(r?.llmContent);
66
+ return err ? { command: cmdOf(i), error: err, cwd: str(i?.cwd) } : null;
67
+ },
68
+ block: exit2Block,
69
+ notice: systemMessage,
70
+ },
71
+ codex: {
72
+ label: 'Codex CLI',
73
+ homeDir: '.codex',
74
+ settings: '.codex/hooks.json',
75
+ style: 'nested',
76
+ pre: { event: 'PreToolUse', matcher: 'Bash' },
77
+ // Codex has no failure event; PostToolUse is recorded only when its output shows a non-zero exit.
78
+ post: { event: 'PostToolUse', matcher: 'Bash' },
79
+ note: 'note_codex_trust',
80
+ parsePre: nestedPre,
81
+ parsePost: (i) => {
82
+ const err = nonZeroExit(i?.tool_response);
83
+ return err ? { command: cmdOf(i), error: err, cwd: str(i?.cwd) } : null;
84
+ },
85
+ block: exit2Block,
86
+ notice: systemMessage,
87
+ },
88
+ };
89
+ export const agentNames = Object.keys(agents);
90
+ export const hookCommand = (agent, phase) => `cto --hook ${agent}-${phase}`;
package/dist/api.js ADDED
@@ -0,0 +1,123 @@
1
+ import { t } from './i18n.js';
2
+ import { maskSensitiveState } from './masker.js';
3
+ const env = process.env;
4
+ const HOUR = 3_600_000;
5
+ const FIVE_MIN = 300_000;
6
+ // One row per provider. Same questions/answers schema everywhere; only URL, auth and body wrapping differ.
7
+ export const providers = {
8
+ cloudflare: {
9
+ label: 'Cloudflare',
10
+ enabled: () => !!(env.CLOUDFLARE_API_TOKEN && env.CLOUDFLARE_ACCOUNT_ID),
11
+ url: () => `https://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/ai/run`,
12
+ headers: () => ({ Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}` }),
13
+ // Cloudflare rejects session_id inside input with a 400 (verified), so it is OpenRouter-only.
14
+ body: ({ session_id: _, ...input }) => ({ model: 'typesafe/jev', input }),
15
+ },
16
+ openrouter: {
17
+ label: 'OpenRouter',
18
+ enabled: () => !!env.OPENROUTER_API_KEY,
19
+ url: () => 'https://openrouter.ai/api/alpha/decisions',
20
+ headers: () => ({ Authorization: `Bearer ${env.OPENROUTER_API_KEY}` }),
21
+ body: (req) => ({ model: 'typesafe/jev-1.13', ...req }),
22
+ },
23
+ };
24
+ /** Ordered provider names honoring CTO_PROVIDER. */
25
+ export function providerOrder() {
26
+ const order = env.CTO_PROVIDER ? [env.CTO_PROVIDER] : Object.keys(providers);
27
+ return order.filter((n) => providers[n]?.enabled());
28
+ }
29
+ async function callOnce(name, req, timeoutMs) {
30
+ const p = providers[name];
31
+ try {
32
+ const res = await fetch(p.url(), {
33
+ method: 'POST',
34
+ headers: { ...p.headers(), 'Content-Type': 'application/json' },
35
+ body: JSON.stringify(p.body(req)),
36
+ signal: AbortSignal.timeout(timeoutMs),
37
+ });
38
+ const text = await res.text();
39
+ let json;
40
+ try {
41
+ json = JSON.parse(text);
42
+ }
43
+ catch { /* handled below */ }
44
+ if (!res.ok) {
45
+ const s = res.status;
46
+ const message = maskSensitiveState(String(json?.error?.message ?? json?.errors?.[0]?.message ?? text).slice(0, 200));
47
+ if (s === 402)
48
+ return { ok: false, kind: 'persistent', status: '402', reason: 'reason_402', message };
49
+ if (s === 401 || s === 403)
50
+ return { ok: false, kind: 'persistent', status: String(s), reason: 'reason_auth', message };
51
+ if (s === 400 || s === 422)
52
+ return { ok: false, kind: 'request', status: String(s), reason: 'bad_request', message };
53
+ if (s === 429)
54
+ return { ok: false, kind: 'transient', status: '429', reason: 'reason_429', message };
55
+ return { ok: false, kind: 'transient', status: String(s), reason: 'reason_5xx', message };
56
+ }
57
+ // Cloudflare docs say unwrapped; tolerate a {success, result} envelope anyway.
58
+ const out = json?.result?.answers ? json.result : json;
59
+ if (!out?.answers || typeof out.answers !== 'object') {
60
+ return { ok: false, kind: 'transient', status: String(res.status), reason: 'reason_bad_response' };
61
+ }
62
+ return { ok: true, answers: out.answers };
63
+ }
64
+ catch (e) {
65
+ const timeout = e?.name === 'TimeoutError' || e?.name === 'AbortError';
66
+ return { ok: false, kind: 'transient', status: timeout ? 'timeout' : 'network', reason: timeout ? 'reason_timeout' : 'reason_network' };
67
+ }
68
+ }
69
+ /** One tiny real request to check a key during setup. */
70
+ export async function probeProvider(name) {
71
+ const r = await callOnce(name, {
72
+ state: 'echo hello',
73
+ questions: { probe: { type: 'noul', instructions: 'Is this a shell command?', criteria: { true: 'Yes', false: 'No' } } },
74
+ }, 15_000);
75
+ return r.ok ? { ok: true } : { ok: false, status: r.status, message: r.message };
76
+ }
77
+ /**
78
+ * Try providers in order, skipping cooled-down ones. Persistent errors cool 1h, transient 5m,
79
+ * a 400 stops without failover. Every failure path is fail-open for the caller.
80
+ * Notices are only added on state changes: switch away, switch back, fail-open.
81
+ */
82
+ export async function evaluate(req, ctx) {
83
+ const { brain, lang, notices } = ctx;
84
+ const now = ctx.now ?? Date.now;
85
+ const enabled = providerOrder();
86
+ if (!enabled.length)
87
+ return { ok: false, reason: 'no_keys' };
88
+ const candidates = env.CTO_FAILOVER === '0' ? enabled.slice(0, 1) : enabled;
89
+ let lastFail;
90
+ for (const name of candidates) {
91
+ const cd = brain.provider_cooldown[name];
92
+ if (cd && cd.until > now())
93
+ continue;
94
+ const r = await callOnce(name, req, ctx.timeoutMs);
95
+ if (r.ok) {
96
+ if (cd) {
97
+ delete brain.provider_cooldown[name];
98
+ notices.add(t(lang, 'recovered', { name: providers[name].label }));
99
+ }
100
+ if (lastFail) {
101
+ brain.failover_count++;
102
+ notices.add(t(lang, 'switched', {
103
+ from: providers[lastFail.name].label, reason: t(lang, lastFail.reason), status: lastFail.status,
104
+ dur: t(lang, lastFail.dur), to: providers[name].label,
105
+ }));
106
+ }
107
+ return { ok: true, provider: name, answers: r.answers };
108
+ }
109
+ if (r.kind === 'request') {
110
+ notices.add(t(lang, 'bad_request', { msg: r.message ?? '' }));
111
+ return { ok: false, reason: 'bad_request' };
112
+ }
113
+ const dur = r.kind === 'persistent' ? 'dur_1h' : 'dur_5m';
114
+ brain.provider_cooldown[name] = { until: now() + (r.kind === 'persistent' ? HOUR : FIVE_MIN), status: r.status };
115
+ lastFail = { name, status: r.status, reason: r.reason, dur };
116
+ }
117
+ if (lastFail) {
118
+ notices.add(t(lang, 'fail_open', {
119
+ detail: t(lang, 'fail_open_detail', { from: providers[lastFail.name].label, reason: t(lang, lastFail.reason), status: lastFail.status }),
120
+ }));
121
+ }
122
+ return { ok: false, reason: 'all_failed' };
123
+ }
package/dist/brain.js ADDED
@@ -0,0 +1,100 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ export const DEFAULT_CONFIG = {
5
+ sprint_goal: '',
6
+ thresholds: {
7
+ credential_leak: 0.5,
8
+ destructive_command: 0.7,
9
+ infinite_loop: 0.85,
10
+ architecture_violation: 0.85,
11
+ code_complexity: 2,
12
+ },
13
+ };
14
+ const emptyBrain = () => ({
15
+ recent_errors: [],
16
+ blocked_attempts: 0,
17
+ skipped_attempts: 0,
18
+ failover_count: 0,
19
+ provider_cooldown: {},
20
+ notified_sessions: [],
21
+ });
22
+ /** Nearest ancestor holding .cto.json or .git; falls back to start. No git spawn. */
23
+ export function findRoot(start = process.cwd()) {
24
+ let dir = start;
25
+ for (;;) {
26
+ if (existsSync(join(dir, '.cto.json')) || existsSync(join(dir, '.git')))
27
+ return dir;
28
+ const up = dirname(dir);
29
+ if (up === dir)
30
+ return start;
31
+ dir = up;
32
+ }
33
+ }
34
+ function readJson(path) {
35
+ try {
36
+ return JSON.parse(readFileSync(path, 'utf8'));
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ export function loadConfig(root) {
43
+ const raw = readJson(join(root, '.cto.json')) ?? {};
44
+ return {
45
+ sprint_goal: typeof raw.sprint_goal === 'string' ? raw.sprint_goal : '',
46
+ thresholds: { ...DEFAULT_CONFIG.thresholds, ...(raw.thresholds ?? {}) },
47
+ };
48
+ }
49
+ /** Corrupt or missing state rebuilds as empty: the gate must never crash on its own bookkeeping. */
50
+ export function loadBrain(root) {
51
+ const raw = readJson(join(root, '.cto-brain.json'));
52
+ return raw && typeof raw === 'object' ? { ...emptyBrain(), ...raw } : emptyBrain();
53
+ }
54
+ // ---------- user-level credentials ----------
55
+ // GUI-launched agents and git clients often do not inherit shell env vars, so keys set in .zshrc
56
+ // never reach the hook. A per-user file outside every repo fixes that without touching rc files.
57
+ export const CREDENTIAL_KEYS = ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID', 'OPENROUTER_API_KEY'];
58
+ export function credentialsPath(env = process.env) {
59
+ const base = env.APPDATA ?? env.XDG_CONFIG_HOME ?? join(homedir(), '.config');
60
+ return join(base, 'your-cto-jev', 'credentials.json');
61
+ }
62
+ export function loadCredentials() {
63
+ const raw = readJson(credentialsPath()) ?? {};
64
+ return Object.fromEntries(CREDENTIAL_KEYS.filter((k) => typeof raw[k] === 'string' && raw[k]).map((k) => [k, raw[k]]));
65
+ }
66
+ /** Env vars win; the file only fills what is missing. */
67
+ export function applyCredentials(env = process.env) {
68
+ for (const [k, v] of Object.entries(loadCredentials()))
69
+ if (!env[k])
70
+ env[k] = v;
71
+ }
72
+ export function saveCredentials(c) {
73
+ const path = credentialsPath();
74
+ mkdirSync(dirname(path), { recursive: true });
75
+ writeFileSync(path, JSON.stringify(c, null, 2) + '\n', { mode: 0o600 });
76
+ return path;
77
+ }
78
+ /**
79
+ * Atomic write via per-process tmp + rename. No file lock (spec section 7): parallel hooks race, last write wins.
80
+ * Never throws: losing bookkeeping is fine, but an exception here would turn a block into a fail-open pass.
81
+ */
82
+ export function saveBrain(root, brain) {
83
+ const path = join(root, '.cto-brain.json');
84
+ const tmp = `${path}.${process.pid}.tmp`;
85
+ try {
86
+ writeFileSync(tmp, JSON.stringify(brain, null, 2) + '\n');
87
+ renameSync(tmp, path);
88
+ }
89
+ catch {
90
+ try {
91
+ unlinkSync(tmp);
92
+ }
93
+ catch { /* nothing to clean */ }
94
+ }
95
+ }
96
+ /** Failures older than this are history, not a loop. */
97
+ export const RECENT_ERROR_WINDOW_MS = 15 * 60_000;
98
+ export function freshErrors(brain, now = Date.now()) {
99
+ return brain.recent_errors.filter((e) => now - Date.parse(e.at) < RECENT_ERROR_WINDOW_MS);
100
+ }
package/dist/diff.js ADDED
@@ -0,0 +1,66 @@
1
+ // Large diff handling (spec section 6): filter noise, then chunk by file.
2
+ const EXCLUDE = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock)$|\.min\.js$|\.map$/;
3
+ export const CHUNK_TOKENS = 24_000;
4
+ /** ~1 token per CJK char, ~4 chars per token otherwise. */
5
+ export function estimateTokens(s) {
6
+ const cjk = s.match(/[ -鿿가-힯豈-﫿＀-￯]/g)?.length ?? 0;
7
+ return cjk + Math.ceil((s.length - cjk) / 4);
8
+ }
9
+ export function splitFiles(diff) {
10
+ return diff.split(/^(?=diff --git )/m).filter((s) => s.trim());
11
+ }
12
+ export function filterDiff(diff) {
13
+ return splitFiles(diff).filter((section) => {
14
+ const m = /^diff --git a\/.+? b\/(.+)$/m.exec(section);
15
+ if (m && EXCLUDE.test(m[1]))
16
+ return false;
17
+ return !/^(Binary files .* differ|GIT binary patch)$/m.test(section);
18
+ });
19
+ }
20
+ /** Greedy pack of file sections into chunks. A single oversized file is split by lines: no sampling. */
21
+ export function chunkDiff(files, limit = CHUNK_TOKENS) {
22
+ const pieces = [];
23
+ for (const f of files) {
24
+ if (estimateTokens(f) <= limit) {
25
+ pieces.push(f);
26
+ continue;
27
+ }
28
+ // Repeat the file header on every piece so Jev always knows which file (.env vs .env.example) it sees.
29
+ const [header, ...rest] = f.split('\n');
30
+ let buf = header + '\n';
31
+ for (const line of rest) {
32
+ if (buf !== header + '\n' && estimateTokens(buf + line) > limit) {
33
+ pieces.push(buf);
34
+ buf = header + '\n';
35
+ }
36
+ buf += line + '\n';
37
+ }
38
+ if (buf)
39
+ pieces.push(buf);
40
+ }
41
+ const chunks = [];
42
+ let cur = '';
43
+ for (const p of pieces) {
44
+ if (cur && estimateTokens(cur + p) > limit) {
45
+ chunks.push(cur);
46
+ cur = '';
47
+ }
48
+ cur += p;
49
+ }
50
+ if (cur)
51
+ chunks.push(cur);
52
+ return chunks;
53
+ }
54
+ /** Run tasks with at most `limit` in flight. */
55
+ export async function pool(items, limit, fn) {
56
+ const out = new Array(items.length);
57
+ let next = 0;
58
+ const worker = async () => {
59
+ while (next < items.length) {
60
+ const i = next++;
61
+ out[i] = await fn(items[i], i);
62
+ }
63
+ };
64
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
65
+ return out;
66
+ }