@anhvupt/tito 0.3.4 → 0.4.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.
@@ -0,0 +1,303 @@
1
+ import { BRANCH_BASES } from "./git-flow.js";
2
+ const ALLOW = { permission: "allow" };
3
+ const BASES = new Set(BRANCH_BASES);
4
+ function isBase(name) {
5
+ return BASES.has(name);
6
+ }
7
+ function rank(verdict) {
8
+ return verdict.permission === "deny" ? 2 : verdict.permission === "ask" ? 1 : 0;
9
+ }
10
+ function ask(message) {
11
+ return { permission: "ask", user_message: message, agent_message: message };
12
+ }
13
+ function deny(message) {
14
+ return { permission: "deny", user_message: message, agent_message: message };
15
+ }
16
+ function unquote(token) {
17
+ if (token.length >= 2) {
18
+ const start = token[0];
19
+ const end = token[token.length - 1];
20
+ if ((start === '"' || start === "'") && start === end)
21
+ return token.slice(1, -1);
22
+ }
23
+ return token;
24
+ }
25
+ function tokenize(segment) {
26
+ const toks = [];
27
+ let cur = "";
28
+ let quote = null;
29
+ const flush = () => {
30
+ if (cur.length === 0)
31
+ return;
32
+ toks.push(unquote(cur));
33
+ cur = "";
34
+ };
35
+ for (const ch of segment) {
36
+ if (quote !== null) {
37
+ cur += ch;
38
+ if (ch === quote)
39
+ quote = null;
40
+ continue;
41
+ }
42
+ if (ch === '"' || ch === "'") {
43
+ quote = ch;
44
+ cur += ch;
45
+ continue;
46
+ }
47
+ if (ch === " " || ch === "\t") {
48
+ flush();
49
+ continue;
50
+ }
51
+ cur += ch;
52
+ }
53
+ flush();
54
+ return toks;
55
+ }
56
+ export function splitCommand(command) {
57
+ const parts = [];
58
+ let current = "";
59
+ let quote = null;
60
+ const flush = () => {
61
+ const part = current.trim();
62
+ if (part.length > 0)
63
+ parts.push(part);
64
+ current = "";
65
+ };
66
+ for (let i = 0; i < command.length; i += 1) {
67
+ const ch = command[i];
68
+ if (quote !== null) {
69
+ current += ch;
70
+ if (ch === quote)
71
+ quote = null;
72
+ continue;
73
+ }
74
+ if (ch === '"' || ch === "'") {
75
+ quote = ch;
76
+ current += ch;
77
+ continue;
78
+ }
79
+ if (ch === ";") {
80
+ flush();
81
+ continue;
82
+ }
83
+ if (ch === "&" && command[i + 1] === "&") {
84
+ flush();
85
+ i += 1;
86
+ continue;
87
+ }
88
+ if (ch === "|") {
89
+ if (command[i + 1] === "|")
90
+ i += 1;
91
+ flush();
92
+ continue;
93
+ }
94
+ current += ch;
95
+ }
96
+ flush();
97
+ return parts;
98
+ }
99
+ function skipGitOptions(toks, start) {
100
+ let i = start;
101
+ while (i < toks.length) {
102
+ const t = toks[i];
103
+ if (t === "-C" || t === "-c" || t === "--git-dir" || t === "--work-tree") {
104
+ i += 2;
105
+ continue;
106
+ }
107
+ if (t.startsWith("-C") && t.length > 2 ||
108
+ t.startsWith("--git-dir=") ||
109
+ t.startsWith("--work-tree=")) {
110
+ i += 1;
111
+ continue;
112
+ }
113
+ if (t.startsWith("-") && t !== "-" && t !== "--") {
114
+ i += 1;
115
+ continue;
116
+ }
117
+ break;
118
+ }
119
+ return i;
120
+ }
121
+ function refspecDest(spec) {
122
+ const body = spec.startsWith("+") ? spec.slice(1) : spec;
123
+ const colon = body.lastIndexOf(":");
124
+ return colon >= 0 ? body.slice(colon + 1) : body;
125
+ }
126
+ function isForceFlag(token) {
127
+ if (token === "--force" || token.startsWith("--force-with-lease"))
128
+ return true;
129
+ return token.startsWith("-") && !token.startsWith("--") && token.includes("f");
130
+ }
131
+ function pushDests(args) {
132
+ if (args.length === 0)
133
+ return [];
134
+ if (args.length === 1) {
135
+ const one = args[0];
136
+ if (one.includes(":") || one.startsWith("+") || isBase(one))
137
+ return [refspecDest(one)];
138
+ return [];
139
+ }
140
+ return args.slice(1).map(refspecDest);
141
+ }
142
+ function decideGit(toks, currentBranch) {
143
+ const subAt = skipGitOptions(toks, 1);
144
+ const sub = toks[subAt];
145
+ const rest = toks.slice(subAt + 1);
146
+ if (sub === "push") {
147
+ let force = false;
148
+ const args = [];
149
+ for (const token of rest) {
150
+ if (token === "--")
151
+ continue;
152
+ if (token.startsWith("-")) {
153
+ if (isForceFlag(token))
154
+ force = true;
155
+ continue;
156
+ }
157
+ args.push(token);
158
+ }
159
+ if (force)
160
+ return ask("Force-push requires confirmation.");
161
+ const dests = pushDests(args);
162
+ const hit = dests.find((name) => isBase(name));
163
+ if (hit !== undefined)
164
+ return ask(`Push to base branch ${hit} requires confirmation.`);
165
+ if (dests.length === 0 && currentBranch !== undefined && isBase(currentBranch)) {
166
+ return ask(`Push from base branch ${currentBranch} requires confirmation.`);
167
+ }
168
+ return ALLOW;
169
+ }
170
+ if (sub === "merge" && currentBranch !== undefined && isBase(currentBranch)) {
171
+ return ask(`Merge on base branch ${currentBranch} requires confirmation.`);
172
+ }
173
+ return ALLOW;
174
+ }
175
+ function isApproveFlag(token) {
176
+ if (token === "--approve" || token === "-a")
177
+ return true;
178
+ return token.startsWith("-") && !token.startsWith("--") && token.includes("a");
179
+ }
180
+ function decideGh(toks, autoMerge) {
181
+ const pr = toks.indexOf("pr", 1);
182
+ if (pr < 0)
183
+ return ALLOW;
184
+ const rest = toks.slice(pr + 1);
185
+ if (rest.includes("review") && rest.some(isApproveFlag)) {
186
+ return deny("Tito never approves a pull request.");
187
+ }
188
+ if (rest.includes("merge")) {
189
+ return autoMerge ? ALLOW : ask("Merging a pull request requires confirmation.");
190
+ }
191
+ return ALLOW;
192
+ }
193
+ function decideNpm(toks) {
194
+ let i = 1;
195
+ while (i < toks.length && toks[i].startsWith("-"))
196
+ i += 1;
197
+ if (toks[i] === "publish")
198
+ return ask("npm publish requires confirmation.");
199
+ return ALLOW;
200
+ }
201
+ function decideSegment(segment, currentBranch, autoMerge) {
202
+ const toks = tokenize(segment);
203
+ const cmd = toks[0];
204
+ if (cmd === "git")
205
+ return decideGit(toks, currentBranch);
206
+ if (cmd === "gh")
207
+ return decideGh(toks, autoMerge);
208
+ if (cmd === "npm")
209
+ return decideNpm(toks);
210
+ return ALLOW;
211
+ }
212
+ export function decideShell(command, currentBranch, autoMerge) {
213
+ const mergeOn = autoMerge === true;
214
+ let best = ALLOW;
215
+ for (const segment of splitCommand(command)) {
216
+ const verdict = decideSegment(segment, currentBranch, mergeOn);
217
+ if (rank(verdict) > rank(best))
218
+ best = verdict;
219
+ }
220
+ return best;
221
+ }
222
+ const MCP_GUARDED = new Set([
223
+ "git_push",
224
+ "git_branch",
225
+ "merge_pull_request",
226
+ "create_pull_request_review",
227
+ "npm_publish",
228
+ ]);
229
+ function parseMcpInput(raw) {
230
+ try {
231
+ const parsed = JSON.parse(raw);
232
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
233
+ return null;
234
+ return parsed;
235
+ }
236
+ catch {
237
+ return null;
238
+ }
239
+ }
240
+ function fieldString(body, key) {
241
+ const value = body[key];
242
+ return typeof value === "string" ? value : undefined;
243
+ }
244
+ function pushedRefs(body) {
245
+ const names = [];
246
+ for (const key of ["branch", "ref", "dest", "target"]) {
247
+ const value = fieldString(body, key);
248
+ if (value !== undefined)
249
+ names.push(value);
250
+ }
251
+ if (Array.isArray(body.refs)) {
252
+ for (const item of body.refs)
253
+ if (typeof item === "string")
254
+ names.push(item);
255
+ }
256
+ return names;
257
+ }
258
+ function decideGitPush(body, currentBranch) {
259
+ let best = ALLOW;
260
+ const force = body.force === true || body.forceWithLease === true || body.force_with_lease === true;
261
+ if (force)
262
+ best = ask("Force-push requires confirmation.");
263
+ const dests = pushedRefs(body);
264
+ const hit = dests.find((name) => isBase(name));
265
+ if (hit !== undefined) {
266
+ const next = ask(`Push to base branch ${hit} requires confirmation.`);
267
+ if (rank(next) > rank(best))
268
+ best = next;
269
+ }
270
+ if (dests.length === 0 && isBase(currentBranch)) {
271
+ const next = ask(`Push from base branch ${currentBranch} requires confirmation.`);
272
+ if (rank(next) > rank(best))
273
+ best = next;
274
+ }
275
+ return best;
276
+ }
277
+ export function decideMcp(input) {
278
+ if (!MCP_GUARDED.has(input.toolName))
279
+ return ALLOW;
280
+ const body = parseMcpInput(input.toolInput);
281
+ if (body === null)
282
+ return ask("Could not read MCP tool input.");
283
+ if (input.toolName === "npm_publish" || body.command === "npm publish") {
284
+ return ask("npm publish requires confirmation.");
285
+ }
286
+ if (input.toolName === "merge_pull_request") {
287
+ return input.autoMerge ? ALLOW : ask("Merging a pull request requires confirmation.");
288
+ }
289
+ if (input.toolName === "create_pull_request_review") {
290
+ const event = fieldString(body, "event") ?? fieldString(body, "state");
291
+ if (event !== undefined && event.toUpperCase() === "APPROVE") {
292
+ return deny("Tito never approves a pull request.");
293
+ }
294
+ return ALLOW;
295
+ }
296
+ if (input.toolName === "git_branch") {
297
+ if (fieldString(body, "action") === "merge" && isBase(input.currentBranch)) {
298
+ return ask(`Merge on base branch ${input.currentBranch} requires confirmation.`);
299
+ }
300
+ return ALLOW;
301
+ }
302
+ return decideGitPush(body, input.currentBranch);
303
+ }
package/dist/core/init.js CHANGED
@@ -30,12 +30,14 @@ export const SCREEN_LANGUAGE_PROMPT = "Screen language (vi, en): ";
30
30
  export const TENANCY_PROMPT = "Tenancy (single, multi): ";
