@smartmemory/compose 0.2.12-beta → 0.2.14-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.
package/lib/build.js CHANGED
@@ -18,7 +18,7 @@ import { createHash, randomUUID } from 'node:crypto';
18
18
  import { StratumMcpClient, StratumError } from './stratum-mcp-client.js';
19
19
  import { runAndNormalize, AgentTimeoutError, UserInterruptError, AgentError } from './result-normalizer.js';
20
20
  import { checkCapabilityViolation } from './capability-checker.js';
21
- import { buildStepPrompt, buildRetryPrompt, buildGateContext, clearAmbientContextCache } from './step-prompt.js';
21
+ import { buildStepPrompt, buildRetryPrompt, buildGateContext, clearAmbientContextCache, formatBounceForPrompt } from './step-prompt.js';
22
22
  import { promptGate } from './gate-prompt.js';
23
23
  import { VisionWriter, ServerUnreachableError } from './vision-writer.js';
24
24
  import { resolvePort } from './resolve-port.js';
@@ -60,6 +60,7 @@ import { appendHypothesisEntry, readHypotheses } from './bug-ledger.js';
60
60
  import { tier1CodexReview, tier2FreshAgent } from './bug-escalation.js';
61
61
  import { recordTaskStates, writeTimingSidecar } from './gsd-timing.js';
62
62
  import { writeGsdTaskDiff } from './gsd-diff-capture.js';
63
+ import { resolvePreMergeGate } from './gsd.js';
63
64
 
64
65
  // ---------------------------------------------------------------------------
65
66
  // COMP-FIX-HARD T5: per-step retries cap parser
