ranni-mcp 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ranni-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Provider-agnostic multi-agent orchestration MCP server",
5
5
  "repository": {
6
6
  "type": "git",
package/src/config.ts CHANGED
@@ -4,6 +4,13 @@ import { parse } from 'yaml';
4
4
  import { z } from 'zod';
5
5
  import type { Config } from './types.js';
6
6
 
7
+ const GitConfigSchema = z.object({
8
+ auto_pr: z.boolean().default(true),
9
+ branch_prefix: z.string().default('ranni'),
10
+ base_branch: z.string().default('main'),
11
+ await_merge: z.boolean().default(false)
12
+ })
13
+
7
14
  const ConfigSchema = z.object({
8
15
  worker: z.object({
9
16
  command: z.string(),
@@ -11,6 +18,7 @@ const ConfigSchema = z.object({
11
18
  }),
12
19
  max_workers: z.number().int().min(1).default(3),
13
20
  persist_runs: z.boolean().default(false),
21
+ git: GitConfigSchema.optional(),
14
22
  dirs: z.record(z.string()),
15
23
  manager_context: z.string().optional()
16
24
  })
package/src/index.ts CHANGED
@@ -4,16 +4,20 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
4
4
  import { loadConfig } from './config.js';
5
5
  import { detectConflict, handleConflict } from './conflict.js';
6
6
  import {
7
+ autoAcknowledge,
7
8
  cancelTask,
8
9
  complete,
9
10
  enqueue,
10
11
  getPendingResults,
11
12
  getSnapshot,
13
+ markAwaitingReview,
12
14
  queuePath,
13
15
  readQueue,
14
16
  resetInterrupted,
15
- startNext
17
+ startNext,
18
+ writeQueue
16
19
  } from './queue.js';
20
+ import { commitAndOpenPR, pollPRStatus, pushToPRBranch } from './pr.js';
17
21
  import { initRun } from './runs.js';
18
22
  import type { Config, Task } from './types.js';
19
23
  import { runWorker } from './worker.js';
@@ -23,7 +27,8 @@ const DEFAULT_MANAGER_CONTEXT = `ORCHESTRATOR RULES (read before every action):
23
27
  2. Each task must be self-contained: include all file paths, context, and background the worker needs.
24
28
  3. Use depends_on when task B genuinely needs task A's output to be on disk first.
25
29
  4. When a worker returns needs_help, surface the question to the user before dispatching a follow-up.
26
- 5. When a worker returns error, decide: retry, dispatch a corrected version, or inform the user.`
30
+ 5. When a worker returns error, decide: retry, dispatch a corrected version, or inform the user.
31
+ 6. Tasks in awaiting_review are waiting for their PR to be merged — do not cancel them.`
27
32
 
28
33
  function formatFooter(managerContext: string): string {
29
34
  return `\n\n---\n${managerContext}`
@@ -38,7 +43,7 @@ async function main() {
38
43
 
39
44
  let activeCount = 0
40
45
 
41
- async function tickPool() {
46
+ function tickPool() {
42
47
  const available = config.max_workers - activeCount
43
48
  if (available <= 0) return
44
49
 
@@ -55,6 +60,17 @@ async function main() {
55
60
 
56
61
  setInterval(tickPool, 200)
57
62
 
63
+ if (config.git?.auto_pr) {
64
+ setInterval(() => {
65
+ pollPRStatus(qPath, config, tasks => {
66
+ enqueue(qPath, tasks)
67
+ tickPool()
68
+ }).catch(err => {
69
+ process.stderr.write(`[ranni] Poll error: ${err}\n`)
70
+ })
71
+ }, 60_000)
72
+ }
73
+
58
74
  const server = new Server({ name: 'ranni-mcp', version: '0.1.0' }, { capabilities: { tools: {} } })
59
75
 
60
76
  const footer = formatFooter(config.manager_context ?? DEFAULT_MANAGER_CONTEXT)
@@ -105,7 +121,7 @@ async function main() {
105
121
  },
106
122
  {
107
123
  name: 'list_active_workers',
108
- description: 'Snapshot of the current queue: running workers, pending tasks, and free slots.',
124
+ description: 'Snapshot of the current queue: running workers, pending tasks, and PRs awaiting review.',
109
125
  inputSchema: { type: 'object', properties: {} }
110
126
  },
111
127
  {
@@ -176,18 +192,22 @@ async function main() {
176
192
  case 'list_active_workers': {
177
193
  const snapshot = getSnapshot(qPath)
178
194
  const runningList = snapshot.running
179
- .map(t => ` [running] ${t.id} (${t.dir}) — started ${t.started_at}`)
195
+ .map(t => ` [running] ${t.id} (${t.dir}) — started ${t.started_at}`)
180
196
  .join('\n')
181
197
  const queuedList = snapshot.queued
182
198
  .map(
183
199
  t =>
184
- ` [queued] ${t.id} (${t.dir})${t.depends_on?.length ? ` — waiting on: ${t.depends_on.join(', ')}` : ''}`
200
+ ` [queued] ${t.id} (${t.dir})${t.depends_on?.length ? ` — waiting on: ${t.depends_on.join(', ')}` : ''}`
185
201
  )
186
202
  .join('\n')
203
+ const awaitingList = snapshot.awaiting
204
+ .map(t => ` [awaiting_review] ${t.id} (${t.dir}) — ${t.result?.pr_url ?? 'PR pending'}`)
205
+ .join('\n')
187
206
  const lines = [
188
207
  `Workers: ${activeCount}/${config.max_workers} active`,
189
208
  runningList || ' (none running)',
190
- queuedList || ' (queue empty)'
209
+ queuedList || ' (queue empty)',
210
+ awaitingList || ' (no PRs awaiting review)'
191
211
  ].join('\n')
192
212
  return { content: [{ type: 'text', text: lines + footer }] }
193
213
  }
@@ -207,7 +227,8 @@ async function main() {
207
227
  const summary = r ? r.summary : '(no summary)'
208
228
  const files = r?.files_changed?.length ? `\n Files: ${r.files_changed.join(', ')}` : ''
209
229
  const msg = r?.message ? `\n Message: ${r.message}` : ''
210
- return `[${status.toUpperCase()}] ${t.id} (${t.dir})\n ${summary}${files}${msg}`
230
+ const pr = r?.pr_url ? `\n PR: ${r.pr_url}` : ''
231
+ return `[${status.toUpperCase()}] ${t.id} (${t.dir})\n ${summary}${files}${msg}${pr}`
211
232
  })
212
233
  .join('\n\n')
213
234
 
@@ -247,13 +268,41 @@ async function runWorkerAsync(
247
268
 
248
269
  runLogger?.updateSummary(readQueue(qPath).tasks)
249
270
 
250
- const queue = readQueue(qPath)
251
- const conflict = detectConflict(result, queue, task.id)
271
+ const resolvedDir = config.dirs[task.dir]!
272
+
273
+ const conflict = detectConflict(result, readQueue(qPath), task.id)
252
274
  if (conflict) {
253
- const resolvedDir = config.dirs[task.dir]!
254
275
  await handleConflict(qPath, task, conflict, resolvedDir)
255
276
  }
256
277
 
278
+ if (task.parent_task_id && result.status === 'done') {
279
+ // Correction worker: push changes to the parent task's existing PR branch
280
+ const parentTask = readQueue(qPath).tasks.find(t => t.id === task.parent_task_id)
281
+ if (parentTask?.pr_branch) {
282
+ await pushToPRBranch(task, result, resolvedDir, parentTask.pr_branch)
283
+ }
284
+ autoAcknowledge(qPath, task.id)
285
+ } else if (!task.parent_task_id && result.status === 'done') {
286
+ // Normal worker: open a PR, then either babysit it or just record the URL
287
+ const pr = await commitAndOpenPR(task, result, config, resolvedDir)
288
+ if (pr) {
289
+ if (config.git?.await_merge) {
290
+ markAwaitingReview(qPath, task.id, pr.prUrl, pr.branch)
291
+ } else {
292
+ // PR created, task stays done — store url+branch and mark unmerged so
293
+ // depends_on chains wait for the poll to confirm the PR landed
294
+ const q = readQueue(qPath)
295
+ const t = q.tasks.find(x => x.id === task.id)
296
+ if (t) {
297
+ t.pr_branch = pr.branch
298
+ t.pr_merged = false
299
+ if (t.result) t.result.pr_url = pr.prUrl
300
+ writeQueue(qPath, q)
301
+ }
302
+ }
303
+ }
304
+ }
305
+
257
306
  if (runLogger) {
258
307
  const stdout = result.message ?? result.summary
259
308
  runLogger.logTask(task.id, stdout)
package/src/pr.ts ADDED
@@ -0,0 +1,291 @@
1
+ import { execSync } from 'child_process'
2
+ import { relative } from 'path'
3
+ import { markPRMerged, readQueue, updatePrCommentCursor, writeQueue } from './queue.js'
4
+ import type { Config, Task, WorkerResult } from './types.js'
5
+
6
+ // Promise-chain mutex: only one git operation runs at a time to avoid index corruption
7
+ let gate: Promise<void> = Promise.resolve()
8
+
9
+ function run(cmd: string, cwd: string): string {
10
+ return execSync(cmd, { cwd, encoding: 'utf8' }).trim()
11
+ }
12
+
13
+ function findGitRoot(dir: string): string {
14
+ return run('git rev-parse --show-toplevel', dir)
15
+ }
16
+
17
+ function withGateLock<T>(fn: () => Promise<T>): Promise<T> {
18
+ let release!: () => void
19
+ const next = new Promise<void>(r => { release = r })
20
+ const prev = gate
21
+ gate = next
22
+ return prev.then(fn).finally(release)
23
+ }
24
+
25
+ function gitPaths(resolvedDir: string, gitRoot: string, filesChanged: string[]): string[] {
26
+ const rel = relative(gitRoot, resolvedDir)
27
+ return filesChanged.map(f => (rel ? `${rel}/${f}` : f))
28
+ }
29
+
30
+ function stashAndCheckout(gitRoot: string, label: string, branch: string): { origBranch: string; stashed: boolean } {
31
+ const origBranch = run('git rev-parse --abbrev-ref HEAD', gitRoot)
32
+ const stashOut = run(`git stash push --include-untracked -m "ranni-${label}"`, gitRoot)
33
+ const stashed = !stashOut.includes('No local changes to save')
34
+ run(`git checkout ${branch}`, gitRoot)
35
+ return { origBranch, stashed }
36
+ }
37
+
38
+ function restoreAndReturn(gitRoot: string, origBranch: string, stashed: boolean): void {
39
+ run(`git checkout ${origBranch}`, gitRoot)
40
+ if (stashed) run('git stash pop', gitRoot)
41
+ }
42
+
43
+ function cherryPickFromStash(gitRoot: string, paths: string[]): void {
44
+ for (const p of paths) {
45
+ try {
46
+ run(`git checkout stash@{0} -- "${p}"`, gitRoot)
47
+ } catch {
48
+ // path may differ between resolvedDir and gitRoot — skip
49
+ }
50
+ }
51
+ }
52
+
53
+ // ──────────────────────────────────────────────
54
+ // Create branch + PR for a finished task
55
+ // ──────────────────────────────────────────────
56
+
57
+ export async function commitAndOpenPR(
58
+ task: Task,
59
+ result: WorkerResult,
60
+ config: Config,
61
+ resolvedDir: string
62
+ ): Promise<{ prUrl: string; branch: string } | undefined> {
63
+ const gitCfg = config.git
64
+ if (!gitCfg?.auto_pr || result.status !== 'done' || result.files_changed.length === 0) {
65
+ return undefined
66
+ }
67
+ return withGateLock(() => doCommitAndPR(task, result, gitCfg, resolvedDir))
68
+ }
69
+
70
+ async function doCommitAndPR(
71
+ task: Task,
72
+ result: WorkerResult,
73
+ gitCfg: NonNullable<Config['git']>,
74
+ resolvedDir: string
75
+ ): Promise<{ prUrl: string; branch: string } | undefined> {
76
+ const branch = `${gitCfg.branch_prefix}/${task.id}`
77
+
78
+ let gitRoot: string
79
+ try {
80
+ gitRoot = findGitRoot(resolvedDir)
81
+ } catch {
82
+ process.stderr.write(`[ranni] PR skipped for ${task.id}: not in a git repository\n`)
83
+ return undefined
84
+ }
85
+
86
+ const paths = gitPaths(resolvedDir, gitRoot, result.files_changed)
87
+ let origBranch = gitCfg.base_branch
88
+ let stashed = false
89
+
90
+ try {
91
+ origBranch = run('git rev-parse --abbrev-ref HEAD', gitRoot)
92
+
93
+ const stashOut = run(`git stash push --include-untracked -m "ranni-temp-${task.id}"`, gitRoot)
94
+ stashed = !stashOut.includes('No local changes to save')
95
+
96
+ run(`git fetch origin ${gitCfg.base_branch} --quiet`, gitRoot)
97
+ run(`git checkout -b ${branch} origin/${gitCfg.base_branch}`, gitRoot)
98
+
99
+ if (stashed) cherryPickFromStash(gitRoot, paths)
100
+
101
+ run(`git add -- ${paths.map(p => `"${p}"`).join(' ')}`, gitRoot)
102
+ run(`git commit -m ${JSON.stringify(`${result.summary}\n\nTask: ${task.id}`)}`, gitRoot)
103
+ run(`git push -u origin ${branch}`, gitRoot)
104
+
105
+ restoreAndReturn(gitRoot, origBranch, stashed)
106
+
107
+ const prBody = buildPrBody(task, result)
108
+ const prUrl = run(
109
+ `gh pr create --title ${JSON.stringify(result.summary)} --body ${JSON.stringify(prBody)} --base ${gitCfg.base_branch} --head ${branch}`,
110
+ gitRoot
111
+ )
112
+
113
+ process.stderr.write(`[ranni] PR opened for ${task.id}: ${prUrl}\n`)
114
+ return { prUrl, branch }
115
+ } catch (err) {
116
+ process.stderr.write(`[ranni] PR creation failed for ${task.id}: ${err}\n`)
117
+ try { restoreAndReturn(gitRoot!, origBranch, stashed) } catch {}
118
+ return undefined
119
+ }
120
+ }
121
+
122
+ // ──────────────────────────────────────────────
123
+ // Push a correction worker's changes to an existing PR branch
124
+ // ──────────────────────────────────────────────
125
+
126
+ export async function pushToPRBranch(
127
+ task: Task,
128
+ result: WorkerResult,
129
+ resolvedDir: string,
130
+ prBranch: string
131
+ ): Promise<void> {
132
+ return withGateLock(() => doPushToPRBranch(task, result, resolvedDir, prBranch))
133
+ }
134
+
135
+ async function doPushToPRBranch(
136
+ task: Task,
137
+ result: WorkerResult,
138
+ resolvedDir: string,
139
+ prBranch: string
140
+ ): Promise<void> {
141
+ let gitRoot: string
142
+ try {
143
+ gitRoot = findGitRoot(resolvedDir)
144
+ } catch {
145
+ process.stderr.write(`[ranni] Push skipped for ${task.id}: not in a git repository\n`)
146
+ return
147
+ }
148
+
149
+ const paths = gitPaths(resolvedDir, gitRoot, result.files_changed)
150
+ let origBranch = prBranch
151
+ let stashed = false
152
+
153
+ try {
154
+ ;({ origBranch, stashed } = stashAndCheckout(gitRoot, `correction-${task.id}`, prBranch))
155
+
156
+ if (stashed) cherryPickFromStash(gitRoot, paths)
157
+
158
+ run(`git add -- ${paths.map(p => `"${p}"`).join(' ')}`, gitRoot)
159
+ run(`git commit -m "Address review feedback (task ${task.id})"`, gitRoot)
160
+ run(`git push origin ${prBranch}`, gitRoot)
161
+
162
+ restoreAndReturn(gitRoot, origBranch, stashed)
163
+ process.stderr.write(`[ranni] Pushed correction for ${task.id} → ${prBranch}\n`)
164
+ } catch (err) {
165
+ process.stderr.write(`[ranni] Push correction failed for ${task.id}: ${err}\n`)
166
+ try { restoreAndReturn(gitRoot!, origBranch, stashed) } catch {}
167
+ }
168
+ }
169
+
170
+ // ──────────────────────────────────────────────
171
+ // Poll open PRs: merge detection (always) + comment relay (await_merge only)
172
+ // ──────────────────────────────────────────────
173
+
174
+ type CorrectionTask = Omit<Task, 'status' | 'created_at' | 'started_at' | 'finished_at' | 'result' | 'acknowledged'>
175
+
176
+ export async function pollPRStatus(
177
+ qPath: string,
178
+ config: Config,
179
+ dispatch: (tasks: CorrectionTask[]) => void
180
+ ): Promise<void> {
181
+ const queue = readQueue(qPath)
182
+ const awaitMerge = config.git?.await_merge ?? false
183
+
184
+ // Track all tasks that have an open PR not yet confirmed merged
185
+ const tracked = queue.tasks.filter(
186
+ t => t.pr_branch && t.pr_merged !== true && (t.status === 'done' || t.status === 'awaiting_review')
187
+ )
188
+ if (tracked.length === 0) return
189
+
190
+ for (const task of tracked) {
191
+ const prUrl = task.result?.pr_url
192
+ if (!prUrl) continue
193
+
194
+ try {
195
+ const resolvedDir = config.dirs[task.dir]
196
+ if (!resolvedDir) continue
197
+
198
+ let gitRoot: string
199
+ try {
200
+ gitRoot = findGitRoot(resolvedDir)
201
+ } catch {
202
+ continue
203
+ }
204
+
205
+ // Fetch comments only when babysitting an awaiting_review task
206
+ const needComments = awaitMerge && task.status === 'awaiting_review'
207
+ const fields = needComments ? 'state,comments' : 'state'
208
+ const raw = run(`gh pr view "${prUrl}" --json ${fields}`, gitRoot)
209
+ const data = JSON.parse(raw) as {
210
+ state: string
211
+ comments?: Array<{ body: string; createdAt: string; author: { login: string } }>
212
+ }
213
+
214
+ if (data.state === 'MERGED') {
215
+ // Works for both done and awaiting_review — markPRMerged handles both
216
+ markPRMerged(qPath, task.id)
217
+ process.stderr.write(`[ranni] PR merged — ${task.id}\n`)
218
+ continue
219
+ }
220
+
221
+ if (data.state === 'CLOSED' && task.status === 'awaiting_review') {
222
+ const q = readQueue(qPath)
223
+ const t = q.tasks.find(x => x.id === task.id)
224
+ if (t) {
225
+ t.status = 'error'
226
+ if (t.result) t.result.message = 'PR closed without merging'
227
+ writeQueue(qPath, q)
228
+ }
229
+ process.stderr.write(`[ranni] PR closed without merge — ${task.id}\n`)
230
+ continue
231
+ }
232
+
233
+ // Comment relay only when babysitting
234
+ if (!needComments) continue
235
+
236
+ const cursor = task.pr_comment_cursor ?? '1970-01-01T00:00:00Z'
237
+ const newComments = (data.comments ?? []).filter(c => c.createdAt > cursor)
238
+ if (newComments.length === 0) continue
239
+
240
+ const latestTime = newComments.reduce((m, c) => (c.createdAt > m ? c.createdAt : m), cursor)
241
+ updatePrCommentCursor(qPath, task.id, latestTime)
242
+
243
+ const existingCorrections = queue.tasks.filter(t => t.parent_task_id === task.id).length
244
+ const correctionId = `${task.id}-correction-${existingCorrections + 1}`
245
+ const commentsText = newComments.map(c => `**${c.author.login}**: ${c.body}`).join('\n\n')
246
+
247
+ dispatch([{
248
+ id: correctionId,
249
+ dir: task.dir,
250
+ task: buildCorrectionPrompt(task, commentsText),
251
+ context: task.context,
252
+ parent_task_id: task.id
253
+ }])
254
+
255
+ process.stderr.write(`[ranni] ${newComments.length} new comment(s) on ${task.id} — dispatching ${correctionId}\n`)
256
+ } catch (err) {
257
+ process.stderr.write(`[ranni] Poll error for ${task.id}: ${err}\n`)
258
+ }
259
+ }
260
+ }
261
+
262
+ // ──────────────────────────────────────────────
263
+ // Helpers
264
+ // ──────────────────────────────────────────────
265
+
266
+ function buildCorrectionPrompt(task: Task, commentsText: string): string {
267
+ const prUrl = task.result?.pr_url ?? '(see context)'
268
+ return `You are addressing reviewer feedback on an open pull request.
269
+
270
+ PR: ${prUrl}
271
+
272
+ ## Reviewer Comments
273
+ ${commentsText}
274
+
275
+ ## Original Task
276
+ ${task.task}
277
+
278
+ Apply only the changes needed to satisfy the reviewer's comments. Do not commit, push, or open a new PR — the orchestrator handles that.`
279
+ }
280
+
281
+ function buildPrBody(task: Task, result: WorkerResult): string {
282
+ const sections: string[] = []
283
+ sections.push(`## Task\n${task.task}`)
284
+ if (task.context) sections.push(`## Context\n${task.context}`)
285
+ if (result.files_changed.length) {
286
+ sections.push(`## Files Changed\n${result.files_changed.map(f => `- \`${f}\``).join('\n')}`)
287
+ }
288
+ if (result.message) sections.push(`## Notes\n${result.message}`)
289
+ sections.push(`_Generated by [ranni](https://github.com/filfp/ranni) · task \`${task.id}\`_`)
290
+ return sections.join('\n\n')
291
+ }
package/src/queue.ts CHANGED
@@ -43,7 +43,12 @@ export function enqueue(
43
43
 
44
44
  export function startNext(path: string): Task | null {
45
45
  const queue = readQueue(path)
46
- const doneIds = new Set(queue.tasks.filter(t => t.status === 'done').map(t => t.id))
46
+ // A done task with a PR only satisfies depends_on after the PR lands in base_branch
47
+ const doneIds = new Set(
48
+ queue.tasks
49
+ .filter(t => t.status === 'done' && (!t.pr_branch || t.pr_merged === true))
50
+ .map(t => t.id)
51
+ )
47
52
 
48
53
  const idx = queue.tasks.findIndex(t => {
49
54
  if (t.status !== 'pending') return false
@@ -85,6 +90,43 @@ export function markConflict(path: string, id: string): void {
85
90
  }
86
91
  }
87
92
 
93
+ export function markAwaitingReview(path: string, id: string, prUrl: string, branch: string): void {
94
+ const queue = readQueue(path)
95
+ const task = queue.tasks.find(t => t.id === id)
96
+ if (!task) return
97
+ task.status = 'awaiting_review'
98
+ task.pr_branch = branch
99
+ task.pr_merged = false
100
+ if (task.result) task.result.pr_url = prUrl
101
+ writeQueue(path, queue)
102
+ }
103
+
104
+ // Called when the PR lands. Moves awaiting_review → done; for already-done tasks just sets the flag.
105
+ export function markPRMerged(path: string, id: string): void {
106
+ const queue = readQueue(path)
107
+ const task = queue.tasks.find(t => t.id === id)
108
+ if (!task) return
109
+ task.pr_merged = true
110
+ if (task.status === 'awaiting_review') task.status = 'done'
111
+ writeQueue(path, queue)
112
+ }
113
+
114
+ export function updatePrCommentCursor(path: string, id: string, cursor: string): void {
115
+ const queue = readQueue(path)
116
+ const task = queue.tasks.find(t => t.id === id)
117
+ if (!task) return
118
+ task.pr_comment_cursor = cursor
119
+ writeQueue(path, queue)
120
+ }
121
+
122
+ export function autoAcknowledge(path: string, id: string): void {
123
+ const queue = readQueue(path)
124
+ const task = queue.tasks.find(t => t.id === id)
125
+ if (!task) return
126
+ task.acknowledged = true
127
+ writeQueue(path, queue)
128
+ }
129
+
88
130
  export function resetInterrupted(path: string): void {
89
131
  const queue = readQueue(path)
90
132
  let changed = false
@@ -126,14 +168,12 @@ export function getPendingResults(path: string, drain: boolean): Task[] {
126
168
  export function getSnapshot(path: string): {
127
169
  running: Task[]
128
170
  queued: Task[]
129
- slots_free: number
130
- max_workers: number
171
+ awaiting: Task[]
131
172
  } {
132
173
  const queue = readQueue(path)
133
174
  return {
134
175
  running: queue.tasks.filter(t => t.status === 'running'),
135
176
  queued: queue.tasks.filter(t => t.status === 'pending'),
136
- slots_free: 0,
137
- max_workers: 0
177
+ awaiting: queue.tasks.filter(t => t.status === 'awaiting_review')
138
178
  }
139
179
  }
package/src/types.ts CHANGED
@@ -1,4 +1,12 @@
1
- export type TaskStatus = 'pending' | 'running' | 'done' | 'done_with_conflict' | 'needs_help' | 'error' | 'cancelled'
1
+ export type TaskStatus =
2
+ | 'pending'
3
+ | 'running'
4
+ | 'done'
5
+ | 'done_with_conflict'
6
+ | 'needs_help'
7
+ | 'error'
8
+ | 'cancelled'
9
+ | 'awaiting_review'
2
10
 
3
11
  export type Task = {
4
12
  id: string
@@ -14,6 +22,12 @@ export type Task = {
14
22
  finished_at: string | null
15
23
  result: WorkerResult | null
16
24
  acknowledged: boolean
25
+ // PR tracking fields (set when auto_pr creates a branch)
26
+ pr_branch?: string
27
+ pr_merged?: boolean // true once the PR lands in base_branch; gates depends_on satisfaction
28
+ pr_comment_cursor?: string // ISO timestamp — comments after this are unread (await_merge only)
29
+ // Set on correction workers dispatched in response to review comments
30
+ parent_task_id?: string
17
31
  }
18
32
 
19
33
  export type WorkerResult = {
@@ -22,6 +36,7 @@ export type WorkerResult = {
22
36
  files_changed: string[]
23
37
  message?: string
24
38
  exit_code: number
39
+ pr_url?: string
25
40
  }
26
41
 
27
42
  export type QueueFile = {
@@ -29,10 +44,18 @@ export type QueueFile = {
29
44
  touched_files: Record<string, string>
30
45
  }
31
46
 
47
+ export type GitConfig = {
48
+ auto_pr: boolean
49
+ branch_prefix: string
50
+ base_branch: string
51
+ await_merge: boolean
52
+ }
53
+
32
54
  export type Config = {
33
55
  worker: { command: string; args: string[] }
34
56
  max_workers: number
35
57
  persist_runs: boolean
58
+ git?: GitConfig
36
59
  dirs: Record<string, string>
37
60
  manager_context?: string
38
61
  }
@@ -1,17 +0,0 @@
1
- worker:
2
- command: claude
3
- args: [--print, --dangerously-skip-permissions]
4
-
5
- max_workers: 3
6
- persist_runs: false
7
-
8
- dirs:
9
- root: .
10
- # Add your project directories:
11
- # backend: ./backend
12
- # web: ./apps/web
13
- # mobile: ./apps/mobile
14
-
15
- # Optional — injected into every MCP tool response as a reminder to the manager
16
- # manager_context: |
17
- # Always call get_pending_results() before dispatching new tasks.