@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.
@@ -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
+ }
@@ -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: [knowledge("backend-implementation", "task")],
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("angular", "stack"),
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: [knowledge("devops-safety", "always")],
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: "Standard",
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: "Stop after the technical docs match the finished module.",
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: "Standard",
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: "Stop after the user docs match the finished module.",
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
- export function validateDelegation(activations, phaseName) {
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
- if (mutators.length > 1) {
214
- reject("multiple-mutators", "Only one specialist mode may mutate at a time.");
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
+ }
@@ -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 { shippedSkills } from "./init.js";
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 [...compiledCursorAgents(), ...shippedSkills()];
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)
@@ -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 = [];