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.
Files changed (59) hide show
  1. package/.agents/rules/03-gemstack-security.md +2 -2
  2. package/.gemstack/state.json +10 -11
  3. package/CHANGELOG.md +33 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +47 -13
  6. package/RELEASE_NOTES.md +40 -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/011-gemstack-2.0-hardening/.gemstack.json +5 -0
  12. package/specs/011-gemstack-2.0-hardening/closure.json +58 -0
  13. package/specs/011-gemstack-2.0-hardening/plan.md +210 -0
  14. package/specs/011-gemstack-2.0-hardening/spec.md +277 -0
  15. package/specs/011-gemstack-2.0-hardening/tasks.md +59 -0
  16. package/specs/012-gemstack-2.0-honest-evidence/.gemstack.json +5 -0
  17. package/specs/012-gemstack-2.0-honest-evidence/closure.json +58 -0
  18. package/specs/012-gemstack-2.0-honest-evidence/plan.md +202 -0
  19. package/specs/012-gemstack-2.0-honest-evidence/spec.md +222 -0
  20. package/specs/012-gemstack-2.0-honest-evidence/tasks.md +99 -0
  21. package/specs/013-gemstack-2.0-adaptable-sdd/.gemstack.json +9 -0
  22. package/specs/013-gemstack-2.0-adaptable-sdd/closure.json +58 -0
  23. package/specs/013-gemstack-2.0-adaptable-sdd/context-capsule.json +227 -0
  24. package/specs/013-gemstack-2.0-adaptable-sdd/plan.md +179 -0
  25. package/specs/013-gemstack-2.0-adaptable-sdd/spec.md +212 -0
  26. package/specs/013-gemstack-2.0-adaptable-sdd/tasks.md +90 -0
  27. package/specs/014-gemstack-2.0-context-memory/.gemstack.json +9 -0
  28. package/specs/014-gemstack-2.0-context-memory/closure.json +58 -0
  29. package/specs/014-gemstack-2.0-context-memory/plan.md +161 -0
  30. package/specs/014-gemstack-2.0-context-memory/spec.md +163 -0
  31. package/specs/014-gemstack-2.0-context-memory/tasks.md +79 -0
  32. package/src/cli.js +3 -0
  33. package/src/commands/doctor.js +18 -0
  34. package/src/commands/hooks.js +98 -14
  35. package/src/commands/init.js +1 -1
  36. package/src/commands/install.js +174 -49
  37. package/src/commands/spec.js +105 -0
  38. package/src/commands/update.js +1 -1
  39. package/src/commands/verify.js +10 -0
  40. package/src/lib/backup.js +3 -3
  41. package/src/lib/context-fatigue.js +165 -0
  42. package/src/lib/contract-amendments.js +109 -0
  43. package/src/lib/dependency-audit.js +202 -0
  44. package/src/lib/filesystem-safe.js +85 -15
  45. package/src/lib/memory-audit.js +121 -0
  46. package/src/lib/provider-boundary.js +5 -1
  47. package/src/lib/provider-registry.js +6 -4
  48. package/src/lib/safety-gates.js +176 -8
  49. package/src/lib/sdd-rigor.js +181 -0
  50. package/src/lib/spec-delta.js +194 -0
  51. package/src/lib/spec-merge.js +168 -0
  52. package/src/lib/swarm.js +2 -2
  53. package/src/lib/visual-qa.js +162 -9
  54. package/template/.agents/rules/03-gemstack-security.md +2 -2
  55. package/.github/workflows/main-ci.yml +0 -32
  56. package/.github/workflows/pr-ci.yml +0 -31
  57. package/.github/workflows/publish.yml +0 -52
  58. package/.github/workflows/release-readiness.yml +0 -43
  59. package/gemstack-ai-1.4.0.tgz +0 -0
@@ -2,10 +2,58 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const logger = require('../lib/logger');
4
4
 
