@hustle-together/api-dev-tools 3.0.0 → 3.2.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 (38) hide show
  1. package/README.md +71 -0
  2. package/bin/cli.js +184 -14
  3. package/demo/audio/generate-all-narrations.js +124 -59
  4. package/demo/audio/generate-narration.js +120 -56
  5. package/demo/audio/narration-adam-timing.json +3086 -2077
  6. package/demo/audio/narration-adam.mp3 +0 -0
  7. package/demo/audio/narration-creature-timing.json +3094 -2085
  8. package/demo/audio/narration-creature.mp3 +0 -0
  9. package/demo/audio/narration-gaming-timing.json +3091 -2082
  10. package/demo/audio/narration-gaming.mp3 +0 -0
  11. package/demo/audio/narration-hope-timing.json +3072 -2063
  12. package/demo/audio/narration-hope.mp3 +0 -0
  13. package/demo/audio/narration-mark-timing.json +3090 -2081
  14. package/demo/audio/narration-mark.mp3 +0 -0
  15. package/demo/audio/voices-manifest.json +16 -16
  16. package/demo/workflow-demo.html +1528 -411
  17. package/hooks/api-workflow-check.py +2 -0
  18. package/hooks/enforce-deep-research.py +180 -0
  19. package/hooks/enforce-disambiguation.py +149 -0
  20. package/hooks/enforce-documentation.py +187 -0
  21. package/hooks/enforce-environment.py +249 -0
  22. package/hooks/enforce-interview.py +64 -1
  23. package/hooks/enforce-refactor.py +187 -0
  24. package/hooks/enforce-research.py +93 -46
  25. package/hooks/enforce-schema.py +186 -0
  26. package/hooks/enforce-scope.py +156 -0
  27. package/hooks/enforce-tdd-red.py +246 -0
  28. package/hooks/enforce-verify.py +186 -0
  29. package/hooks/verify-after-green.py +136 -6
  30. package/package.json +2 -1
  31. package/scripts/collect-test-results.ts +404 -0
  32. package/scripts/extract-parameters.ts +483 -0
  33. package/scripts/generate-test-manifest.ts +520 -0
  34. package/templates/CLAUDE-SECTION.md +84 -0
  35. package/templates/api-dev-state.json +45 -5
  36. package/templates/api-test/page.tsx +315 -0
  37. package/templates/api-test/test-structure/route.ts +269 -0
  38. package/templates/settings.json +36 -0