@@ -626,6 +627,15 @@ export async function runBuild(featureCode, opts = {}) {
626
627
  try { composeConfig = JSON.parse(readFileSync(configPath, 'utf-8')); } catch { /* use defaults */ }
627
628
  const contextDirPath = join(cwd, composeConfig.paths?.context ?? 'docs/context');
628
629
 
630
+ // COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (D5): per-task pre-merge gate is opt-in.
631
+ // Resolve ONCE and thread into startFresh's planInputs only when the capability
632
+ // is on. Left `undefined` ⇒ the `pre_merge_gate` key is omitted from the plan
633
+ // envelope (not `[]`), so the default-OFF path is byte-identical to before.
634
+ let preMergeGate;
635
+ if (composeConfig?.capabilities?.preMergeGate) {
636
+ preMergeGate = resolvePreMergeGate(agentCwd, opts.preMergeGate);
637
+ }
638
+
629
639
  // ---------------------------------------------------------------------------
630
640
  // Pre-build triage — runs before spec loading so profile can toggle skip_if.
631
641
  // Skipped when:
@@ -868,10 +878,10 @@ export async function runBuild(featureCode, opts = {}) {
868
878
  `Previous build for ${featureCode} was in ${active.mode} mode, ` +
869
879
  `current invocation is ${mode} mode. Starting fresh.`
870
880
  );
871
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode);
881
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
872
882
  } else if (active.status && active.status !== 'running') {
873
883
  console.log(`Previous build ${active.status}. Starting fresh.`);
874
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode);
884
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
875
885
  } else if (active.pid && active.pid !== process.pid && isProcessAlive(active.pid)) {
876
886
  // Same feature, different live process — block
877
887
  throw new Error(
@@ -884,7 +894,7 @@ export async function runBuild(featureCode, opts = {}) {
884
894
  response = await stratum.resume(active.flowId);
885
895
  if (isTerminalFlow(response.status)) {
886
896
  console.log(`Previous build already ${response.status}. Starting fresh.`);
887
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode);
897
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
888
898
  } else {
889
899
  console.log(`Resuming from step: ${response.step_id}`);
890
900
  isFreshStart = false;
@@ -895,7 +905,7 @@ export async function runBuild(featureCode, opts = {}) {
895
905
  || err?.message?.includes('No active flow');
896
906
  if (recoverable) {
897
907
  console.log('Previous flow not found. Starting fresh.');
898
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode);
908
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
899
909
  } else {
900
910
  throw err;
901
911
  }
@@ -905,7 +915,7 @@ export async function runBuild(featureCode, opts = {}) {
905
915
  // Different feature or no active build — start fresh.
906
916
  // active-build.json is last-writer-wins: concurrent builds for
907
917
  // different features are allowed; the UI shows the most recent.
908
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode);
918
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
909
919
  }
910
920
 
911
921
  // Update vision state
@@ -1638,6 +1648,17 @@ export async function runBuild(featureCode, opts = {}) {
1638
1648
  response = await stratum.stepDone(parentFlowId, parentStepId, childResult);
1639
1649
 
1640
1650
  } else if (response.status === 'ensure_failed' || response.status === 'schema_failed') {
1651
+ // COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (W4): a parallel step that exhausted its
1652
+ // OWN retry loop must NOT be single-agent-retried here. Terminate the build.
1653
+ if (isParallelRetriesExhausted(response)) {
1654
+ streamWriter?.write({
1655
+ type: 'build_error', stepId: response.step_id ?? stepId,
1656
+ message: 'parallel step failed after retries', flowId: response.flow_id,
1657
+ });
1658
+ buildStatus = 'failed';
1659
+ response.status = 'killed';
1660
+ break;
1661
+ }
1641
1662
  {
1642
1663
  // COMP-HEALTH: track contract compliance failure
1643
1664
  contractCompliance.push({ passed: false, stepId: response.step_id ?? stepId, status: response.status });
@@ -2591,7 +2612,7 @@ async function runCrossModelReview(mergedResult, filesChanged, cwd, stratum, str
2591
2612
  * Handles the child's internal step loop (execute_step, await_gate, ensure_failed, etc.)
2592
2613
  * including nested execute_flow (recursive).
2593
2614
  */
2594
- async function executeChildFlow(
2615
+ export async function executeChildFlow(
2595
2616
  flowDispatch, stratum, context,
2596
2617
  visionWriter, itemId, dataDir, gateOpts, progress,
2597
2618
  streamWriter
@@ -2782,6 +2803,17 @@ async function executeChildFlow(
2782
2803
  }
2783
2804
 
2784
2805
  } else if (resp.status === 'ensure_failed' || resp.status === 'schema_failed') {
2806
+ // COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (W4): a child-flow parallel step that
2807
+ // exhausted its OWN retry loop must NOT be single-agent-fixed here (no double-
2808
+ // handle). Terminate the child flow; the marker keeps it out of the fix branch.
2809
+ if (isParallelRetriesExhausted(resp)) {
2810
+ streamWriter?.write({
2811
+ type: 'build_error', stepId: resp.step_id,
2812
+ message: 'parallel step failed after retries', flowId: childFlowId,
2813
+ });
2814
+ resp.status = 'killed';
2815
+ continue;
2816
+ }
2785
2817
  {
2786
2818
  const currentState = readActiveBuild(dataDir);
2787
2819
  const violationList = (resp.violations || []).slice(-10);
@@ -2901,6 +2933,18 @@ export function resolveBuildStatusForCompleteResponse(response) {
2901
2933
  return 'complete';
2902
2934
  }
2903
2935
 
2936
+ /**
2937
+ * COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (W4): a parallel_dispatch step that exhausted
2938
+ * its OWN bounded retry loop (executeParallelDispatch) tags its terminal envelope
2939
+ * with `_parallelRetriesExhausted: true`. The outer dispatch loops (runBuild and
2940
+ * executeChildFlow) must recognise that marker and terminate — never fall through
2941
+ * to the single-agent fix/retry path, which cannot re-run parallel tasks. Keyed on
2942
+ * the explicit marker, NOT a brittle "does response.tasks exist" heuristic.
2943
+ */
2944
+ export function isParallelRetriesExhausted(response) {
2945
+ return !!(response && response._parallelRetriesExhausted);
2946
+ }
2947
+
2904
2948
  export function shouldUseServerDispatch(dispatchResponse) {
2905
2949
  if (process.env.COMPOSE_SERVER_DISPATCH !== '1') return false;
2906
2950
  const isolation = dispatchResponse?.isolation ?? 'worktree';
@@ -3574,7 +3618,121 @@ function applyServerDispatchDiffs(taskList, pollTasks, baseCwd, streamWriter, st
3574
3618
  }
3575
3619
  }
3576
3620
 
3577
- async function executeParallelDispatch(
3621
+ // ---------------------------------------------------------------------------
3622
+ // COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (W2) — anchor-commit + entry-snapshot
3623
+ // helpers for the consumer-path parallel retry loop. All three operate through
3624
+ // a TEMP git index so the base repo's real index, working tree, and HEAD are
3625
+ // never mutated. The temp index lives in os.tmpdir() (NOT inside the repo):
3626
+ // `git add -A` would otherwise stage the temp-index file itself into the
3627
+ // snapshot tree, since `.compose/` is not guaranteed to be gitignored in target
3628
+ // repos. Putting it outside the worktree sidesteps that self-capture entirely.
3629
+ // The dangling commits these create carry no ref → git GC reclaims them.
3630
+ // ---------------------------------------------------------------------------
3631
+
3632
+ let anchorSeq = 0;
3633
+
3634
+ function tempIndexEnv(baseCwd) {
3635
+ const idx = join(tmpdir(), `compose-par-idx-${process.pid}-${anchorSeq++}`);
3636
+ // Deterministic identity so commit-tree works even in repos without a
3637
+ // configured git user. These commits never reach a ref.
3638
+ const env = {
3639
+ ...process.env,
3640
+ GIT_INDEX_FILE: idx,
3641
+ GIT_AUTHOR_NAME: 'compose', GIT_AUTHOR_EMAIL: 'compose@local',
3642
+ GIT_COMMITTER_NAME: 'compose', GIT_COMMITTER_EMAIL: 'compose@local',
3643
+ };
3644
+ return { idx, env };
3645
+ }
3646
+
3647
+ /**
3648
+ * Build a dangling anchor commit = HEAD + `goodDiffs` (applied index-only, in
3649
+ * the order given). Returns the commit SHA. The base working tree, real index,
3650
+ * and HEAD are untouched. Used to seed round-N retry worktrees with the round's
3651
+ * already-successful work (`git worktree add <sha> --detach`).
3652
+ */
3653
+ export function buildAnchorCommit(baseCwd, goodDiffs = [], label = 'par-anchor') {
3654
+ const { idx, env } = tempIndexEnv(baseCwd);
3655
+ try {
3656
+ execSync('git read-tree HEAD', { cwd: baseCwd, env, stdio: 'pipe' });
3657
+ for (const d of goodDiffs) {
3658
+ if (!d) continue;
3659
+ execSync('git apply --cached -', { cwd: baseCwd, env, input: d, stdio: 'pipe' });
3660
+ }
3661
+ const tree = execSync('git write-tree', { cwd: baseCwd, env, encoding: 'utf-8', stdio: 'pipe' }).trim();
3662
+ const sha = execFileSync('git', ['commit-tree', tree, '-p', 'HEAD', '-m', label], {
3663
+ cwd: baseCwd, env, encoding: 'utf-8', stdio: 'pipe',
3664
+ }).trim();
3665
+ return sha;
3666
+ } finally {
3667
+ try { rmSync(idx, { force: true }); } catch { /* best effort */ }
3668
+ }
3669
+ }
3670
+
3671
+ /**
3672
+ * Capture the FULL entry working tree (tracked + untracked, honoring
3673
+ * .gitignore) as a dangling commit, through a temp index so the real index and
3674
+ * working tree are never touched. Returns the snapshot commit SHA. Restored
3675
+ * between retry rounds via restoreToSnapshot.
3676
+ */
3677
+ export function captureEntrySnapshot(baseCwd, label = 'par-entry-snapshot') {
3678
+ const { idx, env } = tempIndexEnv(baseCwd);
3679
+ try {
3680
+ execSync('git read-tree HEAD', { cwd: baseCwd, env, stdio: 'pipe' });
3681
+ execSync('git add -A', { cwd: baseCwd, env, stdio: 'pipe' }); // tracked + untracked → TEMP index only
3682
+ const tree = execSync('git write-tree', { cwd: baseCwd, env, encoding: 'utf-8', stdio: 'pipe' }).trim();
3683
+ const sha = execFileSync('git', ['commit-tree', tree, '-p', 'HEAD', '-m', label], {
3684
+ cwd: baseCwd, env, encoding: 'utf-8', stdio: 'pipe',
3685
+ }).trim();
3686
+ return sha;
3687
+ } finally {
3688
+ try { rmSync(idx, { force: true }); } catch { /* best effort */ }
3689
+ }
3690
+ }
3691
+
3692
+ /**
3693
+ * Restore the base working-tree CONTENT (tracked + untracked) to a snapshot
3694
+ * captured by captureEntrySnapshot. The staged-vs-unstaged split is NOT
3695
+ * preserved (the next round's applyTaskDiffsToBaseCwd stash normalizes the
3696
+ * index anyway). Drops this round's tracked + untracked changes, then
3697
+ * re-materializes the entry tree, then resets the index to HEAD.
3698
+ */
3699
+ export function restoreToSnapshot(baseCwd, snap) {
3700
+ const run = (cmd) => execSync(cmd, { cwd: baseCwd, encoding: 'utf-8', timeout: 30000, stdio: 'pipe' });
3701
+ try { run('git checkout -- .'); } catch { /* nothing tracked to drop */ }
3702
+ try { run('git clean -fd'); } catch { /* nothing untracked to drop */ }
3703
+ run(`git checkout ${snap} -- .`); // re-materialize entry tracked + untracked content
3704
+ try { run('git reset -q'); } catch { /* unstage; best effort */ }
3705
+ }
3706
+
3707
+ /**
3708
+ * Return the diffs in `diffMap` ordered by the tasks' depends_on topology (the
3709
+ * same DFS applyTaskDiffsToBaseCwd uses), so an anchor commit replays the good
3710
+ * diffs in a consistent order. COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (W2).
3711
+ */
3712
+ function topoOrderedDiffs(tasks, diffMap) {
3713
+ const taskMap = new Map(tasks.map(t => [t.id, t]));
3714
+ const order = [];
3715
+ const visited = new Set();
3716
+ const visiting = new Set();
3717
+ const visit = (id) => {
3718
+ if (visited.has(id) || visiting.has(id)) return;
3719
+ visiting.add(id);
3720
+ const t = taskMap.get(id);
3721
+ if (t) for (const dep of (t.depends_on ?? [])) visit(dep);
3722
+ visiting.delete(id);
3723
+ visited.add(id);
3724
+ order.push(id);
3725
+ };
3726
+ for (const t of tasks) visit(t.id);
3727
+ const out = [];
3728
+ for (const id of order) {
3729
+ const d = diffMap.get(id);
3730
+ if (d) out.push(d);
3731
+ }
3732
+ return out;
3733
+ }
3734
+
3735
+ export async function executeParallelDispatch(
3578
3736
  dispatchResponse,
3579
3737
  stratum,
3580
3738
  context,
@@ -3620,8 +3778,52 @@ async function executeParallelDispatch(
3620
3778
  const worktreeIsolation = useWorktrees && isGitRepo;
3621
3779
 
3622
3780
  const maxConcurrent = Math.max(1, dispatchResponse.max_concurrent ?? 3);
3781
+
3782
+ // COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (W3): the consumer-dispatch path owns a
3783
+ // bounded, bounce-injected retry loop. Each round re-runs ONLY the failed
3784
+ // subset; successful diffs are replayed onto a throwaway per-round anchor
3785
+ // commit so re-run tasks see prior good work. The real base is restored to an
3786
+ // entry snapshot between rounds (no cross-round double-apply), the union is
3787
+ // applied to the base BEFORE parallelDone every round (today's order), and the
3788
+ // terminal build_step_done fires exactly once — after the terminal
3789
+ // parallelDone — mirroring the server-dispatch discipline.
3790
+ // Cap = the step's declared `retries` (Stratum's field is declarative-only on the
3791
+ // consumer path; Compose enforces it here), then a per-dispatch override, then 2.
3792
+ // Hard-ceilinged at 10 as a runaway backstop.
3793
+ const RETRY_CAP = Math.min(10, Math.max(0, dispatchResponse.retries ?? dispatchResponse.max_par_retries ?? 2));
3794
+
3795
+ // Entry snapshot: full working tree (tracked + untracked), captured ONCE before
3796
+ // round 0 mutates the base (its state is gone after the round-0 apply), for
3797
+ // restore-between-rounds. Skipped when retries are disabled. On the single-round
3798
+ // happy path it is a captured-but-never-used dangling commit (GC-reclaimable) —
3799
+ // the real index/worktree and the parallelDone call stay byte-identical to today.
3800
+ let entrySnapshot = null;
3801
+ if (worktreeIsolation && RETRY_CAP > 0) {
3802
+ try {
3803
+ entrySnapshot = captureEntrySnapshot(baseCwd);
3804
+ } catch (err) {
3805
+ // Capture only fails when the base has no HEAD (unborn branch — the worktree
3806
+ // dispatch itself can't run) or git/disk is broken. We DEGRADE to a single
3807
+ // pass: retryable is gated on `entrySnapshot !== null`, so no round ever runs
3808
+ // against an unrecoverable base. A failed step then leaves round 0's partial
3809
+ // merge in place — identical to the pre-feature consumer path, which never
3810
+ // rolled back either (a coarse `checkout/clean` is NOT a safe fallback: it
3811
+ // would also discard the user's entry uncommitted changes).
3812
+ entrySnapshot = null;
3813
+ console.warn(`[par-retry] entry snapshot capture failed (${err?.message ?? err}); parallel retry disabled for ${dispStepId}`);
3814
+ }
3815
+ }
3816
+
3817
+ // Cross-round state.
3818
+ const goodDiffs = new Map(); // taskId -> captured diff of a non-failed task (replayed each round)
3819
+ const goodResults = new Map(); // taskId -> { task_id, status:'complete', result }
3820
+ let subset = tasks; // round 0 runs all tasks
3821
+ let inboundBounces = new Map(); // taskId -> ParMergeBounce from the prior round (drives W1 injection)
3822
+ let round = 0;
3823
+
3824
+ // Per-round mutable state (reset at the top of each round).
3623
3825
  let activeSlots = 0;
3624
- const slotWaiters = [];
3826
+ let slotWaiters = [];
3625
3827
  const acquireSlot = () => {
3626
3828
  if (activeSlots < maxConcurrent) { activeSlots++; return Promise.resolve(); }
3627
3829
  return new Promise(res => slotWaiters.push(res));
@@ -3630,12 +3832,24 @@ async function executeParallelDispatch(
3630
3832
  activeSlots--;
3631
3833
  if (slotWaiters.length > 0) { activeSlots++; slotWaiters.shift()(); }
3632
3834
  };
3835
+ let worktreePaths = new Map();
3836
+ let taskDiffs = new Map();
3837
+ let anchorRef = 'HEAD';
3633
3838
 
3634
- const worktreePaths = new Map();
3635
- const taskDiffs = new Map();
3839
+ while (true) {
3840
+ // Reset per-round state.
3841
+ activeSlots = 0;
3842
+ slotWaiters = [];
3843
+ worktreePaths = new Map();
3844
+ taskDiffs = new Map();
3845
+ // Round 0 worktrees off HEAD (byte-identical). Round N off a dangling anchor
3846
+ // = HEAD + replayed good diffs (topo order) so re-run tasks SEE prior good work.
3847
+ anchorRef = round === 0
3848
+ ? 'HEAD'
3849
+ : buildAnchorCommit(baseCwd, topoOrderedDiffs(tasks, goodDiffs), `par-anchor-round-${round}`);
3636
3850
 
3637
3851
  const settled = await Promise.allSettled(
3638
- tasks.map(async (task) => {
3852
+ subset.map(async (task) => {
3639
3853
  await acquireSlot();
3640
3854
  const taskId = task.id;
3641
3855
  let taskCwd = baseCwd;
@@ -3643,7 +3857,7 @@ async function executeParallelDispatch(
3643
3857
 
3644
3858
  if (worktreeIsolation) {
3645
3859
  try {
3646
- execSync(`git worktree add "${wtPath}" --detach HEAD`, {
3860
+ execSync(`git worktree add "${wtPath}" --detach ${anchorRef}`, {
3647
3861
  cwd: baseCwd, encoding: 'utf-8', timeout: 30000, stdio: 'pipe',
3648
3862
  });
3649
3863
  worktreePaths.set(taskId, wtPath);
@@ -3700,7 +3914,16 @@ async function executeParallelDispatch(
3700
3914
  }
3701
3915
 
3702
3916
  try {
3703
- const baseTaskPrompt = buildStepPrompt(syntheticDispatch, context);
3917
+ let baseTaskPrompt = buildStepPrompt(syntheticDispatch, context);
3918
+ // COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (W1): inject the prior round's bounce
3919
+ // (gate failure / merge conflict) into a re-run task's prompt. Empty on round 0.
3920
+ const inbound = inboundBounces.get(taskId);
3921
+ if (inbound) {
3922
+ try {
3923
+ const bounceText = formatBounceForPrompt(inbound);
3924
+ if (bounceText) baseTaskPrompt = `${baseTaskPrompt}\n\n${bounceText}`;
3925
+ } catch { /* degrade — never block a task on injection */ }
3926
+ }
3704
3927
  const taskTimeout = STEP_TIMEOUT_MS[dispStepId] ?? DEFAULT_TIMEOUT_MS;
3705
3928
  // review_mode is passed via inputs (as string "true") since top-level step props are Stratum-validated.
3706
3929
  // Fallback: parallel_dispatch steps with output_contract=ReviewResult are review by definition.
@@ -3776,6 +3999,11 @@ async function executeParallelDispatch(
3776
3999
 
3777
4000
  try {
3778
4001
  execSync('git add -A', { cwd: wtPath, encoding: 'utf-8', timeout: 10000, stdio: 'pipe' });
4002
+ // COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY: never let the worktree-ownership
4003
+ // marker leak into a task's captured diff — every task writes `.owner`
4004
+ // at its worktree root, so capturing it makes each task's diff add the
4005
+ // same path and the SECOND task's merge conflicts ("already exists").
4006
+ try { execSync('git reset -q -- .owner', { cwd: wtPath, encoding: 'utf-8', timeout: 10000, stdio: 'pipe' }); } catch { /* .owner absent — fine */ }
3779
4007
  const diff = execSync('git diff --cached HEAD', {
3780
4008
  cwd: wtPath, encoding: 'utf-8', timeout: 30000, stdio: 'pipe',
3781
4009
  });
@@ -3815,82 +4043,190 @@ async function executeParallelDispatch(
3815
4043
  })
3816
4044
  );
3817
4045
 
3818
- // COMP-PAR-MERGE-QUEUE-CONSUMER: collect structured bounce records (gate-failed
3819
- // here, merge-conflict below) to surface on the parallelDone envelope.
3820
- const bouncedTasks = [];
3821
- const taskResults = settled.map(outcome => {
3822
- if (outcome.status === 'rejected') {
3823
- return { task_id: 'unknown', status: 'failed', error: String(outcome.reason) };
3824
- }
3825
- const { taskId, status, result, error, gateBounce } = outcome.value;
3826
- if (gateBounce) bouncedTasks.push(gateBounce);
3827
- return status === 'complete'
3828
- ? { task_id: taskId, status: 'complete', result }
3829
- : { task_id: taskId, status: 'failed', error };
3830
- });
4046
+ // COMP-PAR-MERGE-QUEUE-CONSUMER: collect structured bounce records (gate-failed
4047
+ // here, merge-conflict below) to surface on the parallelDone envelope.
4048
+ const bouncedTasks = [];
4049
+ const roundResults = settled.map(outcome => {
4050
+ if (outcome.status === 'rejected') {
4051
+ return { task_id: 'unknown', status: 'failed', error: String(outcome.reason) };
4052
+ }
4053
+ const { taskId, status, result, error, gateBounce } = outcome.value;
4054
+ if (gateBounce) bouncedTasks.push(gateBounce);
4055
+ return status === 'complete'
4056
+ ? { task_id: taskId, status: 'complete', result }
4057
+ : { task_id: taskId, status: 'failed', error };
4058
+ });
3831
4059
 
3832
- let mergeStatus = 'clean';
3833
- if (worktreeIsolation && taskDiffs.size > 0) {
3834
- const result = applyTaskDiffsToBaseCwd(
3835
- tasks, taskDiffs, baseCwd, streamWriter, dispStepId, parDir,
3836
- );
3837
- mergeStatus = result.mergeStatus;
4060
+ // Union re-applied to the real base each round = carried good diffs + this
4061
+ // round's fresh diffs. The base was restored to the entry snapshot before
4062
+ // this round, so applyTaskDiffsToBaseCwd never sees a prior round's union
4063
+ // no cross-round double-apply. Round 0: goodDiffs empty ⇒ union = today's diffs.
4064
+ const unionDiffs = new Map(goodDiffs);
4065
+ for (const [id, d] of taskDiffs) unionDiffs.set(id, d);
4066
+
4067
+ let mergeStatus = 'clean';
4068
+ if (worktreeIsolation && unionDiffs.size > 0) {
4069
+ const result = applyTaskDiffsToBaseCwd(
4070
+ tasks, unionDiffs, baseCwd, streamWriter, dispStepId, parDir,
4071
+ );
4072
+ mergeStatus = result.mergeStatus;
3838
4073
 
3839
- // Merge applied files into context (matches existing behavior)
3840
- if (mergeStatus !== 'conflict' && result.appliedFiles.length > 0) {
3841
- const existing = new Set(context.filesChanged ?? []);
3842
- for (const f of result.appliedFiles) existing.add(f);
3843
- context.filesChanged = [...existing];
4074
+ // Merge applied files into context (matches existing behavior)
4075
+ if (mergeStatus !== 'conflict' && result.appliedFiles.length > 0) {
4076
+ const existing = new Set(context.filesChanged ?? []);
4077
+ for (const f of result.appliedFiles) existing.add(f);
4078
+ context.filesChanged = [...existing];
4079
+ }
4080
+
4081
+ // Mark conflicted task as failed in THIS round's results (matches existing behavior)
4082
+ if (mergeStatus === 'conflict' && result.conflictedTaskId) {
4083
+ const idx = roundResults.findIndex(r => r.task_id === result.conflictedTaskId);
4084
+ if (idx >= 0) {
4085
+ roundResults[idx].status = 'failed';
4086
+ roundResults[idx].error = `merge conflict: ${result.conflictError}`;
4087
+ }
4088
+ // COMP-PAR-MERGE-QUEUE-CONSUMER: structured merge-conflict bounce.
4089
+ bouncedTasks.push(
4090
+ buildMergeConflictBounce(result.conflictedTaskId, result.conflictError, result.conflictFiles),
4091
+ );
4092
+ }
4093
+ }
4094
+
4095
+ if (worktreeIsolation) {
4096
+ try { execSync(`rm -rf "${parDir}"`, { cwd: baseCwd, timeout: 5000, stdio: 'pipe' }); } catch { /* ignore */ }
3844
4097
  }
3845
4098
 
3846
- // Mark conflicted task as failed in taskResults (matches existing behavior)
3847
- if (mergeStatus === 'conflict' && result.conflictedTaskId) {
3848
- const idx = taskResults.findIndex(r => r.task_id === result.conflictedTaskId);
3849
- if (idx >= 0) {
3850
- taskResults[idx].status = 'failed';
3851
- taskResults[idx].error = `merge conflict: ${result.conflictError}`;
4099
+ // Full aggregate over ALL tasks = carried good results (complete) + this round's.
4100
+ const taskResults = [...goodResults.values(), ...roundResults];
4101
+
4102
+ // COMP-PAR-MERGE-QUEUE-CONSUMER: when there are bounce records, send a structured
4103
+ // merge_status {status, bounced_tasks} so Stratum threads them onto the failure
4104
+ // envelope (and derives readable violations). Bare string otherwise — byte-identical.
4105
+ const mergeArg = bouncedTasks.length > 0
4106
+ ? { status: mergeStatus, bounced_tasks: bouncedTasks }
4107
+ : mergeStatus;
4108
+
4109
+ // isolation:none (e.g. the review lenses): nothing to merge, no retry semantics.
4110
+ // Preserve the pre-feature single-pass behavior EXACTLY — emit the parent
4111
+ // build_step_done (mergeStatus-based summary) BEFORE parallelDone, then return
4112
+ // the raw envelope so the parent fix-loop owns any ensure_failed. No snapshot,
4113
+ // no marker, no event-ordering change for this path.
4114
+ if (!worktreeIsolation) {
4115
+ if (streamWriter) {
4116
+ const nComplete = taskResults.filter(r => r.status === 'complete').length;
4117
+ streamWriter.write({
4118
+ type: 'build_step_done', stepId: dispStepId,
4119
+ summary: `parallel_dispatch: ${nComplete}/${taskResults.length} tasks ${mergeStatus === 'clean' ? 'merged' : 'conflict'}`,
4120
+ retries: 0,
4121
+ violations: [],
4122
+ flowId: dispFlowId, parallel: true,
4123
+ ...(parentFlowId ? { parentFlowId } : {}),
4124
+ });
3852
4125
  }
3853
- // COMP-PAR-MERGE-QUEUE-CONSUMER: structured merge-conflict bounce.
3854
- bouncedTasks.push(
3855
- buildMergeConflictBounce(result.conflictedTaskId, result.conflictError, result.conflictFiles),
3856
- );
4126
+ return stratum.parallelDone(dispFlowId, dispStepId, taskResults, mergeArg);
3857
4127
  }
3858
- }
3859
4128
 
3860
- if (worktreeIsolation) {
3861
- try { execSync(`rm -rf "${parDir}"`, { cwd: baseCwd, timeout: 5000, stdio: 'pipe' }); } catch { /* ignore */ }
3862
- }
4129
+ const env = await stratum.parallelDone(dispFlowId, dispStepId, taskResults, mergeArg);
3863
4130
 
3864
- const nComplete = taskResults.filter(r => r.status === 'complete').length;
3865
- if (streamWriter) {
3866
- streamWriter.write({
3867
- type: 'build_step_done', stepId: dispStepId,
3868
- summary: `parallel_dispatch: ${nComplete}/${taskResults.length} tasks ${mergeStatus === 'clean' ? 'merged' : 'conflict'}`,
3869
- retries: 0,
3870
- violations: [],
3871
- flowId: dispFlowId, parallel: true,
3872
- ...(parentFlowId ? { parentFlowId } : {}),
3873
- });
3874
- }
4131
+ // The terminal build_step_done (parent step) emitted exactly ONCE, after the
4132
+ // terminal parallelDone, mirroring executeParallelDispatchServer's discipline.
4133
+ // (The one deliberate departure from the pre-feature consumer path, which emitted
4134
+ // unconditionally before parallelDone; terminality is only known after the call.)
4135
+ const emitTerminalDone = () => {
4136
+ if (!streamWriter) return;
4137
+ const nComplete = taskResults.filter(r => r.status === 'complete').length;
4138
+ streamWriter.write({
4139
+ type: 'build_step_done', stepId: dispStepId,
4140
+ summary: `parallel_dispatch: ${nComplete}/${taskResults.length} tasks ${env.status === 'complete' ? 'merged' : 'failed'}`,
4141
+ retries: round,
4142
+ violations: [],
4143
+ flowId: dispFlowId, parallel: true,
4144
+ ...(parentFlowId ? { parentFlowId } : {}),
4145
+ });
4146
+ };
3875
4147
 
3876
- // COMP-PAR-MERGE-QUEUE-CONSUMER: when there are bounce records, send a structured
3877
- // merge_status {status, bounced_tasks} so Stratum threads them onto the failure
3878
- // envelope (and derives readable violations). Bare string otherwisebyte-identical.
3879
- const mergeArg = bouncedTasks.length > 0
3880
- ? { status: mergeStatus, bounced_tasks: bouncedTasks }
3881
- : mergeStatus;
3882
- return stratum.parallelDone(dispFlowId, dispStepId, taskResults, mergeArg);
4148
+ // Terminal complete the base holds the merged union and context.filesChanged
4149
+ // is set. Emit the single terminal build_step_done and return the envelope.
4150
+ // On round 0 this is one apply + one parallelDone + one emit same final base,
4151
+ // same event content, same stratum call as the pre-feature path.
4152
+ if (env.status === 'complete') {
4153
+ emitTerminalDone();
4154
+ return env;
4155
+ }
4156
+
4157
+ // Retry — same gate the server-dispatch path uses (mirror), bounded by RETRY_CAP.
4158
+ // REQUIRES a clean-base snapshot: a successful entry snapshot is the only thing
4159
+ // that lets a later round start from the pristine base. If capture failed
4160
+ // (entrySnapshot === null) we must NOT retry — retrying against round 0's already-
4161
+ // applied union would double-apply / corrupt the base. Degrade to a tagged
4162
+ // terminal instead (the W4 guard then fails the build cleanly).
4163
+ const retryable = (env.status === 'ensure_failed' || env.status === 'schema_failed')
4164
+ && Array.isArray(env.tasks)
4165
+ && env.step_id === dispStepId
4166
+ && round < RETRY_CAP
4167
+ && entrySnapshot !== null;
4168
+
4169
+ if (retryable) {
4170
+ // Restore the base to the entry snapshot so the next round starts clean (no
4171
+ // leftover/partial union). Idempotent on a conflict round, which
4172
+ // applyTaskDiffsToBaseCwd already rolled back. If the restore FAILS we cannot
4173
+ // guarantee a clean base for the next round, so we abort the retry and fall
4174
+ // through to the tagged terminal rather than running against residual state.
4175
+ let restored = true;
4176
+ try { restoreToSnapshot(baseCwd, entrySnapshot); } catch { restored = false; }
4177
+ if (restored) {
4178
+ // Accumulate every non-failed task's diff/result so it replays next round.
4179
+ for (const r of roundResults) {
4180
+ if (r.status !== 'failed') {
4181
+ goodResults.set(r.task_id, r);
4182
+ const d = taskDiffs.get(r.task_id);
4183
+ if (d) goodDiffs.set(r.task_id, d);
4184
+ }
4185
+ }
4186
+ // Authoritative failed set (gate-failed + schema_failed + conflict-loser)
4187
+ // becomes the next subset — never inferred from per-file apply output.
4188
+ const taskById = new Map(tasks.map(t => [t.id, t]));
4189
+ subset = taskResults
4190
+ .filter(r => r.status === 'failed')
4191
+ .map(r => taskById.get(r.task_id))
4192
+ .filter(Boolean);
4193
+ inboundBounces = new Map((bouncedTasks ?? []).map(b => [b.task_id, b]));
4194
+ round++;
4195
+ continue; // no build_step_done this round
4196
+ }
4197
+ if (streamWriter) {
4198
+ streamWriter.write({
4199
+ type: 'build_error', stepId: dispStepId,
4200
+ message: 'parallel retry aborted: base snapshot restore failed', flowId: dispFlowId,
4201
+ });
4202
+ }
4203
+ // fall through to the tagged terminal below
4204
+ }
4205
+
4206
+ // Worktree path, non-complete terminal: cap exceeded or a terminal we cannot
4207
+ // retry. Restore the base (the step failed → leave no partial merge behind) and
4208
+ // tag the envelope so the outer loop terminates instead of single-agent-retrying
4209
+ // a parallel step (W4). (isolation:none already returned above.)
4210
+ emitTerminalDone();
4211
+ if (entrySnapshot) {
4212
+ try { restoreToSnapshot(baseCwd, entrySnapshot); } catch { /* best effort */ }
4213
+ }
4214
+ return { ...env, _parallelRetriesExhausted: true };
4215
+ }
3883
4216
  }
3884
4217
 
3885
- async function startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode = 'feature') {
4218
+ export async function startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode = 'feature', preMergeGate) {
3886
4219
  const flowName = extractFlowName(specYaml, templateName);
3887
4220
  console.log(`Starting ${flowName} for ${featureCode}...`);
3888
4221
  // COMP-FIX-HARD T4: bug-mode flows take input as { task: <description> }
3889
4222
  // because pipelines/bug-fix.stratum.yaml's flow input contract uses `task`,
3890
4223
  // not the feature flow's `{ featureCode, description }`.
4224
+ // COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (D5): fold pre_merge_gate into the
4225
+ // feature plan envelope ONLY when resolved (undefined ⇒ key omitted, not [])
4226
+ // so the default-OFF path is byte-identical to pre-feature behavior.
3891
4227
  const planInputs = mode === 'bug'
3892
4228
  ? { task: description }
3893
- : { featureCode, description };
4229
+ : { featureCode, description, ...(preMergeGate !== undefined ? { pre_merge_gate: preMergeGate } : {}) };
3894
4230
  const response = await stratum.plan(specYaml, flowName, planInputs);
3895
4231
 
3896
4232
  writeActiveBuild(dataDir, {
@@ -162,11 +162,46 @@ 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`.
165
+ // COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY: pre-merge bounce context (gate_failed /
166
+ // merge_conflict) is injected into a re-dispatched task's prompt on BOTH paths:
167
+ // - server-dispatch: SERVER-SIDE in Stratum's ParallelExecutor._render_prompt
168
+ // (the server re-resolves the task list from flow state on each re-dispatch).
169
+ // - consumer-dispatch: COMPOSE-SIDE via formatBounceForPrompt() below, appended
170
+ // in executeParallelDispatch's retry loop (Compose, not Stratum, builds the
171
+ // per-task prompt on that path). This is the Compose mirror of
172
+ // parallel_exec.py `_format_bounce_for_prompt`.
173
+
174
+ /**
175
+ * Format a ParMergeBounce record (contracts/par-merge-bounce.json) as a prompt
176
+ * section telling a re-dispatched task its prior attempt was rejected before
177
+ * merge. Mirrors Stratum's `_format_bounce_for_prompt` (parallel_exec.py).
178
+ * Degrades to '' on null/non-object input so a malformed bounce never breaks a
179
+ * re-run prompt.
180
+ *
181
+ * @param {{task_id?:string, reason?:string, command?:string|null,
182
+ * exit_code?:number|null, files?:string[], excerpt?:string}} bounce
183
+ * @returns {string}
184
+ */
185
+ export function formatBounceForPrompt(bounce) {
186
+ if (!bounce || typeof bounce !== 'object') return '';
187
+ const files = Array.isArray(bounce.files) && bounce.files.length
188
+ ? bounce.files.join(', ')
189
+ : '(none reported)';
190
+ const lines = ['## Previous attempt was rejected before merge — fix this before finishing'];
191
+ if (bounce.reason === 'gate_failed') {
192
+ const code = bounce.exit_code == null ? '?' : String(bounce.exit_code);
193
+ lines.push(`Your last attempt FAILED the pre-merge gate \`${bounce.command ?? '?'}\` (exit ${code}). It was not merged.`);
194
+ } else if (bounce.reason === 'merge_conflict') {
195
+ lines.push("Your last attempt produced changes that CONFLICTED with another task's changes at merge time. It was not merged.");
196
+ } else {
197
+ lines.push('Your last attempt was rejected before merge.');
198
+ }
199
+ lines.push(`Files involved: ${files}`);
200
+ if (bounce.excerpt) {
201
+ lines.push('Failure output:', '```', String(bounce.excerpt), '```');
202
+ }
203
+ return lines.join('\n');
204
+ }
170
205
 
171
206
  /**
172
207
  * Build a retry prompt when postconditions failed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartmemory/compose",
3
- "version": "0.2.12-beta",
3
+ "version": "0.2.14-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
  description:
19
19
  type: string
20
20
  required: true
21
+ pre_merge_gate:
22
+ type: array
23
+ required: false # COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY: default-OFF per-task gate (lint+build), omitted unless capabilities.preMergeGate
21
24
 
22
25
  contracts:
23
26
  PhaseResult:
@@ -189,6 +192,7 @@ flows:
189
192
  input:
190
193
  featureCode: {type: string}
191
194
  description: {type: string}
195
+ pre_merge_gate: {type: array} # COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY: optional per-task pre-merge gate
192
196
  output: PhaseResult
193
197
  max_rounds: 10
194
198
  steps:
@@ -345,6 +349,7 @@ flows:
345
349
  isolation: worktree
346
350
  capture_diff: true
347
351
  defer_advance: true
352
+ pre_merge_verify: "$.input.pre_merge_gate" # COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY: optional per-task fast gate (default-OFF)
348
353
  require: all
349
354
  merge: sequential_apply
350
355
  intent_template: >