gemstack-ai 1.2.0 → 1.4.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/.gemstack/state.json +11 -10
- package/CHANGELOG.md +97 -0
- package/README.md +89 -9
- package/RELEASE_NOTES.md +77 -0
- package/{gemstack-ai-1.2.0.tgz → gemstack-ai-1.4.0.tgz} +0 -0
- package/handoff.md +14 -12
- package/package.json +2 -2
- 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/010-agent-swarm-visual-qa/.gemstack.json +5 -0
- package/specs/010-agent-swarm-visual-qa/closure.json +59 -0
- package/specs/010-agent-swarm-visual-qa/plan.md +759 -0
- package/specs/010-agent-swarm-visual-qa/spec.md +842 -0
- package/specs/010-agent-swarm-visual-qa/swarm.json +49 -0
- package/specs/010-agent-swarm-visual-qa/tasks.md +873 -0
- package/specs/010-agent-swarm-visual-qa/visual-qa.json +41 -0
- package/src/cli.js +10 -0
- package/src/commands/context.js +95 -0
- package/src/commands/swarm.js +111 -0
- package/src/commands/verify.js +92 -0
- package/src/commands/visual.js +82 -0
- package/src/lib/closure-context.js +18 -1
- 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/safety-gates.js +277 -0
- package/src/lib/swarm.js +639 -0
- package/src/lib/visual-qa.js +499 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://gemstack.dev/schemas/v1/visual-qa.json",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"feature_id": "010-agent-swarm-visual-qa",
|
|
5
|
+
"target_base_url": "http://localhost:3000",
|
|
6
|
+
"scenarios": [
|
|
7
|
+
{
|
|
8
|
+
"scenario_id": "VQA-LOGIN-001",
|
|
9
|
+
"description": "Login screen mobile portrait conformance",
|
|
10
|
+
"route": "/login",
|
|
11
|
+
"acceptance_ids": [
|
|
12
|
+
"TEST-VISUAL-A01"
|
|
13
|
+
],
|
|
14
|
+
"viewport": {
|
|
15
|
+
"name": "mobile-portrait",
|
|
16
|
+
"width": 375,
|
|
17
|
+
"height": 667,
|
|
18
|
+
"device_scale_factor": 2,
|
|
19
|
+
"color_scheme": "light"
|
|
20
|
+
},
|
|
21
|
+
"selectors": {
|
|
22
|
+
"root": "#login-container",
|
|
23
|
+
"mask": [
|
|
24
|
+
".dynamic-timestamp",
|
|
25
|
+
".live-avatar"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"tolerances": {
|
|
29
|
+
"max_diff_percentage": 0,
|
|
30
|
+
"anti_aliasing_threshold": 0.1
|
|
31
|
+
},
|
|
32
|
+
"baseline": {
|
|
33
|
+
"image_path": "specs/010-agent-swarm-visual-qa/baselines/vqa-login-001.png",
|
|
34
|
+
"image_sha256": "c35ba1ccd49a5e52bdc206d7e8723bc31d288d4f38b6c9045f4f78ab79e6018f",
|
|
35
|
+
"dom_hash": "c5d6e7f80123456789abcdef0123456789abcdef0123456789abcdef01234567",
|
|
36
|
+
"approved_by": "human-lead",
|
|
37
|
+
"approved_at": "2026-09-11T19:10:00Z"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -11,6 +11,9 @@ const installCommand = require('./commands/install');
|
|
|
11
11
|
const verifyCommand = require('./commands/verify');
|
|
12
12
|
const collectCommand = require('./commands/collect');
|
|
13
13
|
const shipCommand = require('./commands/ship');
|
|
14
|
+
const contextCommand = require('./commands/context');
|
|
15
|
+
const swarmCommand = require('./commands/swarm');
|
|
16
|
+
const visualCommand = require('./commands/visual');
|
|
14
17
|
|
|
15
18
|
async function main() {
|
|
16
19
|
const { command, args, flags } = parser.parse(process.argv);
|
|
@@ -24,6 +27,9 @@ Commands:
|
|
|
24
27
|
verify Run complete integrity, state, memory and security audit (alias: audit)
|
|
25
28
|
collect Collect mechanical test matrix and closure evidence
|
|
26
29
|
ship Verify closure gates and transition feature to shipped
|
|
30
|
+
context Generate, show, or verify context capsule
|
|
31
|
+
swarm Plan or validate agent swarm work and write partitions
|
|
32
|
+
vqa Validate or promote visual QA manifests and evidence
|
|
27
33
|
list List available skills
|
|
28
34
|
show Show content of a skill
|
|
29
35
|
handoff Show content of handoff.md
|
|
@@ -48,6 +54,10 @@ Options:
|
|
|
48
54
|
case 'audit': await verifyCommand(flags); break;
|
|
49
55
|
case 'collect': await collectCommand(flags); break;
|
|
50
56
|
case 'ship': await shipCommand(flags); break;
|
|
57
|
+
case 'context': await contextCommand(args, flags); break;
|
|
58
|
+
case 'swarm': await swarmCommand(args, flags); break;
|
|
59
|
+
case 'vqa':
|
|
60
|
+
case 'visual': await visualCommand(args, flags); break;
|
|
51
61
|
case 'list': await listCommand(flags); break;
|
|
52
62
|
case 'show': await showCommand(args[0], flags); break;
|
|
53
63
|
case 'hooks': hooksCommand.installHooks(flags.target); break;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const logger = require('../lib/logger');
|
|
4
|
+
const fssafe = require('../lib/filesystem-safe');
|
|
5
|
+
const {
|
|
6
|
+
generateContextCapsule,
|
|
7
|
+
validateContextCapsule
|
|
8
|
+
} = require('../lib/context-capsule');
|
|
9
|
+
|
|
10
|
+
async function contextCommand(args = [], flags = {}) {
|
|
11
|
+
const subcommand = args[0] || 'show';
|
|
12
|
+
const targetDir = flags.target ? path.resolve(flags.target) : process.cwd();
|
|
13
|
+
|
|
14
|
+
let activeSpec = (args[1] && !args[1].startsWith('-') ? args[1] : null) || flags.feature;
|
|
15
|
+
if (!activeSpec) {
|
|
16
|
+
const stateFile = path.join(targetDir, '.gemstack/state.json');
|
|
17
|
+
if (fs.existsSync(stateFile)) {
|
|
18
|
+
try {
|
|
19
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
20
|
+
activeSpec = state.active_spec;
|
|
21
|
+
} catch (_) {}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!activeSpec) {
|
|
26
|
+
// Check if specs/009-context-capsule or specs/current exists
|
|
27
|
+
if (fs.existsSync(path.join(targetDir, 'specs/009-context-capsule'))) {
|
|
28
|
+
activeSpec = 'specs/009-context-capsule';
|
|
29
|
+
} else if (fs.existsSync(path.join(targetDir, 'specs/current'))) {
|
|
30
|
+
activeSpec = 'specs/current';
|
|
31
|
+
} else {
|
|
32
|
+
logger.error('No active feature found or specified via --feature.');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
switch (subcommand) {
|
|
38
|
+
case 'generate': {
|
|
39
|
+
logger.info(`Generating context capsule for "${activeSpec}" in: ${targetDir}`);
|
|
40
|
+
try {
|
|
41
|
+
const res = generateContextCapsule(targetDir, activeSpec);
|
|
42
|
+
logger.ok(`Context capsule generated successfully: ${res.path} (${res.byteLength} bytes, ${res.invariantsCount} invariants).`);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
logger.error(`Generation failed: ${err.message}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
case 'show': {
|
|
51
|
+
const capsuleFile = path.join(targetDir, activeSpec, 'context-capsule.json');
|
|
52
|
+
if (!fs.existsSync(capsuleFile)) {
|
|
53
|
+
logger.error(`Context capsule not found at: ${capsuleFile}`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
const raw = fs.readFileSync(capsuleFile, 'utf8');
|
|
57
|
+
if (flags.json) {
|
|
58
|
+
console.log(raw);
|
|
59
|
+
} else {
|
|
60
|
+
const parsed = JSON.parse(raw);
|
|
61
|
+
console.log('=== Gemstack Context Capsule ===');
|
|
62
|
+
console.log(`Schema Version: ${parsed.schema_version}`);
|
|
63
|
+
console.log(`Project: ${parsed.project ? parsed.project.name : 'unknown'}`);
|
|
64
|
+
console.log(`Feature: ${parsed.project ? parsed.project.active_feature : 'unknown'}`);
|
|
65
|
+
console.log(`Lifecycle: ${parsed.project ? parsed.project.lifecycle_status : 'unknown'}`);
|
|
66
|
+
console.log(`Sources: ${parsed.provenance ? parsed.provenance.sources.length : 0} files`);
|
|
67
|
+
console.log(`Invariants: ${parsed.canonical_invariants ? parsed.canonical_invariants.length : 0} rules`);
|
|
68
|
+
console.log(`Contracts: ${parsed.frozen_contracts ? parsed.frozen_contracts.length : 0} contracts`);
|
|
69
|
+
console.log(`Acceptance: ${parsed.acceptance_matrix ? parsed.acceptance_matrix.total_required : 0} tests`);
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
case 'verify': {
|
|
75
|
+
logger.info(`Auditing context capsule for "${activeSpec}" in: ${targetDir}`);
|
|
76
|
+
const res = validateContextCapsule(targetDir, activeSpec);
|
|
77
|
+
if (res.valid) {
|
|
78
|
+
logger.ok(`[OK] Context capsule is VALID and FRESH for ${activeSpec}.`);
|
|
79
|
+
} else {
|
|
80
|
+
logger.error(`[${res.state}] Context capsule validation failed.`);
|
|
81
|
+
for (const f of res.findings) {
|
|
82
|
+
logger.error(` - [${f.code}] ${f.message}`);
|
|
83
|
+
}
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
default:
|
|
90
|
+
logger.error(`Unknown context subcommand: ${subcommand}. Valid subcommands: generate, show, verify.`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = contextCommand;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemstack Swarm Command Handler (Upgrade E)
|
|
3
|
+
*
|
|
4
|
+
* Implements "gemstack swarm plan" and "gemstack swarm validate" subcommands.
|
|
5
|
+
* Read-only planning and manifest auditing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const logger = require('../lib/logger');
|
|
11
|
+
const fssafe = require('../lib/filesystem-safe');
|
|
12
|
+
const { readState } = require('../lib/state');
|
|
13
|
+
const { planSwarmWaves, validateSwarmManifest, canonicalSerialize } = require('../lib/swarm');
|
|
14
|
+
|
|
15
|
+
async function swarmCommand(args = [], flags = {}) {
|
|
16
|
+
const subcommand = args[0] || 'validate';
|
|
17
|
+
const targetDir = flags.target ? path.resolve(flags.target) : process.cwd();
|
|
18
|
+
|
|
19
|
+
let state;
|
|
20
|
+
try {
|
|
21
|
+
state = readState(targetDir);
|
|
22
|
+
} catch (e) {
|
|
23
|
+
logger.error(`Error loading .gemstack/state.json: ${e.message}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const activeSpec = state.active_spec;
|
|
28
|
+
if (!activeSpec) {
|
|
29
|
+
if (flags.json) {
|
|
30
|
+
console.log(JSON.stringify({ status: 'NO_ACTIVE_SPEC', message: 'No active feature spec set.' }));
|
|
31
|
+
} else {
|
|
32
|
+
logger.info('Sin spec activa pendiente. Opera en modo legacy.');
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const featureDir = fssafe.resolveSafe(targetDir, activeSpec);
|
|
38
|
+
|
|
39
|
+
if (subcommand === 'plan') {
|
|
40
|
+
const tasksPath = path.join(featureDir, 'tasks.md');
|
|
41
|
+
if (!fs.existsSync(tasksPath)) {
|
|
42
|
+
logger.error(`tasks.md not found in "${activeSpec}".`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const tasksRaw = fs.readFileSync(tasksPath, 'utf8');
|
|
47
|
+
// Extract tasks with parallel tag or lines
|
|
48
|
+
const parsedTasks = [];
|
|
49
|
+
const lines = tasksRaw.split('\n');
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
const m = line.match(/###\s+(?:Task\s+)?([A-Za-z0-9_-]+)/i);
|
|
52
|
+
if (m) {
|
|
53
|
+
parsedTasks.push({
|
|
54
|
+
task_id: m[1],
|
|
55
|
+
description: line.replace(/^#+\s*/, '').trim(),
|
|
56
|
+
assigned_role: 'implementer',
|
|
57
|
+
worker_id: `worker-impl-${parsedTasks.length + 1}`,
|
|
58
|
+
write_set: [`src/${m[1].toLowerCase()}.js`],
|
|
59
|
+
status: 'PLANNED'
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const waves = planSwarmWaves(parsedTasks);
|
|
65
|
+
const manifest = {
|
|
66
|
+
$schema: 'https://gemstack.dev/schemas/v1/swarm.json',
|
|
67
|
+
version: '1.0.0',
|
|
68
|
+
feature_id: activeSpec,
|
|
69
|
+
source_capsule_hash: 'd0mmy_c4psul3_h4sh',
|
|
70
|
+
max_workers: 4,
|
|
71
|
+
waves
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const manifestPath = path.join(featureDir, 'swarm.json');
|
|
75
|
+
if (!flags['dry-run']) {
|
|
76
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
77
|
+
logger.ok(`Swarm plan generated: ${manifestPath} (${waves.length} wave(s)).`);
|
|
78
|
+
} else {
|
|
79
|
+
logger.info(`[DRY-RUN] Swarm plan computed: ${waves.length} wave(s).`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (flags.json) {
|
|
83
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
84
|
+
}
|
|
85
|
+
} else if (subcommand === 'validate') {
|
|
86
|
+
const res = validateSwarmManifest(targetDir, activeSpec);
|
|
87
|
+
if (flags.json) {
|
|
88
|
+
console.log(JSON.stringify(res, null, 2));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (res.state === 'MISSING') {
|
|
93
|
+
logger.info(`[LEGACY] No se detectó swarm.json en "${activeSpec}".`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (res.valid) {
|
|
98
|
+
logger.ok(`Swarm manifest validado con éxito: ${activeSpec}/swarm.json.`);
|
|
99
|
+
} else {
|
|
100
|
+
for (const f of res.findings) {
|
|
101
|
+
logger.error(`[${f.code}] ${f.details}`);
|
|
102
|
+
}
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
logger.error(`Unknown swarm subcommand: "${subcommand}". Use "plan" or "validate".`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = swarmCommand;
|
package/src/commands/verify.js
CHANGED
|
@@ -376,6 +376,98 @@ module.exports = async (flags) => {
|
|
|
376
376
|
logger.ok('Sin spec activa configurada para validación de evidencia de cierre.');
|
|
377
377
|
}
|
|
378
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
|
+
// 5.3 Verificación de Swarm Manifest (Upgrade E - Read-Only)
|
|
434
|
+
logger.info('--- 5.3 Verificación de Swarm Manifest & Partition Safety (Read-Only) ---');
|
|
435
|
+
if (loadedState && loadedState.active_spec) {
|
|
436
|
+
const { validateSwarmManifest } = require('../lib/swarm');
|
|
437
|
+
const swarmResult = validateSwarmManifest(targetDir, loadedState.active_spec);
|
|
438
|
+
if (swarmResult.valid) {
|
|
439
|
+
logger.ok(`Swarm manifest verificado y sin colisiones (${loadedState.active_spec}/swarm.json).`);
|
|
440
|
+
} else if (swarmResult.state === 'MISSING') {
|
|
441
|
+
logger.info(`[LEGACY] No se detectó swarm.json en "${loadedState.active_spec}" (Modo Legacy Swarm-Free).`);
|
|
442
|
+
} else {
|
|
443
|
+
for (const f of swarmResult.findings) {
|
|
444
|
+
logger.error(`[${f.code}] ${f.details || f.message}`);
|
|
445
|
+
totalErrors++;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
logger.ok('Sin spec activa configurada para verificación de swarm manifest.');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// 5.4 Verificación de Visual QA Manifest & Baselines (Upgrade E - Read-Only)
|
|
453
|
+
logger.info('--- 5.4 Verificación de Visual QA Manifest & Baselines (Read-Only) ---');
|
|
454
|
+
if (loadedState && loadedState.active_spec) {
|
|
455
|
+
const { validateVisualManifest } = require('../lib/visual-qa');
|
|
456
|
+
const vqaResult = validateVisualManifest(targetDir, loadedState.active_spec);
|
|
457
|
+
if (vqaResult.valid) {
|
|
458
|
+
logger.ok(`Visual QA manifest y baselines íntegros (${loadedState.active_spec}/visual-qa.json).`);
|
|
459
|
+
} else if (vqaResult.state === 'MISSING') {
|
|
460
|
+
logger.info(`[LEGACY] No se detectó visual-qa.json en "${loadedState.active_spec}" (Modo Legacy Visual-Free).`);
|
|
461
|
+
} else {
|
|
462
|
+
for (const f of vqaResult.findings) {
|
|
463
|
+
logger.error(`[${f.code}] ${f.details || f.message}`);
|
|
464
|
+
totalErrors++;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
} else {
|
|
468
|
+
logger.ok('Sin spec activa configurada para verificación de visual QA.');
|
|
469
|
+
}
|
|
470
|
+
|
|
379
471
|
// 6. Seguridad Local y Anti-Silent Failures en Tests
|
|
380
472
|
logger.info('--- 6/6 Verificación de Seguridad y Test Runners ---');
|
|
381
473
|
const envPath = fssafe.resolveSafe(targetDir, '.env');
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemstack Visual QA Command Handler (Upgrade E)
|
|
3
|
+
*
|
|
4
|
+
* Implements "gemstack vqa validate" and "gemstack vqa promote" subcommands.
|
|
5
|
+
* Read-only validation and explicit baseline promotion.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const logger = require('../lib/logger');
|
|
11
|
+
const fssafe = require('../lib/filesystem-safe');
|
|
12
|
+
const { readState } = require('../lib/state');
|
|
13
|
+
const { validateVisualManifest, promoteVisualBaseline } = require('../lib/visual-qa');
|
|
14
|
+
|
|
15
|
+
async function visualCommand(args = [], flags = {}) {
|
|
16
|
+
const subcommand = args[0] || 'validate';
|
|
17
|
+
const targetDir = flags.target ? path.resolve(flags.target) : process.cwd();
|
|
18
|
+
|
|
19
|
+
let state;
|
|
20
|
+
try {
|
|
21
|
+
state = readState(targetDir);
|
|
22
|
+
} catch (e) {
|
|
23
|
+
logger.error(`Error loading .gemstack/state.json: ${e.message}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const activeSpec = state.active_spec;
|
|
28
|
+
if (!activeSpec) {
|
|
29
|
+
if (flags.json) {
|
|
30
|
+
console.log(JSON.stringify({ status: 'NO_ACTIVE_SPEC', message: 'No active feature spec set.' }));
|
|
31
|
+
} else {
|
|
32
|
+
logger.info('Sin spec activa pendiente. Opera en modo legacy.');
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (subcommand === 'validate') {
|
|
38
|
+
const res = validateVisualManifest(targetDir, activeSpec);
|
|
39
|
+
if (flags.json) {
|
|
40
|
+
console.log(JSON.stringify(res, null, 2));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (res.state === 'MISSING') {
|
|
45
|
+
logger.info(`[LEGACY] No se detectó visual-qa.json en "${activeSpec}".`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (res.valid) {
|
|
50
|
+
logger.ok(`Visual QA manifest validado con éxito: ${activeSpec}/visual-qa.json.`);
|
|
51
|
+
} else {
|
|
52
|
+
for (const f of res.findings) {
|
|
53
|
+
logger.error(`[${f.code}] ${f.details}`);
|
|
54
|
+
}
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
} else if (subcommand === 'promote') {
|
|
58
|
+
const scenarioId = args[1];
|
|
59
|
+
const liveImagePath = args[2];
|
|
60
|
+
|
|
61
|
+
if (!scenarioId || !liveImagePath) {
|
|
62
|
+
logger.error('Usage: gemstack vqa promote <scenario-id> <live-image-path>');
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const res = promoteVisualBaseline(targetDir, activeSpec, scenarioId, liveImagePath);
|
|
68
|
+
logger.ok(`Baseline promoted for scenario "${scenarioId}". Updated ${activeSpec}/visual-qa.json.`);
|
|
69
|
+
if (flags.json) {
|
|
70
|
+
console.log(JSON.stringify(res, null, 2));
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
logger.error(`Failed to promote baseline: ${err.message}`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
logger.error(`Unknown visual subcommand: "${subcommand}". Use "validate" or "promote".`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = visualCommand;
|
|
@@ -220,7 +220,7 @@ function parseTaskMetadata(tasksContent) {
|
|
|
220
220
|
const lines = normalized.split('\n');
|
|
221
221
|
const tasks = [];
|
|
222
222
|
|
|
223
|
-
const taskHeaderRegex = /^[*-]\s+\[[ xX]\]\s+\*\*(
|
|
223
|
+
const taskHeaderRegex = /^[*-]\s+\[[ xX]\]\s+\*\*([A-Za-z0-9_-]+):\s*(.*?)\*\*/;
|
|
224
224
|
let currentTask = null;
|
|
225
225
|
|
|
226
226
|
for (const line of lines) {
|
|
@@ -334,6 +334,23 @@ function resolveRelevantFiles(rootPath, featureDir, planBindings, taskList, plan
|
|
|
334
334
|
if (fs.existsSync(path.join(rootPath, planPath))) filesSet.add(planPath);
|
|
335
335
|
if (fs.existsSync(path.join(rootPath, tasksPath))) filesSet.add(tasksPath);
|
|
336
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
|
+
const featureSwarm = (relFeature + '/swarm.json').replace(/^\.\//, '');
|
|
347
|
+
if (fs.existsSync(path.join(rootPath, featureSwarm))) filesSet.add(featureSwarm);
|
|
348
|
+
if (fs.existsSync(path.join(rootPath, '.gemstack/swarm.json'))) filesSet.add('.gemstack/swarm.json');
|
|
349
|
+
|
|
350
|
+
const featureVqa = (relFeature + '/visual-qa.json').replace(/^\.\//, '');
|
|
351
|
+
if (fs.existsSync(path.join(rootPath, featureVqa))) filesSet.add(featureVqa);
|
|
352
|
+
if (fs.existsSync(path.join(rootPath, '.gemstack/visual-qa.json'))) filesSet.add('.gemstack/visual-qa.json');
|
|
353
|
+
|
|
337
354
|
for (const b of (planBindings || [])) {
|
|
338
355
|
if (b.file && fs.existsSync(path.join(rootPath, b.file))) {
|
|
339
356
|
filesSet.add(b.file);
|