@anhvupt/tito 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,9 +19,11 @@ specialists, and it updates the docs named in the plan before a pull request.
19
19
  uncertain or critical work, and Agent only for an approved implementation
20
20
  slice.
21
21
  - **Plans that make decisions:** Tito confirms the goal and what is out of
22
- scope, then asks one batch of clarifying questions, lighter on `solo-fast`.
23
- One obvious reading continues after a one-line confirmation. Tito still asks
24
- before locking a technical decision or a product-vision change. The plan
22
+ scope, then asks only the questions that are large; zero is fine.
23
+ `client-careful` still asks for medium decisions. `solo-balanced` and
24
+ `solo-fast` decide small and medium choices and report them on a `Decided:`
25
+ line. One obvious reading continues after a one-line confirmation. Tito asks
26
+ before locking a large decision or a product-vision change. The plan
25
27
  records that decision and includes signatures, schemas, pseudocode, or
26
28
  guidance code when that makes implementation clearer. Decisions name the
27
29
  convention they follow. Test cases carry a layer tag: `[unit]`,
@@ -66,6 +68,7 @@ Several read-only specialists may work together. Coding sub-agents follow the
66
68
  profile cap: `client-careful` 3, `solo-balanced` 6, and `solo-fast` 12. Each
67
69
  has its own plan and branch. Tito does not code in the chat. Every change,
68
70
  including a small one, goes to a sub-agent, and Tito returns to the user.
71
+ Before writing an approved slice, Tito lists installed skills, reads only each name and description, and names in the handoff the ones the writer must read.
69
72
  When two or more slices are independent, Tito sends those sub-agents together,
70
73
  each on its own branch, up to that cap, and does not line them up one after
71
74
  another. Check out each branch only after you accept that branch.
@@ -107,7 +110,9 @@ The first `init` prints the plan and writes nothing. In a terminal, omitting
107
110
  `--profile` asks you to choose `client-careful`, `solo-balanced`, or
108
111
  `solo-fast`. A script or piped command must still pass `--profile`.
109
112
  `--confirm` creates the missing `tito.yaml`, specialist agents, and `/tito`
110
- skills. If `AGENTS.md` already exists, Tito appends its bootstrap and leaves
113
+ skills. It also installs Tito's scoped rules under `.cursor/rules/`
114
+ (pull-request, screen-language, init-upgrade-admin, and commit-branch). If
115
+ `AGENTS.md` already exists, Tito appends its bootstrap and leaves
111
116
  the existing guidance in place. It refuses to overwrite a Tito file that is
112
117
  already there. In a terminal, `tito init --confirm` then asks for the git base
113
118
  (`dev`, `develop`, `main`, or `master`), screen language (`vi` or `en`),
@@ -130,7 +135,8 @@ npx tito upgrade --confirm
130
135
  ```
131
136
 
132
137
  `--confirm` installs the latest `@anhvupt/tito` and replaces Tito-owned
133
- agents, skills, and the Tito section of `AGENTS.md`. Consumer rules, consumer
138
+ agents, skills, those Tito-owned scoped rules, and the Tito section of
139
+ `AGENTS.md`. Consumer rules, consumer
134
140
  agents, and other project skills stay untouched. In a terminal, `tito upgrade --confirm` asks only for settings missing from `tito.yaml`: git base, screen language, tenancy, surfaces, and auto-create pull request. Enter on that last question still stores yes. It does not change a value that is already present, and it does not change the profile. A non-interactive upgrade does not ask. If `tito.yaml` is missing, it skips those questions.
135
141
 
136
142
  Tito is installed in each project. It is not a required global command.
@@ -178,6 +184,9 @@ node dist/cli.js admin list
178
184
  node dist/cli.js admin refresh
179
185
  node dist/cli.js admin remove --root /path/to/repo
180
186
  node dist/cli.js admin retro --week 2026-W40
187
+ node dist/cli.js guard test-first --base main --test test/example.test.mjs
188
+ node dist/cli.js worktree tidy
189
+ node dist/cli.js worktree tidy --confirm
181
190
  ```