@@ -0,0 +1,520 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Generate Test Manifest Script
4
+ *
5
+ * Programmatically generates api-tests-manifest.json by parsing:
6
+ * 1. Vitest test files (*.test.ts, *.spec.ts)
7
+ * 2. Zod schemas (for parameter extraction)
8
+ * 3. API route files (for endpoint metadata)
9
+ * 4. Interview state file (.claude/api-dev-state.json)
10
+ *
11
+ * IMPORTANT: This is 100% programmatic - NO LLM involvement.
12
+ * Tests are the SOURCE OF TRUTH.
13
+ *
14
+ * @generated by @hustle-together/api-dev-tools v3.0
15
+ */
16
+
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+
20
+ // ============================================
21
+ // Types
22
+ // ============================================
23
+
24
+ interface TestCase {
25
+ name: string;
26
+ line: number;
27
+ description?: string;
28
+ }
29
+
30
+ interface TestGroup {
31
+ name: string;
32
+ tests: TestCase[];
33
+ groups: TestGroup[];
34
+ }
35
+
36
+ interface ParameterInfo {
37
+ name: string;
38
+ type: string;
39
+ required: boolean;
40
+ description?: string;
41
+ default?: unknown;
42
+ enum?: string[];
43
+ }
44
+
45
+ interface EndpointManifest {
46
+ id: string;
47
+ name: string;
48
+ endpoint: string;
49
+ method: string;
50
+ description: string;
51
+ category: string;
52
+ parameters: {
53
+ query?: ParameterInfo[];
54
+ body?: ParameterInfo[];
55
+ headers?: ParameterInfo[];
56
+ };
57
+ responses: {
58
+ success: { status: number; description: string };
59
+ error: { status: number; description: string }[];
60
+ };
61
+ testFile: string;
62
+ testCount: number;
63
+ testCases: string[];
64
+ interviewDecisions?: Record<string, string>;
65
+ generatedAt: string;
66
+ }
67
+
68
+ interface ManifestOutput {
69
+ version: string;
70
+ generatedAt: string;
71
+ endpoints: EndpointManifest[];
72
+ summary: {
73
+ totalEndpoints: number;
74
+ totalTests: number;
75
+ categories: string[];
76
+ };
77
+ }
78
+
79
+ // ============================================
80
+ // File Discovery
81
+ // ============================================
82
+
83
+ function findFiles(baseDir: string, pattern: RegExp, exclude: string[] = ['node_modules', '.git', 'dist']): string[] {
84
+ const files: string[] = [];
85
+
86
+ function walk(dir: string) {
87
+ try {
88
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
89
+ for (const entry of entries) {
90
+ if (exclude.includes(entry.name)) continue;
91
+
92
+ const fullPath = path.join(dir, entry.name);
93
+ if (entry.isDirectory()) {
94
+ walk(fullPath);
95
+ } else if (entry.isFile() && pattern.test(entry.name)) {
96
+ files.push(fullPath);
97
+ }
98
+ }
99
+ } catch {
100
+ // Skip directories we can't read
101
+ }
102
+ }
103
+
104
+ walk(baseDir);
105
+ return files;
106
+ }
107
+
108
+ // ============================================
109
+ // Test File Parser
110
+ // ============================================
111
+
112
+ function parseTestFile(filePath: string): { groups: TestGroup[]; endpoint?: string; method?: string } {
113
+ const content = fs.readFileSync(filePath, 'utf-8');
114
+ const lines = content.split('\n');
115
+
116
+ const rootGroups: TestGroup[] = [];
117
+ const groupStack: TestGroup[] = [];
118
+
119
+ let braceCount = 0;
120
+ let describeStartBrace = 0;
121
+
122
+ // Extract endpoint from test file comments or describe blocks
123
+ let endpoint: string | undefined;
124
+ let method: string | undefined;
125
+
126
+ // Look for endpoint pattern in describe or comments
127
+ const endpointMatch = content.match(/(?:\/api\/[\w\/-]+)/);
128
+ if (endpointMatch) {
129
+ endpoint = endpointMatch[0];
130
+ }
131
+
132
+ // Look for HTTP method
133
+ const methodMatch = content.match(/(?:GET|POST|PUT|DELETE|PATCH)\s*(?:request|endpoint)?/i);
134
+ if (methodMatch) {
135
+ method = methodMatch[0].split(/\s/)[0].toUpperCase();
136
+ }
137
+
138
+ for (let i = 0; i < lines.length; i++) {
139
+ const line = lines[i];
140
+ const lineNum = i + 1;
141
+
142
+ // Match describe blocks
143
+ const describeMatch = line.match(/describe\s*\(\s*['"`]([^'"`]+)['"`]/);
144
+ if (describeMatch) {
145
+ const group: TestGroup = {
146
+ name: describeMatch[1],
147
+ tests: [],
148
+ groups: []
149
+ };
150
+
151
+ if (groupStack.length > 0) {
152
+ groupStack[groupStack.length - 1].groups.push(group);
153
+ } else {
154
+ rootGroups.push(group);
155
+ }
156
+
157
+ groupStack.push(group);
158
+ describeStartBrace = braceCount;
159
+ }
160
+
161
+ // Match it/test blocks
162
+ const testMatch = line.match(/(?:it|test)\s*\(\s*['"`]([^'"`]+)['"`]/);
163
+ if (testMatch && groupStack.length > 0) {
164
+ const testCase: TestCase = {
165
+ name: testMatch[1],
166
+ line: lineNum
167
+ };
168
+
169
+ // Extract description from test name
170
+ if (testCase.name.includes('should')) {
171
+ testCase.description = testCase.name;
172
+ }
173
+
174
+ groupStack[groupStack.length - 1].tests.push(testCase);
175
+ }
176
+
177
+ // Track braces for scope
178
+ const openBraces = (line.match(/{/g) || []).length;
179
+ const closeBraces = (line.match(/}/g) || []).length;
180
+ braceCount += openBraces - closeBraces;
181
+
182
+ // Pop stack when scope closes
183
+ if (braceCount <= describeStartBrace && groupStack.length > 0) {
184
+ groupStack.pop();
185
+ if (groupStack.length > 0) {
186
+ describeStartBrace = braceCount;
187
+ }
188
+ }
189
+ }
190
+
191
+ return { groups: rootGroups, endpoint, method };
192
+ }
193
+
194
+ // ============================================
195
+ // Zod Schema Parser
196
+ // ============================================
197
+
198
+ function parseZodSchema(filePath: string): ParameterInfo[] {
199
+ const content = fs.readFileSync(filePath, 'utf-8');
200
+ const parameters: ParameterInfo[] = [];
201
+
202
+ // Match z.object({ ... }) blocks
203
+ const objectMatch = content.match(/z\.object\s*\(\s*\{([^}]+)\}/gs);
204
+ if (!objectMatch) return parameters;
205
+
206
+ for (const block of objectMatch) {
207
+ // Extract individual field definitions
208
+ // Pattern: fieldName: z.type().modifier()
209
+ const fieldRegex = /(\w+)\s*:\s*z\.(\w+)\s*\(\s*([^)]*)\s*\)([^,\n]*)/g;
210
+ let match;
211
+
212
+ while ((match = fieldRegex.exec(block)) !== null) {
213
+ const [, name, type, typeArgs, modifiers] = match;
214
+
215
+ const param: ParameterInfo = {
216
+ name,
217
+ type: mapZodType(type),
218
+ required: !modifiers.includes('.optional()') && !modifiers.includes('.nullable()')
219
+ };
220
+
221
+ // Extract description from .describe()
222
+ const descMatch = modifiers.match(/\.describe\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/);
223
+ if (descMatch) {
224
+ param.description = descMatch[1];
225
+ }
226
+
227
+ // Extract default value
228
+ const defaultMatch = modifiers.match(/\.default\s*\(\s*([^)]+)\s*\)/);
229
+ if (defaultMatch) {
230
+ try {
231
+ param.default = JSON.parse(defaultMatch[1]);
232
+ } catch {
233
+ param.default = defaultMatch[1];
234
+ }
235
+ }
236
+
237
+ // Extract enum values for z.enum()
238
+ if (type === 'enum' && typeArgs) {
239
+ const enumMatch = typeArgs.match(/\[([^\]]+)\]/);
240
+ if (enumMatch) {
241
+ param.enum = enumMatch[1]
242
+ .split(',')
243
+ .map(s => s.trim().replace(/['"`]/g, ''));
244
+ }
245
+ }
246
+
247
+ parameters.push(param);
248
+ }
249
+ }
250
+
251
+ return parameters;
252
+ }
253
+
254
+ function mapZodType(zodType: string): string {
255
+ const typeMap: Record<string, string> = {
256
+ 'string': 'string',
257
+ 'number': 'number',
258
+ 'boolean': 'boolean',
259
+ 'array': 'array',
260
+ 'object': 'object',
261
+ 'enum': 'enum',
262
+ 'literal': 'literal',
263
+ 'union': 'union',
264
+ 'date': 'date',
265
+ 'any': 'any',
266
+ 'unknown': 'unknown',
267
+ 'null': 'null',
268
+ 'undefined': 'undefined',
269
+ 'void': 'void',
270
+ 'never': 'never',
271
+ 'bigint': 'bigint',
272
+ 'symbol': 'symbol'
273
+ };
274
+ return typeMap[zodType] || zodType;
275
+ }
276
+
277
+ // ============================================
278
+ // Route File Parser
279
+ // ============================================
280
+
281
+ function parseRouteFile(filePath: string): { methods: string[]; description?: string } {
282
+ const content = fs.readFileSync(filePath, 'utf-8');
283
+ const methods: string[] = [];
284
+
285
+ // Find exported HTTP method handlers
286
+ const methodPatterns = [
287
+ /export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH)/g,
288
+ /export\s+const\s+(GET|POST|PUT|DELETE|PATCH)\s*=/g
289
+ ];
290
+
291
+ for (const pattern of methodPatterns) {
292
+ let match;
293
+ while ((match = pattern.exec(content)) !== null) {
294
+ if (!methods.includes(match[1])) {
295
+ methods.push(match[1]);
296
+ }
297
+ }
298
+ }
299
+
300
+ // Extract description from JSDoc
301
+ const jsdocMatch = content.match(/\/\*\*\s*\n\s*\*\s*([^\n*]+)/);
302
+ const description = jsdocMatch ? jsdocMatch[1].trim() : undefined;
303
+
304
+ return { methods, description };
305
+ }
306
+
307
+ // ============================================
308
+ // Interview State Reader
309
+ // ============================================
310
+
311
+ interface InterviewState {
312
+ endpoint: string;
313
+ decisions: Record<string, string>;
314
+ phase: number;
315
+ }
316
+
317
+ function readInterviewState(baseDir: string): Map<string, Record<string, string>> {
318
+ const stateFile = path.join(baseDir, '.claude', 'api-dev-state.json');
319
+ const decisions = new Map<string, Record<string, string>>();
320
+
321
+ if (!fs.existsSync(stateFile)) {
322
+ return decisions;
323
+ }
324
+
325
+ try {
326
+ const content = fs.readFileSync(stateFile, 'utf-8');
327
+ const state = JSON.parse(content);
328
+
329
+ if (Array.isArray(state.endpoints)) {
330
+ for (const ep of state.endpoints) {
331
+ if (ep.endpoint && ep.decisions) {
332
+ decisions.set(ep.endpoint, ep.decisions);
333
+ }
334
+ }
335
+ }
336
+ } catch {
337
+ // Invalid state file, return empty
338
+ }
339
+
340
+ return decisions;
341
+ }
342
+
343
+ // ============================================
344
+ // Main Generator
345
+ // ============================================
346
+
347
+ function generateManifest(baseDir: string): ManifestOutput {
348
+ console.log('🔍 Scanning for test files...');
349
+
350
+ // Find all test files
351
+ const testFiles = findFiles(baseDir, /\.(test|spec)\.(ts|tsx)$/);
352
+ console.log(` Found ${testFiles.length} test files`);
353
+
354
+ // Find all schema files
355
+ const schemaFiles = findFiles(baseDir, /schema.*\.ts$/);
356
+ console.log(` Found ${schemaFiles.length} schema files`);
357
+
358
+ // Read interview decisions
359
+ const interviewDecisions = readInterviewState(baseDir);
360
+ console.log(` Found ${interviewDecisions.size} interview records`);
361
+
362
+ const endpoints: EndpointManifest[] = [];
363
+ const categories = new Set<string>();
364
+ let totalTests = 0;
365
+
366
+ for (const testFile of testFiles) {
367
+ const relativePath = path.relative(baseDir, testFile);
368
+ console.log(`\n📄 Processing: ${relativePath}`);
369
+
370
+ // Parse test file
371
+ const { groups, endpoint, method } = parseTestFile(testFile);
372
+
373
+ // Count tests and extract names
374
+ const testCases: string[] = [];
375
+ function countTests(grps: TestGroup[]): number {
376
+ let count = 0;
377
+ for (const g of grps) {
378
+ for (const t of g.tests) {
379
+ count++;
380
+ testCases.push(t.name);
381
+ }
382
+ count += countTests(g.groups);
383
+ }
384
+ return count;
385
+ }
386
+ const testCount = countTests(groups);
387
+ totalTests += testCount;
388
+
389
+ if (!endpoint) {
390
+ console.log(` ⚠️ No endpoint detected, skipping`);
391
+ continue;
392
+ }
393
+
394
+ // Determine category from path
395
+ const pathParts = relativePath.split(path.sep);
396
+ let category = 'General';
397
+ if (pathParts.includes('api')) {
398
+ const apiIndex = pathParts.indexOf('api');
399
+ if (apiIndex + 1 < pathParts.length) {
400
+ category = pathParts[apiIndex + 1];
401
+ }
402
+ }
403
+ categories.add(category);
404
+
405
+ // Find matching route file
406
+ const routeDir = path.dirname(testFile).replace('__tests__', '').replace('.test', '');
407
+ const possibleRoutes = [
408
+ path.join(routeDir, 'route.ts'),
409
+ path.join(routeDir, 'route.tsx'),
410
+ testFile.replace(/\.(test|spec)\.(ts|tsx)$/, '.ts')
411
+ ];
412
+
413
+ let routeInfo = { methods: [method || 'GET'], description: undefined as string | undefined };
414
+ for (const routePath of possibleRoutes) {
415
+ if (fs.existsSync(routePath)) {
416
+ routeInfo = parseRouteFile(routePath);
417
+ break;
418
+ }
419
+ }
420
+
421
+ // Find matching schema file
422
+ const schemaBaseName = path.basename(testFile).replace(/\.(test|spec)\.(ts|tsx)$/, '');
423
+ const matchingSchemas = schemaFiles.filter(s =>
424
+ s.includes(schemaBaseName) || s.includes(category.toLowerCase())
425
+ );
426
+
427
+ let parameters: ParameterInfo[] = [];
428
+ for (const schemaFile of matchingSchemas) {
429
+ parameters = [...parameters, ...parseZodSchema(schemaFile)];
430
+ }
431
+
432
+ // Get interview decisions if available
433
+ const decisions = interviewDecisions.get(endpoint);
434
+
435
+ // Generate endpoint ID
436
+ const endpointId = endpoint
437
+ .replace(/^\/api\//, '')
438
+ .replace(/\//g, '-')
439
+ .replace(/[^a-z0-9-]/gi, '');
440
+
441
+ const manifest: EndpointManifest = {
442
+ id: endpointId,
443
+ name: groups[0]?.name || endpointId,
444
+ endpoint,
445
+ method: routeInfo.methods[0] || method || 'GET',
446
+ description: routeInfo.description || `API endpoint: ${endpoint}`,
447
+ category,
448
+ parameters: {
449
+ query: parameters.filter(p => !p.name.startsWith('body')),
450
+ body: parameters.filter(p => p.name.startsWith('body') || p.name === 'data'),
451
+ headers: []
452
+ },
453
+ responses: {
454
+ success: { status: 200, description: 'Successful response' },
455
+ error: [
456
+ { status: 400, description: 'Bad request' },
457
+ { status: 500, description: 'Internal server error' }
458
+ ]
459
+ },
460
+ testFile: relativePath,
461
+ testCount,
462
+ testCases,
463
+ interviewDecisions: decisions,
464
+ generatedAt: new Date().toISOString()
465
+ };
466
+
467
+ endpoints.push(manifest);
468
+ console.log(` ✅ Generated manifest for ${endpoint} (${testCount} tests)`);
469
+ }
470
+
471
+ return {
472
+ version: '3.0.0',
473
+ generatedAt: new Date().toISOString(),
474
+ endpoints,
475
+ summary: {
476
+ totalEndpoints: endpoints.length,
477
+ totalTests,
478
+ categories: Array.from(categories)
479
+ }
480
+ };
481
+ }
482
+
483
+ // ============================================
484
+ // CLI Entry Point
485
+ // ============================================
486
+
487
+ function main() {
488
+ const args = process.argv.slice(2);
489
+ const baseDir = args[0] || process.cwd();
490
+ const outputPath = args[1] || path.join(baseDir, 'src', 'app', 'api-test', 'api-tests-manifest.json');
491
+
492
+ console.log('═══════════════════════════════════════════════════════════════');
493
+ console.log(' 📋 API Test Manifest Generator');
494
+ console.log(' @hustle-together/api-dev-tools v3.0');
495
+ console.log('═══════════════════════════════════════════════════════════════');
496
+ console.log(`\n📁 Base directory: ${baseDir}`);
497
+ console.log(`📄 Output file: ${outputPath}\n`);
498
+
499
+ const manifest = generateManifest(baseDir);
500
+
501
+ // Ensure output directory exists
502
+ const outputDir = path.dirname(outputPath);
503
+ if (!fs.existsSync(outputDir)) {
504
+ fs.mkdirSync(outputDir, { recursive: true });
505
+ }
506
+
507
+ // Write manifest
508
+ fs.writeFileSync(outputPath, JSON.stringify(manifest, null, 2));
509
+
510
+ console.log('\n═══════════════════════════════════════════════════════════════');
511
+ console.log(' ✅ Manifest generated successfully!');
512
+ console.log('═══════════════════════════════════════════════════════════════');
513
+ console.log(`\n📊 Summary:`);
514
+ console.log(` • Endpoints: ${manifest.summary.totalEndpoints}`);
515
+ console.log(` • Tests: ${manifest.summary.totalTests}`);
516
+ console.log(` • Categories: ${manifest.summary.categories.join(', ')}`);
517
+ console.log(`\n📄 Output: ${outputPath}\n`);
518
+ }
519
+
520
+ main();
@@ -0,0 +1,84 @@
1
+ ## API Development Workflow (v3.0)
2
+
3
+ This project uses **@hustle-together/api-dev-tools** for interview-driven, research-first API development.
4
+
5
+ ### Available Commands
6
+
7
+ | Command | Purpose |
8
+ |---------|---------|
9
+ | `/api-create [endpoint]` | Complete 12-phase workflow |
10
+ | `/api-interview [endpoint]` | Questions FROM research findings |
11
+ | `/api-research [library]` | Adaptive propose-approve research |
12
+ | `/api-verify [endpoint]` | Re-research and verify implementation |
13
+ | `/api-env [endpoint]` | Check API keys |
14
+ | `/api-status [endpoint]` | Track progress |
15
+
16
+ ### 12-Phase Flow
17
+
18
+ ```
19
+ Phase 0: DISAMBIGUATION - Clarify ambiguous terms before research
20
+ Phase 1: SCOPE - Confirm understanding of endpoint
21
+ Phase 2: INITIAL RESEARCH - 2-3 targeted searches (Context7, WebSearch)
22
+ Phase 3: INTERVIEW - Questions generated FROM discovered params
23
+ Phase 4: DEEP RESEARCH - Propose additional searches based on answers
24
+ Phase 5: SCHEMA - Create Zod schema from research + interview
25
+ Phase 6: ENVIRONMENT - Verify API keys exist
26
+ Phase 7: TDD RED - Write failing tests from schema
27
+ Phase 8: TDD GREEN - Minimal implementation to pass tests
28
+ Phase 9: VERIFY - Re-research docs, compare to implementation
29
+ Phase 10: TDD REFACTOR - Clean up code while tests pass
30
+ Phase 11: DOCUMENTATION - Update manifests, cache research
31
+ Phase 12: COMPLETION - Final verification, commit
32
+ ```
33
+
34
+ ### Key Principles
35
+
36
+ 1. **Loop Until Green** - Every verification phase loops back if not successful
37
+ 2. **Questions FROM Research** - Never use generic template questions
38
+ 3. **Adaptive Research** - Propose searches based on context, not shotgun
39
+ 4. **7-Turn Re-grounding** - Context injected every 7 turns to prevent dilution
40
+ 5. **Verify After Green** - Re-research to catch memory-based implementation errors
41
+
42
+ ### State Tracking
43
+
44
+ All progress is tracked in `.claude/api-dev-state.json`:
45
+ - Current phase and status for each
46
+ - Interview decisions (injected during implementation)
47
+ - Research sources with freshness tracking
48
+ - Turn count for re-grounding
49
+
50
+ ### Research Cache
51
+
52
+ Research is cached in `.claude/research/` with 7-day freshness:
53
+ - `index.json` - Freshness tracking
54
+ - `[api-name]/CURRENT.md` - Latest research
55
+ - Stale research (>7 days) triggers re-research prompt
56
+
57
+ ### Hooks (Automatic Enforcement)
58
+
59
+ | Hook | When | Action |
60
+ |------|------|--------|
61
+ | `session-startup.py` | Session start | Inject state context |
62
+ | `enforce-external-research.py` | API questions | Require research first |
63
+ | `enforce-research.py` | Write/Edit | Block without research |
64
+ | `enforce-interview.py` | Write/Edit | Inject interview decisions |
65
+ | `verify-after-green.py` | Tests pass | Trigger Phase 9 |
66
+ | `periodic-reground.py` | Every 7 turns | Re-inject context |
67
+ | `api-workflow-check.py` | Stop | Block if incomplete |
68
+
69
+ ### Usage
70
+
71
+ ```bash
72
+ # Full automated workflow
73
+ /api-create my-endpoint
74
+
75
+ # Manual step-by-step
76
+ /api-research [library]
77
+ /api-interview [endpoint]
78
+ /api-env [endpoint]
79
+ /red
80
+ /green
81
+ /api-verify [endpoint]
82
+ /refactor
83
+ /commit
84
+ ```