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,371 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Workspace — Interface Lock Mechanism
5
+ *
6
+ * Prevents concurrent modification of shared interfaces by different repos.
7
+ * When a worker starts modifying a shared endpoint/type, it acquires a lock.
8
+ * Other workers get warned if they try to modify the same interface.
9
+ *
10
+ * Locks are:
11
+ * - File-based (.workspace/state/locks/)
12
+ * - Auto-expiring (configurable TTL, default 30 minutes)
13
+ * - Best-effort (advisory, not mandatory — warns but doesn't hard-block)
14
+ *
15
+ * Lock file format: { interface, owner, taskId, acquiredAt, expiresAt }
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+ const crypto = require('node:crypto');
23
+
24
+ // ============================================================
25
+ // Constants
26
+ // ============================================================
27
+
28
+ const LOCKS_DIR_NAME = 'locks';
29
+ const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes
30
+ const MAX_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours cap
31
+ const LOCK_ID_PATTERN = /^lock-[a-f0-9]{8}$/;
32
+ const VALID_NAME_PATTERN = /^[a-zA-Z0-9_\-/.:{} ]{1,256}$/;
33
+
34
+ // ============================================================
35
+ // Lock Directory
36
+ // ============================================================
37
+
38
+ /**
39
+ * Get the locks directory path, ensuring it exists.
40
+ * @param {string} workspaceRoot
41
+ * @returns {string} locks directory path
42
+ */
43
+ function getLocksDir(workspaceRoot) {
44
+ const dir = path.join(workspaceRoot, '.workspace', 'state', LOCKS_DIR_NAME);
45
+ fs.mkdirSync(dir, { recursive: true });
46
+ return dir;
47
+ }
48
+
49
+ /**
50
+ * Generate a unique lock ID.
51
+ * @returns {string} lock-XXXXXXXX
52
+ */
53
+ function generateLockId() {
54
+ return 'lock-' + crypto.randomBytes(4).toString('hex');
55
+ }
56
+
57
+ // ============================================================
58
+ // Lock Operations
59
+ // ============================================================
60
+
61
+ /**
62
+ * Acquire a lock on a shared interface.
63
+ *
64
+ * @param {string} workspaceRoot
65
+ * @param {Object} params
66
+ * @param {string} params.interface — the endpoint/type being locked (e.g., "GET /api/users", "UserDTO")
67
+ * @param {string} params.owner — the repo name acquiring the lock
68
+ * @param {string} [params.taskId] — optional task ID for traceability
69
+ * @param {number} [params.ttlMs] — lock TTL in milliseconds (default: 30 min)
70
+ * @returns {{ acquired: boolean, lockId: string|null, conflict: Object|null }}
71
+ */
72
+ function acquireLock(workspaceRoot, params) {
73
+ const { interface: iface, owner, taskId = '', ttlMs = DEFAULT_TTL_MS } = params;
74
+
75
+ if (!iface || !VALID_NAME_PATTERN.test(iface)) {
76
+ return { acquired: false, lockId: null, conflict: null, error: 'Invalid interface name' };
77
+ }
78
+ if (!owner || !VALID_NAME_PATTERN.test(owner)) {
79
+ return { acquired: false, lockId: null, conflict: null, error: 'Invalid owner name' };
80
+ }
81
+
82
+ const locksDir = getLocksDir(workspaceRoot);
83
+ const effectiveTtl = Math.min(ttlMs, MAX_TTL_MS);
84
+
85
+ // Check for existing lock on this interface
86
+ const existing = findLockForInterface(workspaceRoot, iface);
87
+ if (existing) {
88
+ // Check if expired
89
+ if (new Date(existing.expiresAt).getTime() < Date.now()) {
90
+ // Expired — clean up and proceed
91
+ releaseLock(workspaceRoot, existing.id);
92
+ } else if (existing.owner === owner) {
93
+ // Same owner — extend the lock
94
+ existing.expiresAt = new Date(Date.now() + effectiveTtl).toISOString();
95
+ if (taskId) existing.taskId = taskId;
96
+ const lockPath = path.join(locksDir, `${existing.id}.json`);
97
+ fs.writeFileSync(lockPath, JSON.stringify(existing, null, 2));
98
+ return { acquired: true, lockId: existing.id, conflict: null };
99
+ } else {
100
+ // Conflict — another repo holds the lock
101
+ return { acquired: false, lockId: null, conflict: existing };
102
+ }
103
+ }
104
+
105
+ // Create new lock using exclusive create flag to prevent TOCTOU races.
106
+ // If another process creates the same lock file between our check and write,
107
+ // the 'wx' flag will throw EEXIST, and we retry with a new ID.
108
+ const lockId = generateLockId();
109
+ const lock = {
110
+ id: lockId,
111
+ interface: iface,
112
+ owner,
113
+ taskId,
114
+ acquiredAt: new Date().toISOString(),
115
+ expiresAt: new Date(Date.now() + effectiveTtl).toISOString()
116
+ };
117
+
118
+ const lockPath = path.join(locksDir, `${lockId}.json`);
119
+ try {
120
+ fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2), { flag: 'wx' });
121
+ } catch (err) {
122
+ if (err.code === 'EEXIST') {
123
+ // Extremely unlikely with random IDs, but handle gracefully
124
+ return { acquired: false, lockId: null, conflict: null, error: 'Lock ID collision — retry' };
125
+ }
126
+ return { acquired: false, lockId: null, conflict: null, error: err.message };
127
+ }
128
+
129
+ return { acquired: true, lockId, conflict: null };
130
+ }
131
+
132
+ /**
133
+ * Release a lock.
134
+ *
135
+ * @param {string} workspaceRoot
136
+ * @param {string} lockId
137
+ * @returns {boolean} true if lock was found and removed
138
+ */
139
+ function releaseLock(workspaceRoot, lockId) {
140
+ if (!LOCK_ID_PATTERN.test(lockId)) return false;
141
+
142
+ const locksDir = getLocksDir(workspaceRoot);
143
+ const lockPath = path.join(locksDir, `${lockId}.json`);
144
+
145
+ try {
146
+ if (fs.existsSync(lockPath)) {
147
+ fs.unlinkSync(lockPath);
148
+ return true;
149
+ }
150
+ } catch (_err) {
151
+ // Best effort
152
+ }
153
+
154
+ return false;
155
+ }
156
+
157
+ /**
158
+ * Release all locks held by a specific owner (repo).
159
+ * Typically called on task completion or session end.
160
+ *
161
+ * @param {string} workspaceRoot
162
+ * @param {string} owner — repo name
163
+ * @returns {number} number of locks released
164
+ */
165
+ function releaseAllByOwner(workspaceRoot, owner) {
166
+ const locks = listLocks(workspaceRoot);
167
+ let released = 0;
168
+
169
+ for (const lock of locks) {
170
+ if (lock.owner === owner) {
171
+ if (releaseLock(workspaceRoot, lock.id)) {
172
+ released++;
173
+ }
174
+ }
175
+ }
176
+
177
+ return released;
178
+ }
179
+
180
+ /**
181
+ * Release all locks for a specific task.
182
+ *
183
+ * @param {string} workspaceRoot
184
+ * @param {string} taskId
185
+ * @returns {number} number of locks released
186
+ */
187
+ function releaseAllByTask(workspaceRoot, taskId) {
188
+ const locks = listLocks(workspaceRoot);
189
+ let released = 0;
190
+
191
+ for (const lock of locks) {
192
+ if (lock.taskId === taskId) {
193
+ if (releaseLock(workspaceRoot, lock.id)) {
194
+ released++;
195
+ }
196
+ }
197
+ }
198
+
199
+ return released;
200
+ }
201
+
202
+ // ============================================================
203
+ // Lock Queries
204
+ // ============================================================
205
+
206
+ /**
207
+ * Find an active (non-expired) lock for a specific interface.
208
+ *
209
+ * @param {string} workspaceRoot
210
+ * @param {string} iface — interface name
211
+ * @returns {Object|null} lock object or null
212
+ */
213
+ function findLockForInterface(workspaceRoot, iface) {
214
+ const locks = listLocks(workspaceRoot);
215
+ const now = Date.now();
216
+ const ifaceLower = iface.toLowerCase();
217
+
218
+ for (const lock of locks) {
219
+ if (lock.interface.toLowerCase() === ifaceLower) {
220
+ if (new Date(lock.expiresAt).getTime() > now) {
221
+ return lock;
222
+ }
223
+ }
224
+ }
225
+
226
+ return null;
227
+ }
228
+
229
+ /**
230
+ * List all locks (including expired ones).
231
+ *
232
+ * @param {string} workspaceRoot
233
+ * @returns {Array<Object>} lock objects
234
+ */
235
+ function listLocks(workspaceRoot) {
236
+ const locksDir = getLocksDir(workspaceRoot);
237
+ const locks = [];
238
+
239
+ try {
240
+ const files = fs.readdirSync(locksDir).filter(f => f.endsWith('.json'));
241
+ for (const file of files) {
242
+ try {
243
+ const content = JSON.parse(fs.readFileSync(path.join(locksDir, file), 'utf-8'));
244
+ if (content.id && content.interface && content.owner) {
245
+ locks.push(content);
246
+ }
247
+ } catch (_err) {
248
+ // Skip malformed lock files
249
+ }
250
+ }
251
+ } catch (_err) {
252
+ // Locks dir doesn't exist yet
253
+ }
254
+
255
+ return locks;
256
+ }
257
+
258
+ /**
259
+ * List only active (non-expired) locks.
260
+ *
261
+ * @param {string} workspaceRoot
262
+ * @returns {Array<Object>} active locks
263
+ */
264
+ function listActiveLocks(workspaceRoot) {
265
+ const now = Date.now();
266
+ return listLocks(workspaceRoot).filter(l => new Date(l.expiresAt).getTime() > now);
267
+ }
268
+
269
+ /**
270
+ * Clean up all expired locks.
271
+ *
272
+ * @param {string} workspaceRoot
273
+ * @returns {number} number of expired locks removed
274
+ */
275
+ function cleanExpiredLocks(workspaceRoot) {
276
+ const now = Date.now();
277
+ const locks = listLocks(workspaceRoot);
278
+ let cleaned = 0;
279
+
280
+ for (const lock of locks) {
281
+ if (new Date(lock.expiresAt).getTime() <= now) {
282
+ if (releaseLock(workspaceRoot, lock.id)) {
283
+ cleaned++;
284
+ }
285
+ }
286
+ }
287
+
288
+ return cleaned;
289
+ }
290
+
291
+ /**
292
+ * Check if modifying a set of interfaces would conflict with any existing locks.
293
+ *
294
+ * @param {string} workspaceRoot
295
+ * @param {string[]} interfaces — interfaces to check
296
+ * @param {string} requestingRepo — the repo that wants to modify
297
+ * @returns {{ clear: boolean, conflicts: Array<Object> }}
298
+ */
299
+ function checkForConflicts(workspaceRoot, interfaces, requestingRepo) {
300
+ const conflicts = [];
301
+ const now = Date.now();
302
+
303
+ for (const iface of interfaces) {
304
+ const lock = findLockForInterface(workspaceRoot, iface);
305
+ if (lock && lock.owner !== requestingRepo && new Date(lock.expiresAt).getTime() > now) {
306
+ conflicts.push({
307
+ interface: iface,
308
+ heldBy: lock.owner,
309
+ taskId: lock.taskId,
310
+ acquiredAt: lock.acquiredAt,
311
+ expiresAt: lock.expiresAt
312
+ });
313
+ }
314
+ }
315
+
316
+ return { clear: conflicts.length === 0, conflicts };
317
+ }
318
+
319
+ /**
320
+ * Format locks for display.
321
+ *
322
+ * @param {Array<Object>} locks
323
+ * @returns {string} formatted text
324
+ */
325
+ function formatLocksForDisplay(locks) {
326
+ if (locks.length === 0) return 'No active locks.';
327
+
328
+ const now = Date.now();
329
+ const lines = [];
330
+
331
+ for (const lock of locks) {
332
+ const remaining = new Date(lock.expiresAt).getTime() - now;
333
+ const expired = remaining <= 0;
334
+ const timeStr = expired
335
+ ? 'EXPIRED'
336
+ : remaining < 60000
337
+ ? `${Math.round(remaining / 1000)}s remaining`
338
+ : `${Math.round(remaining / 60000)}m remaining`;
339
+
340
+ const icon = expired ? '🔓' : '🔒';
341
+ lines.push(` ${icon} ${lock.interface} — held by ${lock.owner}${lock.taskId ? ` (${lock.taskId})` : ''} [${timeStr}]`);
342
+ }
343
+
344
+ return lines.join('\n');
345
+ }
346
+
347
+ // ============================================================
348
+ // Exports
349
+ // ============================================================
350
+
351
+ module.exports = {
352
+ // Lock operations
353
+ acquireLock,
354
+ releaseLock,
355
+ releaseAllByOwner,
356
+ releaseAllByTask,
357
+
358
+ // Lock queries
359
+ findLockForInterface,
360
+ listLocks,
361
+ listActiveLocks,
362
+ cleanExpiredLocks,
363
+ checkForConflicts,
364
+
365
+ // Display
366
+ formatLocksForDisplay,
367
+
368
+ // Constants
369
+ DEFAULT_TTL_MS,
370
+ MAX_TTL_MS
371
+ };
@@ -22,7 +22,13 @@ const MESSAGE_TYPES = [
22
22
  'bug-report', // "Your endpoint returns 500 when I send Y"
23
23
  'task-complete', // "I finished my side of feature Z"
24
24
  'needs-help', // "I'm stuck, can you check X on your side?"
25
- 'heads-up' // "I'm about to change Y, just FYI"
25
+ 'heads-up', // "I'm about to change Y, just FYI"
26
+ 'impact-query', // Pre-dev: "I'm about to change X, will this break you?"
27
+ 'impact-response', // Pre-dev response: "Yes/No, here's what to watch out for"
28
+ 'verification-request', // Post-change: "Please verify your integrations"
29
+ 'lock-acquired', // "I'm editing shared interface X"
30
+ 'lock-released', // "Done editing shared interface X"
31
+ 'decision-broadcast' // "New workspace-wide decision: ..."
26
32
  ];
