gemstack-ai 1.3.0 → 1.4.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/.gemstack/state.json +6 -6
- package/CHANGELOG.md +54 -0
- package/README.md +66 -9
- package/RELEASE_NOTES.md +40 -0
- package/{gemstack-ai-1.3.0.tgz → gemstack-ai-1.4.0.tgz} +0 -0
- package/package.json +2 -2
- package/specs/009-context-capsule/context-capsule.json +4 -4
- package/specs/010-agent-swarm-visual-qa/.gemstack.json +5 -0
- package/specs/010-agent-swarm-visual-qa/closure.json +59 -0
- package/specs/010-agent-swarm-visual-qa/plan.md +759 -0
- package/specs/010-agent-swarm-visual-qa/spec.md +842 -0
- package/specs/010-agent-swarm-visual-qa/swarm.json +49 -0
- package/specs/010-agent-swarm-visual-qa/tasks.md +873 -0
- package/specs/010-agent-swarm-visual-qa/visual-qa.json +41 -0
- package/src/cli.js +8 -0
- package/src/commands/context.js +1 -1
- package/src/commands/swarm.js +111 -0
- package/src/commands/verify.js +38 -0
- package/src/commands/visual.js +82 -0
- package/src/lib/closure-context.js +9 -1
- package/src/lib/swarm.js +639 -0
- package/src/lib/visual-qa.js +499 -0
package/src/lib/swarm.js
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemstack Swarm Coordination & Validation Engine (Upgrade E)
|
|
3
|
+
*
|
|
4
|
+
* Implements deterministic swarm planning, exclusive write partitions,
|
|
5
|
+
* separation of duties (AUTHOR != REVIEWER), context capsule projection,
|
|
6
|
+
* and Upgrade C safety gate interception.
|
|
7
|
+
*
|
|
8
|
+
* ZERO RUNTIME DEPENDENCIES - Node.js built-ins exclusively.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const crypto = require('node:crypto');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { normalizePath } = require('./hasher');
|
|
15
|
+
const { createFinding } = require('./findings');
|
|
16
|
+
|
|
17
|
+
const SWARM_SCHEMA_VERSION = '1.0.0';
|
|
18
|
+
const CANONICAL_ROLES = ['implementer', 'reviewer', 'security-auditor', 'coordinator'];
|
|
19
|
+
const DEFAULT_MAX_WORKERS = 4;
|
|
20
|
+
const HARD_MAX_WORKERS = 8;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parses and validates a swarm manifest against canonical schema v1.0.0.
|
|
24
|
+
*
|
|
25
|
+
* @param {string|object} input - Raw JSON string or parsed object
|
|
26
|
+
* @returns {object} Canonical parsed manifest
|
|
27
|
+
*/
|
|
28
|
+
function parseSwarmManifest(input) {
|
|
29
|
+
let parsed;
|
|
30
|
+
if (typeof input === 'string') {
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(input);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
const error = new Error(`Invalid JSON in swarm manifest: ${err.message}`);
|
|
35
|
+
error.code = 'SWARM_PARSE_ERROR';
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
} else if (input && typeof input === 'object') {
|
|
39
|
+
parsed = input;
|
|
40
|
+
} else {
|
|
41
|
+
const error = new Error('Swarm manifest input must be a JSON string or object.');
|
|
42
|
+
error.code = 'SWARM_INVALID_INPUT';
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return validateSwarmSchema(parsed);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validates the schema structure of a parsed swarm manifest.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} manifest
|
|
53
|
+
* @returns {object} Validated manifest
|
|
54
|
+
*/
|
|
55
|
+
function validateSwarmSchema(manifest) {
|
|
56
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
57
|
+
const err = new Error('Swarm manifest root must be a JSON object.');
|
|
58
|
+
err.code = 'SWARM_INVALID_SCHEMA';
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (manifest.version && manifest.version !== SWARM_SCHEMA_VERSION) {
|
|
63
|
+
const err = new Error(`Unsupported swarm schema version: "${manifest.version}". Expected "${SWARM_SCHEMA_VERSION}".`);
|
|
64
|
+
err.code = 'SWARM_INVALID_SCHEMA';
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!manifest.feature_id || typeof manifest.feature_id !== 'string') {
|
|
69
|
+
const err = new Error('Swarm manifest must declare a valid "feature_id".');
|
|
70
|
+
err.code = 'SWARM_INVALID_SCHEMA';
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!Array.isArray(manifest.waves)) {
|
|
75
|
+
const err = new Error('Swarm manifest must declare a "waves" array.');
|
|
76
|
+
err.code = 'SWARM_INVALID_SCHEMA';
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return manifest;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Serializes an object deterministically with UTF-16 code-unit sorted keys.
|
|
85
|
+
*
|
|
86
|
+
* @param {*} value
|
|
87
|
+
* @returns {string} Deterministic JSON string
|
|
88
|
+
*/
|
|
89
|
+
function canonicalSerialize(value) {
|
|
90
|
+
if (value === null || typeof value !== 'object') {
|
|
91
|
+
return JSON.stringify(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (Array.isArray(value)) {
|
|
95
|
+
const serializedItems = value.map(canonicalSerialize);
|
|
96
|
+
return '[' + serializedItems.join(',') + ']';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const keys = Object.keys(value).sort((a, b) => {
|
|
100
|
+
if (a < b) return -1;
|
|
101
|
+
if (a > b) return 1;
|
|
102
|
+
return 0;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const parts = [];
|
|
106
|
+
for (const k of keys) {
|
|
107
|
+
parts.push(JSON.stringify(k) + ':' + canonicalSerialize(value[k]));
|
|
108
|
+
}
|
|
109
|
+
return '{' + parts.join(',') + '}';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Validates worker IDs, role assignments, and checks that manifest is subordinate to specifications.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} manifest
|
|
116
|
+
* @param {object} [authoritativeSources={}]
|
|
117
|
+
* @returns {{ valid: boolean, findings: Array<object> }}
|
|
118
|
+
*/
|
|
119
|
+
function validateSwarmAuthority(manifest, authoritativeSources = {}) {
|
|
120
|
+
const findings = [];
|
|
121
|
+
|
|
122
|
+
// Check if manifest attempts to modify or contradict specification content
|
|
123
|
+
if (manifest.overrides_spec === true || manifest.alter_contracts === true) {
|
|
124
|
+
findings.push(createFinding({
|
|
125
|
+
code: 'SWARM_AUTHORITY_CONFLICT',
|
|
126
|
+
contractId: 'swarm-authority-subordinate',
|
|
127
|
+
phase: 'swarm',
|
|
128
|
+
location: `specs/${manifest.feature_id}/swarm.json`,
|
|
129
|
+
details: 'Swarm manifest attempted to declare authority overrides over specifications or contracts.'
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
valid: findings.length === 0,
|
|
135
|
+
findings
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Validates worker identity format and mechanical role permissions.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} task
|
|
143
|
+
* @returns {{ valid: boolean, findings: Array<object> }}
|
|
144
|
+
*/
|
|
145
|
+
function validateWorkerIdentityAndRole(task) {
|
|
146
|
+
const findings = [];
|
|
147
|
+
const workerId = task.worker_id;
|
|
148
|
+
const role = task.assigned_role;
|
|
149
|
+
|
|
150
|
+
if (!workerId || typeof workerId !== 'string' || workerId.trim().length === 0) {
|
|
151
|
+
findings.push(createFinding({
|
|
152
|
+
code: 'SWARM_ASSIGNMENT_INVALID',
|
|
153
|
+
contractId: 'swarm-authority-subordinate',
|
|
154
|
+
phase: 'swarm',
|
|
155
|
+
location: task.task_id || 'unknown',
|
|
156
|
+
details: 'Worker ID must be a non-empty explicit string.'
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!role || !CANONICAL_ROLES.includes(role)) {
|
|
161
|
+
findings.push(createFinding({
|
|
162
|
+
code: 'SWARM_ASSIGNMENT_INVALID',
|
|
163
|
+
contractId: 'swarm-authority-subordinate',
|
|
164
|
+
phase: 'swarm',
|
|
165
|
+
location: task.task_id || 'unknown',
|
|
166
|
+
details: `Assigned role "${role}" is not in canonical roles: ${CANONICAL_ROLES.join(', ')}.`
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
valid: findings.length === 0,
|
|
172
|
+
findings
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Validates that all tasks in swarm manifest reference valid tasks in tasks.md.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} tasksContent - Content of tasks.md
|
|
180
|
+
* @param {object} manifest - Swarm manifest
|
|
181
|
+
* @returns {{ valid: boolean, findings: Array<object> }}
|
|
182
|
+
*/
|
|
183
|
+
function resolveTaskOwnership(tasksContent, manifest) {
|
|
184
|
+
const findings = [];
|
|
185
|
+
const declaredTaskIds = new Set();
|
|
186
|
+
|
|
187
|
+
// Parse task IDs from tasks.md (e.g. "Task UE-T001", "TASK-001", "### UE-T001", etc.)
|
|
188
|
+
const lines = (tasksContent || '').split('\n');
|
|
189
|
+
for (const line of lines) {
|
|
190
|
+
const m = line.match(/\b(TASK-[A-Za-z0-9_-]+|UE-T[0-9]{3}|T[0-9]{3})\b/i);
|
|
191
|
+
if (m) {
|
|
192
|
+
declaredTaskIds.add(m[1].toUpperCase());
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const waves = manifest.waves || [];
|
|
197
|
+
for (const wave of waves) {
|
|
198
|
+
const tasks = wave.tasks || [];
|
|
199
|
+
for (const t of tasks) {
|
|
200
|
+
const tid = (t.task_id || '').toUpperCase();
|
|
201
|
+
if (!declaredTaskIds.has(tid) && declaredTaskIds.size > 0) {
|
|
202
|
+
findings.push(createFinding({
|
|
203
|
+
code: 'SWARM_TASK_ORPHANED',
|
|
204
|
+
contractId: 'swarm-authority-subordinate',
|
|
205
|
+
phase: 'swarm',
|
|
206
|
+
location: t.task_id || 'unknown',
|
|
207
|
+
details: `Task "${t.task_id}" in swarm manifest is not declared in authoritative tasks.md.`
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
valid: findings.length === 0,
|
|
215
|
+
findings
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Plans and groups parallel tasks into concurrent waves ensuring disjoint write sets.
|
|
221
|
+
*
|
|
222
|
+
* @param {Array<object>} tasks - Array of task objects with task_id and write_set
|
|
223
|
+
* @returns {Array<object>} Scheduled waves
|
|
224
|
+
*/
|
|
225
|
+
function planSwarmWaves(tasks) {
|
|
226
|
+
if (!Array.isArray(tasks)) return [];
|
|
227
|
+
|
|
228
|
+
const waves = [];
|
|
229
|
+
let remaining = [...tasks];
|
|
230
|
+
|
|
231
|
+
while (remaining.length > 0) {
|
|
232
|
+
const currentWaveTasks = [];
|
|
233
|
+
const currentWaveWriteSet = new Set();
|
|
234
|
+
const nextRemaining = [];
|
|
235
|
+
|
|
236
|
+
for (const task of remaining) {
|
|
237
|
+
const taskWrites = (task.write_set || []).map(normalizePath);
|
|
238
|
+
let collides = false;
|
|
239
|
+
|
|
240
|
+
for (const p of taskWrites) {
|
|
241
|
+
if (currentWaveWriteSet.has(p)) {
|
|
242
|
+
collides = true;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!collides && currentWaveTasks.length < DEFAULT_MAX_WORKERS) {
|
|
248
|
+
currentWaveTasks.push(task);
|
|
249
|
+
for (const p of taskWrites) {
|
|
250
|
+
currentWaveWriteSet.add(p);
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
nextRemaining.push(task);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (currentWaveTasks.length === 0 && nextRemaining.length > 0) {
|
|
258
|
+
// Force single task progress if deadlock occurs
|
|
259
|
+
currentWaveTasks.push(nextRemaining.shift());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
waves.push({
|
|
263
|
+
wave_index: waves.length + 1,
|
|
264
|
+
status: 'PLANNED',
|
|
265
|
+
tasks: currentWaveTasks
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
remaining = nextRemaining;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return waves;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Validates that concurrent tasks in a wave declare mutually disjoint write sets.
|
|
276
|
+
*
|
|
277
|
+
* @param {object} wave - Swarm wave object
|
|
278
|
+
* @returns {{ valid: boolean, findings: Array<object> }}
|
|
279
|
+
*/
|
|
280
|
+
function validateWritePartitions(wave) {
|
|
281
|
+
const findings = [];
|
|
282
|
+
const tasks = (wave && wave.tasks) || [];
|
|
283
|
+
const claimedPaths = new Map(); // path -> task_id
|
|
284
|
+
|
|
285
|
+
for (const t of tasks) {
|
|
286
|
+
const writes = (t.write_set || []).map(normalizePath);
|
|
287
|
+
for (const p of writes) {
|
|
288
|
+
if (claimedPaths.has(p)) {
|
|
289
|
+
const otherTaskId = claimedPaths.get(p);
|
|
290
|
+
findings.push(createFinding({
|
|
291
|
+
code: 'SWARM_WRITE_COLLISION',
|
|
292
|
+
contractId: 'exclusive-task-write-ownership',
|
|
293
|
+
phase: 'swarm',
|
|
294
|
+
location: p,
|
|
295
|
+
details: `Write collision on "${p}": concurrently claimed by "${otherTaskId}" and "${t.task_id}".`
|
|
296
|
+
}));
|
|
297
|
+
} else {
|
|
298
|
+
claimedPaths.set(p, t.task_id);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
valid: findings.length === 0,
|
|
305
|
+
findings
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Enforces non-waivable separation of duties: AUTHOR != REVIEWER.
|
|
311
|
+
*
|
|
312
|
+
* @param {object} task - Task assignment with implementer and review block
|
|
313
|
+
* @returns {{ valid: boolean, findings: Array<object> }}
|
|
314
|
+
*/
|
|
315
|
+
function validateReviewSeparation(task) {
|
|
316
|
+
const findings = [];
|
|
317
|
+
if (!task || !task.review) {
|
|
318
|
+
return { valid: true, findings: [] };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const authorId = (task.worker_id || '').trim();
|
|
322
|
+
const reviewerId = (task.review.reviewer_id || '').trim();
|
|
323
|
+
|
|
324
|
+
if (authorId && reviewerId && authorId.toLowerCase() === reviewerId.toLowerCase()) {
|
|
325
|
+
findings.push(createFinding({
|
|
326
|
+
code: 'SWARM_SELF_REVIEW_DETECTED',
|
|
327
|
+
contractId: 'author-not-reviewer',
|
|
328
|
+
phase: 'swarm',
|
|
329
|
+
location: task.task_id || 'unknown',
|
|
330
|
+
details: `Self-review detected on task "${task.task_id}": Author "${authorId}" is identical to Reviewer "${reviewerId}".`
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
valid: findings.length === 0,
|
|
336
|
+
findings
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Enforces role-specific write-set boundaries (e.g. reviewer is read-only).
|
|
342
|
+
*
|
|
343
|
+
* @param {string} role - Worker role
|
|
344
|
+
* @param {Array<string>} modifiedFiles - List of files modified by worker
|
|
345
|
+
* @param {string} [taskId='unknown']
|
|
346
|
+
* @returns {{ valid: boolean, findings: Array<object> }}
|
|
347
|
+
*/
|
|
348
|
+
function validateRoleWriteScope(role, modifiedFiles = [], taskId = 'unknown') {
|
|
349
|
+
const findings = [];
|
|
350
|
+
const normModified = (modifiedFiles || []).map(normalizePath);
|
|
351
|
+
|
|
352
|
+
if ((role === 'reviewer' || role === 'security-auditor') && normModified.length > 0) {
|
|
353
|
+
findings.push(createFinding({
|
|
354
|
+
code: 'SWARM_WRITE_SET_VIOLATION',
|
|
355
|
+
contractId: 'author-not-reviewer',
|
|
356
|
+
phase: 'swarm',
|
|
357
|
+
location: normModified[0],
|
|
358
|
+
details: `Role "${role}" on task "${taskId}" is strictly read-only but modified ${normModified.length} file(s).`
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (role === 'visual-qa') {
|
|
363
|
+
for (const f of normModified) {
|
|
364
|
+
if (!f.includes('evidence/') && !f.endsWith('.png') && !f.endsWith('.json')) {
|
|
365
|
+
findings.push(createFinding({
|
|
366
|
+
code: 'SWARM_WRITE_SET_VIOLATION',
|
|
367
|
+
contractId: 'exclusive-task-write-ownership',
|
|
368
|
+
phase: 'swarm',
|
|
369
|
+
location: f,
|
|
370
|
+
details: `Role "visual-qa" is restricted to evidence directories but modified "${f}".`
|
|
371
|
+
}));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
valid: findings.length === 0,
|
|
378
|
+
findings
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Projects a minimal, task-scoped context derived from context-capsule.json.
|
|
384
|
+
*
|
|
385
|
+
* @param {object} capsule - Loaded context-capsule.json object
|
|
386
|
+
* @param {string} taskId - Target task ID
|
|
387
|
+
* @param {string} [liveCapsuleHash=null] - Live SHA-256 hash of context-capsule.json
|
|
388
|
+
* @returns {object} Projected worker context
|
|
389
|
+
*/
|
|
390
|
+
function projectWorkerContext(capsule, taskId, liveCapsuleHash = null) {
|
|
391
|
+
if (!capsule || typeof capsule !== 'object') {
|
|
392
|
+
const err = new Error('Capsule must be an object to project worker context.');
|
|
393
|
+
err.code = 'SWARM_CONTEXT_INVALID';
|
|
394
|
+
throw err;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const recordedHash = capsule.provenance ? capsule.provenance.source_set_hash : null;
|
|
398
|
+
const isStale = Boolean(liveCapsuleHash && recordedHash && liveCapsuleHash !== recordedHash);
|
|
399
|
+
|
|
400
|
+
// Extract only task-relevant items
|
|
401
|
+
const activeTasks = (capsule.active_feature && capsule.active_feature.active_tasks) || [];
|
|
402
|
+
const matchedTask = activeTasks.find(t => (t.id || '').toUpperCase() === (taskId || '').toUpperCase()) || null;
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
task_id: taskId,
|
|
406
|
+
feature_id: capsule.active_feature ? capsule.active_feature.id : 'unknown',
|
|
407
|
+
source_capsule_hash: recordedHash,
|
|
408
|
+
freshness: isStale ? 'STALE' : 'FRESH',
|
|
409
|
+
canonical_invariants: capsule.canonical_invariants || [],
|
|
410
|
+
frozen_contracts: capsule.frozen_contracts || [],
|
|
411
|
+
task_metadata: matchedTask,
|
|
412
|
+
// Strictly exclude conversational chat history
|
|
413
|
+
chat_history: null,
|
|
414
|
+
developer_prompts: null
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Validates worker count limits and rejects recursive spawning claims.
|
|
420
|
+
*
|
|
421
|
+
* @param {object} manifest - Swarm manifest
|
|
422
|
+
* @returns {{ valid: boolean, findings: Array<object> }}
|
|
423
|
+
*/
|
|
424
|
+
function validateWorkerLimits(manifest) {
|
|
425
|
+
const findings = [];
|
|
426
|
+
const maxWorkers = manifest.max_workers || DEFAULT_MAX_WORKERS;
|
|
427
|
+
|
|
428
|
+
const waves = manifest.waves || [];
|
|
429
|
+
for (const wave of waves) {
|
|
430
|
+
const tasks = wave.tasks || [];
|
|
431
|
+
const workers = new Set(tasks.map(t => t.worker_id).filter(Boolean));
|
|
432
|
+
|
|
433
|
+
if (workers.size > maxWorkers || workers.size > HARD_MAX_WORKERS) {
|
|
434
|
+
findings.push(createFinding({
|
|
435
|
+
code: 'SWARM_WORKER_LIMIT_EXCEEDED',
|
|
436
|
+
contractId: 'swarm-authority-subordinate',
|
|
437
|
+
phase: 'swarm',
|
|
438
|
+
location: `Wave ${wave.wave_index}`,
|
|
439
|
+
details: `Wave ${wave.wave_index} declared ${workers.size} workers, exceeding maximum allowed (${maxWorkers}).`
|
|
440
|
+
}));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
for (const t of tasks) {
|
|
444
|
+
if (t.spawned_children && t.spawned_children.length > 0) {
|
|
445
|
+
findings.push(createFinding({
|
|
446
|
+
code: 'SWARM_RECURSIVE_SPAWN_DENIED',
|
|
447
|
+
contractId: 'swarm-authority-subordinate',
|
|
448
|
+
phase: 'swarm',
|
|
449
|
+
location: t.task_id || 'unknown',
|
|
450
|
+
details: `Worker "${t.worker_id}" claimed spawned child workers. Recursive subagent spawning is prohibited.`
|
|
451
|
+
}));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
valid: findings.length === 0,
|
|
458
|
+
findings
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Intercepts worker external tool actions through Upgrade C Provider Safety Gates.
|
|
464
|
+
*
|
|
465
|
+
* @param {object} workerAction - Action payload with provider_id and capability_id
|
|
466
|
+
* @param {object} [options={}] - Gate options
|
|
467
|
+
* @returns {object} Gate decision
|
|
468
|
+
*/
|
|
469
|
+
function validateSwarmProviderSafety(workerAction, options = {}) {
|
|
470
|
+
const { evaluateProviderCapability } = require('./safety-gates');
|
|
471
|
+
const { createProviderRegistry } = require('./provider-registry');
|
|
472
|
+
|
|
473
|
+
// Build registry with mock provider for testing if none provided
|
|
474
|
+
const providers = options.providers || {
|
|
475
|
+
'trusted-mock': {
|
|
476
|
+
type: 'MOCK',
|
|
477
|
+
mock_adapter: 'in-memory',
|
|
478
|
+
capabilities: {
|
|
479
|
+
'mock_generation': { cost_state: 'FREE', estimated_unit_cost: 0.0 }
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
const registry = createProviderRegistry(providers);
|
|
484
|
+
const capResult = evaluateProviderCapability(workerAction, registry, options);
|
|
485
|
+
|
|
486
|
+
if (!capResult.authorized) {
|
|
487
|
+
return {
|
|
488
|
+
authorized: false,
|
|
489
|
+
code: 'SWARM_PROVIDER_UNAUTHORIZED',
|
|
490
|
+
message: capResult.message
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (workerAction.estimated_tokens && options.budget_limit) {
|
|
495
|
+
if (workerAction.estimated_tokens > options.budget_limit) {
|
|
496
|
+
return {
|
|
497
|
+
authorized: false,
|
|
498
|
+
code: 'SWARM_COST_LIMIT_EXCEEDED',
|
|
499
|
+
message: `Token usage ${workerAction.estimated_tokens} exceeds wave budget limit of ${options.budget_limit}.`
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
authorized: true,
|
|
506
|
+
code: 'SWARM_PROVIDER_AUTHORIZED',
|
|
507
|
+
message: 'Worker provider action authorized.'
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Validates a complete swarm.json file in read-only mode.
|
|
513
|
+
*
|
|
514
|
+
* @param {string} targetDir - Repository target directory
|
|
515
|
+
* @param {string} featureId - Active feature directory path
|
|
516
|
+
* @returns {{ valid: boolean, state: string, findings: Array<object> }}
|
|
517
|
+
*/
|
|
518
|
+
function validateSwarmManifest(targetDir, featureId) {
|
|
519
|
+
const manifestPath = path.join(targetDir, featureId, 'swarm.json');
|
|
520
|
+
if (!fs.existsSync(manifestPath)) {
|
|
521
|
+
return {
|
|
522
|
+
valid: false,
|
|
523
|
+
state: 'MISSING',
|
|
524
|
+
findings: []
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
let raw;
|
|
529
|
+
try {
|
|
530
|
+
raw = fs.readFileSync(manifestPath, 'utf8');
|
|
531
|
+
} catch (err) {
|
|
532
|
+
return {
|
|
533
|
+
valid: false,
|
|
534
|
+
state: 'UNREADABLE',
|
|
535
|
+
findings: [createFinding({
|
|
536
|
+
code: 'SWARM_UNREADABLE',
|
|
537
|
+
contractId: 'swarm-authority-subordinate',
|
|
538
|
+
phase: 'swarm',
|
|
539
|
+
location: manifestPath,
|
|
540
|
+
details: err.message
|
|
541
|
+
})]
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
let manifest;
|
|
546
|
+
try {
|
|
547
|
+
manifest = parseSwarmManifest(raw);
|
|
548
|
+
} catch (err) {
|
|
549
|
+
return {
|
|
550
|
+
valid: false,
|
|
551
|
+
state: 'INVALID',
|
|
552
|
+
findings: [createFinding({
|
|
553
|
+
code: 'SWARM_INVALID_SCHEMA',
|
|
554
|
+
contractId: 'swarm-authority-subordinate',
|
|
555
|
+
phase: 'swarm',
|
|
556
|
+
location: manifestPath,
|
|
557
|
+
details: err.message
|
|
558
|
+
})]
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const findings = [];
|
|
563
|
+
|
|
564
|
+
// 1. Authority validation
|
|
565
|
+
const authCheck = validateSwarmAuthority(manifest);
|
|
566
|
+
findings.push(...authCheck.findings);
|
|
567
|
+
|
|
568
|
+
// 2. Worker limits & recursive spawns
|
|
569
|
+
const limitsCheck = validateWorkerLimits(manifest);
|
|
570
|
+
findings.push(...limitsCheck.findings);
|
|
571
|
+
|
|
572
|
+
// 3. Per wave validations
|
|
573
|
+
const waves = manifest.waves || [];
|
|
574
|
+
for (const wave of waves) {
|
|
575
|
+
// Write collisions
|
|
576
|
+
const writeCheck = validateWritePartitions(wave);
|
|
577
|
+
findings.push(...writeCheck.findings);
|
|
578
|
+
|
|
579
|
+
// Tasks check
|
|
580
|
+
for (const t of wave.tasks || []) {
|
|
581
|
+
const idRoleCheck = validateWorkerIdentityAndRole(t);
|
|
582
|
+
findings.push(...idRoleCheck.findings);
|
|
583
|
+
|
|
584
|
+
const reviewCheck = validateReviewSeparation(t);
|
|
585
|
+
findings.push(...reviewCheck.findings);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// 4. Tasks.md ownership check if available
|
|
590
|
+
const tasksPath = path.join(targetDir, featureId, 'tasks.md');
|
|
591
|
+
if (fs.existsSync(tasksPath)) {
|
|
592
|
+
const tasksRaw = fs.readFileSync(tasksPath, 'utf8');
|
|
593
|
+
const ownerCheck = resolveTaskOwnership(tasksRaw, manifest);
|
|
594
|
+
findings.push(...ownerCheck.findings);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// 5. Context capsule freshness check if available
|
|
598
|
+
const capsulePath = path.join(targetDir, featureId, 'context-capsule.json');
|
|
599
|
+
if (fs.existsSync(capsulePath) && manifest.source_capsule_hash) {
|
|
600
|
+
const { hashFile } = require('./hasher');
|
|
601
|
+
const liveHash = hashFile(capsulePath);
|
|
602
|
+
if (liveHash !== manifest.source_capsule_hash) {
|
|
603
|
+
findings.push(createFinding({
|
|
604
|
+
code: 'SWARM_CONTEXT_STALE',
|
|
605
|
+
contractId: 'swarm-context-projected',
|
|
606
|
+
phase: 'swarm',
|
|
607
|
+
location: manifestPath,
|
|
608
|
+
details: `Swarm manifest source_capsule_hash (${manifest.source_capsule_hash.slice(0, 12)}...) is stale. Live: ${liveHash.slice(0, 12)}...`
|
|
609
|
+
}));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return {
|
|
614
|
+
valid: findings.length === 0,
|
|
615
|
+
state: findings.length === 0 ? 'VALID' : 'INVALID',
|
|
616
|
+
findings
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
module.exports = {
|
|
621
|
+
SWARM_SCHEMA_VERSION,
|
|
622
|
+
CANONICAL_ROLES,
|
|
623
|
+
DEFAULT_MAX_WORKERS,
|
|
624
|
+
HARD_MAX_WORKERS,
|
|
625
|
+
parseSwarmManifest,
|
|
626
|
+
validateSwarmSchema,
|
|
627
|
+
canonicalSerialize,
|
|
628
|
+
validateSwarmAuthority,
|
|
629
|
+
validateWorkerIdentityAndRole,
|
|
630
|
+
resolveTaskOwnership,
|
|
631
|
+
planSwarmWaves,
|
|
632
|
+
validateWritePartitions,
|
|
633
|
+
validateReviewSeparation,
|
|
634
|
+
validateRoleWriteScope,
|
|
635
|
+
projectWorkerContext,
|
|
636
|
+
validateWorkerLimits,
|
|
637
|
+
validateSwarmProviderSafety,
|
|
638
|
+
validateSwarmManifest
|
|
639
|
+
};
|