gemstack-ai 1.1.2 → 1.3.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/skills/gemstack-plan/SKILL.md +2 -1
- package/.agents/skills/gemstack-qa/SKILL.md +3 -0
- package/.agents/skills/gemstack-ship/SKILL.md +5 -1
- package/.agents/skills/gemstack-spec/SKILL.md +3 -2
- package/.agents/skills/gemstack-tasks/SKILL.md +4 -3
- package/.gemstack/state.json +7 -8
- package/CHANGELOG.md +75 -0
- package/README.md +36 -0
- package/RELEASE_NOTES.md +61 -0
- package/docs/architecture-consistency.md +14 -2
- package/docs/spec-driven-development.md +26 -0
- package/{gemstack-ai-1.1.2.tgz → gemstack-ai-1.3.0.tgz} +0 -0
- package/handoff.md +30 -15
- package/package.json +2 -2
- package/specs/007-mechanical-test-matrix-closure-evidence/.gemstack.json +5 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/closure.json +59 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/plan.md +484 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/spec.md +597 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/tasks.md +536 -0
- package/specs/008-cost-provider-safety-gates/.gemstack.json +5 -0
- package/specs/008-cost-provider-safety-gates/closure.json +59 -0
- package/specs/008-cost-provider-safety-gates/plan.md +456 -0
- package/specs/008-cost-provider-safety-gates/spec.md +633 -0
- package/specs/008-cost-provider-safety-gates/tasks.md +635 -0
- package/specs/009-context-capsule/closure.json +59 -0
- package/specs/009-context-capsule/context-capsule.json +428 -0
- package/specs/009-context-capsule/plan.md +663 -0
- package/specs/009-context-capsule/spec.md +913 -0
- package/specs/009-context-capsule/tasks.md +720 -0
- package/specs/templates/plan.md +30 -0
- package/specs/templates/spec.md +18 -0
- package/specs/templates/tasks.md +9 -0
- package/src/cli.js +8 -0
- package/src/commands/collect.js +340 -0
- package/src/commands/context.js +95 -0
- package/src/commands/ship.js +79 -0
- package/src/commands/verify.js +182 -6
- package/src/lib/closure-context.js +453 -0
- package/src/lib/context-capsule.js +594 -0
- package/src/lib/cost-ledger.js +355 -0
- package/src/lib/provider-boundary.js +186 -0
- package/src/lib/provider-registry.js +265 -0
- package/src/lib/runner-adapters.js +347 -0
- package/src/lib/safety-gates.js +277 -0
- package/src/lib/test-matrix.js +187 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const { hashFile, normalizePath } = require('./hasher');
|
|
5
|
+
const { extractContractsBlock, validateContractSchemas } = require('./contracts');
|
|
6
|
+
const { extractTestMatrixBlock, validateTestMatrix, computeAcceptanceSignature } = require('./test-matrix');
|
|
7
|
+
const { parseTaskMetadata } = require('./closure-context');
|
|
8
|
+
|
|
9
|
+
const SCHEMA_VERSION = 1;
|
|
10
|
+
const TARGET_SIZE_BUDGET_BYTES = 32768; // 32 KB
|
|
11
|
+
const HARD_SIZE_LIMIT_BYTES = 65536; // 64 KB
|
|
12
|
+
|
|
13
|
+
const FORBIDDEN_PROPERTY_KEYS = new Set([
|
|
14
|
+
'apiKey',
|
|
15
|
+
'api_key',
|
|
16
|
+
'token',
|
|
17
|
+
'accessToken',
|
|
18
|
+
'access_token',
|
|
19
|
+
'secret',
|
|
20
|
+
'clientSecret',
|
|
21
|
+
'password',
|
|
22
|
+
'credentials'
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const SECRET_VALUE_PATTERNS = [
|
|
26
|
+
/AKIA[0-9A-Z]{16}/,
|
|
27
|
+
/gh[pousr]_[A-Za-z0-9_]{36,}/,
|
|
28
|
+
/sk-[A-Za-z0-9_-]{20,}/,
|
|
29
|
+
/AIza[0-9A-Za-z-_]{30,}/,
|
|
30
|
+
/Bearer\s+[A-Za-z0-9\-._~+/]+=*/i,
|
|
31
|
+
/-----BEGIN[ A-Z0-9_-]*PRIVATE KEY-----/
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Sorts array elements deterministically by identifier if available.
|
|
36
|
+
*/
|
|
37
|
+
function sortArrayDeterministically(arr) {
|
|
38
|
+
if (!Array.isArray(arr) || arr.length === 0) return arr;
|
|
39
|
+
const copy = [...arr];
|
|
40
|
+
|
|
41
|
+
if (typeof copy[0] === 'string') {
|
|
42
|
+
return copy.sort((a, b) => (a < b ? -1 : (a > b ? 1 : 0)));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (typeof copy[0] === 'object' && copy[0] !== null) {
|
|
46
|
+
if ('path' in copy[0]) {
|
|
47
|
+
return copy.sort((a, b) => (a.path < b.path ? -1 : (a.path > b.path ? 1 : 0)));
|
|
48
|
+
}
|
|
49
|
+
if ('id' in copy[0]) {
|
|
50
|
+
return copy.sort((a, b) => (a.id < b.id ? -1 : (a.id > b.id ? 1 : 0)));
|
|
51
|
+
}
|
|
52
|
+
if ('feature' in copy[0]) {
|
|
53
|
+
return copy.sort((a, b) => (a.feature < b.feature ? -1 : (a.feature > b.feature ? 1 : 0)));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return copy;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Recursively orders all object keys by UTF-16 code units.
|
|
62
|
+
*/
|
|
63
|
+
function canonicalizeObject(value) {
|
|
64
|
+
if (value === null || typeof value !== 'object') {
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
const sorted = sortArrayDeterministically(value);
|
|
70
|
+
return sorted.map(canonicalizeObject);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const sortedKeys = Object.keys(value).sort((a, b) => (a < b ? -1 : (a > b ? 1 : 0)));
|
|
74
|
+
const result = {};
|
|
75
|
+
for (const k of sortedKeys) {
|
|
76
|
+
result[k] = canonicalizeObject(value[k]);
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Serializes object to canonical UTF-8 JSON with sorted keys, 2-space indentation, and LF newline.
|
|
83
|
+
*/
|
|
84
|
+
function serializeCanonicalJson(data) {
|
|
85
|
+
const canonical = canonicalizeObject(data);
|
|
86
|
+
return JSON.stringify(canonical, null, 2) + '\n';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Computes semantic digest of capsule, excluding volatile timestamps.
|
|
91
|
+
*/
|
|
92
|
+
function computeCapsuleSemanticHash(capsuleObj) {
|
|
93
|
+
const copy = JSON.parse(JSON.stringify(capsuleObj));
|
|
94
|
+
delete copy.generated_at;
|
|
95
|
+
const canonical = canonicalizeObject(copy);
|
|
96
|
+
return crypto.createHash('sha256').update(JSON.stringify(canonical), 'utf8').digest('hex');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Recursively scans object for forbidden credential properties or token patterns.
|
|
101
|
+
* Throws fail-closed error if detected.
|
|
102
|
+
*/
|
|
103
|
+
function assertSecretsForbidden(obj, pathPrefix = '') {
|
|
104
|
+
if (obj === null || typeof obj !== 'object') {
|
|
105
|
+
if (typeof obj === 'string') {
|
|
106
|
+
for (const pattern of SECRET_VALUE_PATTERNS) {
|
|
107
|
+
if (pattern.test(obj)) {
|
|
108
|
+
const err = new Error('CONTEXT_CAPSULE_SECRET_DETECTED: Forbidden credential token pattern detected.');
|
|
109
|
+
err.code = 'CONTEXT_CAPSULE_SECRET_DETECTED';
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (Array.isArray(obj)) {
|
|
118
|
+
for (let i = 0; i < obj.length; i++) {
|
|
119
|
+
assertSecretsForbidden(obj[i], `${pathPrefix}[${i}]`);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
125
|
+
if (FORBIDDEN_PROPERTY_KEYS.has(k)) {
|
|
126
|
+
const err = new Error(`CONTEXT_CAPSULE_SECRET_DETECTED: Forbidden credential property "${k}" detected.`);
|
|
127
|
+
err.code = 'CONTEXT_CAPSULE_SECRET_DETECTED';
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
assertSecretsForbidden(v, pathPrefix ? `${pathPrefix}.${k}` : k);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Enforces size budgets (32 KB target, 64 KB hard limit fail-closed).
|
|
136
|
+
*/
|
|
137
|
+
function enforceSizeBudget(capsuleObj) {
|
|
138
|
+
let serialized = serializeCanonicalJson(capsuleObj);
|
|
139
|
+
let byteLength = Buffer.byteLength(serialized, 'utf8');
|
|
140
|
+
|
|
141
|
+
if (byteLength > TARGET_SIZE_BUDGET_BYTES) {
|
|
142
|
+
// Priority 3 Condensation: Condense historical context
|
|
143
|
+
if (Array.isArray(capsuleObj.historical_context)) {
|
|
144
|
+
capsuleObj.historical_context = capsuleObj.historical_context.map(h => ({
|
|
145
|
+
feature: h.feature,
|
|
146
|
+
status: h.status,
|
|
147
|
+
key_guarantees: (h.key_guarantees || []).slice(0, 2)
|
|
148
|
+
}));
|
|
149
|
+
serialized = serializeCanonicalJson(capsuleObj);
|
|
150
|
+
byteLength = Buffer.byteLength(serialized, 'utf8');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Priority 2 Condensation if still > 32 KB
|
|
154
|
+
if (byteLength > TARGET_SIZE_BUDGET_BYTES) {
|
|
155
|
+
if (capsuleObj.acceptance_matrix && Array.isArray(capsuleObj.acceptance_matrix.canonical_ids)) {
|
|
156
|
+
// Retain IDs only (already compact)
|
|
157
|
+
}
|
|
158
|
+
serialized = serializeCanonicalJson(capsuleObj);
|
|
159
|
+
byteLength = Buffer.byteLength(serialized, 'utf8');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (byteLength > HARD_SIZE_LIMIT_BYTES) {
|
|
164
|
+
const err = new Error(`CONTEXT_CAPSULE_TOO_LARGE: Critical invariants exceed capsule size budget (${byteLength} > ${HARD_SIZE_LIMIT_BYTES} bytes).`);
|
|
165
|
+
err.code = 'CONTEXT_CAPSULE_TOO_LARGE';
|
|
166
|
+
err.byteLength = byteLength;
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { capsuleObj, byteLength };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resolveFeatureRel(featureDir, rootPath) {
|
|
174
|
+
if (path.isAbsolute(featureDir)) {
|
|
175
|
+
return normalizePath(featureDir, rootPath);
|
|
176
|
+
}
|
|
177
|
+
return normalizePath(featureDir);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolves authoritative input sources for an active feature and calculates source_set_hash.
|
|
182
|
+
*/
|
|
183
|
+
function resolveAuthoritativeSources(rootPath, featureDir, currentPhase = 'specification') {
|
|
184
|
+
const normFeature = resolveFeatureRel(featureDir, rootPath);
|
|
185
|
+
const sources = [];
|
|
186
|
+
|
|
187
|
+
const specRel = `${normFeature}/spec.md`.replace(/^\.\//, '');
|
|
188
|
+
const planRel = `${normFeature}/plan.md`.replace(/^\.\//, '');
|
|
189
|
+
const tasksRel = `${normFeature}/tasks.md`.replace(/^\.\//, '');
|
|
190
|
+
const stateRel = '.gemstack/state.json';
|
|
191
|
+
const closureRel = `${normFeature}/closure.json`.replace(/^\.\//, '');
|
|
192
|
+
const ledgerRel = 'cost-ledger.json';
|
|
193
|
+
const featureLedgerRel = `${normFeature}/cost-ledger.json`.replace(/^\.\//, '');
|
|
194
|
+
|
|
195
|
+
const checkAndAdd = (relPath, role, mandatory = false) => {
|
|
196
|
+
const absPath = path.join(rootPath, relPath);
|
|
197
|
+
if (fs.existsSync(absPath)) {
|
|
198
|
+
const hash = hashFile(absPath);
|
|
199
|
+
sources.push({
|
|
200
|
+
path: relPath.replace(/\\/g, '/'),
|
|
201
|
+
hash,
|
|
202
|
+
role
|
|
203
|
+
});
|
|
204
|
+
return true;
|
|
205
|
+
} else if (mandatory) {
|
|
206
|
+
const err = new Error(`Missing mandatory authoritative source: ${relPath}`);
|
|
207
|
+
err.code = 'CONTEXT_CAPSULE_SOURCE_MISMATCH';
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
checkAndAdd(specRel, 'SPECIFICATION', true);
|
|
214
|
+
checkAndAdd(planRel, 'PLAN', false);
|
|
215
|
+
checkAndAdd(tasksRel, 'TASKS', false);
|
|
216
|
+
|
|
217
|
+
checkAndAdd(stateRel, 'LIFECYCLE_STATE', false);
|
|
218
|
+
checkAndAdd(closureRel, 'CLOSURE_EVIDENCE', false);
|
|
219
|
+
checkAndAdd(ledgerRel, 'COST_LEDGER', false);
|
|
220
|
+
checkAndAdd(featureLedgerRel, 'FEATURE_COST_LEDGER', false);
|
|
221
|
+
|
|
222
|
+
sources.sort((a, b) => (a.path < b.path ? -1 : (a.path > b.path ? 1 : 0)));
|
|
223
|
+
|
|
224
|
+
const sourceSetEntries = sources.map(s => ({ path: s.path, hash: s.hash }));
|
|
225
|
+
const sourceSetHash = crypto.createHash('sha256')
|
|
226
|
+
.update(JSON.stringify(sourceSetEntries), 'utf8')
|
|
227
|
+
.digest('hex');
|
|
228
|
+
|
|
229
|
+
return { sources, source_set_hash: sourceSetHash };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Extracts normative MUST / MUST NOT rules from spec.md content.
|
|
234
|
+
*/
|
|
235
|
+
function extractNormativeConstraints(specContent) {
|
|
236
|
+
const lines = specContent.split(/\r?\n/);
|
|
237
|
+
const invariants = [];
|
|
238
|
+
let counter = 1;
|
|
239
|
+
let currentSection = 'spec.md';
|
|
240
|
+
|
|
241
|
+
const sectionRegex = /^(?:#{1,4})\s+(?:(\d+(?:\.\d+)?)\.?\s*)?(.*?)$/;
|
|
242
|
+
|
|
243
|
+
for (const line of lines) {
|
|
244
|
+
const secMatch = line.match(sectionRegex);
|
|
245
|
+
if (secMatch) {
|
|
246
|
+
const num = secMatch[1] || '';
|
|
247
|
+
currentSection = num ? `spec.md#${num}` : `spec.md#${secMatch[2].trim().toLowerCase().replace(/\s+/g, '-')}`;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const trimmed = line.trim();
|
|
252
|
+
if (!trimmed || trimmed.startsWith('```')) continue;
|
|
253
|
+
|
|
254
|
+
const hasMustNot = /\b(?:MUST NOT|SHALL NOT|FORBIDDEN|NEVER)\b/.test(trimmed);
|
|
255
|
+
const hasMust = !hasMustNot && /\b(?:MUST|SHALL|REQUIRED)\b/.test(trimmed);
|
|
256
|
+
|
|
257
|
+
if (hasMust || hasMustNot) {
|
|
258
|
+
// Clean leading bullet or list markers
|
|
259
|
+
const cleanRule = trimmed.replace(/^[-*+]\s+/, '').replace(/^\d+\.\s+/, '').trim();
|
|
260
|
+
invariants.push({
|
|
261
|
+
id: `INV-${String(counter++).padStart(3, '0')}`,
|
|
262
|
+
rule: cleanRule,
|
|
263
|
+
normative: hasMustNot ? 'MUST_NOT' : 'MUST',
|
|
264
|
+
source_ref: currentSection
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return invariants;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Generates canonical context-capsule.json atomically.
|
|
274
|
+
*/
|
|
275
|
+
function generateContextCapsule(rootPath, featureDir, options = {}) {
|
|
276
|
+
const normFeature = resolveFeatureRel(featureDir, rootPath);
|
|
277
|
+
const featureAbs = path.join(rootPath, normFeature);
|
|
278
|
+
|
|
279
|
+
let state = { current_phase: 'specification', status: 'SPEC_COMPLETE', active_spec: normFeature };
|
|
280
|
+
const statePath = path.join(rootPath, '.gemstack/state.json');
|
|
281
|
+
if (fs.existsSync(statePath)) {
|
|
282
|
+
try {
|
|
283
|
+
state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
284
|
+
} catch (_) {}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const specPath = path.join(featureAbs, 'spec.md');
|
|
288
|
+
if (!fs.existsSync(specPath)) {
|
|
289
|
+
const err = new Error(`Cannot generate context capsule: spec.md not found in ${normFeature}`);
|
|
290
|
+
err.code = 'CONTEXT_CAPSULE_MISSING';
|
|
291
|
+
throw err;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const specContent = fs.readFileSync(specPath, 'utf8');
|
|
295
|
+
const { sources, source_set_hash } = resolveAuthoritativeSources(rootPath, featureDir, state.current_phase || 'specification');
|
|
296
|
+
|
|
297
|
+
// Parse contracts
|
|
298
|
+
const { contracts } = extractContractsBlock(specContent);
|
|
299
|
+
const validatedContracts = validateContractSchemas(contracts);
|
|
300
|
+
const frozenContracts = validatedContracts.map(c => ({
|
|
301
|
+
id: c.id,
|
|
302
|
+
type: c.type,
|
|
303
|
+
value: c.value !== undefined ? c.value : true
|
|
304
|
+
}));
|
|
305
|
+
|
|
306
|
+
// Parse acceptance matrix
|
|
307
|
+
let acceptanceMatrix = { total_required: 0, signature: null, canonical_ids: [] };
|
|
308
|
+
const { matrix } = extractTestMatrixBlock(specContent);
|
|
309
|
+
if (matrix) {
|
|
310
|
+
const canonicalMatrix = validateTestMatrix(matrix);
|
|
311
|
+
acceptanceMatrix = {
|
|
312
|
+
total_required: canonicalMatrix.length,
|
|
313
|
+
signature: computeAcceptanceSignature(canonicalMatrix),
|
|
314
|
+
canonical_ids: canonicalMatrix.map(m => m.id)
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Parse tasks if present
|
|
319
|
+
let tasksState = { total: 0, completed: 0, in_progress: null, active_task_ids: [] };
|
|
320
|
+
const tasksPath = path.join(featureAbs, 'tasks.md');
|
|
321
|
+
if (fs.existsSync(tasksPath)) {
|
|
322
|
+
try {
|
|
323
|
+
const taskList = parseTaskMetadata(fs.readFileSync(tasksPath, 'utf8'));
|
|
324
|
+
const activeIds = taskList.map(t => t.id);
|
|
325
|
+
tasksState = {
|
|
326
|
+
total: taskList.length,
|
|
327
|
+
completed: 0,
|
|
328
|
+
in_progress: activeIds.length > 0 ? activeIds[0] : null,
|
|
329
|
+
active_task_ids: activeIds
|
|
330
|
+
};
|
|
331
|
+
} catch (_) {}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Extract invariants
|
|
335
|
+
const canonicalInvariants = extractNormativeConstraints(specContent);
|
|
336
|
+
|
|
337
|
+
// Historical upgrades
|
|
338
|
+
const historicalContext = [
|
|
339
|
+
{
|
|
340
|
+
feature: 'specs/006-architecture-consistency-engine',
|
|
341
|
+
status: 'CLOSED',
|
|
342
|
+
key_guarantees: ['Frozen contracts', 'Deterministic hashing', 'Anti-loop findings']
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
feature: 'specs/007-mechanical-test-matrix-closure-evidence',
|
|
346
|
+
status: 'CLOSED',
|
|
347
|
+
key_guarantees: ['Mechanical test matrix', 'VERIFY = VALIDATE', 'closure.json evidence']
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
feature: 'specs/008-cost-provider-safety-gates',
|
|
351
|
+
status: 'CLOSED',
|
|
352
|
+
key_guarantees: ['NO PROOF = NO EXECUTION', 'Fail-closed gates', 'Zero network verify']
|
|
353
|
+
}
|
|
354
|
+
];
|
|
355
|
+
|
|
356
|
+
const payload = {
|
|
357
|
+
$schema: 'https://gemstack.dev/schemas/context-capsule-v1.json',
|
|
358
|
+
schema_version: SCHEMA_VERSION,
|
|
359
|
+
generated_at: new Date().toISOString(),
|
|
360
|
+
generator: {
|
|
361
|
+
name: 'gemstack',
|
|
362
|
+
version: '1.2.0'
|
|
363
|
+
},
|
|
364
|
+
provenance: {
|
|
365
|
+
source_set_hash,
|
|
366
|
+
sources
|
|
367
|
+
},
|
|
368
|
+
project: {
|
|
369
|
+
name: 'gemstack-ai',
|
|
370
|
+
active_feature: normFeature,
|
|
371
|
+
current_phase: state.current_phase || 'specification',
|
|
372
|
+
lifecycle_status: state.status || 'SPEC_COMPLETE',
|
|
373
|
+
next_permitted_phase: 'PLAN'
|
|
374
|
+
},
|
|
375
|
+
historical_context: historicalContext,
|
|
376
|
+
architecture_summary: {
|
|
377
|
+
core_purpose: 'Deterministic context compression and safe continuation for AI-assisted engineering.',
|
|
378
|
+
critical_boundaries: [
|
|
379
|
+
'Capsule is strictly derived, never authoritative',
|
|
380
|
+
'Semantic constraint losslessness: MUST/MUST NOT survive compression',
|
|
381
|
+
'Verification is read-only and never regenerates capsules'
|
|
382
|
+
]
|
|
383
|
+
},
|
|
384
|
+
canonical_invariants: canonicalInvariants,
|
|
385
|
+
frozen_contracts: frozenContracts,
|
|
386
|
+
acceptance_matrix: acceptanceMatrix,
|
|
387
|
+
tasks_state: tasksState,
|
|
388
|
+
relevant_files: [
|
|
389
|
+
'src/lib/context-capsule.js',
|
|
390
|
+
'src/commands/context.js',
|
|
391
|
+
'tests/context-determinism.test.js'
|
|
392
|
+
],
|
|
393
|
+
deferred_items: [
|
|
394
|
+
'Autonomous cross-repo capsule federations (out of scope)',
|
|
395
|
+
'LLM narrative fine-tuning (non-authoritative)'
|
|
396
|
+
],
|
|
397
|
+
unresolved_blockers: []
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
assertSecretsForbidden(payload);
|
|
401
|
+
const { capsuleObj } = enforceSizeBudget(payload);
|
|
402
|
+
const serialized = serializeCanonicalJson(capsuleObj);
|
|
403
|
+
|
|
404
|
+
const targetFile = path.join(featureAbs, 'context-capsule.json');
|
|
405
|
+
const tmpFile = `${targetFile}.tmp.${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
406
|
+
|
|
407
|
+
fs.writeFileSync(tmpFile, serialized, 'utf8');
|
|
408
|
+
|
|
409
|
+
// Atomic replace with retry for Windows file locks
|
|
410
|
+
let attempts = 0;
|
|
411
|
+
const maxAttempts = 5;
|
|
412
|
+
while (attempts < maxAttempts) {
|
|
413
|
+
try {
|
|
414
|
+
fs.renameSync(tmpFile, targetFile);
|
|
415
|
+
break;
|
|
416
|
+
} catch (err) {
|
|
417
|
+
attempts++;
|
|
418
|
+
if (attempts >= maxAttempts) {
|
|
419
|
+
try { fs.unlinkSync(tmpFile); } catch (_) {}
|
|
420
|
+
throw err;
|
|
421
|
+
}
|
|
422
|
+
const start = Date.now();
|
|
423
|
+
while (Date.now() - start < 20) {}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
path: targetFile,
|
|
429
|
+
source_set_hash,
|
|
430
|
+
byteLength: Buffer.byteLength(serialized, 'utf8'),
|
|
431
|
+
invariantsCount: canonicalInvariants.length
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Validates an existing context-capsule.json in strictly read-only mode.
|
|
437
|
+
*/
|
|
438
|
+
function validateContextCapsule(rootPath, featureDir) {
|
|
439
|
+
const normFeature = resolveFeatureRel(featureDir, rootPath);
|
|
440
|
+
const capsulePath = path.join(rootPath, normFeature, 'context-capsule.json');
|
|
441
|
+
|
|
442
|
+
if (!fs.existsSync(capsulePath)) {
|
|
443
|
+
return {
|
|
444
|
+
valid: false,
|
|
445
|
+
state: 'MISSING',
|
|
446
|
+
findings: [{
|
|
447
|
+
code: 'CONTEXT_CAPSULE_MISSING',
|
|
448
|
+
message: `Context capsule not found at ${normFeature}/context-capsule.json`
|
|
449
|
+
}]
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
let capsule;
|
|
454
|
+
try {
|
|
455
|
+
const raw = fs.readFileSync(capsulePath, 'utf8');
|
|
456
|
+
capsule = JSON.parse(raw);
|
|
457
|
+
} catch (err) {
|
|
458
|
+
return {
|
|
459
|
+
valid: false,
|
|
460
|
+
state: 'INVALID',
|
|
461
|
+
findings: [{
|
|
462
|
+
code: 'CONTEXT_CAPSULE_INVALID',
|
|
463
|
+
message: `Context capsule JSON parse error: ${err.message}`
|
|
464
|
+
}]
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (capsule.schema_version !== SCHEMA_VERSION) {
|
|
469
|
+
return {
|
|
470
|
+
valid: false,
|
|
471
|
+
state: 'INVALID',
|
|
472
|
+
findings: [{
|
|
473
|
+
code: 'CONTEXT_CAPSULE_INVALID',
|
|
474
|
+
message: `Unsupported schema version: ${capsule.schema_version}`
|
|
475
|
+
}]
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Size check
|
|
480
|
+
const rawSize = Buffer.byteLength(fs.readFileSync(capsulePath, 'utf8'), 'utf8');
|
|
481
|
+
if (rawSize > HARD_SIZE_LIMIT_BYTES) {
|
|
482
|
+
return {
|
|
483
|
+
valid: false,
|
|
484
|
+
state: 'INVALID',
|
|
485
|
+
findings: [{
|
|
486
|
+
code: 'CONTEXT_CAPSULE_TOO_LARGE',
|
|
487
|
+
message: `Capsule exceeds hard size limit (${rawSize} > ${HARD_SIZE_LIMIT_BYTES} bytes).`
|
|
488
|
+
}]
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Secrets check
|
|
493
|
+
try {
|
|
494
|
+
assertSecretsForbidden(capsule);
|
|
495
|
+
} catch (secErr) {
|
|
496
|
+
return {
|
|
497
|
+
valid: false,
|
|
498
|
+
state: 'INVALID',
|
|
499
|
+
findings: [{
|
|
500
|
+
code: 'CONTEXT_CAPSULE_SECRET_DETECTED',
|
|
501
|
+
message: secErr.message
|
|
502
|
+
}]
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Provenance & Freshness check
|
|
507
|
+
const recordedSources = (capsule.provenance && capsule.provenance.sources) || [];
|
|
508
|
+
for (const src of recordedSources) {
|
|
509
|
+
const diskPath = path.join(rootPath, src.path);
|
|
510
|
+
if (!fs.existsSync(diskPath)) {
|
|
511
|
+
return {
|
|
512
|
+
valid: false,
|
|
513
|
+
state: 'STALE',
|
|
514
|
+
findings: [{
|
|
515
|
+
code: 'CONTEXT_CAPSULE_SOURCE_MISMATCH',
|
|
516
|
+
message: `Recorded source file missing on disk: ${src.path}`
|
|
517
|
+
}]
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
const currentHash = hashFile(diskPath);
|
|
521
|
+
if (currentHash !== src.hash) {
|
|
522
|
+
return {
|
|
523
|
+
valid: false,
|
|
524
|
+
state: 'STALE',
|
|
525
|
+
findings: [{
|
|
526
|
+
code: 'CONTEXT_CAPSULE_STALE',
|
|
527
|
+
message: `Source file ${src.path} was modified since capsule generation.`
|
|
528
|
+
}]
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Check source-set hash integrity
|
|
534
|
+
const sourceSetEntries = recordedSources.map(s => ({ path: s.path, hash: s.hash }));
|
|
535
|
+
const expectedSourceSetHash = crypto.createHash('sha256')
|
|
536
|
+
.update(JSON.stringify(sourceSetEntries), 'utf8')
|
|
537
|
+
.digest('hex');
|
|
538
|
+
|
|
539
|
+
if (capsule.provenance && capsule.provenance.source_set_hash !== expectedSourceSetHash) {
|
|
540
|
+
return {
|
|
541
|
+
valid: false,
|
|
542
|
+
state: 'STALE',
|
|
543
|
+
findings: [{
|
|
544
|
+
code: 'CONTEXT_CAPSULE_STALE',
|
|
545
|
+
message: 'Capsule source_set_hash does not reconcile with recorded source files.'
|
|
546
|
+
}]
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Check semantic constraint coverage
|
|
551
|
+
const specSrc = recordedSources.find(s => s.role === 'SPECIFICATION');
|
|
552
|
+
if (specSrc) {
|
|
553
|
+
const specDiskPath = path.join(rootPath, specSrc.path);
|
|
554
|
+
if (fs.existsSync(specDiskPath)) {
|
|
555
|
+
const specContent = fs.readFileSync(specDiskPath, 'utf8');
|
|
556
|
+
const liveInvariants = extractNormativeConstraints(specContent);
|
|
557
|
+
const capsuleInvariants = new Set((capsule.canonical_invariants || []).map(i => i.rule));
|
|
558
|
+
|
|
559
|
+
for (const live of liveInvariants) {
|
|
560
|
+
if (!capsuleInvariants.has(live.rule)) {
|
|
561
|
+
return {
|
|
562
|
+
valid: false,
|
|
563
|
+
state: 'INVALID',
|
|
564
|
+
findings: [{
|
|
565
|
+
code: 'CONTEXT_CAPSULE_INVARIANT_DROPPED',
|
|
566
|
+
message: `Normative constraint "${live.rule}" is missing from capsule.`
|
|
567
|
+
}]
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return {
|
|
575
|
+
valid: true,
|
|
576
|
+
state: 'VALID',
|
|
577
|
+
findings: []
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
module.exports = {
|
|
582
|
+
SCHEMA_VERSION,
|
|
583
|
+
TARGET_SIZE_BUDGET_BYTES,
|
|
584
|
+
HARD_SIZE_LIMIT_BYTES,
|
|
585
|
+
serializeCanonicalJson,
|
|
586
|
+
canonicalizeObject,
|
|
587
|
+
computeCapsuleSemanticHash,
|
|
588
|
+
assertSecretsForbidden,
|
|
589
|
+
enforceSizeBudget,
|
|
590
|
+
resolveAuthoritativeSources,
|
|
591
|
+
extractNormativeConstraints,
|
|
592
|
+
generateContextCapsule,
|
|
593
|
+
validateContextCapsule
|
|
594
|
+
};
|