wogiflow 2.6.4 → 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 (29) 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/package.json +1 -1
  13. package/scripts/flow-done-gates.js +70 -0
  14. package/.claude/rules/_internal/README.md +0 -64
  15. package/.claude/rules/_internal/document-structure.md +0 -77
  16. package/.claude/rules/_internal/dual-repo-management.md +0 -174
  17. package/.claude/rules/_internal/feature-refactoring-cleanup.md +0 -87
  18. package/.claude/rules/_internal/github-releases.md +0 -71
  19. package/.claude/rules/_internal/model-management.md +0 -35
  20. package/.claude/rules/_internal/self-maintenance.md +0 -87
  21. package/.claude/rules/architecture/component-reuse.md +0 -38
  22. package/.claude/rules/code-style/naming-conventions.md +0 -107
  23. package/.claude/rules/operations/git-workflows.md +0 -92
  24. package/.claude/rules/operations/scratch-directory.md +0 -54
  25. package/.claude/rules/security/security-patterns.md +0 -176
  26. package/.claude/skills/figma-analyzer/knowledge/learnings.md +0 -11
  27. package/.workflow/specs/architecture.md.template +0 -24
  28. package/.workflow/specs/stack.md.template +0 -33
  29. package/.workflow/specs/testing.md.template +0 -36
@@ -36,7 +36,6 @@
36
36
  ],
