@anhvupt/tito 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -24
- package/dist/cli.js +195 -8
- package/dist/core/admin.js +241 -0
- package/dist/core/agents.js +4 -1
- package/dist/core/config.js +225 -3
- package/dist/core/feedback.js +68 -0
- package/dist/core/git-flow.js +174 -0
- package/dist/core/init.js +124 -3
- package/dist/core/models.js +116 -0
- package/dist/core/plan.js +4 -0
- package/dist/core/profiles.js +6 -6
- package/dist/core/retro.js +442 -0
- package/dist/core/specialists.js +27 -15
- package/dist/core/terminal.js +9 -0
- package/dist/core/upgrade.js +65 -2
- package/dist/core/work-plan.js +6 -5
- package/package.json +2 -2
- package/templates/cursor/skills/tito/SKILL.md +73 -14
- package/templates/cursor/skills/tito-admin-retro/SKILL.md +12 -0
|
@@ -0,0 +1,442 @@
|
|
|
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) {
|
|
389
|
+
const lines = [
|
|
390
|
+
`${report.week} (${report.timezone}, ${report.source})`,
|
|
391
|
+
report.summary,
|
|
392
|
+
"reviewFixes: unavailable",
|
|
393
|
+
"docsBlank: unavailable",
|
|
394
|
+
"mergeDuration: unavailable",
|
|
395
|
+
];
|
|
396
|
+
for (const repo of report.repos) {
|
|
397
|
+
if (repo.missing === true)
|
|
398
|
+
lines.push(`missing: ${repo.path}`);
|
|
399
|
+
}
|
|
400
|
+
return `${lines.join("\n")}\n`;
|
|
401
|
+
}
|
|
402
|
+
export function formatRetroMarkdown(report) {
|
|
403
|
+
const outcomes = report.outcomes;
|
|
404
|
+
const lines = [
|
|
405
|
+
`# ${report.week}`,
|
|
406
|
+
"",
|
|
407
|
+
`Timezone: ${report.timezone}. Source: local.`,
|
|
408
|
+
"",
|
|
409
|
+
report.summary,
|
|
410
|
+
"",
|
|
411
|
+
`Prefix misses: ${report.prefixMisses}. Slice over limit: ${report.sliceOverLimit}. Merged: ${report.merged}.`,
|
|
412
|
+
`Outcomes: ${outcomes.accepted} accepted, ${outcomes.edited} edited, ${outcomes.expanded} expanded, ${outcomes.unknown} unknown.`,
|
|
413
|
+
"Review fixes: unavailable. Docs blank: unavailable. Merge duration: unavailable.",
|
|
414
|
+
"Alerts: none.",
|
|
415
|
+
];
|
|
416
|
+
const missing = report.repos.filter((repo) => repo.missing === true);
|
|
417
|
+
if (missing.length > 0) {
|
|
418
|
+
lines.push("", "Missing repos:");
|
|
419
|
+
for (const repo of missing)
|
|
420
|
+
lines.push(`- ${repo.path}`);
|
|
421
|
+
}
|
|
422
|
+
return `${lines.join("\n")}\n`;
|
|
423
|
+
}
|
|
424
|
+
export function retroJson(report) {
|
|
425
|
+
return `${JSON.stringify(report, null, 2)}\n`;
|
|
426
|
+
}
|
|
427
|
+
function writeTextAtomic(path, text) {
|
|
428
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
429
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
430
|
+
try {
|
|
431
|
+
writeFileSync(tmp, text, "utf8");
|
|
432
|
+
renameSync(tmp, path);
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
throw new AdminError("filesystem", path, error instanceof Error ? error.message : `Cannot write ${path}.`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
export function saveRetro(adminRoot, report) {
|
|
439
|
+
const dir = join(resolve(adminRoot), "retros");
|
|
440
|
+
writeTextAtomic(join(dir, `${report.week}.json`), retroJson(report));
|
|
441
|
+
writeTextAtomic(join(dir, `${report.week}.md`), formatRetroMarkdown(report));
|
|
442
|
+
}
|
package/dist/core/specialists.js
CHANGED
|
@@ -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. When a decision depends on an external framework or tool, report the officially maintained resource for the project's version with its maintainer, last update, and a verdict (trusted, use with care, or rejected).",
|
|
67
67
|
}),
|
|
68
68
|
"product-analyst": manifest("product-analyst", {
|
|
69
69
|
tier: "Standard",
|
|
@@ -71,7 +71,7 @@ export const SPECIALISTS = Object.freeze({
|
|
|
71
71
|
triggers: ["clarify requirements", "define acceptance"],
|
|
72
72
|
knowledge: [knowledge("product-analysis", "task")],
|
|
73
73
|
modes: [specialistMode("analyze", "read-only", phase.planning)],
|
|
74
|
-
stopCondition: "Return one slice and its acceptance criteria.",
|
|
74
|
+
stopCondition: "Return the Domain section (a business impact line, terms, rules, invariants, and users), one slice, and its acceptance criteria. Offer at most one optional business suggestion.",
|
|
75
75
|
}),
|
|
76
76
|
architect: manifest("architect", {
|
|
77
77
|
tier: "Reasoning",
|
|
@@ -79,13 +79,16 @@ export const SPECIALISTS = Object.freeze({
|
|
|
79
79
|
triggers: ["choose architecture", "resolve a technical trade-off"],
|
|
80
80
|
knowledge: [knowledge("architecture-decisions", "task")],
|
|
81
81
|
modes: [specialistMode("plan", "read-only", phase.planning)],
|
|
82
|
-
stopCondition: "Return the decided approach and guidance code.",
|
|
82
|
+
stopCondition: "For each technical decision, return the options with pros and cons and the pick for this project's risk profile. Ask Tito when the project's purpose is unclear. Name any official source with its verdict. Return the decided approach and guidance code.",
|
|
83
83
|
}),
|
|
84
84
|
"backend-engineer": manifest("backend-engineer", {
|
|
85
85
|
tier: "Standard",
|
|
86
86
|
role: "Backend implementer",
|
|
87
87
|
triggers: ["backend implementation", "API or data change"],
|
|
88
|
-
knowledge: [
|
|
88
|
+
knowledge: [
|
|
89
|
+
knowledge("backend-implementation", "task"),
|
|
90
|
+
knowledge("official-stack-sources", "stack"),
|
|
91
|
+
],
|
|
89
92
|
modes: [specialistMode("implement", "write-files", phase.implementation)],
|
|
90
93
|
stopCondition: "Stop after the approved backend slice is verified.",
|
|
91
94
|
}),
|
|
@@ -95,7 +98,7 @@ export const SPECIALISTS = Object.freeze({
|
|
|
95
98
|
triggers: ["frontend design", "frontend implementation"],
|
|
96
99
|
knowledge: [
|
|
97
100
|
knowledge("frontend-ux", "always"),
|
|
98
|
-
knowledge("
|
|
101
|
+
knowledge("official-stack-sources", "stack"),
|
|
99
102
|
],
|
|
100
103
|
modes: [
|
|
101
104
|
specialistMode("design", "read-only", phase.planning, ["preview"]),
|
|
@@ -107,7 +110,10 @@ export const SPECIALISTS = Object.freeze({
|
|
|
107
110
|
tier: "Standard",
|
|
108
111
|
role: "Infrastructure implementer",
|
|
109
112
|
triggers: ["infrastructure plan", "deployment change"],
|
|
110
|
-
knowledge: [
|
|
113
|
+
knowledge: [
|
|
114
|
+
knowledge("devops-safety", "always"),
|
|
115
|
+
knowledge("official-stack-sources", "stack"),
|
|
116
|
+
],
|
|
111
117
|
modes: [
|
|
112
118
|
specialistMode("plan", "read-only", phase.planning),
|
|
113
119
|
specialistMode("implement", "write-infrastructure", phase.implementation, [
|
|
@@ -133,7 +139,7 @@ export const SPECIALISTS = Object.freeze({
|
|
|
133
139
|
triggers: ["review correctness", "check edge cases"],
|
|
134
140
|
knowledge: [knowledge("qa-review", "always")],
|
|
135
141
|
modes: [specialistMode("review", "read-only", phase.review)],
|
|
136
|
-
stopCondition: "Return findings without editing the slice.",
|
|
142
|
+
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
143
|
}),
|
|
138
144
|
"security-reviewer": manifest("security-reviewer", {
|
|
139
145
|
tier: "Reasoning",
|
|
@@ -144,20 +150,20 @@ export const SPECIALISTS = Object.freeze({
|
|
|
144
150
|
stopCondition: "Return security findings without editing the slice.",
|
|
145
151
|
}),
|
|
146
152
|
"tech-docs-writer": manifest("tech-docs-writer", {
|
|
147
|
-
tier: "
|
|
153
|
+
tier: "Scout",
|
|
148
154
|
role: "Technical documentation writer",
|
|
149
155
|
triggers: ["module finished", "update technical documentation"],
|
|
150
156
|
knowledge: [knowledge("tech-docs", "task")],
|
|
151
157
|
modes: [specialistMode("update", "write-files", phase.implementation)],
|
|
152
|
-
stopCondition: "
|
|
158
|
+
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
159
|
}),
|
|
154
160
|
"user-docs-writer": manifest("user-docs-writer", {
|
|
155
|
-
tier: "
|
|
161
|
+
tier: "Scout",
|
|
156
162
|
role: "User documentation writer",
|
|
157
163
|
triggers: ["module finished", "update user documentation"],
|
|
158
164
|
knowledge: [knowledge("user-docs", "task")],
|
|
159
165
|
modes: [specialistMode("update", "write-files", phase.implementation)],
|
|
160
|
-
stopCondition: "
|
|
166
|
+
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
167
|
}),
|
|
162
168
|
});
|
|
163
169
|
export class DelegationError extends Error {
|
|
@@ -176,7 +182,8 @@ function reject(code, message) {
|
|
|
176
182
|
function isSpecialistId(value) {
|
|
177
183
|
return SPECIALIST_IDS.includes(value);
|
|
178
184
|
}
|
|
179
|
-
|
|
185
|
+
const DOCUMENTATION_WRITERS = new Set(["tech-docs-writer", "user-docs-writer"]);
|
|
186
|
+
export function validateDelegation(activations, phaseName, maxWriters = 1) {
|
|
180
187
|
const seen = new Set();
|
|
181
188
|
const resolved = [];
|
|
182
189
|
for (const activation of activations) {
|
|
@@ -210,11 +217,16 @@ export function validateDelegation(activations, phaseName) {
|
|
|
210
217
|
});
|
|
211
218
|
}
|
|
212
219
|
const mutators = resolved.filter((item) => item.capability !== "read-only");
|
|
213
|
-
|
|
214
|
-
|
|
220
|
+
const documentationWriters = mutators.filter((item) => DOCUMENTATION_WRITERS.has(item.specialistId));
|
|
221
|
+
if (documentationWriters.length > 1) {
|
|
222
|
+
reject("multiple-mutators", "Documentation writers run one at a time.");
|
|
223
|
+
}
|
|
224
|
+
if (mutators.length > maxWriters) {
|
|
225
|
+
reject("multiple-mutators", `At most ${maxWriters} specialist modes may mutate at a time.`);
|
|
215
226
|
}
|
|
216
227
|
return {
|
|
217
|
-
mutator: mutators[0] ?? null,
|
|
228
|
+
mutator: mutators.length === 1 ? (mutators[0] ?? null) : null,
|
|
229
|
+
mutators,
|
|
218
230
|
advisers: resolved.filter((item) => item.capability === "read-only"),
|
|
219
231
|
};
|
|
220
232
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const RED = "\u001b[31m";
|
|
2
|
+
const RESET = "\u001b[0m";
|
|
3
|
+
export function formatInitError(message, options) {
|
|
4
|
+
const line = message.endsWith("\n") ? message : `${message}\n`;
|
|
5
|
+
if (options.tty && options.noColor !== true) {
|
|
6
|
+
return `${RED}${line}${RESET}`;
|
|
7
|
+
}
|
|
8
|
+
return line;
|
|
9
|
+
}
|
package/dist/core/upgrade.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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 {
|
|
4
|
+
import { parseConfig } from "./config.js";
|
|
5
|
+
import { PULL_REQUEST_TEMPLATE_PATH, pullRequestTemplate } from "./git-flow.js";
|
|
6
|
+
import { shippedSkills, titoYamlBody } from "./init.js";
|
|
5
7
|
import { TITO_BOOTSTRAP_END, TITO_BOOTSTRAP_START, titoBootstrapBlock, } from "./plan.js";
|
|
6
8
|
function readIfFile(root, path) {
|
|
7
9
|
try {
|
|
@@ -52,7 +54,11 @@ export function planUpgrade(root, ownedFiles) {
|
|
|
52
54
|
return files;
|
|
53
55
|
}
|
|
54
56
|
export function titoOwnedFiles() {
|
|
55
|
-
return [
|
|
57
|
+
return [
|
|
58
|
+
...compiledCursorAgents(),
|
|
59
|
+
...shippedSkills(),
|
|
60
|
+
{ path: PULL_REQUEST_TEMPLATE_PATH, body: pullRequestTemplate() },
|
|
61
|
+
];
|
|
56
62
|
}
|
|
57
63
|
export function formatUpgrade(root, files) {
|
|
58
64
|
const lines = [`root: ${root}`, "consumer rules: untouched"];
|
|
@@ -61,6 +67,63 @@ export function formatUpgrade(root, files) {
|
|
|
61
67
|
}
|
|
62
68
|
return `${lines.join("\n")}\n`;
|
|
63
69
|
}
|
|
70
|
+
export function missingSettings(config) {
|
|
71
|
+
const missing = [];
|
|
72
|
+
if (config.git?.defaultBase === undefined)
|
|
73
|
+
missing.push("defaultBase");
|
|
74
|
+
if (config.product?.screenLanguage === undefined)
|
|
75
|
+
missing.push("screenLanguage");
|
|
76
|
+
if (config.product?.tenancy === undefined)
|
|
77
|
+
missing.push("tenancy");
|
|
78
|
+
if (config.product?.surfaces === undefined)
|
|
79
|
+
missing.push("surfaces");
|
|
80
|
+
if (config.git?.autoPullRequest === undefined)
|
|
81
|
+
missing.push("autoPullRequest");
|
|
82
|
+
return missing;
|
|
83
|
+
}
|
|
84
|
+
export function mergeSettings(yamlText, answers) {
|
|
85
|
+
const config = parseConfig(yamlText);
|
|
86
|
+
const settings = {};
|
|
87
|
+
let changed = false;
|
|
88
|
+
const existingBase = config.git?.defaultBase;
|
|
89
|
+
if (existingBase !== undefined)
|
|
90
|
+
settings.defaultBase = existingBase;
|
|
91
|
+
else if (answers.defaultBase !== undefined) {
|
|
92
|
+
settings.defaultBase = answers.defaultBase;
|
|
93
|
+
changed = true;
|
|
94
|
+
}
|
|
95
|
+
const existingAuto = config.git?.autoPullRequest;
|
|
96
|
+
if (existingAuto !== undefined)
|
|
97
|
+
settings.autoPullRequest = existingAuto;
|
|
98
|
+
else if (answers.autoPullRequest !== undefined) {
|
|
99
|
+
settings.autoPullRequest = answers.autoPullRequest;
|
|
100
|
+
changed = true;
|
|
101
|
+
}
|
|
102
|
+
const existingLanguage = config.product?.screenLanguage;
|
|
103
|
+
if (existingLanguage !== undefined)
|
|
104
|
+
settings.screenLanguage = existingLanguage;
|
|
105
|
+
else if (answers.screenLanguage !== undefined) {
|
|
106
|
+
settings.screenLanguage = answers.screenLanguage;
|
|
107
|
+
changed = true;
|
|
108
|
+
}
|
|
109
|
+
const existingTenancy = config.product?.tenancy;
|
|
110
|
+
if (existingTenancy !== undefined)
|
|
111
|
+
settings.tenancy = existingTenancy;
|
|
112
|
+
else if (answers.tenancy !== undefined) {
|
|
113
|
+
settings.tenancy = answers.tenancy;
|
|
114
|
+
changed = true;
|
|
115
|
+
}
|
|
116
|
+
const existingSurfaces = config.product?.surfaces;
|
|
117
|
+
if (existingSurfaces !== undefined)
|
|
118
|
+
settings.surfaces = existingSurfaces;
|
|
119
|
+
else if (answers.surfaces !== undefined && answers.surfaces.length > 0) {
|
|
120
|
+
settings.surfaces = answers.surfaces;
|
|
121
|
+
changed = true;
|
|
122
|
+
}
|
|
123
|
+
if (!changed)
|
|
124
|
+
return yamlText;
|
|
125
|
+
return titoYamlBody(config.profile, settings);
|
|
126
|
+
}
|
|
64
127
|
export function applyUpgrade(root, files) {
|
|
65
128
|
for (const file of files) {
|
|
66
129
|
if (file.action === "keep" || file.body === undefined)
|
package/dist/core/work-plan.js
CHANGED
|
@@ -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
|
-
|
|
181
|
-
|
|
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 >
|
|
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 = [];
|