31
31
  export const SURFACES_PROMPT = "Surfaces (comma-separated ids): ";
32
32
  export const AUTO_PULL_REQUEST_PROMPT = "Auto-create a pull request after implementation? [Y/n] ";
33
+ export const AUTO_MERGE_PROMPT = "Auto-merge a slice pull request after checks pass? [y/N] ";
33
34
  export const INIT_SETTING_KEYS = [
34
35
  "defaultBase",
35
36
  "screenLanguage",
36
37
  "tenancy",
37
38
  "surfaces",
38
39
  "autoPullRequest",
40
+ "autoMerge",
39
41
  ];
40
42
  async function askOneOf(ask, prompt, allowed) {
41
43
  for (;;) {
@@ -75,6 +77,15 @@ async function askAutoPullRequest(ask) {
75
77
  return false;
76
78
  }
77
79
  }
80
+ async function askAutoMerge(ask) {
81
+ for (;;) {
82
+ const answer = (await ask(AUTO_MERGE_PROMPT)).trim().toLowerCase();
83
+ if (answer === "y" || answer === "yes")
84
+ return true;
85
+ if (answer === "" || answer === "n" || answer === "no")
86
+ return false;
87
+ }
88
+ }
78
89
  export async function promptForSettings(ask, keys) {
79
90
  const answers = {};
80
91
  for (const key of keys) {
@@ -96,7 +107,11 @@ export async function promptForSettings(ask, keys) {
96
107
  answers.surfaces = surfaces;
97
108
  continue;
98
109
  }
99
- answers.autoPullRequest = await askAutoPullRequest(ask);
110
+ if (key === "autoPullRequest") {
111
+ answers.autoPullRequest = await askAutoPullRequest(ask);
112
+ continue;
113
+ }
114
+ answers.autoMerge = await askAutoMerge(ask);
100
115
  }
101
116
  return answers;
102
117
  }
@@ -108,6 +123,9 @@ export function titoYamlBody(profile, settings) {
108
123
  if (settings.autoPullRequest !== undefined) {
109
124
  gitLines.push(` autoPullRequest: ${settings.autoPullRequest}`);
110
125
  }
126
+ if (settings.autoMerge !== undefined) {
127
+ gitLines.push(` autoMerge: ${settings.autoMerge}`);
128
+ }
111
129
  if (gitLines.length > 0)
112
130
  lines.push("git:", ...gitLines);
113
131
  const productLines = [];
@@ -137,12 +155,35 @@ function exists(root, path) {
137
155
  }
138
156
  export function shippedSkills() {
139
157
  const root = join(dirname(fileURLToPath(import.meta.url)), "../../templates/cursor/skills");
140
- return readdirSync(root, { withFileTypes: true })
141
- .filter((entry) => entry.isDirectory())
142
- .map((entry) => ({
143
- path: `.cursor/skills/${entry.name}/SKILL.md`,
144
- body: readFileSync(join(root, entry.name, "SKILL.md"), "utf8"),
145
- }));
158
+ const files = [];
159
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
160
+ if (!entry.isDirectory())
161
+ continue;
162
+ files.push({
163
+ path: `.cursor/skills/${entry.name}/SKILL.md`,
164
+ body: readFileSync(join(root, entry.name, "SKILL.md"), "utf8"),
165
+ });
166
+ const referencesRoot = join(root, entry.name, "references");
167
+ let names = [];
168
+ try {
169
+ if (!statSync(referencesRoot).isDirectory())
170
+ continue;
171
+ names = readdirSync(referencesRoot, { withFileTypes: true })
172
+ .filter((item) => item.isFile() && item.name.endsWith(".md"))
173
+ .map((item) => item.name)
174
+ .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
175
+ }
176
+ catch {
177
+ continue;
178
+ }
179
+ for (const name of names) {
180
+ files.push({
181
+ path: `.cursor/skills/${entry.name}/references/${name}`,
182
+ body: readFileSync(join(referencesRoot, name), "utf8"),
183
+ });
184
+ }
185
+ }
186
+ return files;
146
187
  }
147
188
  export function shippedCursorRules() {
148
189
  const root = join(dirname(fileURLToPath(import.meta.url)), "../../templates/cursor/rules");
@@ -155,6 +196,73 @@ export function shippedCursorRules() {
155
196
  body: readFileSync(join(root, name), "utf8"),
156
197
  }));
