@anhvupt/tito 0.3.3 → 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 +39 -11
- package/dist/cli.js +96 -1
- package/dist/core/agents.js +2 -0
- package/dist/core/budgets.js +292 -0
- package/dist/core/config.js +26 -5
- package/dist/core/git-flow.js +10 -8
- package/dist/core/hooks.js +303 -0
- package/dist/core/init.js +120 -8
- package/dist/core/models.js +6 -1
- package/dist/core/specialists.js +26 -2
- package/dist/core/upgrade.js +14 -1
- package/dist/core/usage-eval.js +81 -0
- package/package.json +4 -2
- package/templates/cursor/AGENTS.md +5 -6
- package/templates/cursor/hooks.json +18 -0
- package/templates/cursor/rules/context-budgets.mdc +22 -0
- package/templates/cursor/rules/delegation.mdc +10 -0
- package/templates/cursor/rules/lifecycle.mdc +10 -0
- package/templates/cursor/rules/model-routing.mdc +12 -0
- package/templates/cursor/rules/risk-and-escalation.mdc +44 -0
- package/templates/cursor/skills/tito/SKILL.md +12 -80
- package/templates/cursor/skills/tito/references/execute.md +39 -0
- package/templates/cursor/skills/tito/references/packets.md +23 -0
- package/templates/cursor/skills/tito-local-startup/SKILL.md +14 -0
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
|
|
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.
|
|
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
|
|
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
|
|
|
@@ -136,8 +142,10 @@ npx tito upgrade --confirm
|
|
|
136
142
|
|
|
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
|
-
`AGENTS.md
|
|
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,
|
|
145
|
+
`AGENTS.md`, which includes the stop that shows the test cases before any source edit. Consumer rules, consumer
|
|
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,15 +276,16 @@ 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:
|
|
264
|
-
|
|
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
|
-
finisher:
|
|
288
|
+
finisher: composer-2.5
|
|
267
289
|
```
|
|
268
290
|
|
|
269
291
|
- Code stays English.
|
|
@@ -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
|
|
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;
|
package/dist/core/agents.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/config.js
CHANGED
|
@@ -3,13 +3,21 @@ 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 = [
|
|
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: "
|
|
10
|
-
|
|
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
|
-
finisher: "
|
|
20
|
+
finisher: "composer-2.5",
|
|
13
21
|
});
|
|
14
22
|
export const SCREEN_LANGUAGES = ["vi", "en"];
|
|
15
23
|
export const TENANCY_VALUES = ["single", "multi"];
|
|
@@ -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) {
|
package/dist/core/git-flow.js
CHANGED
|
@@ -159,16 +159,18 @@ export function pullRequestDescription(input) {
|
|
|
159
159
|
return lines.join("\n");
|
|
160
160
|
}
|
|
161
161
|
export function mergeDecision(input) {
|
|
162
|
-
if (
|
|
163
|
-
return { allowed:
|
|
162
|
+
if (input.userAskedToMerge) {
|
|
163
|
+
return { allowed: true, reason: "The user called for the merge." };
|
|
164
164
|
}
|
|
165
|
-
if (
|
|
166
|
-
|
|
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;
|