valent-pipeline 0.19.26 → 0.19.28

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/bin/cli.js CHANGED
@@ -596,6 +596,19 @@ program
596
596
  await checkSpecConformanceCmd(options);
597
597
  });
598
598
 
599
+ // check-groom-currency — re-groom guard: is a GROOMED story's spec stale vs active correction-directives?
600
+ program
601
+ .command('check-groom-currency')
602
+ .description('Exit 1 if a GROOMED story\'s spec predates the latest correction-directives change (the sprint then re-specs instead of skip-spec on a stale groomed spec); exit 0 if current. Graceful no-op without git/directives/spec.')
603
+ .requiredOption('--story <id>', 'Story identifier')
604
+ .option('--root <dir>', 'Project root (defaults to the current directory)')
605
+ .option('--manifest <path>', 'Spec manifest path (defaults to stories/<id>/output/qa-test-spec.manifest.json)')
606
+ .option('--directives <path>', 'Correction-directives path (defaults to knowledge/correction-directives.yaml)')
607
+ .action(async (options) => {
608
+ const { checkGroomCurrency } = await import('../src/commands/check-groom-currency.js');
609
+ await checkGroomCurrency(options);
610
+ });
611
+
599
612
  // spec commands — mechanical spec-phase gate (Phase 2: READINESS Step 0 dissolved into code)
600
613
  const specCmd = program
601
614
  .command('spec')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "valent-pipeline",
3
- "version": "0.19.26",
3
+ "version": "0.19.28",
4
4
  "description": "v3 multi-agent AI pipeline for software development lifecycle",
5
5
  "type": "module",
6
6
  "bin": {
@@ -391,6 +391,20 @@ const SPECCHECK_SCHEMA = {
391
391
  },
392
392
  }
393
393
 
394
+ // GROOM-CURRENCY gate return (re-groom guard): is a groomed story's spec stale vs active directives?
395
+ const GROOM_CURRENCY_SCHEMA = {
396
+ type: 'object',
397
+ required: ['schema', 'story', 'stale'],
398
+ additionalProperties: true,
399
+ properties: {
400
+ schema: { const: 1 },
401
+ agent: { type: 'string' },
402
+ story: { type: 'string' },
403
+ stale: { type: 'boolean' },
404
+ reason: { type: 'string' },
405
+ },
406
+ }
407
+
394
408
  // RED gate return (Phase 2 ATDD). `noAcceptance` = the CLI no-op'd (pre-ATDD story); `weakRed` =
395
409
  // the suite could not run pre-implementation and weak_red=allow accepted it (carried to JUDGE);
396
410
  // `prePassingCases` = acceptance cases that PASSED before implementation (spec bugs -> QA-A).
