@anhvupt/tito 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,444 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { AdminError, defaultGitRunner, isSecretFilename, loadRepos, } from "./admin.js";
4
+ import { parseConfig } from "./config.js";
5
+ import { readFeedbackOutcome } from "./feedback.js";
6
+ import { RISK_PROFILES } from "./profiles.js";
7
+ export const PREFIX_RULE_MARKER = "A commit subject is `<type>: <sentence>`.";
8
+ const WEEK_ID = /^(\d{4})-W(\d{2})$/;
9
+ const TYPE_PREFIX = /^(feat|fix|hot-fix|chores|refactor|debug): /;
10
+ const MERGE_PULL_REQUEST = /^Merge pull request #\d+/;
11
+ const MERGE_BRANCH = /^Merge branch(?:\s|$)/;
12
+ const REVIEW_PATH = /^\.tito\/feedback\/[a-z0-9]+(?:-[a-z0-9]+)*\/review\.md$/;
13
+ const QUIET_SUMMARY = "No Tito activity this week.";
14
+ export class RetroError extends Error {
15
+ code;
16
+ constructor(code, message) {
17
+ super(message);
18
+ this.name = "RetroError";
19
+ this.code = code;
20
+ }
21
+ }
22
+ export function machineTimeZone() {
23
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
24
+ }
25
+ export function isIanaTimeZone(timeZone) {
26
+ try {
27
+ Intl.DateTimeFormat("en-US", { timeZone });
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ function weeksInIsoYear(year) {
35
+ const januaryFirst = new Date(Date.UTC(year, 0, 1)).getUTCDay();
36
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
37
+ if (januaryFirst === 4 || (leap && januaryFirst === 3))
38
+ return 53;
39
+ return 52;
40
+ }
41
+ export function parseWeekId(value) {
42
+ const match = WEEK_ID.exec(value);
43
+ if (match === null)
44
+ return null;
45
+ const year = Number(match[1]);
46
+ const week = Number(match[2]);
47
+ if (!Number.isInteger(year) || !Number.isInteger(week))
48
+ return null;
49
+ if (week < 1 || week > weeksInIsoYear(year))
50
+ return null;
51
+ return { year, week };
52
+ }
53
+ function formatWeekId(year, week) {
54
+ return `${year}-W${String(week).padStart(2, "0")}`;
55
+ }
56
+ function partValue(parts, type) {
57
+ return parts.find((part) => part.type === type)?.value ?? null;
58
+ }
59
+ function civilDate(instant, timeZone) {
60
+ const parts = new Intl.DateTimeFormat("en-US", {
61
+ timeZone,
62
+ year: "numeric",
63
+ month: "2-digit",
64
+ day: "2-digit",
65
+ }).formatToParts(instant);
66
+ const year = partValue(parts, "year");
67
+ const month = partValue(parts, "month");
68
+ const day = partValue(parts, "day");
69
+ if (year === null || month === null || day === null) {
70
+ throw new RetroError("invalid-timezone", `Timezone "${timeZone}" is not an IANA name.`);
71
+ }
72
+ return { year: Number(year), month: Number(month), day: Number(day) };
73
+ }
74
+ function isoWeekOfCivil(civil) {
75
+ const date = new Date(Date.UTC(civil.year, civil.month - 1, civil.day));
76
+ const weekday = date.getUTCDay() || 7;
77
+ date.setUTCDate(date.getUTCDate() + 4 - weekday);
78
+ const year = date.getUTCFullYear();
79
+ const yearStart = new Date(Date.UTC(year, 0, 1));
80
+ const dayIndex = Math.round((date.getTime() - yearStart.getTime()) / 86400000);
81
+ const week = Math.ceil((dayIndex + 1) / 7);
82
+ return { year, week };
83
+ }
84
+ export function isoWeekId(instant, timeZone) {
85
+ if (!isIanaTimeZone(timeZone)) {
86
+ throw new RetroError("invalid-timezone", `Timezone "${timeZone}" is not an IANA name.`);
87
+ }
88
+ const week = isoWeekOfCivil(civilDate(instant, timeZone));
89
+ return formatWeekId(week.year, week.week);
90
+ }
91
+ function timeZoneOffsetMs(instant, timeZone) {
92
+ const parts = new Intl.DateTimeFormat("en-US", {
93
+ timeZone,
94
+ hourCycle: "h23",
95
+ year: "numeric",
96
+ month: "2-digit",
97
+ day: "2-digit",
98
+ hour: "2-digit",
99
+ minute: "2-digit",
100
+ second: "2-digit",
101
+ }).formatToParts(instant);
102
+ const year = partValue(parts, "year");
103
+ const month = partValue(parts, "month");
104
+ const day = partValue(parts, "day");
105
+ const minute = partValue(parts, "minute");
106
+ const second = partValue(parts, "second");
107
+ let hour = partValue(parts, "hour");
108
+ if (year === null || month === null || day === null || hour === null || minute === null || second === null) {
109
+ throw new RetroError("invalid-timezone", `Timezone "${timeZone}" is not an IANA name.`);
110
+ }
111
+ if (hour === "24")
112
+ hour = "00";
113
+ const asUtc = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second));
114
+ return asUtc - instant.getTime();
115
+ }
116
+ function zonedMidnightUtc(civil, timeZone) {
117
+ const guess = Date.UTC(civil.year, civil.month - 1, civil.day, 0, 0, 0, 0);
118
+ const first = guess - timeZoneOffsetMs(new Date(guess), timeZone);
119
+ const second = guess - timeZoneOffsetMs(new Date(first), timeZone);
120
+ return new Date(second);
121
+ }
122
+ function addDays(civil, days) {
123
+ const date = new Date(Date.UTC(civil.year, civil.month - 1, civil.day + days));
124
+ return {
125
+ year: date.getUTCFullYear(),
126
+ month: date.getUTCMonth() + 1,
127
+ day: date.getUTCDate(),
128
+ };
129
+ }
130
+ function isoWeekMonday(year, week) {
131
+ const januaryFourth = new Date(Date.UTC(year, 0, 4));
132
+ const weekday = januaryFourth.getUTCDay() || 7;
133
+ const monday = new Date(januaryFourth);
134
+ monday.setUTCDate(januaryFourth.getUTCDate() - (weekday - 1) + (week - 1) * 7);
135
+ return {
136
+ year: monday.getUTCFullYear(),
137
+ month: monday.getUTCMonth() + 1,
138
+ day: monday.getUTCDate(),
139
+ };
140
+ }
141
+ export function weekRange(weekId, timeZone) {
142
+ const parsed = parseWeekId(weekId);
143
+ if (parsed === null) {
144
+ throw new RetroError("invalid-week", `Week "${weekId}" is not an ISO week (YYYY-Www).`);
145
+ }
146
+ if (!isIanaTimeZone(timeZone)) {
147
+ throw new RetroError("invalid-timezone", `Timezone "${timeZone}" is not an IANA name.`);
148
+ }
149
+ const monday = isoWeekMonday(parsed.year, parsed.week);
150
+ return {
151
+ start: zonedMidnightUtc(monday, timeZone),
152
+ end: zonedMidnightUtc(addDays(monday, 7), timeZone),
153
+ };
154
+ }
155
+ function isMergeSubject(subject) {
156
+ return MERGE_PULL_REQUEST.test(subject) || MERGE_BRANCH.test(subject);
157
+ }
158
+ export function countPrefixMisses(commits, ruleLandedAt, range) {
159
+ if (ruleLandedAt === null)
160
+ return 0;
161
+ const landed = Date.parse(ruleLandedAt);
162
+ if (Number.isNaN(landed))
163
+ return 0;
164
+ const start = range.start.getTime();
165
+ const end = range.end.getTime();
166
+ let count = 0;
167
+ for (const commit of commits) {
168
+ const at = Date.parse(commit.date);
169
+ if (Number.isNaN(at) || at < start || at >= end || at < landed)
170
+ continue;
171
+ if (isMergeSubject(commit.subject) || TYPE_PREFIX.test(commit.subject))
172
+ continue;
173
+ count += 1;
174
+ }
175
+ return count;
176
+ }
177
+ export function countReviewOutcomes(texts) {
178
+ const outcomes = {
179
+ accepted: 0,
180
+ edited: 0,
181
+ expanded: 0,
182
+ unknown: 0,
183
+ };
184
+ for (const text of texts) {
185
+ outcomes[readFeedbackOutcome(text)] += 1;
186
+ }
187
+ return outcomes;
188
+ }
189
+ function emptyOutcomes() {
190
+ return { accepted: 0, edited: 0, expanded: 0, unknown: 0 };
191
+ }
192
+ function authoredFileLimit(profile) {
193
+ const value = RISK_PROFILES[profile];
194
+ return "maxAuthoredFiles" in value ? value.maxAuthoredFiles : null;
195
+ }
196
+ function sliceFileLimit(repoPath) {
197
+ const configPath = join(repoPath, "tito.yaml");
198
+ if (!existsSync(configPath))
199
+ return null;
200
+ try {
201
+ return authoredFileLimit(parseConfig(readFileSync(configPath, "utf8")).profile);
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ }
207
+ function countSliceOverLimit(commits, limit) {
208
+ if (limit === null)
209
+ return 0;
210
+ let count = 0;
211
+ for (const commit of commits) {
212
+ if (commit.files.length > limit)
213
+ count += 1;
214
+ }
215
+ return count;
216
+ }
217
+ function countMerged(commits) {
218
+ let count = 0;
219
+ for (const commit of commits) {
220
+ if (MERGE_PULL_REQUEST.test(commit.subject))
221
+ count += 1;
222
+ }
223
+ return count;
224
+ }
225
+ function summarize(commitCount, prefixMisses, sliceOverLimit, merged, outcomes) {
226
+ if (commitCount === 0)
227
+ return QUIET_SUMMARY;
228
+ const noun = commitCount === 1 ? "commit" : "commits";
229
+ return `${commitCount} ${noun}; prefix misses ${prefixMisses}; slice over limit ${sliceOverLimit}; merged ${merged}; outcomes ${outcomes.accepted} accepted, ${outcomes.edited} edited, ${outcomes.expanded} expanded, ${outcomes.unknown} unknown.`;
230
+ }
231
+ function isDirectory(path) {
232
+ try {
233
+ return statSync(path).isDirectory();
234
+ }
235
+ catch {
236
+ return false;
237
+ }
238
+ }
239
+ function tryGit(git, cwd, args) {
240
+ try {
241
+ return git(cwd, args);
242
+ }
243
+ catch {
244
+ return null;
245
+ }
246
+ }
247
+ function gitInstant(date) {
248
+ return date.toISOString().replace(/\.\d{3}Z$/, "Z");
249
+ }
250
+ function parseRetroLog(stdout) {
251
+ const commits = [];
252
+ for (const block of stdout.split(/\n(?=◆)/)) {
253
+ const trimmed = block.trim();
254
+ if (!trimmed.startsWith("◆"))
255
+ continue;
256
+ const lines = trimmed.split("\n");
257
+ const header = lines[0];
258
+ if (header === undefined || !header.startsWith("◆"))
259
+ continue;
260
+ const parts = header.slice(1).split("\0");
261
+ const hash = parts[0] ?? "";
262
+ const subject = parts[1] ?? "";
263
+ const date = parts[2] ?? "";
264
+ if (hash.length === 0)
265
+ continue;
266
+ const files = [];
267
+ for (const line of lines.slice(1)) {
268
+ const file = line.trim();
269
+ if (file.length === 0 || isSecretFilename(file))
270
+ continue;
271
+ files.push(file);
272
+ }
273
+ commits.push({ hash, subject, date, files });
274
+ }
275
+ return commits;
276
+ }
277
+ function inRange(date, range) {
278
+ const at = Date.parse(date);
279
+ return !Number.isNaN(at) && at >= range.start.getTime() && at < range.end.getTime();
280
+ }
281
+ function readWeekCommits(git, repoPath, range) {
282
+ const since = gitInstant(new Date(range.start.getTime() - 1000));
283
+ const log = git(repoPath, [
284
+ "log",
285
+ `--since=${since}`,
286
+ `--until=${gitInstant(range.end)}`,
287
+ "--pretty=format:◆%H%x00%s%x00%cI",
288
+ "--name-only",
289
+ ]);
290
+ return parseRetroLog(log).filter((commit) => inRange(commit.date, range));
291
+ }
292
+ function ruleLandedAt(git, repoPath) {
293
+ const log = git(repoPath, [
294
+ "log",
295
+ "--reverse",
296
+ "--pretty=format:%H%x00%cI",
297
+ "--",
298
+ "AGENTS.md",
299
+ ]);
300
+ for (const line of log.split("\n")) {
301
+ const trimmed = line.trim();
302
+ if (trimmed.length === 0)
303
+ continue;
304
+ const [hash, date] = trimmed.split("\0");
305
+ if (hash === undefined || date === undefined || hash.length === 0 || date.length === 0)
306
+ continue;
307
+ const text = tryGit(git, repoPath, ["show", `${hash}:AGENTS.md`]);
308
+ if (text !== null && text.includes(PREFIX_RULE_MARKER))
309
+ return date;
310
+ }
311
+ return null;
312
+ }
313
+ function reviewTexts(git, repoPath, commits) {
314
+ const hashesByPath = new Map();
315
+ for (const commit of commits) {
316
+ for (const file of commit.files) {
317
+ if (!REVIEW_PATH.test(file))
318
+ continue;
319
+ const hashes = hashesByPath.get(file);
320
+ if (hashes === undefined)
321
+ hashesByPath.set(file, [commit.hash]);
322
+ else
323
+ hashes.push(commit.hash);
324
+ }
325
+ }
326
+ const texts = [];
327
+ for (const [file, hashes] of hashesByPath) {
328
+ for (const hash of hashes) {
329
+ const text = tryGit(git, repoPath, ["show", `${hash}:${file}`]);
330
+ if (text === null)
331
+ continue;
332
+ texts.push(text);
333
+ break;
334
+ }
335
+ }
336
+ return texts;
337
+ }
338
+ export function buildRetro(options) {
339
+ const timezone = options.timezone ?? machineTimeZone();
340
+ if (!isIanaTimeZone(timezone)) {
341
+ throw new RetroError("invalid-timezone", `Timezone "${timezone}" is not an IANA name.`);
342
+ }
343
+ const week = options.week ?? isoWeekId(options.now ?? new Date(), timezone);
344
+ if (parseWeekId(week) === null) {
345
+ throw new RetroError("invalid-week", `Week "${week}" is not an ISO week (YYYY-Www).`);
346
+ }
347
+ const range = weekRange(week, timezone);
348
+ const git = options.git ?? defaultGitRunner;
349
+ const outcomes = emptyOutcomes();
350
+ const repos = [];
351
+ let prefixMisses = 0;
352
+ let sliceOverLimit = 0;
353
+ let merged = 0;
354
+ let commitCount = 0;
355
+ for (const repo of loadRepos(options.adminRoot)) {
356
+ if (!isDirectory(repo.path)) {
357
+ repos.push({ path: repo.path, missing: true });
358
+ continue;
359
+ }
360
+ repos.push({ path: repo.path });
361
+ const commits = readWeekCommits(git, repo.path, range);
362
+ commitCount += commits.length;
363
+ prefixMisses += countPrefixMisses(commits, ruleLandedAt(git, repo.path), range);
364
+ sliceOverLimit += countSliceOverLimit(commits, sliceFileLimit(repo.path));
365
+ merged += countMerged(commits);
366
+ const counted = countReviewOutcomes(reviewTexts(git, repo.path, commits));
367
+ outcomes.accepted += counted.accepted;
368
+ outcomes.edited += counted.edited;
369
+ outcomes.expanded += counted.expanded;
370
+ outcomes.unknown += counted.unknown;
371
+ }
372
+ return {
373
+ week,
374
+ timezone,
375
+ source: "local",
376
+ summary: summarize(commitCount, prefixMisses, sliceOverLimit, merged, outcomes),
377
+ alerts: [],
378
+ outcomes,
379
+ prefixMisses,
380
+ sliceOverLimit,
381
+ merged,
382
+ reviewFixes: "unavailable",
383
+ docsBlank: "unavailable",
384
+ mergeDuration: "unavailable",
385
+ repos,
386
+ };
387
+ }
388
+ export function formatRetro(report, options = {}) {
389
+ const lines = [
390
+ `${report.week} (${report.timezone}, ${report.source})`,
391
+ report.summary,
392
+ "reviewFixes: unavailable",
393
+ "docsBlank: unavailable",
394
+ "mergeDuration: unavailable",
395
+ ];
396
+ if (options.alerts === true)
397
+ lines.push("alerts: []");
398
+ for (const repo of report.repos) {
399
+ if (repo.missing === true)
400
+ lines.push(`missing: ${repo.path}`);
401
+ }
402
+ return `${lines.join("\n")}\n`;
403
+ }
404
+ export function formatRetroMarkdown(report) {
405
+ const outcomes = report.outcomes;
406
+ const lines = [
407
+ `# ${report.week}`,
408
+ "",
409
+ `Timezone: ${report.timezone}. Source: local.`,
410
+ "",
411
+ report.summary,
412
+ "",
413
+ `Prefix misses: ${report.prefixMisses}. Slice over limit: ${report.sliceOverLimit}. Merged: ${report.merged}.`,
414
+ `Outcomes: ${outcomes.accepted} accepted, ${outcomes.edited} edited, ${outcomes.expanded} expanded, ${outcomes.unknown} unknown.`,
415
+ "Review fixes: unavailable. Docs blank: unavailable. Merge duration: unavailable.",
416
+ "Alerts: none.",
417
+ ];
418
+ const missing = report.repos.filter((repo) => repo.missing === true);
419
+ if (missing.length > 0) {
420
+ lines.push("", "Missing repos:");
421
+ for (const repo of missing)
422
+ lines.push(`- ${repo.path}`);
423
+ }
424
+ return `${lines.join("\n")}\n`;
425
+ }
426
+ export function retroJson(report) {
427
+ return `${JSON.stringify(report, null, 2)}\n`;
428
+ }
429
+ function writeTextAtomic(path, text) {
430
+ mkdirSync(dirname(path), { recursive: true });
431
+ const tmp = `${path}.${process.pid}.tmp`;
432
+ try {
433
+ writeFileSync(tmp, text, "utf8");
434
+ renameSync(tmp, path);
435
+ }
436
+ catch (error) {
437
+ throw new AdminError("filesystem", path, error instanceof Error ? error.message : `Cannot write ${path}.`);
438
+ }
439
+ }
440
+ export function saveRetro(adminRoot, report) {
441
+ const dir = join(resolve(adminRoot), "retros");
442
+ writeTextAtomic(join(dir, `${report.week}.json`), retroJson(report));
443
+ writeTextAtomic(join(dir, `${report.week}.md`), formatRetroMarkdown(report));
444
+ }
@@ -63,7 +63,7 @@ export const SPECIALISTS = Object.freeze({
63
63
  triggers: ["where code lives", "how behavior works"],
64
64
  knowledge: [knowledge("explorer-scout", "always")],
65
65
  modes: [specialistMode("scout", "read-only", phase.discovery)],
66
- stopCondition: "Return evidence, gaps, and the next specialist.",
66
+ stopCondition: "When scouting for a plan, report the principles and project conventions relevant to the decisions, and where each convention lives (AGENTS.md, a .cursor/rules file, or an existing pattern). Stay read-only. Return evidence, gaps, and the next specialist to Tito.",
67
67
  }),
68
68
  "product-analyst": manifest("product-analyst", {
69
69
  tier: "Standard",
@@ -133,7 +133,7 @@ export const SPECIALISTS = Object.freeze({
133
133
  triggers: ["review correctness", "check edge cases"],
134
134
  knowledge: [knowledge("qa-review", "always")],
135
135
  modes: [specialistMode("review", "read-only", phase.review)],
136
- stopCondition: "Return findings without editing the slice.",
136
+ stopCondition: "For a plan, return Arrange / Act / Assert test cases with an ID and a layer tag `[unit]`, `[integration]`, or `[e2e]`. When tenancy is multi or more than one surface is declared, draft the applicable kinds: isolation, consistency, propagation, and permissions. Integration is the default. End-to-end only when the plan names a journey. After coding, check each approved test ID as passing or failing, and flag any test that was not in the approved list. Stay read-only. Return findings without editing the slice.",
137
137
  }),
138
138
  "security-reviewer": manifest("security-reviewer", {
139
139
  tier: "Reasoning",
@@ -149,7 +149,7 @@ export const SPECIALISTS = Object.freeze({
149
149
  triggers: ["module finished", "update technical documentation"],
150
150
  knowledge: [knowledge("tech-docs", "task")],
151
151
  modes: [specialistMode("update", "write-files", phase.implementation)],
152
- stopCondition: "Stop after the technical docs match the finished module.",
152
+ stopCondition: "Apply the plan's Docs impact (update named docs, or record a waiver) before a pull request. The module-end pass still covers larger docs. Stop after that docs work matches the plan.",
153
153
  }),
154
154
  "user-docs-writer": manifest("user-docs-writer", {
155
155
  tier: "Standard",
@@ -157,7 +157,7 @@ export const SPECIALISTS = Object.freeze({
157
157
  triggers: ["module finished", "update user documentation"],
158
158
  knowledge: [knowledge("user-docs", "task")],
159
159
  modes: [specialistMode("update", "write-files", phase.implementation)],
160
- stopCondition: "Stop after the user docs match the finished module.",
160
+ stopCondition: "Apply the plan's Docs impact (update named docs, or record a waiver) before a pull request. The module-end pass still covers larger docs. Stop after that docs work matches the plan.",
161
161
  }),
162
162
  });
163
163
  export class DelegationError extends Error {
@@ -176,7 +176,8 @@ function reject(code, message) {
176
176
  function isSpecialistId(value) {
177
177
  return SPECIALIST_IDS.includes(value);
178
178
  }
179
- export function validateDelegation(activations, phaseName) {
179
+ const DOCUMENTATION_WRITERS = new Set(["tech-docs-writer", "user-docs-writer"]);
180
+ export function validateDelegation(activations, phaseName, maxWriters = 1) {
180
181
  const seen = new Set();
181
182
  const resolved = [];
182
183
  for (const activation of activations) {
@@ -210,11 +211,16 @@ export function validateDelegation(activations, phaseName) {
210
211
  });
211
212
  }
212
213
  const mutators = resolved.filter((item) => item.capability !== "read-only");
213
- if (mutators.length > 1) {
214
- reject("multiple-mutators", "Only one specialist mode may mutate at a time.");
214
+ const documentationWriters = mutators.filter((item) => DOCUMENTATION_WRITERS.has(item.specialistId));
215
+ if (documentationWriters.length > 1) {
216
+ reject("multiple-mutators", "Documentation writers run one at a time.");
217
+ }
218
+ if (mutators.length > maxWriters) {
219
+ reject("multiple-mutators", `At most ${maxWriters} specialist modes may mutate at a time.`);
215
220
  }
216
221
  return {
217
- mutator: mutators[0] ?? null,
222
+ mutator: mutators.length === 1 ? (mutators[0] ?? null) : null,
223
+ mutators,
218
224
  advisers: resolved.filter((item) => item.capability === "read-only"),
219
225
  };
220
226
  }
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { compiledCursorAgents } from "./agents.js";
4
+ import { PULL_REQUEST_TEMPLATE_PATH, pullRequestTemplate } from "./git-flow.js";
4
5
  import { shippedSkills } from "./init.js";
5
6
  import { TITO_BOOTSTRAP_END, TITO_BOOTSTRAP_START, titoBootstrapBlock, } from "./plan.js";
6
7
  function readIfFile(root, path) {
@@ -52,7 +53,11 @@ export function planUpgrade(root, ownedFiles) {
52
53
  return files;
53
54
  }
54
55
  export function titoOwnedFiles() {
55
- return [...compiledCursorAgents(), ...shippedSkills()];
56
+ return [
57
+ ...compiledCursorAgents(),
58
+ ...shippedSkills(),
59
+ { path: PULL_REQUEST_TEMPLATE_PATH, body: pullRequestTemplate() },
60
+ ];
56
61
  }
57
62
  export function formatUpgrade(root, files) {
58
63
  const lines = [`root: ${root}`, "consumer rules: untouched"];
@@ -1,5 +1,5 @@
1
1
  import { LIFECYCLE_STATES, } from "./lifecycle.js";
2
- import { isActiveRiskProfileId, } from "./profiles.js";
2
+ import { RISK_PROFILES, isActiveRiskProfileId, } from "./profiles.js";
3
3
  import { SPECIALISTS, } from "./specialists.js";
4
4
  export class WorkPlanError extends Error {
5
5
  code;
@@ -177,14 +177,15 @@ export function evaluateWorkPlan(plan, progress) {
177
177
  }
178
178
  const activeWriters = validated.slices.filter((slice) => slice.kind === "implementation" &&
179
179
  progress.implementationStates[slice.id] === "IMPLEMENTING");
180
- if (activeWriters.length > 1) {
181
- reject("multiple-active-writers", "Only one implementation slice may be active.");
180
+ const maxWriters = RISK_PROFILES[validated.profile].maxWriters;
181
+ if (activeWriters.length > maxWriters) {
182
+ reject("multiple-active-writers", `At most ${maxWriters} implementation slices may be active.`);
182
183
  }
183
184
  const unreviewedSlices = validated.slices.filter((slice) => slice.kind === "implementation" &&
184
185
  progress.implementationStates[slice.id] === "READY_FOR_REVIEW");
185
186
  if (validated.profile === "client-careful" &&
186
- activeWriters.length + unreviewedSlices.length > 1) {
187
- reject("invalid-progress", "Client-careful cannot stack unreviewed work.");
187
+ activeWriters.length + unreviewedSlices.length > maxWriters) {
188
+ reject("invalid-progress", "Client-careful cannot stack unreviewed work beyond its writer cap.");
188
189
  }
189
190
  const dependencyReasons = (slice) => {
190
191
  const reasons = [];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anhvupt/tito",
3
- "version": "0.1.3",
4
- "description": "Local-first AI development harness for solo builders.",
3
+ "version": "0.2.0",
4
+ "description": "Chat-first engineering coordinator for solo builders. The plan is the spec: decisions, test cases, and docs.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "bin": {
@@ -6,33 +6,81 @@ disable-model-invocation: true
6
6
 
7
7
  # Tito
8
8
 
9
- Act as the root Tito coordinator in this project.
9
+ Act as the root Tito coordinator in the active Cursor chat.
10
10
  Start every response exactly with `Hola, Tito here!`
11
+ Sometimes add one short joke after that greeting. The joke does not replace the answer. Skip it when the user is blocked, when the news is bad, and in CLI or machine-readable output.
11
12
 
12
- Ordinary chat follows `AGENTS.md`. `/tito` is the explicit coordinator. Read `tito.yaml` for the risk profile. Read project documentation only when the task needs it. Do not replace existing project guidance.
13
+ ## Authority
14
+
15
+ Read `TITO-INITIAL-BRIEF.md` completely before planning or editing when that brief is present. Treat it as
16
+ the product contract and source of workflow, risk, delegation, and review
17
+ policy. Ordinary chat follows `AGENTS.md`. `/tito` is the explicit coordinator. Read `tito.yaml` for the risk profile. Read other project documentation only when relevant. Do not replace existing project guidance.
18
+
19
+ Tito owns workflow state. Do not infer it from Cursor UI state, private
20
+ storage, undocumented payloads, or transcript internals.
13
21
 
14
22
  ## Route the task
15
23
 
16
24
  Recommend one mode before acting:
17
25
 
18
26
  - **Ask** for read-only exploration, explanation, impact analysis, or diagnosis.
19
- - **Plan** for ambiguity, architecture, sensitive work, migrations, or work requiring multiple reviewable slices.
27
+ - **Plan** for ambiguity, architecture, sensitive work, migrations, or work
28
+ requiring multiple reviewable slices.
20
29
  - **Agent** only for one explicitly approved implementation slice.
21
30
 
22
31
  A new command, a new write behavior, or any technical choice is always Plan
23
- first. That response contains the plan only. No source edits. "No need to plan"
24
- applies only to the slice named in that message. Implementation starts only
25
- after that plan is approved.
32
+ first. That response contains the plan only. No source edits. Skip the plan only when the user clearly instructs that this slice does not need a plan. Implementation starts only
33
+ after that plan is approved. Cursor being in Agent mode does not approve a slice.
34
+
35
+ Suggest the source branch and change type before checkout. Bases are `dev`, `develop`, `main`, and `master`. `dev` and `develop` are interchangeable. `main` and `master` are interchangeable. Check out only after the user accepts. 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 the user reviews a plan, save Tito's plan and the user's edit as separate files under `.tito/feedback/<slug>/`. `review.md` starts with front matter `outcome: accepted | edited | expanded`. After an approved slice is coded, put every review fix into one plan named `review/<slug>` on the same branch. Ask before opening a pull request only after that plan is coded, or when the user accepts 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. Init creates `.github/pull_request_template.md` from Tito's template when it is missing. Upgrade replaces that file with Tito's template. Never approve a pull request. Never merge unless the user calls for the merge and the pull request already has an approval. After a pull request is merged, ask before the next slice. Switch back to the base branch only when the user says so clearly. Push directly to the base branch only when the user clearly instructs that push.
36
+
37
+ For Plan work, prefer a Reasoning-tier model unless the plan is obvious and
38
+ bounded or the user chose another model. The implementation agent follows the approved decision and does not invent a new one. Include concise guidance code,
39
+ signatures, schemas, or pseudocode where it removes ambiguity.
40
+
41
+ Discover restates the goal and what is out of scope in one or two sentences, then asks whether that is what the user wants, and waits. After the user confirms, ask one batch of 3 to 5 clarifying questions. Each question offers options and a recommended default. `solo-fast` does only the intent check, plus questions about real ambiguity. `client-careful` and `solo-balanced` use the full batch. One obvious reading still continues, after a one-line confirmation. Ask before locking a technical decision or a product-vision change. After the user answers, the plan records that decision and includes guidance code when it removes implementation ambiguity.
42
+
43
+ The workflow is Discover → Plan → Human Approval → Code → Verify → Docs gate → Review. Every plan has Decisions (each names the principle and the project convention it follows, with where that convention lives), Test cases (each has an ID and a layer tag `[unit]`, `[integration]`, or `[e2e]`; behavior tests use Arrange / Act / Assert; edge cases are one line each), Docs impact, and Slices and branch. The approved plan is the spec. For a single slice, the user may say "skip test discussion" or "skip docs", in the same spirit as skipping the plan.
26
44
 
27
- For Plan work, the planner decides the technical approach and includes guidance code when it removes ambiguity. Ask the user only for a genuine product or business choice.
45
+ - Code stays English.
46
+ - 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.
47
+ - An English app (`screenLanguage: en`) keeps English routes and slugs.
48
+ - In a Vietnamese app (`screenLanguage: vi`), every string a person reads is Vietnamese: tables, labels, buttons, headings, and messages. Write native Vietnamese first. Do not invent it by translating English word for word. English may exist as a second field only when the product is bilingual, and it comes after the Vietnamese.
49
+ - An English app (`screenLanguage: en`) stays English on screen.
50
+ - When `screenLanguage` is missing and the screen language is unclear, Tito asks once. One obvious reading continues without a question.
51
+ - Globalized apps store timestamps in UTC and show them in the user's timezone. There is no switch to turn that off.
52
+
53
+ Optional `product.tenancy` is `single` or `multi`. Optional `product.surfaces` is a list of `{ id }` entries. Missing `tenancy` and missing `surfaces` stay valid.
54
+
55
+ A plan that touches shared data includes only the kinds that apply:
56
+
57
+ - Isolation: tenant A cannot read or change tenant B's data. Required when `tenancy` is `multi` and the slice changes tenant-scoped data.
58
+ - Consistency: a write on one surface is the value another surface reads. Required when there are two or more surfaces and the slice changes data more than one surface reads.
59
+ - Propagation: async work is asserted before a deadline, not after a fixed sleep.
60
+ - Permissions: each role on each surface can do only what it should.
61
+ - Default layer is `[integration]`. `[e2e]` only when the plan names a browser journey. `[unit]` stays for logic that does not cross a surface.
62
+ - Arrange uses a fixed seed of at least two tenants and every role when tenancy is multi, reset each run, never production credentials. Tito does not ship the seed or Playwright.
28
63
 
29
64
  ## Execute
30
65
 
31
- 1. Inspect the repository without mutation and preserve uncommitted work.
32
- 2. State facts, affected files, uncertainties, risks, and the recommended mode.
33
- 3. For Plan work, produce independent slices and stop for approval.
34
- 4. Use one code writer. Specialist advisers and reviewers stay read-only.
35
- 5. Implement and verify only the approved slice, then stop for review.
36
- 6. After a finished module, schedule the tech docs writer and then the user docs writer unless the user waives that handoff.
66
+ 1. Inspect the repository without mutation and preserve uncommitted work. When a Tito command exists, use that same core behavior in chat instead of sending the person to the terminal.
67
+ 2. Discover: restate the goal and what is out of scope, confirm that reading, then ask the clarifying batch the risk profile calls for. State facts, affected files, uncertainties, risks, and the recommended mode.
68
+ 3. For Plan work, produce one plan with Decisions, Test cases, Docs impact, and Slices and branch, then stop for approval. The approved plan is the spec.
69
+ 4. Before Agent work, provide the implementation handoff required by the brief.
70
+ 5. Tito does not code in this chat. Send every change, including a small one, to a sub-agent, then return to the user. `client-careful` may run 3 coding sub-agents, `solo-balanced` 6, and `solo-fast` 12. Each has its own plan and branch. Documentation writers stay one at a time. Specialist advisers and reviewers stay read-only.
71
+ 6. Code the approved slice by writing the approved tests first, confirming they fail, then implementing until they pass. A test beyond the approved list is flagged as new. A bug fix starts with a test that reproduces the bug.
72
+ 7. Verify: report each approved test ID as passing or failing, plus lint and build.
73
+ 8. Docs gate: update the docs named in Docs impact, or record the user's waiver, before offering a pull request. After a finished module, schedule the tech docs writer and then the user docs writer for larger docs, one at a time, unless the user waives that handoff.
74
+ 9. Provide the required review handoff and stop. The short code-review walkthrough stays. Review fixes still go into one plan named `review/<slug>` on the same branch.
75
+
76
+ Use Tito's durable lifecycle:
77
+ `DISCOVERY → PLANNED → APPROVED → IMPLEMENTING → READY_FOR_REVIEW → APPROVED_FOR_COMMIT → DONE`.
78
+ `DISCOVERY` is the intent check, then clarify. `PLANNED` means the plan has Decisions, Test cases, Docs impact, and Slices and branch.
79
+
80
+ Per-project Tito stays. Opt-in `tito admin add|list|remove|refresh` indexes
81
+ registered repos under `~/.config/tito/admin/` with commit subject, date, and
82
+ paths only — never diffs. A project `tito.yaml` overrides a personal default;
83
+ do not weaken the safety floor.
37
84
 
38
- Never commit, push, publish, deploy, or perform irreversible external actions without explicit approval.
85
+ Never commit, push, publish, deploy, or perform irreversible external actions
86
+ without explicit approval.