@anhvupt/tito 0.3.1 → 0.3.4
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 +31 -5
- package/dist/cli.js +118 -3
- package/dist/core/agents.js +6 -4
- package/dist/core/config.js +58 -1
- package/dist/core/git-flow.js +71 -0
- package/dist/core/init.js +22 -5
- package/dist/core/models.js +122 -38
- package/dist/core/plan.js +22 -20
- package/dist/core/skills.js +64 -0
- package/dist/core/specialists.js +14 -1
- package/dist/core/test-first.js +95 -0
- package/dist/core/upgrade.js +40 -5
- package/dist/core/work-plan.js +17 -0
- package/dist/core/worktree.js +295 -0
- package/package.json +2 -2
- package/templates/cursor/AGENTS.md +42 -0
- package/templates/cursor/rules/commit-branch.mdc +8 -0
- package/templates/cursor/rules/init-upgrade-admin.mdc +15 -0
- package/templates/cursor/rules/pull-request.mdc +13 -0
- package/templates/cursor/rules/screen-language.mdc +12 -0
- package/templates/cursor/skills/tito/SKILL.md +3 -3
- package/templates/cursor/skills/tito-worktree-tidy/SKILL.md +14 -0
package/dist/core/models.js
CHANGED
|
@@ -12,13 +12,39 @@ const CLASS_ESCALATION = {
|
|
|
12
12
|
core: "Ask the user before selecting a more expensive model.",
|
|
13
13
|
reasoning: "Ask the user before selecting a more expensive model.",
|
|
14
14
|
};
|
|
15
|
+
const GROK_RULE = {
|
|
16
|
+
family: "Grok",
|
|
17
|
+
matchGroups: [(slug) => slug.startsWith("grok-"), (slug) => slug.startsWith("cursor-grok-")],
|
|
18
|
+
pick: "highest",
|
|
19
|
+
};
|
|
20
|
+
const OPUS_RULE = {
|
|
21
|
+
family: "Opus",
|
|
22
|
+
matchGroups: [(slug) => slug.startsWith("claude-opus-")],
|
|
23
|
+
pick: "highest",
|
|
24
|
+
};
|
|
25
|
+
const SONNET_HIGHEST = {
|
|
26
|
+
family: "Sonnet",
|
|
27
|
+
matchGroups: [(slug) => slug.startsWith("claude-sonnet-")],
|
|
28
|
+
pick: "highest",
|
|
29
|
+
};
|
|
30
|
+
const COMPOSER_25 = {
|
|
31
|
+
family: "Composer",
|
|
32
|
+
matchGroups: [(slug) => slug === "composer-2.5"],
|
|
33
|
+
pick: "highest",
|
|
34
|
+
};
|
|
15
35
|
function rulesFor(host, taskClass) {
|
|
16
36
|
if (host === "cursor") {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
37
|
+
if (taskClass === "explore" || taskClass === "docs" || taskClass === "ordinary") {
|
|
38
|
+
return GROK_RULE;
|
|
39
|
+
}
|
|
40
|
+
if (taskClass === "core") {
|
|
41
|
+
return {
|
|
42
|
+
family: "Codex",
|
|
43
|
+
matchGroups: [(slug) => slug.includes("codex"), (slug) => slug.startsWith("composer-")],
|
|
44
|
+
pick: "highest",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return OPUS_RULE;
|
|
22
48
|
}
|
|
23
49
|
if (taskClass === "explore" || taskClass === "docs") {
|
|
24
50
|
return {
|
|
@@ -28,17 +54,39 @@ function rulesFor(host, taskClass) {
|
|
|
28
54
|
};
|
|
29
55
|
}
|
|
30
56
|
if (taskClass === "ordinary") {
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
57
|
+
return SONNET_HIGHEST;
|
|
58
|
+
}
|
|
59
|
+
return OPUS_RULE;
|
|
60
|
+
}
|
|
61
|
+
function rulesForRole(role) {
|
|
62
|
+
switch (role) {
|
|
63
|
+
case "explorer":
|
|
64
|
+
case "sandbox":
|
|
65
|
+
return {
|
|
66
|
+
rule: GROK_RULE,
|
|
67
|
+
tier: "Scout",
|
|
68
|
+
escalation: CLASS_ESCALATION.explore,
|
|
69
|
+
};
|
|
70
|
+
case "test-writer":
|
|
71
|
+
case "implementer":
|
|
72
|
+
return {
|
|
73
|
+
rule: GROK_RULE,
|
|
74
|
+
tier: "Standard",
|
|
75
|
+
escalation: CLASS_ESCALATION.ordinary,
|
|
76
|
+
};
|
|
77
|
+
case "planner":
|
|
78
|
+
return {
|
|
79
|
+
rule: OPUS_RULE,
|
|
80
|
+
tier: "Reasoning",
|
|
81
|
+
escalation: CLASS_ESCALATION.reasoning,
|
|
82
|
+
};
|
|
83
|
+
case "finisher":
|
|
84
|
+
return {
|
|
85
|
+
rule: COMPOSER_25,
|
|
86
|
+
tier: "Scout",
|
|
87
|
+
escalation: CLASS_ESCALATION.docs,
|
|
88
|
+
};
|
|
36
89
|
}
|
|
37
|
-
return {
|
|
38
|
-
family: "Opus",
|
|
39
|
-
matchGroups: [(slug) => slug.startsWith("claude-opus-")],
|
|
40
|
-
pick: "highest",
|
|
41
|
-
};
|
|
42
90
|
}
|
|
43
91
|
function versionTuple(slug) {
|
|
44
92
|
const groups = slug.match(/\d+/g) ?? [];
|
|
@@ -68,35 +116,71 @@ function pickVersion(slugs, pick) {
|
|
|
68
116
|
const direction = pick === "highest" ? 1 : -1;
|
|
69
117
|
return [...slugs].sort((a, b) => compareVersions(versionTuple(a), versionTuple(b)) * direction).at(-1) ?? slugs[0];
|
|
70
118
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
119
|
+
function isFastSlug(slug) {
|
|
120
|
+
return slug.split("-").includes("fast");
|
|
121
|
+
}
|
|
122
|
+
function selectSlug(catalog, rule) {
|
|
123
|
+
const groups = rule.matchGroups.map((matcher) => catalog.filter((slug) => matcher(slug)));
|
|
124
|
+
const nonFastGroups = groups.map((group) => group.filter((slug) => !isFastSlug(slug)));
|
|
125
|
+
const preferNonFast = nonFastGroups.some((group) => group.length > 0);
|
|
126
|
+
const eligible = preferNonFast ? nonFastGroups : groups;
|
|
127
|
+
for (const group of eligible) {
|
|
128
|
+
if (group.length === 0) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const model = pickVersion(group, rule.pick);
|
|
132
|
+
if (preferNonFast) {
|
|
133
|
+
return { model };
|
|
134
|
+
}
|
|
135
|
+
return { model, fast: true };
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
function selectionFor(input) {
|
|
140
|
+
if (input.role !== undefined) {
|
|
141
|
+
return rulesForRole(input.role);
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
rule: rulesFor(input.host, input.taskClass),
|
|
145
|
+
tier: CLASS_TIER[input.taskClass],
|
|
146
|
+
escalation: CLASS_ESCALATION[input.taskClass],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
export function recommendModel(input) {
|
|
150
|
+
const selection = selectionFor(input);
|
|
151
|
+
if (typeof input.userModel === "string" && input.userModel.length > 0) {
|
|
75
152
|
return {
|
|
76
153
|
status: "selected",
|
|
77
|
-
model: userModel,
|
|
78
|
-
tier,
|
|
154
|
+
model: input.userModel,
|
|
155
|
+
tier: selection.tier,
|
|
79
156
|
reason: "Used the explicit model requested by the user.",
|
|
80
|
-
escalation,
|
|
157
|
+
escalation: selection.escalation,
|
|
81
158
|
};
|
|
82
159
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
160
|
+
if (input.role !== undefined && input.host !== "cursor") {
|
|
161
|
+
return {
|
|
162
|
+
status: "ask",
|
|
163
|
+
family: selection.rule.family,
|
|
164
|
+
reason: "Pipeline roles are routed on the Cursor host.",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const picked = selectSlug(input.catalog, selection.rule);
|
|
168
|
+
if (picked === undefined) {
|
|
169
|
+
return {
|
|
170
|
+
status: "ask",
|
|
171
|
+
family: selection.rule.family,
|
|
172
|
+
reason: `No matching ${selection.rule.family} model was found in the current catalog.`,
|
|
173
|
+
};
|
|
96
174
|
}
|
|
175
|
+
const reason = picked.fast
|
|
176
|
+
? `Selected the ${selection.rule.pick} version in the ${selection.rule.family} family. The only match is a -fast slug.`
|
|
177
|
+
: `Selected the ${selection.rule.pick} version in the ${selection.rule.family} family.`;
|
|
97
178
|
return {
|
|
98
|
-
status: "
|
|
99
|
-
|
|
100
|
-
|
|
179
|
+
status: "selected",
|
|
180
|
+
model: picked.model,
|
|
181
|
+
tier: selection.tier,
|
|
182
|
+
reason,
|
|
183
|
+
escalation: selection.escalation,
|
|
184
|
+
...(picked.fast ? { fast: true } : {}),
|
|
101
185
|
};
|
|
102
186
|
}
|
package/dist/core/plan.js
CHANGED
|
@@ -1,26 +1,10 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
1
4
|
import { ACTIVE_RISK_PROFILE_IDS, isActiveRiskProfileId, isFutureRiskProfileId, } from "./profiles.js";
|
|
2
5
|
export const TITO_BOOTSTRAP_START = "<!-- tito:bootstrap start -->";
|
|
3
6
|
export const TITO_BOOTSTRAP_END = "<!-- tito:bootstrap end -->";
|
|
4
|
-
export const ADOPTION_AGENTS_MD =
|
|
5
|
-
|
|
6
|
-
This project uses Tito as its root engineering coordinator.
|
|
7
|
-
|
|
8
|
-
- Start every Tito-coordinated chat response exactly with \`Hola, Tito here!\`
|
|
9
|
-
- Keep that marker out of CLI and machine-readable output.
|
|
10
|
-
- Treat ordinary requests as Tito-coordinated work.
|
|
11
|
-
- Recommend Ask, Plan, or Agent before acting.
|
|
12
|
-
- Preserve uncommitted work and use one code writer at a time.
|
|
13
|
-
- Stop for human approval between reviewable slices.
|
|
14
|
-
- Never commit, push, publish, deploy, or perform irreversible external actions without explicit approval.
|
|
15
|
-
- Use \`/tito\` when Tito coordination is wanted.
|
|
16
|
-
- Plans own technical decisions and include guidance code when useful.
|
|
17
|
-
- Research current official sources and judge them before trusting; adopt maintained tools before building our own; show options with pros and cons for this project.
|
|
18
|
-
- Confirm intent before clarifying. Plans open with Domain (business impact first), then Decisions, Test cases, Docs impact, and Slices and branch.
|
|
19
|
-
- Do not merge a slice branch into \`main\`, \`master\`, \`dev\`, or \`develop\`, on the machine or on the remote, unless the user calls for that merge and the pull request already has an approval. Finishing a slice leaves the branch as it is.
|
|
20
|
-
- When the user comments on an open pull request, Tito updates that pull request's description in the same turn. Review fixes lists every comment, including the earlier ones. A thread reply does not replace that update.
|
|
21
|
-
|
|
22
|
-
Keep this bootstrap compact. Load project documentation only when the task needs it.
|
|
23
|
-
`;
|
|
7
|
+
export const ADOPTION_AGENTS_MD = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../templates/cursor/AGENTS.md"), "utf8");
|
|
24
8
|
export function titoBootstrapBlock() {
|
|
25
9
|
return `${TITO_BOOTSTRAP_START}\n${ADOPTION_AGENTS_MD.trim()}\n${TITO_BOOTSTRAP_END}\n`;
|
|
26
10
|
}
|
|
@@ -66,3 +50,21 @@ export function formatAdoptionPlan(plan) {
|
|
|
66
50
|
}
|
|
67
51
|
return `${lines.join("\n")}\n`;
|
|
68
52
|
}
|
|
53
|
+
function bulletSection(title, lines) {
|
|
54
|
+
const body = lines.length > 0 ? lines.map((line) => `- ${line}`).join("\n") : "-";
|
|
55
|
+
return `## ${title}\n${body}`;
|
|
56
|
+
}
|
|
57
|
+
export function renderImplementationPlan(plan) {
|
|
58
|
+
const cases = plan.testFirst.length > 0
|
|
59
|
+
? plan.testFirst.map((item) => `- \`${item.file}\`: ${item.expectedFailure}`).join("\n")
|
|
60
|
+
: "-";
|
|
61
|
+
return [
|
|
62
|
+
bulletSection("Domain", [plan.domain]),
|
|
63
|
+
bulletSection("Decisions", plan.decisions),
|
|
64
|
+
bulletSection("Test cases", plan.testCases),
|
|
65
|
+
`## Test-first commit\n${cases}`,
|
|
66
|
+
bulletSection("Docs impact", plan.docsImpact),
|
|
67
|
+
bulletSection("Slices and branch", plan.slicesAndBranch),
|
|
68
|
+
"",
|
|
69
|
+
].join("\n\n");
|
|
70
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
function frontMatter(text) {
|
|
5
|
+
const lines = text.split(/\r?\n/);
|
|
6
|
+
if (lines[0] !== "---")
|
|
7
|
+
return null;
|
|
8
|
+
const close = lines.indexOf("---", 1);
|
|
9
|
+
if (close < 1)
|
|
10
|
+
return null;
|
|
11
|
+
return lines.slice(1, close).join("\n");
|
|
12
|
+
}
|
|
13
|
+
function cardFrom(matter) {
|
|
14
|
+
let value;
|
|
15
|
+
try {
|
|
16
|
+
value = parse(matter);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
22
|
+
return null;
|
|
23
|
+
const record = value;
|
|
24
|
+
const nameValue = record.name;
|
|
25
|
+
if (typeof nameValue !== "string" || nameValue.trim() === "")
|
|
26
|
+
return null;
|
|
27
|
+
const name = nameValue.trim();
|
|
28
|
+
const descriptionValue = record.description;
|
|
29
|
+
const description = typeof descriptionValue === "string" ? descriptionValue.trim() : "";
|
|
30
|
+
return {
|
|
31
|
+
name,
|
|
32
|
+
description,
|
|
33
|
+
role: name === "tito" ? "coordinator" : "candidate",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function listSkillCards(dir) {
|
|
37
|
+
let folders;
|
|
38
|
+
try {
|
|
39
|
+
folders = readdirSync(dir, { withFileTypes: true })
|
|
40
|
+
.filter((entry) => entry.isDirectory())
|
|
41
|
+
.map((entry) => entry.name)
|
|
42
|
+
.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
const cards = [];
|
|
48
|
+
for (const folder of folders) {
|
|
49
|
+
let text;
|
|
50
|
+
try {
|
|
51
|
+
text = readFileSync(join(dir, folder, "SKILL.md"), "utf8");
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const matter = frontMatter(text);
|
|
57
|
+
if (matter === null)
|
|
58
|
+
continue;
|
|
59
|
+
const card = cardFrom(matter);
|
|
60
|
+
if (card !== null)
|
|
61
|
+
cards.push(card);
|
|
62
|
+
}
|
|
63
|
+
return cards;
|
|
64
|
+
}
|
package/dist/core/specialists.js
CHANGED
|
@@ -26,6 +26,19 @@ export const READ_ONLY_SPECIALIST_IDS = [
|
|
|
26
26
|
"qa-reviewer",
|
|
27
27
|
"security-reviewer",
|
|
28
28
|
];
|
|
29
|
+
export const SPECIALIST_MODEL_ROLES = Object.freeze({
|
|
30
|
+
explorer: "explorer",
|
|
31
|
+
"product-analyst": "planner",
|
|
32
|
+
architect: "planner",
|
|
33
|
+
"backend-engineer": "implementer",
|
|
34
|
+
"frontend-engineer": "implementer",
|
|
35
|
+
"devops-engineer": "implementer",
|
|
36
|
+
"erp-specialist": "planner",
|
|
37
|
+
"qa-reviewer": "reviewer",
|
|
38
|
+
"security-reviewer": "planner",
|
|
39
|
+
"tech-docs-writer": "finisher",
|
|
40
|
+
"user-docs-writer": "finisher",
|
|
41
|
+
});
|
|
29
42
|
const phase = Object.freeze({
|
|
30
43
|
discovery: ["discovery"],
|
|
31
44
|
planning: ["planning"],
|
|
@@ -46,7 +59,7 @@ function manifest(id, seed) {
|
|
|
46
59
|
role: seed.role,
|
|
47
60
|
model: Object.freeze({
|
|
48
61
|
tier: seed.tier,
|
|
49
|
-
|
|
62
|
+
role: SPECIALIST_MODEL_ROLES[id],
|
|
50
63
|
fallbackPolicy: "ask-user",
|
|
51
64
|
}),
|
|
52
65
|
triggers: Object.freeze([...seed.triggers]),
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
export class TestFirstError extends Error {
|
|
3
|
+
code = "git";
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "TestFirstError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function git(root, args) {
|
|
10
|
+
const result = spawnSync("git", [...args], { cwd: root, encoding: "utf8" });
|
|
11
|
+
if (result.error)
|
|
12
|
+
throw new TestFirstError(result.error.message);
|
|
13
|
+
if (result.status !== 0) {
|
|
14
|
+
const detail = (result.stderr || result.stdout || "git command failed").trim();
|
|
15
|
+
throw new TestFirstError(detail);
|
|
16
|
+
}
|
|
17
|
+
return result.stdout ?? "";
|
|
18
|
+
}
|
|
19
|
+
function normalize(file) {
|
|
20
|
+
return file.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
21
|
+
}
|
|
22
|
+
function hasMarker(base, marker) {
|
|
23
|
+
const index = base.indexOf(marker);
|
|
24
|
+
return index >= 0 && index + marker.length < base.length;
|
|
25
|
+
}
|
|
26
|
+
function isTestFile(file) {
|
|
27
|
+
const path = normalize(file);
|
|
28
|
+
if (path === "test" || path.startsWith("test/"))
|
|
29
|
+
return true;
|
|
30
|
+
const base = path.slice(path.lastIndexOf("/") + 1);
|
|
31
|
+
return hasMarker(base, ".test.") || hasMarker(base, ".spec.");
|
|
32
|
+
}
|
|
33
|
+
function changedFiles(root, sha) {
|
|
34
|
+
const output = git(root, [
|
|
35
|
+
"diff-tree",
|
|
36
|
+
"--no-commit-id",
|
|
37
|
+
"--name-only",
|
|
38
|
+
"-r",
|
|
39
|
+
"-m",
|
|
40
|
+
"--root",
|
|
41
|
+
sha,
|
|
42
|
+
]);
|
|
43
|
+
const files = [];
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
for (const line of output.split("\n")) {
|
|
46
|
+
const file = line.trim();
|
|
47
|
+
if (file.length === 0 || seen.has(file))
|
|
48
|
+
continue;
|
|
49
|
+
seen.add(file);
|
|
50
|
+
files.push(file);
|
|
51
|
+
}
|
|
52
|
+
return files;
|
|
53
|
+
}
|
|
54
|
+
function fail(file, commit, message) {
|
|
55
|
+
return { ok: false, retry: false, file, commit, message };
|
|
56
|
+
}
|
|
57
|
+
export function guardTestFirst(input) {
|
|
58
|
+
const listed = new Set(input.testFiles.map(normalize));
|
|
59
|
+
const commits = git(input.root, ["rev-list", "--reverse", `${input.base}..HEAD`])
|
|
60
|
+
.split("\n")
|
|
61
|
+
.map((line) => line.trim())
|
|
62
|
+
.filter((line) => line.length > 0);
|
|
63
|
+
const first = commits[0];
|
|
64
|
+
if (first === undefined) {
|
|
65
|
+
return fail("(none)", input.base, "The slice branch has no commits after its base.");
|
|
66
|
+
}
|
|
67
|
+
for (const file of changedFiles(input.root, first)) {
|
|
68
|
+
if (!listed.has(normalize(file))) {
|
|
69
|
+
return fail(file, first, `Commit 1 changes ${file}, which is outside the listed test files.`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (let index = 1; index < commits.length; index += 1) {
|
|
73
|
+
const sha = commits[index];
|
|
74
|
+
if (sha === undefined)
|
|
75
|
+
continue;
|
|
76
|
+
for (const file of changedFiles(input.root, sha)) {
|
|
77
|
+
if (!isTestFile(file))
|
|
78
|
+
continue;
|
|
79
|
+
return fail(file, sha, `Commit ${index + 1} changes test file ${file}.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, retry: false };
|
|
83
|
+
}
|
|
84
|
+
export function formatTestFirstReport(report) {
|
|
85
|
+
if (report.ok)
|
|
86
|
+
return "ok: true\nretry: false\n";
|
|
87
|
+
return [
|
|
88
|
+
"ok: false",
|
|
89
|
+
"retry: false",
|
|
90
|
+
`file: ${report.file}`,
|
|
91
|
+
`commit: ${report.commit}`,
|
|
92
|
+
`message: ${report.message}`,
|
|
93
|
+
"",
|
|
94
|
+
].join("\n");
|
|
95
|
+
}
|
package/dist/core/upgrade.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
+
import { parseDocument } from "yaml";
|
|
3
4
|
import { compiledCursorAgents } from "./agents.js";
|
|
4
|
-
import { parseConfig } from "./config.js";
|
|
5
|
+
import { ConfigValidationError, DEFAULT_MODELS, MODEL_ROLES, parseConfig, } from "./config.js";
|
|
5
6
|
import { PULL_REQUEST_TEMPLATE_PATH, pullRequestTemplate } from "./git-flow.js";
|
|
6
|
-
import { shippedSkills, titoYamlBody } from "./init.js";
|
|
7
|
+
import { shippedCursorRules, shippedSkills, titoYamlBody, } from "./init.js";
|
|
7
8
|
import { TITO_BOOTSTRAP_END, TITO_BOOTSTRAP_START, titoBootstrapBlock, } from "./plan.js";
|
|
8
9
|
function readIfFile(root, path) {
|
|
9
10
|
try {
|
|
@@ -41,6 +42,13 @@ export function planUpgrade(root, ownedFiles) {
|
|
|
41
42
|
? { path: file.path, action }
|
|
42
43
|
: { path: file.path, action, body: file.body };
|
|
43
44
|
});
|
|
45
|
+
const yaml = readIfFile(root, "tito.yaml");
|
|
46
|
+
const withModels = yaml === null ? null : addMissingModels(yaml);
|
|
47
|
+
if (yaml !== null && withModels !== null) {
|
|
48
|
+
files.push(withModels === yaml
|
|
49
|
+
? { path: "tito.yaml", action: "keep" }
|
|
50
|
+
: { path: "tito.yaml", action: "replace", body: withModels });
|
|
51
|
+
}
|
|
44
52
|
const agents = readIfFile(root, "AGENTS.md");
|
|
45
53
|
if (agents === null) {
|
|
46
54
|
files.push({ path: "AGENTS.md", action: "create", body: titoBootstrapBlock() });
|
|
@@ -53,10 +61,37 @@ export function planUpgrade(root, ownedFiles) {
|
|
|
53
61
|
}
|
|
54
62
|
return files;
|
|
55
63
|
}
|
|
56
|
-
|
|
64
|
+
function readConfigText(text) {
|
|
65
|
+
try {
|
|
66
|
+
return parseConfig(text);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (error instanceof ConfigValidationError)
|
|
70
|
+
return null;
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function addMissingModels(yamlText) {
|
|
75
|
+
const config = readConfigText(yamlText);
|
|
76
|
+
if (config === null)
|
|
77
|
+
return null;
|
|
78
|
+
const missing = MODEL_ROLES.filter((role) => config.models?.[role] === undefined);
|
|
79
|
+
if (missing.length === 0)
|
|
80
|
+
return yamlText;
|
|
81
|
+
const document = parseDocument(yamlText, { version: "1.2" });
|
|
82
|
+
for (const role of missing)
|
|
83
|
+
document.setIn(["models", role], DEFAULT_MODELS[role]);
|
|
84
|
+
return document.toString();
|
|
85
|
+
}
|
|
86
|
+
export function projectModels(root) {
|
|
87
|
+
const text = readIfFile(root, "tito.yaml");
|
|
88
|
+
return text === null ? undefined : readConfigText(text)?.models;
|
|
89
|
+
}
|
|
90
|
+
export function titoOwnedFiles(models) {
|
|
57
91
|
return [
|
|
58
|
-
...compiledCursorAgents(),
|
|
92
|
+
...compiledCursorAgents(models),
|
|
59
93
|
...shippedSkills(),
|
|
94
|
+
...shippedCursorRules(),
|
|
60
95
|
{ path: PULL_REQUEST_TEMPLATE_PATH, body: pullRequestTemplate() },
|
|
61
96
|
];
|
|
62
97
|
}
|
|
@@ -122,7 +157,7 @@ export function mergeSettings(yamlText, answers) {
|
|
|
122
157
|
}
|
|
123
158
|
if (!changed)
|
|
124
159
|
return yamlText;
|
|
125
|
-
return titoYamlBody(config.profile, settings);
|
|
160
|
+
return titoYamlBody(config.profile, config.models === undefined ? settings : { ...settings, models: config.models });
|
|
126
161
|
}
|
|
127
162
|
export function applyUpgrade(root, files) {
|
|
128
163
|
for (const file of files) {
|
package/dist/core/work-plan.js
CHANGED
|
@@ -269,3 +269,20 @@ export function evaluateWorkPlan(plan, progress) {
|
|
|
269
269
|
}
|
|
270
270
|
return deepFreeze({ readyReadOnly, readyWriterCandidates, blocked });
|
|
271
271
|
}
|
|
272
|
+
const TEST_FIRST_RULE = "Test-first: Commit 1 changes test files only and they fail. Later commits touch no test file.";
|
|
273
|
+
export function buildSliceHandoff(input) {
|
|
274
|
+
const skills = input.skillNames.length === 0
|
|
275
|
+
? "Skills: none match"
|
|
276
|
+
: `Skills: ${input.skillNames.join(", ")}`;
|
|
277
|
+
return [
|
|
278
|
+
`Spec: ${input.specPath}`,
|
|
279
|
+
"Targets:",
|
|
280
|
+
...input.targetFiles,
|
|
281
|
+
"Forbidden:",
|
|
282
|
+
...input.forbiddenFiles,
|
|
283
|
+
`Stop: ${input.stopCondition}`,
|
|
284
|
+
TEST_FIRST_RULE,
|
|
285
|
+
skills,
|
|
286
|
+
"",
|
|
287
|
+
].join("\n");
|
|
288
|
+
}
|