@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.
- package/README.md +37 -9
- package/dist/cli.js +96 -1
- package/dist/core/agents.js +2 -0
- package/dist/core/budgets.js +292 -0
- package/dist/core/config.js +25 -4
- package/dist/core/git-flow.js +10 -8
- package/dist/core/hooks.js +303 -0
- package/dist/core/init.js +120 -8
- package/dist/core/specialists.js +26 -2
- package/dist/core/upgrade.js +14 -1
- package/dist/core/usage-eval.js +81 -0
- package/package.json +4 -2
- package/templates/cursor/AGENTS.md +4 -6
- package/templates/cursor/hooks.json +18 -0
- package/templates/cursor/rules/context-budgets.mdc +22 -0
- package/templates/cursor/rules/delegation.mdc +10 -0
- package/templates/cursor/rules/lifecycle.mdc +10 -0
- package/templates/cursor/rules/model-routing.mdc +12 -0
- package/templates/cursor/rules/risk-and-escalation.mdc +44 -0
- package/templates/cursor/skills/tito/SKILL.md +12 -80
- package/templates/cursor/skills/tito/references/execute.md +39 -0
- package/templates/cursor/skills/tito/references/packets.md +23 -0
- package/templates/cursor/skills/tito-local-startup/SKILL.md +14 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
.
|
|
143
|
-
|
|
144
|
-
|
|
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");
|
package/dist/core/specialists.js
CHANGED
|
@@ -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": "
|
|
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": "
|
|
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;
|
package/dist/core/upgrade.js
CHANGED
|
@@ -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
|
+
}
|