@@ -1975,6 +1989,18 @@ async function runStory(story) {
1975
1989
  // source story's ACs still hold) from the bug's backlog fields and the source story's existing specs,
1976
1990
  // so the normal dev agents have the inputs they read. Then Build -> Critic -> QA -> Judge run as usual.
1977
1991
  // Spec/SpecCheck (REQS/UXA/QA-A + the mechanical gate) are skipped — a bug fixes existing code, it is not re-specced.
1992
+
1993
+ // Re-groom guard (2026-06-17): if a correction-directive landed SINCE this groomed story was specced
1994
+ // (e.g. CD-11-1's "infra-invariant ACs are required:false" rule), its on-disk spec predates the
1995
+ // directive and is stale — fall through to the full Spec path instead of the skip-spec-on-groomed
1996
+ // shortcut, so the new directive actually shapes the spec. Cheap deterministic check; fails open.
1997
+ let specStale = false
1998
+ if (groomed && itemType !== 'bug') {
1999
+ const currency = await runGroomCurrencyGate(storyId)
2000
+ specStale = currency.stale === true
2001
+ if (specStale) log(`${storyId}: groomed spec is STALE vs active directives (${currency.reason}) — re-speccing`)
2002
+ }
2003
+
1978
2004
  if (itemType === 'bug') {
1979
2005
  phase('Spec')
1980
2006
  log(`${storyId}: bug-fix item — running bug-intake (minimal brief + regression spec), skipping full Spec + Readiness`)
@@ -1989,7 +2015,7 @@ async function runStory(story) {
1989
2015
  `that context write a MINIMAL reqs-brief.md (the fix scope) and qa-test-spec.md (a regression proving the ` +
1990
2016
  `defect is gone plus the affected ACs still pass) into this item's output dir. Do NOT re-spec the whole story.`,
1991
2017
  { label: `bug-intake:${storyId}`, phase: 'Spec', model: modelFor('REQS') })
1992
- } else if (groomed) {
2018
+ } else if (groomed && !specStale) {
1993
2019
  // Spec + Readiness are SKIPPED for pre-groomed stories. plan.workflow.js already specced them
1994
2020
  // (reqs-brief/uxa-spec/qa-test-spec are on disk) and passed the spec gate at planning time,
1995
2021
  // so re-running here is duplicated token spend. The standalone single-story path leaves `groomed`
@@ -2529,6 +2555,24 @@ async function runStory(story) {
2529
2555
  // re-derivation: the spec is a derivation chain (REQS -> UXA -> QA-A); reworking upstream
2530
2556
  // without re-deriving downstream guarantees the next cycle re-rejects on staleness. Cap-trip
2531
2557
  // throws — a story whose spec artifacts cannot be completed must not reach Build.
2558
+ // Re-groom guard helper: run the deterministic groom-currency CLI (git commit-time of the spec vs
2559
+ // correction-directives.yaml) and return {stale, reason}. Fails open (stale:false) if it can't run,
2560
+ // so a missing/unavailable check never forces a re-spec we can't justify.
2561
+ async function runGroomCurrencyGate(sid) {
2562
+ const v = await agent(
2563
+ [
2564
+ `You are **GROOM-CURRENCY**, a mechanical check for story ${sid} in the valent-pipeline.`,
2565
+ `Run this ONE command from ${runDirPhrase(sid)}, capturing its exit code and last stdout line (JSON):`,
2566
+ ` node .valent-pipeline/bin/cli.js check-groom-currency --story ${sid}`,
2567
+ `Exit 1 = the groomed spec is STALE vs active correction-directives; exit 0 = current. Transcribe, never interpret.`,
2568
+ `Return ONLY { schema:1, agent:"groom-currency", story:"${sid}", stale:boolean, reason:string } as JSON ` +
2569
+ `(stale=true iff the command exited 1; copy the command's JSON "reason").`,
2570
+ ].join('\n'),
2571
+ { label: `gate:groom-currency:${sid}`, phase: 'Spec', schema: GROOM_CURRENCY_SCHEMA, model: modelFor('SPECCHECK') },
2572
+ )
2573
+ return v && typeof v.stale === 'boolean' ? v : { stale: false, reason: 'currency check unavailable — treated as current' }
2574
+ }
2575
+
2532
2576
  async function runSpecCheckGate(sid) {
2533
2577
  // Full trail: spec artifacts land as `docs({id}): …` before the mechanical checks — and each
2534
2578
  // spec-rework cycle re-commits (the commit is a no-op on an already-clean tree).
@@ -0,0 +1,64 @@
1
+ import { existsSync } from 'fs';
2
+ import { isAbsolute, join } from 'path';
3
+ import { execFileSync } from 'child_process';
4
+
5
+ function resolveRoot(options) {
6
+ const r = options.root || process.cwd();
7
+ return isAbsolute(r) ? r : join(process.cwd(), r);
8
+ }
9
+
10
+ /** Unix commit-time of the last commit touching `relPath`, or null if uncommitted / git unavailable. */
11
+ function lastCommitTime(root, relPath) {
12
+ try {
13
+ const out = execFileSync('git', ['log', '-1', '--format=%ct', '--', relPath], { cwd: root, encoding: 'utf-8' }).trim();
14
+ const t = parseInt(out, 10);
15
+ return Number.isFinite(t) ? t : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * `valent-pipeline check-groom-currency --story <id> [--root <dir>] [--manifest <path>] [--directives <path>]`
23
+ *
24
+ * Is a GROOMED story's spec still CURRENT with the active correction-directives? The sprint skips the
25
+ * Spec phase for a story plan-time grooming already specced — a token win — but a directive that
26
+ * landed AFTER grooming (canonical: CD-11-1's "infra-invariant ACs are required:false" rule) never
27
+ * shaped that on-disk spec, so the skip silently reuses a STALE spec. (Story 2.1 hit exactly this: its
28
+ * spec, groomed before CD-11-1, marked infra invariants required:true and the ATDD red gate rejected
29
+ * it for premature-pass — a manual forced re-groom was the workaround.)
30
+ *
31
+ * STALE iff `correction-directives.yaml` was committed AFTER the story's spec manifest. Prints a JSON
32
+ * verdict and exits 1 when STALE (the sprint workflow then re-specs), 0 when FRESH. Graceful no-op
33
+ * (FRESH / exit 0) when there are no directives, no committed spec, or no git — never forces a
34
+ * re-groom on missing data, so legacy / non-git projects are unaffected.
35
+ */
36
+ export async function checkGroomCurrency(options) {
37
+ const root = resolveRoot(options);
38
+ const story = options.story;
39
+ if (!story) {
40
+ console.error('check-groom-currency: --story is required.');
41
+ process.exit(2);
42
+ }
43
+ const specRel = options.manifest || join('stories', story, 'output', 'qa-test-spec.manifest.json');
44
+ const dirRel = options.directives || join('knowledge', 'correction-directives.yaml');
45
+ const specAbs = isAbsolute(specRel) ? specRel : join(root, specRel);
46
+ const dirAbs = isAbsolute(dirRel) ? dirRel : join(root, dirRel);
47
+
48
+ if (!existsSync(dirAbs) || !existsSync(specAbs)) {
49
+ console.log(JSON.stringify({ ok: true, stale: false, reason: 'no active directives or no spec manifest on disk — nothing to re-groom against' }));
50
+ process.exit(0);
51
+ }
52
+
53
+ const specCt = lastCommitTime(root, specRel);
54
+ const dirCt = lastCommitTime(root, dirRel);
55
+ // Fail SAFE on missing git times: treat as fresh (do not force a re-groom we cannot justify).
56
+ const stale = !!(specCt && dirCt && dirCt > specCt);
57
+ const reason = stale
58
+ ? `correction-directives.yaml was committed (${dirCt}) AFTER the spec manifest (${specCt}) — a directive landed since grooming; re-spec so it applies`
59
+ : (specCt && dirCt
60
+ ? `spec manifest is at or after the latest directive change (spec ${specCt} >= directives ${dirCt}) — current`
61
+ : 'spec or directives uncommitted — treating as current (no re-groom forced on missing git data)');
62
+ console.log(JSON.stringify({ ok: true, stale, reason, specCommit: specCt, directivesCommit: dirCt }));
63
+ process.exit(stale ? 1 : 0);
64
+ }
@@ -17,7 +17,7 @@ import { randomBytes } from 'crypto';
17
17
  import { parse as parseYaml } from 'yaml';
18
18
  import {
19
19
  runEvidence, pinGate, formatRunTrailer, formatRunDigest, resolveRoot, evidenceDir, latestRun, latestPins,
20
- verifyIntegrity, assertRules, assertRedRules, snapshotFiles, buildProof, utcCompact,
20
+ verifyIntegrity, assertRules, assertRedRules, pinSourceDelta, snapshotFiles, buildProof, utcCompact,
21
21
  latestAssert, collectFailingCases, authenticateBaseline, verifyShipEvidence, sha256Hex,
22
22
  validStoryId, recordStageResults,
23
23
  } from '../lib/evidence.js';
@@ -342,6 +342,19 @@ export async function evidenceAssertCmd(options) {
342
342
  .map((s) => s.trim())
343
343
  .filter(Boolean);
344
344
 
345
+ // Pin coherence v3 (2026-06-17): a pin/tested SHA mismatch is incoherent only if SOURCE moved
346
+ // between the pin and the tested commit — compute the per-gate source delta here (git IO) so
347
+ // assertRules stays pure. A bookkeeping-only commit (start-story snapshot, board/run state, a
348
+ // re-pin) leaves the reviewed code intact and keeps the pin coherent.
349
+ const pinsForCheck = latestPins(evDir);
350
+ const pinDelta = {};
351
+ for (const gate of requirePins) {
352
+ const pin = pinsForCheck instanceof Map ? pinsForCheck.get(gate) : pinsForCheck?.[gate];
353
+ if (pin && run.record.git && pin.sha !== run.record.git.sha) {
354
+ pinDelta[gate] = pinSourceDelta({ root, fromSha: pin.sha, toSha: run.record.git.sha });
355
+ }
356
+ }
357
+
345
358
  const result = assertRules({
346
359
  record: run.record,
347
360
  parsedReports,
@@ -349,11 +362,12 @@ export async function evidenceAssertCmd(options) {
349
362
  manifestCases,
350
363
  baseline,
351
364
  baselineAuth,
352
- pins: latestPins(evDir),
365
+ pins: pinsForCheck,
353
366
  requirePins,
354
367
  allowDirty: !options.strictDirty,
355
368
  redBaseline,
356
369
  frozenSpecForced: !!options.redBaseline,
370
+ pinDelta,
357
371
  });
358
372
 
359
373
  const verdictRecord = {
Binary file
@@ -177,6 +177,11 @@ const STATE_EXCLUDE_PATTERNS = [
177
177
  // is refused ("local changes would be overwritten"). A transient local pointer has no business in
178
178
  // git history; keep it on disk, out of git. (Root cause of a 3rd Sprint-1 zero-ship, 2026-06-13.)
179
179
  '.valent-pipeline/run-state.json',
180
+ // Resolve snapshots + scratch the orchestrator/skills drop next to a run. start-story's pre-branch
181
+ // snapshot used to sweep these onto the integration branch, creating no-source-delta commits that
182
+ // false-rejected pin coherence AND contaminated the next story's base (2026-06-17). Off git.
183
+ '.resolve-snapshot.json',
184
+ 'scratch/',
180
185
  ];
181
186
 
182
187
  /**
@@ -73,8 +73,10 @@ function stripLineComment(line) {
73
73
  }
74
74
 
75
75
  /**
76
- * Scan one story test file for un-waived skip/only/todo markers. A waiver is `// valent-waiver: ...`
77
- * on the same line or the line immediately above the marker.
76
+ * Scan one story test file for un-waived skip/only/todo markers. A waiver is `valent-waiver:` on the
77
+ * marker's own line (including embedded in an it.skip/describe.skip title) or in the comment/decorator
78
+ * block immediately preceding it (up to 3 lines up — multi-line reasons, a wrapped decorator). Waivers
79
+ * sit on or above the marker by convention, so the window only looks back, never forward.
78
80
  * @returns {Array<{kind:'unwaived-skip', file:string, line:number, detail:string}>}
79
81
  */
80
82
  function scanSkips(file) {
@@ -86,7 +88,7 @@ function scanSkips(file) {
86
88
  SKIP_MARKER.lastIndex = 0;
87
89
  const m = code.match(SKIP_MARKER);
88
90
  if (!m) continue;
89
- const waived = WAIVER_RE.test(raw) || (i > 0 && WAIVER_RE.test(lines[i - 1]));
91
+ const waived = WAIVER_RE.test(raw) || lines.slice(Math.max(0, i - 3), i).some((l) => WAIVER_RE.test(l));
90
92
  if (waived) continue;
91
93
  out.push({
92
94
  kind: 'unwaived-skip',