create-claude-workspace 2.3.18 → 2.3.19
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.
|
@@ -15,7 +15,21 @@ export function createWorktree(projectDir, branchSlug, baseBranch) {
|
|
|
15
15
|
if (existsSync(worktreePath)) {
|
|
16
16
|
return worktreePath;
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
// If the branch already exists (locally or from remote), check it out instead of creating
|
|
19
|
+
if (branchExists(projectDir, branchSlug)) {
|
|
20
|
+
git(['worktree', 'add', worktreePath, branchSlug], projectDir);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
// Try fetching from remote in case it exists only on origin
|
|
24
|
+
try {
|
|
25
|
+
git(['fetch', 'origin', branchSlug], projectDir, PUSH_TIMEOUT);
|
|
26
|
+
git(['worktree', 'add', worktreePath, '-b', branchSlug, `origin/${branchSlug}`], projectDir);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Branch doesn't exist anywhere — create new from base
|
|
30
|
+
git(['worktree', 'add', worktreePath, '-b', branchSlug, base], projectDir);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
19
33
|
return worktreePath;
|
|
20
34
|
}
|
|
21
35
|
export function cleanupWorktree(projectDir, worktreePath, branch) {
|
|
@@ -216,6 +230,120 @@ export function isBranchMerged(projectDir, branch) {
|
|
|
216
230
|
return false;
|
|
217
231
|
}
|
|
218
232
|
}
|
|
233
|
+
// ─── Branch cleanup ───
|
|
234
|
+
/**
|
|
235
|
+
* Find and clean up local branches that are fully merged into main
|
|
236
|
+
* and have no associated worktree. Also removes their remote counterparts.
|
|
237
|
+
* Returns the list of cleaned branch names.
|
|
238
|
+
*/
|
|
239
|
+
export function cleanMergedBranches(projectDir) {
|
|
240
|
+
const main = getMainBranch(projectDir);
|
|
241
|
+
const cleaned = [];
|
|
242
|
+
// Get all local branches except main
|
|
243
|
+
let branches;
|
|
244
|
+
try {
|
|
245
|
+
const output = git(['branch', '--format', '%(refname:short)'], projectDir);
|
|
246
|
+
branches = output.split('\n').filter(b => b && b !== main);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
// Get worktree branches to avoid cleaning active worktrees
|
|
252
|
+
const worktreeBranches = new Set();
|
|
253
|
+
try {
|
|
254
|
+
const wtOutput = git(['worktree', 'list', '--porcelain'], projectDir);
|
|
255
|
+
for (const line of wtOutput.split('\n')) {
|
|
256
|
+
if (line.startsWith('branch refs/heads/')) {
|
|
257
|
+
worktreeBranches.add(line.replace('branch refs/heads/', '').trim());
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch { /* ignore */ }
|
|
262
|
+
for (const branch of branches) {
|
|
263
|
+
if (worktreeBranches.has(branch))
|
|
264
|
+
continue;
|
|
265
|
+
if (isBranchMerged(projectDir, branch)) {
|
|
266
|
+
// Delete local branch
|
|
267
|
+
try {
|
|
268
|
+
git(['branch', '-d', branch], projectDir);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
try {
|
|
272
|
+
git(['branch', '-D', branch], projectDir);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// Delete remote branch (best-effort)
|
|
279
|
+
deleteBranchRemote(projectDir, branch);
|
|
280
|
+
cleaned.push(branch);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return cleaned;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Clean up remote-tracking branches whose upstream has been deleted.
|
|
287
|
+
* Equivalent to `git fetch --prune`.
|
|
288
|
+
*/
|
|
289
|
+
export function pruneRemoteBranches(projectDir) {
|
|
290
|
+
try {
|
|
291
|
+
git(['fetch', '--prune'], projectDir, PUSH_TIMEOUT);
|
|
292
|
+
}
|
|
293
|
+
catch { /* best-effort */ }
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Find local branches that are NOT merged into main, NOT in any worktree,
|
|
297
|
+
* and NOT the main branch itself. These are potentially abandoned/orphaned
|
|
298
|
+
* work that needs AI analysis to decide what to do with them.
|
|
299
|
+
*/
|
|
300
|
+
export function findStaleUnmergedBranches(projectDir) {
|
|
301
|
+
const main = getMainBranch(projectDir);
|
|
302
|
+
const stale = [];
|
|
303
|
+
let branches;
|
|
304
|
+
try {
|
|
305
|
+
const output = git(['branch', '--format', '%(refname:short)'], projectDir);
|
|
306
|
+
branches = output.split('\n').filter(b => b && b !== main);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
// Get worktree branches to exclude
|
|
312
|
+
const worktreeBranches = new Set();
|
|
313
|
+
try {
|
|
314
|
+
const wtOutput = git(['worktree', 'list', '--porcelain'], projectDir);
|
|
315
|
+
for (const line of wtOutput.split('\n')) {
|
|
316
|
+
if (line.startsWith('branch refs/heads/')) {
|
|
317
|
+
worktreeBranches.add(line.replace('branch refs/heads/', '').trim());
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch { /* ignore */ }
|
|
322
|
+
for (const branch of branches) {
|
|
323
|
+
if (worktreeBranches.has(branch))
|
|
324
|
+
continue;
|
|
325
|
+
if (isBranchMerged(projectDir, branch))
|
|
326
|
+
continue;
|
|
327
|
+
// Get commit info
|
|
328
|
+
try {
|
|
329
|
+
const aheadOutput = git(['rev-list', '--count', `${main}..${branch}`], projectDir);
|
|
330
|
+
const commitsAhead = parseInt(aheadOutput, 10) || 0;
|
|
331
|
+
if (commitsAhead === 0)
|
|
332
|
+
continue; // No actual changes
|
|
333
|
+
const logOutput = git(['log', '-1', '--format=%s|||%aI', branch], projectDir);
|
|
334
|
+
const [lastCommitMsg, lastCommitDate] = logOutput.split('|||');
|
|
335
|
+
let hasRemote = false;
|
|
336
|
+
try {
|
|
337
|
+
git(['rev-parse', '--verify', `origin/${branch}`], projectDir);
|
|
338
|
+
hasRemote = true;
|
|
339
|
+
}
|
|
340
|
+
catch { /* no remote */ }
|
|
341
|
+
stale.push({ name: branch, commitsAhead, lastCommitMsg: lastCommitMsg ?? '', lastCommitDate: lastCommitDate ?? '', hasRemote });
|
|
342
|
+
}
|
|
343
|
+
catch { /* skip branches that can't be inspected */ }
|
|
344
|
+
}
|
|
345
|
+
return stale;
|
|
346
|
+
}
|
|
219
347
|
// ─── Git identity ───
|
|
220
348
|
export function hasGitIdentity(projectDir) {
|
|
221
349
|
try {
|
|
@@ -188,7 +188,6 @@ function getGitLabMRComments(cwd, mrIid) {
|
|
|
188
188
|
return [];
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
|
-
// ─── Merge PR ───
|
|
192
191
|
export function mergePR(cwd, platform, prNumber, method = 'merge') {
|
|
193
192
|
try {
|
|
194
193
|
if (platform === 'github') {
|
|
@@ -201,10 +200,10 @@ export function mergePR(cwd, platform, prNumber, method = 'merge') {
|
|
|
201
200
|
args.push('--squash');
|
|
202
201
|
run('glab', args, cwd, WRITE_TIMEOUT);
|
|
203
202
|
}
|
|
204
|
-
return true;
|
|
203
|
+
return { success: true };
|
|
205
204
|
}
|
|
206
|
-
catch {
|
|
207
|
-
return false;
|
|
205
|
+
catch (err) {
|
|
206
|
+
return { success: false, error: err.message?.split('\n')[0] };
|
|
208
207
|
}
|
|
209
208
|
}
|
|
210
209
|
// ─── Issue label management ───
|
package/dist/scheduler/loop.mjs
CHANGED
|
@@ -8,8 +8,8 @@ import { fetchOpenIssues, issueToTask, updateIssueStatus } from './tasks/issue-s
|
|
|
8
8
|
import { buildGraph, getParallelBatches, isPhaseComplete, getNextPhase, isProjectComplete } from './tasks/queue.mjs';
|
|
9
9
|
import { writeState, appendEvent, createEvent, rotateLog } from './state/state.mjs';
|
|
10
10
|
import { recordSession, getSession, clearSession } from './state/session.mjs';
|
|
11
|
-
import { createWorktree, commitInWorktree, getChangedFiles, cleanupWorktree, mergeToMain, syncMain, pushWorktree, forcePushWorktree, rebaseOnMain, cleanMainForMerge, popStash, getMainBranch, listWorktrees, listOrphanedWorktrees, isBranchMerged, getCurrentBranch, hasUncommittedChanges, deleteBranchRemote, } from './git/manager.mjs';
|
|
12
|
-
import { createPR, getPRStatus, getPRComments, mergePR } from './git/pr-manager.mjs';
|
|
11
|
+
import { createWorktree, commitInWorktree, getChangedFiles, cleanupWorktree, mergeToMain, syncMain, pushWorktree, forcePushWorktree, rebaseOnMain, cleanMainForMerge, popStash, getMainBranch, listWorktrees, listOrphanedWorktrees, isBranchMerged, getCurrentBranch, hasUncommittedChanges, deleteBranchRemote, cleanMergedBranches, pruneRemoteBranches, findStaleUnmergedBranches, } from './git/manager.mjs';
|
|
12
|
+
import { createPR, getPRStatus, getPRComments, mergePR, closePR } from './git/pr-manager.mjs';
|
|
13
13
|
import { scanAgents } from './agents/health-checker.mjs';
|
|
14
14
|
import { detectCIPlatform, fetchFailureLogs } from './git/ci-watcher.mjs';
|
|
15
15
|
import { createRelease } from './git/release.mjs';
|
|
@@ -200,13 +200,7 @@ export async function runIteration(deps) {
|
|
|
200
200
|
const pipeline = state.pipelines[taskId];
|
|
201
201
|
const result = await checkPRWatch(taskId, pipeline, projectDir, agents, deps);
|
|
202
202
|
if (result === 'merged') {
|
|
203
|
-
|
|
204
|
-
try {
|
|
205
|
-
branch = getCurrentBranch(pipeline.worktreePath);
|
|
206
|
-
}
|
|
207
|
-
catch {
|
|
208
|
-
branch = taskId;
|
|
209
|
-
}
|
|
203
|
+
const branch = pipeline.branchSlug;
|
|
210
204
|
logger.info(`[${taskId}] PR merged via platform`);
|
|
211
205
|
appendEvent(projectDir, createEvent('pr_merged', { taskId }));
|
|
212
206
|
syncMain(projectDir);
|
|
@@ -228,10 +222,11 @@ export async function runIteration(deps) {
|
|
|
228
222
|
workDone = true;
|
|
229
223
|
}
|
|
230
224
|
else if (result === 'failed') {
|
|
231
|
-
logger.error(`[${taskId}] PR failed — skipping task`);
|
|
225
|
+
logger.error(`[${taskId}] PR failed — all recovery strategies exhausted, skipping task`);
|
|
232
226
|
pipeline.step = 'failed';
|
|
233
227
|
state.skippedTasks.push(taskId);
|
|
234
228
|
delete state.pipelines[taskId];
|
|
229
|
+
appendEvent(projectDir, createEvent('task_skipped', { taskId, detail: 'PR merge failed after all recovery attempts' }));
|
|
235
230
|
workDone = true;
|
|
236
231
|
}
|
|
237
232
|
else if (result === 'rework') {
|
|
@@ -368,8 +363,8 @@ async function runTaskPipeline(task, workerId, agents, deps) {
|
|
|
368
363
|
const existing = state.pipelines[task.id];
|
|
369
364
|
const resumeStep = existing?.step;
|
|
370
365
|
// Create worktree (returns existing path if already exists)
|
|
371
|
-
const slug = existing
|
|
372
|
-
const worktreePath = existing?.worktreePath ?? createWorktree(projectDir,
|
|
366
|
+
const slug = existing?.branchSlug ?? taskToSlug(task);
|
|
367
|
+
const worktreePath = existing?.worktreePath ?? createWorktree(projectDir, slug);
|
|
373
368
|
if (!existing) {
|
|
374
369
|
logger.info(`[${task.id}] Worktree created: ${taskToSlug(task)}`);
|
|
375
370
|
}
|
|
@@ -378,6 +373,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
|
|
|
378
373
|
taskId: task.id,
|
|
379
374
|
workerId,
|
|
380
375
|
worktreePath,
|
|
376
|
+
branchSlug: slug,
|
|
381
377
|
step: 'plan',
|
|
382
378
|
architectPlan: null,
|
|
383
379
|
apiContract: null,
|
|
@@ -388,6 +384,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
|
|
|
388
384
|
buildFixes: 0,
|
|
389
385
|
assignedAgent: null,
|
|
390
386
|
prState: null,
|
|
387
|
+
approvalWaitingSince: null,
|
|
391
388
|
};
|
|
392
389
|
pipeline.workerId = workerId;
|
|
393
390
|
state.pipelines[task.id] = pipeline;
|
|
@@ -563,6 +560,17 @@ async function runTaskPipeline(task, workerId, agents, deps) {
|
|
|
563
560
|
logger.warn(`[${task.id}] Nothing to commit`);
|
|
564
561
|
}
|
|
565
562
|
} // end if !shouldSkip('commit')
|
|
563
|
+
// If branch has no changes vs main, there's nothing to push/merge — skip to done
|
|
564
|
+
const changedFromMain = getChangedFiles(worktreePath);
|
|
565
|
+
if (changedFromMain.length === 0) {
|
|
566
|
+
logger.warn(`[${task.id}] Branch has no changes vs main — nothing to merge`);
|
|
567
|
+
cleanupWorktree(projectDir, worktreePath, slug);
|
|
568
|
+
deleteBranchRemote(projectDir, slug);
|
|
569
|
+
pipeline.step = 'done';
|
|
570
|
+
clearSession(state, task.id);
|
|
571
|
+
delete state.pipelines[task.id];
|
|
572
|
+
return true; // Not a failure, just nothing to do
|
|
573
|
+
}
|
|
566
574
|
const commitMsg = `feat: ${task.title}${task.issueMarker ? ` (${task.issueMarker})` : ''}`;
|
|
567
575
|
// STEP 6: Push + PR/MR flow
|
|
568
576
|
const ciPlatform = detectCIPlatform(projectDir);
|
|
@@ -582,7 +590,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
|
|
|
582
590
|
pipeline.step = 'pr-create';
|
|
583
591
|
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'pr-create' }));
|
|
584
592
|
const issueNum = extractIssueNumber(task.issueMarker);
|
|
585
|
-
const mainBranch =
|
|
593
|
+
const mainBranch = getMainBranch(projectDir);
|
|
586
594
|
try {
|
|
587
595
|
const prInfo = createPR({
|
|
588
596
|
cwd: projectDir,
|
|
@@ -789,6 +797,7 @@ export async function recoverOrphanedWorktrees(projectDir, state, logger, _deps)
|
|
|
789
797
|
taskId,
|
|
790
798
|
workerId: -1, // Will be assigned when picked up
|
|
791
799
|
worktreePath,
|
|
800
|
+
branchSlug: branch,
|
|
792
801
|
step,
|
|
793
802
|
architectPlan: null,
|
|
794
803
|
apiContract: null,
|
|
@@ -799,6 +808,7 @@ export async function recoverOrphanedWorktrees(projectDir, state, logger, _deps)
|
|
|
799
808
|
buildFixes: 0,
|
|
800
809
|
assignedAgent: null,
|
|
801
810
|
prState: null,
|
|
811
|
+
approvalWaitingSince: null,
|
|
802
812
|
};
|
|
803
813
|
}
|
|
804
814
|
catch (err) {
|
|
@@ -809,6 +819,104 @@ export async function recoverOrphanedWorktrees(projectDir, state, logger, _deps)
|
|
|
809
819
|
if (ciPlatform !== 'none') {
|
|
810
820
|
recoverOpenMRs(projectDir, ciPlatform, state, logger);
|
|
811
821
|
}
|
|
822
|
+
// Phase 3: Clean up merged branches (local + remote) and prune stale remote refs
|
|
823
|
+
pruneRemoteBranches(projectDir);
|
|
824
|
+
const cleaned = cleanMergedBranches(projectDir);
|
|
825
|
+
if (cleaned.length > 0) {
|
|
826
|
+
logger.info(`[recovery] Cleaned ${cleaned.length} merged branch(es): ${cleaned.join(', ')}`);
|
|
827
|
+
}
|
|
828
|
+
// Phase 4: Analyze stale unmerged branches (no worktree, no MR, not merged)
|
|
829
|
+
// These need AI to decide: re-inject, delete, or ignore
|
|
830
|
+
await analyzeStaleUnmergedBranches(projectDir, state, logger, _deps);
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Phase 4: Find branches with unmerged commits that have no worktree and no open MR.
|
|
834
|
+
* Ask orchestrator AI to decide what to do with each one.
|
|
835
|
+
*/
|
|
836
|
+
async function analyzeStaleUnmergedBranches(projectDir, state, logger, deps) {
|
|
837
|
+
const staleBranches = findStaleUnmergedBranches(projectDir);
|
|
838
|
+
if (staleBranches.length === 0)
|
|
839
|
+
return;
|
|
840
|
+
// Exclude branches already in pipelines
|
|
841
|
+
const pipelineBranches = new Set(Object.values(state.pipelines).map(p => p.branchSlug));
|
|
842
|
+
const unhandled = staleBranches.filter(b => !pipelineBranches.has(b.name));
|
|
843
|
+
if (unhandled.length === 0)
|
|
844
|
+
return;
|
|
845
|
+
logger.info(`[recovery] Found ${unhandled.length} stale unmerged branch(es) — consulting orchestrator`);
|
|
846
|
+
const branchSummary = unhandled.map(b => `- ${b.name}: ${b.commitsAhead} commit(s) ahead, last: "${b.lastCommitMsg}" (${b.lastCommitDate})${b.hasRemote ? ' [has remote]' : ' [local only]'}`).join('\n');
|
|
847
|
+
try {
|
|
848
|
+
const result = await deps.orchestrator.consult([
|
|
849
|
+
'The following git branches have unmerged commits but no active worktree or open MR.',
|
|
850
|
+
'For each branch, decide the action:',
|
|
851
|
+
'',
|
|
852
|
+
branchSummary,
|
|
853
|
+
'',
|
|
854
|
+
'Respond with a JSON array of objects: { "branch": "name", "action": "reinject" | "delete" | "ignore", "reason": "..." }',
|
|
855
|
+
'- "reinject": branch has valuable work that should be completed — create worktree and resume pipeline',
|
|
856
|
+
'- "delete": branch is abandoned/superseded/duplicate — safe to remove',
|
|
857
|
+
'- "ignore": leave it alone for now (e.g. user may be working on it manually)',
|
|
858
|
+
].join('\n'));
|
|
859
|
+
// Parse AI decision
|
|
860
|
+
const jsonMatch = result.output.match(/\[[\s\S]*\]/);
|
|
861
|
+
if (!jsonMatch) {
|
|
862
|
+
logger.warn('[recovery] Orchestrator did not return valid JSON for stale branches');
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const decisions = JSON.parse(jsonMatch[0]);
|
|
866
|
+
for (const d of decisions) {
|
|
867
|
+
const branchInfo = unhandled.find(b => b.name === d.branch);
|
|
868
|
+
if (!branchInfo)
|
|
869
|
+
continue;
|
|
870
|
+
if (d.action === 'delete') {
|
|
871
|
+
logger.info(`[recovery] Deleting stale branch "${d.branch}": ${d.reason}`);
|
|
872
|
+
try {
|
|
873
|
+
execFileSync('git', ['branch', '-D', d.branch], { cwd: projectDir, stdio: 'pipe', timeout: 10_000 });
|
|
874
|
+
}
|
|
875
|
+
catch { /* ignore */ }
|
|
876
|
+
deleteBranchRemote(projectDir, d.branch);
|
|
877
|
+
}
|
|
878
|
+
else if (d.action === 'reinject') {
|
|
879
|
+
logger.info(`[recovery] Re-injecting stale branch "${d.branch}": ${d.reason}`);
|
|
880
|
+
const issueNum = extractIssueFromBranch(d.branch);
|
|
881
|
+
const taskId = issueNum ? `#${issueNum}` : d.branch;
|
|
882
|
+
if (state.completedTasks.includes(taskId) || state.skippedTasks.includes(taskId)) {
|
|
883
|
+
logger.info(`[recovery] Task ${taskId} already completed/skipped — skipping re-injection`);
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
try {
|
|
887
|
+
const worktreePath = createWorktree(projectDir, d.branch);
|
|
888
|
+
const step = diagnoseStep(projectDir, d.branch, detectCIPlatform(projectDir));
|
|
889
|
+
state.pipelines[taskId] = {
|
|
890
|
+
taskId,
|
|
891
|
+
workerId: -1,
|
|
892
|
+
worktreePath,
|
|
893
|
+
branchSlug: d.branch,
|
|
894
|
+
step,
|
|
895
|
+
architectPlan: null,
|
|
896
|
+
apiContract: null,
|
|
897
|
+
reviewFindings: null,
|
|
898
|
+
testingSection: null,
|
|
899
|
+
reviewCycles: 0,
|
|
900
|
+
ciFixes: 0,
|
|
901
|
+
buildFixes: 0,
|
|
902
|
+
assignedAgent: null,
|
|
903
|
+
prState: null,
|
|
904
|
+
approvalWaitingSince: null,
|
|
905
|
+
};
|
|
906
|
+
logger.info(`[recovery] Branch "${d.branch}" re-injected at step "${step}"`);
|
|
907
|
+
}
|
|
908
|
+
catch (err) {
|
|
909
|
+
logger.warn(`[recovery] Failed to re-inject "${d.branch}": ${err.message?.split('\n')[0]}`);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
logger.info(`[recovery] Ignoring stale branch "${d.branch}": ${d.reason}`);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
catch (err) {
|
|
918
|
+
logger.warn(`[recovery] Stale branch analysis failed: ${err.message?.split('\n')[0]}`);
|
|
919
|
+
}
|
|
812
920
|
}
|
|
813
921
|
/** Diagnose which pipeline step a recovered worktree should resume from */
|
|
814
922
|
export function diagnoseStep(projectDir, branch, ciPlatform) {
|
|
@@ -860,7 +968,13 @@ export function recoverOpenMRs(projectDir, platform, state, logger) {
|
|
|
860
968
|
continue;
|
|
861
969
|
const prStatus = getPRStatus(projectDir, platform, mr.branch);
|
|
862
970
|
if (prStatus.status === 'merged') {
|
|
863
|
-
logger.info(`[recovery] MR !${mr.number} already merged — closing issue`);
|
|
971
|
+
logger.info(`[recovery] MR !${mr.number} already merged — cleaning up branch + closing issue`);
|
|
972
|
+
// Clean up local branch if it exists (no worktree, but branch may linger)
|
|
973
|
+
try {
|
|
974
|
+
execFileSync('git', ['branch', '-d', mr.branch], { cwd: projectDir, stdio: 'pipe', timeout: 10_000 });
|
|
975
|
+
}
|
|
976
|
+
catch { /* may not exist */ }
|
|
977
|
+
deleteBranchRemote(projectDir, mr.branch);
|
|
864
978
|
closeRecoveredIssue(projectDir, mr.branch, logger);
|
|
865
979
|
continue;
|
|
866
980
|
}
|
|
@@ -881,12 +995,12 @@ export function recoverOpenMRs(projectDir, platform, state, logger) {
|
|
|
881
995
|
step = 'rework';
|
|
882
996
|
logger.info(`[recovery] MR !${mr.number} (${mr.branch}) → creating worktree, pipeline at "${step}"`);
|
|
883
997
|
try {
|
|
884
|
-
const slug = mr.branch.replace(/^feat\//, '');
|
|
885
998
|
const worktreePath = createWorktree(projectDir, mr.branch);
|
|
886
999
|
state.pipelines[taskId] = {
|
|
887
1000
|
taskId,
|
|
888
1001
|
workerId: -1,
|
|
889
1002
|
worktreePath,
|
|
1003
|
+
branchSlug: mr.branch,
|
|
890
1004
|
step,
|
|
891
1005
|
architectPlan: null,
|
|
892
1006
|
apiContract: null,
|
|
@@ -897,6 +1011,7 @@ export function recoverOpenMRs(projectDir, platform, state, logger) {
|
|
|
897
1011
|
buildFixes: 0,
|
|
898
1012
|
assignedAgent: null,
|
|
899
1013
|
prState: { prNumber: mr.number, url: '', issueNumber: issueNum },
|
|
1014
|
+
approvalWaitingSince: null,
|
|
900
1015
|
};
|
|
901
1016
|
}
|
|
902
1017
|
catch (err) {
|
|
@@ -951,33 +1066,165 @@ async function checkPRWatch(taskId, pipeline, projectDir, agents, deps) {
|
|
|
951
1066
|
const ciPlatform = detectCIPlatform(projectDir);
|
|
952
1067
|
if (ciPlatform === 'none' || !pipeline.prState)
|
|
953
1068
|
return 'failed';
|
|
954
|
-
const branch =
|
|
1069
|
+
const branch = pipeline.branchSlug;
|
|
955
1070
|
try {
|
|
956
1071
|
const prStatus = getPRStatus(projectDir, ciPlatform, branch);
|
|
957
1072
|
if (prStatus.status === 'merged')
|
|
958
1073
|
return 'merged';
|
|
959
|
-
|
|
1074
|
+
// PR was closed (not merged) — try local merge as fallback before giving up
|
|
1075
|
+
if (prStatus.status === 'closed') {
|
|
1076
|
+
logger.warn(`[${taskId}] PR was closed without merging — attempting local merge fallback`);
|
|
1077
|
+
syncMain(projectDir);
|
|
1078
|
+
const stashed = cleanMainForMerge(projectDir);
|
|
1079
|
+
const localMerge = mergeToMain(projectDir, pipeline.branchSlug);
|
|
1080
|
+
if (localMerge.success) {
|
|
1081
|
+
logger.info(`[${taskId}] Local merge succeeded after closed PR (${localMerge.sha?.slice(0, 7)})`);
|
|
1082
|
+
if (stashed)
|
|
1083
|
+
popStash(projectDir);
|
|
1084
|
+
return 'merged';
|
|
1085
|
+
}
|
|
1086
|
+
if (stashed)
|
|
1087
|
+
popStash(projectDir);
|
|
1088
|
+
logger.error(`[${taskId}] Local merge also failed after closed PR: ${localMerge.error}`);
|
|
960
1089
|
return 'failed';
|
|
1090
|
+
}
|
|
961
1091
|
// Ready to merge
|
|
962
1092
|
if (prStatus.mergeable) {
|
|
963
1093
|
logger.info(`[${taskId}] PR mergeable — merging`);
|
|
964
|
-
const
|
|
965
|
-
|
|
1094
|
+
const mergeResult = mergePR(projectDir, ciPlatform, prStatus.number);
|
|
1095
|
+
if (mergeResult.success)
|
|
1096
|
+
return 'merged';
|
|
1097
|
+
// Platform merge failed — diagnose and try to recover
|
|
1098
|
+
logger.warn(`[${taskId}] Platform merge failed: ${mergeResult.error} — attempting recovery`);
|
|
1099
|
+
// Step 1: Try rebase onto main and force-push
|
|
1100
|
+
const rebaseResult = rebaseOnMain(pipeline.worktreePath, projectDir);
|
|
1101
|
+
if (rebaseResult.success) {
|
|
1102
|
+
logger.info(`[${taskId}] Rebased successfully — force-pushing`);
|
|
1103
|
+
forcePushWorktree(pipeline.worktreePath);
|
|
1104
|
+
return 'rework'; // Will re-check PR status next iteration
|
|
1105
|
+
}
|
|
1106
|
+
// Step 2: Rebase failed — delegate conflict resolution to agent
|
|
1107
|
+
logger.warn(`[${taskId}] Rebase conflict — delegating to agent for resolution`);
|
|
1108
|
+
const slot = pool.idleSlot();
|
|
1109
|
+
if (slot) {
|
|
1110
|
+
// Sync main and get conflict info
|
|
1111
|
+
syncMain(projectDir);
|
|
1112
|
+
const conflictFiles = rebaseResult.error ?? 'unknown conflict';
|
|
1113
|
+
const task = {
|
|
1114
|
+
id: taskId, title: `Resolve merge conflict for ${taskId}`, phase: state.currentPhase,
|
|
1115
|
+
type: 'fullstack', complexity: 'S', dependsOn: [], issueMarker: taskId,
|
|
1116
|
+
kitUpgrade: false, lineNumber: 0, status: 'in-progress', changelog: 'fixed',
|
|
1117
|
+
};
|
|
1118
|
+
// Fetch the main branch into the worktree so agent can see what changed
|
|
1119
|
+
try {
|
|
1120
|
+
execFileSync('git', ['fetch', 'origin', getMainBranch(projectDir)], { cwd: pipeline.worktreePath, timeout: 60_000, stdio: 'pipe' });
|
|
1121
|
+
}
|
|
1122
|
+
catch { /* best-effort */ }
|
|
1123
|
+
const fixResult = await spawnAgent(pool, slot.id, {
|
|
1124
|
+
agent: pipeline.assignedAgent ?? undefined,
|
|
1125
|
+
cwd: pipeline.worktreePath,
|
|
1126
|
+
prompt: [
|
|
1127
|
+
`The branch "${pipeline.branchSlug}" has merge conflicts with main that must be resolved.`,
|
|
1128
|
+
`Conflict info: ${conflictFiles}`,
|
|
1129
|
+
'',
|
|
1130
|
+
'Steps:',
|
|
1131
|
+
`1. Run: git rebase origin/${getMainBranch(projectDir)}`,
|
|
1132
|
+
'2. Resolve ALL conflicts in the reported files',
|
|
1133
|
+
'3. Stage resolved files with: git add <file>',
|
|
1134
|
+
'4. Continue rebase with: git rebase --continue',
|
|
1135
|
+
'5. If rebase is too complex, abort with: git rebase --abort, then merge main into the branch instead',
|
|
1136
|
+
'',
|
|
1137
|
+
'Do NOT commit separately — the rebase/merge handles commits.',
|
|
1138
|
+
].join('\n'),
|
|
1139
|
+
model: getAgentModel(pipeline.assignedAgent, agents, task),
|
|
1140
|
+
}, state, taskId, logger, onMessage, onSpawnStart, onSpawnEnd);
|
|
1141
|
+
if (fixResult.success) {
|
|
1142
|
+
forcePushWorktree(pipeline.worktreePath);
|
|
1143
|
+
logger.info(`[${taskId}] Agent resolved conflicts — force-pushed, will re-check next iteration`);
|
|
1144
|
+
return 'rework';
|
|
1145
|
+
}
|
|
1146
|
+
logger.warn(`[${taskId}] Agent failed to resolve conflicts`);
|
|
1147
|
+
}
|
|
1148
|
+
else {
|
|
1149
|
+
logger.info(`[${taskId}] No idle worker for conflict resolution — will retry next iteration`);
|
|
1150
|
+
return 'waiting';
|
|
1151
|
+
}
|
|
1152
|
+
// Step 3: Agent failed — fall back to local merge
|
|
1153
|
+
logger.warn(`[${taskId}] Falling back to local merge`);
|
|
1154
|
+
syncMain(projectDir);
|
|
1155
|
+
const stashed = cleanMainForMerge(projectDir);
|
|
1156
|
+
const localMerge = mergeToMain(projectDir, pipeline.branchSlug);
|
|
1157
|
+
if (localMerge.success) {
|
|
1158
|
+
logger.info(`[${taskId}] Local merge succeeded (${localMerge.sha?.slice(0, 7)}) — closing PR`);
|
|
1159
|
+
if (stashed)
|
|
1160
|
+
popStash(projectDir);
|
|
1161
|
+
closePR(projectDir, ciPlatform, prStatus.number);
|
|
1162
|
+
return 'merged';
|
|
1163
|
+
}
|
|
1164
|
+
if (stashed)
|
|
1165
|
+
popStash(projectDir);
|
|
1166
|
+
logger.error(`[${taskId}] All merge strategies exhausted: ${localMerge.error}`);
|
|
1167
|
+
return 'failed';
|
|
966
1168
|
}
|
|
967
1169
|
// CI pending — keep waiting
|
|
968
1170
|
if (prStatus.ciStatus === 'pending' || prStatus.ciStatus === 'not-found') {
|
|
969
1171
|
logger.info(`[${taskId}] CI: ${prStatus.ciStatus} — waiting`);
|
|
970
1172
|
return 'waiting';
|
|
971
1173
|
}
|
|
972
|
-
// CI
|
|
1174
|
+
// CI canceled — force-push to re-trigger pipeline
|
|
1175
|
+
if (prStatus.ciStatus === 'canceled') {
|
|
1176
|
+
logger.warn(`[${taskId}] CI was canceled — force-pushing to re-trigger pipeline`);
|
|
1177
|
+
forcePushWorktree(pipeline.worktreePath);
|
|
1178
|
+
return 'rework';
|
|
1179
|
+
}
|
|
1180
|
+
// CI passed but not mergeable — waiting for approval (with timeout)
|
|
973
1181
|
if (prStatus.ciStatus === 'passed' && !prStatus.mergeable) {
|
|
974
|
-
|
|
975
|
-
|
|
1182
|
+
if (!pipeline.approvalWaitingSince) {
|
|
1183
|
+
pipeline.approvalWaitingSince = Date.now();
|
|
1184
|
+
}
|
|
1185
|
+
const waitingMs = Date.now() - pipeline.approvalWaitingSince;
|
|
1186
|
+
const APPROVAL_TIMEOUT = 30 * 60_000; // 30 minutes
|
|
1187
|
+
if (waitingMs < APPROVAL_TIMEOUT) {
|
|
1188
|
+
const waitMin = Math.round(waitingMs / 60_000);
|
|
1189
|
+
logger.info(`[${taskId}] CI passed, waiting for approval (${waitMin}min/${APPROVAL_TIMEOUT / 60_000}min)`);
|
|
1190
|
+
return 'waiting';
|
|
1191
|
+
}
|
|
1192
|
+
// Timeout reached — fall back to local merge (solo mode behavior)
|
|
1193
|
+
logger.warn(`[${taskId}] Approval timeout (${APPROVAL_TIMEOUT / 60_000}min) — falling back to local merge`);
|
|
1194
|
+
syncMain(projectDir);
|
|
1195
|
+
const stashed = cleanMainForMerge(projectDir);
|
|
1196
|
+
const localMerge = mergeToMain(projectDir, pipeline.branchSlug);
|
|
1197
|
+
if (localMerge.success) {
|
|
1198
|
+
logger.info(`[${taskId}] Local merge succeeded (${localMerge.sha?.slice(0, 7)}) — closing PR`);
|
|
1199
|
+
if (stashed)
|
|
1200
|
+
popStash(projectDir);
|
|
1201
|
+
closePR(projectDir, ciPlatform, prStatus.number);
|
|
1202
|
+
return 'merged';
|
|
1203
|
+
}
|
|
1204
|
+
if (stashed)
|
|
1205
|
+
popStash(projectDir);
|
|
1206
|
+
logger.error(`[${taskId}] Local merge failed after approval timeout: ${localMerge.error}`);
|
|
1207
|
+
return 'failed';
|
|
976
1208
|
}
|
|
1209
|
+
// Reset approval timer if CI status changed from passed
|
|
1210
|
+
pipeline.approvalWaitingSince = null;
|
|
977
1211
|
// CI failed — delegate fix to implementing agent
|
|
978
1212
|
if (prStatus.ciStatus === 'failed') {
|
|
979
1213
|
if (pipeline.ciFixes >= MAX_CI_FIXES) {
|
|
980
|
-
logger.
|
|
1214
|
+
logger.warn(`[${taskId}] CI fix limit (${MAX_CI_FIXES}) reached — falling back to local merge`);
|
|
1215
|
+
syncMain(projectDir);
|
|
1216
|
+
const stashed = cleanMainForMerge(projectDir);
|
|
1217
|
+
const localMerge = mergeToMain(projectDir, pipeline.branchSlug);
|
|
1218
|
+
if (localMerge.success) {
|
|
1219
|
+
logger.info(`[${taskId}] Local merge succeeded after CI fix limit (${localMerge.sha?.slice(0, 7)}) — closing PR`);
|
|
1220
|
+
if (stashed)
|
|
1221
|
+
popStash(projectDir);
|
|
1222
|
+
closePR(projectDir, ciPlatform, prStatus.number);
|
|
1223
|
+
return 'merged';
|
|
1224
|
+
}
|
|
1225
|
+
if (stashed)
|
|
1226
|
+
popStash(projectDir);
|
|
1227
|
+
logger.error(`[${taskId}] Local merge also failed: ${localMerge.error}`);
|
|
981
1228
|
return 'failed';
|
|
982
1229
|
}
|
|
983
1230
|
pipeline.ciFixes++;
|
|
@@ -1063,7 +1310,7 @@ async function pollAndMergePR(task, pipeline, branch, platform, projectDir, work
|
|
|
1063
1310
|
// Ready to merge — CI passed and mergeable
|
|
1064
1311
|
if (prStatus.mergeable) {
|
|
1065
1312
|
logger.info(`[${task.id}] PR mergeable — merging`);
|
|
1066
|
-
return mergePR(projectDir, platform, prStatus.number);
|
|
1313
|
+
return mergePR(projectDir, platform, prStatus.number).success;
|
|
1067
1314
|
}
|
|
1068
1315
|
// CI still running or not started — poll
|
|
1069
1316
|
if (prStatus.ciStatus === 'pending' || prStatus.ciStatus === 'not-found') {
|
|
@@ -1180,9 +1427,6 @@ export function extractIssueFromBranch(branch) {
|
|
|
1180
1427
|
const match = branch.match(/#?(\d+)/);
|
|
1181
1428
|
return match ? parseInt(match[1], 10) : null;
|
|
1182
1429
|
}
|
|
1183
|
-
function getCurrentBranchFromProject(projectDir) {
|
|
1184
|
-
return getMainBranch(projectDir);
|
|
1185
|
-
}
|
|
1186
1430
|
function sleep(ms) {
|
|
1187
1431
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
1188
1432
|
}
|
|
@@ -290,8 +290,7 @@ export class TUI {
|
|
|
290
290
|
return '';
|
|
291
291
|
const cur = this.state.agents[this.state.agents.length - 1];
|
|
292
292
|
const col = ANSI_COLORS[agentColor(cur)] || '';
|
|
293
|
-
|
|
294
|
-
return `${col}${name.padEnd(25)}${RESET}`;
|
|
293
|
+
return `${col}${cur}${RESET} `;
|
|
295
294
|
}
|
|
296
295
|
// ─── Public API ───
|
|
297
296
|
banner() {
|
|
@@ -304,10 +303,10 @@ export class TUI {
|
|
|
304
303
|
}
|
|
305
304
|
this.log('');
|
|
306
305
|
}
|
|
307
|
-
info(msg) { this.log(` ${
|
|
308
|
-
warn(msg) { this.log(` ${
|
|
309
|
-
error(msg) { this.log(` ${
|
|
310
|
-
success(msg) { this.log(` ${
|
|
306
|
+
info(msg) { this.log(` ${ANSI_COLORS.gray}${ts()}${RESET} ${this.prefix()}${ANSI_COLORS.blue}ℹ${RESET} ${msg}`); }
|
|
307
|
+
warn(msg) { this.log(` ${ANSI_COLORS.gray}${ts()}${RESET} ${this.prefix()}${ANSI_COLORS.yellow}⚠ ${msg}${RESET}`); }
|
|
308
|
+
error(msg) { this.log(` ${ANSI_COLORS.gray}${ts()}${RESET} ${this.prefix()}${ANSI_COLORS.red}✗ ${msg}${RESET}`); }
|
|
309
|
+
success(msg) { this.log(` ${ANSI_COLORS.gray}${ts()}${RESET} ${this.prefix()}${ANSI_COLORS.green}✓ ${msg}${RESET}`); }
|
|
311
310
|
setIteration(i, max) {
|
|
312
311
|
this.state.iteration = i;
|
|
313
312
|
this.state.maxIter = max;
|
|
@@ -363,7 +362,7 @@ export class TUI {
|
|
|
363
362
|
}
|
|
364
363
|
for (const block of content) {
|
|
365
364
|
if (block.type === 'text' && block.text?.trim()) {
|
|
366
|
-
this.log(` ${this.prefix()}${block.text}`, `TEXT: ${block.text}`);
|
|
365
|
+
this.log(` ${ANSI_COLORS.gray}${ts()}${RESET} ${this.prefix()}${block.text}`, `TEXT: ${block.text}`);
|
|
367
366
|
}
|
|
368
367
|
if (block.type === 'tool_use')
|
|
369
368
|
this.onToolUse(block);
|
|
@@ -383,7 +382,7 @@ export class TUI {
|
|
|
383
382
|
const col = ANSI_COLORS[agentColor(type)] || '';
|
|
384
383
|
// Don't push to agent stack — this is a sub-agent inside the SDK session.
|
|
385
384
|
// Our external spawn lifecycle is managed by onSpawnStart/onSpawnEnd callbacks.
|
|
386
|
-
this.log(` ${
|
|
385
|
+
this.log(` ${time} ${pre}${icon} ${col}${BOLD}${type}${RESET}${model ? ` ${ANSI_COLORS.gray}(${model})${RESET}` : ''} ${ANSI_COLORS.gray}${desc}${RESET}`, `AGENT: ${type} ${model} — ${desc}`);
|
|
387
386
|
return;
|
|
388
387
|
}
|
|
389
388
|
let detail;
|
|
@@ -406,7 +405,7 @@ export class TUI {
|
|
|
406
405
|
break;
|
|
407
406
|
default: detail = `${ANSI_COLORS.gray}${JSON.stringify(input)}${RESET}`;
|
|
408
407
|
}
|
|
409
|
-
this.log(` ${
|
|
408
|
+
this.log(` ${time} ${pre}${icon} ${BOLD}${name}${RESET} ${detail}`, `TOOL: ${name} ${JSON.stringify(input)}`);
|
|
410
409
|
}
|
|
411
410
|
onToolResult(msg) {
|
|
412
411
|
const content = msg.message?.content;
|
|
@@ -419,15 +418,16 @@ export class TUI {
|
|
|
419
418
|
if (!output)
|
|
420
419
|
continue;
|
|
421
420
|
const pre = this.prefix();
|
|
421
|
+
const time = `${ANSI_COLORS.gray}${ts()}${RESET}`;
|
|
422
422
|
if (block.is_error) {
|
|
423
|
-
this.log(` ${pre}
|
|
423
|
+
this.log(` ${time} ${pre}${ANSI_COLORS.red}✗ ${output}${RESET}`, `ERROR: ${output}`);
|
|
424
424
|
}
|
|
425
425
|
else {
|
|
426
426
|
const lines = output.split('\n');
|
|
427
427
|
const summary = lines.length > 3
|
|
428
428
|
? `${lines.length} lines`
|
|
429
429
|
: output.replace(/\n/g, ' ').trim();
|
|
430
|
-
this.log(` ${pre}
|
|
430
|
+
this.log(` ${time} ${pre}${ANSI_COLORS.green}✓${RESET} ${DIM}${summary}${RESET}`, `OK: ${summary}`);
|
|
431
431
|
}
|
|
432
432
|
}
|
|
433
433
|
}
|
|
@@ -456,7 +456,7 @@ export class TUI {
|
|
|
456
456
|
return;
|
|
457
457
|
}
|
|
458
458
|
if (msg.subtype === 'task_progress' && msg.description) {
|
|
459
|
-
this.log(` ${this.prefix()}
|
|
459
|
+
this.log(` ${ANSI_COLORS.gray}${ts()}${RESET} ${this.prefix()}${DIM}${msg.description}${RESET}`, `PROGRESS: ${msg.description}`);
|
|
460
460
|
return;
|
|
461
461
|
}
|
|
462
462
|
if (msg.subtype === 'api_retry') {
|
|
@@ -870,6 +870,71 @@ If a merge conflict occurs:
|
|
|
870
870
|
4. Push: `git push origin HEAD`
|
|
871
871
|
5. Then clean up: `git worktree remove .worktrees/feat/{slug} && git branch -d feat/{slug}`
|
|
872
872
|
|
|
873
|
+
## DISCOVERED ISSUES Protocol
|
|
874
|
+
|
|
875
|
+
Agents encounter problems outside their current scope during work. This protocol gives them a structured way to report issues without breaking their flow, and ensures the orchestrator processes every report.
|
|
876
|
+
|
|
877
|
+
### Agent-side: How agents report issues
|
|
878
|
+
|
|
879
|
+
Every agent (architect, implementer, reviewer, test-engineer) MAY include a `## DISCOVERED ISSUES` section at the END of their response. Format:
|
|
880
|
+
|
|
881
|
+
```
|
|
882
|
+
## DISCOVERED ISSUES
|
|
883
|
+
|
|
884
|
+
### [NON-BLOCKER] Title describing the problem
|
|
885
|
+
- **Scope**: which agent should handle this (e.g., `deployment-engineer`, `backend-ts-architect`)
|
|
886
|
+
- **Type**: frontend | backend | fullstack | devops
|
|
887
|
+
- **Complexity**: S | M
|
|
888
|
+
- **Description**: What is broken, where, and what the fix should look like
|
|
889
|
+
- **Evidence**: error message, file:line, warning output
|
|
890
|
+
|
|
891
|
+
### [BLOCKER] Title describing the blocking problem
|
|
892
|
+
- **Scope**: which agent should handle this
|
|
893
|
+
- **Type**: frontend | backend | fullstack | devops
|
|
894
|
+
- **Complexity**: S | M
|
|
895
|
+
- **Description**: What is broken and why it blocks the current task
|
|
896
|
+
- **Evidence**: error message, file:line, warning output
|
|
897
|
+
- **Impact**: What cannot proceed until this is resolved
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
**Agent rules:**
|
|
901
|
+
- **In-scope problem** → fix it directly, do NOT add to DISCOVERED ISSUES (it's your job)
|
|
902
|
+
- **Out-of-scope, non-blocking** → add as `[NON-BLOCKER]`, then **continue and deliver your task**
|
|
903
|
+
- **Out-of-scope, blocking** → add as `[BLOCKER]`, finish what you can, then return. The orchestrator will handle delegation
|
|
904
|
+
- **Warnings count** — treat project warnings as issues unless they originate from inside a third-party package's own source code. Misconfiguration or misuse of a third-party tool is a project problem.
|
|
905
|
+
- **"Third-party issue" is narrow** — only valid when the bug is in the third-party package's source code itself
|
|
906
|
+
- **Never dismiss** — if you see a problem, either fix it or report it. "Pre-existing" and "not in scope" are not valid reasons to ignore a problem.
|
|
907
|
+
|
|
908
|
+
### Orchestrator-side: Processing DISCOVERED ISSUES
|
|
909
|
+
|
|
910
|
+
**After EVERY agent delegation** (STEP 2, 3, 4, 5, 6, 7, 8), scan the agent's response for a `## DISCOVERED ISSUES` section. If present:
|
|
911
|
+
|
|
912
|
+
#### NON-BLOCKER items → create tasks, continue current workflow
|
|
913
|
+
1. **Local mode**: Add each item to TODO.md in the CURRENT phase (after the current task). Format:
|
|
914
|
+
```
|
|
915
|
+
- [ ] **[Title from DISCOVERED ISSUES]** — [Description]. Discovered during #[current-task-id] (Complexity: [S|M], Type: [type])
|
|
916
|
+
```
|
|
917
|
+
2. **Platform mode**: Delegate to `devops-integrator`: "Create issues for these discovered problems: [list items]. Label: `status::todo`, `discovered`. Milestone: current phase. Do NOT perform git operations."
|
|
918
|
+
3. **Continue with the current task** — non-blockers do NOT interrupt the workflow.
|
|
919
|
+
|
|
920
|
+
#### BLOCKER items → resolve before continuing
|
|
921
|
+
1. **Assess if truly blocking**: Can the current task still be completed without resolving this? If the agent completed their deliverable despite reporting `[BLOCKER]`, reclassify as `[NON-BLOCKER]` and process accordingly.
|
|
922
|
+
2. **If blocking and in another agent's scope**: Delegate to the appropriate agent IMMEDIATELY (before continuing the current step). Include the evidence and description from the DISCOVERED ISSUES entry. This is an interruption to the current workflow — resume the current step after the blocker is resolved.
|
|
923
|
+
3. **If blocking and unfixable now** (requires human action, external dependency, missing credentials): Mark the current task as `[~]` SKIPPED with reason referencing the blocker. Create the task for the blocker issue. Move to next task.
|
|
924
|
+
4. **Max 1 blocker delegation per step** — if resolving a blocker reveals another blocker, skip the current task and create tasks for both blockers.
|
|
925
|
+
|
|
926
|
+
#### Deduplication
|
|
927
|
+
Before creating a task from a discovered issue:
|
|
928
|
+
- Check TODO.md / platform issues for existing tasks with similar title or description
|
|
929
|
+
- Check if the issue was already reported in a previous iteration (grep TODO.md for keywords)
|
|
930
|
+
- Skip duplicates — do NOT create redundant tasks
|
|
931
|
+
|
|
932
|
+
### Including the protocol in delegation prompts
|
|
933
|
+
|
|
934
|
+
**Add this paragraph to ALL agent delegation prompts** (STEP 2, 3, 4, 6 — all Agent tool calls to specialist agents):
|
|
935
|
+
|
|
936
|
+
> "If you encounter any problems, warnings, or broken infrastructure outside the scope of this task, include a `## DISCOVERED ISSUES` section at the end of your response. Use `[NON-BLOCKER]` for issues that don't prevent you from completing this task, `[BLOCKER]` for issues that do. Format: title, scope (which agent), type (frontend/backend/fullstack/devops), complexity (S/M), description, evidence. Do not dismiss issues as pre-existing — report everything you find."
|
|
937
|
+
|
|
873
938
|
## When Stuck
|
|
874
939
|
|
|
875
940
|
NEVER stay stuck. Escalation order:
|