182
191
 
183
192
  `inspect` reads `tito.yaml` when it exists and checks whether `AGENTS.md` is
@@ -196,10 +205,16 @@ registered paths, `remove` drops one path, and `refresh` writes local branch plu
196
205
  the last 50 commit subjects, dates, and file paths (secrets like `.env` skipped;
197
206
  no diffs) for chat and CLI to share.
198
207
 
208
+ `worktree tidy` (chat: `/tito-worktree-tidy`) reports each linked worktree as protected, merged-clean, dirty, unmerged, behind, fresh, or missing. It removes a merged clean worktree and its local branch only with `--confirm`. It does not delete remote branches or rebase.
209
+
199
210
  `retro` builds that week's report from local git and saves `retros/<week>.json`
200
211
  and `retros/<week>.md` under the admin root. Review-fix counts, docs-blank, and
201
212
  merge duration say unavailable until GitHub data is added.
202
213
 
214
+ `guard test-first` checks a slice branch against `--base`. Commit 1 may change
215
+ only the repeated `--test` files. Later commits may not change `test/**`,
216
+ `*.test.*`, or `*.spec.*` files. A failure names the file and the commit.
217
+
203
218
  ## How work moves
204
219
 
205
220
  The normal careful path is:
@@ -240,6 +255,17 @@ product:
240
255
  - id: admin
241
256
  ```
242
257
 
258
+ Optional `models` maps pipeline roles to Cursor model slugs. Each Tito specialist card compiles to one role: explorer, planner, implementer, reviewer, or finisher. Missing keys fall back to the defaults below. Empty or unknown keys are rejected. `tito init` writes the full block; `tito upgrade` adds missing roles without changing values you already set, and rewrites Tito-owned specialist cards so pinned slugs stay current.
259
+
260
+ ```yaml
261
+ models:
262
+ explorer: cursor-grok-4.6-medium-fast
263
+ planner: claude-opus-5-5-medium
264
+ implementer: grok-4.7-high
265
+ reviewer: grok-4.7-high
266
+ finisher: claude-sonnet-5-5-high
267
+ ```
268
+
243
269
  - Code stays English.
244
270
  - In a Vietnamese app (`screenLanguage: vi`), routes and slugs are Vietnamese first. The public path is native Vietnamese, for example `/tien-ich/ca-phe`. Do not invent that Vietnamese by translating an English slug word for word. If the product is bilingual, the English route comes second.
245
271
  - An English app (`screenLanguage: en`) keeps English routes and slugs.
package/dist/cli.js CHANGED
@@ -9,10 +9,12 @@ import { InspectionError, formatInspection, inspectRepository, } from "./core/in
9
9
  import { PlanError, formatAdoptionPlan, planAdoption } from "./core/plan.js";
10
10
  import { ConfigValidationError, parseConfig } from "./core/config.js";
11
11
  import { INIT_SETTING_KEYS, InitError, applyInitialization, formatInitialization, planInitialization, promptForProfile, promptForSettings, } from "./core/init.js";
12
- import { applyUpgrade, formatUpgrade, mergeSettings, missingSettings, planUpgrade, titoOwnedFiles, } from "./core/upgrade.js";
12
+ import { applyUpgrade, formatUpgrade, mergeSettings, missingSettings, planUpgrade, projectModels, titoOwnedFiles, } from "./core/upgrade.js";
13
13
  import { AdminError, addRepo, formatContexts, formatRepos, listRepos, refreshRepos, removeRepo, resolveAdminRoot, } from "./core/admin.js";
14
14
  import { RetroError, buildRetro, formatRetro, retroJson, saveRetro, } from "./core/retro.js";
15
15
  import { formatInitError } from "./core/terminal.js";
16
+ import { TestFirstError, formatTestFirstReport, guardTestFirst, } from "./core/test-first.js";
17
+ import { WorktreeError, formatTidyReport, tidyWorktrees } from "./core/worktree.js";
16
18
  const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
17
19
  const help = `Tito — quiet orchestration for solo builders
