@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.
@@ -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
  }
@@ -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
+ }
@@ -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.2",
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": {