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.
Files changed (45) hide show
  1. package/.agents/skills/gemstack-plan/SKILL.md +2 -1
  2. package/.agents/skills/gemstack-qa/SKILL.md +3 -0
  3. package/.agents/skills/gemstack-ship/SKILL.md +5 -1
  4. package/.agents/skills/gemstack-spec/SKILL.md +3 -2
  5. package/.agents/skills/gemstack-tasks/SKILL.md +4 -3
  6. package/.gemstack/state.json +7 -8
  7. package/CHANGELOG.md +75 -0
  8. package/README.md +36 -0
  9. package/RELEASE_NOTES.md +61 -0
  10. package/docs/architecture-consistency.md +14 -2
  11. package/docs/spec-driven-development.md +26 -0
  12. package/{gemstack-ai-1.1.2.tgz → gemstack-ai-1.3.0.tgz} +0 -0
  13. package/handoff.md +30 -15
  14. package/package.json +2 -2
  15. package/specs/007-mechanical-test-matrix-closure-evidence/.gemstack.json +5 -0
  16. package/specs/007-mechanical-test-matrix-closure-evidence/closure.json +59 -0
  17. package/specs/007-mechanical-test-matrix-closure-evidence/plan.md +484 -0
  18. package/specs/007-mechanical-test-matrix-closure-evidence/spec.md +597 -0
  19. package/specs/007-mechanical-test-matrix-closure-evidence/tasks.md +536 -0
  20. package/specs/008-cost-provider-safety-gates/.gemstack.json +5 -0
  21. package/specs/008-cost-provider-safety-gates/closure.json +59 -0
  22. package/specs/008-cost-provider-safety-gates/plan.md +456 -0
  23. package/specs/008-cost-provider-safety-gates/spec.md +633 -0
  24. package/specs/008-cost-provider-safety-gates/tasks.md +635 -0
  25. package/specs/009-context-capsule/closure.json +59 -0
  26. package/specs/009-context-capsule/context-capsule.json +428 -0
  27. package/specs/009-context-capsule/plan.md +663 -0
  28. package/specs/009-context-capsule/spec.md +913 -0
  29. package/specs/009-context-capsule/tasks.md +720 -0
  30. package/specs/templates/plan.md +30 -0
  31. package/specs/templates/spec.md +18 -0
  32. package/specs/templates/tasks.md +9 -0
  33. package/src/cli.js +8 -0
  34. package/src/commands/collect.js +340 -0
  35. package/src/commands/context.js +95 -0
  36. package/src/commands/ship.js +79 -0
  37. package/src/commands/verify.js +182 -6
  38. package/src/lib/closure-context.js +453 -0
  39. package/src/lib/context-capsule.js +594 -0
  40. package/src/lib/cost-ledger.js +355 -0
  41. package/src/lib/provider-boundary.js +186 -0
  42. package/src/lib/provider-registry.js +265 -0
  43. package/src/lib/runner-adapters.js +347 -0
  44. package/src/lib/safety-gates.js +277 -0
  45. package/src/lib/test-matrix.js +187 -0