18
20
 
@@ -27,6 +29,8 @@ Usage:
27
29
  tito admin remove --root <path> [--admin-root <path>]
28
30
  tito admin refresh [--admin-root <path>]
29
31
  tito admin retro [--week <YYYY-Www>] [--timezone <IANA>] [--json] [--admin-root <path>]
32
+ tito guard test-first --base <ref> --test <file> [--root <path>]
33
+ tito worktree tidy [--confirm] [--root <path>]
30
34
 
31
35
  Options:
32
36
  -h, --help Show help
@@ -38,6 +42,8 @@ Commands:
38
42
  init Install Tito files and specialist agents. Confirm before writing.
39
43
  upgrade Install the latest Tito and replace Tito-owned files only.
40
44
  admin Opt-in local repo index: add, list, remove, refresh, retro.
45
+ guard Check that commit 1 changes only the listed test files.
46
+ worktree Report stale worktrees. Confirm before removing merged clean ones.
41
47
  `;
42
48
  function fail(message) {
43
49
  process.stderr.write(`${message}\n`);
@@ -239,13 +245,15 @@ async function runUpgrade(args) {
239
245
  fail(error instanceof Error ? error.message : "Invalid upgrade arguments.");
240
246
  return;
241
247
  }
242
- const files = planUpgrade(root, titoOwnedFiles());
248
+ let files = planUpgrade(root, titoOwnedFiles(projectModels(root)));
243
249
  if (!confirm) {
244
250
  process.stdout.write(formatUpgrade(root, files));
245
251
  return;
246
252
  }
247
- if (!filesOnly && input.isTTY)
253
+ if (!filesOnly && input.isTTY) {
248
254
  await fillMissingSettings(root);
255
+ files = planUpgrade(root, titoOwnedFiles(projectModels(root)));
256
+ }
249
257
  if (!filesOnly && !isTitoSource(root)) {
250
258
  const install = spawnSync("npm", ["install", "-D", "@anhvupt/tito@latest"], { cwd: root, encoding: "utf8" });
251
259
  if (install.status !== 0) {
@@ -421,6 +429,105 @@ function runRetro(args) {
421
429
  throw error;
422
430
  }
423
431
  }
432
+ function runGuard(args) {
433
+ if (args.includes("-h") || args.includes("--help")) {
434
+ process.stdout.write(help);
435
+ return;
436
+ }
437
+ const [subcommand, ...rest] = args;
438
+ if (subcommand !== "test-first") {
439
+ fail("Unknown guard. Use: tito guard test-first --base <ref> --test <file>");
440
+ return;
441
+ }
442
+ let root = process.cwd();
443
+ let base;
444
+ let testFiles = [];
445
+ try {
446
+ const { values } = parseArgs({
447
+ args: rest,
448
+ options: {
449
+ base: { type: "string" },
450
+ root: { type: "string" },
451
+ test: { type: "string", multiple: true },
452
+ },
453
+ strict: true,
454
+ allowPositionals: false,
455
+ });
456
+ if (values.base !== undefined)
457
+ base = values.base;
458
+ if (values.root !== undefined)
459
+ root = values.root;
460
+ if (Array.isArray(values.test))
461
+ testFiles = values.test;
462
+ else if (typeof values.test === "string")
463
+ testFiles = [values.test];
464
+ }
465
+ catch (error) {
466
+ fail(error instanceof Error ? error.message : "Invalid guard arguments.");
467
+ return;
468
+ }
469
+ if (base === undefined) {
470
+ fail("Missing required option --base.");
471
+ return;
472
+ }
473
+ if (testFiles.length === 0) {
474
+ fail("Missing required option --test.");
475
+ return;
476
+ }
477
+ try {
478
+ const report = guardTestFirst({ root, base, testFiles });
479
+ process.stdout.write(formatTestFirstReport(report));
480
+ if (!report.ok)
481
+ process.exitCode = 1;
482
+ }
483
+ catch (error) {
484
+ if (error instanceof TestFirstError) {
485
+ fail(`${error.code}: ${error.message}`);
486
+ return;
487
+ }
488
+ throw error;
489
+ }
490
+ }
491
+ function runWorktree(args) {
492
+ if (args.includes("-h") || args.includes("--help")) {
493
+ process.stdout.write(help);
494
+ return;
495
+ }
496
+ if (args[0] !== "tidy") {
497
+ fail("Unknown worktree command. Use tito worktree tidy.");
498
+ return;
499
+ }
500
+ let root = process.cwd();
501
+ let confirm = false;
502
+ try {
503
+ const { values } = parseArgs({
504
+ args: args.slice(1),
505
+ options: {
506
+ confirm: { type: "boolean" },
507
+ root: { type: "string" },
508
+ },
509
+ strict: true,
510
+ allowPositionals: false,
511
+ });
512
+ confirm = values.confirm === true;
513
+ if (values.root !== undefined)
514
+ root = values.root;
515
+ }
516
+ catch (error) {
517
+ fail(error instanceof Error ? error.message : "Invalid worktree arguments.");
518
+ return;
519
+ }
520
+ try {
521
+ process.stdout.write(formatTidyReport(tidyWorktrees({ root, confirm })));
522
+ }
523
+ catch (error) {
524
+ if (error instanceof WorktreeError) {
525
+ fail(`${error.code}: ${error.message}`);
526
+ return;
527
+ }
528
+ throw error;
529
+ }
530
+ }
424
531
  async function run(args) {
425
532
  if (args[0] === "inspect") {
426
533
  runInspect(args.slice(1));
@@ -440,6 +547,14 @@ async function run(args) {
440
547
  runAdmin(args.slice(1));
441
548
  return;
442
549
  }
550
+ if (args[0] === "guard") {
551
+ runGuard(args.slice(1));
552
+ return;
553
+ }
554
+ if (args[0] === "worktree") {
555
+ runWorktree(args.slice(1));
556
+ return;
557
+ }
443
558
  let helpRequested = false;
444
559
  let versionRequested = false;
445
560
  try {
@@ -1,3 +1,4 @@
1
+ import { resolveModels } from "./config.js";
1
2
  import { SPECIALIST_IDS, SPECIALISTS } from "./specialists.js";
2
3
  const AGENT_FILE_NAMES = {
3
4
  explorer: "tito-explorer.md",
@@ -15,8 +16,9 @@ const AGENT_FILE_NAMES = {
15
16
  export function cursorAgentPath(id) {
16
17
  return `.cursor/agents/${AGENT_FILE_NAMES[id]}`;
17
18
  }
18
- export function compileCursorAgent(id) {
19
+ export function compileCursorAgent(id, models) {
19
20
  const manifest = SPECIALISTS[id];
21
+ const model = resolveModels(models)[manifest.model.role];
20
22
  const readOnly = manifest.modes.every((mode) => mode.capability === "read-only");
21
23
  const modes = manifest.modes
22
24
  .map((mode) => `- \`${mode.id}\`: ${mode.capability}`)
