ripp-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/build.js ADDED
@@ -0,0 +1,338 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const yaml = require('js-yaml');
4
+
5
+ /**
6
+ * RIPP Build - Canonical Artifact Compilation
7
+ * Deterministic compilation of confirmed intent into canonical RIPP artifacts
8
+ */
9
+
10
+ /**
11
+ * Build canonical RIPP artifacts from confirmed intent
12
+ */
13
+ function buildCanonicalArtifacts(cwd, options = {}) {
14
+ const confirmedPath = path.join(cwd, '.ripp', 'intent.confirmed.yaml');
15
+
16
+ if (!fs.existsSync(confirmedPath)) {
17
+ throw new Error('No confirmed intent found. Run "ripp confirm" first.');
18
+ }
19
+
20
+ const confirmedContent = fs.readFileSync(confirmedPath, 'utf8');
21
+ const confirmed = yaml.load(confirmedContent);
22
+
23
+ if (!confirmed.confirmed || confirmed.confirmed.length === 0) {
24
+ throw new Error('No confirmed intent blocks found');
25
+ }
26
+
27
+ // Build RIPP packet from confirmed intent
28
+ const packet = buildRippPacket(confirmed, options);
29
+
30
+ // Validate the packet
31
+ const validation = validatePacket(packet);
32
+ if (!validation.valid) {
33
+ throw new Error(`Generated packet failed validation: ${validation.errors.join(', ')}`);
34
+ }
35
+
36
+ // Write canonical packet
37
+ const packetPath = path.join(cwd, '.ripp', options.outputName || 'handoff.ripp.yaml');
38
+ fs.writeFileSync(packetPath, yaml.dump(packet, { indent: 2, lineWidth: 100 }), 'utf8');
39
+
40
+ // Generate handoff.ripp.md (consolidated markdown)
41
+ const markdown = generateHandoffMarkdown(packet, confirmed);
42
+ const markdownPath = path.join(cwd, '.ripp', 'handoff.ripp.md');
43
+ fs.writeFileSync(markdownPath, markdown, 'utf8');
44
+
45
+ return {
46
+ packetPath,
47
+ markdownPath,
48
+ packet,
49
+ level: packet.level
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Build RIPP packet from confirmed intent blocks
55
+ */
56
+ function buildRippPacket(confirmed, options) {
57
+ const now = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
58
+
59
+ // Initialize packet with required metadata
60
+ const packet = {
61
+ ripp_version: '1.0',
62
+ packet_id: options.packetId || 'discovered-intent',
63
+ title: options.title || 'Discovered Intent',
64
+ created: now,
65
+ updated: now,
66
+ status: 'draft',
67
+ level: determineLevel(confirmed.confirmed),
68
+ purpose: null,
69
+ ux_flow: null,
70
+ data_contracts: null
71
+ };
72
+
73
+ // Add metadata about generation
74
+ packet.metadata = {
75
+ generated_from: 'ripp-discovery',
76
+ generated_at: new Date().toISOString(),
77
+ source: 'confirmed-intent',
78
+ total_confirmed_blocks: confirmed.confirmed.length
79
+ };
80
+
81
+ // Populate sections from confirmed intent
82
+ confirmed.confirmed.forEach(block => {
83
+ const section = block.section;
84
+ const content = block.content;
85
+
86
+ switch (section) {
87
+ case 'purpose':
88
+ packet.purpose = content;
89
+ break;
90
+ case 'ux_flow':
91
+ packet.ux_flow = Array.isArray(content) ? content : [content];
92
+ break;
93
+ case 'data_contracts':
94
+ packet.data_contracts = content;
95
+ break;
96
+ case 'api_contracts':
97
+ packet.api_contracts = Array.isArray(content) ? content : [content];
98
+ break;
99
+ case 'permissions':
100
+ packet.permissions = Array.isArray(content) ? content : [content];
101
+ break;
102
+ case 'failure_modes':
103
+ packet.failure_modes = Array.isArray(content) ? content : [content];
104
+ break;
105
+ case 'audit_events':
106
+ packet.audit_events = Array.isArray(content) ? content : [content];
107
+ break;
108
+ case 'nfrs':
109
+ packet.nfrs = content;
110
+ break;
111
+ case 'acceptance_tests':
112
+ packet.acceptance_tests = Array.isArray(content) ? content : [content];
113
+ break;
114
+ }
115
+ });
116
+
117
+ // Ensure required sections exist (add placeholders if missing)
118
+ if (!packet.purpose) {
119
+ packet.purpose = {
120
+ problem: 'TODO: Define the problem being solved',
121
+ solution: 'TODO: Describe the solution approach',
122
+ value: 'TODO: Specify the value delivered'
123
+ };
124
+ }
125
+
126
+ if (!packet.ux_flow || packet.ux_flow.length === 0) {
127
+ packet.ux_flow = [
128
+ {
129
+ step: 1,
130
+ actor: 'User',
131
+ action: 'TODO: Define user interaction',
132
+ trigger: 'TODO: Define trigger'
133
+ }
134
+ ];
135
+ }
136
+
137
+ if (!packet.data_contracts) {
138
+ packet.data_contracts = {
139
+ inputs: [],
140
+ outputs: []
141
+ };
142
+ }
143
+
144
+ return packet;
145
+ }
146
+
147
+ /**
148
+ * Determine RIPP level based on confirmed sections
149
+ */
150
+ function determineLevel(confirmed) {
151
+ const sections = new Set(confirmed.map(b => b.section));
152
+
153
+ // Level 3 requirements
154
+ if (sections.has('audit_events') || sections.has('nfrs') || sections.has('acceptance_tests')) {
155
+ return 3;
156
+ }
157
+
158
+ // Level 2 requirements
159
+ if (
160
+ sections.has('api_contracts') ||
161
+ sections.has('permissions') ||
162
+ sections.has('failure_modes')
163
+ ) {
164
+ return 2;
165
+ }
166
+
167
+ // Default to Level 1
168
+ return 1;
169
+ }
170
+
171
+ /**
172
+ * Basic validation of generated packet
173
+ */
174
+ function validatePacket(packet) {
175
+ const errors = [];
176
+
177
+ // Required fields
178
+ if (!packet.ripp_version) errors.push('Missing ripp_version');
179
+ if (!packet.packet_id) errors.push('Missing packet_id');
180
+ if (!packet.title) errors.push('Missing title');
181
+ if (!packet.created) errors.push('Missing created');
182
+ if (!packet.updated) errors.push('Missing updated');
183
+ if (!packet.status) errors.push('Missing status');
184
+ if (!packet.level) errors.push('Missing level');
185
+
186
+ // Required sections
187
+ if (!packet.purpose) errors.push('Missing purpose section');
188
+ if (!packet.ux_flow) errors.push('Missing ux_flow section');
189
+ if (!packet.data_contracts) errors.push('Missing data_contracts section');
190
+
191
+ return {
192
+ valid: errors.length === 0,
193
+ errors
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Generate handoff.ripp.md (consolidated markdown output)
199
+ */
200
+ function generateHandoffMarkdown(packet, confirmed) {
201
+ let md = `# ${packet.title}\n\n`;
202
+ md += `**RIPP Packet** — Generated from Intent Discovery Mode\n\n`;
203
+ md += `- **Packet ID**: ${packet.packet_id}\n`;
204
+ md += `- **Level**: ${packet.level}\n`;
205
+ md += `- **Status**: ${packet.status}\n`;
206
+ md += `- **Generated**: ${packet.metadata.generated_at}\n\n`;
207
+
208
+ md += `---\n\n`;
209
+
210
+ // Purpose
211
+ md += `## Purpose\n\n`;
212
+ if (packet.purpose) {
213
+ md += `**Problem**: ${packet.purpose.problem}\n\n`;
214
+ md += `**Solution**: ${packet.purpose.solution}\n\n`;
215
+ md += `**Value**: ${packet.purpose.value}\n\n`;
216
+ }
217
+
218
+ // UX Flow
219
+ md += `## UX Flow\n\n`;
220
+ if (packet.ux_flow) {
221
+ packet.ux_flow.forEach(step => {
222
+ md += `### Step ${step.step}\n\n`;
223
+ md += `- **Actor**: ${step.actor}\n`;
224
+ md += `- **Action**: ${step.action}\n`;
225
+ if (step.trigger) md += `- **Trigger**: ${step.trigger}\n`;
226
+ if (step.result) md += `- **Result**: ${step.result}\n`;
227
+ md += `\n`;
228
+ });
229
+ }
230
+
231
+ // Data Contracts
232
+ md += `## Data Contracts\n\n`;
233
+ if (packet.data_contracts) {
234
+ md += `### Inputs\n\n`;
235
+ if (packet.data_contracts.inputs && packet.data_contracts.inputs.length > 0) {
236
+ packet.data_contracts.inputs.forEach(input => {
237
+ md += `#### ${input.name}\n\n`;
238
+ if (input.fields) {
239
+ input.fields.forEach(field => {
240
+ md += `- **${field.name}** (${field.type})${field.required ? ' *required*' : ''}: ${field.description || 'N/A'}\n`;
241
+ });
242
+ }
243
+ md += `\n`;
244
+ });
245
+ } else {
246
+ md += `*None defined*\n\n`;
247
+ }
248
+
249
+ md += `### Outputs\n\n`;
250
+ if (packet.data_contracts.outputs && packet.data_contracts.outputs.length > 0) {
251
+ packet.data_contracts.outputs.forEach(output => {
252
+ md += `#### ${output.name}\n\n`;
253
+ if (output.fields) {
254
+ output.fields.forEach(field => {
255
+ md += `- **${field.name}** (${field.type})${field.required ? ' *required*' : ''}: ${field.description || 'N/A'}\n`;
256
+ });
257
+ }
258
+ md += `\n`;
259
+ });
260
+ } else {
261
+ md += `*None defined*\n\n`;
262
+ }
263
+ }
264
+
265
+ // Level 2+ sections
266
+ if (packet.level >= 2) {
267
+ if (packet.api_contracts) {
268
+ md += `## API Contracts\n\n`;
269
+ packet.api_contracts.forEach(api => {
270
+ md += `### ${api.method} ${api.endpoint}\n\n`;
271
+ md += `**Purpose**: ${api.purpose || 'N/A'}\n\n`;
272
+ });
273
+ }
274
+
275
+ if (packet.permissions) {
276
+ md += `## Permissions\n\n`;
277
+ packet.permissions.forEach(perm => {
278
+ md += `- **${perm.action}**: ${perm.description || 'N/A'}\n`;
279
+ });
280
+ md += `\n`;
281
+ }
282
+
283
+ if (packet.failure_modes) {
284
+ md += `## Failure Modes\n\n`;
285
+ packet.failure_modes.forEach(fm => {
286
+ md += `- **${fm.scenario}**: ${fm.handling || 'N/A'}\n`;
287
+ });
288
+ md += `\n`;
289
+ }
290
+ }
291
+
292
+ // Level 3 sections
293
+ if (packet.level >= 3) {
294
+ if (packet.audit_events) {
295
+ md += `## Audit Events\n\n`;
296
+ packet.audit_events.forEach(event => {
297
+ md += `- **${event.event}** (${event.severity}): ${event.purpose || 'N/A'}\n`;
298
+ });
299
+ md += `\n`;
300
+ }
301
+
302
+ if (packet.nfrs) {
303
+ md += `## Non-Functional Requirements\n\n`;
304
+ md += `\`\`\`yaml\n${yaml.dump(packet.nfrs, { indent: 2 })}\`\`\`\n\n`;
305
+ }
306
+
307
+ if (packet.acceptance_tests) {
308
+ md += `## Acceptance Tests\n\n`;
309
+ packet.acceptance_tests.forEach(test => {
310
+ md += `### ${test.title}\n\n`;
311
+ md += `- **Given**: ${test.given}\n`;
312
+ md += `- **When**: ${test.when}\n`;
313
+ md += `- **Then**: ${test.then}\n\n`;
314
+ });
315
+ }
316
+ }
317
+
318
+ // Provenance
319
+ md += `---\n\n`;
320
+ md += `## Provenance\n\n`;
321
+ md += `This RIPP packet was generated using Intent Discovery Mode.\n\n`;
322
+ md += `- **Total Confirmed Blocks**: ${confirmed.confirmed.length}\n`;
323
+ md += `- **Generation Method**: AI-assisted with human confirmation\n`;
324
+ md += `- **Validation**: Passed schema validation at generation time\n\n`;
325
+
326
+ md += `### Confirmed Blocks\n\n`;
327
+ confirmed.confirmed.forEach((block, index) => {
328
+ md += `${index + 1}. **${block.section}** (confidence: ${(block.original_confidence * 100).toFixed(1)}%) - confirmed at ${block.confirmed_at}\n`;
329
+ });
330
+
331
+ return md;
332
+ }
333
+
334
+ module.exports = {
335
+ buildCanonicalArtifacts,
336
+ buildRippPacket,
337
+ generateHandoffMarkdown
338
+ };
package/lib/config.js ADDED
@@ -0,0 +1,277 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const yaml = require('js-yaml');
4
+ const Ajv = require('ajv');
5
+ const addFormats = require('ajv-formats');
6
+
7
+ /**
8
+ * RIPP Configuration Manager
9
+ * Handles loading and validation of .ripp/config.yaml with precedence rules
10
+ */
11
+
12
+ const DEFAULT_CONFIG = {
13
+ rippVersion: '1.0',
14
+ ai: {
15
+ enabled: false,
16
+ provider: 'openai',
17
+ model: 'gpt-4o-mini',
18
+ maxRetries: 3,
19
+ timeout: 30000
20
+ },
21
+ evidencePack: {
22
+ includeGlobs: ['src/**', 'app/**', 'api/**', 'db/**', '.github/workflows/**'],
23
+ excludeGlobs: [
24
+ '**/node_modules/**',
25
+ '**/dist/**',
26
+ '**/build/**',
27
+ '**/*.lock',
28
+ '**/.git/**',
29
+ '**/vendor/**',
30
+ '**/.ripp/**'
31
+ ],
32
+ maxFileSize: 1048576, // 1MB
33
+ secretPatterns: []
34
+ },
35
+ discovery: {
36
+ minConfidence: 0.5,
37
+ includeEvidence: true
38
+ }
39
+ };
40
+
41
+ /**
42
+ * Load RIPP configuration from .ripp/config.yaml
43
+ * Applies precedence: defaults < repo config < env vars
44
+ */
45
+ function loadConfig(cwd = process.cwd()) {
46
+ const configPath = path.join(cwd, '.ripp', 'config.yaml');
47
+
48
+ // Start with defaults
49
+ let config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
50
+
51
+ // Load repo config if exists
52
+ if (fs.existsSync(configPath)) {
53
+ try {
54
+ const fileContent = fs.readFileSync(configPath, 'utf8');
55
+ const repoConfig = yaml.load(fileContent);
56
+
57
+ // Merge with defaults
58
+ config = mergeConfig(config, repoConfig);
59
+
60
+ // Validate against schema
61
+ // Resolve schema path from project root (3 levels up from lib/)
62
+ const projectRoot = path.join(__dirname, '../../..');
63
+ const schemaPath = path.join(projectRoot, 'schema/ripp-config.schema.json');
64
+
65
+ if (!fs.existsSync(schemaPath)) {
66
+ throw new Error(`Schema file not found at: ${schemaPath}`);
67
+ }
68
+
69
+ const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
70
+
71
+ const ajv = new Ajv({ allErrors: true, strict: false });
72
+ addFormats(ajv);
73
+ const validate = ajv.compile(schema);
74
+ const valid = validate(config);
75
+
76
+ if (!valid) {
77
+ const errors = validate.errors.map(e => `${e.instancePath}: ${e.message}`).join(', ');
78
+ throw new Error(`Invalid .ripp/config.yaml: ${errors}`);
79
+ }
80
+ } catch (error) {
81
+ if (error.message.includes('Invalid .ripp/config.yaml')) {
82
+ throw error;
83
+ }
84
+ throw new Error(`Failed to parse .ripp/config.yaml: ${error.message}`);
85
+ }
86
+ }
87
+
88
+ // Apply environment variable overrides
89
+ config = applyEnvOverrides(config);
90
+
91
+ return config;
92
+ }
93
+
94
+ /**
95
+ * Merge configuration objects (deep merge for nested objects)
96
+ */
97
+ function mergeConfig(base, override) {
98
+ const result = { ...base };
99
+
100
+ for (const key in override) {
101
+ if (override[key] !== undefined && override[key] !== null) {
102
+ if (
103
+ typeof override[key] === 'object' &&
104
+ !Array.isArray(override[key]) &&
105
+ typeof base[key] === 'object' &&
106
+ !Array.isArray(base[key])
107
+ ) {
108
+ result[key] = mergeConfig(base[key] || {}, override[key]);
109
+ } else {
110
+ result[key] = override[key];
111
+ }
112
+ }
113
+ }
114
+
115
+ return result;
116
+ }
117
+
118
+ /**
119
+ * Apply environment variable overrides
120
+ * Env vars take precedence ONLY if ai.enabled=true in repo config
121
+ */
122
+ function applyEnvOverrides(config) {
123
+ const result = { ...config };
124
+
125
+ // AI configuration (only if enabled in repo)
126
+ if (result.ai.enabled) {
127
+ // RIPP_AI_ENABLED must also be true
128
+ const aiEnabledEnv = process.env.RIPP_AI_ENABLED;
129
+ if (aiEnabledEnv !== undefined) {
130
+ const enabled = aiEnabledEnv.toLowerCase() === 'true';
131
+ if (!enabled) {
132
+ // Env var explicitly disables AI
133
+ result.ai.enabled = false;
134
+ }
135
+ }
136
+
137
+ // Other AI settings (only if AI is enabled)
138
+ if (result.ai.enabled) {
139
+ if (process.env.RIPP_AI_PROVIDER) {
140
+ result.ai.provider = process.env.RIPP_AI_PROVIDER;
141
+ }
142
+ if (process.env.RIPP_AI_MODEL) {
143
+ result.ai.model = process.env.RIPP_AI_MODEL;
144
+ }
145
+ if (process.env.RIPP_AI_ENDPOINT) {
146
+ result.ai.customEndpoint = process.env.RIPP_AI_ENDPOINT;
147
+ }
148
+ if (process.env.RIPP_AI_MAX_RETRIES) {
149
+ result.ai.maxRetries = parseInt(process.env.RIPP_AI_MAX_RETRIES, 10);
150
+ }
151
+ if (process.env.RIPP_AI_TIMEOUT) {
152
+ result.ai.timeout = parseInt(process.env.RIPP_AI_TIMEOUT, 10);
153
+ }
154
+ }
155
+ } else {
156
+ // If ai.enabled=false in repo config, AI is ALWAYS OFF
157
+ result.ai.enabled = false;
158
+ }
159
+
160
+ return result;
161
+ }
162
+
163
+ /**
164
+ * Check if AI is enabled and properly configured
165
+ * Returns { enabled: boolean, reason: string }
166
+ */
167
+ function checkAiEnabled(config) {
168
+ if (!config.ai.enabled) {
169
+ return {
170
+ enabled: false,
171
+ reason: 'AI is disabled in .ripp/config.yaml (ai.enabled: false)'
172
+ };
173
+ }
174
+
175
+ // Check for runtime env var
176
+ const aiEnabledEnv = process.env.RIPP_AI_ENABLED;
177
+ if (aiEnabledEnv === undefined || aiEnabledEnv.toLowerCase() !== 'true') {
178
+ return {
179
+ enabled: false,
180
+ reason: 'AI is enabled in config but RIPP_AI_ENABLED env var is not set to "true"'
181
+ };
182
+ }
183
+
184
+ // Check for API key based on provider
185
+ const provider = config.ai.provider;
186
+ if (provider === 'openai') {
187
+ if (!process.env.OPENAI_API_KEY) {
188
+ return {
189
+ enabled: false,
190
+ reason: 'OPENAI_API_KEY environment variable is not set'
191
+ };
192
+ }
193
+ } else if (provider === 'azure-openai') {
194
+ if (!process.env.AZURE_OPENAI_API_KEY || !process.env.AZURE_OPENAI_ENDPOINT) {
195
+ return {
196
+ enabled: false,
197
+ reason: 'AZURE_OPENAI_API_KEY and AZURE_OPENAI_ENDPOINT environment variables are required'
198
+ };
199
+ }
200
+ } else if (provider === 'custom') {
201
+ if (!config.ai.customEndpoint) {
202
+ return {
203
+ enabled: false,
204
+ reason: 'Custom provider requires customEndpoint in config'
205
+ };
206
+ }
207
+ }
208
+
209
+ return { enabled: true, reason: '' };
210
+ }
211
+
212
+ /**
213
+ * Create default .ripp/config.yaml file
214
+ */
215
+ function createDefaultConfig(cwd = process.cwd(), options = {}) {
216
+ const rippDir = path.join(cwd, '.ripp');
217
+ const configPath = path.join(rippDir, 'config.yaml');
218
+
219
+ // Create .ripp directory if it doesn't exist
220
+ if (!fs.existsSync(rippDir)) {
221
+ fs.mkdirSync(rippDir, { recursive: true });
222
+ }
223
+
224
+ // Check if config already exists
225
+ if (fs.existsSync(configPath) && !options.force) {
226
+ return { created: false, path: configPath };
227
+ }
228
+
229
+ // Create config with helpful comments
230
+ const configContent = `# RIPP Configuration
231
+ # Version: 1.0
232
+ # Documentation: https://dylan-natter.github.io/ripp-protocol
233
+
234
+ rippVersion: "1.0"
235
+
236
+ # AI Configuration (vNext Intent Discovery Mode)
237
+ # AI is DISABLED BY DEFAULT for security and trust
238
+ ai:
239
+ enabled: false # Set to true AND set RIPP_AI_ENABLED=true to enable AI features
240
+ provider: openai # openai | azure-openai | ollama | custom
241
+ model: gpt-4o-mini # Model identifier
242
+
243
+ # Evidence Pack Configuration
244
+ evidencePack:
245
+ includeGlobs:
246
+ - "src/**"
247
+ - "app/**"
248
+ - "api/**"
249
+ - "db/**"
250
+ - ".github/workflows/**"
251
+ excludeGlobs:
252
+ - "**/node_modules/**"
253
+ - "**/dist/**"
254
+ - "**/build/**"
255
+ - "**/*.lock"
256
+ - "**/.git/**"
257
+ - "**/vendor/**"
258
+ - "**/.ripp/**"
259
+ maxFileSize: 1048576 # 1MB
260
+
261
+ # Discovery Configuration
262
+ discovery:
263
+ minConfidence: 0.5 # Minimum confidence for AI-inferred intent (0.0-1.0)
264
+ includeEvidence: true # Include evidence references in candidates
265
+ `;
266
+
267
+ fs.writeFileSync(configPath, configContent, 'utf8');
268
+
269
+ return { created: true, path: configPath };
270
+ }
271
+
272
+ module.exports = {
273
+ loadConfig,
274
+ checkAiEnabled,
275
+ createDefaultConfig,
276
+ DEFAULT_CONFIG
277
+ };