wogiflow 2.7.1 → 2.9.0

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.
@@ -721,6 +721,80 @@ function testDiscoveryGate(ctx) {
721
721
  }
722
722
  }
723
723
 
724
+ /**
725
+ * Verification proof gate — checks that completed acceptance criteria
726
+ * have verification evidence recorded in the durable session.
727
+ *
728
+ * Without this, agents can mark criteria as "completed" without any
729
+ * behavioral evidence that the feature actually works.
730
+ *
731
+ * Checks durable-session.json for verificationProof on each completed step.
732
+ * Steps without proof are flagged. If ALL steps lack proof, gate blocks.
733
+ * If SOME steps have proof, gate warns (partial evidence).
734
+ */
735
+ function verificationProofGate(ctx) {
736
+ let loadDurableSession;
737
+ try {
738
+ ({ loadDurableSession } = require('./flow-durable-session'));
739
+ } catch (_err) {
740
+ ctx.warn('verificationProof (durable session module not available)');
741
+ return { passed: true };
742
+ }
743
+
744
+ try {
745
+ const session = loadDurableSession();
746
+ if (!session || !session.taskId) {
747
+ // No durable session — graceful fallback for tasks created before this gate existed
748
+ console.log(` ${ctx.color('yellow', '\u25CB')} verificationProof (no durable session — skipping)`);
749
+ return { passed: true };
750
+ }
751
+
752
+ // Only check acceptance criteria steps, not system steps.
753
+ // Normalize step type to handle both kebab-case and snake_case variants.
754
+ const normalizeStepType = (type) => (type || '').toLowerCase().replace(/_/g, '-');
755
+ const criteriaSteps = (session.steps || []).filter(s =>
756
+ s.status === 'completed' && normalizeStepType(s.type) === 'acceptance-criteria'
757
+ );
758
+
759
+ if (criteriaSteps.length === 0) {
760
+ console.log(` ${ctx.color('yellow', '\u25CB')} verificationProof (no completed criteria — skipping)`);
761
+ return { passed: true };
762
+ }
763
+
764
+ const unverified = criteriaSteps.filter(s => !s.verificationProof);
765
+ const verified = criteriaSteps.length - unverified.length;
766
+
767
+ if (unverified.length === 0) {
768
+ ctx.success(`verificationProof (${verified}/${criteriaSteps.length} criteria have evidence)`);
769
+ return { passed: true };
770
+ }
771
+
772
+ // If ALL criteria lack proof → hard block
773
+ if (verified === 0) {
774
+ ctx.error(`verificationProof (0/${criteriaSteps.length} criteria have verification evidence)`);
775
+ for (const s of unverified.slice(0, 5)) {
776
+ console.log(ctx.color('dim', ` - ${(s.description || s.title || s.id || '').substring(0, 100)}`));
777
+ }
778
+ console.log(ctx.color('dim', ' Run runtime verification or provide behavioral evidence for each criterion.'));
779
+ return {
780
+ passed: false,
781
+ errorOutput: `${unverified.length} acceptance criteria completed without verification proof. ` +
782
+ 'Each criterion needs behavioral evidence (WebMCP, Playwright, curl, or manual checklist).'
783
+ };
784
+ }
785
+
786
+ // Partial proof — warn but allow (transitional)
787
+ console.log(` ${ctx.color('yellow', '\u25CB')} verificationProof (${verified}/${criteriaSteps.length} verified — ${unverified.length} missing proof)`);
788
+ for (const s of unverified.slice(0, 3)) {
789
+ console.log(ctx.color('dim', ` - Missing: ${(s.description || s.title || s.id || '').substring(0, 80)}`));
790
+ }
791
+ return { passed: true };
792
+ } catch (err) {
793
+ ctx.warn(`verificationProof (error: ${ctx.truncateOutput(err.message, 3, 200)})`);
794
+ return { passed: true };
795
+ }
796
+ }
797
+
724
798
  function unknownGate(ctx, gateName) {
725
799
  console.log(` ${ctx.color('yellow', '\u25CB')} ${gateName} (manual check)`);
726
800
  return { passed: true };
@@ -804,6 +878,7 @@ const GATE_REGISTRY = {
804
878
  uiVerification: verificationGate,
805
879
  apiVerification: verificationGate,
806
880
  testDiscovery: testDiscoveryGate,
881
+ verificationProof: verificationProofGate,
807
882
  // Workspace gates (conditional — auto-skip when not in workspace)
808
883
  workspaceCompliance: workspaceGate,
809
884
  };
@@ -851,6 +926,7 @@ module.exports = {
851
926
  generatedTestsPassGate,
852
927
  verificationGate,
853
928
  testDiscoveryGate,
929
+ verificationProofGate,
854
930
  workspaceGate,
855
931
  unknownGate,
856
932
  };
@@ -557,6 +557,20 @@ async function main() {
557
557
  process.exit(1);
558
558
  }
559
559
 
560
+ // Write gate latch — proves quality gates passed for this task.
561
+ // The TaskCompleted hook checks this latch before allowing completion.
562
+ // Without it, agents can call TaskUpdate and bypass all gates.
563
+ try {
564
+ const { setGateLatch } = require('./flow-gate-latch');
565
+ const gates = getConfig().qualityGates?.[taskTypeForGates]?.require
566
+ ?? getConfig().qualityGates?.feature?.require ?? [];
567
+ setGateLatch(taskId, gates);
568
+ } catch (err) {
569
+ if (process.env.DEBUG) {
570
+ console.error(`[flow-done] Gate latch write failed: ${err.message}`);
571
+ }
572
+ }
573
+
560
574
  console.log('');
561
575
 
562
576
  // Check if task exists
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow - Gate Latch
5
+ *
6
+ * Records when quality gates have passed for a task.
7
+ * The TaskCompleted hook checks for this latch before allowing
8
+ * a task to move to recentlyCompleted.
9
+ *
10
+ * Without the latch, agents can call TaskUpdate(status: "completed")
11
+ * and bypass all quality gates. The latch ensures that the only path
12
+ * to completion goes through the quality gate pipeline.
13
+ *
14
+ * Flow:
15
+ * 1. flow-done.js runs quality gates → gates pass → writes latch
16
+ * 2. Agent calls TaskUpdate → TaskCompleted hook fires
17
+ * 3. Hook checks latch → if present and recent → allows completion
18
+ * 4. If no latch → blocks completion with actionable error message
19
+ *
20
+ * Latch file: .workflow/state/.gates-passed.json
21
+ * TTL: 30 minutes (stale latches are ignored)
22
+ */
23
+
24
+ const path = require('node:path');
25
+ const fs = require('node:fs');
26
+ const { PATHS, safeJsonParse } = require('./flow-utils');
27
+
28
+ /** Latch time-to-live in milliseconds (30 minutes) */
29
+ const LATCH_TTL_MS = 30 * 60 * 1000;
30
+
31
+ /** Path to the gate latch file */
32
+ const LATCH_PATH = path.join(PATHS.state, '.gates-passed.json');
33
+
34
+ /**
35
+ * Record that quality gates have passed for a task.
36
+ * Called by flow-done.js after all gates pass.
37
+ *
38
+ * @param {string} taskId - The task ID that passed gates
39
+ * @param {string[]} gatesPassed - Names of gates that passed
40
+ * @returns {{ written: boolean, path: string }}
41
+ */
42
+ function setGateLatch(taskId, gatesPassed = []) {
43
+ try {
44
+ const latch = {
45
+ taskId,
46
+ gatesPassed,
47
+ passedAt: new Date().toISOString(),
48
+ pid: process.pid
49
+ };
50
+ fs.writeFileSync(LATCH_PATH, JSON.stringify(latch, null, 2));
51
+ return { written: true, path: LATCH_PATH };
52
+ } catch (err) {
53
+ if (process.env.DEBUG) {
54
+ console.error(`[gate-latch] Failed to write latch: ${err.message}`);
55
+ }
56
+ return { written: false, path: LATCH_PATH };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Check if quality gates have passed for a task.
62
+ * Returns the latch data if valid, null if no latch or expired.
63
+ *
64
+ * @param {string} taskId - The task ID to check
65
+ * @returns {{ valid: boolean, latch: Object|null, reason: string }}
66
+ */
67
+ function checkGateLatch(taskId) {
68
+ const latch = safeJsonParse(LATCH_PATH, null);
69
+
70
+ if (!latch) {
71
+ return {
72
+ valid: false,
73
+ latch: null,
74
+ reason: 'No gate latch found. Quality gates have not been run for this task.'
75
+ };
76
+ }
77
+
78
+ // Check task ID matches
79
+ if (latch.taskId !== taskId) {
80
+ return {
81
+ valid: false,
82
+ latch,
83
+ reason: `Gate latch is for task ${latch.taskId}, not ${taskId}.`
84
+ };
85
+ }
86
+
87
+ // Check TTL
88
+ const passedAt = new Date(latch.passedAt).getTime();
89
+ const age = Date.now() - passedAt;
90
+ if (age > LATCH_TTL_MS) {
91
+ return {
92
+ valid: false,
93
+ latch,
94
+ reason: `Gate latch expired (${Math.round(age / 60000)} min old, TTL is ${LATCH_TTL_MS / 60000} min).`
95
+ };
96
+ }
97
+
98
+ return {
99
+ valid: true,
100
+ latch,
101
+ reason: `Gates passed at ${latch.passedAt} (${latch.gatesPassed.length} gates)`
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Clear the gate latch after task completion.
107
+ * Prevents stale latches from persisting.
108
+ */
109
+ function clearGateLatch() {
110
+ try {
111
+ if (fs.existsSync(LATCH_PATH)) {
112
+ fs.unlinkSync(LATCH_PATH);
113
+ }
114
+ } catch (_err) {
115
+ // Non-critical
116
+ }
117
+ }
118
+
119
+ module.exports = { setGateLatch, checkGateLatch, clearGateLatch, LATCH_PATH };