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
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Context Fatigue & Noise Pruning Engine (Gemstack 2.0 Sprint D)
5
+ * Monitors accumulated token load, detects redundancy, and prunes ephemeral noise.
6
+ */
7
+
8
+ const DEFAULT_TOKEN_THRESHOLD = 16000;
9
+ const DEFAULT_REDUNDANCY_THRESHOLD = 0.4;
10
+
11
+ /**
12
+ * Fast conservative token estimation (~4 characters per token).
13
+ * @param {string} text
14
+ * @returns {number}
15
+ */
16
+ function estimateTokens(text) {
17
+ if (!text || typeof text !== 'string') return 0;
18
+ return Math.ceil(text.length / 4);
19
+ }
20
+
21
+ /**
22
+ * Calculates redundancy ratio based on repeated content blocks and duplicate messages.
23
+ * @param {Array<string|object>} messages
24
+ * @returns {number} Float between 0.0 and 1.0
25
+ */
26
+ function computeRedundancyRatio(messages) {
27
+ if (!Array.isArray(messages) || messages.length <= 1) return 0;
28
+
29
+ const texts = messages.map(m => (typeof m === 'string' ? m : (m.content || m.text || JSON.stringify(m))));
30
+ const totalLength = texts.reduce((acc, t) => acc + t.length, 0);
31
+ if (totalLength === 0) return 0;
32
+
33
+ const seen = new Set();
34
+ let duplicateLength = 0;
35
+
36
+ for (const t of texts) {
37
+ const trimmed = t.trim();
38
+ if (seen.has(trimmed)) {
39
+ duplicateLength += trimmed.length;
40
+ } else {
41
+ seen.add(trimmed);
42
+ }
43
+ }
44
+
45
+ return Number((duplicateLength / totalLength).toFixed(4));
46
+ }
47
+
48
+ /**
49
+ * Detects whether the current context window suffers from token fatigue or high redundancy.
50
+ * @param {Array<string|object>} messages
51
+ * @param {object} options - { tokenThreshold?: number, redundancyThreshold?: number }
52
+ * @returns {{ fatigue: boolean, total_tokens: number, token_limit: number, redundancy_ratio: number, reason?: string }}
53
+ */
54
+ function detectContextFatigue(messages, options = {}) {
55
+ const tokenThreshold = options.tokenThreshold || DEFAULT_TOKEN_THRESHOLD;
56
+ const redundancyThreshold = options.redundancyThreshold || DEFAULT_REDUNDANCY_THRESHOLD;
57
+
58
+ if (!Array.isArray(messages)) {
59
+ return {
60
+ fatigue: false,
61
+ total_tokens: 0,
62
+ token_limit: tokenThreshold,
63
+ redundancy_ratio: 0
64
+ };
65
+ }
66
+
67
+ const texts = messages.map(m => (typeof m === 'string' ? m : (m.content || m.text || JSON.stringify(m))));
68
+ const totalTokens = texts.reduce((acc, t) => acc + estimateTokens(t), 0);
69
+ const redundancyRatio = computeRedundancyRatio(messages);
70
+
71
+ let fatigue = false;
72
+ const reasons = [];
73
+
74
+ if (totalTokens > tokenThreshold) {
75
+ fatigue = true;
76
+ reasons.push(`Token count (${totalTokens}) exceeds threshold (${tokenThreshold})`);
77
+ }
78
+
79
+ if (redundancyRatio > redundancyThreshold) {
80
+ fatigue = true;
81
+ reasons.push(`Redundancy ratio (${(redundancyRatio * 100).toFixed(1)}%) exceeds limit (${(redundancyThreshold * 100).toFixed(1)}%)`);
82
+ }
83
+
84
+ return {
85
+ fatigue,
86
+ total_tokens: totalTokens,
87
+ token_limit: tokenThreshold,
88
+ redundancy_ratio: redundancyRatio,
89
+ reason: reasons.length > 0 ? reasons.join('; ') : undefined
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Deterministically prunes ephemeral noise, duplicated tool outputs, and redundant dialogue.
95
+ * Guarantees preservation of architectural contracts, state definitions, and critical decisions.
96
+ * @param {Array<string|object>} messages
97
+ * @param {object} options - { retainTail?: number }
98
+ * @returns {Array<string|object>} Pruned message array
99
+ */
100
+ function pruneContextNoise(messages, options = {}) {
101
+ if (!Array.isArray(messages) || messages.length === 0) return [];
102
+ const retainTail = options.retainTail || 3;
103
+
104
+ const isContractOrState = (text) => {
105
+ return (
106
+ text.includes('gemstack-contracts') ||
107
+ text.includes('gemstack-inherited-contracts') ||
108
+ text.includes('gemstack-test-matrix') ||
109
+ text.includes('phase_hashes') ||
110
+ text.includes('handoff.md') ||
111
+ text.includes('### Intentos fallidos') ||
112
+ text.includes('4. Intentos fallidos')
113
+ );
114
+ };
115
+
116
+ const isEphemeralNoise = (text) => {
117
+ const trimmed = text.trim();
118
+ if (trimmed.length < 5) return true;
119
+ if (/^(ok|done|entendido|continuando|esperando|running)(?:\.{1,3})?$/i.test(trimmed)) return true;
120
+ return false;
121
+ };
122
+
123
+ const seenHashes = new Set();
124
+ const pruned = [];
125
+
126
+ for (let i = 0; i < messages.length; i++) {
127
+ const m = messages[i];
128
+ const text = typeof m === 'string' ? m : (m.content || m.text || JSON.stringify(m));
129
+
130
+ // Always preserve contracts, invariants and state
131
+ if (isContractOrState(text)) {
132
+ pruned.push(m);
133
+ continue;
134
+ }
135
+
136
+ // Always preserve recent tail messages
137
+ if (i >= messages.length - retainTail) {
138
+ pruned.push(m);
139
+ continue;
140
+ }
141
+
142
+ // Skip pure ephemeral chit-chat
143
+ if (isEphemeralNoise(text)) {
144
+ continue;
145
+ }
146
+
147
+ // De-duplicate identical intermediate messages
148
+ const trimmed = text.trim();
149
+ if (seenHashes.has(trimmed)) {
150
+ continue;
151
+ }
152
+ seenHashes.add(trimmed);
153
+
154
+ pruned.push(m);
155
+ }
156
+
157
+ return pruned;
158
+ }
159
+
160
+ module.exports = {
161
+ estimateTokens,
162
+ computeRedundancyRatio,
163
+ detectContextFatigue,
164
+ pruneContextNoise
165
+ };
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Formal Contract Amendment Engine (Gemstack 2.0 Sprint C)
5
+ * Replaces silent contract mutations with signed, auditable amendment records.
6
+ */
7
+
8
+ const crypto = require('crypto');
9
+
10
+ const REQUIRED_AMENDMENT_FIELDS = ['amendment_id', 'contract_id', 'version', 'reason', 'approved_by', 'signature'];
11
+
12
+ /**
13
+ * Computes deterministic signature for an amendment.
14
+ * @param {object} amendment - { amendment_id, contract_id, version, reason, approved_by }
15
+ * @param {string|null} secret - Optional HMAC secret
16
+ * @returns {string} Hex-encoded SHA-256 or HMAC-SHA256 digest
17
+ */
18
+ function computeAmendmentSignature(amendment, secret = null) {
19
+ if (!amendment || typeof amendment !== 'object') {
20
+ throw new Error('Amendment must be an object');
21
+ }
22
+
23
+ const payload = [
24
+ String(amendment.amendment_id || ''),
25
+ String(amendment.contract_id || ''),
26
+ String(amendment.version || ''),
27
+ String(amendment.reason || '').trim(),
28
+ String(amendment.approved_by || '').trim()
29
+ ].join('|');
30
+
31
+ if (secret && typeof secret === 'string' && secret.length > 0) {
32
+ return crypto.createHmac('sha256', secret).update(payload).digest('hex');
33
+ }
34
+
35
+ return crypto.createHash('sha256').update(payload).digest('hex');
36
+ }
37
+
38
+ /**
39
+ * Validates that any changes from upstreamContracts to currentContracts are justified by signed amendments.
40
+ * @param {Array<object>} upstreamContracts
41
+ * @param {Array<object>} currentContracts
42
+ * @param {Array<object>} amendments
43
+ * @param {object} options - { secret?: string }
44
+ * @returns {{ valid: boolean, code?: string, error?: string, verified_amendments?: number }}
45
+ */
46
+ function validateContractAmendments(upstreamContracts = [], currentContracts = [], amendments = [], options = {}) {
47
+ const upstreamMap = new Map((upstreamContracts || []).map(c => [c.id, c]));
48
+ const currentMap = new Map((currentContracts || []).map(c => [c.id, c]));
49
+ const amendmentList = Array.isArray(amendments) ? amendments : [];
50
+ const amendmentMap = new Map(amendmentList.map(a => [a.contract_id, a]));
51
+
52
+ // 1. Detect modified or removed contracts
53
+ const changedContractIds = [];
54
+
55
+ for (const [id, upstream] of upstreamMap.entries()) {
56
+ if (!currentMap.has(id)) {
57
+ changedContractIds.push({ id, type: 'REMOVED', upstream });
58
+ } else {
59
+ const current = currentMap.get(id);
60
+ if (JSON.stringify(upstream) !== JSON.stringify(current)) {
61
+ changedContractIds.push({ id, type: 'MODIFIED', upstream, current });
62
+ }
63
+ }
64
+ }
65
+
66
+ // 2. Ensure each changed contract has a valid, signed amendment
67
+ for (const changed of changedContractIds) {
68
+ if (!amendmentMap.has(changed.id)) {
69
+ return {
70
+ valid: false,
71
+ code: 'UNAUTHORIZED_CONTRACT_MUTATION',
72
+ error: `El contrato congelado "${changed.id}" fue ${changed.type === 'REMOVED' ? 'eliminado' : 'modificado'} sin un registro formal de enmienda.`
73
+ };
74
+ }
75
+ }
76
+
77
+ // 3. Verify each declared amendment
78
+ for (const a of amendmentList) {
79
+ for (const field of REQUIRED_AMENDMENT_FIELDS) {
80
+ if (!a[field] && a[field] !== 0) {
81
+ return {
82
+ valid: false,
83
+ code: 'AMENDMENT_MALFORMED',
84
+ error: `Enmienda "${a.amendment_id || 'UNKNOWN'}" carece del campo obligatorio "${field}".`
85
+ };
86
+ }
87
+ }
88
+
89
+ const expectedSig = computeAmendmentSignature(a, options.secret);
90
+ if (a.signature !== expectedSig) {
91
+ return {
92
+ valid: false,
93
+ code: 'AMENDMENT_SIGNATURE_INVALID',
94
+ error: `Firma criptográfica inválida para la enmienda "${a.amendment_id}" del contrato "${a.contract_id}".`
95
+ };
96
+ }
97
+ }
98
+
99
+ return {
100
+ valid: true,
101
+ verified_amendments: amendmentList.length
102
+ };
103
+ }
104
+
105
+ module.exports = {
106
+ REQUIRED_AMENDMENT_FIELDS,
107
+ computeAmendmentSignature,
108
+ validateContractAmendments
109
+ };
@@ -0,0 +1,202 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Offline Dependency Auditor (Gemstack 2.0 Sprint D)
5
+ * Detects orphan dependencies, undeclared imports, and circular local import cycles offline.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const fssafe = require('./filesystem-safe');
11
+
12
+ const NODE_BUILTINS = new Set([
13
+ 'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console',
14
+ 'constants', 'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain',
15
+ 'events', 'fs', 'fs/promises', 'http', 'http2', 'https', 'inspector',
16
+ 'module', 'net', 'os', 'path', 'path/posix', 'path/win32', 'perf_hooks',
17
+ 'process', 'punycode', 'querystring', 'readline', 'repl', 'stream',
18
+ 'stream/promises', 'stream/consumers', 'stream/web', 'string_decoder',
19
+ 'test', 'timers', 'timers/promises', 'tls', 'trace_events', 'tty',
20
+ 'url', 'util', 'util/types', 'v8', 'vm', 'wasi', 'worker_threads', 'zlib'
21
+ ]);
22
+
23
+ function isNodeBuiltin(moduleName) {
24
+ if (moduleName.startsWith('node:')) return true;
25
+ return NODE_BUILTINS.has(moduleName);
26
+ }
27
+
28
+ /**
29
+ * Extracts import/require targets from file content.
30
+ * @param {string} content
31
+ * @returns {Array<string>} List of required/imported module specifiers
32
+ */
33
+ function extractImportsFromContent(content) {
34
+ const imports = [];
35
+ // Match require('...')
36
+ const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
37
+ let match;
38
+ while ((match = requireRegex.exec(content)) !== null) {
39
+ imports.push(match[1]);
40
+ }
41
+
42
+ // Match import ... from '...' or import('...')
43
+ const importRegex = /(?:import\s+(?:[\s\S]*?from\s+)?|import\s*\()\s*['"]([^'"]+)['"]/g;
44
+ while ((match = importRegex.exec(content)) !== null) {
45
+ imports.push(match[1]);
46
+ }
47
+
48
+ return imports;
49
+ }
50
+
51
+ /**
52
+ * Resolves package name from import specifier.
53
+ * E.g., 'express' -> 'express', '@scope/pkg/sub' -> '@scope/pkg'
54
+ */
55
+ function getPackageName(specifier) {
56
+ if (specifier.startsWith('@')) {
57
+ const parts = specifier.split('/');
58
+ return parts.slice(0, 2).join('/');
59
+ }
60
+ return specifier.split('/')[0];
61
+ }
62
+
63
+ /**
64
+ * Scans directory recursively for JavaScript/TypeScript files.
65
+ */
66
+ function collectSourceFiles(dir, files = []) {
67
+ if (!fs.existsSync(dir)) return files;
68
+
69
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
70
+ for (const entry of entries) {
71
+ const fullPath = path.join(dir, entry.name);
72
+ if (entry.isDirectory()) {
73
+ if (entry.name !== 'node_modules' && entry.name !== '.git') {
74
+ collectSourceFiles(fullPath, files);
75
+ }
76
+ } else if (entry.isFile() && /\.(js|mjs|cjs|ts)$/.test(entry.name)) {
77
+ files.push(fullPath);
78
+ }
79
+ }
80
+ return files;
81
+ }
82
+
83
+ /**
84
+ * Detects circular dependency cycles using depth-first search.
85
+ */
86
+ function findCircularCycles(dependencyGraph) {
87
+ const cycles = [];
88
+ const visited = new Set();
89
+ const recursionStack = [];
90
+
91
+ function dfs(node) {
92
+ visited.add(node);
93
+ recursionStack.push(node);
94
+
95
+ const neighbors = dependencyGraph.get(node) || [];
96
+ for (const neighbor of neighbors) {
97
+ if (!visited.has(neighbor)) {
98
+ dfs(neighbor);
99
+ } else {
100
+ const cycleStartIndex = recursionStack.indexOf(neighbor);
101
+ if (cycleStartIndex !== -1) {
102
+ const cyclePath = [...recursionStack.slice(cycleStartIndex), neighbor];
103
+ cycles.push(cyclePath);
104
+ }
105
+ }
106
+ }
107
+
108
+ recursionStack.pop();
109
+ }
110
+
111
+ for (const node of dependencyGraph.keys()) {
112
+ if (!visited.has(node)) {
113
+ dfs(node);
114
+ }
115
+ }
116
+
117
+ return cycles;
118
+ }
119
+
120
+ /**
121
+ * Audits project dependencies and local module cycles completely offline.
122
+ * @param {string} targetDir - Directory containing package.json and src/
123
+ * @returns {{ orphans: string[], undeclared: string[], circularCycles: string[][], is_clean: boolean }}
124
+ */
125
+ function auditDependencies(targetDir) {
126
+ const pkgPath = fssafe.resolveSafe(targetDir, 'package.json');
127
+ let dependencies = {};
128
+ let devDependencies = {};
129
+
130
+ if (fs.existsSync(pkgPath)) {
131
+ try {
132
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
133
+ dependencies = pkg.dependencies || {};
134
+ devDependencies = pkg.devDependencies || {};
135
+ } catch (_) {}
136
+ }
137
+
138
+ const srcDir = fssafe.resolveSafe(targetDir, 'src');
139
+ const sourceFiles = collectSourceFiles(srcDir);
140
+
141
+ const usedPackages = new Set();
142
+ const dependencyGraph = new Map();
143
+
144
+ for (const filePath of sourceFiles) {
145
+ const normFile = filePath.replace(/\\/g, '/');
146
+ dependencyGraph.set(normFile, []);
147
+
148
+ let content = '';
149
+ try {
150
+ content = fs.readFileSync(filePath, 'utf8');
151
+ } catch (_) {
152
+ continue;
153
+ }
154
+
155
+ const imports = extractImportsFromContent(content);
156
+ for (const imp of imports) {
157
+ if (imp.startsWith('.')) {
158
+ // Local relative import
159
+ const resolvedPath = path.resolve(path.dirname(filePath), imp);
160
+ const candidates = [
161
+ resolvedPath,
162
+ resolvedPath + '.js',
163
+ resolvedPath + '.mjs',
164
+ path.join(resolvedPath, 'index.js')
165
+ ];
166
+ const match = candidates.find(c => fs.existsSync(c) && fs.statSync(c).isFile());
167
+ if (match) {
168
+ const normNeighbor = match.replace(/\\/g, '/');
169
+ dependencyGraph.get(normFile).push(normNeighbor);
170
+ }
171
+ } else if (!isNodeBuiltin(imp)) {
172
+ // External package
173
+ const pkgName = getPackageName(imp);
174
+ usedPackages.add(pkgName);
175
+ }
176
+ }
177
+ }
178
+
179
+ // 1. Detect orphans (in dependencies but not used in src)
180
+ const orphans = Object.keys(dependencies).filter(dep => !usedPackages.has(dep));
181
+
182
+ // 2. Detect undeclared (used in src but missing from dependencies & devDependencies)
183
+ const allDeclared = new Set([...Object.keys(dependencies), ...Object.keys(devDependencies)]);
184
+ const undeclared = Array.from(usedPackages).filter(dep => !allDeclared.has(dep));
185
+
186
+ // 3. Detect circular cycles
187
+ const circularCycles = findCircularCycles(dependencyGraph);
188
+
189
+ return {
190
+ orphans,
191
+ undeclared,
192
+ circularCycles,
193
+ is_clean: orphans.length === 0 && undeclared.length === 0 && circularCycles.length === 0
194
+ };
195
+ }
196
+
197
+ module.exports = {
198
+ isNodeBuiltin,
199
+ extractImportsFromContent,
200
+ auditDependencies,
201
+ findCircularCycles
202
+ };
@@ -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
+ };