27
33
 
28
34
  const MESSAGE_STATUSES = ['pending', 'acknowledged', 'task-created', 'resolved'];
@@ -159,7 +165,15 @@ function updateMessageStatus(workspaceRoot, messageId, newStatus, extra = {}) {
159
165
  const message = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
160
166
  message.status = newStatus;
161
167
  message.updatedAt = new Date().toISOString();
162
- Object.assign(message, extra);
168
+ // Safe merge: filter dangerous keys to prevent prototype pollution
169
+ if (extra && typeof extra === 'object') {
170
+ const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
171
+ for (const [key, value] of Object.entries(extra)) {
172
+ if (!DANGEROUS_KEYS.has(key)) {
173
+ message[key] = value;
174
+ }
175
+ }
176
+ }
163
177
  fs.writeFileSync(filePath, JSON.stringify(message, null, 2));
164
178
  return message;
165
179
  } catch (_err) {
@@ -416,6 +430,182 @@ function readWorkspaceConfig(workspaceRoot) {
416
430
  }
417
431
  }
418
432
 
433
+ // ============================================================
434
+ // Peer Query Protocol (Pre-Dev Impact Queries)
435
+ // ============================================================
436
+
437
+ /**
438
+ * Send an impact query to a peer before making changes.
439
+ * This is a structured pre-dev check: "I'm about to change X, will this break you?"
440
+ *
441
+ * @param {string} fromRepo
442
+ * @param {string} toRepo
443
+ * @param {Object} params
444
+ * @param {string} params.taskTitle — what work is planned
445
+ * @param {string[]} [params.affectedEndpoints] — endpoints that will change
446
+ * @param {string[]} [params.affectedTypes] — types/schemas that will change
447
+ * @param {string} [params.changeDescription] — detailed description of planned changes
448
+ * @returns {Object} impact-query message (unsaved — caller must saveMessage)
449
+ */
450
+ function sendImpactQuery(fromRepo, toRepo, params) {
451
+ const { taskTitle, affectedEndpoints = [], affectedTypes = [], changeDescription = '' } = params;
452
+
453
+ let body = `## Pre-Dev Impact Query\n\n`;
454
+ body += `**Planned work**: ${taskTitle}\n\n`;
455
+ if (affectedEndpoints.length > 0) {
456
+ body += `**Endpoints that will change**:\n${affectedEndpoints.map(e => `- \`${e}\``).join('\n')}\n\n`;
457
+ }
458
+ if (affectedTypes.length > 0) {
459
+ body += `**Types/schemas that will change**:\n${affectedTypes.map(t => `- \`${t}\``).join('\n')}\n\n`;
460
+ }
461
+ if (changeDescription) {
462
+ body += `**Details**: ${changeDescription}\n\n`;
463
+ }
464
+ body += `**Please respond with**:\n`;
465
+ body += `1. Will this break anything on your side?\n`;
466
+ body += `2. Are there any endpoints/types I should be aware of?\n`;
467
+ body += `3. Any coordination needed?\n`;
468
+
469
+ return createMessage({
470
+ from: fromRepo,
471
+ to: toRepo,
472
+ type: 'impact-query',
473
+ subject: `Impact query: ${taskTitle.substring(0, 60)}`,
474
+ body,
475
+ priority: 'high',
476
+ actionRequired: true
477
+ });
478
+ }
479
+
480
+ /**
481
+ * Respond to an impact query from a peer.
482
+ *
483
+ * @param {string} workspaceRoot
484
+ * @param {string} originalMessageId — the impact-query being responded to
485
+ * @param {string} fromRepo — who is responding
486
+ * @param {Object} response
487
+ * @param {boolean} response.willBreak — will the planned changes break this repo?
488
+ * @param {string[]} [response.concerns] — specific concerns
489
+ * @param {string} [response.suggestion] — suggested approach
490
+ * @returns {Object} impact-response message (unsaved)
491
+ */
492
+ function respondToImpactQuery(workspaceRoot, originalMessageId, fromRepo, response) {
493
+ if (!MESSAGE_ID_PATTERN.test(originalMessageId)) {
494
+ throw new Error(`Invalid messageId: ${originalMessageId}. Must match msg-[a-f0-9]{8}`);
495
+ }
496
+
497
+ // Mark original as acknowledged
498
+ updateMessageStatus(workspaceRoot, originalMessageId, 'acknowledged');
499
+
500
+ // Read original to get sender
501
+ const filePath = path.join(workspaceRoot, '.workspace', 'messages', `${originalMessageId}.json`);
502
+ let originalFrom = 'unknown';
503
+ try {
504
+ const original = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
505
+ originalFrom = original.from;
506
+ } catch (_err) {
507
+ // Non-critical
508
+ }
509
+
510
+ const { willBreak, concerns = [], suggestion = '' } = response;
511
+ let body = `## Impact Response\n\n`;
512
+ body += `**Will break**: ${willBreak ? 'YES' : 'No'}\n\n`;
513
+ if (concerns.length > 0) {
514
+ body += `**Concerns**:\n${concerns.map(c => `- ${c}`).join('\n')}\n\n`;
515
+ }
516
+ if (suggestion) {
517
+ body += `**Suggestion**: ${suggestion}\n`;
518
+ }
519
+
520
+ return createMessage({
521
+ from: fromRepo,
522
+ to: originalFrom,
523
+ type: 'impact-response',
524
+ subject: `Re: ${originalMessageId} — ${willBreak ? 'BREAKING' : 'OK'}`,
525
+ body,
526
+ priority: willBreak ? 'critical' : 'medium',
527
+ actionRequired: willBreak
528
+ });
529
+ }
530
+
531
+ // ============================================================
532
+ // Verification Requests (Post-Change)
533
+ // ============================================================
534
+
535
+ /**
536
+ * Create a verification request for a consumer repo after provider changes.
537
+ *
538
+ * @param {string} fromRepo — the repo that made changes
539
+ * @param {string} toRepo — the consumer that needs to verify
540
+ * @param {Object} params
541
+ * @param {string} params.taskTitle — what was changed
542
+ * @param {string[]} [params.changedEndpoints] — endpoints that changed
543
+ * @param {string[]} [params.changedTypes] — types that changed
544
+ * @param {boolean} [params.contractDriftDetected] — if drift was found
545
+ * @returns {Object} verification-request message (unsaved)
546
+ */
547
+ function createVerificationRequest(fromRepo, toRepo, params) {
548
+ const { taskTitle, changedEndpoints = [], changedTypes = [], contractDriftDetected = false } = params;
549
+
550
+ let body = `## Verification Required\n\n`;
551
+ body += `Repo \`${fromRepo}\` completed: **${taskTitle}**\n\n`;
552
+ if (changedEndpoints.length > 0) {
553
+ body += `**Changed endpoints**:\n${changedEndpoints.map(e => `- \`${e}\``).join('\n')}\n\n`;
554
+ }
555
+ if (changedTypes.length > 0) {
556
+ body += `**Changed types**:\n${changedTypes.map(t => `- \`${t}\``).join('\n')}\n\n`;
557
+ }
558
+ if (contractDriftDetected) {
559
+ body += `**WARNING**: Contract drift detected — your integration may be broken.\n\n`;
560
+ }
561
+ body += `**Action**: Please verify your API calls and type usage still match.\n`;
562
+
563
+ return createMessage({
564
+ from: fromRepo,
565
+ to: toRepo,
566
+ type: 'verification-request',
567
+ subject: `Verify: ${fromRepo} changed ${taskTitle.substring(0, 50)}`,
568
+ body,
569
+ priority: contractDriftDetected ? 'critical' : 'high',
570
+ actionRequired: true,
571
+ suggestedTask: {
572
+ title: `Verify integrations after ${fromRepo} changes — ${taskTitle.substring(0, 40)}`,
573
+ type: 'fix',
574
+ priority: contractDriftDetected ? 'P0' : 'P1'
575
+ }
576
+ });
577
+ }
578
+
579
+ // ============================================================
580
+ // Decision Broadcast
581
+ // ============================================================
582
+
583
+ /**
584
+ * Broadcast a workspace-wide decision to all members.
585
+ *
586
+ * @param {string} fromRepo — the repo that made the decision
587
+ * @param {string} decisionTitle
588
+ * @param {string} decisionContent
589
+ * @param {string[]} targetRepos — list of member repo names
590
+ * @returns {Array<Object>} messages (unsaved)
591
+ */
592
+ function broadcastDecision(fromRepo, decisionTitle, decisionContent, targetRepos) {
593
+ const messages = [];
594
+ for (const target of targetRepos) {
595
+ if (target === fromRepo) continue;
596
+ messages.push(createMessage({
597
+ from: fromRepo,
598
+ to: target,
599
+ type: 'decision-broadcast',
600
+ subject: `Decision: ${decisionTitle.substring(0, 60)}`,
601
+ body: `## New Workspace Decision\n\n### ${decisionTitle}\n\n${decisionContent}\n\n*This decision applies workspace-wide. Please follow it in your repo.*`,
602
+ priority: 'high',
603
+ actionRequired: false
604
+ }));
605
+ }
606
+ return messages;
607
+ }
608
+
419
609
  // ============================================================
420
610
  // Exports
421
611
  // ============================================================
@@ -444,5 +634,15 @@ module.exports = {
444
634
 
445
635
  // Agent questions
446
636
  askQuestion,
447
- answerQuestion
637
+ answerQuestion,
638
+
639
+ // Peer query protocol (pre-dev)
640
+ sendImpactQuery,
641
+ respondToImpactQuery,
642
+
643
+ // Verification requests (post-change)
644
+ createVerificationRequest,
645
+
646
+ // Decision broadcast
647
+ broadcastDecision
448
648
  };