valent-pipeline 0.19.45 → 0.19.47
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
|
@@ -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
|
-
|
|
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)`)
|
package/src/commands/init.js
CHANGED
|
@@ -2,7 +2,7 @@ import { fileURLToPath } from 'url';
|
|
|
2
2
|
import { dirname, join, resolve } from 'path';
|
|
3
3
|
import { existsSync, readFileSync, rmSync } from 'fs';
|
|
4
4
|
import { defaults } from '../lib/config-schema.js';
|
|
5
|
-
import { copyDir, writeFileSafe, appendToGitignore, fileExists } from '../lib/file-ops.js';
|
|
5
|
+
import { copyDir, writeFileSafe, appendToGitignore, writeFrameworkGitignore, fileExists } from '../lib/file-ops.js';
|
|
6
6
|
|
|
7
7
|
const __filename = fileURLToPath(import.meta.url);
|
|
8
8
|
const __dirname = dirname(__filename);
|
|
@@ -225,12 +225,16 @@ export async function init(options = {}) {
|
|
|
225
225
|
writeFileSafe(claudeSettingsPath, JSON.stringify(claudeSettings, null, 2) + '\n');
|
|
226
226
|
console.log(` Configured .claude/settings.json (agent teams; pre-approved MCP: ${[...approved].join(', ') || 'none'})`);
|
|
227
227
|
|
|
228
|
-
// 9. Update .gitignore
|
|
228
|
+
// 9. Update .gitignore. The vendored framework is an untracked INSTALL (see writeFrameworkGitignore):
|
|
229
|
+
// a `.valent-pipeline/.gitignore` keeps the framework code out of git so `upgrade` applies repo-wide
|
|
230
|
+
// and a reused story branch can never run a stale committed framework (M-52 Finding 1). The
|
|
231
|
+
// root-.gitignore lines below are belt-and-suspenders for the runtime artifacts.
|
|
232
|
+
writeFrameworkGitignore(projectRoot);
|
|
229
233
|
appendToGitignore(projectRoot, '.valent-pipeline/pipeline.db');
|
|
230
234
|
appendToGitignore(projectRoot, '.valent-pipeline/pipeline.db-*');
|
|
231
235
|
if (usesChroma) appendToGitignore(projectRoot, '.valent-pipeline/chromadb-data/');
|
|
232
236
|
appendToGitignore(projectRoot, '.valent-pipeline/node_modules/');
|
|
233
|
-
console.log(' Updated .gitignore');
|
|
237
|
+
console.log(' Updated .gitignore (framework vendored as an untracked install)');
|
|
234
238
|
|
|
235
239
|
// 9. Write version file (pkg was read in step 7)
|
|
236
240
|
writeFileSafe(join(projectRoot, '.valent-pipeline', '.valent-version'), pkg.version);
|
package/src/commands/upgrade.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { fileURLToPath } from 'url';
|
|
2
2
|
import { dirname, join, resolve } from 'path';
|
|
3
3
|
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, copyFileSync, rmSync } from 'fs';
|
|
4
|
-
import {
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import { copyDir, writeFileSafe, writeFrameworkGitignore, fileExists, readFile } from '../lib/file-ops.js';
|
|
5
6
|
import { obsoletePaths } from '../lib/obsolete-manifest.js';
|
|
6
7
|
|
|
7
8
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -215,6 +216,26 @@ export async function upgrade(options = {}) {
|
|
|
215
216
|
writeFileSafe(versionFile, packageVersion);
|
|
216
217
|
console.log(`Updated .valent-pipeline/.valent-version to ${packageVersion}`);
|
|
217
218
|
|
|
219
|
+
// Vendor the framework as an untracked INSTALL (M-52 Finding 1): write .valent-pipeline/.gitignore so
|
|
220
|
+
// the framework lives only in the working tree — `upgrade` then applies repo-wide and a reused story
|
|
221
|
+
// branch can never run a stale committed framework. If the framework is still git-TRACKED here, print
|
|
222
|
+
// the one-time untrack transition (we never touch the user's git index ourselves).
|
|
223
|
+
writeFrameworkGitignore(projectRoot);
|
|
224
|
+
let frameworkTracked = false;
|
|
225
|
+
try {
|
|
226
|
+
execSync('git ls-files --error-unmatch .valent-pipeline/.valent-version', { cwd: projectRoot, stdio: 'ignore' });
|
|
227
|
+
frameworkTracked = true;
|
|
228
|
+
} catch { /* not a git repo, or already untracked — no transition needed */ }
|
|
229
|
+
if (frameworkTracked) {
|
|
230
|
+
console.log('\n⚠ The vendored framework is still git-TRACKED, so a reused/old story branch will run its');
|
|
231
|
+
console.log(' STALE committed framework (not this upgrade). To finish the install→untracked transition');
|
|
232
|
+
console.log(' ONCE, at a clean boundary, run from the repo root:');
|
|
233
|
+
console.log(' git rm -r --cached --quiet .valent-pipeline && git add .valent-pipeline \\');
|
|
234
|
+
console.log(' && git commit -m "chore: untrack vendored framework — install not source"');
|
|
235
|
+
console.log(' (git add re-adds ONLY pipeline-config.yaml + knowledge/ + .gitignore per the new ignore;');
|
|
236
|
+
console.log(' old branches then need a fresh recut off the transitioned target to pick up the install.)');
|
|
237
|
+
}
|
|
238
|
+
|
|
218
239
|
console.log('\nUpgrade complete.');
|
|
219
240
|
}
|
|
220
241
|
|
package/src/lib/file-ops.js
CHANGED
|
@@ -43,6 +43,34 @@ export function appendToGitignore(projectRoot, line) {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// The vendored framework is an INSTALL (re-vendored by `valent-pipeline upgrade`), not source. Keeping
|
|
47
|
+
// it git-tracked meant a REUSED story branch ran whatever framework version it was last committed at
|
|
48
|
+
// (a bug-fix re-run silently executed a stale framework — HARNESS-GAPS 2026-06-19 M-52 Finding 1).
|
|
49
|
+
// This `.valent-pipeline/.gitignore` makes it untracked, so the install lives only in the working tree:
|
|
50
|
+
// gitignored files persist across ALL branch checkouts, one `upgrade` applies repo-wide, and no branch
|
|
51
|
+
// can revert it. `/*` is anchored (direct children only — future-proof to new framework dirs); only
|
|
52
|
+
// project STATE is re-included. KEEP IN SYNC with upgrade.js PROTECTED_FILES.
|
|
53
|
+
export const FRAMEWORK_GITIGNORE = `# valent-pipeline vendored framework — an INSTALL (re-vendored by \`valent-pipeline upgrade\`), not source.
|
|
54
|
+
# Kept UNTRACKED so an upgrade applies repo-wide and a reused story branch never runs a stale committed
|
|
55
|
+
# framework. Only project STATE stays tracked (pipeline-config.yaml, knowledge/).
|
|
56
|
+
/*
|
|
57
|
+
!/.gitignore
|
|
58
|
+
!/pipeline-config.yaml
|
|
59
|
+
!/knowledge/
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Write `.valent-pipeline/.gitignore` so the vendored framework is an untracked install. Idempotent —
|
|
64
|
+
* rewrites to the canonical content. Returns true if it created or changed the file.
|
|
65
|
+
*/
|
|
66
|
+
export function writeFrameworkGitignore(projectRoot) {
|
|
67
|
+
const p = join(projectRoot, '.valent-pipeline', '.gitignore');
|
|
68
|
+
const existing = existsSync(p) ? readFileSync(p, 'utf-8') : null;
|
|
69
|
+
if (existing === FRAMEWORK_GITIGNORE) return false;
|
|
70
|
+
writeFileSync(p, FRAMEWORK_GITIGNORE, 'utf-8');
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
46
74
|
/**
|
|
47
75
|
* Check if a file exists.
|
|
48
76
|
*/
|
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
|