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.
- package/lib/workspace-gates.js +87 -0
- package/lib/workspace-session.js +308 -0
- package/lib/workspace.js +39 -3
- package/package.json +1 -1
- package/scripts/flow-config-defaults.js +27 -4
- package/scripts/flow-config-migrate.js +270 -0
- package/scripts/flow-context-manifest.js +322 -0
- package/scripts/flow-done-gates.js +76 -0
- package/scripts/flow-done.js +14 -0
- package/scripts/flow-gate-latch.js +119 -0
- package/scripts/hooks/core/post-compact.js +11 -1
- package/scripts/hooks/core/session-context.js +51 -7
- package/scripts/hooks/core/task-completed.js +26 -0
- package/scripts/postinstall.js +20 -0
|
@@ -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
|
};
|
package/scripts/flow-done.js
CHANGED
|
@@ -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
|