5
- const PRE_COMMIT_HOOK = `#!/bin/sh
6
- # Gemstack - Git Pre-commit Hook
7
- # Este hook previene que subas secretos o rompas la Ley de Seguridad (Rule 03).
5
+ const SECRET_PATTERNS = [
6
+ { type: 'GOOGLE_AI_KEY', regex: new RegExp('AIza[0-9A-Za-z_-]{35}'), description: 'Google AI / Gemini API Key' },
7
+ { type: 'OPENAI_KEY', regex: new RegExp('sk-(?:proj-)?[a-zA-Z0-9_-]{20,}'), description: 'OpenAI API Key' },
8
+ { type: 'ANTHROPIC_KEY', regex: new RegExp('sk-ant-[a-zA-Z0-9_-]{20,}'), description: 'Anthropic API Key' },
9
+ { type: 'GITHUB_PAT', regex: new RegExp('(?:' + 'ghp_' + '[0-9a-zA-Z]{36}|' + 'github_pat_' + '[0-9a-zA-Z_]{22,})'), description: 'GitHub Personal Access Token' },
10
+ { type: 'SLACK_TOKEN', regex: new RegExp('xox' + '[baprs]-[0-9a-zA-Z-]{10,}'), description: 'Slack Token' },
11
+ { type: 'STRIPE_KEY', regex: new RegExp('sk_' + '(?:live|test)_[0-9a-zA-Z]{24}'), description: 'Stripe Secret Key' },
12
+ { type: 'AWS_KEY', regex: new RegExp('AK' + 'IA[0-9A-Z]{16}'), description: 'AWS Access Key' },
13
+ { type: 'PRIVATE_KEY', regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/, description: 'Private Cryptographic Key' }
14
+ ];
8
15
 
16
+ function detectSecrets(content) {
17
+ if (!content || typeof content !== 'string') return [];
18
+ const results = [];
19
+ const lines = content.split('\n');
20
+
21
+ for (let i = 0; i < lines.length; i++) {
22
+ const line = lines[i];
23
+ if (line.includes('gemstack:allow-secret') || line.includes('NOOP_TEST_FIXTURE')) {
24
+ continue;
25
+ }
26
+ for (const pattern of SECRET_PATTERNS) {
27
+ const match = line.match(pattern.regex);
28
+ if (match) {
29
+ results.push({
30
+ type: pattern.type,
31
+ description: pattern.description,
32
+ match: match[0],
33
+ lineNumber: i + 1,
34
+ lineSnippet: line.trim()
35
+ });
36
+ }
37
+ }
38
+ }
39
+ return results;
40
+ }
41
+
42
+ const BASH_PATTERNS = [
43
+ 'AIza[0-9A-Za-z_-]{35}',
44
+ 'sk-(proj-)?[a-zA-Z0-9_-]{20,}',
45
+ 'sk-ant-',
46
+ 'gh' + 'p_',
47
+ 'github_' + 'pat_',
48
+ 'xox' + '[baprs]-',
49
+ 'sk_' + 'live_',
50
+ 'sk_' + 'test_',
51
+ 'AK' + 'IA[0-9A-Z]{16}',
52
+ '-----BEGIN [A-Z ]*PRIVATE KEY-----'
53
+ ].join('|');
54
+
55
+ const GEMSTACK_SECURITY_BLOCK = `
56
+ # --- [Gemstack] Active Security Scanner ---
9
57
  echo "[Gemstack] Ejecutando análisis de seguridad local..."
10
58
 
11
59
  # 1. Evitar que se suban archivos .env
@@ -17,10 +65,8 @@ fi
17
65
 
18
66
  # 2. Buscar marcadores de conflicto de merge olvidados
19
67
  if git diff --cached -S"<<<<<<< HEAD" --quiet; then
20
- # -quiet retorna 0 si encuentra coincidencias (no output)
21
68
  :
22
69
  else
23
- # Si git diff encuenta "<<<<<<<"
24
70
  if git diff --cached | grep -E "^\\+<<<<<<<" > /dev/null; then
25
71
  echo "❌ ERROR (Gemstack): Hay marcadores de conflicto de git (<<<<<<<) en tus archivos."