37
37
  "PostToolUse": [
38
38
  {
39
- "matcher": "Edit|Write|Bash",
40
39
  "hooks": [
41
40
  {
42
41
  "type": "command",
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Workspace — Cross-Repo Changelog Aggregation
5
+ *
6
+ * Aggregates request-log entries from all member repos into a unified
7
+ * timeline showing the full story of cross-repo changes.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+
15
+ const { WORKSPACE_CONFIG_FILE } = require('./workspace');
16
+
17
+ // ============================================================
18
+ // Request Log Parsing
19
+ // ============================================================
20
+
21
+ /**
22
+ * Parse a member's request-log.md into structured entries.
23
+ *
24
+ * @param {string} logContent — raw request-log.md content
25
+ * @param {string} repoName — name of the repo
26
+ * @returns {Array<Object>} parsed entries
27
+ */
28
+ function parseRequestLog(logContent, repoName) {
29
+ const entries = [];
30
+ const sections = logContent.split(/^### /m).filter(Boolean);
31
+
32
+ for (const section of sections) {
33
+ const lines = section.trim().split('\n');
34
+ const header = lines[0] || '';
35
+
36
+ // Parse header: R-XXX | YYYY-MM-DD HH:MM
37
+ const headerMatch = header.match(/R-(\d+)\s*\|\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})/);
38
+ if (!headerMatch) continue;
39
+
40
+ const entry = {
41
+ repo: repoName,
42
+ id: `R-${headerMatch[1]}`,
43
+ date: headerMatch[2],
44
+ time: headerMatch[3],
45
+ // Note: request-log times are treated as local time (no timezone in format)
46
+ timestamp: new Date(`${headerMatch[2]}T${headerMatch[3]}:00`).toISOString(),
47
+ type: '',
48
+ tags: [],
49
+ request: '',
50
+ result: '',
51
+ files: []
52
+ };
53
+
54
+ // Parse fields
55
+ for (const line of lines.slice(1)) {
56
+ const typeMatch = line.match(/\*\*Type\*\*:\s*(.+)/);
57
+ if (typeMatch) entry.type = typeMatch[1].trim();
58
+
59
+ const tagsMatch = line.match(/\*\*Tags\*\*:\s*(.+)/);
60
+ if (tagsMatch) entry.tags = tagsMatch[1].trim().split(/\s+/);
61
+
62
+ const reqMatch = line.match(/\*\*Request\*\*:\s*"?(.+?)"?\s*$/);
63
+ if (reqMatch) entry.request = reqMatch[1].trim();
64
+
65
+ const resMatch = line.match(/\*\*Result\*\*:\s*(.+)/);
66
+ if (resMatch) entry.result = resMatch[1].trim();
67
+
68
+ const filesMatch = line.match(/\*\*Files\*\*:\s*(.+)/);
69
+ if (filesMatch) entry.files = filesMatch[1].trim().split(/[,\s]+/).filter(Boolean);
70
+ }
71
+
72
+ entries.push(entry);
73
+ }
74
+
75
+ return entries;
76
+ }
77
+
78
+ // ============================================================
79
+ // Changelog Aggregation
80
+ // ============================================================
81
+
82
+ /**
83
+ * Aggregate request logs from all workspace members into a unified timeline.
84
+ *
85
+ * @param {string} workspaceRoot
86
+ * @param {Object} [options]
87
+ * @param {string} [options.since] — only entries after this date (YYYY-MM-DD)
88
+ * @param {number} [options.limit] — max entries (default: 50)
89
+ * @returns {{ entries: Array<Object>, memberCount: number, totalEntries: number }}
90
+ */
91
+ function aggregateChangelogs(workspaceRoot, options = {}) {
92
+ const { since = '', limit = 50 } = options;
93
+ const configPath = path.join(workspaceRoot, WORKSPACE_CONFIG_FILE);
94
+
95
+ let config;
96
+ try {
97
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
98
+ } catch (_err) {
99
+ return { entries: [], memberCount: 0, totalEntries: 0 };
100
+ }
101
+
102
+ let allEntries = [];
103
+ let memberCount = 0;
104
+
105
+ for (const [name, memberConfig] of Object.entries(config.members || {})) {
106
+ const memberPath = path.resolve(workspaceRoot, memberConfig.path);
107
+ const logPath = path.join(memberPath, '.workflow', 'state', 'request-log.md');
108
+
109
+ try {
110
+ if (!fs.existsSync(logPath)) continue;
111
+ const content = fs.readFileSync(logPath, 'utf-8');
112
+ const entries = parseRequestLog(content, name);
113
+ allEntries = allEntries.concat(entries);
114
+ memberCount++;
115
+ } catch (_err) {
116
+ // Skip unreadable logs
117
+ }
118
+ }
119
+
120
+ // Filter by date if specified
121
+ if (since) {
122
+ const sinceTime = new Date(since).getTime();
123
+ allEntries = allEntries.filter(e => new Date(e.timestamp).getTime() >= sinceTime);
124
+ }
125
+
126
+ // Sort by timestamp (newest first)
127
+ allEntries.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
128
+
129
+ const totalEntries = allEntries.length;
130
+
131
+ // Apply limit
132
+ if (limit > 0) {
133
+ allEntries = allEntries.slice(0, limit);
134
+ }
135
+
136
+ return { entries: allEntries, memberCount, totalEntries };
137
+ }
138
+
139
+ /**
140
+ * Format aggregated changelog as markdown.
141
+ *
142
+ * @param {Object} result — from aggregateChangelogs()
143
+ * @returns {string} formatted markdown
144
+ */
145
+ function formatAggregatedChangelog(result) {
146
+ const lines = [
147
+ '# Workspace Changelog',
148
+ '',
149
+ `*${result.memberCount} repos, ${result.totalEntries} entries*`,
150
+ ''
151
+ ];
152
+
153
+ let currentDate = '';
154
+ for (const entry of result.entries) {
155
+ if (entry.date !== currentDate) {
156
+ currentDate = entry.date;
157
+ lines.push(`## ${currentDate}`);
158
+ lines.push('');
159
+ }
160
+
161
+ const typeIcon = {
162
+ new: '+',
163
+ fix: '!',
164
+ change: '~',
165
+ refactor: '>'
166
+ }[entry.type] || '*';
167
+
168
+ lines.push(`- \`${entry.time}\` **${entry.repo}** [${typeIcon}${entry.type}] ${entry.request || entry.result}`);
169
+ }
170
+
171
+ return lines.join('\n');
172
+ }
173
+
174
+ // ============================================================
175
+ // Exports
176
+ // ============================================================
177
+
178
+ module.exports = {
179
+ parseRequestLog,
180
+ aggregateChangelogs,
181
+ formatAggregatedChangelog
182
+ };
@@ -384,6 +384,59 @@ rl.on('line', (line) => {
384
384
  // HTTP Webhook Server (receives dispatches from manager/peers)
385
385
  // ============================================================
386
386
 
387
+ // ============================================================
388
+ // SSE Client Management (Event Bus)
389
+ // ============================================================
390
+
391
+ const sseClients = new Set();
392
+
393
+ function addSSEClient(res, lastEventId) {
394
+ res.writeHead(200, {
395
+ 'Content-Type': 'text/event-stream',
396
+ 'Cache-Control': 'no-cache',
397
+ Connection: 'keep-alive',
398
+ 'X-Accel-Buffering': 'no'
399
+ });
400
+ res.write(':ok\n\n');
401
+
402
+ // If workspace root is available, send missed events
403
+ if (WORKSPACE_ROOT && lastEventId) {
404
+ try {
405
+ const events = require('./workspace-events');
406
+ const missed = events.getEventsSince(WORKSPACE_ROOT, lastEventId);
407
+ for (const evt of missed) {
408
+ res.write(events.formatAsSSE(evt));
409
+ }
410
+ } catch (_err) {
411
+ // Non-critical
412
+ }
413
+ }
414
+
415
+ sseClients.add(res);
416
+ res.on('close', () => sseClients.delete(res));
417
+ }
418
+
419
+ function broadcastSSE(event) {
420
+ let formatted;
421
+ try {
422
+ const events = require('./workspace-events');
423
+ formatted = events.formatAsSSE(event);
424
+ } catch (_err) {
425
+ formatted = `data: ${JSON.stringify(event)}\n\n`;
426
+ }
427
+ for (const client of sseClients) {
428
+ try {
429
+ client.write(formatted);
430
+ } catch (_err) {
431
+ sseClients.delete(client);
432
+ }
433
+ }
434
+ }
435
+
436
+ // ============================================================
437
+ // HTTP Server
438
+ // ============================================================
439
+
387
440
  const server = http.createServer(async (req, res) => {
388
441
  // Health check — minimal info, no topology exposure
389
442
  if (req.method === 'GET' && req.url === '/health') {
@@ -392,6 +445,13 @@ const server = http.createServer(async (req, res) => {
392
445
  return;
393
446
  }
394
447
 
448
+ // SSE endpoint for event subscriptions
449
+ if (req.method === 'GET' && req.url?.startsWith('/events')) {
450
+ const lastEventId = req.headers['last-event-id'] || '';
451
+ addSSEClient(res, lastEventId);
452
+ return;
453
+ }
454
+
395
455
  // Receive webhook (POST)
396
456
  if (req.method === 'POST') {
397
457
  const { body, truncated } = await collectBody(req, MAX_BODY_BYTES);
@@ -406,12 +466,25 @@ const server = http.createServer(async (req, res) => {
406
466
  const from = req.headers['x-wogi-from'] || 'workspace-manager';
407
467
 
408
468
  // Forward as channel notification to Claude Code
409
- sendChannelNotification(body, {
469
+ const meta = {
410
470
  from,
411
471
  port: String(PORT),
412
472
  repo: REPO_NAME,
413
473
  receivedAt: new Date().toISOString()
414
- });
474
+ };
475
+ sendChannelNotification(body, meta);
476
+
477
+ // Also broadcast to SSE subscribers
478
+ if (sseClients.size > 0) {
479
+ const crypto = require('node:crypto');
480
+ broadcastSSE({
481
+ id: 'evt-' + crypto.randomBytes(4).toString('hex'),
482
+ type: 'webhook-received',
483
+ source: from,
484
+ data: { body: body.substring(0, 500) },
485
+ timestamp: meta.receivedAt
486
+ });
487
+ }
415
488
 
416
489
  res.writeHead(200, { 'Content-Type': 'text/plain' });
417
490
  res.end('ok');
@@ -571,6 +571,152 @@ function writeContract(workspaceRoot, contractName, format, content, changedBy,
571
571
  return trackContractVersion(workspaceRoot, contractName, serialized, changedBy, reason);
572
572
  }
573
573
 
574
+ // ============================================================
575
+ // Schema/Type Sync Enforcement
576
+ // ============================================================
577
+
578
+ /**
579
+ * Auto-generate shared TypeScript interfaces from all providers' schemas.
580
+ * Writes to .workspace/contracts/shared-types.d.ts so consumers can import.
581
+ *
582
+ * @param {string} workspaceRoot
583
+ * @param {Object} manifest — workspace manifest
584
+ * @returns {{ typesGenerated: number, filePath: string }}
585
+ */
586
+ function generateSharedTypes(workspaceRoot, manifest) {
587
+ const contractsDir = path.join(workspaceRoot, '.workspace', 'contracts');
588
+ fs.mkdirSync(contractsDir, { recursive: true });
589
+
590
+ const lines = [
591
+ '/**',
592
+ ' * Auto-generated shared types from workspace providers.',
593
+ ` * Generated: ${new Date().toISOString()}`,
594
+ ' * DO NOT EDIT — regenerated by `flow workspace sync`',
595
+ ' */',
596
+ ''
597
+ ];
598
+
599
+ let typesGenerated = 0;
600
+ const seenTypes = new Map(); // Track types across repos to detect conflicts
601
+
602
+ // Strict identifier validation to prevent code injection via manifest data
603
+ const VALID_TS_IDENTIFIER = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
604
+ const VALID_REPO_NAME = /^[a-zA-Z0-9_-]+$/;
605
+
606
+ for (const [name, member] of Object.entries(manifest.members || {})) {
607
+ if (member.role !== 'provider' && member.role !== 'both' && member.role !== 'library') continue;
608
+
609
+ const schemas = member.schemas || [];
610
+ if (schemas.length === 0) continue;
611
+
612
+ const safeName = VALID_REPO_NAME.test(name) ? name : 'unknown';
613
+ const safeRole = VALID_REPO_NAME.test(member.role) ? member.role : 'unknown';
614
+ lines.push(`// ── From ${safeName} (${safeRole}) ${'─'.repeat(Math.max(1, 50 - safeName.length))}`);
615
+ lines.push('');
616
+
617
+ for (const schema of schemas) {
618
+ const typeName = schema.name || schema;
619
+ const fields = schema.fields || [];
620
+
621
+ // Validate type name is a safe TypeScript identifier
622
+ if (!VALID_TS_IDENTIFIER.test(typeName)) {
623
+ lines.push(`// SKIPPED: Invalid type name "${String(typeName).replace(/[^a-zA-Z0-9_$ ]/g, '')}"`);
624
+ continue;
625
+ }
626
+
627
+ // Track for conflict detection
628
+ if (seenTypes.has(typeName)) {
629
+ const prevDef = VALID_REPO_NAME.test(seenTypes.get(typeName)) ? seenTypes.get(typeName) : 'unknown';
630
+ lines.push(`// WARNING: Type "${typeName}" also defined by ${prevDef}`);
631
+ }
632
+ seenTypes.set(typeName, name);
633
+
634
+ lines.push(`export interface ${typeName} {`);
635
+ for (const field of fields) {
636
+ const fieldName = field.name || field;
637
+ // Validate field name is a safe identifier
638
+ if (!VALID_TS_IDENTIFIER.test(fieldName)) continue;
639
+ const fieldType = mapToTsType(field.type || 'string');
640
+ const optional = field.nullable || field.optional ? '?' : '';
641
+ lines.push(` ${fieldName}${optional}: ${fieldType};`);
642
+ }
643
+ lines.push('}');
644
+ lines.push('');
645
+ typesGenerated++;
646
+ }
647
+ }
648
+
649
+ if (typesGenerated === 0) {
650
+ lines.push('// No provider schemas found in workspace manifest.');
651
+ lines.push('// Run `flow workspace sync` after adding provider repos.');
652
+ }
653
+
654
+ const filePath = path.join(contractsDir, 'shared-types.d.ts');
655
+ fs.writeFileSync(filePath, lines.join('\n'));
656
+
657
+ // Track version
658
+ const content = lines.join('\n');
659
+ try {
660
+ trackContractVersion(workspaceRoot, 'shared-types', content, 'workspace-sync', 'Auto-generated from provider schemas');
661
+ } catch (_err) {
662
+ // Non-critical
663
+ }
664
+
665
+ return { typesGenerated, filePath };
666
+ }
667
+
668
+ /**
669
+ * Verify that consumer repos reference shared types correctly.
670
+ * Checks if consumers have local type definitions that duplicate shared types.
671
+ *
672
+ * @param {string} workspaceRoot
673
+ * @param {Object} manifest
674
+ * @returns {Array<{ consumer: string, type: string, issue: string }>}
675
+ */
676
+ function checkTypeSyncCompliance(workspaceRoot, manifest) {
677
+ const issues = [];
678
+ const sharedTypesPath = path.join(workspaceRoot, '.workspace', 'contracts', 'shared-types.d.ts');
679
+
680
+ if (!fs.existsSync(sharedTypesPath)) return issues;
681
+
682
+ // Extract type names from shared types
683
+ const sharedContent = fs.readFileSync(sharedTypesPath, 'utf-8');
684
+ const typeNames = [];
685
+ const typeRegex = /export interface (\w+)/g;
686
+ let match;
687
+ while ((match = typeRegex.exec(sharedContent)) !== null) {
688
+ typeNames.push(match[1]);
689
+ }
690
+
691
+ if (typeNames.length === 0) return issues;
692
+
693
+ // Check each consumer's schema-map for duplicates
694
+ const configPath = path.join(workspaceRoot, 'wogi-workspace.json');
695
+ try {
696
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
697
+
698
+ for (const [name, memberConfig] of Object.entries(config.members || {})) {
699
+ const member = manifest.members?.[name];
700
+ if (!member || member.role === 'provider') continue; // Only check consumers
701
+
702
+ const memberSchemas = (member.schemas || []).map(s => s.name || s);
703
+ for (const typeName of typeNames) {
704
+ if (memberSchemas.includes(typeName)) {
705
+ issues.push({
706
+ consumer: name,
707
+ type: typeName,
708
+ issue: `Consumer "${name}" defines "${typeName}" locally — should import from .workspace/contracts/shared-types.d.ts`
709
+ });
710
+ }
711
+ }
712
+ }
713
+ } catch (_err) {
714
+ // Non-critical
715
+ }
716
+
717
+ return issues;
718
+ }
719
+
574
720
  // ============================================================
575
721
  // Exports
576
722
  // ============================================================
@@ -595,5 +741,9 @@ module.exports = {
595
741
 
596
742
  // Multi-format
597
743
  detectContractFormat,
598
- writeContract
744
+ writeContract,
745
+
746
+ // Schema/type sync
747
+ generateSharedTypes,
748
+ checkTypeSyncCompliance
599
749
  };