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
package/src/commands/hooks.js
CHANGED
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
85
|
+
# --- End [Gemstack] ---
|
|
41
86
|
`;
|
|
42
87
|
|
|
43
|
-
function
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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;
|
package/src/commands/init.js
CHANGED
|
@@ -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.
|
|
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)) {
|
package/src/commands/install.js
CHANGED
|
@@ -1,72 +1,197 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
18
|
+
return true;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
39
|
-
if (
|
|
40
|
-
|
|
85
|
+
const contentLength = response.headers.get('content-length');
|
|
86
|
+
if (contentLength) {
|
|
87
|
+
validateSkillPayloadSize(Number(contentLength));
|
|
41
88
|
}
|
|
42
89
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const destPath = fssafe.resolveSafe(targetDir, relativePath);
|
|
90
|
+
const buffer = await response.arrayBuffer();
|
|
91
|
+
validateSkillPayloadSize(buffer.byteLength);
|
|
46
92
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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;
|
package/src/commands/update.js
CHANGED
|
@@ -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.
|
|
27
|
+
const destPath = fssafe.resolveSafeStrict(targetDir, relativePath);
|
|
28
28
|
|
|
29
29
|
const tmplContent = fs.readFileSync(filePath);
|
|
30
30
|
const tmplCheck = manifestLib.getChecksum(tmplContent);
|
package/src/commands/verify.js
CHANGED
|
@@ -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.
|
|
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.
|
|
13
|
-
const backupDest = fssafe.
|
|
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) {
|