@@ -13,7 +13,7 @@ module.exports = async (flags) => {
13
13
  let totalWarnings = 0;
14
14
 
15
15
  // 1. Verificación Estructural y Manifest
16
- logger.info('--- 1/5 Verificación Estructural (Archivos Base) ---');
16
+ logger.info('--- 1/6 Verificación Estructural (Archivos Base) ---');
17
17
  const manifestPath = fssafe.resolveSafe(targetDir, '.gemstack/manifest.json');
18
18
  if (!fs.existsSync(manifestPath)) {
19
19
  logger.warn('Manifest no encontrado (.gemstack/manifest.json). Es posible que Gemstack no esté inicializado en este directorio.');
@@ -58,7 +58,7 @@ module.exports = async (flags) => {
58
58
  }
59
59
 
60
60
  // 2. Verificación de Memoria (handoff.md)
61
- logger.info('--- 2/5 Verificación de Memoria e Integridad de Handoff ---');
61
+ logger.info('--- 2/6 Verificación de Memoria e Integridad de Handoff ---');
62
62
  const handoffPath = fssafe.resolveSafe(targetDir, 'handoff.md');
63
63
  if (!fs.existsSync(handoffPath)) {
64
64
  logger.error('handoff.md no existe en la raíz. La memoria de sesión es obligatoria.');
@@ -90,7 +90,7 @@ module.exports = async (flags) => {
90
90
  }
91
91
 
92
92
  // 3. Consistencia de Estado Local (.gemstack/state.json)
93
- logger.info('--- 3/5 Verificación de Estado Local (.gemstack/state.json) ---');
93
+ logger.info('--- 3/6 Verificación de Estado Local (.gemstack/state.json) ---');
94
94
  const statePath = fssafe.resolveSafe(targetDir, '.gemstack/state.json');
95
95
  let loadedState = null;
96
96
  if (!fs.existsSync(statePath)) {
@@ -119,7 +119,7 @@ module.exports = async (flags) => {
119
119
  }
120
120
 
121
121
  // 4. Consistencia de Arquitectura y Hashes de Fase (Upgrade A)
122
- logger.info('--- 4/5 Verificación de Consistencia de Arquitectura y Hashes de Fase ---');
122
+ logger.info('--- 4/6 Verificación de Consistencia de Arquitectura y Hashes de Fase ---');
123
123
  if (loadedState && loadedState.active_spec) {
124
124
  try {
125
125
  const { hashFile } = require('../lib/hasher');
@@ -254,8 +254,184 @@ module.exports = async (flags) => {
254
254
  logger.ok('Sin spec activa configurada para verificación de contratos.');
255
255
  }
256
256
 
257
- // 5. Seguridad Local y Anti-Silent Failures en Tests
258
- logger.info('--- 5/5 Verificación de Seguridad y Test Runners ---');
257
+ // 5. Verificación de Evidencia de Cierre Mecánico (Upgrade B - Read-Only)
258
+ logger.info('--- 5/6 Verificación de Evidencia de Cierre Mecánico (Read-Only) ---');
259
+ if (loadedState && loadedState.active_spec) {
260
+ try {
261
+ const { extractTestMatrixBlock } = require('../lib/test-matrix');
262
+ const specFile = fssafe.resolveSafe(targetDir, path.join(loadedState.active_spec, 'spec.md'));
263
+
264
+ if (!fs.existsSync(specFile)) {
265
+ logger.ok(`Modo legacy: no existe spec.md en ${loadedState.active_spec}`);
266
+ } else {
267
+ const specContent = fs.readFileSync(specFile, 'utf8');
268
+ const { isLegacy } = extractTestMatrixBlock(specContent);
269
+
270
+ if (isLegacy) {
271
+ logger.info(`[LEGACY] Spec "${loadedState.active_spec}" opera en modo legacy sin matriz de pruebas.`);
272
+ } else {
273
+ // Feature structured: read closure.json in strictly read-only mode
274
+ const specDir = fssafe.resolveSafe(targetDir, loadedState.active_spec);
275
+ const closurePath = path.join(specDir, 'closure.json');
276
+
277
+ if (!fs.existsSync(closurePath)) {
278
+ logger.error(`[CLOSURE_MANIFEST_MISSING] closure.json no existe en ${loadedState.active_spec}. Ejecuta "gemstack collect" para generar la evidencia mecánica.`);
279
+ totalErrors++;
280
+ } else {
281
+ let manifest;
282
+ try {
283
+ manifest = JSON.parse(fs.readFileSync(closurePath, 'utf8'));
284
+ } catch (e) {
285
+ logger.error(`[CLOSURE_MANIFEST_INVALID] closure.json tiene formato JSON inválido: ${e.message}`);
286
+ totalErrors++;
287
+ manifest = null;
288
+ }
289
+
290
+ if (manifest) {
291
+ // Recompute closureContextHash in-memory without modifying any file
292
+ const {
293
+ validateTestMatrix,
294
+ computeAcceptanceSignature
295
+ } = require('../lib/test-matrix');
296
+ const {
297
+ parsePlanBindings,
298
+ parsePlanGates,
299
+ parseTaskMetadata,
300
+ computeContentAggregateHash,
301
+ resolveRepositoryContext,
302
+ computeClosureContextHash
303
+ } = require('../lib/closure-context');
304
+ const { hashFile } = require('../lib/hasher');
305
+
306
+ const planFile = path.join(specDir, 'plan.md');
307
+ const tasksFile = path.join(specDir, 'tasks.md');
308
+
309
+ let planBindings = [];
310
+ let planGates = [];
311
+ if (fs.existsSync(planFile)) {
312
+ const planContent = fs.readFileSync(planFile, 'utf8');
313
+ planBindings = parsePlanBindings(planContent);
314
+ planGates = parsePlanGates(planContent);
315
+ }
316
+
317
+ let tasks = [];
318
+ if (fs.existsSync(tasksFile)) {
319
+ const tasksContent = fs.readFileSync(tasksFile, 'utf8');
320
+ tasks = parseTaskMetadata(tasksContent);
321
+ }
322
+
323
+ const { matrix } = extractTestMatrixBlock(specContent);
324
+ const canonicalMatrix = validateTestMatrix(matrix);
325
+ const acceptanceSignature = computeAcceptanceSignature(canonicalMatrix);
326
+
327
+ const repoContext = resolveRepositoryContext(targetDir);
328
+ const phaseHashes = {
329
+ spec: hashFile(specFile),
330
+ plan: fs.existsSync(planFile) ? hashFile(planFile) : null,
331
+ tasks: fs.existsSync(tasksFile) ? hashFile(tasksFile) : null
332
+ };
333
+
334
+ const boundTestFiles = Array.from(new Set(planBindings.map(b => b.file)));
335
+ const testFilesHash = computeContentAggregateHash(targetDir, boundTestFiles);
336
+ const implementationFiles = Array.from(new Set(tasks.flatMap(t => t.files || [])))
337
+ .filter(f => !f.endsWith('closure.json'));
338
+ const implementationContextHash = computeContentAggregateHash(targetDir, implementationFiles);
339
+ const requiredGateDefinitionHash = computeContentAggregateHash(targetDir, ['package.json']);
340
+
341
+ const freshContextObj = {
342
+ version: 1,
343
+ repository: repoContext,
344
+ phase_hashes: phaseHashes,
345
+ acceptance_signature: acceptanceSignature,
346
+ test_files_hash: testFilesHash,
347
+ implementation_context_hash: implementationContextHash,
348
+ required_gate_definition_hash: requiredGateDefinitionHash
349
+ };
350
+
351
+ const freshContextHash = computeClosureContextHash(freshContextObj);
352
+ const recordedContextHash = manifest.closure_context ? manifest.closure_context.closure_context_hash : null;
353
+
354
+ if (recordedContextHash !== freshContextHash) {
355
+ logger.error(`[CLOSURE_EVIDENCE_STALE] La evidencia de cierre está desactualizada respecto al estado actual del proyecto. Re-ejecuta "gemstack collect". (Registrado: ${recordedContextHash ? recordedContextHash.slice(0, 12) : 'none'}..., Actual: ${freshContextHash.slice(0, 12)}...)`);
356
+ totalErrors++;
357
+ } else {
358
+ logger.ok(`Frescura de evidencia de cierre verificada (${freshContextHash.slice(0, 12)}...).`);
359
+
360
+ if (manifest.status !== 'VERIFIED' && manifest.status !== 'VERIFIED_WITH_EXCEPTIONS') {
361
+ logger.error(`[CLOSURE_NOT_VERIFIED] Estado del manifiesto es "${manifest.status}". Bloqueadores: ${JSON.stringify(manifest.blockers || [])}`);
362
+ totalErrors++;
363
+ } else {
364
+ logger.ok(`Evidencia de cierre aprobada: status="${manifest.status}", ${manifest.canonical_summary ? manifest.canonical_summary.required_passed : 0}/${manifest.canonical_summary ? manifest.canonical_summary.required_total : 0} pruebas canónicas pasadas.`);
365
+ }
366
+ }
367
+ }
368
+ }
369
+ }
370
+ }
371
+ } catch (mErr) {
372
+ logger.error(`Error en verificación de evidencia de cierre: ${mErr.message}`);
373
+ totalErrors++;
374
+ }
375
+ } else {
376
+ logger.ok('Sin spec activa configurada para validación de evidencia de cierre.');
377
+ }
378
+
379
+ // 5.1 Verificación de Políticas de Costos y Proveedores (Upgrade C - Read-Only)
380
+ const { loadCostLedger } = require('../lib/cost-ledger');
381
+ let costLedgerFound = false;
382
+ const ledgerCandidates = [];
383
+ if (loadedState && loadedState.active_spec) {
384
+ ledgerCandidates.push(fssafe.resolveSafe(targetDir, path.join(loadedState.active_spec, 'cost-ledger.json')));
385
+ }
386
+ ledgerCandidates.push(fssafe.resolveSafe(targetDir, 'cost-ledger.json'));
387
+ ledgerCandidates.push(fssafe.resolveSafe(targetDir, '.gemstack/cost-ledger.json'));
388
+
389
+ for (const lPath of ledgerCandidates) {
390
+ if (fs.existsSync(lPath)) {
391
+ costLedgerFound = true;
392
+ const res = loadCostLedger(lPath);
393
+ if (res.findings.length > 0) {
394
+ for (const f of res.findings) {
395
+ if (f.is_blocking) {
396
+ logger.error(`[COST_SAFETY_BLOCKER] ${f.code}: ${f.details}`);
397
+ totalErrors++;
398
+ } else {
399
+ logger.warn(`[COST_SAFETY_WARNING] ${f.code}: ${f.details}`);
400
+ totalWarnings++;
401
+ }
402
+ }
403
+ } else {
404
+ logger.ok(`Cost ledger verificado (${path.basename(lPath)}): íntegro y sin secretos.`);
405
+ }
406
+ break;
407
+ }
408
+ }
409
+
410
+ if (!costLedgerFound) {
411
+ logger.info('[LEGACY] [LEGACY_NO_PROVIDERS_DECLARED] No se detectaron declaraciones de costos o proveedores (Modo Legacy Provider-Free).');
412
+ }
413
+
414
+ // 5.2 Verificación de Context Capsule (Upgrade D - Read-Only)
415
+ logger.info('--- 5.2 Verificación de Context Capsule (Read-Only) ---');
416
+ if (loadedState && loadedState.active_spec) {
417
+ const { validateContextCapsule } = require('../lib/context-capsule');
418
+ const capResult = validateContextCapsule(targetDir, loadedState.active_spec);
419
+ if (capResult.valid) {
420
+ logger.ok(`Context capsule verificado y fresco (${loadedState.active_spec}/context-capsule.json).`);
421
+ } else if (capResult.state === 'MISSING') {
422
+ logger.info(`[LEGACY] No se detectó context-capsule.json en "${loadedState.active_spec}" (Modo Legacy Context-Free).`);
423
+ } else {
424
+ for (const f of capResult.findings) {
425
+ logger.error(`[${f.code}] ${f.message}`);
426
+ totalErrors++;
427
+ }
428
+ }
429
+ } else {
430
+ logger.ok('Sin spec activa configurada para verificación de context capsule.');
431
+ }
432
+
433
+ // 6. Seguridad Local y Anti-Silent Failures en Tests
434
+ logger.info('--- 6/6 Verificación de Seguridad y Test Runners ---');
259
435
  const envPath = fssafe.resolveSafe(targetDir, '.env');
260
436
  if (fs.existsSync(envPath)) {
261
437
  logger.warn('Archivo .env detectado en el directorio de trabajo. Verifica que esté en .gitignore.');
@@ -0,0 +1,453 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const crypto = require('node:crypto');
4
+ const { execFileSync } = require('node:child_process');
5
+ const { hashFile, normalizePath, normalizeContent } = require('./hasher');
6
+
7
+ function parsePlanBindings(planContent, canonicalMatrix) {
8
+ const normalized = normalizeContent(planContent);
9
+ const lines = normalized.split('\n');
10
+ const fence = String.fromCharCode(96, 96, 96);
11
+ const header = fence + 'gemstack-test-bindings';
12
+
13
+ const blocks = [];
14
+ let inBlock = false;
15
+ let blockLines = [];
16
+
17
+ for (const line of lines) {
18
+ if (!inBlock) {
19
+ if (line.trimEnd() === header) {
20
+ inBlock = true;
21
+ blockLines = [];
22
+ }
23
+ } else {
24
+ if (line.trimEnd() === fence) {
25
+ inBlock = false;
26
+ blocks.push(blockLines.join('\n'));
27
+ } else {
28
+ blockLines.push(line);
29
+ }
30
+ }
31
+ }
32
+
33
+ if (blocks.length === 0) {
34
+ return [];
35
+ }
36
+
37
+ if (blocks.length > 1) {
38
+ const err = new Error('Multiple gemstack-test-bindings blocks detected. Exactly one is permitted.');
39
+ err.code = 'BINDINGS_PARSE_ERROR';
40
+ throw err;
41
+ }
42
+
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(blocks[0].trim());
46
+ } catch (e) {
47
+ const err = new Error('Failed to parse gemstack-test-bindings JSON: ' + e.message);
48
+ err.code = 'BINDINGS_PARSE_ERROR';
49
+ throw err;
50
+ }
51
+
52
+ if (!Array.isArray(parsed)) {
53
+ const err = new Error('gemstack-test-bindings must be a JSON array');
54
+ err.code = 'BINDINGS_PARSE_ERROR';
55
+ throw err;
56
+ }
57
+
58
+ const validCanonicalIds = new Set(canonicalMatrix ? canonicalMatrix.map(m => m.id) : []);
59
+ const seenTestIds = new Set();
60
+ const validated = [];
61
+
62
+ for (let i = 0; i < parsed.length; i++) {
63
+ const item = parsed[i];
64
+ if (!item || typeof item !== 'object') {
65
+ const err = new Error('Binding at index ' + i + ' must be an object');
66
+ err.code = 'BINDINGS_PARSE_ERROR';
67
+ throw err;
68
+ }
69
+
70
+ const testId = item.test_id || item.id;
71
+ if (!testId || typeof testId !== 'string') {
72
+ const err = new Error('Binding at index ' + i + ' missing test_id');
73
+ err.code = 'BINDINGS_PARSE_ERROR';
74
+ throw err;
75
+ }
76
+
77
+ if (validCanonicalIds.size > 0 && !validCanonicalIds.has(testId)) {
78
+ const err = new Error('Binding references unknown canonical ID: "' + testId + '"');
79
+ err.code = 'BINDINGS_PARSE_ERROR';
80
+ throw err;
81
+ }
82
+
83
+ if (seenTestIds.has(testId)) {
84
+ const err = new Error('Duplicate test_id binding detected: "' + testId + '"');
85
+ err.code = 'DUPLICATE_TEST_BINDING';
86
+ throw err;
87
+ }
88
+ seenTestIds.add(testId);
89
+
90
+ if (!item.file || typeof item.file !== 'string') {
91
+ const err = new Error('Binding for "' + testId + '" missing file');
92
+ err.code = 'BINDINGS_PARSE_ERROR';
93
+ throw err;
94
+ }
95
+
96
+ const normFile = item.file.replace(/\\/g, '/');
97
+ if (path.isAbsolute(normFile) || normFile.startsWith('../')) {
98
+ const err = new Error('Binding file must be repository-relative POSIX path: "' + item.file + '"');
99
+ err.code = 'BINDINGS_PARSE_ERROR';
100
+ throw err;
101
+ }
102
+
103
+ validated.push({
104
+ test_id: testId,
105
+ runner: item.runner || 'node:test',
106
+ file: normFile
107
+ });
108
+ }
109
+
110
+ return validated;
111
+ }
112
+
113
+ function parsePlanGates(planContent) {
114
+ const normalized = normalizeContent(planContent);
115
+ const lines = normalized.split('\n');
116
+ const fence = String.fromCharCode(96, 96, 96);
117
+ const header = fence + 'gemstack-closure-gates';
118
+
119
+ const blocks = [];
120
+ let inBlock = false;
121
+ let blockLines = [];
122
+
123
+ for (const line of lines) {
124
+ if (!inBlock) {
125
+ if (line.trimEnd() === header) {
126
+ inBlock = true;
127
+ blockLines = [];
128
+ }
129
+ } else {
130
+ if (line.trimEnd() === fence) {
131
+ inBlock = false;
132
+ blocks.push(blockLines.join('\n'));
133
+ } else {
134
+ blockLines.push(line);
135
+ }
136
+ }
137
+ }
138
+
139
+ if (blocks.length === 0) {
140
+ return [];
141
+ }
142
+
143
+ if (blocks.length > 1) {
144
+ const err = new Error('Multiple gemstack-closure-gates blocks detected. Exactly one is permitted.');
145
+ err.code = 'GATES_PARSE_ERROR';
146
+ throw err;
147
+ }
148
+
149
+ let parsed;
150
+ try {
151
+ parsed = JSON.parse(blocks[0].trim());
152
+ } catch (e) {
153
+ const err = new Error('Failed to parse gemstack-closure-gates JSON: ' + e.message);
154
+ err.code = 'GATES_PARSE_ERROR';
155
+ throw err;
156
+ }
157
+
158
+ if (!Array.isArray(parsed)) {
159
+ const err = new Error('gemstack-closure-gates must be a JSON array');
160
+ err.code = 'GATES_PARSE_ERROR';
161
+ throw err;
162
+ }
163
+
164
+ const seenGateIds = new Set();
165
+ const validated = [];
166
+
167
+ for (let i = 0; i < parsed.length; i++) {
168
+ const item = parsed[i];
169
+ if (!item.id || typeof item.id !== 'string') {
170
+ const err = new Error('Gate at index ' + i + ' missing id');
171
+ err.code = 'GATES_PARSE_ERROR';
172
+ throw err;
173
+ }
174
+
175
+ if (seenGateIds.has(item.id)) {
176
+ const err = new Error('Duplicate gate id detected: "' + item.id + '"');
177
+ err.code = 'GATES_PARSE_ERROR';
178
+ throw err;
179
+ }
180
+ seenGateIds.add(item.id);
181
+
182
+ if (item.type !== 'PACKAGE_SCRIPT') {
183
+ const err = new Error('Unsupported gate type: "' + item.type + '". Only PACKAGE_SCRIPT is supported in MVP.');
184
+ err.code = 'GATES_PARSE_ERROR';
185
+ throw err;
186
+ }
187
+
188
+ if (!item.script || typeof item.script !== 'string') {
189
+ const err = new Error('PACKAGE_SCRIPT gate "' + item.id + '" missing script name');
190
+ err.code = 'GATES_PARSE_ERROR';
191
+ throw err;
192
+ }
193
+
194
+ if (!['REQUIRED', 'SUPPLEMENTAL'].includes(item.requirement)) {
195
+ const err = new Error('Gate "' + item.id + '" has invalid requirement: "' + item.requirement + '"');
196
+ err.code = 'GATES_PARSE_ERROR';
197
+ throw err;
198
+ }
199
+
200
+ if (item.requirement === 'REQUIRED' && typeof item.waivable !== 'boolean') {
201
+ const err = new Error('REQUIRED gate "' + item.id + '" requires explicit boolean waivable property');
202
+ err.code = 'GATES_PARSE_ERROR';
203
+ throw err;
204
+ }
205
+
206
+ validated.push({
207
+ id: item.id,
208
+ type: item.type,
209
+ script: item.script,
210
+ requirement: item.requirement,
211
+ waivable: typeof item.waivable === 'boolean' ? item.waivable : false
212
+ });
213
+ }
214
+
215
+ return validated;
216
+ }
217
+
218
+ function parseTaskMetadata(tasksContent) {
219
+ const normalized = normalizeContent(tasksContent);
220
+ const lines = normalized.split('\n');
221
+ const tasks = [];
222
+
223
+ const taskHeaderRegex = /^[*-]\s+\[[ xX]\]\s+\*\*(T\d+):\s*(.*?)\*\*/;
224
+ let currentTask = null;
225
+
226
+ for (const line of lines) {
227
+ const match = line.match(taskHeaderRegex);
228
+ if (match) {
229
+ if (currentTask) {
230
+ tasks.push(currentTask);
231
+ }
232
+ currentTask = {
233
+ id: match[1],
234
+ title: match[2].trim(),
235
+ validation_required: null,
236
+ tests: [],
237
+ files: [],
238
+ depends: []
239
+ };
240
+ continue;
241
+ }
242
+
243
+ if (currentTask) {
244
+ const vMatch = line.match(/<!--\s*gemstack:validation_required=(true|false)\s*-->/);
245
+ if (vMatch) {
246
+ currentTask.validation_required = vMatch[1] === 'true';
247
+ continue;
248
+ }
249
+ const tMatch = line.match(/<!--\s*gemstack:tests=(.*?)\s*-->/);
250
+ if (tMatch) {
251
+ currentTask.tests = tMatch[1] ? tMatch[1].split(',').map(s => s.trim()).filter(Boolean) : [];
252
+ continue;
253
+ }
254
+ const fMatch = line.match(/<!--\s*gemstack:files=(.*?)\s*-->/);
255
+ if (fMatch) {
256
+ currentTask.files = fMatch[1] ? fMatch[1].split(',').map(s => s.trim().replace(/\\/g, '/')).filter(Boolean) : [];
257
+ continue;
258
+ }
259
+ const dMatch = line.match(/<!--\s*gemstack:depends=(.*?)\s*-->/);
260
+ if (dMatch) {
261
+ currentTask.depends = dMatch[1] ? dMatch[1].split(',').map(s => s.trim()).filter(Boolean) : [];
262
+ continue;
263
+ }
264
+ }
265
+ }
266
+
267
+ if (currentTask) {
268
+ tasks.push(currentTask);
269
+ }
270
+
271
+ for (const t of tasks) {
272
+ if (t.validation_required === null) {
273
+ const err = new Error('Task ' + t.id + ' must explicitly declare validation_required');
274
+ err.code = 'TASK_VALIDATION_MISSING';
275
+ throw err;
276
+ }
277
+ if (t.validation_required && t.tests.length === 0) {
278
+ const err = new Error('Task ' + t.id + ' has validation_required=true but specifies no tests');
279
+ err.code = 'TASK_VALIDATION_MISSING';
280
+ throw err;
281
+ }
282
+ }
283
+
284
+ return tasks;
285
+ }
286
+
287
+ function reconcileTaskTraceability(canonicalMatrix, taskList) {
288
+ const reverseMap = {};
289
+ const tasksWithValidation = [];
290
+ const tasksDocOnly = [];
291
+
292
+ for (const t of taskList) {
293
+ if (t.validation_required) {
294
+ tasksWithValidation.push(t.id);
295
+ for (const testId of t.tests) {
296
+ if (!reverseMap[testId]) {
297
+ reverseMap[testId] = [];
298
+ }
299
+ reverseMap[testId].push(t.id);
300
+ }
301
+ } else {
302
+ tasksDocOnly.push(t.id);
303
+ }
304
+ }
305
+
306
+ const unmappedCanonical = [];
307
+ const requiredCanonical = canonicalMatrix.filter(m => m.gate === 'REQUIRED');
308
+
309
+ for (const c of requiredCanonical) {
310
+ if (!reverseMap[c.id] || reverseMap[c.id].length === 0) {
311
+ unmappedCanonical.push(c.id);
312
+ }
313
+ }
314
+
315
+ const summary = {
316
+ tasks_total: taskList.length,
317
+ tasks_with_validation: tasksWithValidation.length,
318
+ tasks_documentation_only: tasksDocOnly.length,
319
+ unmapped_canonical_tests: unmappedCanonical
320
+ };
321
+
322
+ return { summary, unmappedCanonical, reverseMap };
323
+ }
324
+
325
+ function resolveRelevantFiles(rootPath, featureDir, planBindings, taskList, planGates) {
326
+ const relFeature = normalizePath(featureDir, rootPath);
327
+ const filesSet = new Set();
328
+
329
+ const specPath = (relFeature + '/spec.md').replace(/^\.\//, '');
330
+ const planPath = (relFeature + '/plan.md').replace(/^\.\//, '');
331
+ const tasksPath = (relFeature + '/tasks.md').replace(/^\.\//, '');
332
+
333
+ if (fs.existsSync(path.join(rootPath, specPath))) filesSet.add(specPath);
334
+ if (fs.existsSync(path.join(rootPath, planPath))) filesSet.add(planPath);
335
+ if (fs.existsSync(path.join(rootPath, tasksPath))) filesSet.add(tasksPath);
336
+
337
+ const featureLedger = (relFeature + '/cost-ledger.json').replace(/^\.\//, '');
338
+ if (fs.existsSync(path.join(rootPath, featureLedger))) filesSet.add(featureLedger);
339
+ if (fs.existsSync(path.join(rootPath, 'cost-ledger.json'))) filesSet.add('cost-ledger.json');
340
+ if (fs.existsSync(path.join(rootPath, '.gemstack/cost-ledger.json'))) filesSet.add('.gemstack/cost-ledger.json');
341
+
342
+ const featureCapsule = (relFeature + '/context-capsule.json').replace(/^\.\//, '');
343
+ if (fs.existsSync(path.join(rootPath, featureCapsule))) filesSet.add(featureCapsule);
344
+ if (fs.existsSync(path.join(rootPath, '.gemstack/context-capsule.json'))) filesSet.add('.gemstack/context-capsule.json');
345
+
346
+ for (const b of (planBindings || [])) {
347
+ if (b.file && fs.existsSync(path.join(rootPath, b.file))) {
348
+ filesSet.add(b.file);
349
+ }
350
+ }
351
+
352
+ for (const t of (taskList || [])) {
353
+ for (const f of (t.files || [])) {
354
+ if (fs.existsSync(path.join(rootPath, f))) {
355
+ filesSet.add(f);
356
+ }
357
+ }
358
+ }
359
+
360
+ const hasPkgScript = (planGates || []).some(g => g.type === 'PACKAGE_SCRIPT');
361
+ if (hasPkgScript && fs.existsSync(path.join(rootPath, 'package.json'))) {
362
+ filesSet.add('package.json');
363
+ }
364
+
365
+ return Array.from(filesSet).sort((a, b) => (a < b ? -1 : (a > b ? 1 : 0)));
366
+ }
367
+
368
+ function computeContentAggregateHash(rootPath, filePaths) {
369
+ const sortedPaths = [...filePaths].sort((a, b) => (a < b ? -1 : (a > b ? 1 : 0)));
370
+ const entries = [];
371
+
372
+ for (const p of sortedPaths) {
373
+ const abs = path.join(rootPath, p);
374
+ if (fs.existsSync(abs)) {
375
+ entries.push({
376
+ path: p.replace(/\\/g, '/'),
377
+ hash: hashFile(abs)
378
+ });
379
+ }
380
+ }
381
+
382
+ const canonicalJson = JSON.stringify(entries);
383
+ return crypto.createHash('sha256').update(canonicalJson, 'utf8').digest('hex');
384
+ }
385
+
386
+ function resolveRepositoryContext(rootPath) {
387
+ const gitDir = path.join(rootPath, '.git');
388
+ if (!fs.existsSync(gitDir)) {
389
+ return {
390
+ type: 'non-git',
391
+ commit: null,
392
+ working_tree_clean: null
393
+ };
394
+ }
395
+
396
+ try {
397
+ const headCommit = execFileSync('git', ['rev-parse', 'HEAD'], {
398
+ cwd: rootPath,
399
+ stdio: ['ignore', 'pipe', 'ignore'],
400
+ encoding: 'utf8'
401
+ }).trim();
402
+
403
+ const statusOutput = execFileSync('git', ['status', '--porcelain'], {
404
+ cwd: rootPath,
405
+ stdio: ['ignore', 'pipe', 'ignore'],
406
+ encoding: 'utf8'
407
+ }).trim();
408
+
409
+ return {
410
+ type: 'git',
411
+ commit: headCommit,
412
+ working_tree_clean: statusOutput.length === 0
413
+ };
414
+ } catch (e) {
415
+ return {
416
+ type: 'git',
417
+ commit: null,
418
+ working_tree_clean: null
419
+ };
420
+ }
421
+ }
422
+
423
+ function computeClosureContextHash(contextObj) {
424
+ const sortedKeys = Object.keys(contextObj).sort((a, b) => (a < b ? -1 : (a > b ? 1 : 0)));
425
+ const normalized = {};
426
+
427
+ for (const k of sortedKeys) {
428
+ const val = contextObj[k];
429
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
430
+ const subKeys = Object.keys(val).sort((a, b) => (a < b ? -1 : (a > b ? 1 : 0)));
431
+ normalized[k] = {};
432
+ for (const sk of subKeys) {
433
+ normalized[k][sk] = val[sk];
434
+ }
435
+ } else {
436
+ normalized[k] = val;
437
+ }
438
+ }
439
+
440
+ const canonicalJson = JSON.stringify(normalized);
441
+ return crypto.createHash('sha256').update(canonicalJson, 'utf8').digest('hex');
442
+ }
443
+
444
+ module.exports = {
445
+ parsePlanBindings,
446
+ parsePlanGates,
447
+ parseTaskMetadata,
448
+ reconcileTaskTraceability,
449
+ resolveRelevantFiles,
450
+ computeContentAggregateHash,
451
+ resolveRepositoryContext,
452
+ computeClosureContextHash
453
+ };