wogiflow 2.6.3 → 2.7.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.
Files changed (30) hide show
  1. package/.claude/settings.json +0 -1
  2. package/lib/workspace-changelog.js +182 -0
  3. package/lib/workspace-channel-server.js +75 -2
  4. package/lib/workspace-contracts.js +151 -1
  5. package/lib/workspace-events.js +383 -0
  6. package/lib/workspace-gates.js +740 -0
  7. package/lib/workspace-integration-tests.js +299 -0
  8. package/lib/workspace-intelligence.js +486 -1
  9. package/lib/workspace-locks.js +371 -0
  10. package/lib/workspace-messages.js +203 -3
  11. package/lib/workspace-routing.js +144 -0
  12. package/lib/workspace.js +18 -3
  13. package/package.json +1 -1
  14. package/scripts/flow-done-gates.js +70 -0
  15. package/.claude/rules/_internal/README.md +0 -64
  16. package/.claude/rules/_internal/document-structure.md +0 -77
  17. package/.claude/rules/_internal/dual-repo-management.md +0 -174
  18. package/.claude/rules/_internal/feature-refactoring-cleanup.md +0 -87
  19. package/.claude/rules/_internal/github-releases.md +0 -71
  20. package/.claude/rules/_internal/model-management.md +0 -35
  21. package/.claude/rules/_internal/self-maintenance.md +0 -87
  22. package/.claude/rules/architecture/component-reuse.md +0 -38
  23. package/.claude/rules/code-style/naming-conventions.md +0 -107
  24. package/.claude/rules/operations/git-workflows.md +0 -92
  25. package/.claude/rules/operations/scratch-directory.md +0 -54
  26. package/.claude/rules/security/security-patterns.md +0 -176
  27. package/.claude/skills/figma-analyzer/knowledge/learnings.md +0 -11
  28. package/.workflow/specs/architecture.md.template +0 -24
  29. package/.workflow/specs/stack.md.template +0 -33
  30. package/.workflow/specs/testing.md.template +0 -36
