your-cto-jev 0.1.0 → 0.2.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/README.md CHANGED
@@ -31,11 +31,14 @@ Jev judges meaning and answers with probabilities, not prose. It costs about $0.
31
31
  | Signal | Where | What happens |
32
32
  |---|---|---|
33
33
  | `credential_leak` | every `git commit` | **Blocks** the commit |
34
+ | `test_tampering` | `git commit`s that touch tests or test config | **Blocks** the commit |
34
35
  | `destructive_command` | every agent shell command | **Blocks** the command |
35
36
  | `infinite_loop` | agent shell commands, after a recent failure | **Blocks** the command |
36
37
  | `architecture_violation` | `git commit`, once you set a sprint goal | Warns only |
37
38
  | `code_complexity` | every `git commit` | Warns only |
38
39
 
40
+ `test_tampering` catches the classic agent shortcut: making a red test green by skipping it, deleting its assertions, loosening the expectation, or lowering the coverage bar, instead of fixing the code. It lets through real fixes, new or tighter tests, refactors, and tests removed together with their feature.
41
+
39
42
  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
43
 
41
44
  When nothing crosses a threshold, `cto` prints nothing at all.
@@ -55,7 +58,7 @@ flowchart TD
55
58
  P1 -- "error, cooling down or no key" --> P2{"OpenRouter"}
56
59
  P2 -- "answers" --> J
57
60
  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"]
61
+ J -- "secret, weakened tests,<br/>destructive or loop" --> X["BLOCK<br/>commit exit 1 · tool call exit 2"]
59
62
  J -- "complex or off the sprint goal" --> W["Warn, then allow"]
60
63
  J -- "nothing crosses" --> S["Allow silently"]
61
64
  ```
@@ -155,6 +158,7 @@ Nothing else is uploaded, and nothing is logged unless you set `CTO_DEBUG=1`. Th
155
158
  "sprint_goal": "",
156
159
  "thresholds": {
157
160
  "credential_leak": 0.5,
161
+ "test_tampering": 0.8,
158
162
  "destructive_command": 0.7,
159
163
  "infinite_loop": 0.85,
160
164
  "architecture_violation": 0.85,
@@ -165,7 +169,8 @@ Nothing else is uploaded, and nothing is logged unless you set `CTO_DEBUG=1`. Th
165
169
 
166
170
  - **`sprint_goal`**: fill it in to enable the architecture check, for example `"Ship Stripe billing, no new services"`.
167
171
  - **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.
172
+ - **Calibration**: on 32 hand-written diffs, every one scoring above 0.8 for `test_tampering` really weakened the tests, and no legitimate change scored above 0.37. The 0.8 default caught 13 of 16 weakening diffs; the misses were subtle ones such as a meaningless float tolerance or a skip hidden behind an env var. Lower it to 0.5 to catch 15 of 16, at a higher risk of blocking honest commits.
173
+ - In the same way, `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
174
 
170
175
  `.cto-brain.json` holds personal runtime state and is gitignored.
171
176
 
package/dist/brain.js CHANGED
@@ -8,6 +8,7 @@ export const DEFAULT_CONFIG = {
8
8
  destructive_command: 0.7,
9
9
  infinite_loop: 0.85,
10
10
  architecture_violation: 0.85,
11
+ test_tampering: 0.8,
11
12
  code_complexity: 2,
12
13
  },
13
14
  };
package/dist/hooks.js CHANGED
@@ -32,6 +32,20 @@ const Q = {
32
32
  false: 'A new approach, a fix attempt, or unrelated to the earlier failures',
33
33
  },
34
34
  },
