@iceinvein/agent-skills 0.1.39 → 0.1.40
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/package.json +1 -1
- package/skills/index.json +1 -1
- package/skills/magpie/README.md +2 -1
- package/skills/magpie/SKILL.md +27 -12
- package/skills/magpie/fixtures/example-pr/brief.json +18 -0
- package/skills/magpie/fixtures/fake-gh.sh +14 -0
- package/skills/magpie/package.json +1 -1
- package/skills/magpie/references/scout.md +90 -0
- package/skills/magpie/references/specialists.md +84 -4
- package/skills/magpie/scripts/__tests__/gh.test.ts +21 -0
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +16 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +45 -0
- package/skills/magpie/scripts/__tests__/render-cmd.test.ts +78 -1
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +118 -1
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +111 -6
- package/skills/magpie/scripts/__tests__/types.test.ts +47 -0
- package/skills/magpie/scripts/gh.ts +4 -1
- package/skills/magpie/scripts/preview-cmd.ts +11 -1
- package/skills/magpie/scripts/refresh.ts +24 -3
- package/skills/magpie/scripts/render-cmd.ts +8 -3
- package/skills/magpie/scripts/render-findings.ts +67 -1
- package/skills/magpie/scripts/status-cmd.ts +3 -3
- package/skills/magpie/scripts/types.ts +50 -0
- package/skills/magpie/skill.json +1 -1
- package/skills/magpie/templates/styles.css +61 -0
|
@@ -2,7 +2,7 @@ import { beforeAll, expect, test } from 'bun:test'
|
|
|
2
2
|
import type { Highlighter } from 'shiki'
|
|
3
3
|
import { getHighlighter } from '../highlight.ts'
|
|
4
4
|
import { renderFindingsHtml } from '../render-findings.ts'
|
|
5
|
-
import type { ReviewFinding } from '../types.ts'
|
|
5
|
+
import type { PrBrief, ReviewFinding } from '../types.ts'
|
|
6
6
|
|
|
7
7
|
function f(p: Partial<ReviewFinding> & { id: string; title: string }): ReviewFinding {
|
|
8
8
|
return {
|
|
@@ -200,3 +200,120 @@ test('renders pr meta when supplied', () => {
|
|
|
200
200
|
expect(html).toContain('feat/x')
|
|
201
201
|
expect(html).toContain('deadbeefdead')
|
|
202
202
|
})
|
|
203
|
+
|
|
204
|
+
const SAMPLE_BRIEF: PrBrief = {
|
|
205
|
+
purpose:
|
|
206
|
+
'Adds bounded retries to the upload path so transient S3 failures stop surfacing to users.',
|
|
207
|
+
changes: ['Wraps the S3 put in a bounded retry', 'Adds a jittered backoff helper'],
|
|
208
|
+
subsystems: [
|
|
209
|
+
{ name: 'upload', role: 'owns the client-facing put path' },
|
|
210
|
+
{ name: 'storage-client', role: 'wraps the S3 SDK' },
|
|
211
|
+
],
|
|
212
|
+
watchItems: ['The PR body claims idempotency but no request key is sent'],
|
|
213
|
+
unclear: ['Whether the retry budget interacts with the outer request timeout'],
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
test('brief header renders purpose, changes, and subsystem chips', () => {
|
|
217
|
+
const html = renderFindingsHtml({
|
|
218
|
+
findings: SAMPLE_FINDINGS,
|
|
219
|
+
postStatus: {},
|
|
220
|
+
highlighter: hl,
|
|
221
|
+
brief: SAMPLE_BRIEF,
|
|
222
|
+
})
|
|
223
|
+
expect(html).toContain('class="pr-brief"')
|
|
224
|
+
expect(html).toContain('Adds bounded retries to the upload path')
|
|
225
|
+
expect(html).toContain('Wraps the S3 put in a bounded retry')
|
|
226
|
+
expect(html).toContain('class="brief-chip"')
|
|
227
|
+
expect(html).toContain('>upload<')
|
|
228
|
+
expect(html).toContain('>storage-client<')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('brief header omits watchItems and unclear, which are prompt-only', () => {
|
|
232
|
+
const html = renderFindingsHtml({
|
|
233
|
+
findings: SAMPLE_FINDINGS,
|
|
234
|
+
postStatus: {},
|
|
235
|
+
highlighter: hl,
|
|
236
|
+
brief: SAMPLE_BRIEF,
|
|
237
|
+
})
|
|
238
|
+
expect(html).not.toContain('no request key is sent')
|
|
239
|
+
expect(html).not.toContain('outer request timeout')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('no brief means no brief header at all', () => {
|
|
243
|
+
const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {}, highlighter: hl })
|
|
244
|
+
expect(html).not.toContain('class="pr-brief"')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('an empty subsystem list renders no chip row', () => {
|
|
248
|
+
const html = renderFindingsHtml({
|
|
249
|
+
findings: SAMPLE_FINDINGS,
|
|
250
|
+
postStatus: {},
|
|
251
|
+
highlighter: hl,
|
|
252
|
+
brief: { ...SAMPLE_BRIEF, subsystems: [] },
|
|
253
|
+
})
|
|
254
|
+
expect(html).toContain('class="pr-brief"')
|
|
255
|
+
expect(html).not.toContain('brief-subsystems')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('brief header renders on the empty-findings page too', () => {
|
|
259
|
+
const html = renderFindingsHtml({
|
|
260
|
+
findings: [],
|
|
261
|
+
postStatus: {},
|
|
262
|
+
highlighter: hl,
|
|
263
|
+
brief: SAMPLE_BRIEF,
|
|
264
|
+
})
|
|
265
|
+
expect(html).toContain('No findings')
|
|
266
|
+
expect(html).toContain('class="pr-brief"')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('linked issues render as links when supplied', () => {
|
|
270
|
+
const html = renderFindingsHtml({
|
|
271
|
+
findings: SAMPLE_FINDINGS,
|
|
272
|
+
postStatus: {},
|
|
273
|
+
highlighter: hl,
|
|
274
|
+
brief: SAMPLE_BRIEF,
|
|
275
|
+
issues: [
|
|
276
|
+
{ number: 42, title: 'Uploads fail intermittently', url: 'https://example.test/issues/42' },
|
|
277
|
+
],
|
|
278
|
+
})
|
|
279
|
+
expect(html).toContain('https://example.test/issues/42')
|
|
280
|
+
expect(html).toContain('#42')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('brief content is HTML-escaped', () => {
|
|
284
|
+
const html = renderFindingsHtml({
|
|
285
|
+
findings: SAMPLE_FINDINGS,
|
|
286
|
+
postStatus: {},
|
|
287
|
+
highlighter: hl,
|
|
288
|
+
brief: { ...SAMPLE_BRIEF, purpose: 'Fixes <script>alert(1)</script> handling' },
|
|
289
|
+
})
|
|
290
|
+
expect(html).not.toContain('<script>alert(1)</script>')
|
|
291
|
+
expect(html).toContain('<script>')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test('subsystem role and issue title escape quotes in their title="" attribute context', () => {
|
|
295
|
+
const html = renderFindingsHtml({
|
|
296
|
+
findings: SAMPLE_FINDINGS,
|
|
297
|
+
postStatus: {},
|
|
298
|
+
highlighter: hl,
|
|
299
|
+
brief: {
|
|
300
|
+
...SAMPLE_BRIEF,
|
|
301
|
+
subsystems: [{ name: 'upload', role: 'owns the put path" onmouseover="alert(1)' }],
|
|
302
|
+
},
|
|
303
|
+
issues: [
|
|
304
|
+
{
|
|
305
|
+
number: 42,
|
|
306
|
+
title: 'fails intermittently" onmouseover="alert(2)',
|
|
307
|
+
url: 'https://example.test/issues/42',
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
})
|
|
311
|
+
// Raw quote-breakout must never appear unescaped in either attribute.
|
|
312
|
+
expect(html).not.toContain('path" onmouseover="alert(1)')
|
|
313
|
+
expect(html).not.toContain('intermittently" onmouseover="alert(2)')
|
|
314
|
+
// Escaped form must appear instead.
|
|
315
|
+
expect(html).toContain('path" onmouseover="alert(1)')
|
|
316
|
+
expect(html).toContain('intermittently" onmouseover="alert(2)')
|
|
317
|
+
// purpose escaping (already covered above) must remain intact alongside this.
|
|
318
|
+
expect(html).toContain('Adds bounded retries to the upload path')
|
|
319
|
+
})
|
|
@@ -24,7 +24,7 @@ test('references/ ships in the install bundle', async () => {
|
|
|
24
24
|
}
|
|
25
25
|
const covers = (p: string) =>
|
|
26
26
|
manifest.bundle.include.some((inc) => inc === p || p.startsWith(`${inc}/`))
|
|
27
|
-
for (const name of ['specialists.md', 'critic.md', 'peer-review.md']) {
|
|
27
|
+
for (const name of ['specialists.md', 'critic.md', 'peer-review.md', 'scout.md']) {
|
|
28
28
|
expect(covers(`references/${name}`)).toBe(true)
|
|
29
29
|
}
|
|
30
30
|
})
|
|
@@ -81,6 +81,22 @@ test('references/peer-review.md holds the prompt and the Claude preamble', async
|
|
|
81
81
|
}
|
|
82
82
|
})
|
|
83
83
|
|
|
84
|
+
test('references/scout.md holds the scout prompt and the brief contract', async () => {
|
|
85
|
+
const text = await readFile(ref('scout.md'), 'utf8')
|
|
86
|
+
expect(text).toContain('```magpie-scout')
|
|
87
|
+
expect(text).toContain('brief.json')
|
|
88
|
+
for (const key of ['purpose', 'changes', 'subsystems', 'watchItems', 'unclear']) {
|
|
89
|
+
expect(text).toContain(key)
|
|
90
|
+
}
|
|
91
|
+
for (const ph of ['<<RUN_DIR>>', '<<PR_NUMBER>>']) {
|
|
92
|
+
expect(text).toContain(ph)
|
|
93
|
+
}
|
|
94
|
+
// The scout must never trigger a full index; that is a consent-gated GPU pass.
|
|
95
|
+
expect(text).toMatch(/never call `approve_indexing`|do not call `approve_indexing`/i)
|
|
96
|
+
// watchItems are context for specialists, not findings in their own right.
|
|
97
|
+
expect(text).toMatch(/not a finding/i)
|
|
98
|
+
})
|
|
99
|
+
|
|
84
100
|
test('SKILL.md sends each stage to the reference file it needs', async () => {
|
|
85
101
|
const text = await readFile(SKILL, 'utf8')
|
|
86
102
|
const section = (heading: string) => {
|
|
@@ -89,20 +105,73 @@ test('SKILL.md sends each stage to the reference file it needs', async () => {
|
|
|
89
105
|
const next = text.indexOf('\n### ', start + heading.length)
|
|
90
106
|
return text.slice(start, next === -1 ? undefined : next)
|
|
91
107
|
}
|
|
92
|
-
expect(section('### 3.
|
|
93
|
-
expect(section('###
|
|
94
|
-
expect(section('### 6.
|
|
108
|
+
expect(section('### 3. Context')).toContain('references/scout.md')
|
|
109
|
+
expect(section('### 4. Specialists')).toContain('references/specialists.md')
|
|
110
|
+
expect(section('### 6. Critic')).toContain('references/critic.md')
|
|
111
|
+
expect(section('### 7. Peer review')).toContain('references/peer-review.md')
|
|
95
112
|
})
|
|
96
113
|
|
|
97
114
|
test('SKILL.md no longer inlines the prompt bodies it moved out', async () => {
|
|
98
115
|
const text = await readFile(SKILL, 'utf8')
|
|
99
|
-
for (const tag of ['magpie-specialist-', 'magpie-critic', 'magpie-peer-review']) {
|
|
116
|
+
for (const tag of ['magpie-specialist-', 'magpie-critic', 'magpie-peer-review', 'magpie-scout']) {
|
|
100
117
|
expect(text).not.toContain(`\`\`\`${tag}`)
|
|
101
118
|
}
|
|
102
119
|
// The walkthrough is the always-read part; keep it small enough to be cheap.
|
|
103
120
|
expect(text.split(/\s+/).length).toBeLessThan(2600)
|
|
104
121
|
})
|
|
105
122
|
|
|
123
|
+
test('SKILL.md never instructs the agent to approve indexing', async () => {
|
|
124
|
+
const text = await readFile(SKILL, 'utf8')
|
|
125
|
+
// A full index is a consent-gated GPU pass. The context stage degrades instead.
|
|
126
|
+
expect(text).toContain('approve_indexing')
|
|
127
|
+
expect(text).toMatch(/never call `approve_indexing`|do not call `approve_indexing`/i)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('SKILL.md logs codeIntelligence on both the done and skipped context outcomes', async () => {
|
|
131
|
+
const text = await readFile(SKILL, 'utf8')
|
|
132
|
+
const start = text.indexOf('### 3. Context')
|
|
133
|
+
expect(start).toBeGreaterThan(-1)
|
|
134
|
+
const section = text.slice(start, text.indexOf('\n### 4.', start))
|
|
135
|
+
// The bind probe's result is known by the time either log line is written,
|
|
136
|
+
// regardless of whether the scout produced a brief; specialists read this key
|
|
137
|
+
// to decide whether to include the codebase-intelligence block.
|
|
138
|
+
const doneEntry = section.match(/\{stage: context, status: done[^}]*\}/)
|
|
139
|
+
const skippedEntry = section.match(/\{stage: context, status: skipped[^}]*\}/)
|
|
140
|
+
expect(doneEntry?.[0]).toContain('codeIntelligence')
|
|
141
|
+
expect(skippedEntry?.[0]).toContain('codeIntelligence')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('SKILL.md rebinds the code-intelligence session at cleanup', async () => {
|
|
145
|
+
const text = await readFile(SKILL, 'utf8')
|
|
146
|
+
const start = text.indexOf('### 10. Cleanup')
|
|
147
|
+
expect(start).toBeGreaterThan(-1)
|
|
148
|
+
const section = text.slice(start, text.indexOf('\n## ', start))
|
|
149
|
+
// Binding is per session with no per-call override, so a run that ends without
|
|
150
|
+
// rebinding leaves the session pointed at a worktree that no longer exists.
|
|
151
|
+
expect(section).toContain('bind_workspace')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('SKILL.md rebinds before cleanup on the abort path too', async () => {
|
|
155
|
+
const text = await readFile(SKILL, 'utf8')
|
|
156
|
+
const start = text.indexOf('## Aborting')
|
|
157
|
+
expect(start).toBeGreaterThan(-1)
|
|
158
|
+
const section = text.slice(start)
|
|
159
|
+
// Stage 3 bound the session to the worktree; `abort` deletes that worktree via
|
|
160
|
+
// `magpie cleanup`, so it must rebind first or leave the session dangling.
|
|
161
|
+
expect(section).toMatch(/rebind.*(\$REPO|stage 10)/i)
|
|
162
|
+
expect(section).toContain('magpie cleanup')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('SKILL.md rebinds before the stage-4 all-specialists-failed hard stop', async () => {
|
|
166
|
+
const text = await readFile(SKILL, 'utf8')
|
|
167
|
+
const start = text.indexOf('### 4. Specialists')
|
|
168
|
+
expect(start).toBeGreaterThan(-1)
|
|
169
|
+
const section = text.slice(start, text.indexOf('\n### 5.', start))
|
|
170
|
+
// This path stops the run without calling cleanup, but a later resume or
|
|
171
|
+
// abort must not find the session still pointed at the worktree.
|
|
172
|
+
expect(section).toMatch(/rebind.*(\$REPO|stage 10)/i)
|
|
173
|
+
})
|
|
174
|
+
|
|
106
175
|
test('styles.css declares a prefers-color-scheme:dark block that overrides core tokens', async () => {
|
|
107
176
|
const STYLES = new URL('../../templates/styles.css', import.meta.url).pathname
|
|
108
177
|
const css = await readFile(STYLES, 'utf8')
|
|
@@ -195,7 +264,8 @@ test('SKILL.md does not gate resume on state/server-info', async () => {
|
|
|
195
264
|
expect(section).not.toMatch(/if .*server-info.* exists/i)
|
|
196
265
|
// Resuming must restart the server; the old one is gone.
|
|
197
266
|
expect(section).toContain('magpie serve')
|
|
198
|
-
// `context`
|
|
267
|
+
// `context` re-runs on resume too (bind probe plus a conditional scout
|
|
268
|
+
// dispatch); the resume section must still call it out explicitly.
|
|
199
269
|
expect(section).toContain('context')
|
|
200
270
|
})
|
|
201
271
|
|
|
@@ -222,3 +292,38 @@ test('SKILL.md has the stage walkthrough', async () => {
|
|
|
222
292
|
expect(text).toMatch(/magpie render/)
|
|
223
293
|
expect(text).toMatch(/magpie cleanup/)
|
|
224
294
|
})
|
|
295
|
+
|
|
296
|
+
test('references/specialists.md carries the codebase-intelligence block', async () => {
|
|
297
|
+
const text = await readFile(ref('specialists.md'), 'utf8')
|
|
298
|
+
expect(text).toContain('```magpie-codebase-intelligence')
|
|
299
|
+
const start = text.indexOf('```magpie-codebase-intelligence')
|
|
300
|
+
const block = text.slice(start, text.indexOf('```', start + 32))
|
|
301
|
+
expect(block).toContain('bind_workspace')
|
|
302
|
+
expect(block).toContain('indexing_in_progress')
|
|
303
|
+
// The one operation a specialist must never perform.
|
|
304
|
+
expect(block).toMatch(/never call `approve_indexing`|do not call `approve_indexing`/i)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('every focus block names the code-intelligence tool for its focus', async () => {
|
|
308
|
+
const text = await readFile(ref('specialists.md'), 'utf8')
|
|
309
|
+
const tools: Record<(typeof FOCUSES)[number], string> = {
|
|
310
|
+
security: 'trace_data_flow',
|
|
311
|
+
bugs: 'get_call_hierarchy',
|
|
312
|
+
performance: 'find_affected_code',
|
|
313
|
+
'code-smells': 'search_code',
|
|
314
|
+
architecture: 'explore_dependency_graph',
|
|
315
|
+
}
|
|
316
|
+
for (const focus of FOCUSES) {
|
|
317
|
+
const fence = `\`\`\`magpie-specialist-${focus}`
|
|
318
|
+
const start = text.indexOf(fence)
|
|
319
|
+
const block = text.slice(start, text.indexOf('```', start + fence.length))
|
|
320
|
+
expect(block).toContain(tools[focus])
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('the output contract tells specialists to look before they hedge', async () => {
|
|
325
|
+
const text = await readFile(ref('specialists.md'), 'utf8')
|
|
326
|
+
const contract = text.slice(0, text.indexOf('```magpie-specialist-'))
|
|
327
|
+
expect(contract).toContain('Needs verification:')
|
|
328
|
+
expect(contract).toMatch(/look before .*hedg/i)
|
|
329
|
+
})
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
FOCUS_IDS,
|
|
7
7
|
isSuggestion,
|
|
8
8
|
looksLikeProse,
|
|
9
|
+
parseBrief,
|
|
9
10
|
parseFinding,
|
|
10
11
|
} from '../types.ts'
|
|
11
12
|
|
|
@@ -316,3 +317,49 @@ describe('isSuggestion', () => {
|
|
|
316
317
|
).toBe(false)
|
|
317
318
|
})
|
|
318
319
|
})
|
|
320
|
+
|
|
321
|
+
test('parseBrief accepts a well-formed brief', () => {
|
|
322
|
+
const brief = parseBrief({
|
|
323
|
+
purpose: 'Adds retry handling to the upload path.',
|
|
324
|
+
changes: ['Wraps the S3 put in a bounded retry', 'Adds a jittered backoff helper'],
|
|
325
|
+
subsystems: [{ name: 'upload', role: 'owns the client-facing put path' }],
|
|
326
|
+
watchItems: ['The PR body claims idempotency but no request key is sent'],
|
|
327
|
+
unclear: ['Whether the retry budget interacts with the outer request timeout'],
|
|
328
|
+
})
|
|
329
|
+
expect(brief).not.toBeNull()
|
|
330
|
+
expect(brief?.purpose).toBe('Adds retry handling to the upload path.')
|
|
331
|
+
expect(brief?.changes).toHaveLength(2)
|
|
332
|
+
expect(brief?.subsystems[0]).toEqual({ name: 'upload', role: 'owns the client-facing put path' })
|
|
333
|
+
expect(brief?.watchItems).toHaveLength(1)
|
|
334
|
+
expect(brief?.unclear).toHaveLength(1)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
test('parseBrief returns null for a brief with no purpose', () => {
|
|
338
|
+
expect(parseBrief({ changes: ['a'] })).toBeNull()
|
|
339
|
+
expect(parseBrief({ purpose: ' ', changes: ['a'] })).toBeNull()
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('parseBrief returns null for non-objects', () => {
|
|
343
|
+
expect(parseBrief(null)).toBeNull()
|
|
344
|
+
expect(parseBrief('a brief')).toBeNull()
|
|
345
|
+
expect(parseBrief(['a brief'])).toBeNull()
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
test('parseBrief drops junk entries instead of throwing', () => {
|
|
349
|
+
const brief = parseBrief({
|
|
350
|
+
purpose: 'Does a thing.',
|
|
351
|
+
changes: ['kept', 42, null, ' ', 'also kept'],
|
|
352
|
+
subsystems: [{ name: 'kept', role: 'r' }, { role: 'no name' }, 'not an object', null],
|
|
353
|
+
watchItems: 'not an array',
|
|
354
|
+
unclear: undefined,
|
|
355
|
+
})
|
|
356
|
+
expect(brief?.changes).toEqual(['kept', 'also kept'])
|
|
357
|
+
expect(brief?.subsystems).toEqual([{ name: 'kept', role: 'r' }])
|
|
358
|
+
expect(brief?.watchItems).toEqual([])
|
|
359
|
+
expect(brief?.unclear).toEqual([])
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
test('parseBrief defaults a subsystem with no role to an empty role', () => {
|
|
363
|
+
const brief = parseBrief({ purpose: 'p', subsystems: [{ name: 'auth' }] })
|
|
364
|
+
expect(brief?.subsystems).toEqual([{ name: 'auth', role: '' }])
|
|
365
|
+
})
|
|
@@ -13,7 +13,7 @@ export type FetchPrResult =
|
|
|
13
13
|
| { ok: true; prJsonPath: string; diffPath: string }
|
|
14
14
|
| { ok: false; error: string }
|
|
15
15
|
|
|
16
|
-
const PR_VIEW_FIELDS = [
|
|
16
|
+
export const PR_VIEW_FIELDS = [
|
|
17
17
|
'number',
|
|
18
18
|
'title',
|
|
19
19
|
'headRefName',
|
|
@@ -26,6 +26,9 @@ const PR_VIEW_FIELDS = [
|
|
|
26
26
|
// depending on cwd (the worktree is gone after cleanup).
|
|
27
27
|
'url',
|
|
28
28
|
'files',
|
|
29
|
+
// Intent evidence for the scout's brief, and issue links for the report header.
|
|
30
|
+
'commits',
|
|
31
|
+
'closingIssuesReferences',
|
|
29
32
|
].join(',')
|
|
30
33
|
|
|
31
34
|
export async function fetchPr(input: FetchPrInput): Promise<FetchPrResult> {
|
|
@@ -4,7 +4,7 @@ import type { PostStatusMap } from './render-findings.ts'
|
|
|
4
4
|
import { renderFindingsToDisk } from './render-findings.ts'
|
|
5
5
|
import type { RenderProgressInput, StageId, StageStatus } from './render-progress.ts'
|
|
6
6
|
import { renderProgressToDisk } from './render-progress.ts'
|
|
7
|
-
import { parseFinding } from './types.ts'
|
|
7
|
+
import { type PrBrief, parseBrief, parseFinding } from './types.ts'
|
|
8
8
|
|
|
9
9
|
export type PreviewPage = 'findings' | 'progress' | 'both'
|
|
10
10
|
|
|
@@ -181,6 +181,7 @@ async function readFixture(fixtureDir: string): Promise<{
|
|
|
181
181
|
postStatus: PostStatusMap
|
|
182
182
|
files: Array<{ path: string; additions: number; deletions: number }>
|
|
183
183
|
diff: string
|
|
184
|
+
brief: PrBrief | undefined
|
|
184
185
|
}> {
|
|
185
186
|
const prRaw = JSON.parse(await readFile(join(fixtureDir, 'pr.json'), 'utf8')) as Record<
|
|
186
187
|
string,
|
|
@@ -214,6 +215,13 @@ async function readFixture(fixtureDir: string): Promise<{
|
|
|
214
215
|
} catch {
|
|
215
216
|
// optional file
|
|
216
217
|
}
|
|
218
|
+
let brief: PrBrief | undefined
|
|
219
|
+
try {
|
|
220
|
+
const briefRaw = JSON.parse(await readFile(join(fixtureDir, 'brief.json'), 'utf8'))
|
|
221
|
+
brief = parseBrief(briefRaw) ?? undefined
|
|
222
|
+
} catch {
|
|
223
|
+
// optional file
|
|
224
|
+
}
|
|
217
225
|
return {
|
|
218
226
|
pr: {
|
|
219
227
|
number: Number(prRaw.number ?? 0),
|
|
@@ -224,6 +232,7 @@ async function readFixture(fixtureDir: string): Promise<{
|
|
|
224
232
|
postStatus: postStatusRaw as PostStatusMap,
|
|
225
233
|
files: filesArray,
|
|
226
234
|
diff,
|
|
235
|
+
brief,
|
|
227
236
|
}
|
|
228
237
|
}
|
|
229
238
|
|
|
@@ -270,6 +279,7 @@ export async function runPreview(opts: PreviewOptions): Promise<PreviewResult> {
|
|
|
270
279
|
pr: fixture.pr,
|
|
271
280
|
files: fixture.files,
|
|
272
281
|
diff: fixture.diff,
|
|
282
|
+
brief: fixture.brief,
|
|
273
283
|
},
|
|
274
284
|
findingsPath,
|
|
275
285
|
)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdir, readFile, unlink } from 'node:fs/promises'
|
|
2
2
|
import { basename, join } from 'node:path'
|
|
3
|
-
import { type PostStatusMap, renderFindingsToDisk } from './render-findings.ts'
|
|
4
|
-
import { type PrFileEntry, parseFinding } from './types.ts'
|
|
3
|
+
import { type PostStatusMap, parseClosingIssues, renderFindingsToDisk } from './render-findings.ts'
|
|
4
|
+
import { type PrFileEntry, parseBrief, parseFinding } from './types.ts'
|
|
5
5
|
|
|
6
6
|
export type RefreshResult = {
|
|
7
7
|
refreshed: boolean
|
|
@@ -61,6 +61,7 @@ export async function refreshFindings(runDir: string): Promise<RefreshResult> {
|
|
|
61
61
|
|
|
62
62
|
let pr: { number: number; branch: string; headSha: string } | undefined
|
|
63
63
|
let files: PrFileEntry[] = []
|
|
64
|
+
let issues: ReturnType<typeof parseClosingIssues> = []
|
|
64
65
|
try {
|
|
65
66
|
const prJson = (await Bun.file(join(runDir, 'pr.json')).json()) as Record<string, unknown>
|
|
66
67
|
const prNumber = Number(prJson.number ?? 0)
|
|
@@ -80,14 +81,34 @@ export async function refreshFindings(runDir: string): Promise<RefreshResult> {
|
|
|
80
81
|
deletions: Number(entry.deletions ?? 0),
|
|
81
82
|
}
|
|
82
83
|
})
|
|
84
|
+
issues = parseClosingIssues(prJson)
|
|
83
85
|
} catch {
|
|
84
86
|
// optional file; archived runs may not include pr.json
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
// Scout-produced summary. Same lenient-degrade contract as render-cmd.ts: a
|
|
90
|
+
// missing or malformed brief.json simply omits the header rather than
|
|
91
|
+
// failing the refresh (older archives predate the scout stage entirely).
|
|
92
|
+
let brief: ReturnType<typeof parseBrief> | undefined
|
|
93
|
+
try {
|
|
94
|
+
brief = parseBrief(await Bun.file(join(runDir, 'brief.json')).json())
|
|
95
|
+
} catch {
|
|
96
|
+
// optional file
|
|
97
|
+
}
|
|
98
|
+
|
|
87
99
|
const diff = await readFile(join(runDir, 'diff.patch'), 'utf8').catch(() => '')
|
|
88
100
|
|
|
89
101
|
await renderFindingsToDisk(
|
|
90
|
-
{
|
|
102
|
+
{
|
|
103
|
+
findings,
|
|
104
|
+
postStatus,
|
|
105
|
+
runId: basename(runDir),
|
|
106
|
+
pr,
|
|
107
|
+
files,
|
|
108
|
+
diff,
|
|
109
|
+
brief: brief ?? undefined,
|
|
110
|
+
issues,
|
|
111
|
+
},
|
|
91
112
|
join(screenDir, 'findings.html'),
|
|
92
113
|
)
|
|
93
114
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readdir, readFile } from 'node:fs/promises'
|
|
2
2
|
import { basename, join } from 'node:path'
|
|
3
|
-
import { type PostStatusMap, renderFindingsToDisk } from './render-findings.ts'
|
|
3
|
+
import { type PostStatusMap, parseClosingIssues, renderFindingsToDisk } from './render-findings.ts'
|
|
4
4
|
import { renderProgressToDisk } from './render-progress.ts'
|
|
5
|
-
import { parseFinding } from './types.ts'
|
|
5
|
+
import { parseBrief, parseFinding } from './types.ts'
|
|
6
6
|
|
|
7
7
|
async function nextVersionedPath(screenDir: string, base: string): Promise<string> {
|
|
8
8
|
const entries: string[] = await readdir(screenDir).catch(() => [] as string[])
|
|
@@ -119,9 +119,14 @@ export async function runRender(runDir: string, page: 'progress' | 'findings'):
|
|
|
119
119
|
}
|
|
120
120
|
})
|
|
121
121
|
const diff = await readFile(join(runDir, 'diff.patch'), 'utf8').catch(() => '')
|
|
122
|
+
// `readJson` swallows both a missing file and malformed JSON, and `parseBrief`
|
|
123
|
+
// returns null for a brief that parsed but is unusable. Either way the header
|
|
124
|
+
// is simply omitted.
|
|
125
|
+
const brief = parseBrief(await readJson<unknown>(join(runDir, 'brief.json'), null)) ?? undefined
|
|
126
|
+
const issues = parseClosingIssues(prJson)
|
|
122
127
|
const outPath = await nextVersionedPath(screenDir, 'findings')
|
|
123
128
|
await renderFindingsToDisk(
|
|
124
|
-
{ findings, postStatus, runId: basename(runDir), pr, files, diff },
|
|
129
|
+
{ findings, postStatus, runId: basename(runDir), pr, files, diff, brief, issues },
|
|
125
130
|
outPath,
|
|
126
131
|
)
|
|
127
132
|
return 0
|
|
@@ -7,7 +7,7 @@ import { renderActionBar } from './render-action-bar.ts'
|
|
|
7
7
|
import { renderSplitDiff, renderUnifiedDiff } from './render-diff.ts'
|
|
8
8
|
import { renderFileTree } from './render-file-tree.ts'
|
|
9
9
|
import { renderIssuesList } from './render-issues-list.ts'
|
|
10
|
-
import type { PostStatusMap, PrFileEntry, ReviewFinding } from './types.ts'
|
|
10
|
+
import type { PostStatusMap, PrBrief, PrFileEntry, ReviewFinding } from './types.ts'
|
|
11
11
|
|
|
12
12
|
export type { PostStatusEntry, PostStatusMap } from './types.ts'
|
|
13
13
|
|
|
@@ -17,6 +17,31 @@ export type FindingsPrMeta = {
|
|
|
17
17
|
headSha: string
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export type BriefIssue = {
|
|
21
|
+
number: number
|
|
22
|
+
title: string
|
|
23
|
+
url: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Maps pr.json's `closingIssuesReferences` into the brief header's issue links.
|
|
28
|
+
* Shared by render-cmd's findings branch and refresh (which re-renders
|
|
29
|
+
* findings.html for `magpie open` / `magpie serve <archived-id>`), so both
|
|
30
|
+
* call sites stay in lockstep instead of drifting apart.
|
|
31
|
+
*/
|
|
32
|
+
export function parseClosingIssues(prJson: Record<string, unknown>): BriefIssue[] {
|
|
33
|
+
const issuesRaw = Array.isArray(prJson.closingIssuesReferences)
|
|
34
|
+
? (prJson.closingIssuesReferences as unknown[])
|
|
35
|
+
: []
|
|
36
|
+
return issuesRaw.flatMap((entry) => {
|
|
37
|
+
if (!entry || typeof entry !== 'object') return []
|
|
38
|
+
const e = entry as Record<string, unknown>
|
|
39
|
+
const number = Number(e.number ?? 0)
|
|
40
|
+
if (!Number.isFinite(number) || number <= 0) return []
|
|
41
|
+
return [{ number, title: String(e.title ?? ''), url: String(e.url ?? '') }]
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
20
45
|
export type RenderFindingsInput = {
|
|
21
46
|
findings: ReviewFinding[]
|
|
22
47
|
postStatus: PostStatusMap
|
|
@@ -28,6 +53,10 @@ export type RenderFindingsInput = {
|
|
|
28
53
|
files?: PrFileEntry[]
|
|
29
54
|
/** Raw unified diff text for the PR. */
|
|
30
55
|
diff?: string
|
|
56
|
+
/** Scout-produced PR summary. When absent, the report renders no brief header. */
|
|
57
|
+
brief?: PrBrief
|
|
58
|
+
/** Issues this PR closes, from pr.json's closingIssuesReferences. */
|
|
59
|
+
issues?: BriefIssue[]
|
|
31
60
|
/** Shiki highlighter, prepared by the caller. */
|
|
32
61
|
highlighter: Highlighter
|
|
33
62
|
}
|
|
@@ -76,6 +105,40 @@ function prHeader(input: RenderFindingsInput): string {
|
|
|
76
105
|
</header>`
|
|
77
106
|
}
|
|
78
107
|
|
|
108
|
+
function briefBlock(brief: PrBrief | undefined, issues: BriefIssue[]): string {
|
|
109
|
+
// Absent brief renders nothing: archived runs from before the scout stage
|
|
110
|
+
// existed must still open cleanly.
|
|
111
|
+
if (!brief) return ''
|
|
112
|
+
const changes =
|
|
113
|
+
brief.changes.length > 0
|
|
114
|
+
? `<ul class="brief-changes">${brief.changes.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>`
|
|
115
|
+
: ''
|
|
116
|
+
const subsystems =
|
|
117
|
+
brief.subsystems.length > 0
|
|
118
|
+
? `<div class="brief-subsystems">${brief.subsystems
|
|
119
|
+
.map((s) => `<span class="brief-chip" title="${esc(s.role)}">${esc(s.name)}</span>`)
|
|
120
|
+
.join('')}</div>`
|
|
121
|
+
: ''
|
|
122
|
+
const issueLinks =
|
|
123
|
+
issues.length > 0
|
|
124
|
+
? `<div class="brief-issues">${issues
|
|
125
|
+
.map(
|
|
126
|
+
(i) =>
|
|
127
|
+
`<a class="brief-issue" href="${esc(i.url)}" title="${esc(i.title)}">#${i.number}</a>`,
|
|
128
|
+
)
|
|
129
|
+
.join('')}</div>`
|
|
130
|
+
: ''
|
|
131
|
+
return `<details class="pr-brief" open>
|
|
132
|
+
<summary class="brief-summary">What this PR is for</summary>
|
|
133
|
+
<div class="brief-body">
|
|
134
|
+
<p class="brief-purpose">${esc(brief.purpose)}</p>
|
|
135
|
+
${changes}
|
|
136
|
+
${subsystems}
|
|
137
|
+
${issueLinks}
|
|
138
|
+
</div>
|
|
139
|
+
</details>`
|
|
140
|
+
}
|
|
141
|
+
|
|
79
142
|
function filePane(opts: {
|
|
80
143
|
file: PrFileEntry
|
|
81
144
|
fileDiff: string
|
|
@@ -163,6 +226,7 @@ export function renderFindingsHtml(input: RenderFindingsInput): string {
|
|
|
163
226
|
const diff = input.diff ?? ''
|
|
164
227
|
const splitDiffs = splitDiffByFile(diff)
|
|
165
228
|
const selectedIds = new Set<string>()
|
|
229
|
+
const briefHtml = briefBlock(input.brief, input.issues ?? [])
|
|
166
230
|
|
|
167
231
|
if (input.findings.length === 0) {
|
|
168
232
|
return `<!DOCTYPE html>
|
|
@@ -175,6 +239,7 @@ export function renderFindingsHtml(input: RenderFindingsInput): string {
|
|
|
175
239
|
</head>
|
|
176
240
|
<body data-run-id="${esc(runId)}" data-page="findings" data-view="files" data-diff-mode="unified" data-show-suggestions="false">
|
|
177
241
|
${prHeader(input)}
|
|
242
|
+
${briefHtml}
|
|
178
243
|
<main class="page-main">
|
|
179
244
|
<div class="empty-state"><h1>No findings</h1><p>All specialists returned cleanly.</p></div>
|
|
180
245
|
</main>
|
|
@@ -223,6 +288,7 @@ ${prHeader(input)}
|
|
|
223
288
|
</head>
|
|
224
289
|
<body data-run-id="${esc(runId)}" data-page="findings" data-view="files" data-diff-mode="unified" data-show-suggestions="false">
|
|
225
290
|
${prHeader(input)}
|
|
291
|
+
${briefHtml}
|
|
226
292
|
<main class="page-main">
|
|
227
293
|
<div class="view files-view">
|
|
228
294
|
${tree}
|
|
@@ -15,9 +15,9 @@ const ORDER = [
|
|
|
15
15
|
export type StatusResult = {
|
|
16
16
|
/**
|
|
17
17
|
* Highest stage the log says is behind us. A stage logged `skipped` counts:
|
|
18
|
-
* `context`
|
|
19
|
-
* treating it as unfinished would send a resume
|
|
20
|
-
*
|
|
18
|
+
* `context` logs `skipped` when the scout produced no brief, and the pipeline
|
|
19
|
+
* has nothing to go back for, so treating it as unfinished would send a resume
|
|
20
|
+
* to a stage with no remaining work.
|
|
21
21
|
*/
|
|
22
22
|
lastCompleted: (typeof ORDER)[number] | null
|
|
23
23
|
next: (typeof ORDER)[number] | 'cleanup'
|
|
@@ -332,3 +332,53 @@ export type PrFileEntry = {
|
|
|
332
332
|
export function isSuggestion(f: ReviewFinding): boolean {
|
|
333
333
|
return f.risk.action === 'consider' || f.risk.action === 'optional'
|
|
334
334
|
}
|
|
335
|
+
|
|
336
|
+
export type BriefSubsystem = {
|
|
337
|
+
name: string
|
|
338
|
+
role: string
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Scout-produced PR summary. Written to `$RUN_DIR/brief.json` by the context stage. */
|
|
342
|
+
export type PrBrief = {
|
|
343
|
+
purpose: string
|
|
344
|
+
changes: string[]
|
|
345
|
+
subsystems: BriefSubsystem[]
|
|
346
|
+
watchItems: string[]
|
|
347
|
+
unclear: string[]
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function briefStrings(raw: unknown): string[] {
|
|
351
|
+
if (!Array.isArray(raw)) return []
|
|
352
|
+
return raw
|
|
353
|
+
.filter((v): v is string => typeof v === 'string')
|
|
354
|
+
.map((v) => v.trim())
|
|
355
|
+
.filter((v) => v.length > 0)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Lenient by design, unlike `parseFinding`. A subagent-authored brief that came
|
|
360
|
+
* back malformed must degrade the report header to absent, not throw away an
|
|
361
|
+
* otherwise-complete review at render time.
|
|
362
|
+
*/
|
|
363
|
+
export function parseBrief(raw: unknown): PrBrief | null {
|
|
364
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
365
|
+
const r = raw as Record<string, unknown>
|
|
366
|
+
const purpose = typeof r.purpose === 'string' ? r.purpose.trim() : ''
|
|
367
|
+
if (purpose.length === 0) return null
|
|
368
|
+
const subsystems: BriefSubsystem[] = Array.isArray(r.subsystems)
|
|
369
|
+
? (r.subsystems as unknown[]).flatMap((entry) => {
|
|
370
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
|
|
371
|
+
const e = entry as Record<string, unknown>
|
|
372
|
+
const name = typeof e.name === 'string' ? e.name.trim() : ''
|
|
373
|
+
if (name.length === 0) return []
|
|
374
|
+
return [{ name, role: typeof e.role === 'string' ? e.role.trim() : '' }]
|
|
375
|
+
})
|
|
376
|
+
: []
|
|
377
|
+
return {
|
|
378
|
+
purpose,
|
|
379
|
+
changes: briefStrings(r.changes),
|
|
380
|
+
subsystems,
|
|
381
|
+
watchItems: briefStrings(r.watchItems),
|
|
382
|
+
unclear: briefStrings(r.unclear),
|
|
383
|
+
}
|
|
384
|
+
}
|