gemstack-ai 1.4.0 → 2.0.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/.agents/rules/03-gemstack-security.md +2 -2
- package/.gemstack/state.json +10 -11
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +47 -13
- package/RELEASE_NOTES.md +40 -1
- package/handoff.md +33 -19
- package/package.json +4 -3
- package/scripts/ci/check-package-contents.js +1 -1
- package/scripts/ci/check-secrets.js +84 -0
- package/specs/011-gemstack-2.0-hardening/.gemstack.json +5 -0
- package/specs/011-gemstack-2.0-hardening/closure.json +58 -0
- package/specs/011-gemstack-2.0-hardening/plan.md +210 -0
- package/specs/011-gemstack-2.0-hardening/spec.md +277 -0
- package/specs/011-gemstack-2.0-hardening/tasks.md +59 -0
- package/specs/012-gemstack-2.0-honest-evidence/.gemstack.json +5 -0
- package/specs/012-gemstack-2.0-honest-evidence/closure.json +58 -0
- package/specs/012-gemstack-2.0-honest-evidence/plan.md +202 -0
- package/specs/012-gemstack-2.0-honest-evidence/spec.md +222 -0
- package/specs/012-gemstack-2.0-honest-evidence/tasks.md +99 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/.gemstack.json +9 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/closure.json +58 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/context-capsule.json +227 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/plan.md +179 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/spec.md +212 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/tasks.md +90 -0
- package/specs/014-gemstack-2.0-context-memory/.gemstack.json +9 -0
- package/specs/014-gemstack-2.0-context-memory/closure.json +58 -0
- package/specs/014-gemstack-2.0-context-memory/plan.md +161 -0
- package/specs/014-gemstack-2.0-context-memory/spec.md +163 -0
- package/specs/014-gemstack-2.0-context-memory/tasks.md +79 -0
- package/src/cli.js +3 -0
- package/src/commands/doctor.js +18 -0
- package/src/commands/hooks.js +98 -14
- package/src/commands/init.js +1 -1
- package/src/commands/install.js +174 -49
- package/src/commands/spec.js +105 -0
- package/src/commands/update.js +1 -1
- package/src/commands/verify.js +10 -0
- package/src/lib/backup.js +3 -3
- package/src/lib/context-fatigue.js +165 -0
- package/src/lib/contract-amendments.js +109 -0
- package/src/lib/dependency-audit.js +202 -0
- package/src/lib/filesystem-safe.js +85 -15
- package/src/lib/memory-audit.js +121 -0
- package/src/lib/provider-boundary.js +5 -1
- package/src/lib/provider-registry.js +6 -4
- package/src/lib/safety-gates.js +176 -8
- package/src/lib/sdd-rigor.js +181 -0
- package/src/lib/spec-delta.js +194 -0
- package/src/lib/spec-merge.js +168 -0
- package/src/lib/swarm.js +2 -2
- package/src/lib/visual-qa.js +162 -9
- package/template/.agents/rules/03-gemstack-security.md +2 -2
- package/.github/workflows/main-ci.yml +0 -32
- package/.github/workflows/pr-ci.yml +0 -31
- package/.github/workflows/publish.yml +0 -52
- package/.github/workflows/release-readiness.yml +0 -43
- package/gemstack-ai-1.4.0.tgz +0 -0
|
@@ -34,13 +34,17 @@ function normalizeProviderId(rawId) {
|
|
|
34
34
|
* @param {object} [tokenPayload={}]
|
|
35
35
|
* @returns {string} SHA-256 bound token
|
|
36
36
|
*/
|
|
37
|
-
function createBoundToken(providerId, capabilityId, actionId, tokenPayload = {}) {
|
|
37
|
+
function createBoundToken(providerId, capabilityId, actionId, tokenPayload = {}, secret = null) {
|
|
38
38
|
const payload = JSON.stringify({
|
|
39
39
|
providerId,
|
|
40
40
|
capabilityId,
|
|
41
41
|
actionId,
|
|
42
42
|
token: tokenPayload
|
|
43
43
|
});
|
|
44
|
+
const effectiveSecret = secret || process.env.GEMSTACK_BOUNDARY_SECRET;
|
|
45
|
+
if (effectiveSecret) {
|
|
46
|
+
return crypto.createHmac('sha256', effectiveSecret).update(payload, 'utf8').digest('hex');
|
|
47
|
+
}
|
|
44
48
|
return crypto.createHash('sha256').update(payload, 'utf8').digest('hex');
|
|
45
49
|
}
|
|
46
50
|
|
|
@@ -17,12 +17,14 @@ const REMOTE_URL_PATTERN = /^https?:\/\//i;
|
|
|
17
17
|
function resolveEnvironmentTier(options = {}) {
|
|
18
18
|
const env = options.env || process.env;
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
const rawEnv = (options.environment || '').toLowerCase().trim();
|
|
21
|
+
if (rawEnv && VALID_ENV_TIERS.includes(rawEnv)) {
|
|
22
|
+
return rawEnv;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
const rawGemstackEnv = (env.GEMSTACK_ENV || '').toLowerCase().trim();
|
|
26
|
+
if (rawGemstackEnv && VALID_ENV_TIERS.includes(rawGemstackEnv)) {
|
|
27
|
+
return rawGemstackEnv;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
// Detect CI first
|
package/src/lib/safety-gates.js
CHANGED
|
@@ -1,5 +1,71 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
1
2
|
const { checkEnvironmentCommercialPolicy, resolveEnvironmentTier } = require('./provider-registry');
|
|
2
3
|
|
|
4
|
+
const tokenSpendingLedger = new Map();
|
|
5
|
+
|
|
6
|
+
function resetTokenSpendingLedger() {
|
|
7
|
+
tokenSpendingLedger.clear();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Issues a cryptographically signed spending token from a trusted boundary.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} params
|
|
14
|
+
* @param {string} [params.secret]
|
|
15
|
+
* @param {string} [params.provider_id='*']
|
|
16
|
+
* @param {string} [params.action_id='*']
|
|
17
|
+
* @param {number} [params.max_budget_units=100]
|
|
18
|
+
* @param {number} [params.ttl_seconds=3600]
|
|
19
|
+
* @returns {object} Signed spending token
|
|
20
|
+
*/
|
|
21
|
+
function issueSpendingToken({ secret, provider_id = '*', action_id = '*', max_budget_units = 100, ttl_seconds = 3600 } = {}) {
|
|
22
|
+
const tokenId = (crypto.randomUUID && typeof crypto.randomUUID === 'function')
|
|
23
|
+
? crypto.randomUUID()
|
|
24
|
+
: crypto.randomBytes(16).toString('hex');
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const issuedAt = new Date(now).toISOString();
|
|
27
|
+
const expiresAt = new Date(now + ttl_seconds * 1000).toISOString();
|
|
28
|
+
const effectiveSecret = secret || process.env.GEMSTACK_BOUNDARY_SECRET || 'gemstack-boundary-internal-signing-secret';
|
|
29
|
+
|
|
30
|
+
const payload = [tokenId, provider_id, action_id, String(max_budget_units), expiresAt].join(':');
|
|
31
|
+
const signature = crypto.createHmac('sha256', effectiveSecret).update(payload).digest('hex');
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
token_id: tokenId,
|
|
35
|
+
provider_id,
|
|
36
|
+
action_id,
|
|
37
|
+
max_budget_units: Number(max_budget_units),
|
|
38
|
+
issued_at: issuedAt,
|
|
39
|
+
expires_at: expiresAt,
|
|
40
|
+
signature,
|
|
41
|
+
spent_units: 0
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Verifies a spending token signature and expiration.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} token
|
|
49
|
+
* @param {string} [secret]
|
|
50
|
+
* @returns {{ valid: boolean, reason?: string }}
|
|
51
|
+
*/
|
|
52
|
+
function verifySpendingToken(token, secret) {
|
|
53
|
+
if (!token || typeof token !== 'object') return { valid: false, reason: 'TOKEN_INVALID_FORMAT' };
|
|
54
|
+
if (!token.token_id || !token.signature || !token.expires_at) return { valid: false, reason: 'TOKEN_INCOMPLETE' };
|
|
55
|
+
|
|
56
|
+
const effectiveSecret = secret || process.env.GEMSTACK_BOUNDARY_SECRET || 'gemstack-boundary-internal-signing-secret';
|
|
57
|
+
const payload = [token.token_id, token.provider_id || '*', token.action_id || '*', String(token.max_budget_units), token.expires_at].join(':');
|
|
58
|
+
const expectedSig = crypto.createHmac('sha256', effectiveSecret).update(payload).digest('hex');
|
|
59
|
+
|
|
60
|
+
const bufA = Buffer.from(token.signature);
|
|
61
|
+
const bufB = Buffer.from(expectedSig);
|
|
62
|
+
if (bufA.length !== bufB.length || !crypto.timingSafeEqual(bufA, bufB)) {
|
|
63
|
+
return { valid: false, reason: 'TOKEN_SIGNATURE_INVALID' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { valid: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
3
69
|
/**
|
|
4
70
|
* Creates a normalized, structured gate decision object.
|
|
5
71
|
*
|
|
@@ -155,7 +221,17 @@ function evaluateBillableAction(request, ledger, options = {}) {
|
|
|
155
221
|
const providerId = (request.provider_id || '').toLowerCase().trim();
|
|
156
222
|
const capabilityId = request.capability_id;
|
|
157
223
|
const envTier = resolveEnvironmentTier({ environment: request.environment, ...options });
|
|
158
|
-
const requestedUnits =
|
|
224
|
+
const requestedUnits = request.requested_units !== undefined ? request.requested_units : 1;
|
|
225
|
+
|
|
226
|
+
if (typeof requestedUnits !== 'number' || !Number.isFinite(requestedUnits) || requestedUnits <= 0) {
|
|
227
|
+
return createGateDecision({
|
|
228
|
+
authorized: false,
|
|
229
|
+
decision: 'DENY',
|
|
230
|
+
reasonCode: 'INVALID_REQUESTED_UNITS',
|
|
231
|
+
message: 'Requested units must be a strictly positive finite number.',
|
|
232
|
+
context: { action_id: actionId, provider_id: providerId, requested_units: requestedUnits }
|
|
233
|
+
});
|
|
234
|
+
}
|
|
159
235
|
|
|
160
236
|
// 1. Action Declaration Check (Must be explicitly declared)
|
|
161
237
|
if (!actionId || typeof actionId !== 'string' || !actionId.trim()) {
|
|
@@ -232,9 +308,7 @@ function evaluateBillableAction(request, ledger, options = {}) {
|
|
|
232
308
|
|
|
233
309
|
// 5. BILLABLE / POTENTIALLY_BILLABLE Actions -> Require Explicit Spending Token
|
|
234
310
|
const token = request.authorization_token;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (!isTokenValid) {
|
|
311
|
+
if (!token || typeof token !== 'object') {
|
|
238
312
|
return createGateDecision({
|
|
239
313
|
authorized: false,
|
|
240
314
|
decision: 'DENY',
|
|
@@ -244,20 +318,111 @@ function evaluateBillableAction(request, ledger, options = {}) {
|
|
|
244
318
|
});
|
|
245
319
|
}
|
|
246
320
|
|
|
321
|
+
const boundarySecret = options.boundarySecret || process.env.GEMSTACK_BOUNDARY_SECRET;
|
|
322
|
+
|
|
323
|
+
if (boundarySecret) {
|
|
324
|
+
const verified = verifySpendingToken(token, boundarySecret);
|
|
325
|
+
if (!verified.valid) {
|
|
326
|
+
return createGateDecision({
|
|
327
|
+
authorized: false,
|
|
328
|
+
decision: 'DENY',
|
|
329
|
+
reasonCode: 'BILLABLE_ACTION_UNAUTHORIZED',
|
|
330
|
+
message: 'Authorization token rejected by trusted boundary: ' + verified.reason,
|
|
331
|
+
context: { action_id: actionId, provider_id: providerId, reason: verified.reason }
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
} else if (token.signature && token.token_id) {
|
|
335
|
+
const verified = verifySpendingToken(token);
|
|
336
|
+
if (!verified.valid) {
|
|
337
|
+
return createGateDecision({
|
|
338
|
+
authorized: false,
|
|
339
|
+
decision: 'DENY',
|
|
340
|
+
reasonCode: 'BILLABLE_ACTION_UNAUTHORIZED',
|
|
341
|
+
message: 'Authorization token signature invalid.',
|
|
342
|
+
context: { action_id: actionId, provider_id: providerId }
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
// Legacy fallback only when no boundarySecret is active
|
|
347
|
+
const isTokenValid = token && (token.granted === true || token.granted_by || token.source === 'CLI_FLAG' || token.max_budget_units !== undefined);
|
|
348
|
+
if (!isTokenValid) {
|
|
349
|
+
return createGateDecision({
|
|
350
|
+
authorized: false,
|
|
351
|
+
decision: 'DENY',
|
|
352
|
+
reasonCode: 'BILLABLE_ACTION_UNAUTHORIZED',
|
|
353
|
+
message: 'Action "' + actionId + '" on provider "' + providerId + '" requires explicit spending authorization.',
|
|
354
|
+
context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier }
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Expiration check
|
|
360
|
+
if (token.expires_at) {
|
|
361
|
+
const expMs = Date.parse(token.expires_at);
|
|
362
|
+
if (!Number.isNaN(expMs) && Date.now() > expMs) {
|
|
363
|
+
return createGateDecision({
|
|
364
|
+
authorized: false,
|
|
365
|
+
decision: 'DENY',
|
|
366
|
+
reasonCode: 'TOKEN_EXPIRED',
|
|
367
|
+
message: 'Authorization token expired at ' + token.expires_at + '.',
|
|
368
|
+
context: { action_id: actionId, provider_id: providerId, expires_at: token.expires_at }
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Scope check (Provider & Action)
|
|
374
|
+
if (token.provider_id && token.provider_id !== '*' && token.provider_id.toLowerCase() !== providerId.toLowerCase()) {
|
|
375
|
+
return createGateDecision({
|
|
376
|
+
authorized: false,
|
|
377
|
+
decision: 'DENY',
|
|
378
|
+
reasonCode: 'TOKEN_SCOPE_MISMATCH',
|
|
379
|
+
message: 'Token scope provider "' + token.provider_id + '" does not match requested provider "' + providerId + '".',
|
|
380
|
+
context: { action_id: actionId, provider_id: providerId, token_provider: token.provider_id }
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (token.action_id && token.action_id !== '*' && token.action_id !== actionId) {
|
|
385
|
+
return createGateDecision({
|
|
386
|
+
authorized: false,
|
|
387
|
+
decision: 'DENY',
|
|
388
|
+
reasonCode: 'TOKEN_SCOPE_MISMATCH',
|
|
389
|
+
message: 'Token scope action "' + token.action_id + '" does not match requested action "' + actionId + '".',
|
|
390
|
+
context: { action_id: actionId, provider_id: providerId, token_action: token.action_id }
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
247
394
|
// 6. Budget & Unit Threshold Check
|
|
248
395
|
const estimatedUnitCost = (capabilityEntry && typeof capabilityEntry.estimated_unit_cost === 'number') ? capabilityEntry.estimated_unit_cost : 1;
|
|
249
396
|
const estimatedTotalCost = requestedUnits * estimatedUnitCost;
|
|
250
397
|
|
|
251
398
|
if (token && typeof token.max_budget_units === 'number') {
|
|
252
|
-
|
|
399
|
+
const tokenId = token.token_id;
|
|
400
|
+
const currentSpent = tokenId ? (tokenSpendingLedger.get(tokenId) || 0) : 0;
|
|
401
|
+
const projectedTotal = currentSpent + estimatedTotalCost;
|
|
402
|
+
|
|
403
|
+
if (projectedTotal > token.max_budget_units) {
|
|
253
404
|
return createGateDecision({
|
|
254
405
|
authorized: false,
|
|
255
406
|
decision: 'DENY',
|
|
256
407
|
reasonCode: 'BUDGET_THRESHOLD_EXCEEDED',
|
|
257
|
-
message: 'Action "' + actionId + '" estimated cost (' +
|
|
258
|
-
context: {
|
|
408
|
+
message: 'Action "' + actionId + '" estimated cost (' + projectedTotal + ') exceeds authorized budget limit (' + token.max_budget_units + ').',
|
|
409
|
+
context: {
|
|
410
|
+
action_id: actionId,
|
|
411
|
+
provider_id: providerId,
|
|
412
|
+
capability_id: capabilityId,
|
|
413
|
+
cost_state: costState,
|
|
414
|
+
environment: envTier,
|
|
415
|
+
estimated_total_cost: estimatedTotalCost,
|
|
416
|
+
current_spent: currentSpent,
|
|
417
|
+
projected_total: projectedTotal,
|
|
418
|
+
max_budget_units: token.max_budget_units
|
|
419
|
+
}
|
|
259
420
|
});
|
|
260
421
|
}
|
|
422
|
+
|
|
423
|
+
if (tokenId) {
|
|
424
|
+
tokenSpendingLedger.set(tokenId, projectedTotal);
|
|
425
|
+
}
|
|
261
426
|
}
|
|
262
427
|
|
|
263
428
|
// 7. Full Authorization Pass
|
|
@@ -273,5 +438,8 @@ function evaluateBillableAction(request, ledger, options = {}) {
|
|
|
273
438
|
module.exports = {
|
|
274
439
|
createGateDecision,
|
|
275
440
|
evaluateProviderCapability,
|
|
276
|
-
evaluateBillableAction
|
|
441
|
+
evaluateBillableAction,
|
|
442
|
+
issueSpendingToken,
|
|
443
|
+
verifySpendingToken,
|
|
444
|
+
resetTokenSpendingLedger
|
|
277
445
|
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SDD Rigor Engine (Gemstack 2.0 Sprint C)
|
|
5
|
+
* Defines and enforces 4 levels of SDD rigor: quick, fix, feature, high-risk.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const RIGOR_LEVELS = Object.freeze(['quick', 'fix', 'feature', 'high-risk']);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Detects the declared rigor level from spec content or metadata.
|
|
12
|
+
* @param {string} specContent - Content of spec.md or quick.md
|
|
13
|
+
* @returns {string} Detected rigor level ('quick'|'fix'|'feature'|'high-risk')
|
|
14
|
+
*/
|
|
15
|
+
function detectRigorLevel(specContent) {
|
|
16
|
+
if (!specContent || typeof specContent !== 'string') {
|
|
17
|
+
return 'feature';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Check YAML frontmatter: rigor: <level>
|
|
21
|
+
const frontmatterMatch = specContent.match(/^---\s*[\r\n]+([\s\S]*?)[\r\n]+---/);
|
|
22
|
+
if (frontmatterMatch) {
|
|
23
|
+
const rMatch = frontmatterMatch[1].match(/^\s*rigor:\s*([a-zA-Z0-9_-]+)/m);
|
|
24
|
+
if (rMatch) {
|
|
25
|
+
const level = rMatch[1].trim().toLowerCase();
|
|
26
|
+
if (!RIGOR_LEVELS.includes(level)) {
|
|
27
|
+
const err = new Error(`Nivel de rigor inválido "${level}". Niveles válidos: ${RIGOR_LEVELS.join(', ')}`);
|
|
28
|
+
err.code = 'INVALID_RIGOR_LEVEL';
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
return level;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check Markdown annotations: **Rigor Level**: `level` or Rigor: level
|
|
36
|
+
const inlineMatch = specContent.match(/\*\*Rigor(?:\s+Level)?\*\*:\s*`?([a-zA-Z0-9_-]+)`?/i);
|
|
37
|
+
if (inlineMatch) {
|
|
38
|
+
const level = inlineMatch[1].trim().toLowerCase();
|
|
39
|
+
if (!RIGOR_LEVELS.includes(level)) {
|
|
40
|
+
const err = new Error(`Nivel de rigor inválido "${level}". Niveles válidos: ${RIGOR_LEVELS.join(', ')}`);
|
|
41
|
+
err.code = 'INVALID_RIGOR_LEVEL';
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
return level;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return 'feature';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validates requirements for a given rigor level.
|
|
52
|
+
* @param {string} rigorLevel - 'quick'|'fix'|'feature'|'high-risk'
|
|
53
|
+
* @param {object} context - Context containing specContent, planContent, tasksContent, testMatrix, sidecar, etc.
|
|
54
|
+
* @returns {{ valid: boolean, code?: string, error?: string, [key: string]: any }}
|
|
55
|
+
*/
|
|
56
|
+
function validateRigorRequirements(rigorLevel, context = {}) {
|
|
57
|
+
const normLevel = (rigorLevel || 'feature').toLowerCase();
|
|
58
|
+
if (!RIGOR_LEVELS.includes(normLevel)) {
|
|
59
|
+
const err = new Error(`Nivel de rigor inválido "${normLevel}".`);
|
|
60
|
+
err.code = 'INVALID_RIGOR_LEVEL';
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const {
|
|
65
|
+
specContent = '',
|
|
66
|
+
planContent = null,
|
|
67
|
+
tasksContent = null,
|
|
68
|
+
testMatrix = [],
|
|
69
|
+
sidecar = null
|
|
70
|
+
} = context;
|
|
71
|
+
|
|
72
|
+
switch (normLevel) {
|
|
73
|
+
case 'quick': {
|
|
74
|
+
if (!specContent || typeof specContent !== 'string' || specContent.trim().length === 0) {
|
|
75
|
+
return {
|
|
76
|
+
valid: false,
|
|
77
|
+
code: 'QUICK_SPEC_MISSING',
|
|
78
|
+
error: 'Quick rigor requires at least one spec or quick artifact'
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
valid: true,
|
|
83
|
+
rigor: 'quick',
|
|
84
|
+
single_artifact_allowed: true
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
case 'fix': {
|
|
89
|
+
// Must have at least one regression test in the test matrix
|
|
90
|
+
const matrix = Array.isArray(testMatrix) ? testMatrix : [];
|
|
91
|
+
const regressionTest = matrix.find(t => {
|
|
92
|
+
const cat = (t.category || '').toUpperCase();
|
|
93
|
+
const id = (t.id || '').toUpperCase();
|
|
94
|
+
const desc = (t.description || '').toLowerCase();
|
|
95
|
+
return cat === 'REGRESSION' || id.includes('REG') || desc.includes('regression') || desc.includes('reproduce');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (!regressionTest) {
|
|
99
|
+
return {
|
|
100
|
+
valid: false,
|
|
101
|
+
code: 'FIX_MISSING_REGRESSION_TEST',
|
|
102
|
+
error: 'Fix rigor requires at least one linked regression test in the test matrix (category REGRESSION or ID matching *REG*)'
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
valid: true,
|
|
108
|
+
rigor: 'fix',
|
|
109
|
+
regression_test_id: regressionTest.id
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
case 'feature': {
|
|
114
|
+
if (!specContent) {
|
|
115
|
+
return { valid: false, code: 'FEATURE_SPEC_MISSING', error: 'Feature rigor requires spec.md' };
|
|
116
|
+
}
|
|
117
|
+
if (!planContent) {
|
|
118
|
+
return { valid: false, code: 'FEATURE_PLAN_MISSING', error: 'Feature rigor requires plan.md' };
|
|
119
|
+
}
|
|
120
|
+
if (!tasksContent) {
|
|
121
|
+
return { valid: false, code: 'FEATURE_TASKS_MISSING', error: 'Feature rigor requires tasks.md' };
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
valid: true,
|
|
125
|
+
rigor: 'feature'
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case 'high-risk': {
|
|
130
|
+
// 1. Threat Model in specContent
|
|
131
|
+
const hasThreatModel = context.hasThreatModel ||
|
|
132
|
+
/##\s+(?:[0-9.]+\s+)?(?:Threat\s+Model|Modelo\s+de\s+Amenazas)/i.test(specContent);
|
|
133
|
+
if (!hasThreatModel) {
|
|
134
|
+
return {
|
|
135
|
+
valid: false,
|
|
136
|
+
code: 'HIGH_RISK_MISSING_THREAT_MODEL',
|
|
137
|
+
error: 'High-risk rigor requires an explicit Threat Model section in spec.md'
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 2. Rollback Plan in planContent
|
|
142
|
+
const hasRollbackPlan = context.hasRollbackPlan ||
|
|
143
|
+
(planContent && /##\s+(?:[0-9.]+\s+)?(?:Rollback\s+Plan|Plan\s+de\s+Rollback)/i.test(planContent));
|
|
144
|
+
if (!hasRollbackPlan) {
|
|
145
|
+
return {
|
|
146
|
+
valid: false,
|
|
147
|
+
code: 'HIGH_RISK_MISSING_ROLLBACK_PLAN',
|
|
148
|
+
error: 'High-risk rigor requires an explicit Rollback Plan section in plan.md'
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 3. Dual human approvals in sidecar
|
|
153
|
+
const approvals = sidecar && Array.isArray(sidecar.approvals) ? sidecar.approvals : [];
|
|
154
|
+
const validApprovals = approvals.filter(a => a && a.approver && a.signature);
|
|
155
|
+
const uniqueApprovers = new Set(validApprovals.map(a => a.approver));
|
|
156
|
+
|
|
157
|
+
if (uniqueApprovers.size < 2) {
|
|
158
|
+
return {
|
|
159
|
+
valid: false,
|
|
160
|
+
code: 'HIGH_RISK_INSUFFICIENT_APPROVALS',
|
|
161
|
+
error: `High-risk rigor requires at least 2 distinct human approval signatures. Found: ${uniqueApprovers.size}`
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
valid: true,
|
|
167
|
+
rigor: 'high-risk',
|
|
168
|
+
approvals_count: uniqueApprovers.size
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
default:
|
|
173
|
+
return { valid: false, code: 'UNKNOWN_RIGOR', error: `Unknown rigor: ${normLevel}` };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
RIGOR_LEVELS,
|
|
179
|
+
detectRigorLevel,
|
|
180
|
+
validateRigorRequirements
|
|
181
|
+
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Incremental Spec Delta Engine (Gemstack 2.0 Sprint C)
|
|
5
|
+
* Parses and applies structured ADDED, MODIFIED, REMOVED deltas onto base specifications.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extracts and parses a spec delta block or JSON structure.
|
|
10
|
+
* @param {string|object} input - Markdown content containing gemstack-spec-delta block or parsed delta object.
|
|
11
|
+
* @returns {object} Normalized delta object { base_spec, added, modified, removed }
|
|
12
|
+
*/
|
|
13
|
+
function parseSpecDelta(input) {
|
|
14
|
+
if (typeof input === 'object' && input !== null) {
|
|
15
|
+
return normalizeDelta(input);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (typeof input !== 'string') {
|
|
19
|
+
throw new Error('Delta input must be a string or object');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const blockRegex = /```gemstack-spec-delta\s*([\s\S]*?)```/;
|
|
23
|
+
const match = input.match(blockRegex);
|
|
24
|
+
let parsed;
|
|
25
|
+
|
|
26
|
+
if (match) {
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(match[1].trim());
|
|
29
|
+
} catch (e) {
|
|
30
|
+
const err = new Error(`Error parseando gemstack-spec-delta: ${e.message}`);
|
|
31
|
+
err.code = 'DELTA_PARSE_ERROR';
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
// Try parsing raw JSON
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(input.trim());
|
|
38
|
+
} catch (e) {
|
|
39
|
+
const err = new Error('No se encontró bloque gemstack-spec-delta ni JSON válido');
|
|
40
|
+
err.code = 'DELTA_BLOCK_MISSING';
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return normalizeDelta(parsed);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeDelta(parsed) {
|
|
49
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
50
|
+
const err = new Error('Delta debe ser un objeto');
|
|
51
|
+
err.code = 'INVALID_DELTA_SCHEMA';
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
base_spec: parsed.base_spec || null,
|
|
57
|
+
added: {
|
|
58
|
+
contracts: Array.isArray(parsed.added?.contracts) ? parsed.added.contracts : [],
|
|
59
|
+
requirements: Array.isArray(parsed.added?.requirements) ? parsed.added.requirements : [],
|
|
60
|
+
tests: Array.isArray(parsed.added?.tests) ? parsed.added.tests : []
|
|
61
|
+
},
|
|
62
|
+
modified: {
|
|
63
|
+
contracts: Array.isArray(parsed.modified?.contracts) ? parsed.modified.contracts : [],
|
|
64
|
+
requirements: Array.isArray(parsed.modified?.requirements) ? parsed.modified.requirements : [],
|
|
65
|
+
tests: Array.isArray(parsed.modified?.tests) ? parsed.modified.tests : []
|
|
66
|
+
},
|
|
67
|
+
removed: {
|
|
68
|
+
contracts: Array.isArray(parsed.removed?.contracts) ? parsed.removed.contracts : [],
|
|
69
|
+
requirements: Array.isArray(parsed.removed?.requirements) ? parsed.removed.requirements : [],
|
|
70
|
+
tests: Array.isArray(parsed.removed?.tests) ? parsed.removed.tests : []
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Applies a normalized delta onto a base specification representation.
|
|
77
|
+
* @param {object} baseSpec - { contracts: [], requirements: [], tests: [] }
|
|
78
|
+
* @param {object} delta - Normalized delta from parseSpecDelta
|
|
79
|
+
* @returns {object} Merged specification representation
|
|
80
|
+
*/
|
|
81
|
+
function applySpecDelta(baseSpec, delta) {
|
|
82
|
+
if (!baseSpec || typeof baseSpec !== 'object') {
|
|
83
|
+
throw new Error('baseSpec must be an object');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const normDelta = normalizeDelta(delta);
|
|
87
|
+
|
|
88
|
+
// Clone collections
|
|
89
|
+
const contracts = Array.isArray(baseSpec.contracts) ? JSON.parse(JSON.stringify(baseSpec.contracts)) : [];
|
|
90
|
+
const requirements = Array.isArray(baseSpec.requirements) ? JSON.parse(JSON.stringify(baseSpec.requirements)) : [];
|
|
91
|
+
const tests = Array.isArray(baseSpec.tests) ? JSON.parse(JSON.stringify(baseSpec.tests)) : [];
|
|
92
|
+
|
|
93
|
+
// 1. Process REMOVED
|
|
94
|
+
for (const item of normDelta.removed.contracts) {
|
|
95
|
+
const id = typeof item === 'string' ? item : item.id;
|
|
96
|
+
const idx = contracts.findIndex(c => c.id === id);
|
|
97
|
+
if (idx === -1) {
|
|
98
|
+
const err = new Error(`No se puede remover el contrato "${id}": no existe en la especificación base.`);
|
|
99
|
+
err.code = 'DELTA_TARGET_NOT_FOUND';
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
contracts.splice(idx, 1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const item of normDelta.removed.tests) {
|
|
106
|
+
const id = typeof item === 'string' ? item : item.id;
|
|
107
|
+
const idx = tests.findIndex(t => t.id === id);
|
|
108
|
+
if (idx === -1) {
|
|
109
|
+
const err = new Error(`No se puede remover el test canónico "${id}": no existe en la especificación base.`);
|
|
110
|
+
err.code = 'DELTA_TARGET_NOT_FOUND';
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
tests.splice(idx, 1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const item of normDelta.removed.requirements) {
|
|
117
|
+
const id = typeof item === 'string' ? item : item.id;
|
|
118
|
+
const idx = requirements.findIndex(r => (typeof r === 'string' ? r === id : r.id === id));
|
|
119
|
+
if (idx === -1) {
|
|
120
|
+
const err = new Error(`No se puede remover el requisito "${id}": no existe en la especificación base.`);
|
|
121
|
+
err.code = 'DELTA_TARGET_NOT_FOUND';
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
requirements.splice(idx, 1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 2. Process MODIFIED
|
|
128
|
+
for (const item of normDelta.modified.contracts) {
|
|
129
|
+
const idx = contracts.findIndex(c => c.id === item.id);
|
|
130
|
+
if (idx === -1) {
|
|
131
|
+
const err = new Error(`No se puede modificar el contrato "${item.id}": no existe en la especificación base.`);
|
|
132
|
+
err.code = 'DELTA_TARGET_NOT_FOUND';
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
contracts[idx] = { ...contracts[idx], ...item };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const item of normDelta.modified.tests) {
|
|
139
|
+
const idx = tests.findIndex(t => t.id === item.id);
|
|
140
|
+
if (idx === -1) {
|
|
141
|
+
const err = new Error(`No se puede modificar el test "${item.id}": no existe en la especificación base.`);
|
|
142
|
+
err.code = 'DELTA_TARGET_NOT_FOUND';
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
tests[idx] = { ...tests[idx], ...item };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const item of normDelta.modified.requirements) {
|
|
149
|
+
const id = typeof item === 'string' ? item : item.id;
|
|
150
|
+
const idx = requirements.findIndex(r => (typeof r === 'string' ? r === id : r.id === id));
|
|
151
|
+
if (idx === -1) {
|
|
152
|
+
const err = new Error(`No se puede modificar el requisito "${id}": no existe en la especificación base.`);
|
|
153
|
+
err.code = 'DELTA_TARGET_NOT_FOUND';
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
requirements[idx] = typeof item === 'string' ? item : { ...requirements[idx], ...item };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 3. Process ADDED
|
|
160
|
+
for (const item of normDelta.added.contracts) {
|
|
161
|
+
const existing = contracts.find(c => c.id === item.id);
|
|
162
|
+
if (existing) {
|
|
163
|
+
const err = new Error(`Conflicto al agregar contrato "${item.id}": ya existe en la especificación base.`);
|
|
164
|
+
err.code = 'DELTA_ADD_CONFLICT';
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
contracts.push(item);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const item of normDelta.added.tests) {
|
|
171
|
+
const existing = tests.find(t => t.id === item.id);
|
|
172
|
+
if (existing) {
|
|
173
|
+
const err = new Error(`Conflicto al agregar test "${item.id}": ya existe en la especificación base.`);
|
|
174
|
+
err.code = 'DELTA_ADD_CONFLICT';
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
tests.push(item);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const item of normDelta.added.requirements) {
|
|
181
|
+
requirements.push(item);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
contracts,
|
|
186
|
+
requirements,
|
|
187
|
+
tests
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = {
|
|
192
|
+
parseSpecDelta,
|
|
193
|
+
applySpecDelta
|
|
194
|
+
};
|