@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 CHANGED
@@ -63,6 +63,8 @@ mode decides whether it may change anything:
63
63
  | DevOps engineer | Plan | Change infrastructure, with approval |
64
64
  | Tech docs writer | — | Update technical documentation |
65
65
  | User docs writer | — | Update user documentation |
66
+ | Cleanup specialist | — | Remove leftover files |
67
+ | Local startup | — | Start missing local processes; ask before pending migrations |
66
68
 
67
69
  Several read-only specialists may work together. Coding sub-agents follow the
68
70
  profile cap: `client-careful` 3, `solo-balanced` 6, and `solo-fast` 12. Each
@@ -93,6 +95,7 @@ core and exposed through both chat and the CLI:
93
95
  - `/tito` is the open-ended coordinator.
94
96
  - `/tito-inspect` and `tito inspect` produce the same repository report.
95
97
  - `/tito-apply` and `tito apply --dry-run` produce the same adoption plan.
98
+ - `/tito-local-startup` brings this repo's local stack up.
96
99
 
97
100
  Tito-coordinated chat responses begin with `Hola, Tito here!` so you can see
98
101
  that the coordinator is active. The greeting is not added to CLI or
@@ -117,13 +120,16 @@ the existing guidance in place. It refuses to overwrite a Tito file that is
117
120
  already there. In a terminal, `tito init --confirm` then asks for the git base
118
121
  (`dev`, `develop`, `main`, or `master`), screen language (`vi` or `en`),
119
122
  tenancy (`single` or `multi`), optional comma-separated surface ids, and
120
- whether to auto-create a pull request after implementation. That question is
123
+ whether to auto-create a pull request after implementation, and whether to
124
+ auto-merge a slice pull request after checks pass. The auto-create question is
121
125
  `Auto-create a pull request after implementation? [Y/n]`. Enter, `y`, or `yes`
122
- stores yes. `n` or `no` stores no. Empty surface ids omit `product.surfaces`.
126
+ stores yes. `n` or `no` stores no. The auto-merge question is
127
+ `Auto-merge a slice pull request after checks pass? [y/N]`. Enter, `n`, or
128
+ `no` stores no. `y` or `yes` stores yes. Empty surface ids omit `product.surfaces`.
123
129
  A non-interactive init with `--profile` does not ask; it stores
124
- `git.autoPullRequest: true` and does not invent the other product fields.
130
+ `git.autoPullRequest: true` and `git.autoMerge: false` and does not invent the other product fields.
125
131
  Preview without `--confirm` does not ask and does not write. An existing
126
- `tito.yaml` is left unchanged. Storing the answer does not open a pull request.
132
+ `tito.yaml` is left unchanged. Storing those answers does not open or merge a pull request.
127
133
  Errors on a terminal are red, and `NO_COLOR` turns that off.
128
134
  Open a new Cursor chat, then start with `/tito`.
129
135
 
@@ -137,7 +143,9 @@ npx tito upgrade --confirm
137
143
  `--confirm` installs the latest `@anhvupt/tito` and replaces Tito-owned
138
144
  agents, skills, those Tito-owned scoped rules, and the Tito section of
139
145
  `AGENTS.md`, which includes the stop that shows the test cases before any source edit. Consumer rules, consumer
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.
146
+ 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, auto-create pull request, and auto-merge. Enter on the auto-create question still stores yes. Enter on the auto-merge question stores no. 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.
147
+
148
+ `tito init` and `tito upgrade` install Tito's shell and MCP guard hooks in `.cursor/hooks.json`. A missing file is created with both Tito entries. A valid file gets each missing `tito hook` entry appended; other keys stay. An existing Tito entry is left unchanged. Cloud agents do not run beforeMCPExecution. Remove a guard by deleting Tito's entry, or the file if it only has those entries. Invalid JSON is a conflict: `init --confirm` refuses and writes nothing.
141
149
 
142
150
  Tito is installed in each project. It is not a required global command.
143
151
  An opt-in local admin index (`tito admin add|list|remove|refresh`) can register
@@ -185,6 +193,8 @@ node dist/cli.js admin refresh
185
193
  node dist/cli.js admin remove --root /path/to/repo
