valent-pipeline 0.19.65 → 0.19.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/lib/git-flow.js +106 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "valent-pipeline",
3
- "version": "0.19.65",
3
+ "version": "0.19.67",
4
4
  "description": "v3 multi-agent AI pipeline for software development lifecycle",
5
5
  "type": "module",
6
6
  "bin": {
@@ -139,15 +139,19 @@ const REFRESH_TARGET_WINS = new Set(['pipeline-backlog.yaml']);
139
139
  // bug entry never reached the branch and `backlog ship --story <bug>` then errored "not found" →
140
140
  // manual reconcile every bug-fix sprint. The ship CLAMP is untouched — evidence still verifies against
141
141
  // the branch's own tree; this only delivers the backlog ENTRY the target already committed.
142
- function resolveRefreshConflict(dir) {
143
- if (!mergeInProgress(dir)) return false; // a non-conflict merge failure — the caller aborts
144
- let conflicted;
142
+ // The repo-relative paths git reports as unmerged (conflicted) in the current merge. [] on error.
143
+ function conflictedPaths(dir) {
145
144
  try {
146
- conflicted = git(dir, ['diff', '--name-only', '--diff-filter=U'])
145
+ return git(dir, ['diff', '--name-only', '--diff-filter=U'])
147
146
  .split('\n').map((s) => s.trim()).filter(Boolean);
148
147
  } catch {
149
- return false;
148
+ return [];
150
149
  }
150
+ }
151
+
152
+ function resolveRefreshConflict(dir) {
153
+ if (!mergeInProgress(dir)) return false; // a non-conflict merge failure — the caller aborts
154
+ const conflicted = conflictedPaths(dir);
151
155
  if (!conflicted.length || !conflicted.every((p) => REFRESH_TARGET_WINS.has(p))) return false;
152
156
  for (const p of conflicted) {
153
157
  git(dir, ['checkout', '--theirs', '--', p]);
@@ -167,6 +171,54 @@ function isMergedInto(root, branch, target) {
167
171
  }
168
172
  }
169
173
 
174
+ // RESUME-path backlog refresh (M-208). The create-path sync (startStory, the `merge --no-ff target`
175
+ // below) fires ONLY on create — never on a RESUME, because a full merge there would move HEAD past the
176
+ // critic pin (the documented constraint at the create-path guard). But a --from-story bug-fix branch
177
+ // can still END UP missing the target-filed bug entry across a resume: if the entry is filed to the
178
+ // target AFTER the branch is cut (the rejecting sprint's rollover-persist lands ~seconds after the
179
+ // branch base, or the create launch dies before the entry exists) and the run then RESUMES the branch,
180
+ // the create refresh never re-runs → `backlog ship --story <bug>` errors "backlog item not found"
181
+ // (3.5-BUG-001, the M-36/M-65 class on the resume surface). Bring ONLY the target-canonical backlog
182
+ // forward — a single path-scoped chore commit, NOT a merge — so the entry arrives while the pin and all
183
+ // code stay exactly put (the backlog is already target-wins on every merge, REFRESH_TARGET_WINS). A
184
+ // no-op when the branch backlog already matches the target (the create refresh delivered it, or nothing
185
+ // was filed) so a resume never churns an empty commit; gated to inline --from-story fixes (basedOnStory)
186
+ // whose target genuinely advanced, mirroring the create-path guard.
187
+ function syncBacklogFromTargetOnResume(gitDir, root, story, target) {
188
+ try {
189
+ if (!target || !branchExists(root, target)) return false;
190
+ const rec = readFlowRecord(gitDir, story) || readFlowRecord(root, story);
191
+ if (!rec || !rec.basedOnStory) return false; // only inline bug fixes need this
192
+ if (mergeInProgress(gitDir)) return false; // never touch a half-merged tree
193
+ const branch = rec.branch || currentBranch(gitDir);
194
+ if (!branch || isMergedInto(root, target, branch)) return false; // target not ahead → nothing to inherit
195
+ return commitTargetBacklog(gitDir, story, target,
196
+ 'inherit target backlog (filed bug entries) on resume — no code merge, pin intact (M-208)');
197
+ } catch {
198
+ return false; // best-effort: a sync failure leaves the branch as it was (degraded, not wedged)
199
+ }
200
+ }
201
+
202
+ // Deliver ONLY the target's canonical pipeline-backlog.yaml blob onto the CURRENT branch as a
203
+ // path-scoped chore commit (no code merge, HEAD/pin/code untouched). Returns true iff it committed a
204
+ // change (no-op + false when the branch backlog already matches the target — never an empty commit).
205
+ // Shared by the resume-path sync (M-208) and the create-path refresh-abort fallback (M-219): a refresh
206
+ // merge that ABORTS on a non-backlog co-conflict (a project coordination doc, the M-52 untrack reset, …)
207
+ // rolls the cleanly-mergeable bug entry back with the whole merge, so the disposition's filed entry never
208
+ // reaches the branch and `backlog ship --story <bug>` later errors "not found". The backlog is
209
+ // target-canonical (never legitimately branch-edited — target-wins on every merge), so deliver it
210
+ // directly. Caller must ensure no merge is in progress (a clean tree at base).
211
+ function commitTargetBacklog(gitDir, story, target, summary) {
212
+ const backlogFile = 'pipeline-backlog.yaml';
213
+ git(gitDir, ['checkout', target, '--', backlogFile]); // stage the target's canonical backlog blob
214
+ const staged = git(gitDir, ['diff', '--cached', '--name-only'])
215
+ .split('\n').map((s) => s.trim()).filter(Boolean);
216
+ if (!staged.includes(backlogFile)) return false; // already current → no empty commit
217
+ git(gitDir, ['commit', '-m',
218
+ buildCommitMessage({ storyId: story, phase: 'sync', summary }), '--', backlogFile]);
219
+ return true;
220
+ }
221
+
170
222
  // --- worktree mode (parallel story execution) -------------------------------------------------
171
223
  // Each parallel story gets its own worktree under `<root>/<worktreeDir>/<storyId>` with the story
172
224
  // branch checked out there; the MAIN checkout stays on the target branch and is the only place
@@ -470,7 +522,10 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
470
522
  const onBranch = currentBranch(root);
471
523
 
472
524
  if (!worktree && onBranch === branch) {
473
- return { branch, base: readFlowRecord(root, story)?.base ?? null, created: false, resumed: true };
525
+ const rec = readFlowRecord(root, story);
526
+ const backlogResynced = syncBacklogFromTargetOnResume(root, root, story, rec?.target);
527
+ return { branch, base: rec?.base ?? null, created: false, resumed: true,
528
+ ...(backlogResynced ? { backlogResynced: true } : {}) };
474
529
  }
475
530
 
476
531
  // Leftover dirt belongs to whatever produced it (planning artifacts, a prior crash) — sweep it
@@ -516,7 +571,10 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
516
571
  // when intact, and a half-created worktree (crash between `worktree add` and setup) gets
517
572
  // its missing pieces — setup_commands must therefore be idempotent (documented).
518
573
  const setup = setupWorktree(root, existing, setupCommands);
519
- return { branch, base: readFlowRecord(existing, story)?.base ?? null, worktree: existing, created: false, resumed: true, snapshotSha, ...setup };
574
+ const rec = readFlowRecord(existing, story) || readFlowRecord(root, story);
575
+ const backlogResynced = syncBacklogFromTargetOnResume(existing, root, story, rec?.target);
576
+ return { branch, base: rec?.base ?? null, worktree: existing, created: false, resumed: true, snapshotSha,
577
+ ...(backlogResynced ? { backlogResynced: true } : {}), ...setup };
520
578
  }
521
579
  const creating = !branchExists(root, branch);
522
580
  if (creating) {
@@ -544,6 +602,7 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
544
602
  // a conflict aborts cleanly in the worktree, leaving the branch at its base. Needed for parallel:2.
545
603
  let refreshedFromTarget = false;
546
604
  let refreshConflict = false;
605
+ let backlogCleanlyMergeable = false;
547
606
  if (creating && basedOnStory && branchExists(root, target) && !isMergedInto(root, target, branch)) {
548
607
  try {
549
608
  git(wtPath, ['merge', '--no-ff', '-m',
@@ -553,14 +612,27 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
553
612
  // A conflict ONLY on target-authoritative shared state (the backlog with the filed bug entry)
554
613
  // → resolve toward the target so the entry reaches this branch (M-65/M-69); any real-code
555
614
  // conflict aborts cleanly (setupWorktree re-materializes the install if the sync removed it).
615
+ const conflicted = conflictedPaths(wtPath); // captured BEFORE resolve concludes/abort clears it
556
616
  if (resolveRefreshConflict(wtPath)) {
557
617
  refreshedFromTarget = true;
558
618
  } else {
559
619
  if (mergeInProgress(wtPath)) git(wtPath, ['merge', '--abort']);
560
620
  refreshConflict = true;
621
+ backlogCleanlyMergeable = conflicted.length > 0 && !conflicted.some((p) => REFRESH_TARGET_WINS.has(p));
561
622
  }
562
623
  }
563
624
  }
625
+ // M-219 (worktree variant): the refresh aborted on a non-backlog co-conflict while the backlog itself
626
+ // would have merged clean → deliver ONLY the target backlog blob into the worktree so the filed bug
627
+ // entry is present for `backlog ship` (code stays at base; conflict still surfaced via refreshConflict).
628
+ // Runs in wtPath BEFORE setupWorktree re-materializes the install — the tree is clean at base.
629
+ let backlogDelivered = false;
630
+ if (creating && basedOnStory && refreshConflict && !refreshedFromTarget && backlogCleanlyMergeable && branchExists(root, target)) {
631
+ try {
632
+ backlogDelivered = commitTargetBacklog(wtPath, story, target,
633
+ 'inherit target backlog (filed bug entries) after refresh aborted on a co-conflict — no code merge (M-219)');
634
+ } catch { /* best-effort: leave the branch at its base */ }
635
+ }
564
636
  const setup = setupWorktree(root, wtPath, setupCommands);
565
637
  if (creating) {
566
638
  writeFlowRecord(wtPath, story, {
@@ -580,13 +652,17 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
580
652
  ...(orphanMovedTo ? { orphanMovedTo, warning: `orphaned worktree directory moved aside to ${orphanMovedTo} (crashed run; inspect/delete it manually)` } : {}),
581
653
  ...(refreshedFromTarget ? { refreshedFromTarget: true } : {}),
582
654
  ...(refreshConflict ? { refreshConflict: true } : {}),
655
+ ...(backlogDelivered ? { backlogDelivered: true } : {}),
583
656
  ...setup,
584
657
  };
585
658
  }
586
659
 
587
660
  if (branchExists(root, branch)) {
588
661
  git(root, ['checkout', branch]);
589
- return { branch, base: readFlowRecord(root, story)?.base ?? null, created: false, resumed: true, snapshotSha };
662
+ const rec = readFlowRecord(root, story);
663
+ const backlogResynced = syncBacklogFromTargetOnResume(root, root, story, rec?.target);
664
+ return { branch, base: rec?.base ?? null, created: false, resumed: true, snapshotSha,
665
+ ...(backlogResynced ? { backlogResynced: true } : {}) };
590
666
  }
591
667
  if (!branchExists(root, base)) throw new Error(`base branch "${base}" does not exist`);
592
668
 
@@ -605,6 +681,7 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
605
681
  // than wedging a half-merge that would hard-stop every later commit in this checkout.
606
682
  let refreshedFromTarget = false;
607
683
  let refreshConflict = false;
684
+ let backlogCleanlyMergeable = false;
608
685
  if (basedOnStory && branchExists(root, target) && !isMergedInto(root, target, branch)) {
609
686
  // Capture whether the vendored install is present BEFORE the sync, so the hazard guard below fires
610
687
  // ONLY when the merge actually REMOVED it — not when a repo simply never had a materialized install
@@ -619,11 +696,17 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
619
696
  // bug entry into) is resolved toward the target so the entry reaches this branch (M-65/M-69); a
620
697
  // conflict touching any real code aborts cleanly as before (degraded, but no worse). The M-52
621
698
  // framework-deletion guard below still runs on the concluded merge.
699
+ const conflicted = conflictedPaths(root); // captured BEFORE resolve concludes/abort clears it
622
700
  if (resolveRefreshConflict(root)) {
623
701
  refreshedFromTarget = true;
624
702
  } else {
625
703
  if (mergeInProgress(root)) git(root, ['merge', '--abort']);
626
704
  refreshConflict = true;
705
+ // M-219: deliver the backlog entry below ONLY when the backlog itself did NOT conflict — i.e. the
706
+ // entry would have merged clean but an UNRELATED file's conflict (a project coordination doc, …)
707
+ // aborted the whole merge. A backlog+code divergence is a genuinely diverged branch (recut) —
708
+ // leave it untouched so the negative guard "never swallows a code conflict" holds.
709
+ backlogCleanlyMergeable = conflicted.length > 0 && !conflicted.some((p) => REFRESH_TARGET_WINS.has(p));
627
710
  }
628
711
  }
629
712
  // M-52 transition hazard: when `target` UNTRACKS the vendored framework (the post-untrack install
@@ -645,6 +728,19 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
645
728
  );
646
729
  }
647
730
  }
731
+ // M-219: a refresh that ABORTED (a non-backlog co-conflict — e.g. a project coordination doc whose
732
+ // conflict makes resolveRefreshConflict bail — or the M-52 untrack reset above) rolled the
733
+ // cleanly-mergeable target backlog back with the whole merge, so the disposition's filed bug entry
734
+ // never reached this branch and `backlog ship --story <bug>` would error "not found" (3.5/3.7-BUG-001).
735
+ // The tree is clean at base here (merge --abort / reset --hard ran), so deliver ONLY the target
736
+ // backlog blob directly — code stays at base (the conflict is still surfaced via refreshConflict).
737
+ let backlogDelivered = false;
738
+ if (basedOnStory && refreshConflict && !refreshedFromTarget && backlogCleanlyMergeable && branchExists(root, target)) {
739
+ try {
740
+ backlogDelivered = commitTargetBacklog(root, story, target,
741
+ 'inherit target backlog (filed bug entries) after refresh aborted on a co-conflict — no code merge (M-219)');
742
+ } catch { /* best-effort: leave the branch at its base */ }
743
+ }
648
744
  writeFlowRecord(root, story, {
649
745
  schema: 1,
650
746
  kind: 'git-flow',
@@ -657,7 +753,8 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
657
753
  });
658
754
  return { branch, base, target, created: true, resumed: false, snapshotSha,
659
755
  ...(refreshedFromTarget ? { refreshedFromTarget: true } : {}),
660
- ...(refreshConflict ? { refreshConflict: true } : {}) };
756
+ ...(refreshConflict ? { refreshConflict: true } : {}),
757
+ ...(backlogDelivered ? { backlogDelivered: true } : {}) };
661
758
  }
662
759
 
663
760
  /**