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.
@@ -161,6 +161,7 @@ const RESEARCH_TRIGGERS = {
161
161
  const CONFIG_DEFAULTS = {
162
162
  // --- Core ---
163
163
  version: '2.0.0',
164
+ _configVersion: 2, // Tracks config schema version for migrations (see flow-config-migrate.js)
164
165
  projectName: '',
165
166
  cli: {
166
167
  type: 'claude-code',
@@ -172,6 +173,7 @@ const CONFIG_DEFAULTS = {
172
173
  enforcement: {
173
174
  strictMode: true,
174
175
  requireTaskForImplementation: true,
176
+ requireGateLatch: true, // Gate latch: quality gates must pass before TaskCompleted allows completion
175
177
  requirePatternCitation: false,
176
178
  citationFormat: '// Pattern: {pattern}',
177
179
  blockAutoTask: true,
@@ -300,11 +302,11 @@ const CONFIG_DEFAULTS = {
300
302
  qualityGates: {
301
303
  preTaskBaseline: { enabled: false },
302
304
  feature: {
303
- require: ['loopComplete', 'tests', 'generatedTestsPass', 'uiVerification', 'apiVerification', 'registryUpdate', 'requestLogEntry', 'integrationWiring', 'standardsCompliance'],
305
+ require: ['loopComplete', 'tests', 'generatedTestsPass', 'uiVerification', 'apiVerification', 'verificationProof', 'registryUpdate', 'requestLogEntry', 'integrationWiring', 'standardsCompliance'],
304
306
  optional: ['review', 'docs', 'webmcpVerification']
305
307
  },
306
308
  bugfix: {
307
- require: ['loopComplete', 'tests', 'generatedTestsPass', 'requestLogEntry', 'standardsCompliance'],
309
+ require: ['loopComplete', 'tests', 'generatedTestsPass', 'verificationProof', 'requestLogEntry', 'standardsCompliance'],
308
310
  optional: ['learningEnforcement', 'resolutionPopulated', 'review', 'webmcpVerification']
309
311
  },
310
312
  refactor: {
@@ -327,7 +329,7 @@ const CONFIG_DEFAULTS = {
327
329
 
328
330
  // --- Standards & Compliance ---
329
331
  standardsCompliance: {
330
- enabled: false,
332
+ enabled: true,
331
333
  mode: 'block',
332
334
  scopeByTaskType: true,
333
335
  alwaysCheck: ['naming', 'security'],
@@ -337,6 +339,27 @@ const CONFIG_DEFAULTS = {
337
339
  checkpoint: { enabled: false },
338
340
  regressionTesting: { enabled: false },
339
341
 
342
+ // --- Runtime Verification (CC 2.1.89+ enforcement) ---
343
+ // Ensures agents actually test their work before marking done.
344
+ // Without this, agents claim "done" based on static evidence only.
345
+ runtimeVerification: {
346
+ enabled: true,
347
+ autoGenerateTests: true,
348
+ blockOnFailure: true,
349
+ frontend: {
350
+ method: 'webmcp',
351
+ fallback: ['playwright', 'checklist'],
352
+ devServerUrl: 'http://localhost:5173'
353
+ },
354
+ backend: {
355
+ method: 'api-test',
356
+ fallback: ['curl', 'checklist'],
357
+ baseUrl: 'http://localhost:3000'
358
+ },
359
+ testOutput: 'tests/verification',
360
+ persistTests: true
361
+ },
362
+
340
363
  // --- Detection (Project Type Awareness) ---
341
364
  detection: {
342
365
  _comment: "Weighted scoring for project type detection. Overrides take precedence over scoring.",
@@ -402,7 +425,7 @@ const CONFIG_DEFAULTS = {
402
425
  threshold: 30,
403
426
  allRegistries: true,
404
427
  aiAsJudge: true,
405
- blockOnSimilar: false,
428
+ blockOnSimilar: true,
406
429
  injectContext: true,
407
430
  preferVariants: true,
408
431
  requireAppMapEntry: true,
@@ -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
+ };