@anhvupt/tito 0.1.2 → 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.
- package/README.md +88 -17
- package/dist/cli.js +203 -0
- package/dist/core/admin.js +241 -0
- package/dist/core/config.js +185 -2
- package/dist/core/feedback.js +68 -0
- package/dist/core/git-flow.js +174 -0
- package/dist/core/init.js +16 -6
- package/dist/core/plan.js +6 -0
- package/dist/core/profiles.js +6 -6
- package/dist/core/retro.js +444 -0
- package/dist/core/specialists.js +14 -8
- package/dist/core/upgrade.js +77 -0
- package/dist/core/work-plan.js +6 -5
- package/package.json +2 -2
- package/templates/cursor/skills/tito/SKILL.md +64 -11
- package/templates/cursor/skills/tito-admin-retro/SKILL.md +12 -0
|
@@ -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
|
+
}
|
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.",
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { compiledCursorAgents } from "./agents.js";
|
|
4
|
+
import { PULL_REQUEST_TEMPLATE_PATH, pullRequestTemplate } from "./git-flow.js";
|
|
5
|
+
import { shippedSkills } from "./init.js";
|
|
6
|
+
import { TITO_BOOTSTRAP_END, TITO_BOOTSTRAP_START, titoBootstrapBlock, } from "./plan.js";
|
|
7
|
+
function readIfFile(root, path) {
|
|
8
|
+
try {
|
|
9
|
+
return readFileSync(join(root, path), "utf8");
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function ownedText(current, next) {
|
|
16
|
+
if (current === null)
|
|
17
|
+
return "create";
|
|
18
|
+
return current === next ? "keep" : "replace";
|
|
19
|
+
}
|
|
20
|
+
export function refreshAgentsBootstrap(current) {
|
|
21
|
+
const block = titoBootstrapBlock().trimEnd();
|
|
22
|
+
const start = current.indexOf(TITO_BOOTSTRAP_START);
|
|
23
|
+
const end = current.indexOf(TITO_BOOTSTRAP_END);
|
|
24
|
+
if (start >= 0 && end > start) {
|
|
25
|
+
return `${current.slice(0, start)}${block}${current.slice(end + TITO_BOOTSTRAP_END.length)}`;
|
|
26
|
+
}
|
|
27
|
+
const heading = current.indexOf("# Tito bootstrap");
|
|
28
|
+
if (heading >= 0) {
|
|
29
|
+
const prefix = current.slice(0, heading).trimEnd();
|
|
30
|
+
return prefix.length === 0 ? `${block}\n` : `${prefix}\n\n${block}\n`;
|
|
31
|
+
}
|
|
32
|
+
const trimmed = current.trimEnd();
|
|
33
|
+
return `${trimmed}\n\n${block}\n`;
|
|
34
|
+
}
|
|
35
|
+
export function planUpgrade(root, ownedFiles) {
|
|
36
|
+
const files = ownedFiles.map((file) => {
|
|
37
|
+
const current = readIfFile(root, file.path);
|
|
38
|
+
const action = ownedText(current, file.body);
|
|
39
|
+
return action === "keep"
|
|
40
|
+
? { path: file.path, action }
|
|
41
|
+
: { path: file.path, action, body: file.body };
|
|
42
|
+
});
|
|
43
|
+
const agents = readIfFile(root, "AGENTS.md");
|
|
44
|
+
if (agents === null) {
|
|
45
|
+
files.push({ path: "AGENTS.md", action: "create", body: titoBootstrapBlock() });
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const next = refreshAgentsBootstrap(agents);
|
|
49
|
+
files.push(next === agents
|
|
50
|
+
? { path: "AGENTS.md", action: "keep" }
|
|
51
|
+
: { path: "AGENTS.md", action: "replace", body: next });
|
|
52
|
+
}
|
|
53
|
+
return files;
|
|
54
|
+
}
|
|
55
|
+
export function titoOwnedFiles() {
|
|
56
|
+
return [
|
|
57
|
+
...compiledCursorAgents(),
|
|
58
|
+
...shippedSkills(),
|
|
59
|
+
{ path: PULL_REQUEST_TEMPLATE_PATH, body: pullRequestTemplate() },
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
export function formatUpgrade(root, files) {
|
|
63
|
+
const lines = [`root: ${root}`, "consumer rules: untouched"];
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
lines.push(`${file.path}: ${file.action}`);
|
|
66
|
+
}
|
|
67
|
+
return `${lines.join("\n")}\n`;
|
|
68
|
+
}
|
|
69
|
+
export function applyUpgrade(root, files) {
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
if (file.action === "keep" || file.body === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
const absolute = join(root, file.path);
|
|
74
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
75
|
+
writeFileSync(absolute, file.body, "utf8");
|
|
76
|
+
}
|
|
77
|
+
}
|
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 = [];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anhvupt/tito",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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": {
|