@anhvupt/tito 0.1.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,237 @@
1
+ export const SPECIALIST_IDS = [
2
+ "explorer",
3
+ "product-analyst",
4
+ "architect",
5
+ "backend-engineer",
6
+ "frontend-engineer",
7
+ "devops-engineer",
8
+ "erp-specialist",
9
+ "qa-reviewer",
10
+ "security-reviewer",
11
+ "tech-docs-writer",
12
+ "user-docs-writer",
13
+ ];
14
+ export const WRITER_SPECIALIST_IDS = [
15
+ "backend-engineer",
16
+ "frontend-engineer",
17
+ "devops-engineer",
18
+ "tech-docs-writer",
19
+ "user-docs-writer",
20
+ ];
21
+ export const READ_ONLY_SPECIALIST_IDS = [
22
+ "explorer",
23
+ "product-analyst",
24
+ "architect",
25
+ "erp-specialist",
26
+ "qa-reviewer",
27
+ "security-reviewer",
28
+ ];
29
+ const phase = Object.freeze({
30
+ discovery: ["discovery"],
31
+ planning: ["planning"],
32
+ implementation: ["implementation"],
33
+ review: ["review"],
34
+ advise: ["discovery", "planning", "implementation"],
35
+ });
36
+ function knowledge(id, load) {
37
+ return Object.freeze({ id, load });
38
+ }
39
+ function specialistMode(id, capability, phases, gates = []) {
40
+ return Object.freeze({ id, capability, phases, gates: Object.freeze([...gates]) });
41
+ }
42
+ function manifest(id, seed) {
43
+ return Object.freeze({
44
+ id,
45
+ version: 1,
46
+ role: seed.role,
47
+ model: Object.freeze({
48
+ tier: seed.tier,
49
+ preferred: "inherit",
50
+ fallbackPolicy: "ask-user",
51
+ }),
52
+ triggers: Object.freeze([...seed.triggers]),
53
+ knowledge: seed.knowledge,
54
+ handoffs: Object.freeze(SPECIALIST_IDS.filter((candidate) => candidate !== id)),
55
+ modes: seed.modes,
56
+ stopCondition: seed.stopCondition,
57
+ });
58
+ }
59
+ export const SPECIALISTS = Object.freeze({
60
+ explorer: manifest("explorer", {
61
+ tier: "Scout",
62
+ role: "Repository scout",
63
+ triggers: ["where code lives", "how behavior works"],
64
+ knowledge: [knowledge("explorer-scout", "always")],
65
+ modes: [specialistMode("scout", "read-only", phase.discovery)],
66
+ stopCondition: "Return evidence, gaps, and the next specialist.",
67
+ }),
68
+ "product-analyst": manifest("product-analyst", {
69
+ tier: "Standard",
70
+ role: "Requirements analyst",
71
+ triggers: ["clarify requirements", "define acceptance"],
72
+ knowledge: [knowledge("product-analysis", "task")],
73
+ modes: [specialistMode("analyze", "read-only", phase.planning)],
74
+ stopCondition: "Return one slice and its acceptance criteria.",
75
+ }),
76
+ architect: manifest("architect", {
77
+ tier: "Reasoning",
78
+ role: "Technical planner",
79
+ triggers: ["choose architecture", "resolve a technical trade-off"],
80
+ knowledge: [knowledge("architecture-decisions", "task")],
81
+ modes: [specialistMode("plan", "read-only", phase.planning)],
82
+ stopCondition: "Return the decided approach and guidance code.",
83
+ }),
84
+ "backend-engineer": manifest("backend-engineer", {
85
+ tier: "Standard",
86
+ role: "Backend implementer",
87
+ triggers: ["backend implementation", "API or data change"],
88
+ knowledge: [knowledge("backend-implementation", "task")],
89
+ modes: [specialistMode("implement", "write-files", phase.implementation)],
90
+ stopCondition: "Stop after the approved backend slice is verified.",
91
+ }),
92
+ "frontend-engineer": manifest("frontend-engineer", {
93
+ tier: "Standard",
94
+ role: "Frontend designer and implementer",
95
+ triggers: ["frontend design", "frontend implementation"],
96
+ knowledge: [
97
+ knowledge("frontend-ux", "always"),
98
+ knowledge("angular", "stack"),
99
+ ],
100
+ modes: [
101
+ specialistMode("design", "read-only", phase.planning, ["preview"]),
102
+ specialistMode("implement", "write-files", phase.implementation),
103
+ ],
104
+ stopCondition: "Stop after the approved frontend slice is verified.",
105
+ }),
106
+ "devops-engineer": manifest("devops-engineer", {
107
+ tier: "Standard",
108
+ role: "Infrastructure implementer",
109
+ triggers: ["infrastructure plan", "deployment change"],
110
+ knowledge: [knowledge("devops-safety", "always")],
111
+ modes: [
112
+ specialistMode("plan", "read-only", phase.planning),
113
+ specialistMode("implement", "write-infrastructure", phase.implementation, [
114
+ "human-approval",
115
+ ]),
116
+ ],
117
+ stopCondition: "Stop after the approved infrastructure slice is verified.",
118
+ }),
119
+ "erp-specialist": manifest("erp-specialist", {
120
+ tier: "Reasoning",
121
+ role: "ERP domain adviser",
122
+ triggers: ["ERP workflow", "inventory procurement or accounting review"],
123
+ knowledge: [knowledge("erp-domain", "task")],
124
+ modes: [
125
+ specialistMode("advise", "read-only", phase.advise),
126
+ specialistMode("review", "read-only", phase.review),
127
+ ],
128
+ stopCondition: "Return domain constraints without editing files.",
129
+ }),
130
+ "qa-reviewer": manifest("qa-reviewer", {
131
+ tier: "Standard",
132
+ role: "Independent QA reviewer",
133
+ triggers: ["review correctness", "check edge cases"],
134
+ knowledge: [knowledge("qa-review", "always")],
135
+ modes: [specialistMode("review", "read-only", phase.review)],
136
+ stopCondition: "Return findings without editing the slice.",
137
+ }),
138
+ "security-reviewer": manifest("security-reviewer", {
139
+ tier: "Reasoning",
140
+ role: "Independent security reviewer",
141
+ triggers: ["security review", "authorization or secrets review"],
142
+ knowledge: [knowledge("security-review", "task")],
143
+ modes: [specialistMode("review", "read-only", phase.review)],
144
+ stopCondition: "Return security findings without editing the slice.",
145
+ }),
146
+ "tech-docs-writer": manifest("tech-docs-writer", {
147
+ tier: "Standard",
148
+ role: "Technical documentation writer",
149
+ triggers: ["module finished", "update technical documentation"],
150
+ knowledge: [knowledge("tech-docs", "task")],
151
+ modes: [specialistMode("update", "write-files", phase.implementation)],
152
+ stopCondition: "Stop after the technical docs match the finished module.",
153
+ }),
154
+ "user-docs-writer": manifest("user-docs-writer", {
155
+ tier: "Standard",
156
+ role: "User documentation writer",
157
+ triggers: ["module finished", "update user documentation"],
158
+ knowledge: [knowledge("user-docs", "task")],
159
+ modes: [specialistMode("update", "write-files", phase.implementation)],
160
+ stopCondition: "Stop after the user docs match the finished module.",
161
+ }),
162
+ });
163
+ export class DelegationError extends Error {
164
+ code;
165
+ constructor(code, message) {
166
+ super(message);
167
+ this.name = "DelegationError";
168
+ this.code = code;
169
+ }
170
+ }
171
+ const ROOT_IDS = new Set(["tito", "tito-root"]);
172
+ const MAX_PURPOSE_LENGTH = 200;
173
+ function reject(code, message) {
174
+ throw new DelegationError(code, message);
175
+ }
176
+ function isSpecialistId(value) {
177
+ return SPECIALIST_IDS.includes(value);
178
+ }
179
+ export function validateDelegation(activations, phaseName) {
180
+ const seen = new Set();
181
+ const resolved = [];
182
+ for (const activation of activations) {
183
+ if (ROOT_IDS.has(activation.specialistId)) {
184
+ reject("root-recursion", "Tito remains the root coordinator.");
185
+ }
186
+ if (!isSpecialistId(activation.specialistId)) {
187
+ reject("unknown-specialist", `Unknown specialist "${activation.specialistId}".`);
188
+ }
189
+ if (seen.has(activation.specialistId)) {
190
+ reject("duplicate-specialist", `Specialist "${activation.specialistId}" is activated more than once.`);
191
+ }
192
+ seen.add(activation.specialistId);
193
+ const purpose = activation.purpose.trim();
194
+ if (purpose.length === 0 || purpose.length > MAX_PURPOSE_LENGTH) {
195
+ reject("missing-purpose", "Each activation needs one short purpose.");
196
+ }
197
+ const manifestFor = SPECIALISTS[activation.specialistId];
198
+ const selected = manifestFor.modes.find((item) => item.id === activation.modeId);
199
+ if (selected === undefined) {
200
+ reject("unknown-mode", `${activation.specialistId} has no mode "${activation.modeId}".`);
201
+ }
202
+ if (!selected.phases.includes(phaseName)) {
203
+ reject("wrong-phase", `${activation.specialistId}:${selected.id} cannot run during ${phaseName}.`);
204
+ }
205
+ resolved.push({
206
+ specialistId: activation.specialistId,
207
+ modeId: selected.id,
208
+ purpose,
209
+ capability: selected.capability,
210
+ });
211
+ }
212
+ 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.");
215
+ }
216
+ return {
217
+ mutator: mutators[0] ?? null,
218
+ advisers: resolved.filter((item) => item.capability === "read-only"),
219
+ };
220
+ }
221
+ export const DOCUMENTATION_HANDOFF = Object.freeze([
222
+ "tech-docs-writer",
223
+ "user-docs-writer",
224
+ ]);
225
+ export function documentationHandoff(moduleName) {
226
+ const moduleLabel = moduleName.trim();
227
+ if (moduleLabel.length === 0) {
228
+ reject("missing-purpose", "Documentation handoff needs the finished module name.");
229
+ }
230
+ return DOCUMENTATION_HANDOFF.map((specialistId) => Object.freeze({
231
+ specialistId,
232
+ modeId: "update",
233
+ purpose: specialistId === "tech-docs-writer"
234
+ ? `Update technical documentation for ${moduleLabel}.`
235
+ : `Update user documentation for ${moduleLabel}.`,
236
+ }));
237
+ }
@@ -0,0 +1,270 @@
1
+ import { LIFECYCLE_STATES, } from "./lifecycle.js";
2
+ import { isActiveRiskProfileId, } from "./profiles.js";
3
+ import { SPECIALISTS, } from "./specialists.js";
4
+ export class WorkPlanError extends Error {
5
+ code;
6
+ constructor(code, message) {
7
+ super(message);
8
+ this.name = "WorkPlanError";
9
+ this.code = code;
10
+ }
11
+ }
12
+ function deepFreeze(value) {
13
+ if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
14
+ for (const nested of Object.values(value))
15
+ deepFreeze(nested);
16
+ Object.freeze(value);
17
+ }
18
+ return value;
19
+ }
20
+ function reject(code, message) {
21
+ throw new WorkPlanError(code, message);
22
+ }
23
+ function isRecord(value) {
24
+ return typeof value === "object" && value !== null && !Array.isArray(value);
25
+ }
26
+ function specialistManifest(id) {
27
+ if (!Object.hasOwn(SPECIALISTS, id)) {
28
+ reject("invalid-specialist", `Unknown specialist "${id}".`);
29
+ }
30
+ return SPECIALISTS[id];
31
+ }
32
+ function hasMode(id, predicate) {
33
+ return specialistManifest(id).modes.some(predicate);
34
+ }
35
+ export function validateWorkPlan(plan) {
36
+ const input = plan;
37
+ if (!isRecord(input) ||
38
+ typeof input.profile !== "string" ||
39
+ !Array.isArray(input.slices)) {
40
+ reject("invalid-plan", "Work plan must contain a profile and slice array.");
41
+ }
42
+ for (const slice of input.slices) {
43
+ if (!isRecord(slice) ||
44
+ typeof slice.id !== "string" ||
45
+ typeof slice.title !== "string" ||
46
+ typeof slice.kind !== "string" ||
47
+ typeof slice.specialist !== "string" ||
48
+ !Array.isArray(slice.dependsOn) ||
49
+ !slice.dependsOn.every((dependency) => typeof dependency === "string")) {
50
+ reject("invalid-plan", "Every slice must use the work-plan slice shape.");
51
+ }
52
+ }
53
+ if (!isActiveRiskProfileId(plan.profile)) {
54
+ reject("invalid-profile", `Unknown active profile "${plan.profile}".`);
55
+ }
56
+ const byId = new Map();
57
+ for (const slice of plan.slices) {
58
+ if (slice.id.trim() === "")
59
+ reject("invalid-id", "Slice IDs cannot be empty.");
60
+ if (byId.has(slice.id)) {
61
+ reject("duplicate-id", `Slice "${slice.id}" is declared more than once.`);
62
+ }
63
+ byId.set(slice.id, slice);
64
+ const prerequisites = new Set();
65
+ for (const dependency of slice.dependsOn) {
66
+ if (dependency === slice.id) {
67
+ reject("self-prerequisite", `Slice "${slice.id}" cannot depend on itself.`);
68
+ }
69
+ if (prerequisites.has(dependency)) {
70
+ reject("duplicate-prerequisite", `Slice "${slice.id}" repeats prerequisite "${dependency}".`);
71
+ }
72
+ prerequisites.add(dependency);
73
+ }
74
+ }
75
+ for (const slice of plan.slices) {
76
+ if (!["read-only", "implementation", "review"].includes(slice.kind)) {
77
+ reject("invalid-slice-kind", `Slice "${slice.id}" has an invalid kind.`);
78
+ }
79
+ for (const dependency of slice.dependsOn) {
80
+ if (!byId.has(dependency)) {
81
+ reject("missing-prerequisite", `Slice "${slice.id}" references missing prerequisite "${dependency}".`);
82
+ }
83
+ }
84
+ if (slice.kind === "implementation") {
85
+ if (!["self", "independent"].includes(slice.reviewRequirement)) {
86
+ reject("invalid-review-requirement", `Implementation slice "${slice.id}" has an invalid review requirement.`);
87
+ }
88
+ if (!hasMode(slice.specialist, (mode) => mode.capability !== "read-only" && mode.phases.includes("implementation"))) {
89
+ reject("invalid-specialist", `${slice.specialist} cannot implement a slice.`);
90
+ }
91
+ }
92
+ if (slice.kind !== "implementation" &&
93
+ !hasMode(slice.specialist, (mode) => mode.capability === "read-only")) {
94
+ reject("invalid-specialist", `${slice.specialist} cannot run a read-only slice.`);
95
+ }
96
+ if (slice.kind === "review") {
97
+ const target = byId.get(slice.reviews);
98
+ const canReview = hasMode(slice.specialist, (mode) => mode.capability === "read-only" && mode.phases.includes("review"));
99
+ if (target?.kind !== "implementation" ||
100
+ !slice.dependsOn.includes(slice.reviews) ||
101
+ !canReview) {
102
+ reject("invalid-review-target", `Review slice "${slice.id}" must depend on an implementation it can review.`);
103
+ }
104
+ }
105
+ }
106
+ const visiting = new Set();
107
+ const visited = new Set();
108
+ const visit = (id) => {
109
+ if (visiting.has(id))
110
+ reject("cycle", `Slice graph contains a cycle at "${id}".`);
111
+ if (visited.has(id))
112
+ return;
113
+ visiting.add(id);
114
+ for (const dependency of byId.get(id)?.dependsOn ?? [])
115
+ visit(dependency);
116
+ visiting.delete(id);
117
+ visited.add(id);
118
+ };
119
+ for (const slice of plan.slices)
120
+ visit(slice.id);
121
+ for (const slice of plan.slices) {
122
+ if (slice.kind === "implementation" &&
123
+ (plan.profile === "client-careful" ||
124
+ slice.reviewRequirement === "independent") &&
125
+ (slice.reviewRequirement !== "independent" ||
126
+ !plan.slices.some((candidate) => candidate.kind === "review" && candidate.reviews === slice.id))) {
127
+ reject("missing-independent-review", `Implementation slice "${slice.id}" requires an independent review slice.`);
128
+ }
129
+ }
130
+ const copy = {
131
+ profile: plan.profile,
132
+ slices: plan.slices.map((slice) => ({
133
+ ...slice,
134
+ dependsOn: [...slice.dependsOn],
135
+ })),
136
+ };
137
+ return deepFreeze(copy);
138
+ }
139
+ function lifecycleRank(state) {
140
+ return LIFECYCLE_STATES.indexOf(state);
141
+ }
142
+ function reached(state, threshold) {
143
+ return state !== undefined && lifecycleRank(state) >= lifecycleRank(threshold);
144
+ }
145
+ export function evaluateWorkPlan(plan, progress) {
146
+ const validated = validateWorkPlan(plan);
147
+ const progressInput = progress;
148
+ if (!isRecord(progressInput) ||
149
+ !Array.isArray(progressInput.activeReadOnlySliceIds) ||
150
+ !progressInput.activeReadOnlySliceIds.every((id) => typeof id === "string") ||
151
+ !Array.isArray(progressInput.completedSliceIds) ||
152
+ !progressInput.completedSliceIds.every((id) => typeof id === "string") ||
153
+ !isRecord(progressInput.implementationStates)) {
154
+ reject("invalid-progress", "Progress must use the work-plan progress shape.");
155
+ }
156
+ const byId = new Map(validated.slices.map((slice) => [slice.id, slice]));
157
+ const active = new Set(progress.activeReadOnlySliceIds);
158
+ const completed = new Set(progress.completedSliceIds);
159
+ if (active.size !== progress.activeReadOnlySliceIds.length ||
160
+ completed.size !== progress.completedSliceIds.length) {
161
+ reject("invalid-progress", "Progress cannot repeat slice IDs.");
162
+ }
163
+ for (const id of [...active, ...completed]) {
164
+ const slice = byId.get(id);
165
+ if (slice === undefined || slice.kind === "implementation") {
166
+ reject("invalid-progress", `Progress references invalid read-only slice "${id}".`);
167
+ }
168
+ if (active.has(id) && completed.has(id)) {
169
+ reject("invalid-progress", `Slice "${id}" cannot be active and completed.`);
170
+ }
171
+ }
172
+ for (const [id, state] of Object.entries(progress.implementationStates)) {
173
+ if (byId.get(id)?.kind !== "implementation" ||
174
+ !LIFECYCLE_STATES.includes(state)) {
175
+ reject("invalid-progress", `Invalid implementation progress for "${id}".`);
176
+ }
177
+ }
178
+ const activeWriters = validated.slices.filter((slice) => slice.kind === "implementation" &&
179
+ progress.implementationStates[slice.id] === "IMPLEMENTING");
180
+ if (activeWriters.length > 1) {
181
+ reject("multiple-active-writers", "Only one implementation slice may be active.");
182
+ }
183
+ const unreviewedSlices = validated.slices.filter((slice) => slice.kind === "implementation" &&
184
+ progress.implementationStates[slice.id] === "READY_FOR_REVIEW");
185
+ if (validated.profile === "client-careful" &&
186
+ activeWriters.length + unreviewedSlices.length > 1) {
187
+ reject("invalid-progress", "Client-careful cannot stack unreviewed work.");
188
+ }
189
+ const dependencyReasons = (slice) => {
190
+ const reasons = [];
191
+ for (const dependencyId of slice.dependsOn) {
192
+ const dependency = byId.get(dependencyId);
193
+ if (dependency?.kind !== "implementation") {
194
+ if (!completed.has(dependencyId))
195
+ reasons.push(`waiting-for:${dependencyId}`);
196
+ continue;
197
+ }
198
+ const threshold = slice.kind === "review" && slice.reviews === dependencyId
199
+ ? "READY_FOR_REVIEW"
200
+ : validated.profile === "client-careful" ||
201
+ dependency.reviewRequirement === "independent"
202
+ ? "APPROVED_FOR_COMMIT"
203
+ : "READY_FOR_REVIEW";
204
+ if (!reached(progress.implementationStates[dependencyId], threshold)) {
205
+ reasons.push(`waiting-for:${dependencyId}:${threshold}`);
206
+ }
207
+ }
208
+ return reasons;
209
+ };
210
+ for (const id of [...active, ...completed]) {
211
+ const slice = byId.get(id);
212
+ if (slice !== undefined && dependencyReasons(slice).length > 0) {
213
+ reject("invalid-progress", `Progressed slice "${id}" has unmet prerequisites.`);
214
+ }
215
+ }
216
+ for (const slice of validated.slices) {
217
+ if (slice.kind === "implementation" &&
218
+ reached(progress.implementationStates[slice.id], "IMPLEMENTING") &&
219
+ dependencyReasons(slice).length > 0) {
220
+ reject("invalid-progress", `Implementation slice "${slice.id}" has unmet prerequisites.`);
221
+ }
222
+ if (slice.kind === "implementation" &&
223
+ reached(progress.implementationStates[slice.id], "APPROVED_FOR_COMMIT") &&
224
+ (validated.profile === "client-careful" ||
225
+ slice.reviewRequirement === "independent") &&
226
+ !validated.slices.some((candidate) => candidate.kind === "review" &&
227
+ candidate.reviews === slice.id &&
228
+ completed.has(candidate.id))) {
229
+ reject("invalid-progress", `Implementation slice "${slice.id}" lacks completed independent review.`);
230
+ }
231
+ }
232
+ const writerBlocker = activeWriters[0] ??
233
+ (validated.profile === "client-careful"
234
+ ? validated.slices.find((slice) => slice.kind === "implementation" &&
235
+ progress.implementationStates[slice.id] === "READY_FOR_REVIEW")
236
+ : undefined);
237
+ const readyReadOnly = [];
238
+ const readyWriterCandidates = [];
239
+ const blocked = [];
240
+ for (const slice of validated.slices) {
241
+ if (completed.has(slice.id) ||
242
+ active.has(slice.id) ||
243
+ (slice.kind === "implementation" &&
244
+ reached(progress.implementationStates[slice.id], "IMPLEMENTING"))) {
245
+ continue;
246
+ }
247
+ const reasons = dependencyReasons(slice);
248
+ if (slice.kind === "implementation") {
249
+ if (progress.implementationStates[slice.id] !== "APPROVED") {
250
+ reasons.push("awaiting-approval");
251
+ }
252
+ if (writerBlocker !== undefined) {
253
+ const reason = progress.implementationStates[writerBlocker.id] === "IMPLEMENTING"
254
+ ? "writer-active"
255
+ : "unreviewed-slice";
256
+ reasons.push(`${reason}:${writerBlocker.id}`);
257
+ }
258
+ }
259
+ if (reasons.length > 0) {
260
+ blocked.push({ sliceId: slice.id, reasons });
261
+ }
262
+ else if (slice.kind === "implementation") {
263
+ readyWriterCandidates.push(slice);
264
+ }
265
+ else {
266
+ readyReadOnly.push(slice);
267
+ }
268
+ }
269
+ return deepFreeze({ readyReadOnly, readyWriterCandidates, blocked });
270
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@anhvupt/tito",
3
+ "version": "0.1.0",
4
+ "description": "Local-first AI development harness for solo builders.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "bin": {
8
+ "tito": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "test": "npm run build && node --test"
18
+ },
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^20.17.0",
27
+ "typescript": "^5.7.0"
28
+ },
29
+ "dependencies": {
30
+ "yaml": "^2.9.1"
31
+ }
32
+ }