jfl 0.4.2 → 0.4.3

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.
@@ -0,0 +1,371 @@
1
+ name: JFL AI Review
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, synchronize]
6
+ branches: [main]
7
+
8
+ permissions:
9
+ contents: read
10
+ pull-requests: write
11
+
12
+ jobs:
13
+ review:
14
+ name: AI Code Review
15
+ runs-on: ubuntu-latest
16
+ timeout-minutes: 5
17
+ if: startsWith(github.head_ref, 'pp/') || contains(github.event.pull_request.labels.*.name, 'ai-review')
18
+
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ with:
22
+ fetch-depth: 0
23
+
24
+ - uses: actions/setup-node@v4
25
+ with:
26
+ node-version: '22'
27
+
28
+ - name: Gather project context
29
+ id: context
30
+ run: |
31
+ # Read project config
32
+ PROJECT_NAME=$(node -e "
33
+ try { console.log(JSON.parse(require('fs').readFileSync('.jfl/config.json','utf8')).name || 'unknown'); }
34
+ catch(e) { console.log('unknown'); }
35
+ ")
36
+ PROJECT_DESC=$(node -e "
37
+ try { console.log(JSON.parse(require('fs').readFileSync('.jfl/config.json','utf8')).description || ''); }
38
+ catch(e) { console.log(''); }
39
+ ")
40
+ echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
41
+
42
+ # Build project context from repo files
43
+ node -e "
44
+ const fs = require('fs');
45
+ const sections = [];
46
+
47
+ // Project identity
48
+ sections.push('# Project: ' + '$PROJECT_NAME');
49
+ if ('$PROJECT_DESC') sections.push('$PROJECT_DESC');
50
+ sections.push('');
51
+
52
+ // Architecture (tech stack, structure, patterns)
53
+ try {
54
+ const arch = fs.readFileSync('knowledge/ARCHITECTURE.md', 'utf8');
55
+ // Extract first 2000 chars (tech stack + structure)
56
+ sections.push('## Architecture');
57
+ sections.push(arch.substring(0, 2000));
58
+ sections.push('');
59
+ } catch(e) {}
60
+
61
+ // Service spec (what the project does)
62
+ try {
63
+ const spec = fs.readFileSync('knowledge/SERVICE_SPEC.md', 'utf8');
64
+ sections.push('## Service Spec');
65
+ sections.push(spec.substring(0, 1000));
66
+ sections.push('');
67
+ } catch(e) {}
68
+
69
+ // Recent journal entries (last 5 for active context)
70
+ try {
71
+ const { execSync } = require('child_process');
72
+ const journal = execSync('cat .jfl/journal/*.jsonl 2>/dev/null | tail -5', { encoding: 'utf8' });
73
+ if (journal.trim()) {
74
+ sections.push('## Recent Activity');
75
+ sections.push(journal.trim());
76
+ sections.push('');
77
+ }
78
+ } catch(e) {}
79
+
80
+ // Recent decisions
81
+ try {
82
+ const { execSync } = require('child_process');
83
+ const decisions = execSync('cat .jfl/journal/*.jsonl 2>/dev/null | grep decision | tail -3', { encoding: 'utf8' });
84
+ if (decisions.trim()) {
85
+ sections.push('## Recent Decisions');
86
+ sections.push(decisions.trim());
87
+ sections.push('');
88
+ }
89
+ } catch(e) {}
90
+
91
+ const context = sections.join('\n');
92
+ fs.writeFileSync('/tmp/project-context.txt', context);
93
+ console.log('Context size: ' + context.length + ' chars');
94
+ "
95
+
96
+ - name: Get PR diff and review
97
+ id: review
98
+ env:
99
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
100
+ OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
101
+ PR_TITLE: ${{ github.event.pull_request.title }}
102
+ PR_BODY: ${{ github.event.pull_request.body }}
103
+ run: |
104
+ # Get full diff
105
+ git diff origin/main...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' | head -4000 > /tmp/diff.txt
106
+ git diff origin/main...HEAD --stat > /tmp/stats.txt
107
+ git diff origin/main...HEAD --name-only -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/files.txt
108
+
109
+ DIFF_SIZE=$(wc -c < /tmp/diff.txt | tr -d ' ')
110
+ echo "Diff size: $DIFF_SIZE bytes"
111
+
112
+ if [ "$DIFF_SIZE" -lt 10 ]; then
113
+ echo "No code diff to review" > /tmp/review.md
114
+ echo '{"findings":[]}' > /tmp/findings.json
115
+ echo "has_review=true" >> $GITHUB_OUTPUT
116
+ exit 0
117
+ fi
118
+
119
+ # Build context-aware payload
120
+ node -e "
121
+ const fs = require('fs');
122
+ const diff = fs.readFileSync('/tmp/diff.txt', 'utf8').substring(0, 12000);
123
+ const context = fs.readFileSync('/tmp/project-context.txt', 'utf8').substring(0, 3000);
124
+ const title = process.env.PR_TITLE || '';
125
+ const body = (process.env.PR_BODY || '').substring(0, 500);
126
+
127
+ const systemPrompt = [
128
+ 'You are a senior code reviewer with full project context.',
129
+ '',
130
+ '--- PROJECT CONTEXT ---',
131
+ context,
132
+ '--- END CONTEXT ---',
133
+ '',
134
+ 'Review this PR diff with awareness of the project\\'s architecture, patterns, and conventions.',
135
+ '',
136
+ 'Review for:',
137
+ '1. Bugs - Logic errors, null/undefined risks, race conditions',
138
+ '2. Security - Injection, XSS, credential exposure, unsafe operations',
139
+ '3. Architecture - Does this follow the project\\'s established patterns?',
140
+ '4. Performance - N+1 queries, unnecessary re-renders, memory leaks',
141
+ '5. Test gaps - What should be tested that is not',
142
+ '',
143
+ 'Be concise. Only flag real issues, not style preferences. If the code is clean, say so briefly.',
144
+ 'Use your knowledge of the project to distinguish intentional patterns from mistakes.',
145
+ '',
146
+ 'Output format:',
147
+ '## Findings',
148
+ '- [severity_emoji] file:line - description',
149
+ '',
150
+ 'Severity: red_circle (bug/security), yellow_circle (perf/architecture concern), blue_circle (suggestion)',
151
+ '',
152
+ 'If no issues: ## Findings\\nNo issues found. Code looks clean.',
153
+ '',
154
+ 'After the markdown findings, output a JSON block on a new line starting with FINDINGS_JSON:',
155
+ 'FINDINGS_JSON:[{\"severity\":\"red|yellow|blue\",\"file\":\"path\",\"line\":0,\"description\":\"...\"}]',
156
+ 'Output an empty array [] if no findings.',
157
+ ].join('\\n');
158
+
159
+ const payload = {
160
+ model: 'gpt-4o-mini',
161
+ messages: [
162
+ { role: 'system', content: systemPrompt },
163
+ { role: 'user', content: 'PR: ' + title + '\\n' + (body ? 'Description: ' + body + '\\n\\n' : '\\n') + diff }
164
+ ],
165
+ max_tokens: 1500,
166
+ temperature: 0.1
167
+ };
168
+
169
+ fs.writeFileSync('/tmp/payload.json', JSON.stringify(payload));
170
+ "
171
+
172
+ # Try OpenAI
173
+ REVIEW=""
174
+ if [ -n "$OPENAI_API_KEY" ]; then
175
+ RESPONSE=$(curl -s -X POST https://api.openai.com/v1/chat/completions \
176
+ -H "Content-Type: application/json" \
177
+ -H "Authorization: Bearer $OPENAI_API_KEY" \
178
+ -d @/tmp/payload.json 2>/dev/null)
179
+ REVIEW=$(echo "$RESPONSE" | node -e "
180
+ let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{
181
+ try { console.log(JSON.parse(d).choices[0].message.content); }
182
+ catch(e) { console.log(''); }
183
+ })
184
+ ")
185
+ fi
186
+
187
+ # Fallback to OpenRouter
188
+ if [ -z "$REVIEW" ] && [ -n "$OPENROUTER_API_KEY" ]; then
189
+ echo "OpenAI failed, trying OpenRouter..."
190
+ node -e "
191
+ const fs = require('fs');
192
+ const p = JSON.parse(fs.readFileSync('/tmp/payload.json', 'utf8'));
193
+ p.model = 'anthropic/claude-sonnet-4';
194
+ fs.writeFileSync('/tmp/payload-or.json', JSON.stringify(p));
195
+ "
196
+ RESPONSE=$(curl -s -X POST https://openrouter.ai/api/v1/chat/completions \
197
+ -H "Content-Type: application/json" \
198
+ -H "Authorization: Bearer $OPENROUTER_API_KEY" \
199
+ -d @/tmp/payload-or.json 2>/dev/null)
200
+ REVIEW=$(echo "$RESPONSE" | node -e "
201
+ let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{
202
+ try { console.log(JSON.parse(d).choices[0].message.content); }
203
+ catch(e) { console.log(''); }
204
+ })
205
+ ")
206
+ fi
207
+
208
+ if [ -z "$REVIEW" ]; then
209
+ REVIEW="AI review unavailable — both OpenAI and OpenRouter failed."
210
+ echo '{"findings":[]}' > /tmp/findings.json
211
+ fi
212
+
213
+ echo "$REVIEW" > /tmp/review.md
214
+ echo "has_review=true" >> $GITHUB_OUTPUT
215
+
216
+ - name: Extract structured findings
217
+ id: findings
218
+ if: steps.review.outputs.has_review == 'true'
219
+ run: |
220
+ # Parse FINDINGS_JSON from review output
221
+ node -e "
222
+ const fs = require('fs');
223
+ const review = fs.readFileSync('/tmp/review.md', 'utf8');
224
+
225
+ // Extract FINDINGS_JSON line
226
+ const match = review.match(/FINDINGS_JSON:\s*(\[.*\])/);
227
+ let findings = [];
228
+ if (match) {
229
+ try { findings = JSON.parse(match[1]); } catch(e) {}
230
+ }
231
+
232
+ // Also count by severity from markdown as fallback
233
+ const redCount = (review.match(/🔴/g) || []).length;
234
+ const yellowCount = (review.match(/🟡/g) || []).length;
235
+ const blueCount = (review.match(/🔵/g) || []).length;
236
+
237
+ const result = {
238
+ findings: findings,
239
+ counts: { red: redCount, yellow: yellowCount, blue: blueCount },
240
+ total: findings.length || (redCount + yellowCount + blueCount),
241
+ has_blockers: findings.some(f => f.severity === 'red') || redCount > 0
242
+ };
243
+
244
+ fs.writeFileSync('/tmp/findings.json', JSON.stringify(result));
245
+
246
+ // Clean the FINDINGS_JSON line from the review markdown
247
+ const cleanReview = review.replace(/FINDINGS_JSON:.*$/m, '').trim();
248
+ fs.writeFileSync('/tmp/review.md', cleanReview);
249
+
250
+ // Set step outputs for downstream steps
251
+ const outputFile = process.env.GITHUB_OUTPUT;
252
+ if (outputFile) {
253
+ fs.appendFileSync(outputFile, 'has_blockers=' + result.has_blockers + '\n');
254
+ fs.appendFileSync(outputFile, 'red_count=' + result.counts.red + '\n');
255
+ fs.appendFileSync(outputFile, 'yellow_count=' + result.counts.yellow + '\n');
256
+ fs.appendFileSync(outputFile, 'blue_count=' + result.counts.blue + '\n');
257
+ fs.appendFileSync(outputFile, 'total=' + result.total + '\n');
258
+ }
259
+
260
+ console.log('Findings: ' + result.total + ' (red=' + result.counts.red + ' yellow=' + result.counts.yellow + ' blue=' + result.counts.blue + ')');
261
+ "
262
+
263
+ - name: Comment on PR
264
+ if: steps.review.outputs.has_review == 'true'
265
+ uses: actions/github-script@v7
266
+ with:
267
+ script: |
268
+ const fs = require('fs');
269
+ const review = fs.readFileSync('/tmp/review.md', 'utf8');
270
+ const stats = fs.readFileSync('/tmp/stats.txt', 'utf8');
271
+ const findings = JSON.parse(fs.readFileSync('/tmp/findings.json', 'utf8'));
272
+ const projectName = '${{ steps.context.outputs.project_name }}';
273
+ const marker = '<!-- jfl-ai-review -->';
274
+
275
+ const body = [
276
+ marker,
277
+ `## AI Code Review — ${projectName}`,
278
+ '',
279
+ review,
280
+ '',
281
+ '---',
282
+ '<details><summary>Diff stats</summary>',
283
+ '',
284
+ '```',
285
+ stats,
286
+ '```',
287
+ '</details>',
288
+ '',
289
+ findings.has_blockers
290
+ ? ':warning: **Blockers found** — address red findings before merge'
291
+ : (findings.total > 0
292
+ ? ':white_check_mark: No blockers — suggestions only'
293
+ : ':white_check_mark: Clean'),
294
+ '',
295
+ `*Reviewed by JFL AI Review (context-aware) | ${findings.total} finding(s)*`
296
+ ].join('\n');
297
+
298
+ const { data: comments } = await github.rest.issues.listComments({
299
+ owner: context.repo.owner,
300
+ repo: context.repo.repo,
301
+ issue_number: context.issue.number,
302
+ });
303
+
304
+ const existing = comments.find(c => c.body.includes(marker));
305
+ if (existing) {
306
+ await github.rest.issues.updateComment({
307
+ owner: context.repo.owner,
308
+ repo: context.repo.repo,
309
+ comment_id: existing.id,
310
+ body,
311
+ });
312
+ } else {
313
+ await github.rest.issues.createComment({
314
+ owner: context.repo.owner,
315
+ repo: context.repo.repo,
316
+ issue_number: context.issue.number,
317
+ body,
318
+ });
319
+ }
320
+
321
+ - name: Block or approve based on findings
322
+ if: always()
323
+ env:
324
+ GH_TOKEN: ${{ github.token }}
325
+ run: |
326
+ if [ "${{ steps.findings.outputs.has_blockers }}" = "true" ]; then
327
+ echo "Red findings detected — requesting changes"
328
+ gh pr review ${{ github.event.pull_request.number }} --request-changes \
329
+ --body "JFL AI Review: ${{ steps.findings.outputs.red_count }} blocker(s) found. Address red findings before merge."
330
+ fi
331
+
332
+ - name: Post review:findings event to hub
333
+ if: always()
334
+ continue-on-error: true
335
+ env:
336
+ JFL_HUB_URL: ${{ secrets.JFL_HUB_URL }}
337
+ JFL_HUB_TOKEN: ${{ secrets.JFL_HUB_TOKEN }}
338
+ run: |
339
+ # Build structured event with findings
340
+ node -e "
341
+ const fs = require('fs');
342
+ let findings = { findings: [], counts: { red: 0, yellow: 0, blue: 0 }, total: 0, has_blockers: false };
343
+ try { findings = JSON.parse(fs.readFileSync('/tmp/findings.json', 'utf8')); } catch(e) {}
344
+
345
+ const event = {
346
+ type: 'review:findings',
347
+ source: 'github-action',
348
+ data: {
349
+ pr_number: ${{ github.event.pull_request.number }},
350
+ pr_url: '${{ github.event.pull_request.html_url }}',
351
+ branch: '${{ github.head_ref }}',
352
+ commit_sha: '${{ github.sha }}',
353
+ reviewer: 'ai',
354
+ model: 'gpt-4o-mini',
355
+ findings: findings.findings,
356
+ counts: findings.counts,
357
+ total_findings: findings.total,
358
+ has_blockers: findings.has_blockers,
359
+ run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
360
+ }
361
+ };
362
+
363
+ fs.writeFileSync('/tmp/review-event.json', JSON.stringify(event));
364
+ "
365
+
366
+ if [ -n "$JFL_HUB_URL" ]; then
367
+ curl -s -X POST "$JFL_HUB_URL/api/events" \
368
+ -H "Content-Type: application/json" \
369
+ -H "Authorization: Bearer $JFL_HUB_TOKEN" \
370
+ -d @/tmp/review-event.json || echo "Hub not available"
371
+ fi
@@ -66,6 +66,26 @@ flows:
66
66
  - type: log
67
67
  message: "Training tuple logged: state={{data.baseline}} action=PR#{{data.pr_number}} reward={{data.delta}}"
68
68
 
69
+ # Proactive experiment trigger — PP picks its own next task
70
+ - name: proactive-experiment
71
+ description: "Pick and execute the highest-value experiment from trajectory history"
72
+ enabled: true
73
+ trigger:
74
+ pattern: "cron:daily"
75
+ gate:
76
+ requires_approval: true
77
+ actions:
78
+ - type: log
79
+ message: "Proactive experiment trigger: evaluating next best action"
80
+ - type: spawn
81
+ command: jfl
82
+ args: ["peter", "experiment"]
83
+ detach: true
84
+ - type: journal
85
+ entry_type: "milestone"
86
+ title: "Proactive experiment dispatched"
87
+ summary: "Autonomous experiment selection triggered. PP will analyze trajectory history and propose next improvement."
88
+
69
89
  # Review → Fix loop flows
70
90
  - name: block-merge-on-blockers
71
91
  description: "Request changes when AI review finds red (blocker) findings"