valent-pipeline 0.19.66 → 0.19.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "valent-pipeline",
3
- "version": "0.19.66",
3
+ "version": "0.19.68",
4
4
  "description": "v3 multi-agent AI pipeline for software development lifecycle",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,6 +3,7 @@ import { isAbsolute, join } from 'path';
3
3
  import { checkSpecConformance } from '../lib/spec-conformance.js';
4
4
  import { evidenceDir, latestRun } from '../lib/evidence.js';
5
5
  import { parseJunit } from '../lib/junit.js';
6
+ import { detectManifestCaseDrift } from '../lib/spec-manifest.js';
6
7
 
7
8
  const DEFAULT_TEST_RE = /\.(test|spec)\.(js|ts|jsx|tsx|mjs|cjs)$/;
8
9
  const IGNORE_DIRS = new Set([
@@ -79,6 +80,12 @@ export async function checkSpecConformanceCmd(options) {
79
80
  process.exit(2);
80
81
  }
81
82
 
83
+ const drift = detectManifestCaseDrift(manifest);
84
+ if (drift) {
85
+ console.error(`spec-conformance: ${drift}`);
86
+ process.exit(1);
87
+ }
88
+
82
89
  if (!Array.isArray(manifest?.cases) || manifest.cases.length === 0) {
83
90
  console.log(`spec-conformance: manifest has no cases — skipping (no-op pass).`);
84
91
  process.exit(0);
@@ -11,6 +11,7 @@ import { join, isAbsolute, dirname } from 'path';
11
11
  import { traceCheck, traceMatrix } from '../lib/trace.js';
12
12
  import { evidenceDir, resolveRunForAssert, requiredCaseIdsForRun, resolveRoot } from '../lib/evidence.js';
13
13
  import { parseJunit } from '../lib/junit.js';
14
+ import { detectManifestCaseDrift } from '../lib/spec-manifest.js';
14
15
 
15
16
  /** Load an exact evidence run by id (for `--run`), or null if absent/unreadable. */
16
17
  function loadRunById(evDir, runId) {
@@ -48,6 +49,8 @@ function loadInputs(options) {
48
49
  if (!existsSync(specPath)) return { noop: `no spec manifest at ${specPath} — skipping (no-op pass).` };
49
50
  const reqsManifest = readJson(reqsPath, 'reqs manifest');
50
51
  const specManifest = readJson(specPath, 'spec manifest');
52
+ const drift = detectManifestCaseDrift(specManifest);
53
+ if (drift) return { error: drift };
51
54
  const cases = Array.isArray(specManifest?.cases) ? specManifest.cases : [];
52
55
  if (cases.length > 0 && !cases.some((c) => Array.isArray(c.ac) && c.ac.length > 0)) {
53
56
  return { noop: 'spec manifest predates the per-case `ac` field — skipping (no-op pass). Re-run QA-A to gain mechanical AC coverage.' };
@@ -93,6 +96,10 @@ export async function traceCheckCmd(options) {
93
96
  process.exit(2);
94
97
  }
95
98
  const inputs = loadInputs(options);
99
+ if (inputs.error) {
100
+ console.error(`trace-check: ${inputs.error}`);
101
+ process.exit(1);
102
+ }
96
103
  if (inputs.noop) {
97
104
  console.log(`trace-check: ${inputs.noop}`);
98
105
  process.exit(0);
@@ -108,6 +115,10 @@ export async function traceMatrixCmd(options) {
108
115
  process.exit(2);
109
116
  }
110
117
  const inputs = loadInputs(options);
118
+ if (inputs.error) {
119
+ console.error(`trace-matrix: ${inputs.error}`);
120
+ process.exit(1);
121
+ }
111
122
  if (inputs.noop) {
112
123
  console.log(`trace-matrix: ${inputs.noop}`);
113
124
  process.exit(0);
@@ -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]);
@@ -181,7 +185,6 @@ function isMergedInto(root, branch, target) {
181
185
  // was filed) so a resume never churns an empty commit; gated to inline --from-story fixes (basedOnStory)
182
186
  // whose target genuinely advanced, mirroring the create-path guard.
183
187
  function syncBacklogFromTargetOnResume(gitDir, root, story, target) {
184
- const backlogFile = 'pipeline-backlog.yaml';
185
188
  try {
186
189
  if (!target || !branchExists(root, target)) return false;
187
190
  const rec = readFlowRecord(gitDir, story) || readFlowRecord(root, story);
@@ -189,20 +192,33 @@ function syncBacklogFromTargetOnResume(gitDir, root, story, target) {
189
192
  if (mergeInProgress(gitDir)) return false; // never touch a half-merged tree
190
193
  const branch = rec.branch || currentBranch(gitDir);
191
194
  if (!branch || isMergedInto(root, target, branch)) return false; // target not ahead → nothing to inherit
192
- git(gitDir, ['checkout', target, '--', backlogFile]); // stage the target's canonical backlog blob
193
- const staged = git(gitDir, ['diff', '--cached', '--name-only'])
194
- .split('\n').map((s) => s.trim()).filter(Boolean);
195
- if (!staged.includes(backlogFile)) return false; // already current → no empty commit
196
- git(gitDir, ['commit', '-m',
197
- buildCommitMessage({ storyId: story, phase: 'sync',
198
- summary: `inherit target backlog (filed bug entries) on resume — no code merge, pin intact (M-208)` }),
199
- '--', backlogFile]);
200
- return true;
195
+ return commitTargetBacklog(gitDir, story, target,
196
+ 'inherit target backlog (filed bug entries) on resume — no code merge, pin intact (M-208)');
201
197
  } catch {
202
198
  return false; // best-effort: a sync failure leaves the branch as it was (degraded, not wedged)
203
199
  }
204
200
  }
205
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
+
206
222
  // --- worktree mode (parallel story execution) -------------------------------------------------
207
223
  // Each parallel story gets its own worktree under `<root>/<worktreeDir>/<storyId>` with the story
208
224
  // branch checked out there; the MAIN checkout stays on the target branch and is the only place
@@ -586,6 +602,7 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
586
602
  // a conflict aborts cleanly in the worktree, leaving the branch at its base. Needed for parallel:2.
587
603
  let refreshedFromTarget = false;
588
604
  let refreshConflict = false;
605
+ let backlogCleanlyMergeable = false;
589
606
  if (creating && basedOnStory && branchExists(root, target) && !isMergedInto(root, target, branch)) {
590
607
  try {
591
608
  git(wtPath, ['merge', '--no-ff', '-m',
@@ -595,14 +612,27 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
595
612
  // A conflict ONLY on target-authoritative shared state (the backlog with the filed bug entry)
596
613
  // → resolve toward the target so the entry reaches this branch (M-65/M-69); any real-code
597
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
598
616
  if (resolveRefreshConflict(wtPath)) {
599
617
  refreshedFromTarget = true;
600
618
  } else {
601
619
  if (mergeInProgress(wtPath)) git(wtPath, ['merge', '--abort']);
602
620
  refreshConflict = true;
621
+ backlogCleanlyMergeable = conflicted.length > 0 && !conflicted.some((p) => REFRESH_TARGET_WINS.has(p));
603
622
  }
604
623
  }
605
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
+ }
606
636
  const setup = setupWorktree(root, wtPath, setupCommands);
607
637
  if (creating) {
608
638
  writeFlowRecord(wtPath, story, {
@@ -622,6 +652,7 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
622
652
  ...(orphanMovedTo ? { orphanMovedTo, warning: `orphaned worktree directory moved aside to ${orphanMovedTo} (crashed run; inspect/delete it manually)` } : {}),
623
653
  ...(refreshedFromTarget ? { refreshedFromTarget: true } : {}),
624
654
  ...(refreshConflict ? { refreshConflict: true } : {}),
655
+ ...(backlogDelivered ? { backlogDelivered: true } : {}),
625
656
  ...setup,
626
657
  };
627
658
  }
@@ -650,6 +681,7 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
650
681
  // than wedging a half-merge that would hard-stop every later commit in this checkout.
651
682
  let refreshedFromTarget = false;
652
683
  let refreshConflict = false;
684
+ let backlogCleanlyMergeable = false;
653
685
  if (basedOnStory && branchExists(root, target) && !isMergedInto(root, target, branch)) {
654
686
  // Capture whether the vendored install is present BEFORE the sync, so the hazard guard below fires
655
687
  // ONLY when the merge actually REMOVED it — not when a repo simply never had a materialized install
@@ -664,11 +696,17 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
664
696
  // bug entry into) is resolved toward the target so the entry reaches this branch (M-65/M-69); a
665
697
  // conflict touching any real code aborts cleanly as before (degraded, but no worse). The M-52
666
698
  // framework-deletion guard below still runs on the concluded merge.
699
+ const conflicted = conflictedPaths(root); // captured BEFORE resolve concludes/abort clears it
667
700
  if (resolveRefreshConflict(root)) {
668
701
  refreshedFromTarget = true;
669
702
  } else {
670
703
  if (mergeInProgress(root)) git(root, ['merge', '--abort']);
671
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));
672
710
  }
673
711
  }
674
712
  // M-52 transition hazard: when `target` UNTRACKS the vendored framework (the post-untrack install
@@ -690,6 +728,19 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
690
728
  );
691
729
  }
692
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
+ }
693
744
  writeFlowRecord(root, story, {
694
745
  schema: 1,
695
746
  kind: 'git-flow',
@@ -702,7 +753,8 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
702
753
  });
703
754
  return { branch, base, target, created: true, resumed: false, snapshotSha,
704
755
  ...(refreshedFromTarget ? { refreshedFromTarget: true } : {}),
705
- ...(refreshConflict ? { refreshConflict: true } : {}) };
756
+ ...(refreshConflict ? { refreshConflict: true } : {}),
757
+ ...(backlogDelivered ? { backlogDelivered: true } : {}) };
706
758
  }
707
759
 
708
760
  /**
@@ -0,0 +1,30 @@
1
+ // Shared guard against qa-test-spec.manifest.json schema drift (M-223).
2
+ //
3
+ // The canonical manifest carries its spec'd cases under `cases` (pipeline/schemas/qa-spec-manifest.schema.json,
4
+ // `required: ["schema","story","cases"]`). QA-A is an LLM and can drift the key — e.g. emit `testCases` instead
5
+ // of `cases`. The downstream readers (`trace check`, `check-spec-conformance`) then silently see ZERO cases:
6
+ // trace check reports every AC uncovered (a phantom `uncoveredAcs` coverage gap that reads as a real defect),
7
+ // and spec-conformance takes its empty-manifest no-op PASS (masking the drift entirely). Both cost a confused
8
+ // diagnosis + a re-run (3.9, 2026-06-28). The schema existed but was never enforced at ingestion.
9
+ //
10
+ // This detects the specific, recoverable drift — `cases` absent/empty WHILE a case-like array (objects with a
11
+ // string `id`) sits under another top-level key — and returns an actionable message so the gate fails LOUD with
12
+ // the real reason instead of a phantom coverage gap or a vacuous pass. It deliberately does NOT fire for a
13
+ // genuinely empty/absent `cases` (a pre-Phase manifest or a story with no spec'd cases yet) — that stays a
14
+ // legitimate no-op, so this adds a clear failure ONLY where the manifest is populated-but-misnamed. Returns the
15
+ // message string, or null when there is no drift.
16
+ export function detectManifestCaseDrift(manifest) {
17
+ if (!manifest || typeof manifest !== 'object') return null;
18
+ if (Array.isArray(manifest.cases) && manifest.cases.length > 0) return null; // canonical + populated
19
+
20
+ const caseLike = (arr) => Array.isArray(arr) && arr.length > 0
21
+ && arr.every((c) => c && typeof c === 'object' && typeof c.id === 'string' && c.id.length > 0);
22
+ const aliases = Object.keys(manifest).filter((k) => k !== 'cases' && caseLike(manifest[k]));
23
+ if (!aliases.length) return null; // genuinely empty/absent — legitimate no-op, not drift
24
+
25
+ const k = aliases[0];
26
+ const n = manifest[k].length;
27
+ return `no populated \`cases\` array but found ${n} case-like entr${n === 1 ? 'y' : 'ies'} under \`${k}\` `
28
+ + `— qa-test-spec.manifest.json schema drift (the canonical key is \`cases\`). Re-author the manifest with `
29
+ + `the cases under \`cases\` (see pipeline/schemas/qa-spec-manifest.schema.json).`;
30
+ }