26
72
  echo "Resuélvelos antes de hacer commit."
@@ -28,19 +74,35 @@ else
28
74
  fi
29
75
  fi
30
76
 
31
- # 3. Buscar posibles llaves expuestas (Stripe, AWS, JWT genéricos) en código agregado
32
- # Extrae las líneas añadidas y busca patrones peligrosos
33
- if git diff --cached | grep -E "^\\+.*(sk_live_|sk_test_|AKIA[0-9A-Z]{16}|xoxb-|ghp_)" > /dev/null; then
34
- echo "❌ ERROR (Gemstack/CSO): ¡Posible llave secreta detectada en el código!"
77
+ # 3. Buscar llaves y credenciales expuestas en código agregado (excluyendo tests y specs)
78
+ if git diff --cached -- . ':!tests' ':!specs' | grep -E "^\\+.*(${BASH_PATTERNS})" > /dev/null; then
79
+ echo "❌ ERROR (Gemstack/CSO): ¡Posible llave secreta detectada en el código agregado!"
35
80
  echo "Regla 03: Cero Exposición de Credenciales. Extrae el secreto a una variable de entorno."
36
81
  exit 1
37
82
  fi
38
83
 
39
84
  echo "✅ [Gemstack] Código limpio. Committing..."
40
- exit 0
85
+ # --- End [Gemstack] ---
41
86
  `;
42
87
 
43
- function installHooks(targetDir) {
88
+ function generateChainedHook(hasWrappedHook) {
89
+ let script = '#!/bin/sh\n';
90
+ if (hasWrappedHook) {
91
+ script += `# Gemstack Hook Dispatcher with Preserved User Hook\n\n`;
92
+ script += `WRAPPED_HOOK="$(dirname "$0")/pre-commit.gemstack-wrapped"\n`;
93
+ script += `if [ -f "$WRAPPED_HOOK" ]; then\n`;
94
+ script += ` if ! "$WRAPPED_HOOK"; then\n`;
95
+ script += ` echo "❌ ERROR: El pre-commit hook previo del usuario falló."\n`;
96
+ script += ` exit 1\n`;
97
+ script += ` fi\n`;
98
+ script += `fi\n\n`;
99
+ }
100
+ script += GEMSTACK_SECURITY_BLOCK;
101
+ script += '\nexit 0\n';
102
+ return script;
103
+ }
104
+
105
+ function installHooks(targetDir = process.cwd()) {
44
106
  const gitDir = path.join(targetDir, '.git');
45
107
  const hooksDir = path.join(gitDir, 'hooks');
46
108
  const preCommitPath = path.join(hooksDir, 'pre-commit');
@@ -55,8 +117,21 @@ function installHooks(targetDir) {
55
117
  fs.mkdirSync(hooksDir, { recursive: true });
56
118
  }
57
119
 
58
- fs.writeFileSync(preCommitPath, PRE_COMMIT_HOOK, { mode: 0o755 });
59
- logger.ok('Git hooks (pre-commit) instalados exitosamente.');
120
+ let hasWrappedHook = false;
121
+ if (fs.existsSync(preCommitPath)) {
122
+ const existingContent = fs.readFileSync(preCommitPath, 'utf8');
123
+ if (!existingContent.includes('[Gemstack]')) {
124
+ // Preserve user hook by renaming to wrapped script
125
+ const wrappedPath = path.join(hooksDir, 'pre-commit.gemstack-wrapped');
126
+ fs.writeFileSync(wrappedPath, existingContent, { mode: 0o755 });
127
+ hasWrappedHook = true;
128
+ logger.info('Pre-commit hook previo detectado: preservado como pre-commit.gemstack-wrapped.');
129
+ }
130
+ }
131
+
132
+ const newHookContent = generateChainedHook(hasWrappedHook);
133
+ fs.writeFileSync(preCommitPath, newHookContent, { mode: 0o755 });
134
+ logger.ok('Git hooks (pre-commit) instalados exitosamente con encadenamiento seguro.');
60
135
  return true;
61
136
  } catch (err) {
62
137
  logger.error(`Error instalando git hooks: ${err.message}`);
@@ -64,4 +139,13 @@ function installHooks(targetDir) {
64
139
  }
65
140
  }
