@anhvupt/tito 0.1.3 → 0.3.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,174 @@
1
+ export const BRANCH_TYPES = [
2
+ "feat",
3
+ "fix",
4
+ "hot-fix",
5
+ "chores",
6
+ "refactor",
7
+ "debug",
8
+ ];
9
+ export const BRANCH_BASES = ["dev", "develop", "main", "master"];
10
+ const TYPE_BY_KIND = {
11
+ feature: "feat",
12
+ bug: "fix",
13
+ "production-emergency": "hot-fix",
14
+ tooling: "chores",
15
+ refactor: "refactor",
16
+ investigation: "debug",
17
+ };
18
+ export function branchSlug(text) {
19
+ const slug = text
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9]+/g, "-")
22
+ .replace(/^-+|-+$/g, "")
23
+ .slice(0, 40)
24
+ .replace(/-+$/g, "");
25
+ return slug.length > 0 ? slug : "change";
26
+ }
27
+ function isBranchBase(value) {
28
+ return BRANCH_BASES.includes(value);
29
+ }
30
+ function firstExisting(names, existing, fallback) {
31
+ return names.find((name) => existing.has(name)) ?? fallback;
32
+ }
33
+ export function suggestBranch(input) {
34
+ const type = TYPE_BY_KIND[input.taskKind];
35
+ const existing = new Set(input.existingBases);
36
+ const current = input.currentBranch;
37
+ let base;
38
+ let reason;
39
+ if (type === "hot-fix") {
40
+ base =
41
+ input.defaultBase === "main" || input.defaultBase === "master"
42
+ ? input.defaultBase
43
+ : firstExisting(["main", "master"], existing, "main");
44
+ reason = `A production emergency starts from ${base}.`;
45
+ }
46
+ else if (input.continuesFromCurrent && isBranchBase(current)) {
47
+ base = current;
48
+ reason = `The work continues from ${current}.`;
49
+ }
50
+ else if (input.defaultBase) {
51
+ base = input.defaultBase;
52
+ reason = `The project default base is ${input.defaultBase}.`;
53
+ }
54
+ else {
55
+ base = firstExisting(BRANCH_BASES, existing, "main");
56
+ reason =
57
+ base === "dev" || base === "develop"
58
+ ? `Feature work starts from ${base}.`
59
+ : "This repository has no dev or develop branch.";
60
+ }
61
+ const name = `${type}/${branchSlug(input.slug)}`;
62
+ return {
63
+ type,
64
+ base,
65
+ name,
66
+ reason,
67
+ line: `Suggested branch: ${name} from ${base}`,
68
+ };
69
+ }
70
+ export function checkoutCommands(base, branch) {
71
+ return [`git checkout ${base}`, `git checkout -b ${branch}`];
72
+ }
73
+ export function prunePlanIndex(entries, planExists) {
74
+ return entries.filter((entry) => planExists(entry.cursorPlanPath));
75
+ }
76
+ export function linearReference(input) {
77
+ if (input.issueId && input.issueId.trim().length > 0) {
78
+ return { issueId: input.issueId.trim(), createIssue: false };
79
+ }
80
+ return {
81
+ issueId: null,
82
+ createIssue: input.userAskedToCreate,
83
+ };
84
+ }
85
+ export const PULL_REQUEST_TEMPLATE_PATH = ".github/pull_request_template.md";
86
+ const PULL_REQUEST_HEADINGS = {
87
+ problem: "## Problem",
88
+ done: "## What changed",
89
+ review: "## Review fixes",
90
+ checks: "## Checks",
91
+ };
92
+ const MAX_PULL_REQUEST_LINES = 50;
93
+ export function pullRequestTemplate() {
94
+ return [
95
+ PULL_REQUEST_HEADINGS.problem,
96
+ "",
97
+ PULL_REQUEST_HEADINGS.done,
98
+ "",
99
+ "-",
100
+ "",
101
+ PULL_REQUEST_HEADINGS.review,
102
+ "",
103
+ "-",
104
+ "",
105
+ PULL_REQUEST_HEADINGS.checks,
106
+ "",
107
+ "- Lint:",
108
+ "- Code quality:",
109
+ "- Conventions:",
110
+ "- Tests:",
111
+ "- Build:",
112
+ "- Docs:",
113
+ "",
114
+ ].join("\n");
115
+ }
116
+ function bullet(item) {
117
+ const text = item.trim();
118
+ if (text.length === 0 || text.includes("\n"))
119
+ return null;
120
+ return `- ${text}`;
121
+ }
122
+ function bullets(items) {
123
+ const lines = [];
124
+ for (const item of items) {
125
+ const line = bullet(item);
126
+ if (line === null)
127
+ return null;
128
+ lines.push(line);
129
+ }
130
+ return lines;
131
+ }
132
+ export function pullRequestDescription(input) {
133
+ const problem = input.problem.trim();
134
+ if (problem.length === 0 || problem.includes("\n"))
135
+ return null;
136
+ const done = bullets(input.done);
137
+ const checks = bullets(input.checks);
138
+ if (done === null || checks === null || done.length === 0 || checks.length === 0)
139
+ return null;
140
+ const review = bullets(input.reviewFixes.length > 0 ? input.reviewFixes : ["No review fixes."]);
141
+ if (review === null)
142
+ return null;
143
+ const lines = [
144
+ PULL_REQUEST_HEADINGS.problem,
145
+ problem,
146
+ "",
147
+ PULL_REQUEST_HEADINGS.done,
148
+ ...done,
149
+ "",
150
+ PULL_REQUEST_HEADINGS.review,
151
+ ...review,
152
+ "",
153
+ PULL_REQUEST_HEADINGS.checks,
154
+ ...checks,
155
+ ];
156
+ if (lines.length < 2 || lines.length > MAX_PULL_REQUEST_LINES)
157
+ return null;
158
+ return lines.join("\n");
159
+ }
160
+ export function mergeDecision(input) {
161
+ if (!input.userAskedToMerge) {
162
+ return { allowed: false, reason: "Wait for the user to call for the merge." };
163
+ }
164
+ if (!input.hasApproval) {
165
+ return { allowed: false, reason: "The pull request has no approval." };
166
+ }
167
+ return {
168
+ allowed: true,
169
+ reason: "The user called for the merge and the pull request has an approval.",
170
+ };
171
+ }
172
+ export function canApprovePullRequest() {
173
+ return false;
174
+ }
package/dist/core/init.js CHANGED
@@ -2,6 +2,8 @@ import { mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSy
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { compiledCursorAgents } from "./agents.js";
5
+ import { SCREEN_LANGUAGES, TENANCY_VALUES, isSurfaceId, } from "./config.js";
6
+ import { BRANCH_BASES, PULL_REQUEST_TEMPLATE_PATH, pullRequestTemplate, } from "./git-flow.js";
5
7
  import { ACTIVE_RISK_PROFILE_IDS } from "./profiles.js";
6
8
  import { PlanError, TITO_BOOTSTRAP_START, planAdoption, titoBootstrapBlock, } from "./plan.js";
7
9
  export class InitError extends Error {
@@ -23,6 +25,106 @@ export async function promptForProfile(ask) {
23
25
  }
24
26
  return answer;
25
27
  }
28
+ export const GIT_BASE_PROMPT = "Git base (dev, develop, main, master): ";
29
+ export const SCREEN_LANGUAGE_PROMPT = "Screen language (vi, en): ";
30
+ export const TENANCY_PROMPT = "Tenancy (single, multi): ";
31
+ export const SURFACES_PROMPT = "Surfaces (comma-separated ids): ";
32
+ export const AUTO_PULL_REQUEST_PROMPT = "Auto-create a pull request after implementation? [Y/n] ";
33
+ export const INIT_SETTING_KEYS = [
34
+ "defaultBase",
35
+ "screenLanguage",
36
+ "tenancy",
37
+ "surfaces",
38
+ "autoPullRequest",
39
+ ];
40
+ async function askOneOf(ask, prompt, allowed) {
41
+ for (;;) {
42
+ const answer = (await ask(prompt)).trim();
43
+ if (allowed.includes(answer))
44
+ return answer;
45
+ }
46
+ }
47
+ async function askSurfaces(ask) {
48
+ for (;;) {
49
+ const answer = (await ask(SURFACES_PROMPT)).trim();
50
+ if (answer === "")
51
+ return undefined;
52
+ const ids = answer
53
+ .split(",")
54
+ .map((part) => part.trim())
55
+ .filter((part) => part.length > 0);
56
+ if (ids.length === 0)
57
+ return undefined;
58
+ const seen = new Set();
59
+ const valid = ids.every((id) => {
60
+ if (!isSurfaceId(id) || seen.has(id))
61
+ return false;
62
+ seen.add(id);
63
+ return true;
64
+ });
65
+ if (valid)
66
+ return ids.map((id) => ({ id }));
67
+ }
68
+ }
69
+ async function askAutoPullRequest(ask) {
70
+ for (;;) {
71
+ const answer = (await ask(AUTO_PULL_REQUEST_PROMPT)).trim().toLowerCase();
72
+ if (answer === "" || answer === "y" || answer === "yes")
73
+ return true;
74
+ if (answer === "n" || answer === "no")
75
+ return false;
76
+ }
77
+ }
78
+ export async function promptForSettings(ask, keys) {
79
+ const answers = {};
80
+ for (const key of keys) {
81
+ if (key === "defaultBase") {
82
+ answers.defaultBase = await askOneOf(ask, GIT_BASE_PROMPT, BRANCH_BASES);
83
+ continue;
84
+ }
85
+ if (key === "screenLanguage") {
86
+ answers.screenLanguage = await askOneOf(ask, SCREEN_LANGUAGE_PROMPT, SCREEN_LANGUAGES);
87
+ continue;
88
+ }
89
+ if (key === "tenancy") {
90
+ answers.tenancy = await askOneOf(ask, TENANCY_PROMPT, TENANCY_VALUES);
91
+ continue;
92
+ }
93
+ if (key === "surfaces") {
94
+ const surfaces = await askSurfaces(ask);
95
+ if (surfaces !== undefined)
96
+ answers.surfaces = surfaces;
97
+ continue;
98
+ }
99
+ answers.autoPullRequest = await askAutoPullRequest(ask);
100
+ }
101
+ return answers;
102
+ }
103
+ export function titoYamlBody(profile, settings) {
104
+ const lines = ["schemaVersion: 1", `profile: ${profile}`];
105
+ const gitLines = [];
106
+ if (settings.defaultBase !== undefined)
107
+ gitLines.push(` defaultBase: ${settings.defaultBase}`);
108
+ if (settings.autoPullRequest !== undefined) {
109
+ gitLines.push(` autoPullRequest: ${settings.autoPullRequest}`);
110
+ }
111
+ if (gitLines.length > 0)
112
+ lines.push("git:", ...gitLines);
113
+ const productLines = [];
114
+ if (settings.screenLanguage !== undefined) {
115
+ productLines.push(` screenLanguage: ${settings.screenLanguage}`);
116
+ }
117
+ if (settings.tenancy !== undefined)
118
+ productLines.push(` tenancy: ${settings.tenancy}`);
119
+ if (settings.surfaces !== undefined && settings.surfaces.length > 0) {
120
+ productLines.push(" surfaces:");
121
+ for (const surface of settings.surfaces)
122
+ productLines.push(` - id: ${surface.id}`);
123
+ }
124
+ if (productLines.length > 0)
125
+ lines.push("product:", ...productLines);
126
+ return `${lines.join("\n")}\n`;
127
+ }
26
128
  function exists(root, path) {
27
129
  try {
28
130
  return statSync(join(root, path)).isFile();
@@ -55,16 +157,35 @@ function agentsBootstrap(report) {
55
157
  body: `\n${titoBootstrapBlock()}`,
56
158
  };
57
159
  }
58
- export function planInitialization(report, profile) {
160
+ export function planInitialization(report, profile, settings) {
59
161
  const adoption = planAdoption(report, profile);
60
- const files = adoption.files.map((file) => file.path === "AGENTS.md" ? agentsBootstrap(report) : file);
162
+ const files = adoption.files.map((file) => {
163
+ if (file.path === "AGENTS.md")
164
+ return agentsBootstrap(report);
165
+ if (file.path === "tito.yaml" && file.action === "create" && settings !== undefined) {
166
+ return {
167
+ path: "tito.yaml",
168
+ action: "create",
169
+ body: titoYamlBody(adoption.profile, settings),
170
+ };
171
+ }
172
+ return file;
173
+ });
61
174
  const agents = compiledCursorAgents().map((agent) => exists(report.root, agent.path)
62
175
  ? { path: agent.path, action: "conflict" }
63
176
  : { path: agent.path, action: "create", body: agent.body });
64
177
  const skills = shippedSkills().map((skill) => exists(report.root, skill.path)
65
178
  ? { path: skill.path, action: "keep" }
66
179
  : { path: skill.path, action: "create", body: skill.body });
67
- return { root: adoption.root, profile: adoption.profile, files: [...files, ...agents, ...skills] };
180
+ const template = pullRequestTemplate();
181
+ const pullRequest = exists(report.root, PULL_REQUEST_TEMPLATE_PATH)
182
+ ? { path: PULL_REQUEST_TEMPLATE_PATH, action: "keep" }
183
+ : { path: PULL_REQUEST_TEMPLATE_PATH, action: "create", body: template };
184
+ return {
185
+ root: adoption.root,
186
+ profile: adoption.profile,
187
+ files: [...files, ...agents, ...skills, pullRequest],
188
+ };
68
189
  }
69
190
  export function formatInitialization(plan) {
70
191
  const lines = [`root: ${plan.root}`, `profile: ${plan.profile}`];
@@ -0,0 +1,116 @@
1
+ const CLASS_TIER = {
2
+ explore: "Scout",
3
+ docs: "Scout",
4
+ ordinary: "Standard",
5
+ core: "Standard",
6
+ reasoning: "Reasoning",
7
+ };
8
+ const CLASS_ESCALATION = {
9
+ explore: "Step up when the slice is foundation, a core feature, or a core calculation.",
10
+ docs: "Step up when the slice is foundation, a core feature, or a core calculation.",
11
+ ordinary: "Step up when the slice is foundation, a core feature, or a core calculation.",
12
+ core: "Ask the user before selecting a more expensive model.",
13
+ reasoning: "Ask the user before selecting a more expensive model.",
14
+ };
15
+ function rulesFor(host, taskClass) {
16
+ if (host === "cursor") {
17
+ if (taskClass === "explore" || taskClass === "docs" || taskClass === "ordinary") {
18
+ return {
19
+ family: "Grok",
20
+ matchGroups: [(slug) => slug.startsWith("grok-"), (slug) => slug.startsWith("cursor-grok-")],
21
+ pick: "highest",
22
+ };
23
+ }
24
+ if (taskClass === "core") {
25
+ return {
26
+ family: "Codex",
27
+ matchGroups: [(slug) => slug.includes("codex"), (slug) => slug.startsWith("composer-")],
28
+ pick: "highest",
29
+ };
30
+ }
31
+ return {
32
+ family: "Opus",
33
+ matchGroups: [(slug) => slug.startsWith("claude-opus-")],
34
+ pick: "highest",
35
+ };
36
+ }
37
+ if (taskClass === "explore" || taskClass === "docs") {
38
+ return {
39
+ family: "Sonnet",
40
+ matchGroups: [(slug) => slug.startsWith("claude-sonnet-")],
41
+ pick: "lowest",
42
+ };
43
+ }
44
+ if (taskClass === "ordinary") {
45
+ return {
46
+ family: "Sonnet",
47
+ matchGroups: [(slug) => slug.startsWith("claude-sonnet-")],
48
+ pick: "highest",
49
+ };
50
+ }
51
+ return {
52
+ family: "Opus",
53
+ matchGroups: [(slug) => slug.startsWith("claude-opus-")],
54
+ pick: "highest",
55
+ };
56
+ }
57
+ function versionTuple(slug) {
58
+ const groups = slug.match(/\d+/g) ?? [];
59
+ return groups.map((value) => Number.parseInt(value, 10));
60
+ }
61
+ function compareVersions(left, right) {
62
+ const limit = Math.max(left.length, right.length);
63
+ for (let index = 0; index < limit; index += 1) {
64
+ const a = left[index];
65
+ const b = right[index];
66
+ if (a === undefined && b === undefined) {
67
+ return 0;
68
+ }
69
+ if (a === undefined) {
70
+ return -1;
71
+ }
72
+ if (b === undefined) {
73
+ return 1;
74
+ }
75
+ if (a !== b) {
76
+ return a - b;
77
+ }
78
+ }
79
+ return 0;
80
+ }
81
+ function pickVersion(slugs, pick) {
82
+ const direction = pick === "highest" ? 1 : -1;
83
+ return [...slugs].sort((a, b) => compareVersions(versionTuple(a), versionTuple(b)) * direction).at(-1) ?? slugs[0];
84
+ }
85
+ export function recommendModel({ host, taskClass, catalog, userModel, }) {
86
+ const tier = CLASS_TIER[taskClass];
87
+ const escalation = CLASS_ESCALATION[taskClass];
88
+ if (typeof userModel === "string" && userModel.length > 0) {
89
+ return {
90
+ status: "selected",
91
+ model: userModel,
92
+ tier,
93
+ reason: "Used the explicit model requested by the user.",
94
+ escalation,
95
+ };
96
+ }
97
+ const rule = rulesFor(host, taskClass);
98
+ for (const matcher of rule.matchGroups) {
99
+ const matches = catalog.filter((slug) => matcher(slug));
100
+ if (matches.length > 0) {
101
+ const model = pickVersion(matches, rule.pick);
102
+ return {
103
+ status: "selected",
104
+ model,
105
+ tier,
106
+ reason: `Selected the ${rule.pick} version in the ${rule.family} family.`,
107
+ escalation,
108
+ };
109
+ }
110
+ }
111
+ return {
112
+ status: "ask",
113
+ family: rule.family,
114
+ reason: `No matching ${rule.family} model was found in the current catalog.`,
115
+ };
116
+ }
package/dist/core/plan.js CHANGED
@@ -14,6 +14,10 @@ This project uses Tito as its root engineering coordinator.
14
14
  - Never commit, push, publish, deploy, or perform irreversible external actions without explicit approval.
15
15
  - Use \`/tito\` when Tito coordination is wanted.
16
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.
17
21
 
18
22
  Keep this bootstrap compact. Load project documentation only when the task needs it.
19
23
  `;
@@ -27,8 +27,8 @@ export const RISK_PROFILES = deepFreeze({
27
27
  "client-careful": {
28
28
  id: "client-careful",
29
29
  status: "active",
30
- maxWriters: 1,
31
- writerConstraint: "one-at-a-time",
30
+ maxWriters: 3,
31
+ writerConstraint: "bounded-parallel",
32
32
  independentReview: "required",
33
33
  preferredAuthoredLines: { min: 100, max: 200 },
34
34
  maxAuthoredLinesWithoutApproval: 300,
@@ -49,8 +49,8 @@ export const RISK_PROFILES = deepFreeze({
49
49
  "solo-balanced": {
50
50
  id: "solo-balanced",
51
51
  status: "active",
52
- maxWriters: 1,
53
- writerConstraint: "one-at-a-time",
52
+ maxWriters: 6,
53
+ writerConstraint: "bounded-parallel",
54
54
  humanReview: "feature-boundaries",
55
55
  independentReviewFor: ["security", "data", "migrations", "architecture"],
56
56
  diffLimits: "moderate",
@@ -60,8 +60,8 @@ export const RISK_PROFILES = deepFreeze({
60
60
  "solo-fast": {
61
61
  id: "solo-fast",
62
62
  status: "active",
63
- maxWriters: 1,
64
- writerConstraint: "one-at-a-time",
63
+ maxWriters: 12,
64
+ writerConstraint: "bounded-parallel",
65
65
  agentSelfReview: "allowed",
66
66
  humanReview: "milestones",
67
67
  safetyFloor: MANDATORY_ESCALATION_TOPICS,