gemstack-ai 1.3.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.
Files changed (71) hide show
  1. package/.agents/rules/03-gemstack-security.md +2 -2
  2. package/.gemstack/state.json +7 -8
  3. package/CHANGELOG.md +87 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +113 -22
  6. package/RELEASE_NOTES.md +80 -1
  7. package/handoff.md +33 -19
  8. package/package.json +4 -3
  9. package/scripts/ci/check-package-contents.js +1 -1
  10. package/scripts/ci/check-secrets.js +84 -0
  11. package/specs/009-context-capsule/context-capsule.json +4 -4
  12. package/specs/010-agent-swarm-visual-qa/.gemstack.json +5 -0
  13. package/specs/010-agent-swarm-visual-qa/closure.json +59 -0
  14. package/specs/010-agent-swarm-visual-qa/plan.md +759 -0
  15. package/specs/010-agent-swarm-visual-qa/spec.md +842 -0
  16. package/specs/010-agent-swarm-visual-qa/swarm.json +49 -0
  17. package/specs/010-agent-swarm-visual-qa/tasks.md +873 -0
  18. package/specs/010-agent-swarm-visual-qa/visual-qa.json +41 -0
  19. package/specs/011-gemstack-2.0-hardening/.gemstack.json +5 -0
  20. package/specs/011-gemstack-2.0-hardening/closure.json +58 -0
  21. package/specs/011-gemstack-2.0-hardening/plan.md +210 -0
  22. package/specs/011-gemstack-2.0-hardening/spec.md +277 -0
  23. package/specs/011-gemstack-2.0-hardening/tasks.md +59 -0
  24. package/specs/012-gemstack-2.0-honest-evidence/.gemstack.json +5 -0
  25. package/specs/012-gemstack-2.0-honest-evidence/closure.json +58 -0
  26. package/specs/012-gemstack-2.0-honest-evidence/plan.md +202 -0
  27. package/specs/012-gemstack-2.0-honest-evidence/spec.md +222 -0
  28. package/specs/012-gemstack-2.0-honest-evidence/tasks.md +99 -0
  29. package/specs/013-gemstack-2.0-adaptable-sdd/.gemstack.json +9 -0
  30. package/specs/013-gemstack-2.0-adaptable-sdd/closure.json +58 -0
  31. package/specs/013-gemstack-2.0-adaptable-sdd/context-capsule.json +227 -0
  32. package/specs/013-gemstack-2.0-adaptable-sdd/plan.md +179 -0
  33. package/specs/013-gemstack-2.0-adaptable-sdd/spec.md +212 -0
  34. package/specs/013-gemstack-2.0-adaptable-sdd/tasks.md +90 -0
  35. package/specs/014-gemstack-2.0-context-memory/.gemstack.json +9 -0
  36. package/specs/014-gemstack-2.0-context-memory/closure.json +58 -0
  37. package/specs/014-gemstack-2.0-context-memory/plan.md +161 -0
  38. package/specs/014-gemstack-2.0-context-memory/spec.md +163 -0
  39. package/specs/014-gemstack-2.0-context-memory/tasks.md +79 -0
  40. package/src/cli.js +11 -0
  41. package/src/commands/context.js +1 -1
  42. package/src/commands/doctor.js +18 -0
  43. package/src/commands/hooks.js +98 -14
  44. package/src/commands/init.js +1 -1
  45. package/src/commands/install.js +174 -49
  46. package/src/commands/spec.js +105 -0
  47. package/src/commands/swarm.js +111 -0
  48. package/src/commands/update.js +1 -1
  49. package/src/commands/verify.js +48 -0
  50. package/src/commands/visual.js +82 -0
  51. package/src/lib/backup.js +3 -3
  52. package/src/lib/closure-context.js +9 -1
  53. package/src/lib/context-fatigue.js +165 -0
  54. package/src/lib/contract-amendments.js +109 -0
  55. package/src/lib/dependency-audit.js +202 -0
  56. package/src/lib/filesystem-safe.js +85 -15
  57. package/src/lib/memory-audit.js +121 -0
  58. package/src/lib/provider-boundary.js +5 -1
  59. package/src/lib/provider-registry.js +6 -4
  60. package/src/lib/safety-gates.js +176 -8
  61. package/src/lib/sdd-rigor.js +181 -0
  62. package/src/lib/spec-delta.js +194 -0
  63. package/src/lib/spec-merge.js +168 -0
  64. package/src/lib/swarm.js +639 -0
  65. package/src/lib/visual-qa.js +652 -0
  66. package/template/.agents/rules/03-gemstack-security.md +2 -2
  67. package/.github/workflows/main-ci.yml +0 -32
  68. package/.github/workflows/pr-ci.yml +0 -31
  69. package/.github/workflows/publish.yml +0 -52
  70. package/.github/workflows/release-readiness.yml +0 -43
  71. package/gemstack-ai-1.3.0.tgz +0 -0
@@ -1,22 +1,92 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs');
3
+ const crypto = require('crypto');
3
4
 
