valent-pipeline 0.19.46 → 0.19.48

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.46",
3
+ "version": "0.19.48",
4
4
  "description": "v3 multi-agent AI pipeline for software development lifecycle",
5
5
  "type": "module",
6
6
  "bin": {
@@ -661,11 +661,44 @@ if (packGateCommands.length) {
661
661
 
662
662
  phase('Pack')
663
663
  // Deterministic greedy packing happens in code (src/lib/sprint.js), invoked via the CLI.
664
- const pack = await agent(
664
+ let pack = await agent(
665
665
  `Run exactly: \`node .valent-pipeline/bin/cli.js sprint-pack --velocity ${packVelocity} --backlog ${backlogPath}\` ` +
666
666
  `in the project root and return its stdout JSON verbatim (fields: sprint_stories, buffer_story_ids, points_planned, remaining_capacity, over_budget).`,
667
667
  { label: 'sprint-pack', phase: 'Pack', schema: PACK_SCHEMA, model: modelFor('PACK') },
668
668
  )
669
+ // Pack-transcription integrity gate (M-51). The agent above transcribes the sprint-pack CLI's stdout
670
+ // (this sandboxed Workflow script can't run a CLI itself). That transcription has corrupted the result
671
+ // twice in the field — an empty sprint_stories paired with a non-zero points_planned (19, then 9) —
672
+ // making the planner think the deterministic packer deadlocked and hand-launch a story over budget. It
673
+ // never does: points_planned is the SUM of the packed stories' points and the anti-stall guard
674
+ // force-adds the top story when nothing fits (src/lib/sprint.js → packSprint), so an empty pack ALWAYS
675
+ // reports 0. A non-zero total with no stories is an impossible packer output = a dropped-stories
676
+ // transcription. Re-run once verbatim; a second failure is a hard stop (a phantom-empty sprint silently
677
+ // strands all ready work — far worse than a loud planning error). Mirrors
678
+ // src/lib/sprint.js#packTranscriptionIssue (the unit-tested canonical check), kept in sync by hand
679
+ // because a Workflow script cannot import it.
680
+ const packInconsistent = (p) => {
681
+ if (!p || !Array.isArray(p.sprint_stories) || typeof p.points_planned !== 'number') {
682
+ return 'pack result is malformed (sprint_stories must be an array, points_planned a number)'
683
+ }
684
+ if (p.sprint_stories.length === 0 && p.points_planned !== 0) {
685
+ return `empty sprint_stories but points_planned=${p.points_planned} — an empty pack sums to 0, so the transcription dropped the packed stories`
686
+ }
687
+ return null
688
+ }
689
+ let packIssue = packInconsistent(pack)
690
+ if (packIssue) {
691
+ log(`⚠ sprint-pack transcription inconsistent (${packIssue}) — re-running the CLI verbatim`)
692
+ pack = await agent(
693
+ `Your previous answer was INVALID: ${packIssue}. Re-run EXACTLY: \`node .valent-pipeline/bin/cli.js sprint-pack --velocity ${packVelocity} --backlog ${backlogPath}\` ` +
694
+ `in the project root and copy its stdout JSON byte-for-byte — do NOT summarize, re-order, drop stories, or alter any number. Fields: sprint_stories, buffer_story_ids, points_planned, remaining_capacity, over_budget.`,
695
+ { label: 'sprint-pack-retry', phase: 'Pack', schema: PACK_SCHEMA, model: modelFor('PACK') },
696
+ )
697
+ packIssue = packInconsistent(pack)
698
+ if (packIssue) {
699
+ throw new Error(`sprint-pack transcription stayed inconsistent after a verbatim re-run: ${packIssue}. Raw: ${JSON.stringify(pack)}. The deterministic packer (src/lib/sprint.js) never emits this — re-run plan or inspect the CLI output directly.`)
700
+ }
701
+ }
669
702
  log(`packed ${pack.sprint_stories.length} stories (${pack.points_planned} pts) at capacity ${packVelocity} (velocity ${velocity} × ${velocityGrowthFactor} stretch); buffer: ${pack.buffer_story_ids.length}`)
670
703
  if (pack.over_budget) {
671
704
  log(`⚠ sprint ${sprintId} is OVER BUDGET: the highest-priority story exceeds the packing capacity ${packVelocity} (velocity ${velocity} × ${velocityGrowthFactor}) and was planned alone — consider splitting it (${pack.points_planned} pts vs ${packVelocity} capacity)`)
@@ -524,6 +524,10 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
524
524
  let refreshedFromTarget = false;
525
525
  let refreshConflict = false;
526
526
  if (basedOnStory && branchExists(root, target) && !isMergedInto(root, target, branch)) {
527
+ // Capture whether the vendored install is present BEFORE the sync, so the hazard guard below fires
528
+ // ONLY when the merge actually REMOVED it — not when a repo simply never had a materialized install
529
+ // (e.g. a bare test fixture), which must refresh normally.
530
+ const hadFrameworkInstall = existsSync(join(root, '.valent-pipeline', 'bin', 'cli.js'));
527
531
  try {
528
532
  git(root, ['merge', '--no-ff', '-m',
529
533
  buildCommitMessage({ storyId: story, phase: 'sync', summary: `sync ${target} into ${branch} (inherit shared state from a behind base)` }), target]);
@@ -532,6 +536,24 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
532
536
  if (mergeInProgress(root)) git(root, ['merge', '--abort']);
533
537
  refreshConflict = true;
534
538
  }
539
+ // M-52 transition hazard: when `target` UNTRACKS the vendored framework (the post-untrack install
540
+ // model) while THIS branch still TRACKS it (a pre-untrack branch), the merge above applied target's
541
+ // index-deletion to the WORKING TREE and removed the on-disk install — `.valent-pipeline/bin/cli.js`
542
+ // is gone, so the next `node .valent-pipeline/bin/cli.js` call would fail mid-sprint. Such a branch
543
+ // cannot be refreshed in place (keeping the merge loses the install; reverting it restores a STALE
544
+ // framework — the very thing untracking fixed), so undo the sync and surface it like a conflict: the
545
+ // caller must recut the branch FRESH off the untracked target. (The worktree create-path self-heals
546
+ // via setupWorktree's re-materialize; this serial path has no such restore.)
547
+ if (refreshedFromTarget && hadFrameworkInstall && !existsSync(join(root, '.valent-pipeline', 'bin', 'cli.js'))) {
548
+ git(root, ['reset', '--hard', 'ORIG_HEAD']);
549
+ refreshedFromTarget = false;
550
+ refreshConflict = true;
551
+ console.error(
552
+ `git-flow: sync of ${target} into ${branch} would DELETE the vendored framework install ` +
553
+ `(${target} untracks .valent-pipeline/ but ${branch} still tracks it — M-52 untrack transition). ` +
554
+ `Skipped the sync; ${branch} left at its base — recut it FRESH off ${target} instead of --from-story.`,
555
+ );
556
+ }
535
557
  }
536
558
  writeFlowRecord(root, story, {
537
559
  schema: 1,
package/src/lib/sprint.js CHANGED
@@ -281,6 +281,53 @@ export function packSprint(stories, velocity, opts = {}) {
281
281
  };
282
282
  }
283
283
 
284
+ /**
285
+ * Pack-transcription integrity gate (M-51). The PACK workflow step can't run a CLI itself (a
286
+ * Workflow script has no fs/CLI access), so an LLM agent runs `sprint-pack` and transcribes its
287
+ * stdout JSON. That transcription has corrupted the result twice in the field — an empty
288
+ * `sprint_stories` paired with a non-zero `points_planned` (19, then 9) — which made the planner
289
+ * believe the deterministic packer deadlocked and hand-launch a story over budget. It never did:
290
+ * `points_planned` is DEFINED as the sum of the packed stories' points (see packSprint), and the
291
+ * anti-stall guard force-adds the top story when nothing fits, so an empty pack ALWAYS reports 0.
292
+ * This checks those packer INVARIANTS against a (possibly transcribed) result and returns a
293
+ * human-readable reason when one is violated, else null — so the caller can re-run the transcription
294
+ * before trusting it. Invariant-based, so a legitimately empty pack (no packable work → 0 points)
295
+ * passes; only an internally inconsistent result fails (no false positives).
296
+ *
297
+ * NOTE: the PLAN orchestrator (plan.workflow.js) is a sandboxed Workflow script that cannot import
298
+ * this module, so it inlines the empty⟺0 check; keep the two in sync. This exported helper is the
299
+ * canonical, unit-tested spec of the invariant (and is reusable by any non-sandboxed caller).
300
+ *
301
+ * @param {{sprint_stories?: string[], points_planned?: number}} pack a pack result (CLI or transcribed)
302
+ * @param {{pointsById?: Map<string, number>}} [opts] known story→points; when EVERY packed id is
303
+ * present the exact sum is asserted too (skipped otherwise — a buffer story's points may be out of
304
+ * the caller's context, and a partial sum would false-flag).
305
+ * @returns {string|null} why the result violates a packer invariant, or null if consistent
306
+ */
307
+ export function packTranscriptionIssue(pack, opts = {}) {
308
+ if (!pack || typeof pack !== 'object') return 'pack result is not an object';
309
+ const ids = pack.sprint_stories;
310
+ if (!Array.isArray(ids)) return 'sprint_stories is not an array';
311
+ const pts = pack.points_planned;
312
+ if (typeof pts !== 'number' || !Number.isFinite(pts) || pts < 0) {
313
+ return `points_planned must be a non-negative number, got ${JSON.stringify(pts)}`;
314
+ }
315
+ // Invariant: points_planned is a SUM over the packed stories → an empty pack reports exactly 0.
316
+ // (The reverse — non-empty with 0 points — is NOT flagged: a 0-point story is conceivable.)
317
+ if (ids.length === 0 && pts !== 0) {
318
+ return `empty sprint_stories but points_planned=${pts} — an empty pack sums to 0, so the transcription dropped the packed stories`;
319
+ }
320
+ // Invariant (full knowledge only): the total equals the recomputed sum of the packed stories.
321
+ const { pointsById } = opts;
322
+ if (pointsById instanceof Map && ids.length > 0 && ids.every((id) => pointsById.has(id))) {
323
+ const sum = ids.reduce((acc, id) => acc + (Number(pointsById.get(id)) || 0), 0);
324
+ if (sum !== pts) {
325
+ return `points_planned=${pts} but the packed stories sum to ${sum} (${ids.join(', ')}) — the transcription altered the total`;
326
+ }
327
+ }
328
+ return null;
329
+ }
330
+
284
331
  /**
285
332
  * Cross-epic candidate eligibility for the next sprint — which pending backlog items can be
286
333
  * groomed now. A deterministic replacement for the hand-run "collect pending stories whose