omni-pi 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.
Files changed (54) hide show
  1. package/CREDITS.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +81 -0
  4. package/agents/brain.md +24 -0
  5. package/agents/expert.md +21 -0
  6. package/agents/planner.md +22 -0
  7. package/agents/worker.md +21 -0
  8. package/bin/omni.js +79 -0
  9. package/extensions/omni-core/index.ts +22 -0
  10. package/extensions/omni-memory/index.ts +72 -0
  11. package/extensions/omni-skills/index.ts +11 -0
  12. package/extensions/omni-status/index.ts +11 -0
  13. package/package.json +75 -0
  14. package/prompts/brainstorm.md +15 -0
  15. package/prompts/spec-template.md +14 -0
  16. package/prompts/task-template.md +16 -0
  17. package/skills/omni-escalation/SKILL.md +17 -0
  18. package/skills/omni-execution/SKILL.md +18 -0
  19. package/skills/omni-init/SKILL.md +19 -0
  20. package/skills/omni-planning/SKILL.md +19 -0
  21. package/skills/omni-verification/SKILL.md +18 -0
  22. package/src/commands.ts +521 -0
  23. package/src/config.ts +154 -0
  24. package/src/context.ts +165 -0
  25. package/src/contracts.ts +183 -0
  26. package/src/doctor.ts +225 -0
  27. package/src/git.ts +135 -0
  28. package/src/memory.ts +25 -0
  29. package/src/pi.ts +240 -0
  30. package/src/planning.ts +303 -0
  31. package/src/plans.ts +247 -0
  32. package/src/repo.ts +210 -0
  33. package/src/skills.ts +308 -0
  34. package/src/status.ts +105 -0
  35. package/src/subagents.ts +1031 -0
  36. package/src/sync.ts +70 -0
  37. package/src/tasks.ts +141 -0
  38. package/src/templates.ts +261 -0
  39. package/src/work.ts +345 -0
  40. package/src/workflow.ts +375 -0
  41. package/templates/omni/DECISIONS.md +10 -0
  42. package/templates/omni/IDEAS.md +13 -0
  43. package/templates/omni/PROJECT.md +19 -0
  44. package/templates/omni/SESSION-SUMMARY.md +13 -0
  45. package/templates/omni/SKILLS.md +21 -0
  46. package/templates/omni/SPEC.md +11 -0
  47. package/templates/omni/STATE.md +7 -0
  48. package/templates/omni/TASKS.md +6 -0
  49. package/templates/omni/TESTS.md +17 -0
  50. package/templates/omni/research/README.md +3 -0
  51. package/templates/omni/specs/README.md +3 -0
  52. package/templates/omni/tasks/README.md +3 -0
  53. package/templates/pi/agents/omni-expert.md +13 -0
  54. package/templates/pi/agents/omni-worker.md +13 -0