@@ -32,7 +34,7 @@ name: ${AGENT_FILE_NAMES[id].replace(/\.md$/, "")}
32
34
  description: >
33
35
  ${manifest.role}. Tito delegates this specialist only for: ${manifest.triggers.join("; ")}.
34
36
  You are not the root coordinator. Follow the assigned mode and stop condition.
35
- model: inherit
37
+ model: ${model}
36
38
  readonly: ${readOnly}
37
39
  ---
38
40
 
@@ -61,9 +63,9 @@ Return the result to Tito. Suggest another specialist only from this manifest's
61
63
  ${manifest.stopCondition}
62
64
  `;
63
65
  }
64
- export function compiledCursorAgents() {
66
+ export function compiledCursorAgents(models) {
65
67
  return SPECIALIST_IDS.map((id) => ({
66
68
  path: cursorAgentPath(id),
67
- body: compileCursorAgent(id),
69
+ body: compileCursorAgent(id, models),
68
70
  }));
69
71
  }
@@ -2,12 +2,23 @@ import { isMap, isScalar, isSeq, parseAllDocuments } from "yaml";
2
2
  import { BRANCH_BASES } from "./git-flow.js";
3
3
  import { ACTIVE_RISK_PROFILE_IDS, isActiveRiskProfileId, isFutureRiskProfileId, } from "./profiles.js";
4
4
  export const CONFIG_SCHEMA_VERSION = 1;
5
- const FIELDS = new Set(["schemaVersion", "profile", "git", "product"]);
5
+ const FIELDS = new Set(["schemaVersion", "profile", "git", "product", "models"]);
6
+ export const MODEL_ROLES = ["explorer", "planner", "implementer", "reviewer", "finisher"];
7
+ export const DEFAULT_MODELS = Object.freeze({
8
+ explorer: "cursor-grok-4.6-medium-fast",
9
+ planner: "claude-opus-5-5-medium",
10
+ implementer: "grok-4.7-high",
11
+ reviewer: "grok-4.7-high",
12
+ finisher: "claude-sonnet-5-5-high",
13
+ });
6
14
  export const SCREEN_LANGUAGES = ["vi", "en"];
7
15
  export const TENANCY_VALUES = ["single", "multi"];
8
16
  const PRODUCT_FIELDS = new Set(["screenLanguage", "tenancy", "surfaces"]);
9
17
  const SURFACE_ID = /^[a-z][a-z0-9-]*$/;
10
18
  const NON_SCALAR = Symbol("non-scalar");
19
+ export function resolveModels(models) {
20
+ return { ...DEFAULT_MODELS, ...models };
21
+ }
11
22
  export class ConfigValidationError extends Error {
12
23
  issues;
13
24
  constructor(issues) {
@@ -226,6 +237,42 @@ function readProduct(node) {
226
237
  const product = assembleProduct({ screenLanguage, tenancy, surfaces });
227
238
  return product ? { product, issues } : { issues };
228
239
  }
240
+ function isModelRole(value) {
241
+ return MODEL_ROLES.includes(value);
242
+ }
243
+ function readModels(node) {
244
+ if (!isMap(node)) {
245
+ return { issues: [issue("unknown-field", 'Field "models" must be a mapping.', "models")] };
246
+ }
247
+ const issues = [];
248
+ const seen = new Set();
249
+ const models = {};
250
+ for (const item of node.items) {
251
+ const key = mappingKey(item.key);
252
+ if (key === null) {
253
+ issues.push(issue("unknown-field", "Mapping keys must be strings.", "models"));
254
+ continue;
255
+ }
256
+ if (seen.has(key)) {
257
+ issues.push(issue("duplicate-key", `Duplicate key "${key}".`, `models.${key}`));
258
+ continue;
259
+ }
260
+ seen.add(key);
261
+ if (!isModelRole(key)) {
262
+ issues.push(issue("unknown-field", `Unknown field "models.${key}".`, `models.${key}`));
263
+ continue;
264
+ }
265
+ const value = isScalar(item.value) ? item.value.value : NON_SCALAR;
266
+ if (typeof value !== "string" || value.trim().length === 0) {
267
+ issues.push(issue("unknown-field", `Field "models.${key}" must be a non-empty model slug.`, `models.${key}`));
268
+ continue;
269
+ }
270
+ models[key] = value.trim();
271
+ }
272
+ if (issues.length > 0)
273
+ return { issues };
274
+ return Object.keys(models).length > 0 ? { models, issues } : { issues };
275
+ }
229
276
  function profileIssue(value) {
230
277
  if (typeof value !== "string") {
231
278
  return issue("unknown-profile", "Profile must be a string.", "profile");
@@ -268,6 +315,7 @@ export function parseConfig(text) {
268
315
  const values = new Map();
269
316
  let git;
270
317
  let product;
318
+ let models;
271
319
  for (const item of document.contents.items) {
272
320
  const key = isScalar(item.key) && typeof item.key.value === "string" ? item.key.value : null;
273
321
  if (key === null) {
@@ -298,6 +346,13 @@ export function parseConfig(text) {
298
346
  product = parsed.product;
299
347
  continue;
300
348
  }
349
+ if (key === "models") {
350
+ const parsed = readModels(item.value);
351
+ issues.push(...parsed.issues);
352
+ if (parsed.models)
353
+ models = parsed.models;
354
+ continue;
355
+ }
301
356
  values.set(key, isScalar(item.value) ? item.value.value : NON_SCALAR);
302
357
  }
303
358
  if ((occurrences.get("schemaVersion") ?? 0) === 0) {
@@ -326,5 +381,7 @@ export function parseConfig(text) {
326
381
  result.git = git;
327
382
  if (product)
328
383
  result.product = product;
384
+ if (models)
385
+ result.models = models;
329
386
  return result;
330
387
  }
@@ -1,3 +1,4 @@
1
+ import { recommendModel } from "./models.js";
1
2
  export const BRANCH_TYPES = [
2
3
  "feat",
3
4
  "fix",
@@ -172,3 +173,73 @@ export function mergeDecision(input) {
172
173
  export function canApprovePullRequest() {
173
174
  return false;
174
175
  }
176
+ const PROTECTED_PUSH_BRANCHES = new Set(["main", "master", "dev", "develop"]);
177
+ const SLICE_CHECKS = ["guard", "lint", "tests", "build", "docs"];
178
+ export function finishSlice(input) {
179
+ if (input.role !== "finisher") {
180
+ return {
181
+ status: "refused",
182
+ pushed: false,
183
+ reason: `The ${input.role} may not open a pull request.`,
184
+ };
185
+ }
186
+ if (PROTECTED_PUSH_BRANCHES.has(input.branch)) {
187
+ return {
188
+ status: "refused",
189
+ pushed: false,
190
+ reason: `Refusing to push ${input.branch}.`,
191
+ };
192
+ }
193
+ if (input.autoPullRequest !== true) {
194
+ return {
195
+ status: "ask",
196
+ pushed: false,
197
+ reason: "Tito asks before opening a pull request.",
198
+ };
199
+ }
200
+ for (const check of SLICE_CHECKS) {
201
+ if (input.checks[check] !== true) {
202
+ return {
203
+ status: "failed",
204
+ pushed: false,
205
+ check,
206
+ reason: `The ${check} check failed. Nothing was pushed.`,
207
+ };
208
+ }
209
+ }
210
+ const recommendation = recommendModel({
211
+ host: "cursor",
212
+ role: "finisher",
213
+ catalog: input.catalog,
214
+ });
215
+ if (recommendation.status !== "selected") {
216
+ return {
217
+ status: "ask",
218
+ pushed: false,
219
+ reason: recommendation.reason,
220
+ };
221
+ }
222
+ const body = pullRequestDescription(input.description);
223
+ if (body === null) {
224
+ return {
225
+ status: "failed",
226
+ pushed: false,
227
+ reason: "The pull request description is not a valid four-part description.",
228
+ };
229
+ }
230
+ input.adapter.push(input.branch);
231
+ const opened = input.adapter.openPullRequest({
232
+ head: input.branch,
233
+ base: input.base,
234
+ title: input.description.problem.trim(),
235
+ body,
236
+ readyForReview: true,
237
+ });
238
+ return {
239
+ status: "opened",
240
+ model: recommendation.model,
241
+ url: opened.url,
242
+ pushed: true,
243
+ readyForReview: true,
244
+ };
245
+ }
package/dist/core/init.js CHANGED
@@ -2,7 +2,7 @@ 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";
5
+ import { MODEL_ROLES, SCREEN_LANGUAGES, TENANCY_VALUES, isSurfaceId, resolveModels, } from "./config.js";
6
6
  import { BRANCH_BASES, PULL_REQUEST_TEMPLATE_PATH, pullRequestTemplate, } from "./git-flow.js";
7
7
  import { ACTIVE_RISK_PROFILE_IDS } from "./profiles.js";
8
8
  import { PlanError, TITO_BOOTSTRAP_START, planAdoption, titoBootstrapBlock, } from "./plan.js";
@@ -123,6 +123,8 @@ export function titoYamlBody(profile, settings) {
123
123
  }
124
124
  if (productLines.length > 0)
125
125
  lines.push("product:", ...productLines);
126
+ const models = resolveModels(settings.models);
127
+ lines.push("models:", ...MODEL_ROLES.map((role) => ` ${role}: ${models[role]}`));
126
128
  return `${lines.join("\n")}\n`;
127
129
  }
128
130
  function exists(root, path) {
@@ -142,6 +144,17 @@ export function shippedSkills() {
142
144
  body: readFileSync(join(root, entry.name, "SKILL.md"), "utf8"),
143
145
  }));
144
146
  }
147
+ export function shippedCursorRules() {
148
+ const root = join(dirname(fileURLToPath(import.meta.url)), "../../templates/cursor/rules");
149
+ return readdirSync(root, { withFileTypes: true })
150
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".mdc"))
151
+ .map((entry) => entry.name)
152
+ .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))
153
+ .map((name) => ({
154
+ path: `.cursor/rules/${name}`,
155
+ body: readFileSync(join(root, name), "utf8"),
156
+ }));
157
+ }
145
158
  function agentsBootstrap(report) {
146
159
  if (report.agentsMd === "absent") {
147
160
  return { path: "AGENTS.md", action: "create", body: titoBootstrapBlock() };
@@ -162,16 +175,17 @@ export function planInitialization(report, profile, settings) {
162
175
  const files = adoption.files.map((file) => {
163
176
  if (file.path === "AGENTS.md")
164
177
  return agentsBootstrap(report);
165
- if (file.path === "tito.yaml" && file.action === "create" && settings !== undefined) {
178
+ if (file.path === "tito.yaml" && file.action === "create") {
166
179
  return {
167
180
  path: "tito.yaml",
168
181
  action: "create",
169
- body: titoYamlBody(adoption.profile, settings),
182
+ body: titoYamlBody(adoption.profile, settings ?? {}),
170
183
  };
171
184
  }
172
185
  return file;
173
186
  });
174
- const agents = compiledCursorAgents().map((agent) => exists(report.root, agent.path)
187
+ const models = report.titoYaml.status === "valid" ? report.titoYaml.config.models : settings?.models;
188
+ const agents = compiledCursorAgents(models).map((agent) => exists(report.root, agent.path)
175
189
  ? { path: agent.path, action: "conflict" }
176
190
  : { path: agent.path, action: "create", body: agent.body });
177
191
  const skills = shippedSkills().map((skill) => exists(report.root, skill.path)
@@ -181,10 +195,13 @@ export function planInitialization(report, profile, settings) {
181
195
  const pullRequest = exists(report.root, PULL_REQUEST_TEMPLATE_PATH)
182
196
  ? { path: PULL_REQUEST_TEMPLATE_PATH, action: "keep" }
183
197
  : { path: PULL_REQUEST_TEMPLATE_PATH, action: "create", body: template };
198
+ const rules = shippedCursorRules().map((rule) => exists(report.root, rule.path)
199
+ ? { path: rule.path, action: "keep" }
200
+ : { path: rule.path, action: "create", body: rule.body });
184
201
  return {
185
202
  root: adoption.root,
186
203
  profile: adoption.profile,
187
- files: [...files, ...agents, ...skills, pullRequest],
204
+ files: [...files, ...agents, ...skills, pullRequest, ...rules],
188
205
  };
189
206
  }
190
207
  export function formatInitialization(plan) {