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.
- package/.claude/settings.json +0 -1
- package/lib/workspace-changelog.js +182 -0
- package/lib/workspace-channel-server.js +75 -2
- package/lib/workspace-contracts.js +151 -1
- package/lib/workspace-events.js +383 -0
- package/lib/workspace-gates.js +740 -0
- package/lib/workspace-integration-tests.js +299 -0
- package/lib/workspace-intelligence.js +486 -1
- package/lib/workspace-locks.js +371 -0
- package/lib/workspace-messages.js +203 -3
- package/lib/workspace-routing.js +144 -0
- package/lib/workspace.js +18 -3
- package/package.json +1 -1
- package/scripts/flow-done-gates.js +70 -0
- package/.claude/rules/_internal/README.md +0 -64
- package/.claude/rules/_internal/document-structure.md +0 -77
- package/.claude/rules/_internal/dual-repo-management.md +0 -174
- package/.claude/rules/_internal/feature-refactoring-cleanup.md +0 -87
- package/.claude/rules/_internal/github-releases.md +0 -71
- package/.claude/rules/_internal/model-management.md +0 -35
- package/.claude/rules/_internal/self-maintenance.md +0 -87
- package/.claude/rules/architecture/component-reuse.md +0 -38
- package/.claude/rules/code-style/naming-conventions.md +0 -107
- package/.claude/rules/operations/git-workflows.md +0 -92
- package/.claude/rules/operations/scratch-directory.md +0 -54
- package/.claude/rules/security/security-patterns.md +0 -176
- package/.claude/skills/figma-analyzer/knowledge/learnings.md +0 -11
- package/.workflow/specs/architecture.md.template +0 -24
- package/.workflow/specs/stack.md.template +0 -33
- 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'
|
|
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
|
-
|
|
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
|
};
|