@@ -0,0 +1,740 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Workspace — Conditional Gate Injection & Cross-Repo Awareness
5
+ *
6
+ * The backbone for workspace-aware development. Detects when workspace mode
7
+ * is active and provides:
8
+ * - workspaceActive() — detect if current cwd is inside a workspace
9
+ * - loadWorkspaceContext() — load manifest, config, integration map
10
+ * - analyzeTaskImpact() — check if a task touches cross-repo surfaces
11
+ * - getWorkspaceQualityGates() — return conditional gates for workspace mode
12
+ * - broadcastPostChange() — notify peers after task completion
13
+ * - runWorkspaceGate() — execute individual workspace quality gates
14
+ *
15
+ * All workspace features are conditional: zero overhead for single-repo projects.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+
23
+ const { WORKSPACE_CONFIG_FILE, WORKSPACE_DIR } = require('./workspace');
24
+ const { buildIntegrationMap } = require('./workspace-contracts');
25
+ const { detectContractDrift, getCascadeTargets, buildDependencyGraph } = require('./workspace-intelligence');
26
+ const { createMessage, saveMessage, getUnreadMessages } = require('./workspace-messages');
27
+
28
+ // ============================================================
29
+ // Constants
30
+ // ============================================================
31
+
32
+ /**
33
+ * Workspace quality gates — injected into flow-done when workspace is active.
34
+ * Each gate has: name, description, phase (pre|post), severity (error|warning).
35
+ */
36
+ const WORKSPACE_GATES = [
37
+ {
38
+ name: 'crossRepoImpactCheck',
39
+ description: 'Verify cross-repo impact was assessed before implementation',
40
+ phase: 'pre',
41
+ severity: 'error'
42
+ },
43
+ {
44
+ name: 'contractCompliance',
45
+ description: 'Verify changes comply with declared contracts',
46
+ phase: 'post',
47
+ severity: 'error'
48
+ },
49
+ {
50
+ name: 'peerNotification',
51
+ description: 'Notify affected peers of changes made',
52
+ phase: 'post',
53
+ severity: 'warning'
54
+ },
55
+ {
56
+ name: 'cascadeVerification',
57
+ description: 'Verify library changes notified all consumers',
58
+ phase: 'post',
59
+ severity: 'error'
60
+ },
61
+ {
62
+ name: 'integrationMapFreshness',
63
+ description: 'Verify integration map is up-to-date',
64
+ phase: 'pre',
65
+ severity: 'warning'
66
+ }
67
+ ];
68
+
69
+ /** Max age for integration map before staleness warning (24 hours). */
70
+ const MAP_FRESHNESS_MS = 24 * 60 * 60 * 1000;
71
+
72
+ /** Keywords indicating cross-repo surface area. */
73
+ const CROSS_REPO_SURFACE_KEYWORDS = [
74
+ 'endpoint', 'api', 'route', 'contract', 'schema', 'type',
75
+ 'interface', 'dto', 'model', 'event', 'message', 'webhook',
76
+ 'shared', 'common', 'library', 'package'
77
+ ];
78
+
79
+ // ============================================================
80
+ // Workspace Detection
81
+ // ============================================================
82
+
83
+ /**
84
+ * Check if workspace mode is active for the given directory.
85
+ * Walks up the directory tree looking for wogi-workspace.json.
86
+ *
87
+ * @param {string} [cwd] — directory to check (default: process.cwd())
88
+ * @returns {{ active: boolean, root: string|null, configPath: string|null }}
89
+ */
90
+ function workspaceActive(cwd) {
91
+ const startDir = cwd || process.cwd();
92
+ let dir = path.resolve(startDir);
93
+ const root = path.parse(dir).root;
94
+
95
+ // Walk up at most 5 levels to find workspace root
96
+ for (let i = 0; i < 5; i++) {
97
+ const configPath = path.join(dir, WORKSPACE_CONFIG_FILE);
98
+ if (fs.existsSync(configPath)) {
99
+ return { active: true, root: dir, configPath };
100
+ }
101
+ const parent = path.dirname(dir);
102
+ if (parent === dir || parent === root) break;
103
+ dir = parent;
104
+ }
105
+
106
+ // Also check env variable (set by channel server)
107
+ // Validate that env root is an ancestor of cwd to prevent path injection
108
+ const envRoot = process.env.WOGI_WORKSPACE_ROOT;
109
+ if (envRoot) {
110
+ const resolvedEnv = path.resolve(envRoot);
111
+ const resolvedStart = path.resolve(startDir);
112
+ if (resolvedStart.startsWith(resolvedEnv + path.sep) || resolvedStart === resolvedEnv) {
113
+ const envConfig = path.join(resolvedEnv, WORKSPACE_CONFIG_FILE);
114
+ if (fs.existsSync(envConfig)) {
115
+ return { active: true, root: resolvedEnv, configPath: envConfig };
116
+ }
117
+ }
118
+ }
119
+
120
+ return { active: false, root: null, configPath: null };
121
+ }
122
+
123
+ /**
124
+ * Determine which member repo the current directory belongs to.
125
+ *
126
+ * @param {string} workspaceRoot
127
+ * @param {string} [cwd]
128
+ * @returns {{ name: string, role: string, path: string }|null}
129
+ */
130
+ function identifyCurrentMember(workspaceRoot, cwd) {
131
+ const currentDir = path.resolve(cwd || process.cwd());
132
+ const configPath = path.join(workspaceRoot, WORKSPACE_CONFIG_FILE);
133
+
134
+ try {
135
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
136
+ for (const [name, memberConfig] of Object.entries(config.members || {})) {
137
+ const memberPath = path.resolve(workspaceRoot, memberConfig.path);
138
+ if (currentDir === memberPath || currentDir.startsWith(memberPath + path.sep)) {
139
+ return { name, role: memberConfig.role, path: memberPath };
140
+ }
141
+ }
142
+ } catch (_err) {
143
+ // Config read failure
144
+ }
145
+
146
+ return null;
147
+ }
148
+
149
+ // ============================================================
150
+ // Context Loading
151
+ // ============================================================
152
+
153
+ /**
154
+ * Load the full workspace context needed for gate evaluation.
155
+ *
156
+ * @param {string} workspaceRoot
157
+ * @returns {Object} workspace context
158
+ */
159
+ function loadWorkspaceContext(workspaceRoot) {
160
+ const context = {
161
+ config: null,
162
+ manifest: null,
163
+ integrationMap: null,
164
+ currentMember: null,
165
+ unreadMessages: [],
166
+ health: null
167
+ };
168
+
169
+ // Load config
170
+ const configPath = path.join(workspaceRoot, WORKSPACE_CONFIG_FILE);
171
+ try {
172
+ context.config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
173
+ } catch (_err) {
174
+ return context;
175
+ }
176
+
177
+ // Load manifest
178
+ const manifestPath = path.join(workspaceRoot, WORKSPACE_DIR, 'state', 'workspace-manifest.json');
179
+ try {
180
+ if (fs.existsSync(manifestPath)) {
181
+ context.manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
182
+ }
183
+ } catch (_err) {
184
+ // Non-critical
185
+ }
186
+
187
+ // Build integration map from manifest
188
+ if (context.manifest) {
189
+ try {
190
+ context.integrationMap = buildIntegrationMap(context.manifest);
191
+ } catch (_err) {
192
+ // Non-critical
193
+ }
194
+ }
195
+
196
+ // Identify current member
197
+ context.currentMember = identifyCurrentMember(workspaceRoot);
198
+
199
+ // Load unread messages for current member
200
+ if (context.currentMember) {
201
+ try {
202
+ context.unreadMessages = getUnreadMessages(workspaceRoot, context.currentMember.name);
203
+ } catch (_err) {
204
+ context.unreadMessages = [];
205
+ }
206
+ }
207
+
208
+ return context;
209
+ }
210
+
211
+ // ============================================================
212
+ // Task Impact Analysis
213
+ // ============================================================
214
+
215
+ /**
216
+ * Analyze whether a task description touches cross-repo surfaces.
217
+ * Used by the pre-dev impact gate to determine if peers need to be notified.
218
+ *
219
+ * @param {string} taskDescription — task title + criteria text
220
+ * @param {Object} context — from loadWorkspaceContext()
221
+ * @returns {Object} impact analysis
222
+ */
223
+ function analyzeTaskImpact(taskDescription, context) {
224
+ const result = {
225
+ hasCrossRepoImpact: false,
226
+ affectedPeers: [],
227
+ affectedEndpoints: [],
228
+ affectedTypes: [],
229
+ surfaceKeywords: [],
230
+ recommendation: 'none' // 'none' | 'heads-up' | 'query-peers' | 'block-until-ack'
231
+ };
232
+
233
+ if (!context.manifest || !context.currentMember) return result;
234
+
235
+ const descLower = taskDescription.toLowerCase();
236
+
237
+ // 1. Check for cross-repo surface keywords
238
+ for (const kw of CROSS_REPO_SURFACE_KEYWORDS) {
239
+ if (descLower.includes(kw)) {
240
+ result.surfaceKeywords.push(kw);
241
+ }
242
+ }
243
+
244
+ // 2. Check for endpoint mentions against integration map
245
+ if (context.integrationMap) {
246
+ for (const match of context.integrationMap.matched || []) {
247
+ // Check if the task mentions this endpoint
248
+ const epLower = match.endpoint.toLowerCase();
249
+ const pathSegments = epLower.split('/').filter(s => s && s !== 'api' && s !== 'v1');
250
+ for (const seg of pathSegments) {
251
+ if (seg.startsWith(':') || seg.startsWith('{')) continue;
252
+ if (descLower.includes(seg)) {
253
+ result.affectedEndpoints.push(match);
254
+ // Find peers that consume/provide this endpoint
255
+ const peers = [...(match.providers || []), ...(match.consumers || [])]
256
+ .filter(p => p !== context.currentMember.name);
257
+ for (const peer of peers) {
258
+ if (!result.affectedPeers.includes(peer)) {
259
+ result.affectedPeers.push(peer);
260
+ }
261
+ }
262
+ break;
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ // 3. Check for type/schema mentions
269
+ if (context.manifest.members) {
270
+ const currentSchemas = context.manifest.members[context.currentMember.name]?.schemas || [];
271
+ for (const schema of currentSchemas) {
272
+ const schemaName = (schema.name || schema).toLowerCase();
273
+ if (descLower.includes(schemaName)) {
274
+ result.affectedTypes.push(schema);
275
+ // Types affect all repos that share this type
276
+ for (const [name, member] of Object.entries(context.manifest.members)) {
277
+ if (name === context.currentMember.name) continue;
278
+ const memberSchemas = (member.schemas || []).map(s => (s.name || s).toLowerCase());
279
+ if (memberSchemas.includes(schemaName)) {
280
+ if (!result.affectedPeers.includes(name)) {
281
+ result.affectedPeers.push(name);
282
+ }
283
+ }
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ // 4. Library role = always affects consumers
290
+ if (context.currentMember.role === 'library') {
291
+ const graph = buildDependencyGraph(context.manifest);
292
+ const cascadeTargets = getCascadeTargets(context.currentMember.name, context.manifest, graph);
293
+ for (const target of cascadeTargets) {
294
+ if (!result.affectedPeers.includes(target)) {
295
+ result.affectedPeers.push(target);
296
+ }
297
+ }
298
+ }
299
+
300
+ // 5. Determine impact level
301
+ result.hasCrossRepoImpact = result.affectedPeers.length > 0 || result.surfaceKeywords.length >= 2;
302
+
303
+ if (result.affectedEndpoints.length > 0 || result.affectedTypes.length > 0) {
304
+ result.recommendation = 'query-peers';
305
+ } else if (result.surfaceKeywords.length >= 2) {
306
+ result.recommendation = 'heads-up';
307
+ } else if (result.affectedPeers.length > 0) {
308
+ result.recommendation = 'heads-up';
309
+ }
310
+
311
+ return result;
312
+ }
313
+
314
+ /**
315
+ * Broadcast a pre-dev heads-up message to affected peers.
316
+ *
317
+ * @param {string} workspaceRoot
318
+ * @param {string} fromRepo — current repo name
319
+ * @param {Object} impact — from analyzeTaskImpact()
320
+ * @param {string} taskTitle
321
+ * @returns {Array<string>} message IDs created
322
+ */
323
+ function broadcastHeadsUp(workspaceRoot, fromRepo, impact, taskTitle) {
324
+ const messageIds = [];
325
+
326
+ for (const peer of impact.affectedPeers) {
327
+ const endpointList = impact.affectedEndpoints.map(e => e.endpoint).join(', ');
328
+ const typeList = impact.affectedTypes.map(t => t.name || t).join(', ');
329
+
330
+ let body = `I'm about to work on: "${taskTitle}"\n\n`;
331
+ if (endpointList) body += `Affected endpoints: ${endpointList}\n`;
332
+ if (typeList) body += `Affected types: ${typeList}\n`;
333
+ body += `\nDoes this affect your side? Any concerns or things I should be aware of?`;
334
+
335
+ const msg = createMessage({
336
+ from: fromRepo,
337
+ to: peer,
338
+ type: 'heads-up',
339
+ subject: `Pre-dev notice: ${taskTitle.substring(0, 60)}`,
340
+ body,
341
+ priority: impact.recommendation === 'query-peers' ? 'high' : 'medium',
342
+ actionRequired: impact.recommendation === 'query-peers'
343
+ });
344
+
345
+ try {
346
+ saveMessage(workspaceRoot, msg);
347
+ messageIds.push(msg.id);
348
+ } catch (_err) {
349
+ // Best effort
350
+ }
351
+ }
352
+
353
+ return messageIds;
354
+ }
355
+
356
+ // ============================================================
357
+ // Post-Change Broadcast
358
+ // ============================================================
359
+
360
+ /**
361
+ * After task completion, detect changes and notify affected peers.
362
+ *
363
+ * @param {string} workspaceRoot
364
+ * @param {string} fromRepo — repo that completed the task
365
+ * @param {Object} context — from loadWorkspaceContext()
366
+ * @param {Object} [options] — { changedFiles: string[], taskTitle: string }
367
+ * @returns {Object} broadcast result
368
+ */
369
+ function broadcastPostChange(workspaceRoot, fromRepo, context, options = {}) {
370
+ const result = {
371
+ driftsDetected: [],
372
+ messagesCreated: [],
373
+ verificationTasksCreated: []
374
+ };
375
+
376
+ if (!context.manifest) return result;
377
+
378
+ // 1. Detect contract drift
379
+ try {
380
+ const drifts = detectContractDrift(workspaceRoot, context.manifest);
381
+ result.driftsDetected = drifts;
382
+ } catch (_err) {
383
+ // Non-critical
384
+ }
385
+
386
+ // 2. Check which endpoints/types changed by analyzing changed files
387
+ const { changedFiles = [], taskTitle = 'Unknown task' } = options;
388
+
389
+ // 3. Find affected repos via cascade analysis
390
+ const graph = buildDependencyGraph(context.manifest);
391
+ const cascadeTargets = getCascadeTargets(fromRepo, context.manifest, graph);
392
+
393
+ // 4. Send contract-change notifications
394
+ for (const target of cascadeTargets) {
395
+ const msg = createMessage({
396
+ from: fromRepo,
397
+ to: target,
398
+ type: 'contract-change',
399
+ subject: `Post-change notice: ${taskTitle.substring(0, 60)}`,
400
+ body: `Repo "${fromRepo}" completed: "${taskTitle}"\n\n` +
401
+ (changedFiles.length > 0 ? `Changed files:\n${changedFiles.map(f => ` - ${f}`).join('\n')}\n\n` : '') +
402
+ (result.driftsDetected.length > 0 ? `Contract drifts detected: ${result.driftsDetected.length}\n` : '') +
403
+ `Please verify your integrations still work correctly.`,
404
+ priority: result.driftsDetected.length > 0 ? 'critical' : 'high',
405
+ actionRequired: true,
406
+ suggestedTask: {
407
+ title: `Verify integrations after ${fromRepo} changes — ${taskTitle.substring(0, 40)}`,
408
+ type: 'fix',
409
+ priority: result.driftsDetected.length > 0 ? 'P0' : 'P1'
410
+ }
411
+ });
412
+
413
+ try {
414
+ saveMessage(workspaceRoot, msg);
415
+ result.messagesCreated.push(msg.id);
416
+ } catch (_err) {
417
+ // Best effort
418
+ }
419
+ }
420
+
421
+ return result;
422
+ }
423
+
424
+ // ============================================================
425
+ // Quality Gate Runners
426
+ // ============================================================
427
+
428
+ /**
429
+ * Run a specific workspace quality gate.
430
+ *
431
+ * @param {string} gateName — one of WORKSPACE_GATES[].name
432
+ * @param {string} workspaceRoot
433
+ * @param {Object} context — from loadWorkspaceContext()
434
+ * @param {Object} [taskMeta] — { taskId, taskTitle, changedFiles, impactAssessed }
435
+ * @returns {{ passed: boolean, message: string, severity: string }}
436
+ */
437
+ function runWorkspaceGate(gateName, workspaceRoot, context, taskMeta = {}) {
438
+ switch (gateName) {
439
+ case 'crossRepoImpactCheck':
440
+ return gateCrossRepoImpactCheck(context, taskMeta);
441
+
442
+ case 'contractCompliance':
443
+ return gateContractCompliance(workspaceRoot, context);
444
+
445
+ case 'peerNotification':
446
+ return gatePeerNotification(workspaceRoot, context, taskMeta);
447
+
448
+ case 'cascadeVerification':
449
+ return gateCascadeVerification(workspaceRoot, context, taskMeta);
450
+
451
+ case 'integrationMapFreshness':
452
+ return gateIntegrationMapFreshness(workspaceRoot);
453
+
454
+ default:
455
+ return { passed: true, message: `Unknown gate: ${gateName}`, severity: 'warning' };
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Gate: crossRepoImpactCheck
461
+ * Verify that cross-repo impact was assessed before implementation.
462
+ */
463
+ function gateCrossRepoImpactCheck(context, taskMeta) {
464
+ const gate = WORKSPACE_GATES.find(g => g.name === 'crossRepoImpactCheck');
465
+
466
+ if (taskMeta.impactAssessed) {
467
+ return { passed: true, message: 'Cross-repo impact was assessed', severity: gate.severity };
468
+ }
469
+
470
+ // If no impact analysis was done, check if the task even needs one
471
+ if (!context.currentMember) {
472
+ return { passed: true, message: 'Not in a workspace member repo', severity: gate.severity };
473
+ }
474
+
475
+ const impact = analyzeTaskImpact(taskMeta.taskTitle || '', context);
476
+ if (!impact.hasCrossRepoImpact) {
477
+ return { passed: true, message: 'No cross-repo impact detected', severity: gate.severity };
478
+ }
479
+
480
+ return {
481
+ passed: false,
482
+ message: `Cross-repo impact detected (${impact.affectedPeers.join(', ')}) but not assessed. Run impact analysis before implementation.`,
483
+ severity: gate.severity
484
+ };
485
+ }
486
+
487
+ /**
488
+ * Gate: contractCompliance
489
+ * Verify changes comply with declared contracts.
490
+ */
491
+ function gateContractCompliance(workspaceRoot, context) {
492
+ const gate = WORKSPACE_GATES.find(g => g.name === 'contractCompliance');
493
+
494
+ if (!context.manifest) {
495
+ return { passed: true, message: 'No manifest available', severity: gate.severity };
496
+ }
497
+
498
+ try {
499
+ const drifts = detectContractDrift(workspaceRoot, context.manifest);
500
+ const highSeverity = drifts.filter(d => d.severity === 'high');
501
+
502
+ if (highSeverity.length > 0) {
503
+ return {
504
+ passed: false,
505
+ message: `${highSeverity.length} contract compliance issue(s): ${highSeverity.map(d => d.endpoint || d.type).join(', ')}`,
506
+ severity: gate.severity
507
+ };
508
+ }
509
+
510
+ return { passed: true, message: `Contract compliance OK (${drifts.length} info-level items)`, severity: gate.severity };
511
+ } catch (_err) {
512
+ return { passed: true, message: 'Contract check skipped (error reading contracts)', severity: 'warning' };
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Gate: peerNotification
518
+ * Verify affected peers were notified of changes.
519
+ */
520
+ function gatePeerNotification(workspaceRoot, context, taskMeta) {
521
+ const gate = WORKSPACE_GATES.find(g => g.name === 'peerNotification');
522
+
523
+ if (!context.currentMember || !context.manifest) {
524
+ return { passed: true, message: 'Not in workspace context', severity: gate.severity };
525
+ }
526
+
527
+ // Check if the task had cross-repo impact
528
+ const impact = analyzeTaskImpact(taskMeta.taskTitle || '', context);
529
+ if (!impact.hasCrossRepoImpact) {
530
+ return { passed: true, message: 'No peers to notify', severity: gate.severity };
531
+ }
532
+
533
+ // Check if notifications were sent (look for recent messages from this repo)
534
+ try {
535
+ const { readMessages } = require('./workspace-messages');
536
+ const recentMessages = readMessages(workspaceRoot, {
537
+ from: context.currentMember.name,
538
+ type: 'contract-change'
539
+ });
540
+
541
+ // Check if there's a recent notification (within last hour)
542
+ const oneHourAgo = Date.now() - (60 * 60 * 1000);
543
+ const recentNotifications = recentMessages.filter(
544
+ m => new Date(m.timestamp).getTime() > oneHourAgo
545
+ );
546
+
547
+ if (recentNotifications.length > 0) {
548
+ return { passed: true, message: `${recentNotifications.length} peer notification(s) sent`, severity: gate.severity };
549
+ }
550
+
551
+ return {
552
+ passed: false,
553
+ message: `Affected peers (${impact.affectedPeers.join(', ')}) were not notified of changes`,
554
+ severity: gate.severity
555
+ };
556
+ } catch (_err) {
557
+ return { passed: true, message: 'Notification check skipped', severity: 'warning' };
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Gate: cascadeVerification
563
+ * For library repos, verify all consumers were notified.
564
+ */
565
+ function gateCascadeVerification(workspaceRoot, context, taskMeta) {
566
+ const gate = WORKSPACE_GATES.find(g => g.name === 'cascadeVerification');
567
+
568
+ if (!context.currentMember || context.currentMember.role !== 'library') {
569
+ return { passed: true, message: 'Not a library repo — cascade check skipped', severity: gate.severity };
570
+ }
571
+
572
+ if (!context.manifest) {
573
+ return { passed: true, message: 'No manifest available', severity: gate.severity };
574
+ }
575
+
576
+ const graph = buildDependencyGraph(context.manifest);
577
+ const consumers = getCascadeTargets(context.currentMember.name, context.manifest, graph);
578
+
579
+ if (consumers.length === 0) {
580
+ return { passed: true, message: 'No consumers to notify', severity: gate.severity };
581
+ }
582
+
583
+ // Check that messages were sent to ALL consumers
584
+ try {
585
+ const { readMessages } = require('./workspace-messages');
586
+ const recentMessages = readMessages(workspaceRoot, {
587
+ from: context.currentMember.name
588
+ });
589
+
590
+ const oneHourAgo = Date.now() - (60 * 60 * 1000);
591
+ const notifiedPeers = new Set(
592
+ recentMessages
593
+ .filter(m => new Date(m.timestamp).getTime() > oneHourAgo)
594
+ .map(m => m.to)
595
+ );
596
+
597
+ const unnotified = consumers.filter(c => !notifiedPeers.has(c));
598
+
599
+ if (unnotified.length === 0) {
600
+ return { passed: true, message: `All ${consumers.length} consumer(s) notified`, severity: gate.severity };
601
+ }
602
+
603
+ return {
604
+ passed: false,
605
+ message: `Library change: ${unnotified.length} consumer(s) not notified: ${unnotified.join(', ')}`,
606
+ severity: gate.severity
607
+ };
608
+ } catch (_err) {
609
+ return { passed: true, message: 'Cascade check skipped (error)', severity: 'warning' };
610
+ }
611
+ }
612
+
613
+ /**
614
+ * Gate: integrationMapFreshness
615
+ * Verify the integration map is not stale.
616
+ */
617
+ function gateIntegrationMapFreshness(workspaceRoot) {
618
+ const gate = WORKSPACE_GATES.find(g => g.name === 'integrationMapFreshness');
619
+ const mapPath = path.join(workspaceRoot, WORKSPACE_DIR, 'state', 'integration-map.md');
620
+
621
+ try {
622
+ if (!fs.existsSync(mapPath)) {
623
+ return {
624
+ passed: false,
625
+ message: 'Integration map does not exist. Run `flow workspace sync`.',
626
+ severity: gate.severity
627
+ };
628
+ }
629
+
630
+ const stat = fs.statSync(mapPath);
631
+ const age = Date.now() - stat.mtime.getTime();
632
+
633
+ if (age > MAP_FRESHNESS_MS) {
634
+ const hours = Math.round(age / (60 * 60 * 1000));
635
+ return {
636
+ passed: false,
637
+ message: `Integration map is ${hours}h old (threshold: 24h). Run \`flow workspace sync\`.`,
638
+ severity: gate.severity
639
+ };
640
+ }
641
+
642
+ return { passed: true, message: 'Integration map is fresh', severity: gate.severity };
643
+ } catch (_err) {
644
+ return { passed: true, message: 'Freshness check skipped', severity: 'warning' };
645
+ }
646
+ }
647
+
648
+ // ============================================================
649
+ // Gate List & Injection
650
+ // ============================================================
651
+
652
+ /**
653
+ * Get the list of workspace quality gates that should be injected
654
+ * for the current task type.
655
+ *
656
+ * @param {string} taskType — 'feature', 'bugfix', 'refactor', etc.
657
+ * @param {Object} context — from loadWorkspaceContext()
658
+ * @returns {Array<Object>} applicable gates
659
+ */
660
+ function getWorkspaceQualityGates(taskType, context) {
661
+ if (!context.currentMember) return [];
662
+
663
+ // All gates apply to features and refactors
664
+ if (['feature', 'refactor', 'story'].includes(taskType)) {
665
+ return [...WORKSPACE_GATES];
666
+ }
667
+
668
+ // Bugfixes: only check contract compliance and notification
669
+ if (taskType === 'bugfix' || taskType === 'fix') {
670
+ return WORKSPACE_GATES.filter(g =>
671
+ ['contractCompliance', 'peerNotification'].includes(g.name)
672
+ );
673
+ }
674
+
675
+ // Chores/docs: only freshness check
676
+ if (['chore', 'docs'].includes(taskType)) {
677
+ return WORKSPACE_GATES.filter(g => g.name === 'integrationMapFreshness');
678
+ }
679
+
680
+ // Default: all gates
681
+ return [...WORKSPACE_GATES];
682
+ }
683
+
684
+ /**
685
+ * Run all applicable workspace gates and return consolidated results.
686
+ *
687
+ * @param {string} workspaceRoot
688
+ * @param {Object} context
689
+ * @param {Object} taskMeta — { taskId, taskTitle, taskType, changedFiles, impactAssessed }
690
+ * @returns {{ passed: boolean, results: Array<Object>, errors: number, warnings: number }}
691
+ */
692
+ function runAllWorkspaceGates(workspaceRoot, context, taskMeta = {}) {
693
+ const gates = getWorkspaceQualityGates(taskMeta.taskType || 'feature', context);
694
+ const results = [];
695
+ let errors = 0;
696
+ let warnings = 0;
697
+
698
+ for (const gate of gates) {
699
+ const result = runWorkspaceGate(gate.name, workspaceRoot, context, taskMeta);
700
+ results.push({ gate: gate.name, ...result });
701
+
702
+ if (!result.passed) {
703
+ if (result.severity === 'error') errors++;
704
+ else warnings++;
705
+ }
706
+ }
707
+
708
+ return {
709
+ passed: errors === 0,
710
+ results,
711
+ errors,
712
+ warnings
713
+ };
714
+ }
715
+
716
+ // ============================================================
717
+ // Exports
718
+ // ============================================================
719
+
720
+ module.exports = {
721
+ // Detection
722
+ workspaceActive,
723
+ identifyCurrentMember,
724
+
725
+ // Context
726
+ loadWorkspaceContext,
727
+
728
+ // Impact analysis
729
+ analyzeTaskImpact,
730
+ broadcastHeadsUp,
731
+
732
+ // Post-change
733
+ broadcastPostChange,
734
+
735
+ // Quality gates
736
+ WORKSPACE_GATES,
737
+ getWorkspaceQualityGates,
738
+ runWorkspaceGate,
739
+ runAllWorkspaceGates
740
+ };