@smartmemory/compose 0.2.9-beta → 0.2.10-beta

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,39 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "par-merge-bounce.json",
4
+ "_source": "docs/features/COMP-PAR-MERGE-QUEUE/design.md",
5
+ "_roadmap": "COMP-PAR-MERGE-QUEUE",
6
+ "title": "ParMergeBounce",
7
+ "description": "A single bounce record produced by parallel_dispatch's post-dispatch merge gate. Unifies both failure kinds (a failed pre-merge verify gate, or a merge conflict during diff apply) into one structured contract. It is surfaced on the ensure_failed.bounced_tasks[] envelope AND persisted onto the failed task's state so that when the step is re-dispatched, Stratum's ParallelExecutor._render_prompt injects the failure context into the re-run task's prompt (server-side, because the server re-resolves the task list on each re-dispatch). Convention: _source = design doc origin, _roadmap = feature code.",
8
+ "type": "object",
9
+ "required": ["task_id", "reason", "files", "excerpt"],
10
+ "properties": {
11
+ "task_id": {
12
+ "type": "string",
13
+ "description": "Which task to bounce / re-dispatch."
14
+ },
15
+ "reason": {
16
+ "type": "string",
17
+ "enum": ["gate_failed", "merge_conflict"],
18
+ "description": "Why the task bounced. 'gate_failed' = a pre_merge_verify command exited non-zero in the task worktree; 'merge_conflict' = git apply --check/apply failed when merging the task diff onto base."
19
+ },
20
+ "files": {
21
+ "type": "array",
22
+ "items": { "type": "string" },
23
+ "description": "Files involved. gate_failed: from `git diff --name-only HEAD` in the worktree. merge_conflict: parsed from the git-apply error, falling back to the task's owned files."
24
+ },
25
+ "command": {
26
+ "type": ["string", "null"],
27
+ "description": "The gate command that failed, e.g. 'pnpm build'. Populated for gate_failed only; null for merge_conflict."
28
+ },
29
+ "exit_code": {
30
+ "type": ["integer", "null"],
31
+ "description": "Exit code of the failed gate command. Populated for gate_failed only; null for merge_conflict."
32
+ },
33
+ "excerpt": {
34
+ "type": "string",
35
+ "description": "Bounded (~2KB) tail of the gate stdout+stderr (gate_failed) OR the git-apply error text (merge_conflict). Bounded to avoid unbounded prompt growth and to limit credential leakage; do not echo environment."
36
+ }
37
+ },
38
+ "additionalProperties": true
39
+ }
package/lib/build.js CHANGED
@@ -3027,6 +3027,13 @@ export async function executeParallelDispatchServer(
3027
3027
  const captureTiming = context?.gsd === true && !!context?.featureCode;
3028
3028
  const taskTiming = {};
3029
3029
 
3030
+ // COMP-PAR-MERGE-QUEUE: hold the merge+advance outcome (and its summary) so the
3031
+ // re-dispatch check can run AFTER the finally (unsubscribe) below, and so the
3032
+ // terminal `build_step_done` event fires only for the FINAL attempt — not for an
3033
+ // attempt that is immediately re-dispatched. Terminal stuck/budget returns inside
3034
+ // the try short-circuit and never set these.
3035
+ let _redispatchOutcome = null;
3036
+ let _finalSummary = null;
3030
3037
  try {
3031
3038
  // Poll until outcome is present (NOT can_advance — see design §3)
3032
3039
  let pollResult;
@@ -3160,7 +3167,7 @@ export async function executeParallelDispatchServer(
3160
3167
  if (hasServerMerge) {
3161
3168
  if (pollResult.outcome?.status === 'awaiting_consumer_advance') {
3162
3169
  // DEFER PATH: merge locally, report merge_status, let flow advance with truth.
3163
- const { mergeStatus, conflictedTaskId, conflictError } = applyServerDispatchDiffsCore(
3170
+ const { mergeStatus, conflictedTaskId, conflictError, conflictFiles } = applyServerDispatchDiffsCore(
3164
3171
  dispatchResponse.tasks ?? [],
3165
3172
  pollResult.tasks,
3166
3173
  baseCwd,
@@ -3178,7 +3185,14 @@ export async function executeParallelDispatchServer(
3178
3185
  });
3179
3186
  }
3180
3187
 
3181
- const advanceResult = await stratum.parallelAdvance(flowId, stepId, mergeStatus);
3188
+ // COMP-PAR-MERGE-QUEUE: on conflict, send a structured payload carrying a
3189
+ // merge_conflict bounce record so Stratum surfaces it on ensure_failed and
3190
+ // Compose's retry prompt can name the task/files (back-compat: 'clean'
3191
+ // stays a bare string).
3192
+ const advancePayload = mergeStatus === 'conflict'
3193
+ ? { status: 'conflict', bounced_tasks: [buildMergeConflictBounce(conflictedTaskId, conflictError, conflictFiles)] }
3194
+ : 'clean';
3195
+ const advanceResult = await stratum.parallelAdvance(flowId, stepId, advancePayload);
3182
3196
  if (advanceResult?.error) {
3183
3197
  throw new Error(
3184
3198
  `stratum_parallel_advance failed: ${advanceResult.error}: ${advanceResult.message || ''}`,
@@ -3210,18 +3224,57 @@ export async function executeParallelDispatchServer(
3210
3224
  }
3211
3225
  }
3212
3226
 
3213
- if (streamWriter) {
3214
- streamWriter.write({
3215
- type: 'build_step_done', stepId,
3216
- parallel: true,
3217
- summary: pollResult.summary, flowId,
3218
- });
3219
- }
3220
-
3221
- return pollResult.outcome;
3227
+ _redispatchOutcome = pollResult.outcome;
3228
+ _finalSummary = pollResult.summary;
3229
+ // Fall through to the finally (unsubscribe), then the re-dispatch check below.
3230
+ // build_step_done is emitted AFTER that check so a re-dispatched attempt does
3231
+ // not surface a terminal step event.
3222
3232
  } finally {
3223
3233
  if (unsubscribePush) { try { unsubscribePush(); } catch { /* ignore */ } }
3224
3234
  }
3235
+
3236
+ // COMP-PAR-MERGE-QUEUE: a parallel step that failed its pre-merge gate / require
3237
+ // policy / merge returns `ensure_failed`/`schema_failed` carrying the parallel
3238
+ // surface (tasks). Re-dispatch it as a PARALLEL step rather than leaking the
3239
+ // failure to the outer single-agent retry path (which can't re-run parallel
3240
+ // tasks). Stratum reverted current_idx and persisted the bounce onto each
3241
+ // failed task, so the re-run task prompts carry the failure context. The
3242
+ // server's retry cap bounds this — it returns retries_exhausted/error (a
3243
+ // terminal, non-parallel envelope) when exhausted, ending the recursion.
3244
+ const out = _redispatchOutcome;
3245
+ // Defensive cap so a misbehaving server/stub that keeps returning ensure_failed
3246
+ // can't recurse without bound (Stratum's own retry cap should end it first).
3247
+ const redispatchDepth = (opts._redispatchDepth ?? 0);
3248
+ if (
3249
+ out
3250
+ && (out.status === 'ensure_failed' || out.status === 'schema_failed')
3251
+ && Array.isArray(out.tasks)
3252
+ && out.step_id === stepId
3253
+ && redispatchDepth < 10
3254
+ ) {
3255
+ // The ensure_failed envelope is built from Stratum's parallel *surface*, which
3256
+ // omits the merge-config fields (capture_diff/isolation) that gate the
3257
+ // worktree-merge path (hasServerMerge). Carry them over from THIS dispatch (the
3258
+ // same step) so the re-run still merges its diffs instead of silently skipping.
3259
+ const redispatch = {
3260
+ ...out,
3261
+ isolation: dispatchResponse.isolation ?? out.isolation,
3262
+ capture_diff: dispatchResponse.capture_diff ?? out.capture_diff,
3263
+ };
3264
+ return await executeParallelDispatchServer(
3265
+ redispatch, stratum, context, progress, streamWriter, baseCwd,
3266
+ { ...opts, _redispatchDepth: redispatchDepth + 1 },
3267
+ );
3268
+ }
3269
+ // Terminal attempt — now surface the step-done lifecycle event.
3270
+ if (streamWriter) {
3271
+ streamWriter.write({
3272
+ type: 'build_step_done', stepId,
3273
+ parallel: true,
3274
+ summary: _finalSummary, flowId,
3275
+ });
3276
+ }
3277
+ return out;
3225
3278
  }
3226
3279
 
3227
3280
  /**
@@ -3237,9 +3290,42 @@ export async function executeParallelDispatchServer(
3237
3290
  * @param {string} patchDir — directory to write temporary .patch files
3238
3291
  * @returns {{mergeStatus:'clean'|'conflict', appliedFiles:string[], conflictedTaskId:string|null, conflictError:string|null}}
3239
3292
  */
3293
+ /**
3294
+ * COMP-PAR-MERGE-QUEUE: best-effort extraction of the conflicting files from a
3295
+ * `git apply` error message, falling back to the task's declared owned files.
3296
+ * Used to populate a merge_conflict bounce record's `files`.
3297
+ */
3298
+ export function extractConflictFiles(conflictError, ownedFiles = []) {
3299
+ const files = new Set();
3300
+ if (conflictError) {
3301
+ // "error: patch failed: path/to/file:LINE"
3302
+ for (const m of conflictError.matchAll(/patch failed:\s+(.+?):\d+/g)) files.add(m[1]);
3303
+ // "error: path/to/file: patch does not apply"
3304
+ for (const m of conflictError.matchAll(/error:\s+(.+?):\s+patch does not apply/g)) files.add(m[1]);
3305
+ }
3306
+ if (files.size === 0) for (const f of (ownedFiles ?? [])) files.add(f);
3307
+ return [...files];
3308
+ }
3309
+
3310
+ /**
3311
+ * COMP-PAR-MERGE-QUEUE: build a structured merge_conflict bounce record (the
3312
+ * Compose-side counterpart of Stratum's gate_failed bounce). Shape matches
3313
+ * contracts/par-merge-bounce.json.
3314
+ */
3315
+ export function buildMergeConflictBounce(taskId, error, files) {
3316
+ return {
3317
+ task_id: taskId,
3318
+ reason: 'merge_conflict',
3319
+ files: files ?? [],
3320
+ command: null,
3321
+ exit_code: null,
3322
+ excerpt: (error ?? '').slice(-2048),
3323
+ };
3324
+ }
3325
+
3240
3326
  function applyTaskDiffsToBaseCwd(tasks, diffMap, baseCwd, streamWriter, stepId, patchDir) {
3241
3327
  if (diffMap.size === 0) {
3242
- return { mergeStatus: 'clean', appliedFiles: [], conflictedTaskId: null, conflictError: null };
3328
+ return { mergeStatus: 'clean', appliedFiles: [], conflictedTaskId: null, conflictError: null, conflictFiles: [] };
3243
3329
  }
3244
3330
 
3245
3331
  // Topological sort on depends_on edges (DFS)
@@ -3272,6 +3358,7 @@ function applyTaskDiffsToBaseCwd(tasks, diffMap, baseCwd, streamWriter, stepId,
3272
3358
  let mergeStatus = 'clean';
3273
3359
  let conflictedTaskId = null;
3274
3360
  let conflictError = null;
3361
+ let conflictFiles = [];
3275
3362
  const appliedFiles = new Set();
3276
3363
 
3277
3364
  for (const taskId of topoOrder) {
@@ -3300,6 +3387,7 @@ function applyTaskDiffsToBaseCwd(tasks, diffMap, baseCwd, streamWriter, stepId,
3300
3387
  mergeStatus = 'conflict';
3301
3388
  conflictedTaskId = taskId;
3302
3389
  conflictError = err.message;
3390
+ conflictFiles = extractConflictFiles(err.message, taskMap.get(taskId)?.files_owned ?? []);
3303
3391
  if (streamWriter) {
3304
3392
  streamWriter.write({
3305
3393
  type: 'build_error',
@@ -3336,6 +3424,7 @@ function applyTaskDiffsToBaseCwd(tasks, diffMap, baseCwd, streamWriter, stepId,
3336
3424
  appliedFiles: [...appliedFiles],
3337
3425
  conflictedTaskId,
3338
3426
  conflictError,
3427
+ conflictFiles,
3339
3428
  };
3340
3429
  }
3341
3430
 
@@ -3378,12 +3467,12 @@ function applyServerDispatchDiffsCore(taskList, pollTasks, baseCwd, streamWriter
3378
3467
  }
3379
3468
 
3380
3469
  if (diffMap.size === 0) {
3381
- return { mergeStatus: 'clean', conflictedTaskId: null, conflictError: null, appliedFiles: [] };
3470
+ return { mergeStatus: 'clean', conflictedTaskId: null, conflictError: null, appliedFiles: [], conflictFiles: [] };
3382
3471
  }
3383
3472
 
3384
3473
  const patchDir = mkdtempSync(join(tmpdir(), 'compose-server-patch-'));
3385
3474
  try {
3386
- const { mergeStatus, conflictedTaskId, conflictError, appliedFiles } =
3475
+ const { mergeStatus, conflictedTaskId, conflictError, appliedFiles, conflictFiles } =
3387
3476
  applyTaskDiffsToBaseCwd(taskList, diffMap, baseCwd, streamWriter, stepId, patchDir);
3388
3477
 
3389
3478
  if (mergeStatus !== 'conflict' && appliedFiles.length > 0 && context) {
@@ -3392,7 +3481,7 @@ function applyServerDispatchDiffsCore(taskList, pollTasks, baseCwd, streamWriter
3392
3481
  context.filesChanged = [...set];
3393
3482
  }
3394
3483
 
3395
- return { mergeStatus, conflictedTaskId, conflictError, appliedFiles };
3484
+ return { mergeStatus, conflictedTaskId, conflictError, appliedFiles, conflictFiles };
3396
3485
  } finally {
3397
3486
  try { rmSync(patchDir, { recursive: true, force: true }); } catch { /* best-effort */ }
3398
3487
  }
package/lib/gsd.js CHANGED
@@ -38,6 +38,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
38
38
  const PACKAGE_ROOT = resolve(__dirname, '..');
39
39
 
40
40
  const DEFAULT_GATE_COMMANDS = ['pnpm lint', 'pnpm build', 'pnpm test'];
41
+ // COMP-PAR-MERGE-QUEUE: the fast per-task pre-merge gate (lint + build, no full
42
+ // test suite). Enforced in each task's worktree before its diff merges; the full
43
+ // `pnpm test` runs once at ship_gsd. Single-sourced into both the enforced gate
44
+ // (execute.pre_merge_verify) and the instructed gate (task descriptions).
45
+ const DEFAULT_FAST_GATE = ['pnpm lint', 'pnpm build'];
41
46
 
42
47
  // ---------- Public API ----------
43
48
 
@@ -122,6 +127,8 @@ export async function runGsd(featureCode, opts = {}) {
122
127
  // 4. Resolve gateCommands. loadProjectConfig() does not merge defaults, so
123
128
  // explicit fallback here.
124
129
  const gateCommands = resolveGateCommands(cwd, opts.gateCommands);
130
+ // COMP-PAR-MERGE-QUEUE: the fast per-task pre-merge gate (lint+build).
131
+ const preMergeGate = resolvePreMergeGate(cwd, opts.preMergeGate);
125
132
 
126
133
  // 4. Load pipeline spec
127
134
  const specPath = join(PACKAGE_ROOT, 'pipelines', 'gsd.stratum.yaml');
@@ -208,7 +215,7 @@ export async function runGsd(featureCode, opts = {}) {
208
215
  // can stage them. executeShipStep's default filter only stages feature
209
216
  // docs unless context.filesChanged is provided.
210
217
  stepCtx = {
211
- stratum, cwd, featureCode, blueprintText, gateCommands,
218
+ stratum, cwd, featureCode, blueprintText, gateCommands, preMergeGate,
212
219
  filesChanged: [],
213
220
  stuckDetector,
214
221
  resumeTaskGraph,
@@ -260,6 +267,7 @@ export async function runGsd(featureCode, opts = {}) {
260
267
  let response = await stratum.plan(specYaml, 'gsd', {
261
268
  featureCode,
262
269
  gateCommands,
270
+ pre_merge_gate: preMergeGate,
263
271
  });
264
272
  const flowId = response.flow_id;
265
273
  flushState(stepCtx, { flowId, phase: 'decompose' });
@@ -273,11 +281,30 @@ export async function runGsd(featureCode, opts = {}) {
273
281
  response.status !== 'complete' &&
274
282
  response.status !== 'killed' &&
275
283
  response.status !== 'stuck' &&
276
- response.status !== 'budget_exhausted'
284
+ response.status !== 'budget_exhausted' &&
285
+ response.status !== 'error' // COMP-PAR-MERGE-QUEUE: terminal step failure (e.g. retries_exhausted)
277
286
  ) {
278
287
  response = await runOneStep(response, stepCtx);
279
288
  }
280
289
 
290
+ // COMP-PAR-MERGE-QUEUE: a step that exhausted its retries (e.g. the execute
291
+ // step after repeated pre-merge gate failures) surfaces as a terminal `error`
292
+ // envelope rather than silently advancing to ship. Stop here with the failure
293
+ // and its bounce context instead of throwing `unknown response status`.
294
+ if (response.status === 'error') {
295
+ emitCompletionDeltas(stepCtx);
296
+ flushState(stepCtx, { status: 'failed' });
297
+ return {
298
+ status: 'failed',
299
+ flowId,
300
+ stepId: response.step_id ?? stepCtx.lastStepId ?? null,
301
+ errorType: response.error_type ?? 'step_failed',
302
+ message: response.message ?? 'GSD step failed',
303
+ violations: response.violations ?? [],
304
+ bouncedTasks: response.bounced_tasks ?? [],
305
+ };
306
+ }
307
+
281
308
  if (response.status === 'stuck') {
282
309
  // Artifacts (stuck.md/json + pause.json) were written by runOneStep.
283
310
  // COMP-GSD-7-EVENTLOG: flush any completions that finished before the stuck
@@ -422,8 +449,32 @@ export function resolveGateCommands(cwd, override) {
422
449
  return [...DEFAULT_GATE_COMMANDS];
423
450
  }
424
451
 
452
+ // COMP-PAR-MERGE-QUEUE: resolve the fast per-task pre-merge gate. Mirrors
453
+ // resolveGateCommands but defaults to lint+build (no full test suite). Honors
454
+ // `.compose/compose.json#preMergeGate`, else falls back to the non-test subset
455
+ // of `gateCommands`, else DEFAULT_FAST_GATE.
456
+ export function resolvePreMergeGate(cwd, override) {
457
+ if (Array.isArray(override) && override.length > 0) return override;
458
+ const configPath = join(cwd, '.compose', 'compose.json');
459
+ if (existsSync(configPath)) {
460
+ try {
461
+ const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
462
+ if (Array.isArray(cfg.preMergeGate) && cfg.preMergeGate.length > 0) {
463
+ return cfg.preMergeGate;
464
+ }
465
+ if (Array.isArray(cfg.gateCommands) && cfg.gateCommands.length > 0) {
466
+ const fast = cfg.gateCommands.filter((c) => !/\btest\b/.test(c));
467
+ if (fast.length > 0) return fast;
468
+ }
469
+ } catch {
470
+ /* fall through to default */
471
+ }
472
+ }
473
+ return [...DEFAULT_FAST_GATE];
474
+ }
475
+
425
476
  async function runOneStep(response, ctx) {
426
- const { stratum, cwd, featureCode, blueprintText, gateCommands } = ctx;
477
+ const { stratum, cwd, featureCode, blueprintText, gateCommands, preMergeGate } = ctx;
427
478
  const flowId = response.flow_id;
428
479
  const stepId = response.step_id;
429
480
  const stepType = response.type ?? response.step_type;
@@ -526,7 +577,10 @@ async function runOneStep(response, ctx) {
526
577
 
527
578
  // T6 step 7: validate decompose_gsd output and repair missing descriptions.
528
579
  if (stepId === 'decompose_gsd') {
529
- result = validateAndRepairTaskGraph(result, blueprintText, gateCommands);
580
+ // COMP-PAR-MERGE-QUEUE: single-source the per-task instructed gate to the
581
+ // fast pre-merge gate (== the enforced execute.pre_merge_verify). Full
582
+ // `pnpm test` is instructed only at ship_gsd.
583
+ result = validateAndRepairTaskGraph(result, blueprintText, preMergeGate ?? gateCommands);
530
584
  // COMP-GSD-5: remember the ENRICHED graph so a later stuck halt can
531
585
  // persist the full task definitions (with descriptions/produces/consumes)
532
586
  // into pause.json — resume re-dispatches these without re-enriching.
@@ -162,6 +162,12 @@ function buildConflictSection(conflicts) {
162
162
  return lines.join('\n');
163
163
  }
164
164
 
165
+ // COMP-PAR-MERGE-QUEUE note: pre-merge bounce context (gate_failed / merge_conflict)
166
+ // is injected into a re-dispatched task's prompt SERVER-SIDE, in Stratum's
167
+ // ParallelExecutor._render_prompt — because the server re-resolves the task list
168
+ // from flow state on each re-dispatch, so a Compose-side prompt edit here would
169
+ // never reach the re-run task. See parallel_exec.py `_format_bounce_for_prompt`.
170
+
165
171
  /**
166
172
  * Build a retry prompt when postconditions failed.
167
173
  *
@@ -444,7 +444,9 @@ export class StratumMcpClient {
444
444
  *
445
445
  * @param {string} flowId
446
446
  * @param {string} stepId
447
- * @param {'clean'|'conflict'} mergeStatus
447
+ * @param {'clean'|'conflict'|{status:'clean'|'conflict',bounced_tasks?:object[]}} mergeStatus
448
+ * COMP-PAR-MERGE-QUEUE: either a bare status string (back-compat) or a
449
+ * structured object carrying merge_conflict bounce records. Serializes as-is.
448
450
  * @returns {Promise<object>}
449
451
  */
450
452
  async parallelAdvance(flowId, stepId, mergeStatus) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartmemory/compose",
3
- "version": "0.2.9-beta",
3
+ "version": "0.2.10-beta",
4
4
  "description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
5
5
  "author": "SmartMemory",
6
6
  "license": "MIT",
@@ -18,6 +18,9 @@ workflow:
18
18
  gateCommands:
19
19
  type: array
20
20
  required: true # injected by runGsd from loadProjectConfig() with default fallback
21
+ pre_merge_gate:
22
+ type: array
23
+ required: false # COMP-PAR-MERGE-QUEUE: fast per-task enforced gate (lint+build); injected by runGsd
21
24
 
22
25
  contracts:
23
26
  PhaseResult:
@@ -36,6 +39,7 @@ flows:
36
39
  input:
37
40
  featureCode: {type: string}
38
41
  gateCommands: {type: array}
42
+ pre_merge_gate: {type: array} # COMP-PAR-MERGE-QUEUE
39
43
  output: PhaseResult
40
44
  max_rounds: 10
41
45
  steps:
@@ -61,7 +65,7 @@ flows:
61
65
  - "Upstream tasks (spec-level summary; their code lands at end-of-step
62
66
  merge):" + one line per upstream task listing its declared produces
63
67
  - "GATES — you MUST run each command and they MUST pass before
64
- declaring done:" + each command from the gateCommands array
68
+ declaring done:" + each command from the pre_merge_gate array
65
69
  - "Fix and re-run within this invocation. Do NOT declare done while
66
70
  gates are red."
67
71
 
@@ -75,10 +79,12 @@ flows:
75
79
  has no matching task or its File Plan files are split across
76
80
  multiple tasks.
77
81
 
78
- gateCommands to embed: {input.gateCommands}
82
+ pre_merge_gate commands to embed (the fast per-task gate; the full
83
+ test suite runs once at ship): {input.pre_merge_gate}
79
84
  inputs:
80
- featureCode: "$.input.featureCode"
81
- gateCommands: "$.input.gateCommands"
85
+ featureCode: "$.input.featureCode"
86
+ gateCommands: "$.input.gateCommands"
87
+ pre_merge_gate: "$.input.pre_merge_gate"
82
88
  output_contract: TaskGraph
83
89
  ensure:
84
90
  - "no_file_conflicts(result.tasks)"
@@ -93,6 +99,8 @@ flows:
93
99
  max_concurrent: 1
94
100
  isolation: worktree
95
101
  capture_diff: true
102
+ defer_advance: true # COMP-PAR-MERGE-QUEUE: consumer merges + reports status, enabling conflict-bounce
103
+ pre_merge_verify: "$.input.pre_merge_gate" # COMP-PAR-MERGE-QUEUE: enforced fast gate per task worktree
96
104
  require: all
97
105
  merge: sequential_apply
98
106
  retries: 2