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
@@ -0,0 +1,277 @@
1
+ const { checkEnvironmentCommercialPolicy, resolveEnvironmentTier } = require('./provider-registry');
2
+
3
+ /**
4
+ * Creates a normalized, structured gate decision object.
5
+ *
6
+ * @param {object} params
7
+ * @param {boolean} params.authorized
8
+ * @param {string} params.decision - 'ALLOW' | 'DENY'
9
+ * @param {string} params.reasonCode
10
+ * @param {string} params.message
11
+ * @param {object} [params.context={}]
12
+ * @returns {object} Structured gate decision
13
+ */
14
+ function createGateDecision({ authorized, decision, reasonCode, message, context = {} }) {
15
+ // Strip any accidental credential fields from context
16
+ const cleanContext = {};
17
+ for (const [k, v] of Object.entries(context)) {
18
+ const lk = k.toLowerCase();
19
+ if (!lk.includes('key') && !lk.includes('secret') && !lk.includes('token') && !lk.includes('pass')) {
20
+ cleanContext[k] = v;
21
+ }
22
+ }
23
+
24
+ return {
25
+ authorized: Boolean(authorized),
26
+ decision: decision || (authorized ? 'ALLOW' : 'DENY'),
27
+ reasonCode,
28
+ reason_code: reasonCode, // backward/spec compatibility
29
+ message,
30
+ context: cleanContext
31
+ };
32
+ }
33
+
34
+ /**
35
+ * ProviderCapabilityGate: Validates provider registration, environment allowance,
36
+ * capability declaration, and adapter execution support.
37
+ *
38
+ * @param {object} request
39
+ * @param {string} request.provider_id
40
+ * @param {string} request.capability_id
41
+ * @param {string} [request.environment]
42
+ * @param {object} [request.adapter]
43
+ * @param {object} registry - ProviderRegistry instance
44
+ * @param {object} [options={}]
45
+ * @returns {object} Structured gate decision
46
+ */
47
+ function evaluateProviderCapability(request, registry, options = {}) {
48
+ if (!request || typeof request !== 'object') {
49
+ return createGateDecision({
50
+ authorized: false,
51
+ decision: 'DENY',
52
+ reasonCode: 'UNKNOWN_PROVIDER_IDENTITY',
53
+ message: 'Capability evaluation request must be an object.',
54
+ context: {}
55
+ });
56
+ }
57
+
58
+ const providerId = (request.provider_id || '').toLowerCase().trim();
59
+ const capabilityId = request.capability_id;
60
+ const envTier = resolveEnvironmentTier({ environment: request.environment, ...options });
61
+
62
+ // 1. Is Provider Registered?
63
+ if (!providerId || !registry || !registry.hasProvider(providerId)) {
64
+ return createGateDecision({
65
+ authorized: false,
66
+ decision: 'DENY',
67
+ reasonCode: 'UNKNOWN_PROVIDER_IDENTITY',
68
+ message: 'Provider "' + (request.provider_id || '') + '" is not registered in provider registry.',
69
+ context: { provider_id: request.provider_id, capability_id: capabilityId, environment: envTier }
70
+ });
71
+ }
72
+
73
+ const provider = registry.getProvider(providerId);
74
+
75
+ // 2. Is Provider Permitted in Active Environment?
76
+ if (provider.type === 'COMMERCIAL') {
77
+ const envPolicy = checkEnvironmentCommercialPolicy(envTier, options);
78
+ if (!envPolicy.allowed) {
79
+ return createGateDecision({
80
+ authorized: false,
81
+ decision: 'DENY',
82
+ reasonCode: envPolicy.reasonCode,
83
+ message: envPolicy.message,
84
+ context: { provider_id: providerId, capability_id: capabilityId, environment: envTier }
85
+ });
86
+ }
87
+ }
88
+
89
+ // 3. Is Capability Declared for this Provider?
90
+ if (!capabilityId || !registry.hasCapability(providerId, capabilityId)) {
91
+ return createGateDecision({
92
+ authorized: false,
93
+ decision: 'DENY',
94
+ reasonCode: 'PROVIDER_CAPABILITY_UNDECLARED',
95
+ message: 'Capability "' + (capabilityId || '') + '" is not declared for provider "' + providerId + '".',
96
+ context: { provider_id: providerId, capability_id: capabilityId, environment: envTier }
97
+ });
98
+ }
99
+
100
+ // 4. Does Adapter Support Capability?
101
+ if (request.adapter) {
102
+ const hasAdapterSupport = typeof request.adapter.supportsCapability === 'function'
103
+ ? request.adapter.supportsCapability(capabilityId)
104
+ : (Array.isArray(request.adapter.supportedCapabilities) && request.adapter.supportedCapabilities.includes(capabilityId));
105
+
106
+ if (!hasAdapterSupport) {
107
+ return createGateDecision({
108
+ authorized: false,
109
+ decision: 'DENY',
110
+ reasonCode: 'PROVIDER_CAPABILITY_UNSUPPORTED',
111
+ message: 'Adapter for provider "' + providerId + '" does not implement capability "' + capabilityId + '".',
112
+ context: { provider_id: providerId, capability_id: capabilityId, environment: envTier }
113
+ });
114
+ }
115
+ }
116
+
117
+ // 5. Success
118
+ return createGateDecision({
119
+ authorized: true,
120
+ decision: 'ALLOW',
121
+ reasonCode: 'CAPABILITY_VERIFIED',
122
+ message: 'Provider "' + providerId + '" capability "' + capabilityId + '" verified successfully.',
123
+ context: { provider_id: providerId, capability_id: capabilityId, environment: envTier }
124
+ });
125
+ }
126
+
127
+ /**
128
+ * BillableActionGate: Evaluates spending authorization, cost state classification,
129
+ * budget limits, and fail-closed commercial defaults.
130
+ *
131
+ * @param {object} request
132
+ * @param {string} request.action_id
133
+ * @param {string} request.provider_id
134
+ * @param {string} request.capability_id
135
+ * @param {string} [request.environment]
136
+ * @param {number} [request.requested_units=1]
137
+ * @param {object} [request.authorization_token]
138
+ * @param {object} ledger - Loaded cost-ledger object or ProviderRegistry
139
+ * @param {object} [options={}]
140
+ * @param {string[]} [options.declaredActions] - Optional whitelist of declared actions in spec.md
141
+ * @returns {object} Structured gate decision
142
+ */
143
+ function evaluateBillableAction(request, ledger, options = {}) {
144
+ if (!request || typeof request !== 'object') {
145
+ return createGateDecision({
146
+ authorized: false,
147
+ decision: 'DENY',
148
+ reasonCode: 'UNDECLARED_BILLABLE_ACTION',
149
+ message: 'Billable action request must be an object.',
150
+ context: {}
151
+ });
152
+ }
153
+
154
+ const actionId = request.action_id;
155
+ const providerId = (request.provider_id || '').toLowerCase().trim();
156
+ const capabilityId = request.capability_id;
157
+ const envTier = resolveEnvironmentTier({ environment: request.environment, ...options });
158
+ const requestedUnits = typeof request.requested_units === 'number' ? request.requested_units : 1;
159
+
160
+ // 1. Action Declaration Check (Must be explicitly declared)
161
+ if (!actionId || typeof actionId !== 'string' || !actionId.trim()) {
162
+ return createGateDecision({
163
+ authorized: false,
164
+ decision: 'DENY',
165
+ reasonCode: 'UNDECLARED_BILLABLE_ACTION',
166
+ message: 'Action ID is missing or invalid.',
167
+ context: { action_id: actionId, provider_id: providerId, environment: envTier }
168
+ });
169
+ }
170
+
171
+ if (options.declaredActions && Array.isArray(options.declaredActions)) {
172
+ if (!options.declaredActions.includes(actionId)) {
173
+ return createGateDecision({
174
+ authorized: false,
175
+ decision: 'DENY',
176
+ reasonCode: 'UNDECLARED_BILLABLE_ACTION',
177
+ message: 'Billable action "' + actionId + '" is not declared in specification or active plan.',
178
+ context: { action_id: actionId, provider_id: providerId, environment: envTier }
179
+ });
180
+ }
181
+ }
182
+
183
+ // Resolve Provider & Capability from Ledger
184
+ let providerEntry = null;
185
+ let capabilityEntry = null;
186
+
187
+ if (ledger && ledger.providers && ledger.providers[providerId]) {
188
+ providerEntry = ledger.providers[providerId];
189
+ if (providerEntry.capabilities && providerEntry.capabilities[capabilityId]) {
190
+ capabilityEntry = providerEntry.capabilities[capabilityId];
191
+ }
192
+ }
193
+
194
+ // 2. Cost State Resolution (Strict Fail-Closed)
195
+ const costState = capabilityEntry ? capabilityEntry.cost_state : (request.cost_state || 'UNKNOWN');
196
+
197
+ if (costState === 'UNKNOWN' || !['FREE', 'BILLABLE', 'POTENTIALLY_BILLABLE'].includes(costState)) {
198
+ return createGateDecision({
199
+ authorized: false,
200
+ decision: 'DENY',
201
+ reasonCode: 'UNKNOWN_COST_CLASSIFICATION',
202
+ message: 'Action "' + actionId + '" has UNKNOWN cost classification. Fail-closed: execution denied.',
203
+ context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: 'UNKNOWN', environment: envTier }
204
+ });
205
+ }
206
+
207
+ // 3. Environment Check for Commercial / Billable
208
+ const isCommercial = providerEntry ? providerEntry.type === 'COMMERCIAL' : (costState !== 'FREE');
209
+ if (isCommercial) {
210
+ const envPolicy = checkEnvironmentCommercialPolicy(envTier, options);
211
+ if (!envPolicy.allowed) {
212
+ return createGateDecision({
213
+ authorized: false,
214
+ decision: 'DENY',
215
+ reasonCode: envPolicy.reasonCode,
216
+ message: envPolicy.message,
217
+ context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier }
218
+ });
219
+ }
220
+ }
221
+
222
+ // 4. FREE Actions (Permitted for Local / MOCK / Zero Cost)
223
+ if (costState === 'FREE') {
224
+ return createGateDecision({
225
+ authorized: true,
226
+ decision: 'ALLOW',
227
+ reasonCode: 'ACTION_AUTHORIZED_FREE',
228
+ message: 'Action "' + actionId + '" is FREE and authorized for execution.',
229
+ context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: 'FREE', environment: envTier }
230
+ });
231
+ }
232
+
233
+ // 5. BILLABLE / POTENTIALLY_BILLABLE Actions -> Require Explicit Spending Token
234
+ const token = request.authorization_token;
235
+ const isTokenValid = token && (token.granted === true || token.granted_by || token.source === 'CLI_FLAG' || token.max_budget_units !== undefined);
236
+
237
+ if (!isTokenValid) {
238
+ return createGateDecision({
239
+ authorized: false,
240
+ decision: 'DENY',
241
+ reasonCode: 'BILLABLE_ACTION_UNAUTHORIZED',
242
+ message: 'Action "' + actionId + '" on provider "' + providerId + '" requires explicit spending authorization.',
243
+ context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier }
244
+ });
245
+ }
246
+
247
+ // 6. Budget & Unit Threshold Check
248
+ const estimatedUnitCost = (capabilityEntry && typeof capabilityEntry.estimated_unit_cost === 'number') ? capabilityEntry.estimated_unit_cost : 1;
249
+ const estimatedTotalCost = requestedUnits * estimatedUnitCost;
250
+
251
+ if (token && typeof token.max_budget_units === 'number') {
252
+ if (estimatedTotalCost > token.max_budget_units) {
253
+ return createGateDecision({
254
+ authorized: false,
255
+ decision: 'DENY',
256
+ reasonCode: 'BUDGET_THRESHOLD_EXCEEDED',
257
+ message: 'Action "' + actionId + '" estimated cost (' + estimatedTotalCost + ') exceeds authorized budget limit (' + token.max_budget_units + ').',
258
+ context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier, estimated_total_cost: estimatedTotalCost, max_budget_units: token.max_budget_units }
259
+ });
260
+ }
261
+ }
262
+
263
+ // 7. Full Authorization Pass
264
+ return createGateDecision({
265
+ authorized: true,
266
+ decision: 'ALLOW',
267
+ reasonCode: 'ACTION_AUTHORIZED',
268
+ message: 'Action "' + actionId + '" authorized for execution within budget.',
269
+ context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier, estimated_total_cost: estimatedTotalCost }
270
+ });
271
+ }
272
+
273
+ module.exports = {
274
+ createGateDecision,
275
+ evaluateProviderCapability,
276
+ evaluateBillableAction
277
+ };
@@ -0,0 +1,187 @@
1
+ const crypto = require('node:crypto');
2
+ const { normalizeContent } = require('./hasher');
3
+
4
+ const CANONICAL_LAYERS = ['UNIT', 'INTEGRATION', 'E2E', 'CLI'];
5
+ const CANONICAL_GATES = ['REQUIRED', 'SUPPLEMENTAL'];
6
+ const CANONICAL_ID_REGEX = /^TEST-[A-Z0-9]+-[A-Z0-9]+$/;
7
+ const REQUIRED_FIELDS = ['id', 'category', 'layer', 'description', 'pass_criteria', 'gate'];
8
+
9
+ /**
10
+ * Extracts the single column-0 gemstack-test-matrix block from markdown.
11
+ *
12
+ * @param {string} markdownContent
13
+ * @returns {{ matrix: Array<object>|null, isLegacy: boolean }}
14
+ */
15
+ function extractTestMatrixBlock(markdownContent) {
16
+ const normalized = normalizeContent(markdownContent);
17
+ const lines = normalized.split('\n');
18
+ const fence = '```';
19
+ const header = '```gemstack-test-matrix';
20
+
21
+ const blocks = [];
22
+ let inBlock = false;
23
+ let blockLines = [];
24
+
25
+ for (const line of lines) {
26
+ if (!inBlock) {
27
+ if (line.trimEnd() === header) {
28
+ inBlock = true;
29
+ blockLines = [];
30
+ }
31
+ } else {
32
+ if (line.trimEnd() === fence) {
33
+ inBlock = false;
34
+ blocks.push(blockLines.join('\n'));
35
+ } else {
36
+ blockLines.push(line);
37
+ }
38
+ }
39
+ }
40
+
41
+ if (blocks.length === 0) {
42
+ return { matrix: null, isLegacy: true };
43
+ }
44
+
45
+ if (blocks.length > 1) {
46
+ const err = new Error(`Multiple gemstack-test-matrix blocks detected (${blocks.length}). Exactly one is permitted.`);
47
+ err.code = 'TEST_MATRIX_PARSE_ERROR';
48
+ throw err;
49
+ }
50
+
51
+ const jsonRaw = blocks[0].trim();
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(jsonRaw);
55
+ } catch (parseErr) {
56
+ const err = new Error(`Failed to parse gemstack-test-matrix JSON: ${parseErr.message}`);
57
+ err.code = 'TEST_MATRIX_PARSE_ERROR';
58
+ throw err;
59
+ }
60
+
61
+ return { matrix: parsed, isLegacy: false };
62
+ }
63
+
64
+ /**
65
+ * Validates a canonical test matrix array.
66
+ *
67
+ * @param {any} matrix
68
+ * @returns {Array<object>} Sanitized array of canonical test objects
69
+ */
70
+ function validateTestMatrix(matrix) {
71
+ if (!Array.isArray(matrix)) {
72
+ const err = new Error('gemstack-test-matrix must be a JSON array of test objects');
73
+ err.code = 'TEST_MATRIX_INVALID_SHAPE';
74
+ throw err;
75
+ }
76
+
77
+ const seenIds = new Set();
78
+ const validated = [];
79
+
80
+ for (let i = 0; i < matrix.length; i++) {
81
+ const item = matrix[i];
82
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
83
+ const err = new Error(`Item at index ${i} in gemstack-test-matrix must be a non-null object`);
84
+ err.code = 'TEST_MATRIX_INVALID_SHAPE';
85
+ throw err;
86
+ }
87
+
88
+ // Check unknown fields
89
+ const keys = Object.keys(item);
90
+ for (const k of keys) {
91
+ if (!REQUIRED_FIELDS.includes(k)) {
92
+ const err = new Error(`Item at index ${i} contains unknown field: "${k}"`);
93
+ err.code = 'TEST_MATRIX_INVALID_SHAPE';
94
+ throw err;
95
+ }
96
+ }
97
+
98
+ // Check required fields and empty values
99
+ for (const rf of REQUIRED_FIELDS) {
100
+ if (!(rf in item)) {
101
+ const err = new Error(`Item at index ${i} missing required field: "${rf}"`);
102
+ err.code = 'TEST_MATRIX_INVALID_SHAPE';
103
+ throw err;
104
+ }
105
+ if (typeof item[rf] !== 'string' || item[rf].trim().length === 0) {
106
+ const err = new Error(`Item at index ${i} field "${rf}" must be a non-empty string`);
107
+ err.code = 'TEST_MATRIX_INVALID_SHAPE';
108
+ throw err;
109
+ }
110
+ }
111
+
112
+ // Validate ID regex
113
+ if (!CANONICAL_ID_REGEX.test(item.id)) {
114
+ const err = new Error(`Item at index ${i} has invalid ID "${item.id}". Must match ^TEST-[A-Z0-9]+-[A-Z0-9]+$`);
115
+ err.code = 'TEST_MATRIX_INVALID_SHAPE';
116
+ throw err;
117
+ }
118
+
119
+ // Validate duplicate ID
120
+ if (seenIds.has(item.id)) {
121
+ const err = new Error(`Duplicate test ID detected in gemstack-test-matrix: "${item.id}"`);
122
+ err.code = 'TEST_MATRIX_DUPLICATE_ID';
123
+ throw err;
124
+ }
125
+ seenIds.add(item.id);
126
+
127
+ // Validate enum fields
128
+ if (!CANONICAL_LAYERS.includes(item.layer)) {
129
+ const err = new Error(`Item "${item.id}" has invalid layer "${item.layer}". Must be one of: ${CANONICAL_LAYERS.join(', ')}`);
130
+ err.code = 'TEST_MATRIX_INVALID_SHAPE';
131
+ throw err;
132
+ }
133
+
134
+ if (!CANONICAL_GATES.includes(item.gate)) {
135
+ const err = new Error(`Item "${item.id}" has invalid gate "${item.gate}". Must be one of: ${CANONICAL_GATES.join(', ')}`);
136
+ err.code = 'TEST_MATRIX_INVALID_SHAPE';
137
+ throw err;
138
+ }
139
+
140
+ validated.push({
141
+ id: item.id,
142
+ category: item.category,
143
+ layer: item.layer,
144
+ description: item.description,
145
+ pass_criteria: item.pass_criteria,
146
+ gate: item.gate
147
+ });
148
+ }
149
+
150
+ return validated;
151
+ }
152
+
153
+ /**
154
+ * Computes deterministic SHA-256 acceptanceSignature digest over canonical test matrix.
155
+ *
156
+ * @param {Array<object>} matrix
157
+ * @returns {string} 64-character lowercase hexadecimal digest
158
+ */
159
+ function computeAcceptanceSignature(matrix) {
160
+ const validated = validateTestMatrix(matrix);
161
+
162
+ // Sort canonical records by id using deterministic code-unit ordering
163
+ const sorted = [...validated].sort((a, b) => (a.id < b.id ? -1 : (a.id > b.id ? 1 : 0)));
164
+
165
+ // Normalize each record with ASCII-sorted keys: category, description, gate, id, layer, pass_criteria
166
+ const normalizedRecords = sorted.map(rec => ({
167
+ category: rec.category,
168
+ description: rec.description,
169
+ gate: rec.gate,
170
+ id: rec.id,
171
+ layer: rec.layer,
172
+ pass_criteria: rec.pass_criteria
173
+ }));
174
+
175
+ const canonicalJson = JSON.stringify(normalizedRecords);
176
+ return crypto.createHash('sha256').update(canonicalJson, 'utf8').digest('hex');
177
+ }
178
+
179
+ module.exports = {
180
+ CANONICAL_LAYERS,
181
+ CANONICAL_GATES,
182
+ CANONICAL_ID_REGEX,
183
+ REQUIRED_FIELDS,
184
+ extractTestMatrixBlock,
185
+ validateTestMatrix,
186
+ computeAcceptanceSignature
187
+ };