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/dist/hooks.js ADDED
@@ -0,0 +1,174 @@
1
+ import { styleText } from 'node:util';
2
+ import { evaluate, providerOrder, providers } from './api.js';
3
+ import { freshErrors, loadBrain, loadConfig, saveBrain } from './brain.js';
4
+ import { chunkDiff, filterDiff, pool } from './diff.js';
5
+ import { personaDict, t } from './i18n.js';
6
+ import { maskDiff, maskSensitiveState } from './masker.js';
7
+ const Q = {
8
+ credential_leak: {
9
+ type: 'noul',
10
+ instructions: 'Does this git diff add a real secret (API key, token, password, private key) to the repository? ' +
11
+ 'Values shown as [REDACTED: TYPE] were real secrets detected and masked before review; treat them as real. ' +
12
+ 'Every value in .env files is masked as [REDACTED: ENV_VALUE]: committing a real .env file with values is a leak, ' +
13
+ 'while .env.example, .env.sample or .env.template files are normally placeholders.',
14
+ criteria: {
15
+ true: 'A real credential is being committed',
16
+ false: 'No real credential: placeholders, examples, or references to environment variables only',
17
+ },
18
+ },
19
+ destructive_command: {
20
+ type: 'noul',
21
+ instructions: 'Judge only the command about to run. Is this shell command irreversibly destructive ' +
22
+ '(deletes or overwrites data outside build artifacts, force-pushes or rewrites shared git history, drops databases) ' +
23
+ 'in a way that cannot be undone?',
24
+ criteria: { true: 'Irreversibly destroys data or history', false: 'Safe, read-only, or reversible' },
25
+ },
26
+ infinite_loop: {
27
+ type: 'noul',
28
+ instructions: 'The state holds a command about to run and recently failed commands with their errors. ' +
29
+ 'Is the agent stuck repeating the same failing approach, so running this command again will fail the same way?',
30
+ criteria: {
31
+ true: 'Same or near-identical command that already failed with the same error',
32
+ false: 'A new approach, a fix attempt, or unrelated to the earlier failures',
33
+ },
34
+ },
35
+ code_complexity: {
36
+ type: 'score',
37
+ instructions: 'Rate the maintenance cost and structural complexity of this change.',
38
+ criteria: [
39
+ 'Minimal and clean',
40
+ 'Simple with minor noise',
41
+ 'Acceptable complexity',
42
+ 'Over-engineered or deeply nested',
43
+ 'Unmaintainable',
44
+ ],
45
+ },
46
+ };
47
+ const architectureQ = (goal) => ({
48
+ type: 'noul',
49
+ instructions: `The current sprint goal is: "${goal}". Does this change clearly work against or drift away from that goal?`,
50
+ criteria: { true: 'Clearly off-goal or contradicts the sprint goal', false: 'Aligned with or neutral to the sprint goal' },
51
+ });
52
+ const value = (a) => (a?.type === 'noul' ? a.noul : a?.type === 'score' ? a.score : undefined);
53
+ function exceeded(answers, signal, cfg) {
54
+ const v = value(answers[signal]);
55
+ return v !== undefined && v > cfg.thresholds[signal] ? v : undefined;
56
+ }
57
+ function verdict(lang, signal, v, cfg, color) {
58
+ const head = `[cto] ${personaDict[lang][signal]}`;
59
+ const detail = ` ${t(lang, 'detail', { signal, value: v, threshold: cfg.thresholds[signal] })}`;
60
+ if (!color)
61
+ return [head, detail];
62
+ const s = (fmt, txt) => styleText(fmt, txt, { stream: process.stderr });
63
+ return [s([color, 'bold'], head), s('dim', detail)];
64
+ }
65
+ // ---------- git-commit ----------
66
+ export async function gitCommit(rawDiff, { root, lang, now }) {
67
+ const cfg = loadConfig(root);
68
+ const brain = loadBrain(root);
69
+ const out = { code: 0, stderr: [] };
70
+ const files = filterDiff(maskDiff(rawDiff));
71
+ if (!files.length)
72
+ return out;
73
+ const chunks = chunkDiff(files);
74
+ const soft = { code_complexity: Q.code_complexity };
75
+ if (cfg.sprint_goal.trim())
76
+ soft.architecture_violation = architectureQ(cfg.sprint_goal.trim());
77
+ const notices = new Set();
78
+ const ctx = { brain, lang, timeoutMs: 5000, notices, now };
79
+ // Credential check on every chunk (no sampling); soft signals on the first chunk only. Max 3 in flight.
80
+ const results = await pool(chunks, 3, (state, i) => evaluate({ state, questions: i === 0 ? { credential_leak: Q.credential_leak, ...soft } : { credential_leak: Q.credential_leak } }, ctx));
81
+ if (results.some((r) => !r.ok))
82
+ brain.skipped_attempts++;
83
+ if (results.some((r) => !r.ok && r.reason === 'no_keys'))
84
+ notices.add(t(lang, 'no_keys'));
85
+ // No session concept in git: while degraded (non-primary provider), say so on every commit.
86
+ const primary = providerOrder()[0];
87
+ for (const r of results)
88
+ if (r.ok && r.provider !== primary)
89
+ notices.add(t(lang, 'using', { name: providers[r.provider].label }));
90
+ const lines = [];
91
+ const leaks = results.flatMap((r) => (r.ok ? [exceeded(r.answers, 'credential_leak', cfg)] : [])).filter((v) => v !== undefined);
92
+ if (leaks.length) {
93
+ out.code = 1;
94
+ brain.blocked_attempts++;
95
+ lines.push(...verdict(lang, 'credential_leak', Math.max(...leaks), cfg, 'red'));
96
+ }
97
+ const first = results[0];
98
+ if (first?.ok) {
99
+ for (const s of ['architecture_violation', 'code_complexity']) {
100
+ const v = exceeded(first.answers, s, cfg);
101
+ if (v !== undefined)
102
+ lines.push(...verdict(lang, s, v, cfg, 'yellow'));
103
+ }
104
+ }
105
+ out.stderr = [...notices, ...lines];
106
+ saveBrain(root, brain);
107
+ return out;
108
+ }
109
+ // ---------- pre (Bash / shell command gate, any agent) ----------
110
+ function sessionNotice(brain, sessionId, lang, r, notices) {
111
+ if (!sessionId || brain.notified_sessions.includes(sessionId))
112
+ return;
113
+ brain.notified_sessions = [...brain.notified_sessions, sessionId].slice(-20);
114
+ if (r.ok)
115
+ notices.add(t(lang, 'using', { name: providers[r.provider].label }));
116
+ else if (r.reason === 'no_keys')
117
+ notices.add(t(lang, 'no_keys'));
118
+ else if (!notices.size)
119
+ notices.add(t(lang, 'fail_open', { detail: '' }));
120
+ }
121
+ export async function agentPre(agent, input, { root, lang, now }) {
122
+ const command = input.command;
123
+ if (!command?.trim())
124
+ return { code: 0, stderr: [] };
125
+ const cfg = loadConfig(root);
126
+ const brain = loadBrain(root);
127
+ const questions = { destructive_command: Q.destructive_command };
128
+ let state = maskSensitiveState(command);
129
+ const recent = freshErrors(brain, (now ?? Date.now)());
130
+ if (recent.length) {
131
+ questions.infinite_loop = Q.infinite_loop;
132
+ state =
133
+ `Command about to run:\n${state}\n\nRecently failed commands (oldest first):\n` +
134
+ recent.map((e) => `$ ${e.command}\n${e.error}`).join('\n\n');
135
+ }
136
+ const notices = new Set();
137
+ const { sessionId } = input;
138
+ const r = await evaluate({ state, questions, ...(sessionId && { session_id: sessionId }) }, { brain, lang, timeoutMs: 2000, notices, now });
139
+ if (!r.ok)
140
+ brain.skipped_attempts++;
141
+ sessionNotice(brain, sessionId, lang, r, notices);
142
+ let out = { code: 0, stderr: [] };
143
+ if (r.ok) {
144
+ for (const s of ['destructive_command', 'infinite_loop']) {
145
+ const v = exceeded(r.answers, s, cfg);
146
+ if (v === undefined)
147
+ continue;
148
+ brain.blocked_attempts++;
149
+ out = agent.block([...verdict(lang, s, v, cfg, null), ...notices]);
150
+ break;
151
+ }
152
+ }
153
+ if (out.code === 0 && notices.size)
154
+ out = agent.notice([...notices]);
155
+ saveBrain(root, brain);
156
+ return out;
157
+ }
158
+ // ---------- post (failure recording) ----------
159
+ /** Record the failure only; never calls Jev. No error field, no record. */
160
+ export function agentPost(input, { root }) {
161
+ if (!input)
162
+ return { code: 0, stderr: [] };
163
+ const brain = loadBrain(root);
164
+ brain.recent_errors = [
165
+ ...freshErrors(brain),
166
+ {
167
+ command: maskSensitiveState(input.command).slice(0, 500),
168
+ error: maskSensitiveState(input.error).slice(0, 500),
169
+ at: new Date().toISOString(),
170
+ },
171
+ ].slice(-5);
172
+ saveBrain(root, brain);
173
+ return { code: 0, stderr: [] };
174
+ }
package/dist/i18n.js ADDED
@@ -0,0 +1,130 @@
1
+ /** CTO_LANG > LC_ALL / LANG > OS locale via Intl (Windows has no LANG) > en. zh-* maps to zh-TW. */
2
+ export function detectLang(env = process.env) {
3
+ const pick = (v) => {
4
+ if (!v || v === 'C' || v === 'POSIX')
5
+ return undefined;
6
+ return v.toLowerCase().startsWith('zh') ? 'zh-TW' : v.toLowerCase().startsWith('en') ? 'en' : undefined;
7
+ };
8
+ return (pick(env.CTO_LANG) ??
9
+ pick(env.LC_ALL) ??
10
+ pick(env.LANG) ??
11
+ pick(Intl.DateTimeFormat().resolvedOptions().locale) ??
12
+ 'en');
13
+ }
14
+ export const personaDict = {
15
+ 'zh-TW': {
16
+ credential_leak: '喂,大天才。你差點把金鑰推進 Git。Commit 已攔下,立刻拿掉。',
17
+ destructive_command: '停手。這個指令有不可逆的破壞性,這次不准執行。',
18
+ infinite_loop: '同樣的錯誤又來了,你們在鬼打牆燒 Token。這次工具呼叫我擋下了,先去看邏輯。',
19
+ architecture_violation: '這次變更偏離了 Sprint 目標。我放你過,但這筆帳記著。',
20
+ code_complexity: '複雜度超標,過度工程化。這次不擋,但趕快重構。',
21
+ },
22
+ en: {
23
+ credential_leak: 'Nice one, genius. You almost pushed a secret into Git. Commit BLOCKED. Remove it.',
24
+ destructive_command: 'Hold it. This command is irreversibly destructive. Not running it.',
25
+ infinite_loop: 'Same error again. You are looping and burning tokens. This tool call is BLOCKED. Fix the logic first.',
26
+ architecture_violation: 'This change drifts from the sprint goal. Letting it through, but I noticed.',
27
+ code_complexity: 'Over-engineered. Not blocking this time, but refactor it.',
28
+ },
29
+ };
30
+ const messages = {
31
+ 'zh-TW': {
32
+ reason_402: () => '額度不足',
33
+ reason_auth: () => '認證失敗',
34
+ reason_429: () => '請求過多',
35
+ reason_timeout: () => '逾時',
36
+ reason_5xx: () => '服務錯誤',
37
+ reason_network: () => '連線失敗',
38
+ reason_bad_response: () => '回應格式錯誤',
39
+ dur_1h: () => '1 小時',
40
+ dur_5m: () => '5 分鐘',
41
+ switched: (v) => `[cto] ${v.from} ${v.reason} (${v.status}),${v.dur}內改用 ${v.to}`,
42
+ recovered: (v) => `[cto] ${v.name} 已恢復,切回主要 provider`,
43
+ using: (v) => `[cto] 目前使用 ${v.name} 審查`,
44
+ fail_open: (v) => `[cto] ${v.detail}所有 provider 都無法使用,這次直接放行`,
45
+ fail_open_detail: (v) => `${v.from} ${v.reason} (${v.status})。`,
46
+ bad_request: (v) => `[cto] Jev 拒絕請求 (400),這次直接放行:${v.msg}`,
47
+ no_keys: () => '[cto] 未設定 Jev API key,全部放行。請設定 CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID 或 OPENROUTER_API_KEY',
48
+ detail: (v) => `${v.signal} = ${v.value}(門檻 > ${v.threshold})`,
49
+ setup_not_git: () => '[cto] 這裡不是 git repository,setup 中止。',
50
+ setup_bad_json: (v) => `[cto] ${v.path} 不是合法 JSON 或 hooks 格式不符,未修改任何檔案。請先修正該檔。`,
51
+ setup_hook: (v) => `[cto] pre-commit 區塊已寫入 ${v.path}`,
52
+ setup_hook_exit: (v) => `[cto] 警告:${v.path} 在 cto 區塊之前有 exit 0,cto 檢查不會執行。請調整該 hook。`,
53
+ setup_gitignore: () => '[cto] .gitignore 已加入 .cto-brain.json 與 debug_stdin.json',
54
+ setup_config: () => '[cto] 已建立 .cto.json(請 commit 給團隊共用)',
55
+ setup_sprint: () => '[cto] 提示:在 .cto.json 填入 sprint_goal 以啟用架構對齊檢查。',
56
+ setup_done: () => '[cto] 安裝完成。',
57
+ uninstall_hook: (v) => `[cto] 已從 ${v.path} 移除 cto 區塊`,
58
+ uninstall_gitignore: () => '[cto] 已從 .gitignore 移除 cto 區塊',
59
+ uninstall_done: () => '[cto] 已解除安裝(.cto.json 保留)。',
60
+ setup_agent: (v) => `[cto] ${v.label} hooks 已寫入 ${v.path}`,
61
+ uninstall_agent: (v) => `[cto] 已從 ${v.path} 移除 ${v.label} hooks`,
62
+ note_codex_trust: () => '[cto] 注意:Codex 只在專案被信任後才執行 .codex/ 的 hook,第一次在此目錄開 codex 時請選擇信任。',
63
+ wiz_agents: () => '\n要為哪些 coding agent 安裝指令攔截?(git commit 檢查一律安裝,所有 agent 共用)',
64
+ wiz_agent_q: (v) => ` ${v.label}${v.found} [${v.hint}] `,
65
+ wiz_found: () => '(已偵測到)',
66
+ wiz_keys_present: () => '[cto] 已從環境變數或憑證檔找到 Jev API key,略過金鑰設定。要重設請執行 cto setup --keys',
67
+ wiz_keys: (v) => `\n設定 Jev API key(至少一組,直接 Enter 跳過)。key 存在 ${v.path},只有你的帳號可讀,不會進 git。`,
68
+ wiz_or_key: () => ' OpenRouter API key: ',
69
+ wiz_cf_account: () => ' Cloudflare Account ID: ',
70
+ wiz_cf_token: () => ' Cloudflare API token: ',
71
+ wiz_checking: (v) => ` 正在用一次真實請求驗證 ${v.name}...`,
72
+ wiz_ok: (v) => ` ${v.name} 驗證成功`,
73
+ wiz_fail: (v) => ` ${v.name} 驗證失敗 (${v.status}) ${v.msg}`,
74
+ wiz_save_anyway: () => ' 仍要儲存這組 key 嗎?[y/N] ',
75
+ wiz_saved: (v) => `[cto] key 已儲存到 ${v.path}`,
76
+ wiz_no_keys: () => '[cto] 沒有設定任何 key,cto 會全部放行,之後可執行 cto setup --keys 補上。',
77
+ usage: () => '用法:cto setup [--agents claude,cursor,gemini,codex] [--keys] [--yes] [--uninstall] | cto --hook <git-commit|AGENT-pre|AGENT-post>',
78
+ },
79
+ en: {
80
+ reason_402: () => 'out of credits',
81
+ reason_auth: () => 'auth failed',
82
+ reason_429: () => 'rate limited',
83
+ reason_timeout: () => 'timed out',
84
+ reason_5xx: () => 'server error',
85
+ reason_network: () => 'network error',
86
+ reason_bad_response: () => 'malformed response',
87
+ dur_1h: () => '1 hour',
88
+ dur_5m: () => '5 minutes',
89
+ switched: (v) => `[cto] ${v.from} ${v.reason} (${v.status}), using ${v.to} for the next ${v.dur}`,
90
+ recovered: (v) => `[cto] ${v.name} recovered, switched back to the primary provider`,
91
+ using: (v) => `[cto] Reviewing with ${v.name}`,
92
+ fail_open: (v) => `[cto] ${v.detail}No provider available, letting this one through`,
93
+ fail_open_detail: (v) => `${v.from} ${v.reason} (${v.status}). `,
94
+ bad_request: (v) => `[cto] Jev rejected the request (400), letting this one through: ${v.msg}`,
95
+ no_keys: () => '[cto] No Jev API key set, everything passes. Set CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID or OPENROUTER_API_KEY',
96
+ detail: (v) => `${v.signal} = ${v.value} (threshold > ${v.threshold})`,
97
+ setup_not_git: () => '[cto] Not a git repository, setup aborted.',
98
+ setup_bad_json: (v) => `[cto] ${v.path} is not valid JSON or has an unexpected hooks shape. Nothing was changed. Fix it first.`,
99
+ setup_hook: (v) => `[cto] pre-commit block written to ${v.path}`,
100
+ setup_hook_exit: (v) => `[cto] Warning: ${v.path} has an exit 0 before the cto block, so cto will never run. Adjust that hook.`,
101
+ setup_gitignore: () => '[cto] Added .cto-brain.json and debug_stdin.json to .gitignore',
102
+ setup_config: () => '[cto] Created .cto.json (commit it for the team)',
103
+ setup_sprint: () => '[cto] Tip: set sprint_goal in .cto.json to enable the architecture alignment check.',
104
+ setup_done: () => '[cto] Setup complete.',
105
+ uninstall_hook: (v) => `[cto] Removed the cto block from ${v.path}`,
106
+ uninstall_gitignore: () => '[cto] Removed the cto block from .gitignore',
107
+ uninstall_done: () => '[cto] Uninstalled (.cto.json kept).',
108
+ setup_agent: (v) => `[cto] ${v.label} hooks written to ${v.path}`,
109
+ uninstall_agent: (v) => `[cto] Removed ${v.label} hooks from ${v.path}`,
110
+ note_codex_trust: () => '[cto] Note: Codex only runs .codex/ hooks once the project is trusted. Trust it the first time you open codex here.',
111
+ wiz_agents: () => '\nWhich coding agents should get command interception? (the git commit check is always installed and covers every agent)',
112
+ wiz_agent_q: (v) => ` ${v.label}${v.found} [${v.hint}] `,
113
+ wiz_found: () => ' (detected)',
114
+ wiz_keys_present: () => '[cto] Found a Jev API key in env or the credentials file, skipping key setup. Run cto setup --keys to change it.',
115
+ wiz_keys: (v) => `\nSet up a Jev API key (at least one; press Enter to skip). Keys are stored in ${v.path}, readable only by you, never in git.`,
116
+ wiz_or_key: () => ' OpenRouter API key: ',
117
+ wiz_cf_account: () => ' Cloudflare Account ID: ',
118
+ wiz_cf_token: () => ' Cloudflare API token: ',
119
+ wiz_checking: (v) => ` Verifying ${v.name} with one real request...`,
120
+ wiz_ok: (v) => ` ${v.name} OK`,
121
+ wiz_fail: (v) => ` ${v.name} failed (${v.status}) ${v.msg}`,
122
+ wiz_save_anyway: () => ' Save this key anyway? [y/N] ',
123
+ wiz_saved: (v) => `[cto] Keys saved to ${v.path}`,
124
+ wiz_no_keys: () => '[cto] No key set, so cto lets everything through. Run cto setup --keys later.',
125
+ usage: () => 'Usage: cto setup [--agents claude,cursor,gemini,codex] [--keys] [--yes] [--uninstall] | cto --hook <git-commit|AGENT-pre|AGENT-post>',
126
+ },
127
+ };
128
+ export function t(lang, key, vars = {}) {
129
+ return (messages[lang][key] ?? messages.en[key])(vars);
130
+ }
package/dist/index.js ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ import { writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { agentNames, agents } from './agents.js';
5
+ import { applyCredentials, findRoot } from './brain.js';
6
+ import { agentPost, agentPre, gitCommit } from './hooks.js';
7
+ import { detectLang, t } from './i18n.js';
8
+ import { maskDiff, maskSensitiveState } from './masker.js';
9
+ import { detectAgents, setup } from './setup.js';
10
+ import { terminalAsk, wizard } from './wizard.js';
11
+ async function readStdin() {
12
+ if (process.stdin.isTTY)
13
+ return '';
14
+ let s = '';
15
+ process.stdin.setEncoding('utf8');
16
+ for await (const chunk of process.stdin)
17
+ s += chunk;
18
+ return s;
19
+ }
20
+ function emit(out) {
21
+ if (out.stdout)
22
+ process.stdout.write(out.stdout + '\n');
23
+ if (out.stderr.length)
24
+ process.stderr.write(out.stderr.join('\n') + '\n');
25
+ process.exitCode = out.code;
26
+ }
27
+ async function runHook(name) {
28
+ const raw = await readStdin();
29
+ const lang = detectLang();
30
+ const debug = (root, text) => {
31
+ if (process.env.CTO_DEBUG === '1')
32
+ writeFileSync(join(root, 'debug_stdin.json'), text);
33
+ };
34
+ if (name === 'git-commit') {
35
+ const root = findRoot();
36
+ debug(root, maskDiff(raw));
37
+ return gitCommit(raw, { root, lang });
38
+ }
39
+ const m = /^(claude|cursor|gemini|codex)-(pre|post)$/.exec(name);
40
+ if (!m)
41
+ return { code: 0, stderr: [] };
42
+ const agent = agents[m[1]];
43
+ let input = {};
44
+ try {
45
+ input = JSON.parse(raw);
46
+ }
47
+ catch { /* fail-open below */ }
48
+ if (m[2] === 'pre') {
49
+ const pre = agent.parsePre(input);
50
+ const root = findRoot(pre.cwd ?? process.cwd());
51
+ debug(root, maskSensitiveState(raw));
52
+ return agentPre(agent, pre, { root, lang });
53
+ }
54
+ const post = agent.parsePost(input);
55
+ const root = findRoot(post?.cwd ?? input?.cwd ?? process.cwd());
56
+ debug(root, maskSensitiveState(raw));
57
+ return agentPost(post, { root, lang });
58
+ }
59
+ const args = process.argv.slice(2);
60
+ const lang = detectLang();
61
+ const flag = (name) => {
62
+ const i = args.indexOf(name);
63
+ return i >= 0 ? args[i + 1] : undefined;
64
+ };
65
+ applyCredentials();
66
+ if (args[0] === '--hook' && args[1]) {
67
+ try {
68
+ emit(await runHook(args[1]));
69
+ }
70
+ catch (e) {
71
+ // Fail-open: a crash in the gate must never block a commit or a tool call.
72
+ if (process.env.CTO_DEBUG === '1')
73
+ process.stderr.write(String(e?.stack ?? e) + '\n');
74
+ process.exitCode = 0;
75
+ }
76
+ }
77
+ else if (args[0] === 'setup') {
78
+ const uninstall = args.includes('--uninstall');
79
+ let chosen;
80
+ const list = flag('--agents');
81
+ if (list)
82
+ chosen = list.split(',').map((s) => s.trim()).filter((s) => agentNames.includes(s));
83
+ else if (!uninstall && process.stdin.isTTY && process.stdout.isTTY && !args.includes('--yes')) {
84
+ const { ask, close } = terminalAsk();
85
+ try {
86
+ chosen = await wizard(ask, lang, detectAgents(), (s) => console.log(s), args.includes('--keys'));
87
+ }
88
+ finally {
89
+ close();
90
+ }
91
+ }
92
+ else if (!uninstall) {
93
+ chosen = detectAgents();
94
+ }
95
+ const r = setup(process.cwd(), lang, { uninstall, agents: chosen });
96
+ (r.code ? process.stderr : process.stdout).write(r.lines.join('\n') + '\n');
97
+ process.exitCode = r.code;
98
+ }
99
+ else {
100
+ process.stderr.write(t(lang, 'usage') + '\n');
101
+ process.exitCode = args.length ? 1 : 0;
102
+ }
package/dist/masker.js ADDED
@@ -0,0 +1,35 @@
1
+ // Egress filter: everything sent to Jev or written to disk passes through here.
2
+ // Masking is not the verdict; Jev still judges leaks from the [REDACTED: TYPE] labels.
3
+ export function maskSensitiveState(raw) {
4
+ return raw
5
+ .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[REDACTED: PRIVATE_KEY_BLOCK]')
6
+ .replace(/\bsk_(live|test)_[A-Za-z0-9_]{16,}/g, '[REDACTED: STRIPE_KEY]')
7
+ .replace(/\bsk-(proj-)?[A-Za-z0-9_-]{20,}/g, '[REDACTED: OPENAI_KEY]')
8
+ .replace(/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED: AWS_ACCESS_KEY]')
9
+ .replace(/\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}/g, '[REDACTED: GITHUB_TOKEN]')
10
+ .replace(/\bgithub_pat_[A-Za-z0-9_]{22,}/g, '[REDACTED: GITHUB_PAT]')
11
+ .replace(/\bnpm_[A-Za-z0-9]{36,}/g, '[REDACTED: NPM_TOKEN]')
12
+ // Lookahead keeps an already-masked value from being masked twice.
13
+ .replace(/\b(\w*(?:PASSWORD|SECRET|TOKEN|KEY))=(?!\[REDACTED)\S+/gi, '$1=[REDACTED: SENSITIVE_VALUE]');
14
+ }
15
+ const ENV_FILE = /(^|\/)\.env(\.[^/]*)?$/;
16
+ // Added, removed and context lines all carry real values.
17
+ const ENV_LINE = /^([+\- ])(\s*(?:export\s+)?[A-Za-z_][\w.]*\s*=\s*)(\S.*)$/;
18
+ /** Mask a unified diff: every NAME=value line in .env / .env.* files, then the generic patterns. */
19
+ export function maskDiff(diff) {
20
+ let inEnv = false;
21
+ const lines = diff.split('\n').map((line) => {
22
+ if (line.startsWith('diff --git ')) {
23
+ const m = / b\/(.+)$/.exec(line);
24
+ inEnv = !!m && ENV_FILE.test(m[1]);
25
+ return line;
26
+ }
27
+ if (inEnv && !line.startsWith('+++') && !line.startsWith('---')) {
28
+ const m = ENV_LINE.exec(line);
29
+ if (m)
30
+ return `${m[1]}${m[2]}[REDACTED: ENV_VALUE]`;
31
+ }
32
+ return line;
33
+ });
34
+ return maskSensitiveState(lines.join('\n'));
35
+ }
package/dist/setup.js ADDED
@@ -0,0 +1,216 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, join, relative } from 'node:path';
5
+ import { agentNames, agents, hookCommand } from './agents.js';
6
+ import { DEFAULT_CONFIG } from './brain.js';
7
+ import { t } from './i18n.js';
8
+ const START = '# >>> YOUR CTO JEV START >>>';
9
+ const END = '# <<< YOUR CTO JEV END <<<';
10
+ // Pin the diff format: user config (noprefix, color.diff=always, external diff, textconv) would break parsing and masking.
11
+ export const DIFF_CMD = 'git diff --cached --no-color --no-ext-diff --no-textconv --src-prefix=a/ --dst-prefix=b/';
12
+ const HOOK_BODY = `if command -v cto >/dev/null 2>&1; then\n ${DIFF_CMD} | cto --hook git-commit || exit 1\nfi\n`;
13
+ const IGNORE_BODY = '.cto-brain.json\n.cto-brain.json.*tmp\ndebug_stdin.json\n';
14
+ const OURS = (cmd) => typeof cmd === 'string' && cmd.startsWith('cto --hook ');
15
+ const block = (body) => `${START}\n${body}${END}\n`;
16
+ const blockRe = /\n?# >>> YOUR CTO JEV START >>>\n[\s\S]*?# <<< YOUR CTO JEV END <<<\n?/;
17
+ export function removeBlock(content) {
18
+ return content.replace(blockRe, (m) => (m.startsWith('\n') && m.endsWith('\n') ? '\n' : ''));
19
+ }
20
+ export function appendBlock(content, body) {
21
+ const base = removeBlock(content);
22
+ return (base && !base.endsWith('\n') ? base + '\n' : base) + block(body);
23
+ }
24
+ /** An exit 0 outside our block means the appended block never runs. */
25
+ export function hasEarlyExit(content) {
26
+ return /^\s*exit\s+0\b/m.test(removeBlock(content));
27
+ }
28
+ const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
29
+ /** Hooks dir honoring core.hooksPath (via --git-path). Husky's generated .husky/_ is replaced on install, so use .husky. */
30
+ export function hooksDir(root) {
31
+ const dir = git(root, 'rev-parse', '--path-format=absolute', '--git-path', 'hooks');
32
+ return basename(dir) === '_' && basename(dirname(dir)) === '.husky' ? dirname(dir) : dir;
33
+ }
34
+ /** Agents whose home config dir exists. */
35
+ export function detectAgents(home = homedir()) {
36
+ return agentNames.filter((n) => existsSync(join(home, agents[n].homeDir)));
37
+ }
38
+ function readSettings(path) {
39
+ if (!existsSync(path))
40
+ return {};
41
+ try {
42
+ return JSON.parse(readFileSync(path, 'utf8'));
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
49
+ /** Settings must be an object; the hook events cto writes must be arrays if present. Checked before anything is written. */
50
+ function validShape(json, name) {
51
+ if (!isObj(json))
52
+ return false;
53
+ if (json.hooks === undefined)
54
+ return true;
55
+ if (!isObj(json.hooks))
56
+ return false;
57
+ const a = agents[name];
58
+ return [a.pre, a.post].every((spec) => !spec || json.hooks[spec.event] === undefined || Array.isArray(json.hooks[spec.event]));
59
+ }
60
+ function writeOrRemove(path, settings) {
61
+ const meaningful = Object.keys(settings).filter((k) => k !== 'version');
62
+ if (meaningful.length)
63
+ return writeFileSync(path, JSON.stringify(settings, null, 2) + '\n');
64
+ unlinkSync(path);
65
+ try {
66
+ if (!readdirSync(dirname(path)).length)
67
+ rmdirSync(dirname(path));
68
+ }
69
+ catch { /* keep dir */ }
70
+ }
71
+ function installAgent(settings, name) {
72
+ const a = agents[name];
73
+ settings.hooks ??= {};
74
+ if (a.style === 'cursor')
75
+ settings.version ??= 1;
76
+ for (const phase of ['pre', 'post']) {
77
+ const spec = a[phase];
78
+ if (!spec)
79
+ continue;
80
+ const command = hookCommand(name, phase);
81
+ const arr = (settings.hooks[spec.event] ??= []);
82
+ if (a.style === 'cursor') {
83
+ if (!arr.some((h) => h?.command === command))
84
+ arr.push({ command });
85
+ }
86
+ else if (!arr.some((m) => m?.hooks?.some((h) => h?.command === command))) {
87
+ arr.push({ ...(spec.matcher && { matcher: spec.matcher }), hooks: [{ type: 'command', command }] });
88
+ }
89
+ }
90
+ }
91
+ /** Remove every cto hook from a settings object (both styles). Returns whether anything changed. */
92
+ function uninstallHooks(settings) {
93
+ if (!settings.hooks || typeof settings.hooks !== 'object')
94
+ return false;
95
+ let touched = false;
96
+ for (const [event, arr] of Object.entries(settings.hooks)) {
97
+ if (!Array.isArray(arr))
98
+ continue;
99
+ const kept = arr.flatMap((m) => {
100
+ if (OURS(m?.command)) {
101
+ touched = true;
102
+ return [];
103
+ } // cursor style
104
+ if (!Array.isArray(m?.hooks) || !m.hooks.some((h) => OURS(h?.command)))
105
+ return [m];
106
+ touched = true;
107
+ const hooks = m.hooks.filter((h) => !OURS(h?.command));
108
+ return hooks.length ? [{ ...m, hooks }] : [];
109
+ });
110
+ if (kept.length)
111
+ settings.hooks[event] = kept;
112
+ else
113
+ delete settings.hooks[event];
114
+ }
115
+ if (touched && !Object.keys(settings.hooks).length)
116
+ delete settings.hooks;
117
+ return touched;
118
+ }
119
+ export function setup(cwd, lang, opts = {}) {
120
+ const lines = [];
121
+ let root;
122
+ try {
123
+ root = git(cwd, 'rev-parse', '--show-toplevel');
124
+ }
125
+ catch {
126
+ return { code: 1, lines: [t(lang, 'setup_not_git')] };
127
+ }
128
+ const rel = (p) => relative(root, p) || p;
129
+ // Validate every config file we may touch before writing anything.
130
+ const targets = opts.uninstall ? agentNames : (opts.agents ?? []);
131
+ const settings = new Map();
132
+ for (const name of targets) {
133
+ const path = join(root, agents[name].settings);
134
+ const json = readSettings(path);
135
+ if (json === null || !validShape(json, name))
136
+ return { code: 1, lines: [t(lang, 'setup_bad_json', { path: rel(path) })] };
137
+ settings.set(name, { path, json });
138
+ }
139
+ const hookPath = join(hooksDir(root), 'pre-commit');
140
+ const ignorePath = join(root, '.gitignore');
141
+ if (opts.uninstall) {
142
+ if (existsSync(hookPath)) {
143
+ const c = readFileSync(hookPath, 'utf8');
144
+ if (blockRe.test(c)) {
145
+ const rest = removeBlock(c);
146
+ if (!rest.trim() || rest.trim() === '#!/bin/sh')
147
+ unlinkSync(hookPath);
148
+ else
149
+ writeFileSync(hookPath, rest);
150
+ lines.push(t(lang, 'uninstall_hook', { path: rel(hookPath) }));
151
+ }
152
+ }
153
+ for (const [name, { path, json }] of settings) {
154
+ if (!existsSync(path) || !uninstallHooks(json))
155
+ continue;
156
+ writeOrRemove(path, json);
157
+ lines.push(t(lang, 'uninstall_agent', { label: agents[name].label, path: rel(path) }));
158
+ }
159
+ if (existsSync(ignorePath)) {
160
+ const c = readFileSync(ignorePath, 'utf8');
161
+ if (blockRe.test(c)) {
162
+ const rest = removeBlock(c);
163
+ if (rest.trim())
164
+ writeFileSync(ignorePath, rest);
165
+ else
166
+ unlinkSync(ignorePath);
167
+ lines.push(t(lang, 'uninstall_gitignore'));
168
+ }
169
+ }
170
+ lines.push(t(lang, 'uninstall_done'));
171
+ return { code: 0, lines };
172
+ }
173
+ // 1. git pre-commit: covers every agent and humans alike. Append a marked block, never overwrite.
174
+ if (existsSync(hookPath)) {
175
+ const c = readFileSync(hookPath, 'utf8');
176
+ writeFileSync(hookPath, appendBlock(c, HOOK_BODY));
177
+ if (hasEarlyExit(c))
178
+ lines.push(t(lang, 'setup_hook_exit', { path: rel(hookPath) }));
179
+ }
180
+ else {
181
+ mkdirSync(dirname(hookPath), { recursive: true });
182
+ writeFileSync(hookPath, '#!/bin/sh\n' + block(HOOK_BODY));
183
+ }
184
+ chmodSync(hookPath, 0o755);
185
+ lines.push(t(lang, 'setup_hook', { path: rel(hookPath) }));
186
+ // 2. Agent hooks: merge, keep everything else.
187
+ for (const [name, { path, json }] of settings) {
188
+ installAgent(json, name);
189
+ mkdirSync(dirname(path), { recursive: true });
190
+ writeFileSync(path, JSON.stringify(json, null, 2) + '\n');
191
+ lines.push(t(lang, 'setup_agent', { label: agents[name].label, path: rel(path) }));
192
+ const note = agents[name].note;
193
+ if (note)
194
+ lines.push(t(lang, note));
195
+ }
196
+ // 3. .gitignore block.
197
+ writeFileSync(ignorePath, appendBlock(existsSync(ignorePath) ? readFileSync(ignorePath, 'utf8') : '', IGNORE_BODY));
198
+ lines.push(t(lang, 'setup_gitignore'));
199
+ // 4. Shared config, created once and never removed.
200
+ const cfgPath = join(root, '.cto.json');
201
+ let goal = '';
202
+ if (!existsSync(cfgPath)) {
203
+ writeFileSync(cfgPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + '\n');
204
+ lines.push(t(lang, 'setup_config'));
205
+ }
206
+ else {
207
+ try {
208
+ goal = JSON.parse(readFileSync(cfgPath, 'utf8')).sprint_goal ?? '';
209
+ }
210
+ catch { /* keep empty */ }
211
+ }
212
+ if (!String(goal).trim())
213
+ lines.push(t(lang, 'setup_sprint'));
214
+ lines.push(t(lang, 'setup_done'));
215
+ return { code: 0, lines };
216
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};