4
- module.exports = {
5
- resolveSafe: (targetDir, relativePath) => {
6
- const target = path.resolve(targetDir);
7
- const candidate = path.resolve(targetDir, relativePath);
8
- const rel = path.relative(target, candidate);
9
-
10
- const isInside = rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
11
-
12
- if (!isInside) {
13
- throw new Error(`Path Traversal blocked: ${relativePath}`);
5
+ function normalizePlatformPath(p) {
6
+ const resolved = path.resolve(p);
7
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
8
+ }
9
+
10
+ function resolveSafe(targetDir, relativePath) {
11
+ const target = path.resolve(targetDir);
12
+ const candidate = path.resolve(targetDir, relativePath);
13
+ const rel = path.relative(target, candidate);
14
+
15
+ const isInside = rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
16
+
17
+ if (!isInside) {
18
+ const err = new Error(`[PATH_TRAVERSAL_DETECTED] Path Traversal blocked: ${relativePath}`);
19
+ err.code = 'PATH_TRAVERSAL_DETECTED';
20
+ throw err;
21
+ }
22
+ return candidate;
23
+ }
24
+
25
+ function resolveSafeStrict(targetDir, relativePath) {
26
+ const candidate = resolveSafe(targetDir, relativePath);
27
+ const target = path.resolve(targetDir);
28
+
29
+ const realRoot = fs.existsSync(target) ? fs.realpathSync(target) : target;
30
+ const normRoot = normalizePlatformPath(realRoot);
31
+
32
+ // Check intermediate components and candidate for symlink escapes
33
+ const rel = path.relative(target, candidate);
34
+ const parts = rel.split(/[\\/]/).filter(Boolean);
35
+
36
+ let current = target;
37
+ for (const part of parts) {
38
+ current = path.join(current, part);
39
+ if (fs.existsSync(current)) {
40
+ const realCurrent = fs.realpathSync(current);
41
+ const normCurrent = normalizePlatformPath(realCurrent);
42
+ if (!normCurrent.startsWith(normRoot) || (normCurrent !== normRoot && normCurrent[normRoot.length] !== path.sep && normCurrent[normRoot.length] !== '/' && normCurrent[normRoot.length] !== '\\')) {
43
+ const err = new Error(`Symlink escape detected: path component "${part}" resolves to "${realCurrent}" outside project root "${realRoot}"`);
44
+ err.code = 'SYMLINK_ESCAPE_DETECTED';
45
+ throw err;
46
+ }
47
+ }
48
+ }
49
+
50
+ if (fs.existsSync(candidate)) {
51
+ const realCandidate = fs.realpathSync(candidate);
52
+ const normCandidate = normalizePlatformPath(realCandidate);
53
+ if (!normCandidate.startsWith(normRoot)) {
54
+ const err = new Error(`Symlink escape detected: destination resolves to "${realCandidate}" outside project root "${realRoot}"`);
55
+ err.code = 'SYMLINK_ESCAPE_DETECTED';
56
+ throw err;
14
57
  }
15
- return candidate;
16
- },
17
- ensureDir: (dirPath) => {
18
- if (!fs.existsSync(dirPath)) {
19
- fs.mkdirSync(dirPath, { recursive: true });
58
+ }
59
+
60
+ return candidate;
61
+ }
62
+
63
+ function ensureDir(dirPath) {
64
+ if (!fs.existsSync(dirPath)) {
65
+ fs.mkdirSync(dirPath, { recursive: true });
66
+ }
67
+ }
68
+
69
+ function withConfinedAtomicWrite(rootDir, relativePath, writeFn) {
70
+ const finalPath = resolveSafeStrict(rootDir, relativePath);
71
+ const tmpDir = path.join(path.resolve(rootDir), '.gemstack', 'tmp');
72
+ ensureDir(tmpDir);
73
+
74
+ const tmpFile = path.join(tmpDir, `atomic-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.tmp`);
75
+ try {
76
+ writeFn(tmpFile);
77
+ ensureDir(path.dirname(finalPath));
78
+ fs.renameSync(tmpFile, finalPath);
79
+ return finalPath;
80
+ } finally {
81
+ if (fs.existsSync(tmpFile)) {
82
+ try { fs.unlinkSync(tmpFile); } catch {}
20
83
  }
21
84
  }
85
+ }
86
+
87
+ module.exports = {
88
+ resolveSafe,
89
+ resolveSafeStrict,
90
+ ensureDir,
91
+ withConfinedAtomicWrite
22
92
  };
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Memory Cross-Audit Engine (Gemstack 2.0 Sprint D)
5
+ * Reconciles git commit log with handoff.md to detect unrecorded work and memory drift.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { spawnSync } = require('child_process');
11
+ const fssafe = require('./filesystem-safe');
12
+
13
+ const MANDATORY_SECTIONS = [
14
+ '1. Objetivo',
15
+ '2. Estado actual',
16
+ '3. Archivos y cambios',
17
+ '4. Intentos fallidos',
18
+ '5. Próximos pasos'
19
+ ];
20
+
21
+ /**
22
+ * Extracts recent git commits offline from the repository log.
23
+ * @param {string} targetDir
24
+ * @param {number} limit
25
+ * @returns {Array<{ hash: string, message: string }>}
26
+ */
27
+ function getRecentGitCommits(targetDir, limit = 5) {
28
+ try {
29
+ const gitRes = spawnSync('git', ['log', `-n${limit}`, '--oneline'], {
30
+ cwd: targetDir,
31
+ encoding: 'utf8',
32
+ shell: false
33
+ });
34
+
35
+ if (gitRes.status !== 0 || !gitRes.stdout) {
36
+ return [];
37
+ }
38
+
39
+ return gitRes.stdout
40
+ .trim()
41
+ .split('\n')
42
+ .filter(Boolean)
43
+ .map(line => {
44
+ const parts = line.trim().split(' ');
45
+ const hash = parts[0];
46
+ const message = parts.slice(1).join(' ');
47
+ return { hash, message };
48
+ });
49
+ } catch (_) {
50
+ return [];
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Validates handoff integrity and cross-checks recent git commits against handoff records.
56
+ * @param {string} targetDir - Directory containing handoff.md
57
+ * @param {object} options - { commits?: Array<{ hash: string, message: string }>, limit?: number }
58
+ * @returns {{ valid: boolean, handoff_intact: boolean, unrecorded_commits: Array<object>, error?: string }}
59
+ */
60
+ function crossAuditMemoryWithGit(targetDir, options = {}) {
61
+ const handoffPath = fssafe.resolveSafe(targetDir, 'handoff.md');
62
+ if (!fs.existsSync(handoffPath)) {
63
+ return {
64
+ valid: false,
65
+ handoff_intact: false,
66
+ unrecorded_commits: [],
67
+ error: 'handoff.md no encontrado en el directorio raíz'
68
+ };
69
+ }
70
+
71
+ const handoffContent = fs.readFileSync(handoffPath, 'utf8');
72
+
73
+ // 1. Verify mandatory sections
74
+ for (const sec of MANDATORY_SECTIONS) {
75
+ if (!handoffContent.includes(sec)) {
76
+ return {
77
+ valid: false,
78
+ handoff_intact: false,
79
+ missing_section: sec,
80
+ unrecorded_commits: [],
81
+ error: `handoff.md carece de la sección obligatoria: "${sec}".`
82
+ };
83
+ }
84
+ }
85
+
86
+ // 2. Obtain commits to check
87
+ const commits = options.commits || getRecentGitCommits(targetDir, options.limit || 5);
88
+ const unrecordedCommits = [];
89
+
90
+ for (const c of commits) {
91
+ // Check if commit hash or key terms of message are mentioned in handoff
92
+ const hashFound = c.hash && handoffContent.toLowerCase().includes(c.hash.toLowerCase());
93
+
94
+ // Extract meaningful words (length >= 5) from commit message
95
+ const words = c.message
96
+ ? c.message
97
+ .replace(/[^\w\s-]/g, '')
98
+ .split(/\s+/)
99
+ .filter(w => w.length >= 5)
100
+ : [];
101
+
102
+ const wordsFound = words.length > 0 && words.some(w => handoffContent.toLowerCase().includes(w.toLowerCase()));
103
+
104
+ if (!hashFound && !wordsFound) {
105
+ unrecordedCommits.push(c);
106
+ }
107
+ }
108
+
109
+ return {
110
+ valid: unrecordedCommits.length === 0,
111
+ handoff_intact: true,
112
+ unrecorded_commits: unrecordedCommits,
113
+ recent_commits_checked: commits.length
114
+ };
115
+ }
116
+
117
+ module.exports = {
118
+ MANDATORY_SECTIONS,
119
+ getRecentGitCommits,
120
+ crossAuditMemoryWithGit
121
+ };
@@ -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
- if (options.environment && VALID_ENV_TIERS.includes(options.environment)) {
21
- return options.environment;
20
+ const rawEnv = (options.environment || '').toLowerCase().trim();
21
+ if (rawEnv && VALID_ENV_TIERS.includes(rawEnv)) {
22
+ return rawEnv;
22
23
  }
23
24
 
24
- if (env.GEMSTACK_ENV && VALID_ENV_TIERS.includes(env.GEMSTACK_ENV)) {
25
- return env.GEMSTACK_ENV;
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
@@ -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 = typeof request.requested_units === 'number' ? request.requested_units : 1;
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
- const isTokenValid = token && (token.granted === true || token.granted_by || token.source === 'CLI_FLAG' || token.max_budget_units !== undefined);
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
- if (estimatedTotalCost > token.max_budget_units) {
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 (' + estimatedTotalCost + ') exceeds authorized budget limit (' + token.max_budget_units + ').',
258
- context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier, estimated_total_cost: estimatedTotalCost, max_budget_units: token.max_budget_units }
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
+ };