66
141
 
67
- module.exports = { installHooks };
142
+ async function hooksCommand(flags = {}) {
143
+ const targetDir = typeof flags === 'string' ? flags : (flags.target || process.cwd());
144
+ return installHooks(targetDir);
145
+ }
146
+
147
+ hooksCommand.installHooks = installHooks;
148
+ hooksCommand.detectSecrets = detectSecrets;
149
+ hooksCommand.SECRET_PATTERNS = SECRET_PATTERNS;
150
+
151
+ module.exports = hooksCommand;
@@ -31,7 +31,7 @@ module.exports = async (flags) => {
31
31
 
32
32
  walkDir(templateDir, (filePath) => {
33
33
  const relativePath = path.relative(templateDir, filePath).replace(/\\/g, '/');
34
- const destPath = fssafe.resolveSafe(targetDir, relativePath);
34
+ const destPath = fssafe.resolveSafeStrict(targetDir, relativePath);
35
35
 
36
36
  if (fs.existsSync(destPath)) {
37
37
  if (['handoff.md', 'handoff_archive.md', '.gemstack/learnings.md', '.gemstack/state.json'].includes(relativePath)) {
@@ -1,72 +1,197 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const https = require('https');
3
+ const crypto = require('crypto');
4
4
  const fssafe = require('../lib/filesystem-safe');
5
5
  const logger = require('../lib/logger');
6
6
  const manifestLib = require('../lib/manifest');
7
7
 
8
- async function fetchUrl(url) {
9
- const response = await fetch(url);
10
- if (!response.ok) {
11
- throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
8
+ const MAX_SKILL_PAYLOAD_BYTES = 262144; // 256 KB
9
+ const FETCH_TIMEOUT_MS = 8000;
10
+ const SKILL_SLUG_REGEX = /^[a-z0-9][a-z0-9-_]{1,63}$/;
11
+
12
+ function validateSkillPayloadSize(sizeBytes) {
13
+ if (sizeBytes > MAX_SKILL_PAYLOAD_BYTES) {
14
+ const err = new Error(`Skill payload too large: ${sizeBytes} bytes exceeds maximum limit of ${MAX_SKILL_PAYLOAD_BYTES} bytes`);
15
+ err.code = 'SKILL_PAYLOAD_TOO_LARGE';
16
+ throw err;
12
17
  }
13
- return await response.text();
18
+ return true;
14
19
  }
15
20
 
16
- function extractSkillName(content) {
17
- const match = content.match(/^name:\s*(.+)$/m);
18
- return match ? match[1].trim() : null;
21
+ function validateSkillSlug(slug) {
22
+ if (!slug || typeof slug !== 'string' || !SKILL_SLUG_REGEX.test(slug)) {
23
+ const err = new Error(`Invalid skill slug "${slug}". Must match ${SKILL_SLUG_REGEX}`);
24
+ err.code = 'INVALID_SKILL_SLUG';
25
+ throw err;
26
+ }
27
+ return true;
19
28
  }
20
29
 
21
- module.exports = async (url, flags) => {
22
- if (!url) {
23
- logger.error("Debe proporcionar una URL. Ejemplo: gemstack install https://raw.githubusercontent.com/.../SKILL.md");
24
- process.exit(1);
30
+ function isPrivateOrLoopbackHost(hostname) {
31
+ const lower = (hostname || '').toLowerCase().trim();
32
+ if (lower === 'localhost' || lower === '::1' || lower === '127.0.0.1') {
33
+ return true;
34
+ }
35
+ const ipv4Match = lower.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
36
+ if (ipv4Match) {
37
+ const o1 = Number(ipv4Match[1]);
38
+ const o2 = Number(ipv4Match[2]);
39
+ if (o1 === 10) return true;
40
+ if (o1 === 127) return true;
41
+ if (o1 === 169 && o2 === 254) return true;
42
+ if (o1 === 172 && (o2 >= 16 && o2 <= 31)) return true;
43
+ if (o1 === 192 && o2 === 168) return true;
25
44
  }
45
+ return false;
46
+ }
26
47
 
27
- const targetDir = flags.target || process.cwd();
28
- logger.info(`Descargando skill desde: ${url}`);
48
+ function validateUrlSafety(urlStr, flags = {}) {
49
+ let parsed;
50
+ try {
51
+ parsed = new URL(urlStr);
52
+ } catch (e) {
53
+ const err = new Error(`Malformed URL: ${urlStr}`);
54
+ err.code = 'MALFORMED_URL';
55
+ throw err;
56
+ }
57
+
58
+ if (parsed.protocol !== 'https:' && !flags.allowInsecureHttp) {
59
+ const err = new Error(`Insecure HTTP protocol blocked. HTTPS is required: ${urlStr}`);
60
+ err.code = 'INSECURE_HTTP_BLOCKED';
61
+ throw err;
62
+ }
63
+
64
+ if (isPrivateOrLoopbackHost(parsed.hostname)) {
65
+ const err = new Error(`Access to private or loopback IP address blocked: ${parsed.hostname}`);
66
+ err.code = 'PRIVATE_IP_BLOCKED';
67
+ throw err;
68
+ }
69
+
70
+ return parsed;
71
+ }
72
+
73
+ async function fetchSkillUrl(url, flags = {}) {
74
+ validateUrlSafety(url, flags);
75
+
76
+ const controller = new AbortController();
77
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
29
78
 
30
79
  try {
31
- const content = await fetchUrl(url);
32
-
33
- // Ensure it's a valid skill file
34
- if (!content.includes('---')) {
35
- throw new Error('El archivo descargado no parece un SKILL válido (no tiene frontmatter).');
80
+ const response = await fetch(url, { signal: controller.signal, redirect: 'error' });
81
+ if (!response.ok) {
82
+ throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
36
83
  }
37
84
 
38
- const skillName = extractSkillName(content);
39
- if (!skillName) {
40
- throw new Error('No se encontró "name: <nombre>" en el frontmatter del SKILL.');
85
+ const contentLength = response.headers.get('content-length');
86
+ if (contentLength) {
87
+ validateSkillPayloadSize(Number(contentLength));
41
88
  }
42
89
 
43
- // Calculate path
44
- const relativePath = `.agents/skills/${skillName}/SKILL.md`;
45
- const destPath = fssafe.resolveSafe(targetDir, relativePath);
90
+ const buffer = await response.arrayBuffer();
91
+ validateSkillPayloadSize(buffer.byteLength);
46
92
 
47
- if (flags.dryRun) {
48
- logger.ok(`Dry run: Se instalaría el skill "${skillName}" en ${relativePath}`);
49
- return;
93
+ return Buffer.from(buffer).toString('utf8');
94
+ } catch (e) {
95
+ if (e.name === 'AbortError') {
96
+ const err = new Error(`Download timed out after ${FETCH_TIMEOUT_MS}ms`);
97
+ err.code = 'FETCH_TIMEOUT';
98
+ throw err;
50
99
  }
100
+ throw e;
101
+ } finally {
102
+ clearTimeout(timer);
103
+ }
104
+ }
51
105
 
52
- // Install the skill
53
- fssafe.ensureDir(path.dirname(destPath));
54
- fs.writeFileSync(destPath, content, 'utf8');
55
-
56
- // Update manifest
57
- const manifest = manifestLib.loadManifest(targetDir);
58
- if (!manifest.files) manifest.files = [];
59
-
60
- const checksum = manifestLib.getChecksum(Buffer.from(content, 'utf8'));
61
- const ex = manifest.files.find(f => f.path === relativePath);
62
- if (ex) ex.checksum = checksum;
63
- else manifest.files.push({ path: relativePath, checksum });
64
-
65
- manifestLib.saveManifest(targetDir, manifest, false);
66
-
67
- logger.ok(`✅ Skill "${skillName}" instalado exitosamente en ${relativePath}`);
68
- } catch (e) {
69
- logger.error(`Error instalando el skill: ${e.message}`);
70
- process.exit(1);
106
+ function inspectSkillContent(content) {
107
+ if (!content || typeof content !== 'string') {
108
+ const err = new Error('Skill content is empty or invalid');
109
+ err.code = 'INVALID_SKILL_FRONTMATTER';
110
+ throw err;
71
111
  }
72
- };
112
+
113
+ const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
114
+ if (!frontmatterMatch) {
115
+ const err = new Error('El archivo descargado no parece un SKILL válido (no tiene frontmatter delimitado por ---).');
116
+ err.code = 'INVALID_SKILL_FRONTMATTER';
117
+ throw err;
118
+ }
119
+
120
+ const frontmatterBody = frontmatterMatch[1];
121
+ const nameMatch = frontmatterBody.match(/^name:\s*(.+)$/m);
122
+ if (!nameMatch || !nameMatch[1].trim()) {
123
+ const err = new Error('No se encontró "name: <nombre>" en el frontmatter del SKILL.');
124
+ err.code = 'INVALID_SKILL_FRONTMATTER';
125
+ throw err;
126
+ }
127
+
128
+ const rawName = nameMatch[1].trim();
129
+ validateSkillSlug(rawName);
130
+
131
+ const sha256 = crypto.createHash('sha256').update(content, 'utf8').digest('hex');
132
+
133
+ return {
134
+ name: rawName,
135
+ sha256,
136
+ content
137
+ };
138
+ }
139
+
140
+ async function install(arg1, arg2) {
141
+ let url;
142
+ let flags = {};
143
+
144
+ if (typeof arg1 === 'string') {
145
+ url = arg1;
146
+ flags = arg2 || {};
147
+ } else if (arg1 && typeof arg1 === 'object') {
148
+ flags = arg1;
149
+ url = flags.url;
150
+ } else {
151
+ flags = arg2 || {};
152
+ }
153
+
154
+ if (!url) {
155
+ const err = new Error("Debe proporcionar una URL. Ejemplo: gemstack install https://raw.githubusercontent.com/.../SKILL.md");
156
+ err.code = 'MISSING_URL';
157
+ throw err;
158
+ }
159
+
160
+ const targetDir = flags.target || process.cwd();
161
+ logger.info(`Descargando skill desde: ${url}`);
162
+
163
+ const rawContent = await fetchSkillUrl(url, flags);
164
+ const inspected = inspectSkillContent(rawContent);
165
+
166
+ const relativePath = `.agents/skills/${inspected.name}/SKILL.md`;
167
+
168
+ if (flags.dryRun) {
169
+ logger.ok(`Dry run: Se instalaría el skill "${inspected.name}" (SHA-256: ${inspected.sha256.slice(0, 16)}...) en ${relativePath}`);
170
+ return { name: inspected.name, path: relativePath, sha256: inspected.sha256, installed: false };
171
+ }
172
+
173
+ // Atomic write inside project boundaries
174
+ fssafe.withConfinedAtomicWrite(targetDir, relativePath, (tempFile) => {
175
+ fs.writeFileSync(tempFile, rawContent, 'utf8');
176
+ });
177
+
178
+ // Update manifest
179
+ const manifest = manifestLib.loadManifest(targetDir);
180
+ if (!manifest.files) manifest.files = [];
181
+
182
+ const ex = manifest.files.find(f => f.path === relativePath);
183
+ if (ex) ex.checksum = inspected.sha256;
184
+ else manifest.files.push({ path: relativePath, checksum: inspected.sha256 });
185
+
186
+ manifestLib.saveManifest(targetDir, manifest, false);
187
+
188
+ logger.ok(`✅ Skill "${inspected.name}" instalado exitosamente en ${relativePath} (SHA-256: ${inspected.sha256.slice(0, 12)}...)`);
189
+ return { name: inspected.name, path: relativePath, sha256: inspected.sha256, installed: true };
190
+ }
191
+
192
+ install.validateSkillPayloadSize = validateSkillPayloadSize;
193
+ install.validateSkillSlug = validateSkillSlug;
194
+ install.validateUrlSafety = validateUrlSafety;
195
+ install.inspectSkillContent = inspectSkillContent;
196
+
197
+ module.exports = install;
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const logger = require('../lib/logger');
6
+ const fssafe = require('../lib/filesystem-safe');
7
+ const { loadState } = require('../lib/state');
8
+ const { detectSpecConflicts, mergeSpecs } = require('../lib/spec-merge');
9
+ const { detectRigorLevel, validateRigorRequirements } = require('../lib/sdd-rigor');
10
+ const { validateContractAmendments } = require('../lib/contract-amendments');
11
+
12
+ async function specCommand(args = [], flags = {}) {
13
+ const targetDir = flags.target ? path.resolve(flags.target) : process.cwd();
14
+ const subCommand = args[0] || 'validate';
15
+
16
+ const state = loadState(targetDir);
17
+ if (!state || !state.active_spec) {
18
+ logger.error('No hay una especificación activa configurada en .gemstack/state.json');
19
+ process.exit(1);
20
+ }
21
+
22
+ const activeSpecDir = fssafe.resolveSafe(targetDir, state.active_spec);
23
+ const activeSpecFile = path.join(activeSpecDir, 'spec.md');
24
+
25
+ if (!fs.existsSync(activeSpecFile)) {
26
+ logger.error(`spec.md no encontrado en ${activeSpecFile}`);
27
+ process.exit(1);
28
+ }
29
+
30
+ const activeSpecContent = fs.readFileSync(activeSpecFile, 'utf8');
31
+
32
+ switch (subCommand) {
33
+ case 'merge': {
34
+ const targetSpecPath = args[1];
35
+ if (!targetSpecPath) {
36
+ logger.error('Uso: gemstack spec merge <ruta-o-rama-de-spec>');
37
+ process.exit(1);
38
+ }
39
+
40
+ const incomingDir = fssafe.resolveSafe(targetDir, targetSpecPath);
41
+ const incomingSpecFile = path.join(incomingDir, 'spec.md');
42
+
43
+ if (!fs.existsSync(incomingSpecFile)) {
44
+ logger.error(`spec.md entrante no encontrado en: ${incomingSpecFile}`);
45
+ process.exit(1);
46
+ }
47
+
48
+ const incomingContent = fs.readFileSync(incomingSpecFile, 'utf8');
49
+ logger.info(`Comparando especificaciones: "${state.active_spec}" vs "${targetSpecPath}"...`);
50
+
51
+ const conflictReport = detectSpecConflicts(activeSpecContent, incomingContent);
52
+ if (!conflictReport.valid) {
53
+ logger.error(`[SPEC_MERGE_CONFLICT] Se detectaron ${conflictReport.conflicts.length} conflicto(s):`);
54
+ for (const c of conflictReport.conflicts) {
55
+ logger.error(` - [${c.type}] ${c.id}: ${c.reason}`);
56
+ }
57
+ process.exit(1);
58
+ }
59
+
60
+ logger.ok('No se detectaron colisiones de contratos ni duplicados de tests canónicos.');
61
+ const merged = mergeSpecs(activeSpecContent, incomingContent);
62
+ logger.ok(`Fusión completada con éxito (${merged.contracts.length} contratos, ${merged.tests.length} tests canónicos).`);
63
+ break;
64
+ }
65
+
66
+ case 'validate': {
67
+ const rigor = detectRigorLevel(activeSpecContent);
68
+ logger.info(`Validando especificación "${state.active_spec}" (Rigor: ${rigor})...`);
69
+
70
+ const planFile = path.join(activeSpecDir, 'plan.md');
71
+ const tasksFile = path.join(activeSpecDir, 'tasks.md');
72
+ const planContent = fs.existsSync(planFile) ? fs.readFileSync(planFile, 'utf8') : null;
73
+ const tasksContent = fs.existsSync(tasksFile) ? fs.readFileSync(tasksFile, 'utf8') : null;
74
+
75
+ const { extractTestMatrixBlock } = require('../lib/test-matrix');
76
+ const matrixResult = extractTestMatrixBlock(activeSpecContent);
77
+ const testMatrix = !matrixResult.isLegacy ? matrixResult.matrix : [];
78
+
79
+ const { readSidecar } = require('../lib/state');
80
+ const sidecar = readSidecar(activeSpecDir);
81
+
82
+ const rigorResult = validateRigorRequirements(rigor, {
83
+ specContent: activeSpecContent,
84
+ planContent,
85
+ tasksContent,
86
+ testMatrix,
87
+ sidecar
88
+ });
89
+
90
+ if (!rigorResult.valid) {
91
+ logger.error(`[RIGOR_VALIDATION_FAILED] ${rigorResult.code}: ${rigorResult.error}`);
92
+ process.exit(1);
93
+ }
94
+
95
+ logger.ok(`Especificación válida bajo rigor "${rigor}".`);
96
+ break;
97
+ }
98
+
99
+ default:
100
+ logger.error(`Subcomando desconocido para "gemstack spec": ${subCommand}. Subcomandos disponibles: validate, merge`);
101
+ process.exit(1);
102
+ }
103
+ }
104
+
105
+ module.exports = specCommand;
@@ -24,7 +24,7 @@ module.exports = async (flags) => {
24
24
 
25
25
  walkDir(templateDir, (filePath) => {
26
26
  const relativePath = path.relative(templateDir, filePath).replace(/\\/g, '/');
27
- const destPath = fssafe.resolveSafe(targetDir, relativePath);
27
+ const destPath = fssafe.resolveSafeStrict(targetDir, relativePath);
28
28
 
29
29
  const tmplContent = fs.readFileSync(filePath);
30
30
  const tmplCheck = manifestLib.getChecksum(tmplContent);
@@ -87,6 +87,16 @@ module.exports = async (flags) => {
87
87
  } else {
88
88
  logger.ok('Sección inmutable "4. Intentos fallidos" preservada.');
89
89
  }
90
+
91
+ // Cross-Audit con Git Log (Gemstack 2.0 Sprint D)
92
+ const { crossAuditMemoryWithGit } = require('../lib/memory-audit');
93
+ const memAudit = crossAuditMemoryWithGit(targetDir);
94
+ if (!memAudit.valid && memAudit.unrecorded_commits.length > 0) {
95
+ logger.warn(`Detectados commits recientes no registrados en handoff.md: ${memAudit.unrecorded_commits.map(c => c.hash).join(', ')}`);
96
+ totalWarnings++;
97
+ } else {
98
+ logger.ok('Memoria cruzada (handoff.md <-> git log) verificada.');
99
+ }
90
100
  }
91
101
 
92
102
  // 3. Consistencia de Estado Local (.gemstack/state.json)
package/src/lib/backup.js CHANGED
@@ -5,12 +5,12 @@ const logger = require('./logger');
5
5
 
6
6
  module.exports = {
7
7
  backupFile: (targetDir, relativeFilePath, dryRun, sessionTimestamp) => {
8
- const fullPath = fssafe.resolveSafe(targetDir, relativeFilePath);
8
+ const fullPath = fssafe.resolveSafeStrict(targetDir, relativeFilePath);
9
9
  if (!fs.existsSync(fullPath)) return;
10
10
 
11
11
  const timestamp = sessionTimestamp || new Date().toISOString().replace(/[:.]/g, '-');
12
- const backupDir = fssafe.resolveSafe(targetDir, `.gemstack/backups/${timestamp}`);
13
- const backupDest = fssafe.resolveSafe(backupDir, relativeFilePath);
12
+ const backupDir = fssafe.resolveSafeStrict(targetDir, `.gemstack/backups/${timestamp}`);
13
+ const backupDest = fssafe.resolveSafeStrict(backupDir, relativeFilePath);
14
14
 
15
15
  logger.info(`Backup: ${relativeFilePath}`);
16
16
  if (!dryRun) {