valent-pipeline 0.19.27 → 0.19.29

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.27",
3
+ "version": "0.19.29",
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
+ }
Binary file