@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.
@@ -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('&lt;script&gt;')
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&quot; onmouseover=&quot;alert(1)')
316
+ expect(html).toContain('intermittently&quot; onmouseover=&quot;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. Specialists')).toContain('references/specialists.md')
93
- expect(section('### 5. Critic')).toContain('references/critic.md')
94
- expect(section('### 6. Peer review')).toContain('references/peer-review.md')
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` is a no-op stage: say so, or the agent stalls on it.
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
- { findings, postStatus, runId: basename(runDir), pr, files, diff },
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` has no work in the pipeline and is always logged that way, so
19
- * treating it as unfinished would send a resume back to a stage that has no
20
- * step to run.
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
+ }