package/src/context.ts ADDED
@@ -0,0 +1,165 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import type { OmniPhase, TaskBrief } from "./contracts.js";
5
+ import { OMNI_DIR } from "./contracts.js";
6
+
7
+ const CHARS_PER_TOKEN = 4;
8
+
9
+ export interface TokenBudget {
10
+ maxTokens: number;
11
+ usedTokens: number;
12
+ remainingTokens: number;
13
+ }
14
+
15
+ export function estimateTokens(text: string): number {
16
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
17
+ }
18
+
19
+ export function createBudget(maxTokens: number): TokenBudget {
20
+ return { maxTokens, usedTokens: 0, remainingTokens: maxTokens };
21
+ }
22
+
23
+ export function consumeBudget(budget: TokenBudget, text: string): TokenBudget {
24
+ const tokens = estimateTokens(text);
25
+ const usedTokens = budget.usedTokens + tokens;
26
+ return {
27
+ maxTokens: budget.maxTokens,
28
+ usedTokens,
29
+ remainingTokens: Math.max(0, budget.maxTokens - usedTokens),
30
+ };
31
+ }
32
+
33
+ export function fitsInBudget(budget: TokenBudget, text: string): boolean {
34
+ return estimateTokens(text) <= budget.remainingTokens;
35
+ }
36
+
37
+ const PHASE_FILES: Record<OmniPhase, string[]> = {
38
+ understand: ["PROJECT.md", "IDEAS.md", "SESSION-SUMMARY.md", "PROGRESS.md"],
39
+ plan: [
40
+ "PROJECT.md",
41
+ "SPEC.md",
42
+ "TASKS.md",
43
+ "DECISIONS.md",
44
+ "SESSION-SUMMARY.md",
45
+ ],
46
+ build: ["SPEC.md", "TASKS.md", "TESTS.md", "PROGRESS.md"],
47
+ check: ["TESTS.md", "TASKS.md", "SPEC.md"],
48
+ escalate: [
49
+ "SPEC.md",
50
+ "TASKS.md",
51
+ "TESTS.md",
52
+ "DECISIONS.md",
53
+ "SESSION-SUMMARY.md",
54
+ "PROGRESS.md",
55
+ ],
56
+ };
57
+
58
+ export function getPhaseFiles(phase: OmniPhase): string[] {
59
+ return PHASE_FILES[phase];
60
+ }
61
+
62
+ async function safeReadFile(filePath: string): Promise<string | null> {
63
+ try {
64
+ return await readFile(filePath, "utf8");
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ export interface ContextBlock {
71
+ file: string;
72
+ content: string;
73
+ tokens: number;
74
+ }
75
+
76
+ export async function gatherPhaseContext(
77
+ rootDir: string,
78
+ phase: OmniPhase,
79
+ maxTokens: number,
80
+ ): Promise<ContextBlock[]> {
81
+ const files = getPhaseFiles(phase);
82
+ let budget = createBudget(maxTokens);
83
+ const blocks: ContextBlock[] = [];
84
+
85
+ for (const file of files) {
86
+ const content = await safeReadFile(path.join(rootDir, OMNI_DIR, file));
87
+ if (!content) continue;
88
+ if (!fitsInBudget(budget, content)) continue;
89
+
90
+ budget = consumeBudget(budget, content);
91
+ blocks.push({
92
+ file,
93
+ content,
94
+ tokens: estimateTokens(content),
95
+ });
96
+ }
97
+
98
+ return blocks;
99
+ }
100
+
101
+ export async function gatherTaskContext(
102
+ rootDir: string,
103
+ task: TaskBrief,
104
+ maxTokens: number,
105
+ ): Promise<ContextBlock[]> {
106
+ const coreFiles = ["SPEC.md", "TESTS.md"];
107
+ let budget = createBudget(maxTokens);
108
+ const blocks: ContextBlock[] = [];
109
+
110
+ // Core omni files first
111
+ for (const file of coreFiles) {
112
+ const content = await safeReadFile(path.join(rootDir, OMNI_DIR, file));
113
+ if (!content) continue;
114
+ if (!fitsInBudget(budget, content)) continue;
115
+
116
+ budget = consumeBudget(budget, content);
117
+ blocks.push({ file, content, tokens: estimateTokens(content) });
118
+ }
119
+
120
+ // Task brief
121
+ const briefPath = path.join(
122
+ rootDir,
123
+ OMNI_DIR,
124
+ "tasks",
125
+ `${task.id}-BRIEF.md`,
126
+ );
127
+ const briefContent = await safeReadFile(briefPath);
128
+ if (briefContent && fitsInBudget(budget, briefContent)) {
129
+ budget = consumeBudget(budget, briefContent);
130
+ blocks.push({
131
+ file: `tasks/${task.id}-BRIEF.md`,
132
+ content: briefContent,
133
+ tokens: estimateTokens(briefContent),
134
+ });
135
+ }
136
+
137
+ // Context files from the task definition
138
+ for (const file of task.contextFiles) {
139
+ const content = await safeReadFile(path.join(rootDir, file));
140
+ if (!content) continue;
141
+ if (!fitsInBudget(budget, content)) continue;
142
+
143
+ budget = consumeBudget(budget, content);
144
+ blocks.push({ file, content, tokens: estimateTokens(content) });
145
+ }
146
+
147
+ return blocks;
148
+ }
149
+
150
+ export function renderContextBlocks(blocks: ContextBlock[]): string {
151
+ if (blocks.length === 0) return "";
152
+
153
+ return blocks
154
+ .map(
155
+ (block) =>
156
+ `--- ${block.file} (${block.tokens} tokens) ---\n${block.content}`,
157
+ )
158
+ .join("\n\n");
159
+ }
160
+
161
+ export function renderContextSummary(blocks: ContextBlock[]): string {
162
+ const totalTokens = blocks.reduce((sum, b) => sum + b.tokens, 0);
163
+ const fileList = blocks.map((b) => `${b.file} (${b.tokens}t)`).join(", ");
164
+ return `Pre-loaded context: ${totalTokens} tokens from ${blocks.length} files: ${fileList}`;
165
+ }
@@ -0,0 +1,183 @@
1
+ export const OMNI_DIR = ".omni";
2
+
3
+ export type OmniPhase = "understand" | "plan" | "build" | "check" | "escalate";
4
+
5
+ export type TaskStatus = "todo" | "in_progress" | "blocked" | "done";
6
+
7
+ export type SkillPolicy =
8
+ | "auto-install"
9
+ | "recommend-only"
10
+ | "never-auto-install";
11
+
12
+ export interface ConversationBrief {
13
+ summary: string;
14
+ desiredOutcome: string;
15
+ constraints: string[];
16
+ userSignals: string[];
17
+ preset?: WorkflowPreset;
18
+ }
19
+
20
+ export interface ImplementationSpec {
21
+ title: string;
22
+ scope: string[];
23
+ architecture: string[];
24
+ taskSlices: TaskBrief[];
25
+ acceptanceCriteria: string[];
26
+ }
27
+
28
+ export interface TaskBrief {
29
+ id: string;
30
+ title: string;
31
+ objective: string;
32
+ contextFiles: string[];
33
+ skills: string[];
34
+ doneCriteria: string[];
35
+ role: "worker" | "expert";
36
+ status: TaskStatus;
37
+ dependsOn: string[];
38
+ }
39
+
40
+ export interface VerificationResult {
41
+ taskId: string;
42
+ passed: boolean;
43
+ checksRun: string[];
44
+ failureSummary: string[];
45
+ retryRecommended: boolean;
46
+ }
47
+
48
+ export interface TaskAttemptResult {
49
+ summary: string;
50
+ verification: VerificationResult;
51
+ modifiedFiles?: string[];
52
+ }
53
+
54
+ export interface EscalationBrief {
55
+ taskId: string;
56
+ priorAttempts: number;
57
+ failureLogs: string[];
58
+ expertObjective: string;
59
+ verificationResults?: Array<{
60
+ command: string;
61
+ passed: boolean;
62
+ stdout: string;
63
+ stderr: string;
64
+ }>;
65
+ modifiedFiles?: string[];
66
+ }
67
+
68
+ export interface SkillCandidate {
69
+ name: string;
70
+ reason: string;
71
+ confidence: "high" | "medium" | "low";
72
+ policy: SkillPolicy;
73
+ }
74
+
75
+ export interface OmniState {
76
+ currentPhase: OmniPhase;
77
+ activeTask: string;
78
+ statusSummary: string;
79
+ blockers: string[];
80
+ nextStep: string;
81
+ recoveryOptions?: string[];
82
+ }
83
+
84
+ export type WorkflowPreset =
85
+ | "bugfix"
86
+ | "feature"
87
+ | "refactor"
88
+ | "spike"
89
+ | "security-audit";
90
+
91
+ export interface PresetConfig {
92
+ name: WorkflowPreset;
93
+ description: string;
94
+ maxTasks: number;
95
+ skipInterview: boolean;
96
+ requireVerification: boolean;
97
+ workerHint: string;
98
+ }
99
+
100
+ export const WORKFLOW_PRESETS: Record<WorkflowPreset, PresetConfig> = {
101
+ bugfix: {
102
+ name: "bugfix",
103
+ description:
104
+ "Quick fix for a known bug. Minimal planning, regression test required.",
105
+ maxTasks: 2,
106
+ skipInterview: true,
107
+ requireVerification: true,
108
+ workerHint:
109
+ "Focus on the root cause. Write a regression test before fixing.",
110
+ },
111
+ feature: {
112
+ name: "feature",
113
+ description:
114
+ "New feature implementation. Full planning flow with user interview.",
115
+ maxTasks: 8,
116
+ skipInterview: false,
117
+ requireVerification: true,
118
+ workerHint: "Follow the spec. Keep tasks bounded and verifiable.",
119
+ },
120
+ refactor: {
121
+ name: "refactor",
122
+ description: "Code restructuring. Existing tests must stay green.",
123
+ maxTasks: 5,
124
+ skipInterview: true,
125
+ requireVerification: true,
126
+ workerHint:
127
+ "Preserve all existing behavior. Run the full test suite after each change.",
128
+ },
129
+ spike: {
130
+ name: "spike",
131
+ description:
132
+ "Exploratory work. No verification required, no commit artifacts.",
133
+ maxTasks: 1,
134
+ skipInterview: true,
135
+ requireVerification: false,
136
+ workerHint: "Explore freely. Document findings in .omni/research/.",
137
+ },
138
+ "security-audit": {
139
+ name: "security-audit",
140
+ description: "Security review. Read-only analysis, produce a report.",
141
+ maxTasks: 3,
142
+ skipInterview: true,
143
+ requireVerification: false,
144
+ workerHint:
145
+ "Analyze for OWASP Top 10, secrets in code, dependency vulnerabilities. Do not modify source code.",
146
+ },
147
+ };
148
+
149
+ export function detectPreset(
150
+ branchName: string,
151
+ brief: string,
152
+ ): WorkflowPreset | null {
153
+ const lower = `${branchName} ${brief}`.toLowerCase();
154
+ if (/\b(fix|bug|hotfix)\b/u.test(lower)) return "bugfix";
155
+ if (/\b(refactor|clean\s*up|restructure)\b/u.test(lower)) return "refactor";
156
+ if (/\b(spike|explore|experiment|prototype)\b/u.test(lower)) return "spike";
157
+ if (/\b(security|audit|vulnerability|cve)\b/u.test(lower))
158
+ return "security-audit";
159
+ if (/\b(feat|feature|add|implement|build)\b/u.test(lower)) return "feature";
160
+ return null;
161
+ }
162
+
163
+ export interface OmniConfig {
164
+ models: {
165
+ worker: string;
166
+ expert: string;
167
+ planner: string;
168
+ brain: string;
169
+ };
170
+ retryLimit: number;
171
+ chainEnabled: boolean;
172
+ cleanupCompletedPlans: boolean;
173
+ }
174
+
175
+ export type PlanStatus = "active" | "completed" | "discarded";
176
+
177
+ export interface PlanEntry {
178
+ id: string;
179
+ title: string;
180
+ status: PlanStatus;
181
+ createdAt: string;
182
+ completedAt?: string;
183
+ }
package/src/doctor.ts ADDED
@@ -0,0 +1,225 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { readConfig } from "./config.js";
5
+ import { OMNI_DIR } from "./contracts.js";
6
+ import { detectRepoSignals } from "./repo.js";
7
+ import { readTasks } from "./tasks.js";
8
+
9
+ export type HealthLevel = "green" | "yellow" | "red";
10
+
11
+ export interface DiagnosticResult {
12
+ name: string;
13
+ level: HealthLevel;
14
+ message: string;
15
+ }
16
+
17
+ export interface DoctorReport {
18
+ overall: HealthLevel;
19
+ checks: DiagnosticResult[];
20
+ }
21
+
22
+ async function fileExists(filePath: string): Promise<boolean> {
23
+ try {
24
+ await access(filePath);
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ async function checkOmniInitialized(
32
+ rootDir: string,
33
+ ): Promise<DiagnosticResult> {
34
+ const stateExists = await fileExists(
35
+ path.join(rootDir, OMNI_DIR, "STATE.md"),
36
+ );
37
+ if (!stateExists) {
38
+ return {
39
+ name: "omni-init",
40
+ level: "red",
41
+ message:
42
+ ".omni/ directory not found. Run /omni-init to initialize the project.",
43
+ };
44
+ }
45
+ return { name: "omni-init", level: "green", message: "Omni-Pi initialized." };
46
+ }
47
+
48
+ async function checkConfigParseable(
49
+ rootDir: string,
50
+ ): Promise<DiagnosticResult> {
51
+ try {
52
+ const config = await readConfig(rootDir);
53
+ const roles = ["worker", "expert", "planner", "brain"] as const;
54
+ for (const role of roles) {
55
+ if (!config.models[role]) {
56
+ return {
57
+ name: "config",
58
+ level: "yellow",
59
+ message: `Model for ${role} is empty in CONFIG.md.`,
60
+ };
61
+ }
62
+ }
63
+ return { name: "config", level: "green", message: "Config is valid." };
64
+ } catch {
65
+ return {
66
+ name: "config",
67
+ level: "yellow",
68
+ message: "CONFIG.md could not be parsed. Using defaults.",
69
+ };
70
+ }
71
+ }
72
+
73
+ async function checkRepoSignals(rootDir: string): Promise<DiagnosticResult> {
74
+ const signals = await detectRepoSignals(rootDir);
75
+ if (signals.languages.length === 0) {
76
+ return {
77
+ name: "repo-signals",
78
+ level: "yellow",
79
+ message:
80
+ "No programming languages detected. Verification commands may not be inferred automatically.",
81
+ };
82
+ }
83
+ return {
84
+ name: "repo-signals",
85
+ level: "green",
86
+ message: `Detected: ${signals.languages.join(", ")}.`,
87
+ };
88
+ }
89
+
90
+ async function checkOrphanedTasks(rootDir: string): Promise<DiagnosticResult> {
91
+ try {
92
+ const tasksPath = path.join(rootDir, OMNI_DIR, "TASKS.md");
93
+ const tasks = await readTasks(tasksPath);
94
+ const inProgress = tasks.filter((t) => t.status === "in_progress");
95
+ const blocked = tasks.filter((t) => t.status === "blocked");
96
+
97
+ if (blocked.length > 0) {
98
+ return {
99
+ name: "task-health",
100
+ level: "red",
101
+ message: `${blocked.length} blocked task(s): ${blocked.map((t) => t.id).join(", ")}. Review escalation notes or restructure the plan.`,
102
+ };
103
+ }
104
+ if (inProgress.length > 1) {
105
+ return {
106
+ name: "task-health",
107
+ level: "yellow",
108
+ message: `${inProgress.length} tasks marked in_progress simultaneously. Only one should be active.`,
109
+ };
110
+ }
111
+ return {
112
+ name: "task-health",
113
+ level: "green",
114
+ message: "Tasks are healthy.",
115
+ };
116
+ } catch {
117
+ return {
118
+ name: "task-health",
119
+ level: "green",
120
+ message: "No tasks file yet.",
121
+ };
122
+ }
123
+ }
124
+
125
+ export interface StuckSignal {
126
+ detected: boolean;
127
+ reason: string;
128
+ taskId?: string;
129
+ }
130
+
131
+ export async function detectStuck(rootDir: string): Promise<StuckSignal> {
132
+ const taskDir = path.join(rootDir, OMNI_DIR, "tasks");
133
+ try {
134
+ const tasksPath = path.join(rootDir, OMNI_DIR, "TASKS.md");
135
+ const tasks = await readTasks(tasksPath);
136
+ const inProgressOrTodo = tasks.filter(
137
+ (t) => t.status === "in_progress" || t.status === "todo",
138
+ );
139
+ if (inProgressOrTodo.length === 0) {
140
+ return { detected: false, reason: "No active tasks." };
141
+ }
142
+
143
+ const candidate = inProgressOrTodo[0];
144
+ try {
145
+ const historyPath = path.join(taskDir, `${candidate.id}.history.json`);
146
+ const raw = await readFile(historyPath, "utf8");
147
+ const history = JSON.parse(raw) as Array<{
148
+ verification: { passed: boolean; failureSummary: string[] };
149
+ }>;
150
+ const failures = history.filter((h) => !h.verification.passed);
151
+
152
+ if (failures.length >= 3) {
153
+ const lastErrors = failures
154
+ .slice(-3)
155
+ .map((f) => f.verification.failureSummary.join("; "));
156
+ const allSame =
157
+ lastErrors.length === 3 &&
158
+ lastErrors[0] === lastErrors[1] &&
159
+ lastErrors[1] === lastErrors[2];
160
+
161
+ if (allSame) {
162
+ return {
163
+ detected: true,
164
+ reason: `Task ${candidate.id} has failed 3+ times with the same error: "${lastErrors[0]}".`,
165
+ taskId: candidate.id,
166
+ };
167
+ }
168
+
169
+ return {
170
+ detected: true,
171
+ reason: `Task ${candidate.id} has ${failures.length} failures. Consider splitting or restructuring.`,
172
+ taskId: candidate.id,
173
+ };
174
+ }
175
+ } catch {
176
+ // no history yet
177
+ }
178
+
179
+ return { detected: false, reason: "No stuck signals." };
180
+ } catch {
181
+ return { detected: false, reason: "Could not read tasks." };
182
+ }
183
+ }
184
+
185
+ function worstLevel(checks: DiagnosticResult[]): HealthLevel {
186
+ if (checks.some((c) => c.level === "red")) return "red";
187
+ if (checks.some((c) => c.level === "yellow")) return "yellow";
188
+ return "green";
189
+ }
190
+
191
+ export async function runDoctor(rootDir: string): Promise<DoctorReport> {
192
+ const checks = await Promise.all([
193
+ checkOmniInitialized(rootDir),
194
+ checkConfigParseable(rootDir),
195
+ checkRepoSignals(rootDir),
196
+ checkOrphanedTasks(rootDir),
197
+ ]);
198
+
199
+ const stuck = await detectStuck(rootDir);
200
+ if (stuck.detected) {
201
+ checks.push({
202
+ name: "stuck-detection",
203
+ level: "red",
204
+ message: stuck.reason,
205
+ });
206
+ }
207
+
208
+ return { overall: worstLevel(checks), checks };
209
+ }
210
+
211
+ const HEALTH_ICONS: Record<HealthLevel, string> = {
212
+ green: "[OK]",
213
+ yellow: "[WARN]",
214
+ red: "[FAIL]",
215
+ };
216
+
217
+ export function renderDoctorReport(report: DoctorReport): string {
218
+ const lines = [`Health: ${HEALTH_ICONS[report.overall]} ${report.overall}`];
219
+ for (const check of report.checks) {
220
+ lines.push(
221
+ ` ${HEALTH_ICONS[check.level]} ${check.name}: ${check.message}`,
222
+ );
223
+ }
224
+ return lines.join("\n");
225
+ }
package/src/git.ts ADDED
@@ -0,0 +1,135 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import type { TaskBrief } from "./contracts.js";
5
+ import { parseTaskRow } from "./tasks.js";
6
+
7
+ type ExecFn = (
8
+ command: string,
9
+ args: string[],
10
+ options?: { cwd?: string },
11
+ ) => Promise<{ stdout: string; stderr: string; code: number; killed: boolean }>;
12
+
13
+ export interface CommitPlan {
14
+ branch: string;
15
+ message: string;
16
+ files: string[];
17
+ taskId: string;
18
+ prBody: string;
19
+ }
20
+
21
+ export function buildBranchName(taskId: string): string {
22
+ return `omni/${taskId.toLowerCase().replace(/[^a-z0-9-]/gu, "-")}`;
23
+ }
24
+
25
+ export function buildCommitMessage(task: TaskBrief): string {
26
+ return `feat(${task.id}): ${task.title}\n\nObjective: ${task.objective}\nDone criteria: ${task.doneCriteria.join("; ") || "None listed"}`;
27
+ }
28
+
29
+ export function generatePrBody(
30
+ task: TaskBrief,
31
+ verificationSummary?: string,
32
+ ): string {
33
+ const lines = [
34
+ `## Summary`,
35
+ "",
36
+ `Implements ${task.id}: ${task.title}`,
37
+ "",
38
+ `**Objective:** ${task.objective}`,
39
+ "",
40
+ `## Done Criteria`,
41
+ "",
42
+ ...task.doneCriteria.map((c) => `- [x] ${c}`),
43
+ "",
44
+ ];
45
+ if (verificationSummary) {
46
+ lines.push("## Verification", "", verificationSummary, "");
47
+ }
48
+ return lines.join("\n");
49
+ }
50
+
51
+ export async function readLastCompletedTask(
52
+ rootDir: string,
53
+ ): Promise<{ taskId: string; task: TaskBrief } | null> {
54
+ try {
55
+ const tasksContent = await readFile(
56
+ path.join(rootDir, ".omni", "TASKS.md"),
57
+ "utf8",
58
+ );
59
+ const doneRows = tasksContent
60
+ .split("\n")
61
+ .filter((line) => line.startsWith("| T") && line.includes("| done |"));
62
+ if (doneRows.length === 0) return null;
63
+
64
+ const lastRow = doneRows[doneRows.length - 1];
65
+ const task = parseTaskRow(lastRow);
66
+ if (!task || task.status !== "done") return null;
67
+ return { taskId: task.id, task };
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ export async function readModifiedFilesFromHistory(
74
+ rootDir: string,
75
+ taskId: string,
76
+ ): Promise<string[]> {
77
+ try {
78
+ const historyPath = path.join(
79
+ rootDir,
80
+ ".omni",
81
+ "tasks",
82
+ `${taskId}.history.json`,
83
+ );
84
+ const history = JSON.parse(await readFile(historyPath, "utf8")) as Array<{
85
+ modifiedFiles?: string[];
86
+ }>;
87
+ return [...new Set(history.flatMap((entry) => entry.modifiedFiles ?? []))];
88
+ } catch {
89
+ return [];
90
+ }
91
+ }
92
+
93
+ export async function createBranch(
94
+ exec: ExecFn,
95
+ cwd: string,
96
+ branch: string,
97
+ ): Promise<boolean> {
98
+ const result = await exec("git", ["checkout", "-b", branch], { cwd });
99
+ return result.code === 0;
100
+ }
101
+
102
+ export async function stageFiles(
103
+ exec: ExecFn,
104
+ cwd: string,
105
+ files: string[],
106
+ ): Promise<boolean> {
107
+ if (files.length === 0) return false;
108
+ const result = await exec("git", ["add", ...files], { cwd });
109
+ return result.code === 0;
110
+ }
111
+
112
+ export async function commitChanges(
113
+ exec: ExecFn,
114
+ cwd: string,
115
+ message: string,
116
+ ): Promise<boolean> {
117
+ const result = await exec("git", ["commit", "-m", message], { cwd });
118
+ return result.code === 0;
119
+ }
120
+
121
+ export async function prepareCommitPlan(
122
+ rootDir: string,
123
+ ): Promise<CommitPlan | null> {
124
+ const completed = await readLastCompletedTask(rootDir);
125
+ if (!completed) return null;
126
+
127
+ const files = await readModifiedFilesFromHistory(rootDir, completed.taskId);
128
+ return {
129
+ branch: buildBranchName(completed.taskId),
130
+ message: buildCommitMessage(completed.task),
131
+ files,
132
+ taskId: completed.taskId,
133
+ prBody: generatePrBody(completed.task),
134
+ };
135
+ }