wogiflow 2.8.0 → 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.
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow - Config Migration
5
+ *
6
+ * Migrates existing config.json files when defaults change between versions.
7
+ * Without this, users who update WogiFlow keep stale defaults forever because
8
+ * deepMerge(defaults, userConfig) lets user values win — even when those
9
+ * values are outdated defaults from a previous version.
10
+ *
11
+ * Migration strategy:
12
+ * - Each migration has a version number (_configVersion)
13
+ * - Migrations run in order from current version to latest
14
+ * - Only keys that still match the OLD default are upgraded
15
+ * (user-customized values are preserved)
16
+ * - _configVersion is written to config.json after migration
17
+ *
18
+ * Called from postinstall.js on every npm install/update.
19
+ */
20
+
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+
24
+ /** Current config version — increment when adding new migrations */
25
+ const CURRENT_CONFIG_VERSION = 2;
26
+
27
+ /**
28
+ * Migration definitions. Each migration specifies:
29
+ * - version: the target _configVersion after this migration
30
+ * - description: human-readable description
31
+ * - migrate(config): function that modifies config in place
32
+ *
33
+ * IMPORTANT: Migrations must be SAFE for all users:
34
+ * - Only upgrade keys that match the OLD default value
35
+ * - Never overwrite user-customized values
36
+ * - Use ensureKey() for additive changes (new keys)
37
+ * - Use upgradeDefault() for changed defaults (old → new)
38
+ */
39
+ const MIGRATIONS = [
40
+ {
41
+ version: 2,
42
+ description: 'Enforcement defaults: enable standards, reuse blocking, verification proof gate, runtime verification config, gate latch',
43
+ migrate(config) {
44
+ // --- Standards compliance: false → true ---
45
+ upgradeDefault(config, 'standardsCompliance.enabled', false, true);
46
+
47
+ // --- Component reuse: blockOnSimilar false → true ---
48
+ upgradeDefault(config, 'componentReuse.blockOnSimilar', false, true);
49
+
50
+ // --- Add runtimeVerification config (new key) ---
51
+ ensureKey(config, 'runtimeVerification', {
52
+ enabled: true,
53
+ autoGenerateTests: true,
54
+ blockOnFailure: true,
55
+ frontend: {
56
+ method: 'webmcp',
57
+ fallback: ['playwright', 'checklist'],
58
+ devServerUrl: 'http://localhost:5173'
59
+ },
60
+ backend: {
61
+ method: 'api-test',
62
+ fallback: ['curl', 'checklist'],
63
+ baseUrl: 'http://localhost:3000'
64
+ },
65
+ testOutput: 'tests/verification',
66
+ persistTests: true
67
+ });
68
+
69
+ // --- Add enforcement.requireGateLatch (new key) ---
70
+ ensureKey(config, 'enforcement.requireGateLatch', true);
71
+
72
+ // --- Add verificationProof to quality gates ---
73
+ addToGateList(config, 'feature', 'verificationProof');
74
+ addToGateList(config, 'bugfix', 'verificationProof');
75
+ }
76
+ }
77
+ ];
78
+
79
+ // ============================================================
80
+ // Migration Helpers
81
+ // ============================================================
82
+
83
+ /**
84
+ * Get a nested value from an object using dot notation.
85
+ * @param {Object} obj
86
+ * @param {string} path - e.g., 'standardsCompliance.enabled'
87
+ * @returns {*} The value, or undefined if not found
88
+ */
89
+ function getNestedValue(obj, dotPath) {
90
+ const parts = dotPath.split('.');
91
+ let current = obj;
92
+ for (const part of parts) {
93
+ if (current == null || typeof current !== 'object') return undefined;
94
+ current = current[part];
95
+ }
96
+ return current;
97
+ }
98
+
99
+ /**
100
+ * Set a nested value on an object using dot notation.
101
+ * Creates intermediate objects if needed.
102
+ * @param {Object} obj
103
+ * @param {string} dotPath
104
+ * @param {*} value
105
+ */
106
+ function setNestedValue(obj, dotPath, value) {
107
+ const parts = dotPath.split('.');
108
+ let current = obj;
109
+ for (let i = 0; i < parts.length - 1; i++) {
110
+ const existing = current[parts[i]];
111
+ if (existing == null || typeof existing !== 'object' || Array.isArray(existing)) {
112
+ // Only overwrite non-objects (null, undefined, primitives, arrays)
113
+ // to avoid corrupting existing nested config structures
114
+ current[parts[i]] = {};
115
+ }
116
+ current = current[parts[i]];
117
+ }
118
+ current[parts[parts.length - 1]] = value;
119
+ }
120
+
121
+ /**
122
+ * Upgrade a config value ONLY if it still matches the old default.
123
+ * If the user customized it to something else, leave it alone.
124
+ *
125
+ * @param {Object} config
126
+ * @param {string} dotPath - e.g., 'standardsCompliance.enabled'
127
+ * @param {*} oldDefault - the value we're replacing
128
+ * @param {*} newDefault - the new value to set
129
+ * @returns {boolean} true if upgraded
130
+ */
131
+ function upgradeDefault(config, dotPath, oldDefault, newDefault) {
132
+ const current = getNestedValue(config, dotPath);
133
+
134
+ // Only upgrade if value matches old default exactly
135
+ if (current === oldDefault) {
136
+ setNestedValue(config, dotPath, newDefault);
137
+ return true;
138
+ }
139
+
140
+ // Value was customized or doesn't exist — leave it
141
+ return false;
142
+ }
143
+
144
+ /**
145
+ * Add a key to config if it doesn't exist.
146
+ * Never overwrites existing values.
147
+ *
148
+ * @param {Object} config
149
+ * @param {string} dotPath
150
+ * @param {*} value
151
+ * @returns {boolean} true if added
152
+ */
153
+ function ensureKey(config, dotPath, value) {
154
+ const current = getNestedValue(config, dotPath);
155
+ if (current == null) {
156
+ setNestedValue(config, dotPath, value);
157
+ return true;
158
+ }
159
+ return false;
160
+ }
161
+
162
+ /**
163
+ * Add a gate name to a quality gate list if not already present.
164
+ *
165
+ * @param {Object} config
166
+ * @param {string} taskType - e.g., 'feature', 'bugfix'
167
+ * @param {string} gateName - e.g., 'verificationProof'
168
+ * @returns {boolean} true if added
169
+ */
170
+ function addToGateList(config, taskType, gateName) {
171
+ const gates = config.qualityGates?.[taskType]?.require;
172
+ if (!Array.isArray(gates)) return false;
173
+ if (gates.includes(gateName)) return false;
174
+
175
+ // Insert before the last gate (usually standardsCompliance) for logical ordering
176
+ const insertIndex = Math.max(0, gates.length - 1);
177
+ gates.splice(insertIndex, 0, gateName);
178
+ return true;
179
+ }
180
+
181
+ // ============================================================
182
+ // Public API
183
+ // ============================================================
184
+
185
+ /**
186
+ * Run all pending migrations on a config object.
187
+ *
188
+ * @param {Object} config - The user's config.json content (mutated in place)
189
+ * @returns {{ migrated: boolean, fromVersion: number, toVersion: number, applied: string[] }}
190
+ */
191
+ function migrateConfig(config) {
192
+ const currentVersion = config._configVersion ?? 1;
193
+ const applied = [];
194
+
195
+ if (currentVersion >= CURRENT_CONFIG_VERSION) {
196
+ return { migrated: false, fromVersion: currentVersion, toVersion: currentVersion, applied };
197
+ }
198
+
199
+ for (const migration of MIGRATIONS) {
200
+ if (migration.version > currentVersion) {
201
+ try {
202
+ migration.migrate(config);
203
+ applied.push(`v${migration.version}: ${migration.description}`);
204
+ } catch (err) {
205
+ if (process.env.DEBUG) {
206
+ console.error(`[config-migrate] Migration v${migration.version} failed: ${err.message}`);
207
+ }
208
+ // Continue with other migrations — partial upgrade is better than none
209
+ }
210
+ }
211
+ }
212
+
213
+ config._configVersion = CURRENT_CONFIG_VERSION;
214
+
215
+ return {
216
+ migrated: applied.length > 0,
217
+ fromVersion: currentVersion,
218
+ toVersion: CURRENT_CONFIG_VERSION,
219
+ applied
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Migrate config.json file on disk.
225
+ * Safe to call multiple times — idempotent via _configVersion tracking.
226
+ *
227
+ * @param {string} configPath - Path to config.json
228
+ * @returns {{ migrated: boolean, fromVersion: number, toVersion: number, applied: string[] }}
229
+ */
230
+ function migrateConfigFile(configPath) {
231
+ if (!fs.existsSync(configPath)) {
232
+ return { migrated: false, fromVersion: 0, toVersion: 0, applied: [] };
233
+ }
234
+
235
+ let config;
236
+ try {
237
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
238
+ } catch (err) {
239
+ if (process.env.DEBUG) {
240
+ console.error(`[config-migrate] Failed to read config: ${err.message}`);
241
+ }
242
+ return { migrated: false, fromVersion: 0, toVersion: 0, applied: [] };
243
+ }
244
+
245
+ const result = migrateConfig(config);
246
+
247
+ if (result.migrated) {
248
+ try {
249
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
250
+ } catch (err) {
251
+ if (process.env.DEBUG) {
252
+ console.error(`[config-migrate] Failed to write migrated config: ${err.message}`);
253
+ }
254
+ }
255
+ }
256
+
257
+ return result;
258
+ }
259
+
260
+ module.exports = {
261
+ migrateConfig,
262
+ migrateConfigFile,
263
+ CURRENT_CONFIG_VERSION,
264
+ // Export helpers for testing
265
+ upgradeDefault,
266
+ ensureKey,
267
+ addToGateList,
268
+ getNestedValue,
269
+ setNestedValue
270
+ };
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow - Context Manifest Generator
5
+ *
6
+ * Generates a compact context manifest from project registry files.
7
+ * The manifest provides one-line summaries of all coding rules, components,
8
+ * utility functions, and API endpoints — enough for Claude to know WHAT EXISTS
9
+ * without injecting full content upfront.
10
+ *
11
+ * Part of the Tiered Context Architecture (CC 2.1.89+):
12
+ * - T1: Critical state (task, routing, warnings) — always injected
13
+ * - T2: Context manifest (this file) — compact inventory of available context
14
+ * - T3: Full content — loaded on-demand via Read when Claude needs it
15
+ *
16
+ * The manifest solves the "unknown unknowns" problem: if Claude doesn't know
17
+ * a utility exists, it will reinvent it. The manifest lists everything that
18
+ * exists with enough context to know when to look deeper.
19
+ */
20
+
21
+ const path = require('node:path');
22
+ const fs = require('node:fs');
23
+ const { PATHS, safeJsonParse } = require('./flow-utils');
24
+
25
+ /** Maximum chars per summary line in the manifest */
26
+ const MAX_SUMMARY_LEN = 120;
27
+
28
+ /** Maximum entries per registry section */
29
+ const MAX_ENTRIES_PER_SECTION = 30;
30
+
31
+ /**
32
+ * Extract coding rules from decisions.md as one-line summaries.
33
+ * Parses ## sections and extracts the first meaningful line of each.
34
+ *
35
+ * @returns {Array<{title: string, summary: string}>}
36
+ */
37
+ function extractDecisionSummaries() {
38
+ if (!fs.existsSync(PATHS.decisions)) return [];
39
+
40
+ try {
41
+ const content = fs.readFileSync(PATHS.decisions, 'utf-8');
42
+ const sections = content.split(/^##\s+/m).slice(1);
43
+ const results = [];
44
+
45
+ for (const section of sections) {
46
+ if (results.length >= MAX_ENTRIES_PER_SECTION) break;
47
+
48
+ const lines = section.split('\n');
49
+ const title = lines[0].trim();
50
+ if (!title) continue;
51
+
52
+ // Skip the divider line and header, find first content line
53
+ const subsections = section.split(/^###\s+/m).slice(1);
54
+ if (subsections.length > 0) {
55
+ // Has subsections — list them (skip placeholder subsections)
56
+ const subNames = subsections
57
+ .map(s => s.split('\n')[0].trim())
58
+ .filter(name => name && !name.includes('<!--'))
59
+ .slice(0, 5);
60
+ if (subNames.length > 0) {
61
+ results.push({
62
+ title,
63
+ summary: subNames.join(', ')
64
+ });
65
+ }
66
+ } else {
67
+ // No subsections — take first non-empty content line
68
+ const bodyLines = lines.slice(1).filter(l =>
69
+ l.trim() && !l.startsWith('---') && !l.startsWith('**Source') && !l.includes('<!--')
70
+ );
71
+ const summary = bodyLines[0]?.trim().substring(0, MAX_SUMMARY_LEN) || '';
72
+ if (summary) {
73
+ results.push({ title, summary });
74
+ }
75
+ }
76
+ }
77
+
78
+ return results;
79
+ } catch (_err) {
80
+ return [];
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Extract components from app-map.md tables.
86
+ * Parses markdown tables for Screen, Modal, and Component entries.
87
+ *
88
+ * @returns {Array<{type: string, name: string, detail: string}>}
89
+ */
90
+ function extractComponentSummaries() {
91
+ const appMapPath = PATHS.appMap || path.join(PATHS.state, 'app-map.md');
92
+ if (!fs.existsSync(appMapPath)) return [];
93
+
94
+ try {
95
+ const content = fs.readFileSync(appMapPath, 'utf-8');
96
+ const results = [];
97
+
98
+ // Parse table rows: | Name | ... | ... |
99
+ // Skip header rows (containing ---), template rows (_Example_), and column header rows
100
+ const HEADER_KEYWORDS = ['Screen', 'Route', 'Status', 'Modal', 'Trigger', 'Component', 'Variants', 'Path', 'Details'];
101
+ const tableRows = content.match(/^\|[^|]+\|.+\|$/gm) || [];
102
+ for (const row of tableRows) {
103
+ if (results.length >= MAX_ENTRIES_PER_SECTION) break;
104
+ if (row.includes('---') || row.includes('_Example_')) continue;
105
+ // Skip column header rows (first row of each table)
106
+ const cells = row.split('|').filter(Boolean).map(c => c.trim());
107
+ if (cells.length >= 2 && HEADER_KEYWORDS.includes(cells[0]) && HEADER_KEYWORDS.includes(cells[1])) continue;
108
+
109
+ if (cells.length >= 2 && cells[0]) {
110
+ results.push({
111
+ type: 'component',
112
+ name: cells[0],
113
+ detail: cells.slice(1, 3).filter(Boolean).join(' — ').substring(0, MAX_SUMMARY_LEN)
114
+ });
115
+ }
116
+ }
117
+
118
+ return results;
119
+ } catch (_err) {
120
+ return [];
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Extract utility functions from function-map.md.
126
+ * Parses both table format and manual entry format.
127
+ *
128
+ * @returns {Array<{name: string, file: string, purpose: string}>}
129
+ */
130
+ function extractFunctionSummaries() {
131
+ const fnMapPath = path.join(PATHS.state, 'function-map.md');
132
+ if (!fs.existsSync(fnMapPath)) return [];
133
+
134
+ try {
135
+ const content = fs.readFileSync(fnMapPath, 'utf-8');
136
+ const results = [];
137
+
138
+ // Parse table rows: | functionName | file | purpose |
139
+ const tableRows = content.match(/^\|[^|]+\|.+\|$/gm) || [];
140
+ for (const row of tableRows) {
141
+ if (results.length >= MAX_ENTRIES_PER_SECTION) break;
142
+ if (row.includes('---') || row.includes('Function') && row.includes('File')) continue;
143
+
144
+ const cells = row.split('|').filter(Boolean).map(c => c.trim());
145
+ if (cells.length >= 2 && cells[0] && !cells[0].startsWith('_')) {
146
+ results.push({
147
+ name: cells[0],
148
+ file: cells[1] || '',
149
+ purpose: (cells[2] || '').substring(0, MAX_SUMMARY_LEN)
150
+ });
151
+ }
152
+ }
153
+
154
+ // Parse ### entries (manual format): ### functionName(params)
155
+ const manualEntries = content.match(/^###\s+\w+.*$/gm) || [];
156
+ for (const entry of manualEntries) {
157
+ if (results.length >= MAX_ENTRIES_PER_SECTION) break;
158
+ const name = entry.replace(/^###\s+/, '').trim();
159
+ if (name && !name.includes('Rules') && !name.includes('Scan')) {
160
+ // Avoid duplicates
161
+ if (!results.some(r => r.name === name)) {
162
+ results.push({ name, file: '', purpose: '' });
163
+ }
164
+ }
165
+ }
166
+
167
+ return results;
168
+ } catch (_err) {
169
+ return [];
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Extract API endpoints from api-map.md.
175
+ *
176
+ * @returns {Array<{method: string, path: string, purpose: string}>}
177
+ */
178
+ function extractApiSummaries() {
179
+ const apiMapPath = path.join(PATHS.state, 'api-map.md');
180
+ if (!fs.existsSync(apiMapPath)) return [];
181
+
182
+ try {
183
+ const content = fs.readFileSync(apiMapPath, 'utf-8');
184
+ const results = [];
185
+
186
+ // Parse ### METHOD /path entries
187
+ const endpointHeaders = content.match(/^###\s+(GET|POST|PUT|PATCH|DELETE)\s+\S+/gm) || [];
188
+ for (const header of endpointHeaders) {
189
+ if (results.length >= MAX_ENTRIES_PER_SECTION) break;
190
+ const match = header.match(/^###\s+(GET|POST|PUT|PATCH|DELETE)\s+(\S+)/);
191
+ if (match) {
192
+ // Find purpose line after the header
193
+ const idx = content.indexOf(header);
194
+ const afterHeader = content.substring(idx + header.length, idx + header.length + 300);
195
+ const purposeMatch = afterHeader.match(/\*\*Purpose\*\*:\s*(.+)/);
196
+ results.push({
197
+ method: match[1],
198
+ path: match[2],
199
+ purpose: (purposeMatch ? purposeMatch[1] : '').substring(0, MAX_SUMMARY_LEN)
200
+ });
201
+ }
202
+ }
203
+
204
+ // Parse table format: | METHOD | /path | purpose |
205
+ const tableRows = content.match(/^\|[^|]+\|.+\|$/gm) || [];
206
+ for (const row of tableRows) {
207
+ if (results.length >= MAX_ENTRIES_PER_SECTION) break;
208
+ if (row.includes('---') || row.includes('Method') && row.includes('Path')) continue;
209
+
210
+ const cells = row.split('|').filter(Boolean).map(c => c.trim());
211
+ if (cells.length >= 2 && /^(GET|POST|PUT|PATCH|DELETE)$/i.test(cells[0])) {
212
+ if (!results.some(r => r.method === cells[0] && r.path === cells[1])) {
213
+ results.push({
214
+ method: cells[0],
215
+ path: cells[1],
216
+ purpose: (cells[2] || '').substring(0, MAX_SUMMARY_LEN)
217
+ });
218
+ }
219
+ }
220
+ }
221
+
222
+ return results;
223
+ } catch (_err) {
224
+ return [];
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Generate the full context manifest.
230
+ * Returns a structured object with all registry summaries.
231
+ *
232
+ * @returns {{ decisions: Array, components: Array, functions: Array, apis: Array, generatedAt: string }}
233
+ */
234
+ function generateManifest() {
235
+ return {
236
+ decisions: extractDecisionSummaries(),
237
+ components: extractComponentSummaries(),
238
+ functions: extractFunctionSummaries(),
239
+ apis: extractApiSummaries(),
240
+ generatedAt: new Date().toISOString()
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Format the manifest as a compact markdown string for context injection.
246
+ * Designed to be small enough to always fit in T2 (target: 2-8KB depending on project size).
247
+ *
248
+ * @param {Object} manifest - From generateManifest()
249
+ * @returns {string} Formatted markdown
250
+ */
251
+ function formatManifestForInjection(manifest) {
252
+ if (!manifest) return '';
253
+
254
+ const parts = [];
255
+
256
+ // Coding rules
257
+ if (manifest.decisions.length > 0) {
258
+ parts.push('**Coding Rules** (load full: `Read .workflow/state/decisions.md`):');
259
+ for (const d of manifest.decisions) {
260
+ parts.push(`- ${d.title}: ${d.summary}`);
261
+ }
262
+ }
263
+
264
+ // Components
265
+ if (manifest.components.length > 0) {
266
+ parts.push('**Components** (load full: `Read .workflow/state/app-map.md`):');
267
+ for (const c of manifest.components) {
268
+ parts.push(`- ${c.name}: ${c.detail}`);
269
+ }
270
+ }
271
+
272
+ // Utility functions
273
+ if (manifest.functions.length > 0) {
274
+ parts.push('**Utility Functions** (load full: `Read .workflow/state/function-map.md`):');
275
+ for (const f of manifest.functions) {
276
+ const detail = f.purpose ? ` — ${f.purpose}` : '';
277
+ const file = f.file ? ` (${f.file})` : '';
278
+ parts.push(`- \`${f.name}\`${file}${detail}`);
279
+ }
280
+ }
281
+
282
+ // API endpoints
283
+ if (manifest.apis.length > 0) {
284
+ parts.push('**API Endpoints** (load full: `Read .workflow/state/api-map.md`):');
285
+ for (const a of manifest.apis) {
286
+ const purpose = a.purpose ? ` — ${a.purpose}` : '';
287
+ parts.push(`- ${a.method} ${a.path}${purpose}`);
288
+ }
289
+ }
290
+
291
+ if (parts.length === 0) {
292
+ return '';
293
+ }
294
+
295
+ return parts.join('\n');
296
+ }
297
+
298
+ /**
299
+ * Check if any registries have content worth manifesting.
300
+ * Returns false for empty/template-only registries.
301
+ *
302
+ * @param {Object} manifest - From generateManifest()
303
+ * @returns {boolean}
304
+ */
305
+ function hasContent(manifest) {
306
+ return (
307
+ manifest.decisions.length > 0 ||
308
+ manifest.components.length > 0 ||
309
+ manifest.functions.length > 0 ||
310
+ manifest.apis.length > 0
311
+ );
312
+ }
313
+
314
+ module.exports = {
315
+ generateManifest,
316
+ formatManifestForInjection,
317
+ hasContent,
318
+ extractDecisionSummaries,
319
+ extractComponentSummaries,
320
+ extractFunctionSummaries,
321
+ extractApiSummaries
322
+ };
@@ -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