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.
- package/README.md +203 -34
- package/dist/commands/ci-setup.d.ts +5 -0
- package/dist/commands/ci-setup.d.ts.map +1 -0
- package/dist/commands/ci-setup.js +82 -0
- package/dist/commands/ci-setup.js.map +1 -0
- package/dist/commands/peter.d.ts +2 -1
- package/dist/commands/peter.d.ts.map +1 -1
- package/dist/commands/peter.js +195 -1
- package/dist/commands/peter.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +58 -4
- package/dist/commands/update.js.map +1 -1
- package/dist/index.js +16 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/flow-engine.d.ts +2 -0
- package/dist/lib/flow-engine.d.ts.map +1 -1
- package/dist/lib/flow-engine.js +40 -0
- package/dist/lib/flow-engine.js.map +1 -1
- package/dist/types/map.d.ts +1 -1
- package/dist/types/map.d.ts.map +1 -1
- package/dist/types/map.js.map +1 -1
- package/package.json +1 -1
- package/template/.github/workflows/jfl-eval.yml +448 -0
- package/template/.github/workflows/jfl-review.yml +371 -0
- package/template/.jfl/{flows-self-driving.yaml → flows/self-driving.yaml} +20 -0
|
@@ -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"
|