157
198
  }
199
+ export const CURSOR_HOOKS_PATH = ".cursor/hooks.json";
200
+ export function shippedCursorHooks() {
201
+ return readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../templates/cursor/hooks.json"), "utf8");
202
+ }
203
+ function hookCommandHas(value, needle) {
204
+ if (Array.isArray(value))
205
+ return value.some((item) => hookCommandHas(item, needle));
206
+ if (value === null || typeof value !== "object")
207
+ return false;
208
+ const record = value;
209
+ return ((typeof record.command === "string" && record.command.includes(needle)) ||
210
+ Object.values(record).some((item) => hookCommandHas(item, needle)));
211
+ }
212
+ function shippedHookEntries() {
213
+ const shipped = JSON.parse(shippedCursorHooks());
214
+ const rows = [];
215
+ for (const [event, list] of Object.entries(shipped.hooks ?? {})) {
216
+ if (!Array.isArray(list))
217
+ continue;
218
+ for (const entry of list) {
219
+ const command = entry !== null && typeof entry === "object"
220
+ ? entry.command
221
+ : undefined;
222
+ if (typeof command !== "string")
223
+ continue;
224
+ const at = command.indexOf("tito hook ");
225
+ if (at < 0)
226
+ continue;
227
+ rows.push({ event, entry, needle: command.slice(at) });
228
+ }
229
+ }
230
+ return rows;
231
+ }
232
+ export function planCursorHooks(root) {
233
+ if (!exists(root, CURSOR_HOOKS_PATH)) {
234
+ return { path: CURSOR_HOOKS_PATH, action: "create", body: shippedCursorHooks() };
235
+ }
236
+ try {
237
+ const parsed = JSON.parse(readFileSync(join(root, CURSOR_HOOKS_PATH), "utf8"));
238
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
239
+ throw new Error();
240
+ const body = parsed;
241
+ const hooks = body.hooks !== null && typeof body.hooks === "object" && !Array.isArray(body.hooks)
242
+ ? { ...body.hooks }
243
+ : {};
244
+ let changed = false;
245
+ for (const { event, entry, needle } of shippedHookEntries()) {
246
+ if (hookCommandHas(body, needle))
247
+ continue;
248
+ const current = hooks[event];
249
+ const list = Array.isArray(current) ? [...current] : [];
250
+ list.push(entry);
251
+ hooks[event] = list;
252
+ changed = true;
253
+ }
254
+ if (!changed)
255
+ return { path: CURSOR_HOOKS_PATH, action: "keep" };
256
+ return {
257
+ path: CURSOR_HOOKS_PATH,
258
+ action: "append",
259
+ body: `${JSON.stringify({ ...body, hooks }, null, 2)}\n`,
260
+ };
261
+ }
262
+ catch {
263
+ return { path: CURSOR_HOOKS_PATH, action: "conflict" };
264
+ }
265
+ }
158
266
  function agentsBootstrap(report) {
159
267
  if (report.agentsMd === "absent") {
160
268
  return { path: "AGENTS.md", action: "create", body: titoBootstrapBlock() };
@@ -201,7 +309,7 @@ export function planInitialization(report, profile, settings) {
201
309
  return {
202
310
  root: adoption.root,
203
311
  profile: adoption.profile,
204
- files: [...files, ...agents, ...skills, pullRequest, ...rules],
312
+ files: [...files, ...agents, ...skills, pullRequest, ...rules, planCursorHooks(report.root)],
205
313
  };
206
314
  }
207
315
  export function formatInitialization(plan) {
@@ -224,6 +332,10 @@ export function applyInitialization(plan) {
224
332
  for (const file of plan.files) {
225
333
  if (file.action === "append") {
226
334
  const absolute = join(plan.root, file.path);
335
+ if (file.path === CURSOR_HOOKS_PATH) {
336
+ writeFileSync(absolute, file.body, "utf8");
337
+ continue;
338
+ }
227
339
  const current = readFileSync(absolute, "utf8");
228
340
  const separator = current.endsWith("\n") ? "\n" : "\n\n";
229
341
  writeFileSync(absolute, `${current}${separator}${file.body}`, "utf8");
@@ -10,6 +10,8 @@ export const SPECIALIST_IDS = [
10
10
  "security-reviewer",
11
11
  "tech-docs-writer",
12
12
  "user-docs-writer",
13
+ "cleaner",
14
+ "startup",
13
15
  ];
14
16
  export const WRITER_SPECIALIST_IDS = [
15
17
  "backend-engineer",
@@ -17,6 +19,8 @@ export const WRITER_SPECIALIST_IDS = [
17
19
  "devops-engineer",
18
20
  "tech-docs-writer",
19
21
  "user-docs-writer",
22
+ "cleaner",
23
+ "startup",
20
24
  ];
21
25
  export const READ_ONLY_SPECIALIST_IDS = [
22
26
  "explorer",
@@ -28,16 +32,18 @@ export const READ_ONLY_SPECIALIST_IDS = [
28
32
  ];
29
33
  export const SPECIALIST_MODEL_ROLES = Object.freeze({
30
34
  explorer: "explorer",
31
- "product-analyst": "planner",
35
+ "product-analyst": "advisor",
32
36
  architect: "planner",
33
37
  "backend-engineer": "implementer",
34
38
  "frontend-engineer": "implementer",
35
39
  "devops-engineer": "implementer",
36
- "erp-specialist": "planner",
40
+ "erp-specialist": "advisor",
37
41
  "qa-reviewer": "reviewer",
38
42
  "security-reviewer": "planner",
39
43
  "tech-docs-writer": "finisher",
40
44
  "user-docs-writer": "finisher",
45
+ cleaner: "finisher",
46
+ startup: "finisher",
41
47
  });
42
48
  const phase = Object.freeze({
43
49
  discovery: ["discovery"],
@@ -178,6 +184,24 @@ export const SPECIALISTS = Object.freeze({
178
184
  modes: [specialistMode("update", "write-files", phase.implementation)],
179
185
  stopCondition: "Apply the plan's Docs impact (update named docs, or record a waiver) before a pull request. The module-end pass still covers larger docs. Stop after that docs work matches the plan.",
180
186
  }),
187
+ cleaner: manifest("cleaner", {
188
+ tier: "Scout",
189
+ role: "Cleanup specialist",
190
+ triggers: ["leftover files", "unused local artifacts", "dead code cleanup"],
191
+ knowledge: [knowledge("cleanup-safety", "always")],
192
+ modes: [specialistMode("implement", "write-files", phase.implementation)],
193
+ stopCondition: "Stop after the approved cleanup slice is verified. Do not delete remote branches, production data, secrets, or the main checkout. Do not rebase.",
194
+ }),
195
+ startup: manifest("startup", {
196
+ tier: "Scout",
197
+ role: "Local startup",
198
+ triggers: ["local infra", "start apps", "pending migrations"],
199
+ knowledge: [knowledge("local-startup", "task")],
200
+ modes: [
201
+ specialistMode("start", "write-infrastructure", phase.implementation, ["human-approval"]),
202
+ ],
203
+ stopCondition: "Stop after missing local processes are started. Ask before applying pending migrations. Local only. Do not migrate remote or production databases. Do not docker compose down -v. Do not drop data.",
204
+ }),
181
205
  });
182
206
  export class DelegationError extends Error {
183
207
  code;
@@ -4,7 +4,7 @@ import { parseDocument } from "yaml";
4
4
  import { compiledCursorAgents } from "./agents.js";
5
5
  import { ConfigValidationError, DEFAULT_MODELS, MODEL_ROLES, parseConfig, } from "./config.js";
6
6
  import { PULL_REQUEST_TEMPLATE_PATH, pullRequestTemplate } from "./git-flow.js";
7
- import { shippedCursorRules, shippedSkills, titoYamlBody, } from "./init.js";
7
+ import { planCursorHooks, shippedCursorRules, shippedSkills, titoYamlBody, } from "./init.js";
8
8
  import { TITO_BOOTSTRAP_END, TITO_BOOTSTRAP_START, titoBootstrapBlock, } from "./plan.js";
9
9
  function readIfFile(root, path) {
10
10
  try {
@@ -59,6 +59,10 @@ export function planUpgrade(root, ownedFiles) {
59
59
  ? { path: "AGENTS.md", action: "keep" }
60
60
  : { path: "AGENTS.md", action: "replace", body: next });
61
61
  }
62
+ const hooks = planCursorHooks(root);
63
+ files.push(hooks.action === "create" || hooks.action === "append"
64
+ ? { path: hooks.path, action: hooks.action, body: hooks.body }
65
+ : { path: hooks.path, action: hooks.action });
62
66
  return files;
63
67
  }
64
68
  function readConfigText(text) {
@@ -114,6 +118,8 @@ export function missingSettings(config) {
114
118
  missing.push("surfaces");
115
119
  if (config.git?.autoPullRequest === undefined)
116
120
  missing.push("autoPullRequest");
121
+ if (config.git?.autoMerge === undefined)
122
+ missing.push("autoMerge");
117
123
  return missing;
118
124
  }
119
125
  export function mergeSettings(yamlText, answers) {
@@ -134,6 +140,13 @@ export function mergeSettings(yamlText, answers) {
134
140
  settings.autoPullRequest = answers.autoPullRequest;
135
141
  changed = true;
136
142
  }
143
+ const existingMerge = config.git?.autoMerge;
144
+ if (existingMerge !== undefined)
145
+ settings.autoMerge = existingMerge;
146
+ else if (answers.autoMerge !== undefined) {
147
+ settings.autoMerge = answers.autoMerge;
148
+ changed = true;
149
+ }
137
150
  const existingLanguage = config.product?.screenLanguage;
138
151
  if (existingLanguage !== undefined)
139
152
  settings.screenLanguage = existingLanguage;
@@ -0,0 +1,81 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export const LOCKED_MODEL_ID = "composer-2.5";
4
+ export const USAGE_BASELINE_PATH = "evals/usage-baseline.json";
5
+ export function overBudget(actual, baseline) {
6
+ return actual > baseline * 1.25;
7
+ }
8
+ function copyUsage(usage) {
9
+ const copied = {
10
+ inputTokens: usage.inputTokens,
11
+ outputTokens: usage.outputTokens,
12
+ cacheReadTokens: usage.cacheReadTokens,
13
+ cacheWriteTokens: usage.cacheWriteTokens,
14
+ totalTokens: usage.totalTokens,
15
+ };
16
+ return usage.reasoningTokens === undefined ? copied : { ...copied, reasoningTokens: usage.reasoningTokens };
17
+ }
18
+ export async function buildUsageRecord(input) {
19
+ if (input.result.usage === undefined)
20
+ return { ok: false, failure: "missing-usage" };
21
+ let cost = null;
22
+ try {
23
+ cost = (await input.getUsage()).cost ?? null;
24
+ }
25
+ catch {
26
+ cost = null;
27
+ }
28
+ return {
29
+ ok: true,
30
+ record: {
31
+ scenario: input.scenario,
32
+ model: input.model,
33
+ usage: copyUsage(input.result.usage),
34
+ cost,
35
+ requestId: input.result.requestId,
36
+ status: input.result.status,
37
+ },
38
+ };
39
+ }
40
+ export function persistOutcome(root, outcome) {
41
+ if (!outcome.ok)
42
+ return;
43
+ const dir = join(root, ".tito", "evals", "usage");
44
+ mkdirSync(dir, { recursive: true });
45
+ writeFileSync(join(dir, `${outcome.record.scenario}.json`), `${JSON.stringify(outcome.record, null, 2)}\n`);
46
+ }
47
+ export function baselineFromRecords(records) {
48
+ const scenarios = {};
49
+ for (const record of records)
50
+ scenarios[record.scenario] = { totalTokens: record.usage.totalTokens };
51
+ return { model: LOCKED_MODEL_ID, scenarios };
52
+ }
53
+ export function writeBaseline(root, baseline) {
54
+ mkdirSync(join(root, "evals"), { recursive: true });
55
+ writeFileSync(join(root, USAGE_BASELINE_PATH), `${JSON.stringify(baseline, null, 2)}\n`);
56
+ }
57
+ export function thresholdFailures(records, baseline) {
58
+ const failures = [];
59
+ for (const record of records) {
60
+ const expected = baseline.scenarios[record.scenario]?.totalTokens;
61
+ if (expected === undefined)
62
+ failures.push(`${record.scenario}: missing-baseline`);
63
+ else if (overBudget(record.usage.totalTokens, expected))
64
+ failures.push(`${record.scenario}: over-budget`);
65
+ }
66
+ return failures;
67
+ }
68
+ export function evalExitCode(input) {
69
+ if (input.missingUsage)
70
+ return 1;
71
+ if (input.record)
72
+ return 0;
73
+ return input.overBudget ? 1 : 0;
74
+ }
75
+ export function nodeVersionTooOld(version) {
76
+ const [major, minor] = version.split(".").map((part) => Number(part));
77
+ return (major ?? 0) < 22 || ((major ?? 0) === 22 && (minor ?? 0) < 13);
78
+ }
79
+ export function missingCursorApiKey(apiKey) {
80
+ return apiKey === undefined || apiKey.trim() === "";
81
+ }