186
194
  node dist/cli.js admin retro --week 2026-W40
187
195
  node dist/cli.js guard test-first --base main --test test/example.test.mjs
196
+ node dist/cli.js hook before-shell
197
+ node dist/cli.js hook before-mcp
188
198
  node dist/cli.js worktree tidy
189
199
  node dist/cli.js worktree tidy --confirm
190
200
  ```
@@ -215,6 +225,14 @@ merge duration say unavailable until GitHub data is added.
215
225
  only the repeated `--test` files. Later commits may not change `test/**`,
216
226
  `*.test.*`, or `*.spec.*` files. A failure names the file and the commit.
217
227
 
228
+ `hook before-shell` is machine-only (no chat skill). It reads Cursor
229
+ beforeShellExecution JSON on stdin and writes `{permission: allow|ask|deny}` to
230
+ stdout, then exits 0. Invalid input fails open with allow. It asks before
231
+ base-branch pushes and merges, force-pushes, `gh pr merge` (unless
232
+ `git.autoMerge` is true), and `npm publish`. It denies `gh pr review --approve`.
233
+ `hook before-mcp` is the same contract for beforeMCPExecution; cloud agents do
234
+ not run that hook.
235
+
218
236
  ## How work moves
219
237
 
220
238
  The normal careful path is:
@@ -241,10 +259,13 @@ profile: client-careful
241
259
  git:
242
260
  defaultBase: develop
243
261
  autoPullRequest: true
262
+ autoMerge: false
244
263
  ```
245
264
 
246
265
  `git.autoPullRequest` is an optional boolean. Missing it is valid. A non-boolean value is rejected. Storing it does not open a pull request.
247
266
 
267
+ `git.autoMerge` is an optional boolean. Missing it is valid and means off. A non-boolean value is rejected. `true` is the user's standing merge call. Storing it does not merge a pull request.
268
+
248
269
  `product.screenLanguage` is optional and accepts only `vi` or `en`. A missing product block is valid. A missing `screenLanguage` is valid. Unknown product fields are rejected. `product.tenancy` is optional (`single` or `multi`). `product.surfaces` is an optional list of `{ id }` entries. Missing `tenancy` and missing `surfaces` stay valid.
249
270
 
250
271
  ```yaml
@@ -255,13 +276,14 @@ product:
255
276
  - id: admin
256
277
  ```
257
278
 
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.
279
+ Optional `models` maps pipeline roles to Cursor model slugs. Each Tito specialist card compiles to one role: explorer, planner, advisor, 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
280
 
260
281
  ```yaml
261
282
  models:
262
283
  explorer: cursor-grok-4.6-medium-fast
263
- planner: claude-opus-5-5-medium
264
- implementer: grok-4.7-high
284
+ planner: grok-4.7-high
285
+ advisor: claude-opus-5-5-medium
286
+ implementer: cursor-grok-4.6-high
265
287
  reviewer: grok-4.7-high
266
288
  finisher: composer-2.5
267
289
  ```
@@ -274,7 +296,11 @@ models:
274
296
  - When `screenLanguage` is missing and the screen language is unclear, Tito asks once. One obvious reading continues without a question.
275
297
  - Globalized apps store timestamps in UTC and show them in the user's timezone. There is no switch to turn that off.
276
298
 
277
- A commit subject is `<type>: <sentence>`. The type is the same token as the branch type: `feat`, `fix`, `hot-fix`, `chores`, `refactor`, or `debug`. Examples: `feat: add commit message rule`, `fix: reject a duplicate surface id`, `chores: record the commit prefix rule`, and `hot-fix: stop a bad release build`. The words after the colon are one finished sentence of at most 70 words. The type is not counted in those 70 words. The body is a separate description. When you review a plan, Tito saves that plan and your edit as separate files under `.tito/feedback/<slug>/`. `review.md` starts with front matter `outcome: accepted | edited | expanded`. Tito indexes only the plan name. The plan body stays in Cursor's plan file. After an approved slice is coded, every review fix goes into one plan named `review/<slug>` on the same branch. Tito asks before opening a pull request only after that plan is coded, or when you accept the code with no changes. The pull request description has four parts within 2 to 50 lines: a one-line problem, what changed, review fixes, and checks for lint, code quality, conventions, tests, build, and docs. 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. Init creates `.github/pull_request_template.md` from Tito's template when it is missing. Upgrade replaces that file with Tito's template. Tito does not approve a pull request. Tito merges only when you call for the merge and the pull request already has an approval. 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. After a pull request is merged, Tito asks before the next slice. Tito switches back to the base branch only when you say so clearly. Tito pushes directly to the base branch only when you clearly instruct that push.
299
+ A commit subject is `<type>: <sentence>`. The type is the same token as the branch type: `feat`, `fix`, `hot-fix`, `chores`, `refactor`, or `debug`. Examples: `feat: add commit message rule`, `fix: reject a duplicate surface id`, `chores: record the commit prefix rule`, and `hot-fix: stop a bad release build`. The words after the colon are one finished sentence of at most 70 words. The type is not counted in those 70 words. The body is a separate description. When you review a plan, Tito saves that plan and your edit as separate files under `.tito/feedback/<slug>/`. `review.md` starts with front matter `outcome: accepted | edited | expanded`. Tito indexes only the plan name. The plan body stays in Cursor's plan file. After an approved slice is coded, every review fix goes into one plan named `review/<slug>` on the same branch. Tito asks before opening a pull request only after that plan is coded, or when you accept the code with no changes. The pull request description has four parts within 2 to 50 lines: a one-line problem, what changed, review fixes, and checks for lint, code quality, conventions, tests, build, and docs. 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. Init creates `.github/pull_request_template.md` from Tito's template when it is missing. Upgrade replaces that file with Tito's template. Tito never approves a pull request. Tito merges a slice pull request into a base branch only via `gh pr merge --squash`, on your merge call or `git.autoMerge: true`, after the finisher re-runs the test-first guard, tests, build, and docs gate. An approval is not required. When auto-merge is on, Tito still waits when the profile is `client-careful` or the slice touches mandatory escalation (authentication, payments, secrets, migrations, production data, infrastructure). Finishing a slice leaves the branch as it is. After a pull request is merged, Tito asks before the next slice. Tito switches back to the base branch only when you say so clearly. Tito pushes directly to the base branch only when you clearly instruct that push.
300
+
301
+ ## Cost
302
+
303
+ Real token usage is measured only by the opt-in `npm run eval:usage` script; file-size budgets in `npm test` stay estimates.
278
304
 
279
305
  ## Development
280
306
 
@@ -289,6 +315,8 @@ npm run build
289
315
  npm test
290
316
  ```
291
317
 
318
+ `npm run eval:usage` spends Cursor tokens. It needs `CURSOR_API_KEY` and Node.js 22.13 or newer. It is not part of `npm test`. Pass `--record` to write `evals/usage-baseline.json`.
319
+
292
320
  ## Acknowledgement
293
321
 
294
322
  Tito is respectfully inspired by Francesc “Tito” Vilanova: quiet leadership,
package/dist/cli.js CHANGED
@@ -15,6 +15,7 @@ import { RetroError, buildRetro, formatRetro, retroJson, saveRetro, } from "./co
15
15
  import { formatInitError } from "./core/terminal.js";
16
16
  import { TestFirstError, formatTestFirstReport, guardTestFirst, } from "./core/test-first.js";
17
17
  import { WorktreeError, formatTidyReport, tidyWorktrees } from "./core/worktree.js";
18
+ import { decideMcp, decideShell } from "./core/hooks.js";
18
19
  const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
19
20
  const help = `Tito — quiet orchestration for solo builders
20
21
 
@@ -30,6 +31,8 @@ Usage:
30
31
  tito admin refresh [--admin-root <path>]
31
32
  tito admin retro [--week <YYYY-Www>] [--timezone <IANA>] [--json] [--admin-root <path>]
32
33
  tito guard test-first --base <ref> --test <file> [--root <path>]
34
+ tito hook before-shell
35
+ tito hook before-mcp
33
36
  tito worktree tidy [--confirm] [--root <path>]
34
37
 
35
38
  Options:
@@ -43,6 +46,7 @@ Commands:
43
46
  upgrade Install the latest Tito and replace Tito-owned files only.
44
47
  admin Opt-in local repo index: add, list, remove, refresh, retro.
45
48
  guard Check that commit 1 changes only the listed test files.
49
+ hook Machine-only shell and MCP guards. Fail-open. No chat entry.
46
50
  worktree Report stale worktrees. Confirm before removing merged clean ones.
47
51
  `;
48
52
  function fail(message) {
@@ -183,7 +187,7 @@ async function runInit(args) {
183
187
  const creatingYaml = report.titoYaml.status === "absent";
184
188
  let settings;
185
189
  if (creatingYaml && !input.isTTY)
186
- settings = { autoPullRequest: true };
190
+ settings = { autoPullRequest: true, autoMerge: false };
187
191
  let plan = planInitialization(report, profile, settings);
188
192
  if (!confirm) {
189
193
  process.stdout.write(formatInitialization(plan));
@@ -488,6 +492,93 @@ function runGuard(args) {
488
492
  throw error;
489
493
  }
490
494
  }
495
+ function currentBranch(cwd) {
496
+ const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
497
+ cwd,
498
+ encoding: "utf8",
499
+ });
500
+ if (result.status !== 0)
501
+ return undefined;
502
+ const name = (result.stdout ?? "").trim();
503
+ return name.length > 0 ? name : undefined;
504
+ }
505
+ function autoMergeFromCwd(cwd) {
506
+ try {
507
+ return parseConfig(readFileSync(join(cwd, "tito.yaml"), "utf8")).git?.autoMerge === true;
508
+ }
509
+ catch {
510
+ return false;
511
+ }
512
+ }
513
+ function allowOpen(reason) {
514
+ process.stderr.write(`${reason}\n`);
515
+ process.stdout.write('{"permission":"allow"}\n');
516
+ }
517
+ function runHook(args) {
518
+ if (args.includes("-h") || args.includes("--help") || args.length === 0) {
519
+ process.stdout.write(help);
520
+ return;
521
+ }
522
+ if (args[0] !== "before-shell" && args[0] !== "before-mcp") {
523
+ fail("Unknown hook. Use: tito hook before-shell or tito hook before-mcp");
524
+ return;
525
+ }
526
+ let raw;
527
+ try {
528
+ raw = readFileSync(0, "utf8");
529
+ }
530
+ catch (error) {
531
+ allowOpen(error instanceof Error ? error.message : "Could not read stdin.");
532
+ return;
533
+ }
534
+ const trimmed = raw.trim();
535
+ if (trimmed.length === 0) {
536
+ allowOpen("Empty hook input.");
537
+ return;
538
+ }
539
+ let parsed;
540
+ try {
541
+ parsed = JSON.parse(trimmed);
542
+ }
543
+ catch {
544
+ allowOpen("Hook stdin is not JSON.");
545
+ return;
546
+ }
547
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
548
+ allowOpen("Hook stdin is not a JSON object.");
549
+ return;
550
+ }
551
+ try {
552
+ if (args[0] === "before-mcp") {
553
+ const body = parsed;
554
+ const toolInput = typeof body.tool_input === "string"
555
+ ? body.tool_input
556
+ : body.tool_input === undefined
557
+ ? ""
558
+ : JSON.stringify(body.tool_input);
559
+ const cwd = process.cwd();
560
+ const verdict = decideMcp({
561
+ toolName: typeof body.tool_name === "string" ? body.tool_name : "",
562
+ toolInput,
563
+ currentBranch: currentBranch(cwd) ?? "",
564
+ autoMerge: autoMergeFromCwd(cwd),
565
+ });
566
+ process.stdout.write(`${JSON.stringify(verdict)}\n`);
567
+ return;
568
+ }
569
+ const body = parsed;
570
+ if (typeof body.command !== "string") {
571
+ allowOpen("Hook stdin is missing a command string.");
572
+ return;
573
+ }
574
+ const cwd = typeof body.cwd === "string" && body.cwd.length > 0 ? body.cwd : process.cwd();
575
+ const verdict = decideShell(body.command, currentBranch(cwd), autoMergeFromCwd(cwd));
576
+ process.stdout.write(`${JSON.stringify(verdict)}\n`);
577
+ }
578
+ catch (error) {
579
+ allowOpen(error instanceof Error ? error.message : "Hook decision failed.");
580
+ }
581
+ }
491
582
  function runWorktree(args) {
492
583
  if (args.includes("-h") || args.includes("--help")) {
493
584
  process.stdout.write(help);
@@ -551,6 +642,10 @@ async function run(args) {
551
642
  runGuard(args.slice(1));
552
643
  return;
553
644
  }
645
+ if (args[0] === "hook") {
646
+ runHook(args.slice(1));
647
+ return;
648
+ }
554
649
  if (args[0] === "worktree") {
555
650
  runWorktree(args.slice(1));
556
651
  return;
@@ -12,6 +12,8 @@ const AGENT_FILE_NAMES = {
12
12
  "security-reviewer": "tito-security.md",
13
13
  "tech-docs-writer": "tito-tech-docs.md",
14
14
  "user-docs-writer": "tito-user-docs.md",
15
+ cleaner: "tito-cleaner.md",
16
+ startup: "tito-startup.md",
15
17
  };
16
18
  export function cursorAgentPath(id) {
17
19
  return `.cursor/agents/${AGENT_FILE_NAMES[id]}`;
@@ -0,0 +1,292 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, relative, sep } from "node:path";
3
+ export const estimateTokens = (text) => Math.ceil(text.length / 4);
4
+ export const BUDGETS = Object.freeze({
5
+ ordinary: 1500,
6
+ "always-on": 2100,
7
+ planning: 4100,
8
+ "tito-skill": 5300,
9
+ command: 2500,
10
+ card: 400,
11
+ });
12
+ export const CEILINGS = Object.freeze({});
13
+ const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
14
+ const FIELD = /^(description|alwaysApply|globs|disable-model-invocation):[ \t]*(.*)$/;
15
+ const FOLDED = /^>([+-])?$/;
16
+ const MD_REF = /`([^`\n]+\.md)`/g;
17
+ function isDir(path) {
18
+ try {
19
+ return statSync(path).isDirectory();
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ function readText(path) {
26
+ try {
27
+ return readFileSync(path, "utf8");
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ function rel(root, abs) {
34
+ return relative(root, abs).split(sep).join("/");
35
+ }
36
+ function surfaceDir(root, name) {
37
+ const nested = join(root, ".cursor", name);
38
+ if (isDir(nested))
39
+ return nested;
40
+ const flat = join(root, name);
41
+ return isDir(flat) ? flat : null;
42
+ }
43
+ function listFiles(dir, suffix) {
44
+ try {
45
+ return readdirSync(dir, { withFileTypes: true })
46
+ .filter((entry) => entry.isFile() && entry.name.endsWith(suffix))
47
+ .map((entry) => entry.name)
48
+ .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
49
+ }
50
+ catch {
51
+ return [];
52
+ }
53
+ }
54
+ function listDirs(dir) {
55
+ try {
56
+ return readdirSync(dir, { withFileTypes: true })
57
+ .filter((entry) => entry.isDirectory())
58
+ .map((entry) => entry.name)
59
+ .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ }
65
+ function parseFrontMatter(text) {
66
+ const block = FRONT_MATTER.exec(text)?.[1];
67
+ if (block === undefined)
68
+ return {};
69
+ const fields = {};
70
+ const lines = block.replace(/\r/g, "").split("\n");
71
+ let i = 0;
72
+ while (i < lines.length) {
73
+ const line = lines[i] ?? "";
74
+ const match = FIELD.exec(line);
75
+ if (match === null) {
76
+ i += 1;
77
+ continue;
78
+ }
79
+ const key = match[1] ?? "";
80
+ const rest = (match[2] ?? "").trim();
81
+ if (FOLDED.test(rest)) {
82
+ const folded = [];
83
+ i += 1;
84
+ while (i < lines.length) {
85
+ const next = lines[i] ?? "";
86
+ if (next !== "" && !/^[ \t]/.test(next))
87
+ break;
88
+ folded.push(next.replace(/^[ \t]+/, ""));
89
+ i += 1;
90
+ }
91
+ fields[key] = folded.filter((part) => part.length > 0).join(" ");
92
+ continue;
93
+ }
94
+ fields[key] = rest;
95
+ i += 1;
96
+ }
97
+ return fields;
98
+ }
99
+ function sumTokens(parts) {
100
+ return parts.reduce((total, part) => total + estimateTokens(part), 0);
101
+ }
102
+ function budgetFor(id) {
103
+ if (id.startsWith("command:"))
104
+ return BUDGETS.command;
105
+ if (id.startsWith("card:"))
106
+ return BUDGETS.card;
107
+ if (id === "ordinary")
108
+ return BUDGETS.ordinary;
109
+ if (id === "always-on")
110
+ return BUDGETS["always-on"];
111
+ if (id === "planning")
112
+ return BUDGETS.planning;
113
+ if (id === "tito-skill")
114
+ return BUDGETS["tito-skill"];
115
+ return 0;
116
+ }
117
+ function makeReport(id, files, missing, tokens) {
118
+ const budget = budgetFor(id);
119
+ const ceiling = CEILINGS[id];
120
+ if (ceiling === undefined) {
121
+ return { id, files, missing, tokens, budget };
122
+ }
123
+ return { id, files, missing, tokens, budget, ceiling };
124
+ }
125
+ function referencedMarkdown(agentsText) {
126
+ const found = [];
127
+ const seen = new Set();
128
+ for (const match of agentsText.matchAll(MD_REF)) {
129
+ const name = match[1];
130
+ if (name === undefined || seen.has(name))
131
+ continue;
132
+ seen.add(name);
133
+ found.push(name);
134
+ }
135
+ return found;
136
+ }
137
+ export function descriptionCatalog(root) {
138
+ const items = [];
139
+ const agentsDir = surfaceDir(root, "agents");
140
+ if (agentsDir !== null) {
141
+ for (const name of listFiles(agentsDir, ".md")) {
142
+ const abs = join(agentsDir, name);
143
+ const text = readText(abs);
144
+ if (text === null)
145
+ continue;
146
+ const description = parseFrontMatter(text).description ?? "";
147
+ if (description === "")
148
+ continue;
149
+ items.push({ source: rel(root, abs), text: description });
150
+ }
151
+ }
152
+ const rulesDir = surfaceDir(root, "rules");
153
+ if (rulesDir !== null) {
154
+ for (const name of listFiles(rulesDir, ".mdc")) {
155
+ const abs = join(rulesDir, name);
156
+ const text = readText(abs);
157
+ if (text === null)
158
+ continue;
159
+ const fields = parseFrontMatter(text);
160
+ const description = fields.description ?? "";
161
+ if (description === "")
162
+ continue;
163
+ if (fields.alwaysApply !== "false")
164
+ continue;
165
+ if ((fields.globs ?? "") !== "")
166
+ continue;
167
+ items.push({ source: rel(root, abs), text: description });
168
+ }
169
+ }
170
+ const skillsDir = surfaceDir(root, "skills");
171
+ if (skillsDir !== null) {
172
+ for (const folder of listDirs(skillsDir)) {
173
+ const abs = join(skillsDir, folder, "SKILL.md");
174
+ const text = readText(abs);
175
+ if (text === null)
176
+ continue;
177
+ const fields = parseFrontMatter(text);
178
+ if (fields["disable-model-invocation"] === "true")
179
+ continue;
180
+ const description = fields.description ?? "";
181
+ if (description === "")
182
+ continue;
183
+ items.push({ source: rel(root, abs), text: description });
184
+ }
185
+ }
186
+ return items;
187
+ }
188
+ export function measurePaths(root) {
189
+ const reports = [];
190
+ const ordinaryFiles = [];
191
+ const ordinaryTexts = [];
192
+ const ordinaryMissing = [];
193
+ const agentsText = readText(join(root, "AGENTS.md"));
194
+ if (agentsText === null) {
195
+ ordinaryMissing.push("AGENTS.md");
196
+ }
197
+ else {
198
+ ordinaryFiles.push("AGENTS.md");
199
+ ordinaryTexts.push(agentsText);
200
+ }
201
+ const rulesDir = surfaceDir(root, "rules");
202
+ if (rulesDir !== null) {
203
+ for (const name of listFiles(rulesDir, ".mdc")) {
204
+ const abs = join(rulesDir, name);
205
+ const text = readText(abs);
206
+ if (text === null)
207
+ continue;
208
+ if (parseFrontMatter(text).alwaysApply !== "true")
209
+ continue;
210
+ ordinaryFiles.push(rel(root, abs));
211
+ ordinaryTexts.push(text);
212
+ }
213
+ }
214
+ const catalog = descriptionCatalog(root);
215
+ const alwaysOnFiles = [...ordinaryFiles];
216
+ const alwaysOnTexts = [...ordinaryTexts];
217
+ for (const item of catalog) {
218
+ if (!alwaysOnFiles.includes(item.source))
219
+ alwaysOnFiles.push(item.source);
220
+ alwaysOnTexts.push(item.text);
221
+ }
222
+ reports.push(makeReport("ordinary", ordinaryFiles, [...ordinaryMissing], sumTokens(ordinaryTexts)));
223
+ reports.push(makeReport("always-on", alwaysOnFiles, [...ordinaryMissing], sumTokens(alwaysOnTexts)));
224
+ const planningFiles = [...alwaysOnFiles];
225
+ const planningTexts = [...alwaysOnTexts];
226
+ const planningMissing = [...ordinaryMissing];
227
+ if (agentsText !== null) {
228
+ for (const ref of referencedMarkdown(agentsText)) {
229
+ const abs = join(root, ref);
230
+ const text = readText(abs);
231
+ if (text === null) {
232
+ planningMissing.push(ref);
233
+ continue;
234
+ }
235
+ if (!planningFiles.includes(ref))
236
+ planningFiles.push(ref);
237
+ planningTexts.push(text);
238
+ }
239
+ }
240
+ reports.push(makeReport("planning", planningFiles, planningMissing, sumTokens(planningTexts)));
241
+ const skillsDir = surfaceDir(root, "skills");
242
+ const titoAbs = join(skillsDir ?? join(root, ".cursor", "skills"), "tito", "SKILL.md");
243
+ const titoText = readText(titoAbs);
244
+ const titoFiles = [...planningFiles];
245
+ const titoTexts = [...planningTexts];
246
+ const titoMissing = [...planningMissing];
247
+ if (titoText === null) {
248
+ titoMissing.push(rel(root, titoAbs));
249
+ }
250
+ else {
251
+ titoFiles.push(rel(root, titoAbs));
252
+ titoTexts.push(titoText);
253
+ }
254
+ reports.push(makeReport("tito-skill", titoFiles, titoMissing, sumTokens(titoTexts)));
255
+ if (skillsDir !== null) {
256
+ for (const folder of listDirs(skillsDir)) {
257
+ if (folder === "tito")
258
+ continue;
259
+ const abs = join(skillsDir, folder, "SKILL.md");
260
+ const text = readText(abs);
261
+ if (text === null)
262
+ continue;
263
+ reports.push(makeReport(`command:${folder}`, [...alwaysOnFiles, rel(root, abs)], [...ordinaryMissing], sumTokens([...alwaysOnTexts, text])));
264
+ }
265
+ }
266
+ const agentsDir = surfaceDir(root, "agents");
267
+ if (agentsDir !== null) {
268
+ for (const name of listFiles(agentsDir, ".md")) {
269
+ const abs = join(agentsDir, name);
270
+ const text = readText(abs);
271
+ if (text === null)
272
+ continue;
273
+ reports.push(makeReport(`card:${name.replace(/\.md$/, "")}`, [rel(root, abs)], [], estimateTokens(text)));
274
+ }
275
+ }
276
+ return reports;
277
+ }
278
+ export function budgetFailures(reports) {
279
+ const failures = [];
280
+ for (const item of reports) {
281
+ if (item.tokens <= item.budget)
282
+ continue;
283
+ if (item.ceiling !== undefined) {
284
+ if (item.tokens <= item.ceiling)
285
+ continue;
286
+ failures.push(`${item.id}: ${item.tokens} > ceiling ${item.ceiling}`);
287
+ continue;
288
+ }
289
+ failures.push(`${item.id}: ${item.tokens} > budget ${item.budget}`);
290
+ }
291
+ return failures;
292
+ }
@@ -3,11 +3,19 @@ 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
5
  const FIELDS = new Set(["schemaVersion", "profile", "git", "product", "models"]);
6
- export const MODEL_ROLES = ["explorer", "planner", "implementer", "reviewer", "finisher"];
6
+ export const MODEL_ROLES = [
7
+ "explorer",
8
+ "planner",
9
+ "advisor",
10
+ "implementer",
11
+ "reviewer",
12
+ "finisher",
13
+ ];
7
14
  export const DEFAULT_MODELS = Object.freeze({
8
15
  explorer: "cursor-grok-4.6-medium-fast",
9
- planner: "claude-opus-5-5-medium",
10
- implementer: "grok-4.7-high",
16
+ planner: "grok-4.7-high",
17
+ advisor: "claude-opus-5-5-medium",
18
+ implementer: "cursor-grok-4.6-high",
11
19
  reviewer: "grok-4.7-high",
12
20
  finisher: "composer-2.5",
13
21
  });
@@ -53,6 +61,8 @@ function assembleGit(parts) {
53
61
  git.defaultBase = parts.defaultBase;
54
62
  if (parts.autoPullRequest !== undefined)
55
63
  git.autoPullRequest = parts.autoPullRequest;
64
+ if (parts.autoMerge !== undefined)
65
+ git.autoMerge = parts.autoMerge;
56
66
  return Object.keys(git).length > 0 ? git : undefined;
57
67
  }
58
68
  function readGit(node) {
@@ -63,6 +73,7 @@ function readGit(node) {
63
73
  const seen = new Set();
64
74
  let defaultBase;
65
75
  let autoPullRequest = undefined;
76
+ let autoMerge = undefined;
66
77
  for (const item of node.items) {
67
78
  const key = isScalar(item.key) && typeof item.key.value === "string" ? item.key.value : null;
68
79
  if (key === null) {
@@ -94,11 +105,21 @@ function readGit(node) {
94
105
  }
95
106
  continue;
96
107
  }
108
+ if (key === "autoMerge") {
109
+ const value = isScalar(item.value) ? item.value.value : NON_SCALAR;
110
+ if (typeof value !== "boolean") {
111
+ issues.push(issue("unknown-field", 'Field "git.autoMerge" must be a boolean.', "git.autoMerge"));
112
+ }
113
+ else {
114
+ autoMerge = value;
115
+ }
116
+ continue;
117
+ }
97
118
  issues.push(issue("unknown-field", `Unknown field "git.${key}".`, `git.${key}`));
98
119
  }
99
120
  if (issues.length > 0)
100
121
  return { issues };
101
- const git = assembleGit({ defaultBase, autoPullRequest });
122
+ const git = assembleGit({ defaultBase, autoPullRequest, autoMerge });
102
123
  return git ? { git, issues } : { issues };
103
124
  }
104
125
  function isScreenLanguage(value) {
@@ -159,16 +159,18 @@ export function pullRequestDescription(input) {
159
159
  return lines.join("\n");
160
160
  }
161
161
  export function mergeDecision(input) {
162
- if (!input.userAskedToMerge) {
163
- return { allowed: false, reason: "Wait for the user to call for the merge." };
162
+ if (input.userAskedToMerge) {
163
+ return { allowed: true, reason: "The user called for the merge." };
164
164
  }
165
- if (!input.hasApproval) {
166
- return { allowed: false, reason: "The pull request has no approval." };
165
+ if (input.autoMerge === true &&
166
+ input.profile !== "client-careful" &&
167
+ input.touchesEscalation !== true) {
168
+ return {
169
+ allowed: true,
170
+ reason: "git.autoMerge is the standing merge call.",
171
+ };
167
172
  }
168
- return {
169
- allowed: true,
170
- reason: "The user called for the merge and the pull request has an approval.",
171
- };
173
+ return { allowed: false, reason: "Wait for the user to call for the merge." };
172
174
  }
173
175
  export function canApprovePullRequest() {
174
176
  return false;