35
+ test_tampering: {
36
+ type: 'noul',
37
+ instructions: 'Does this git diff weaken the test suite to make failures disappear, rather than fixing the code under test? ' +
38
+ 'Weakening includes: skipping or commenting out tests, deleting assertions or whole tests while the code they cover stays, ' +
39
+ 'replacing exact expectations with vague ones, changing an expected value to match current output without changing the code, ' +
40
+ 'swallowing errors the test should check, or lowering coverage thresholds. ' +
41
+ 'Not weakening: fixing source code, adding or tightening tests, refactoring tests without losing checks, ' +
42
+ 'updating expectations together with an intentional source change, removing tests together with the feature they cover, ' +
43
+ 'or a conditional skip with a concrete reason and alternative coverage.',
44
+ criteria: {
45
+ true: 'The diff reduces what the tests verify without a matching intentional code change',
46
+ false: 'The tests verify as much or more, or changed together with the code they cover',
47
+ },
48
+ },
35
49
  code_complexity: {
36
50
  type: 'score',
37
51
  instructions: 'Rate the maintenance cost and structural complexity of this change.',
@@ -62,6 +76,9 @@ function verdict(lang, signal, v, cfg, color) {
62
76
  const s = (fmt, txt) => styleText(fmt, txt, { stream: process.stderr });
63
77
  return [s([color, 'bold'], head), s('dim', detail)];
64
78
  }
79
+ // Test files, snapshots, test runner configs and package.json (test script). Matched on diff headers.
80
+ const TESTISH = /^diff --git a\/.+? b\/(?:.*\/)?(?:(?:tests?|__tests__|spec)\/.*|[^/]*\.(?:test|spec)\.[cm]?[jt]sx?|test_[^/]*\.py|[^/]*_test\.(?:py|go)|[^/]*\.snap|(?:jest|vitest|karma|playwright)\.config\.[^/]*|pytest\.ini|conftest\.py|tox\.ini|package\.json)$/m;
81
+ export const touchesTests = (diff) => TESTISH.test(diff);
65
82
  // ---------- git-commit ----------
66
83
  export async function gitCommit(rawDiff, { root, lang, now }) {
67
84
  const cfg = loadConfig(root);
@@ -76,8 +93,15 @@ export async function gitCommit(rawDiff, { root, lang, now }) {
76
93
  soft.architecture_violation = architectureQ(cfg.sprint_goal.trim());
77
94
  const notices = new Set();
78
95
  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));
96
+ // Credential check on every chunk (no sampling); test check on chunks that touch tests; soft signals on the first chunk only. Max 3 in flight.
97
+ const results = await pool(chunks, 3, (state, i) => evaluate({
98
+ state,
99
+ questions: {
100
+ credential_leak: Q.credential_leak,
101
+ ...(touchesTests(state) && { test_tampering: Q.test_tampering }),
102
+ ...(i === 0 && soft),
103
+ },
104
+ }, ctx));
81
105
  if (results.some((r) => !r.ok))
82
106
  brain.skipped_attempts++;
83
107
  if (results.some((r) => !r.ok && r.reason === 'no_keys'))
@@ -88,12 +112,15 @@ export async function gitCommit(rawDiff, { root, lang, now }) {
88
112
  if (r.ok && r.provider !== primary)
89
113
  notices.add(t(lang, 'using', { name: providers[r.provider].label }));
90
114
  const lines = [];
91
- const leaks = results.flatMap((r) => (r.ok ? [exceeded(r.answers, 'credential_leak', cfg)] : [])).filter((v) => v !== undefined);
92
- if (leaks.length) {
115
+ for (const s of ['credential_leak', 'test_tampering']) {
116
+ const hits = results.flatMap((r) => (r.ok ? [exceeded(r.answers, s, cfg)] : [])).filter((v) => v !== undefined);
117
+ if (!hits.length)
118
+ continue;
93
119
  out.code = 1;
94
- brain.blocked_attempts++;
95
- lines.push(...verdict(lang, 'credential_leak', Math.max(...leaks), cfg, 'red'));
120
+ lines.push(...verdict(lang, s, Math.max(...hits), cfg, 'red'));
96
121
  }
122
+ if (out.code)
123
+ brain.blocked_attempts++;
97
124
  const first = results[0];
98
125
  if (first?.ok) {
99
126
  for (const s of ['architecture_violation', 'code_complexity']) {
package/dist/i18n.js CHANGED
@@ -17,6 +17,7 @@ export const personaDict = {
17
17
  destructive_command: '停手。這個指令有不可逆的破壞性,這次不准執行。',
18
18
  infinite_loop: '同樣的錯誤又來了,你們在鬼打牆燒 Token。這次工具呼叫我擋下了,先去看邏輯。',
19
19
  architecture_violation: '這次變更偏離了 Sprint 目標。我放你過,但這筆帳記著。',
20
+ test_tampering: '程式沒修好,倒先把測試改弱了?Commit 已攔下。去修程式,不是修測試。',
20
21
  code_complexity: '複雜度超標,過度工程化。這次不擋,但趕快重構。',
21
22
  },
22
23
  en: {
@@ -24,6 +25,7 @@ export const personaDict = {
24
25
  destructive_command: 'Hold it. This command is irreversibly destructive. Not running it.',
25
26
  infinite_loop: 'Same error again. You are looping and burning tokens. This tool call is BLOCKED. Fix the logic first.',
26
27
  architecture_violation: 'This change drifts from the sprint goal. Letting it through, but I noticed.',
28
+ test_tampering: 'Weakening the tests instead of fixing the code? Commit BLOCKED. Fix the code, not the tests.',
27
29
  code_complexity: 'Over-engineered. Not blocking this time, but refactor it.',
28
30
  },
29
31
  };
package/dist/index.js CHANGED
@@ -85,6 +85,9 @@ else if (args[0] === 'setup') {
85
85
  try {
86
86
  chosen = await wizard(ask, lang, detectAgents(), (s) => console.log(s), args.includes('--keys'));
87
87
  }
88
+ catch {
89
+ process.exit(130); // Ctrl+C or closed input: stop quietly, nothing has been written yet
90
+ }
88
91
  finally {
89
92
  close();
90
93
  }
package/dist/wizard.js CHANGED
@@ -3,28 +3,57 @@ import { agentNames, agents } from './agents.js';
3
3
  import { probeProvider, providers } from './api.js';
4
4
  import { credentialsPath, loadCredentials, saveCredentials } from './brain.js';
5
5
  import { t } from './i18n.js';
6
- /** Terminal prompt; hidden input echoes nothing (for keys). */
6
+ /**
7
+ * Read a line without echoing it (for keys). Public APIs only: raw mode + our own key handling.
8
+ * Enter submits, Backspace deletes, Ctrl+C aborts; escape sequences such as bracketed paste markers are dropped.
9
+ */
10
+ export function readHidden(q, input = process.stdin, output = process.stdout) {
11
+ return new Promise((resolve, reject) => {
12
+ output.write(q);
13
+ let buf = '';
14
+ const finish = (err) => {
15
+ input.removeListener('data', onData);
16
+ input.setRawMode?.(false);
17
+ input.pause();
18
+ output.write('\n');
19
+ if (err)
20
+ reject(err);
21
+ else
22
+ resolve(buf.trim());
23
+ };
24
+ const onData = (chunk) => {
25
+ const s = String(chunk).replace(/\x1b\[[0-9;]*[~A-Za-z]/g, '');
26
+ for (const ch of s) {
27
+ if (ch === '\r' || ch === '\n')
28
+ return finish();
29
+ if (ch === '\u0003')
30
+ return finish(new Error('aborted'));
31
+ if (ch === '\u007f' || ch === '\b')
32
+ buf = buf.slice(0, -1);
33
+ else if (ch >= ' ')
34
+ buf += ch;
35
+ }
36
+ };
37
+ input.setRawMode?.(true);
38
+ input.setEncoding('utf8');
39
+ input.on('data', onData);
40
+ input.resume();
41
+ });
42
+ }
43
+ /** Terminal prompts. A fresh readline per visible question, so it never fights raw-mode hidden input. */
7
44
  export function terminalAsk() {
8
- const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
9
- const anyRl = rl;
10
- const write = anyRl._writeToOutput.bind(rl);
11
- let muted = false;
12
- anyRl._writeToOutput = (s) => { if (!muted)
13
- write(s); };
14
45
  const ask = async (q, hidden = false) => {
15
- if (!hidden)
16
- return (await rl.question(q)).trim();
17
- process.stdout.write(q);
18
- muted = true;
46
+ if (hidden)
47
+ return readHidden(q);
48
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
19
49
  try {
20
- return (await rl.question('')).trim();
50
+ return (await rl.question(q)).trim();
21
51
  }
22
52
  finally {
23
- muted = false;
24
- process.stdout.write('\n');
53
+ rl.close();
25
54
  }
26
55
  };
27
- return { ask, close: () => rl.close() };
56
+ return { ask, close: () => process.stdin.pause() };
28
57
  }
29
58
  const yes = (ans, def) => (ans ? /^y/i.test(ans) : def);
30
59
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "your-cto-jev",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A sharp-tongued CTO gatekeeper for AI coding agents: blocks leaked secrets in git commits and destructive shell commands in Claude Code, Cursor, Gemini CLI and Codex, using TypeSafe Jev.",
5
5
  "keywords": [
6
6
  "claude-code",