@iceinvein/agent-skills 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/skills/bug-hunter/SKILL.md +5 -0
  4. package/skills/bug-hunter/evals/README.md +6 -1
  5. package/skills/bug-hunter/evals/callers-tree-unchanged/graders/output-ignores-itself.md +11 -0
  6. package/skills/bug-hunter/evals/rules.json +8 -0
  7. package/skills/bug-hunter/skill.json +1 -1
  8. package/skills/index.json +4 -4
  9. package/skills/magpie/README.md +5 -3
  10. package/skills/magpie/SKILL.md +40 -15
  11. package/skills/magpie/bin/magpie.ts +83 -7
  12. package/skills/magpie/evals/README.md +7 -4
  13. package/skills/magpie/evals/local-mode-hands-back/case.yaml +4 -0
  14. package/skills/magpie/evals/local-mode-hands-back/fixture.sh +300 -0
  15. package/skills/magpie/evals/local-mode-hands-back/graders/branch-was-reachable.md +5 -0
  16. package/skills/magpie/evals/local-mode-hands-back/graders/cleanup-after-handoff.md +6 -0
  17. package/skills/magpie/evals/local-mode-hands-back/graders/handoff-was-run.md +6 -0
  18. package/skills/magpie/evals/local-mode-hands-back/graders/nothing-served-or-posted.md +7 -0
  19. package/skills/magpie/evals/local-mode-hands-back/graders/reply-lists-both-findings.md +10 -0
  20. package/skills/magpie/evals/local-mode-hands-back/graders/skill-fired.md +5 -0
  21. package/skills/magpie/evals/local-mode-hands-back/prompt.md +11 -0
  22. package/skills/magpie/evals/quality/README.md +73 -2
  23. package/skills/magpie/evals/quality/__tests__/recall.test.ts +234 -0
  24. package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +164 -0
  25. package/skills/magpie/evals/quality/corpus.ts +4 -1
  26. package/skills/magpie/evals/quality/recall.ts +249 -0
  27. package/skills/magpie/evals/quality/replay-critic.ts +3 -0
  28. package/skills/magpie/evals/quality/replay-full.ts +168 -6
  29. package/skills/magpie/evals/rules.json +9 -1
  30. package/skills/magpie/references/checks.md +78 -0
  31. package/skills/magpie/references/peer-review.md +7 -0
  32. package/skills/magpie/references/scout.md +12 -5
  33. package/skills/magpie/references/specialists.md +10 -8
  34. package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +94 -0
  35. package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +18 -0
  36. package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +80 -1
  37. package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +319 -0
  38. package/skills/magpie/scripts/__tests__/housekeeping-cmd.test.ts +14 -0
  39. package/skills/magpie/scripts/__tests__/local-diff.test.ts +275 -0
  40. package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -0
  41. package/skills/magpie/scripts/__tests__/resolve-run.test.ts +28 -0
  42. package/skills/magpie/scripts/__tests__/server.test.ts +30 -1
  43. package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +324 -1
  44. package/skills/magpie/scripts/__tests__/skill-lint.test.ts +8 -5
  45. package/skills/magpie/scripts/__tests__/status-cmd.test.ts +44 -0
  46. package/skills/magpie/scripts/__tests__/target.test.ts +84 -0
  47. package/skills/magpie/scripts/__tests__/types.test.ts +2 -1
  48. package/skills/magpie/scripts/cleanup-cmd.ts +5 -1
  49. package/skills/magpie/scripts/critic-cmd.ts +2 -1
  50. package/skills/magpie/scripts/dedupe-cmd.ts +15 -5
  51. package/skills/magpie/scripts/handoff-cmd.ts +132 -0
  52. package/skills/magpie/scripts/housekeeping-cmd.ts +7 -5
  53. package/skills/magpie/scripts/local-diff.ts +228 -0
  54. package/skills/magpie/scripts/post-cmd.ts +1 -0
  55. package/skills/magpie/scripts/render-annotation.ts +1 -0
  56. package/skills/magpie/scripts/resolve-run.ts +17 -3
  57. package/skills/magpie/scripts/server.ts +9 -1
  58. package/skills/magpie/scripts/setup-cmd.ts +241 -49
  59. package/skills/magpie/scripts/status-cmd.ts +22 -6
  60. package/skills/magpie/scripts/target.ts +56 -0
  61. package/skills/magpie/scripts/types.ts +1 -0
  62. package/skills/magpie/skill.json +2 -2
  63. package/skills/sluice/SKILL.md +3 -1
  64. package/skills/sluice/evals/README.md +10 -2
  65. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/case.yaml +4 -0
  66. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/fixture.sh +108 -0
  67. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/deploy-names-its-steps.md +9 -0
  68. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/kept-status-by-changing-code.md +12 -0
  69. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/sluice-fired.md +5 -0
  70. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/status-test-unchanged.md +6 -0
  71. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/prompt.md +11 -0
  72. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/case.yaml +4 -0
  73. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/fixture.sh +73 -0
  74. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/proved-after-the-last-edit.md +18 -0
  75. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/sluice-fired.md +5 -0
  76. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/test-expects-the-new-line.md +5 -0
  77. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/updated-without-asking.md +11 -0
  78. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/prompt.md +11 -0
  79. package/skills/sluice/evals/rules.json +11 -0
  80. package/skills/sluice/references/review.md +6 -4
  81. package/skills/sluice/references/test-first.md +24 -4
  82. package/skills/sluice/scripts/content-digest.sh +1 -1
  83. package/skills/sluice/scripts/run-stats.sh +14 -0
  84. package/skills/sluice/scripts/session-start.sh +44 -2
  85. package/skills/sluice/scripts/status.sh +293 -0
  86. package/skills/sluice/scripts/stop-guard.sh +80 -6
  87. package/skills/sluice/scripts/test-changes.sh +135 -0
  88. package/skills/sluice/skill.json +1 -1
@@ -7,6 +7,7 @@ import {
7
7
  DEFAULT_BATCH_SIZE,
8
8
  parseCriticVerdicts,
9
9
  } from './critic.ts'
10
+ import { reviewRoot } from './target.ts'
10
11
  import { parseBrief, parseFinding, type ReviewFinding, type ReviewRule } from './types.ts'
11
12
 
12
13
  const DEFAULT_TEMPLATE_PATH = new URL('../references/critic.md', import.meta.url).pathname
@@ -84,7 +85,7 @@ export async function runCriticPrompt(
84
85
  candidates: await readCandidates(runDir),
85
86
  mergeCandidates: await readMergeCandidates(runDir),
86
87
  reviewRules: await readReviewRules(runDir),
87
- worktree: join(runDir, 'worktree'),
88
+ worktree: await reviewRoot(runDir),
88
89
  diffPath: join(runDir, 'diff.patch'),
89
90
  runDir,
90
91
  batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE,
@@ -1,3 +1,4 @@
1
+ import { existsSync } from 'node:fs'
1
2
  import { appendFile, readdir, readFile, rm, writeFile } from 'node:fs/promises'
2
3
  import { join } from 'node:path'
3
4
  import { annotateChangedLines } from './changed-lines.ts'
@@ -5,6 +6,7 @@ import { deduplicateFindings, findMergeCandidates } from './dedupe.ts'
5
6
  import { verifyEvidence } from './evidence-filter.ts'
6
7
  import { namespaceId, parseFindingsFilename } from './findings-files.ts'
7
8
  import { DEFAULT_THRESHOLD, scoreRisk } from './score.ts'
9
+ import { readTarget } from './target.ts'
8
10
  import { FOCUS_IDS, parseFinding, type ReviewFinding } from './types.ts'
9
11
 
10
12
  async function logLine(runDir: string, entry: Record<string, unknown>): Promise<void> {
@@ -18,11 +20,12 @@ export type RunDedupeOptions = {
18
20
  }
19
21
 
20
22
  /**
21
- * The five focuses a specialist subagent writes. `tests` is a `FOCUS_IDS` entry
22
- * too, but setup writes `findings/tests.json` once for the whole run with no
23
- * subagent, so it is never expected per shard.
23
+ * The five focuses a specialist subagent writes per shard. `tests` and `checks`
24
+ * are `FOCUS_IDS` entries too, but each is written once for the whole run
25
+ * (`tests.json` by setup, `checks.json` by the stage 3 checks pass), so neither
26
+ * is ever expected per shard.
24
27
  */
25
- const SPECIALIST_FOCUS_IDS = FOCUS_IDS.filter((id) => id !== 'tests')
28
+ const SPECIALIST_FOCUS_IDS = FOCUS_IDS.filter((id) => id !== 'tests' && id !== 'checks')
26
29
 
27
30
  type Coverage = { expected: number; missing: string[] }
28
31
 
@@ -61,6 +64,13 @@ async function reconcileCoverage(runDir: string, present: Set<string>): Promise<
61
64
  }
62
65
 
63
66
  export async function runDedupe(runDir: string, options: RunDedupeOptions = {}): Promise<number> {
67
+ const target = await readTarget(runDir)
68
+ // A missing PR worktree means an archived run and evidence checks are skipped; a
69
+ // missing local root means the caller's repo moved or was mistyped.
70
+ if (target.mode === 'local' && !existsSync(target.root)) {
71
+ process.stderr.write(`magpie: dedupe: review root ${target.root} does not exist\n`)
72
+ return 1
73
+ }
64
74
  const threshold = options.threshold ?? DEFAULT_THRESHOLD
65
75
  const findingsDir = join(runDir, 'findings')
66
76
  const collected: ReviewFinding[] = []
@@ -123,7 +133,7 @@ export async function runDedupe(runDir: string, options: RunDedupeOptions = {}):
123
133
 
124
134
  const deduped = deduplicateFindings(collected)
125
135
  const scored = deduped.map((f) => ({ ...f, score: scoreRisk(f.risk) }))
126
- const evidence = await verifyEvidence(scored, join(runDir, 'worktree'))
136
+ const evidence = await verifyEvidence(scored, target.root)
127
137
  let diff = ''
128
138
  try {
129
139
  diff = await readFile(join(runDir, 'diff.patch'), 'utf8')
@@ -0,0 +1,132 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { appendFile, readFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { formatFindingDescriptionMarkdown } from './finding-description.ts'
5
+ import { diffSha256, localBranchDiff } from './local-diff.ts'
6
+ import { rankActionable } from './render-issues-list.ts'
7
+ import { readTarget } from './target.ts'
8
+ import { isSuggestion, parseFinding, type ReviewFinding, type Suggestion } from './types.ts'
9
+
10
+ async function logLine(runDir: string, entry: Record<string, unknown>): Promise<void> {
11
+ const line = `${JSON.stringify({ ...entry, ts: Date.now() })}\n`
12
+ await appendFile(join(runDir, 'log.jsonl'), line)
13
+ }
14
+
15
+ export type FormatHandoffInput = {
16
+ target: string
17
+ findings: ReviewFinding[]
18
+ stale: boolean
19
+ }
20
+
21
+ /** A fence longer than any backtick run in the body, so the body cannot close it early. */
22
+ function fenceFor(body: string): string {
23
+ const longestRun = Math.max(0, ...(body.match(/`+/g) ?? []).map((run) => run.length))
24
+ return '`'.repeat(Math.max(3, longestRun + 1))
25
+ }
26
+
27
+ function formatSuggestion(s: Suggestion): string {
28
+ const lines =
29
+ s.startLine === s.endLine ? `line ${s.startLine}` : `lines ${s.startLine}-${s.endLine}`
30
+ const fence = fenceFor(s.body)
31
+ return `Suggested replacement for ${lines}:\n${fence}\n${s.body}\n${fence}`
32
+ }
33
+
34
+ function formatItem(n: number, f: ReviewFinding): string {
35
+ const anchor = f.line === null ? f.file : `${f.file}:${f.line}`
36
+ const parts = [`${n}. [${f.risk.action}] ${anchor}: ${f.title}`]
37
+ const body = formatFindingDescriptionMarkdown(f.description)
38
+ if (body) parts.push(body)
39
+ if (f.suggestion) parts.push(formatSuggestion(f.suggestion))
40
+ return parts.join('\n\n')
41
+ }
42
+
43
+ export function formatHandoff(input: FormatHandoffInput): string {
44
+ const { target, findings, stale } = input
45
+ const header = [`# magpie local review: ${target}`]
46
+ if (stale) {
47
+ header.push('Warning: the tree changed since this review began; line anchors may be off.')
48
+ }
49
+ if (findings.length === 0) return `${header.join('\n')}\n\nNo findings.\n`
50
+
51
+ const mustFix = findings.filter((f) => f.risk.action === 'must-fix').length
52
+ const shouldFix = findings.filter((f) => f.risk.action === 'should-fix').length
53
+ const optional = findings.filter(isSuggestion).length
54
+ // topN covers every finding, so nothing folds and the order is score alone.
55
+ const { recommended } = rankActionable(findings, {}, new Map(), findings.length)
56
+ const ordered = [...recommended, ...findings.filter(isSuggestion)]
57
+
58
+ const sections = [
59
+ header.join('\n'),
60
+ `${mustFix} must-fix, ${shouldFix} should-fix, ${optional} optional`,
61
+ ...ordered.map((f, i) => formatItem(i + 1, f)),
62
+ 'Address every must-fix and should-fix, or say why not. The rest are optional.',
63
+ ]
64
+ return `${sections.join('\n\n')}\n`
65
+ }
66
+
67
+ async function readFindings(path: string): Promise<ReviewFinding[]> {
68
+ const raw: unknown = JSON.parse(await readFile(path, 'utf8'))
69
+ if (!Array.isArray(raw)) throw new Error(`${path}: expected an array of findings`)
70
+ return raw.map(parseFinding)
71
+ }
72
+
73
+ async function readLocalMeta(path: string): Promise<{ branch: string | null; baseRef: string }> {
74
+ const raw: unknown = JSON.parse(await readFile(path, 'utf8'))
75
+ if (typeof raw !== 'object' || raw === null) throw new Error(`${path}: expected an object`)
76
+ const { branch, baseRef } = raw as Record<string, unknown>
77
+ if (typeof baseRef !== 'string') {
78
+ throw new Error(`${path}: baseRef must be a string, got ${JSON.stringify(baseRef)}`)
79
+ }
80
+ if (branch !== null && typeof branch !== 'string') {
81
+ throw new Error(`${path}: branch must be a string or null, got ${JSON.stringify(branch)}`)
82
+ }
83
+ return { branch, baseRef }
84
+ }
85
+
86
+ /** The recorded sha is of the raw diff the review started from; a missing one cannot vouch for freshness. */
87
+ async function recordedDiffSha(runDir: string): Promise<string | null> {
88
+ const path = join(runDir, 'diff-source.json')
89
+ if (!existsSync(path)) return null
90
+ const raw: unknown = JSON.parse(await readFile(path, 'utf8'))
91
+ if (typeof raw !== 'object' || raw === null) return null
92
+ const { diffSha256: sha } = raw as Record<string, unknown>
93
+ return typeof sha === 'string' ? sha : null
94
+ }
95
+
96
+ export async function runHandoff(runDir: string, opts: { gitBin?: string } = {}): Promise<number> {
97
+ const gitBin = opts.gitBin ?? 'git'
98
+ const target = await readTarget(runDir)
99
+ if (target.mode !== 'local') {
100
+ process.stderr.write(`magpie: handoff only applies to local runs; ${runDir} is a PR run\n`)
101
+ return 2
102
+ }
103
+ const findingsPath = join(runDir, 'findings.final.json')
104
+ const metaPath = join(runDir, 'local.json')
105
+ for (const path of [findingsPath, metaPath]) {
106
+ if (!existsSync(path)) {
107
+ process.stderr.write(`magpie: handoff: ${path} is missing\n`)
108
+ return 4
109
+ }
110
+ }
111
+ const findings = await readFindings(findingsPath)
112
+ const meta = await readLocalMeta(metaPath)
113
+
114
+ const recorded = await recordedDiffSha(runDir)
115
+ // A failed recompute proves nothing about the tree, so it is reported as stale.
116
+ let currentSha: string | null = null
117
+ try {
118
+ const current = await localBranchDiff({ gitBin, repoPath: target.root, baseRef: meta.baseRef })
119
+ if (current.ok) currentSha = diffSha256(current.diff)
120
+ } catch (err) {
121
+ const message = err instanceof Error ? err.message : String(err)
122
+ process.stderr.write(`magpie: handoff: could not recompute the diff: ${message}\n`)
123
+ }
124
+ const stale = recorded === null || currentSha === null || currentSha !== recorded
125
+
126
+ const subject = meta.branch === null ? 'detached HEAD' : `local branch ${meta.branch}`
127
+ process.stdout.write(
128
+ formatHandoff({ target: `${subject} against ${meta.baseRef}`, findings, stale }),
129
+ )
130
+ await logLine(runDir, { stage: 'handoff', status: 'done', findings: findings.length })
131
+ return 0
132
+ }
@@ -2,8 +2,10 @@ import { readdir, rm, stat } from 'node:fs/promises'
2
2
  import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
 
5
- const ACTIVE_PATTERN = /^pr-\d+-\d+$/
6
- const ARCHIVED_PATTERN = /^pr-\d+-\d+\.archived-\d+$/
5
+ // Local slugs may contain dots and dashes, so an archived local id also matches
6
+ // ACTIVE_PATTERN: test ARCHIVED_PATTERN first.
7
+ const ACTIVE_PATTERN = /^(?:pr-\d+|local-[A-Za-z0-9._-]+)-\d+$/
8
+ const ARCHIVED_PATTERN = /^(?:pr-\d+|local-[A-Za-z0-9._-]+)-\d+\.archived-\d+$/
7
9
 
8
10
  export function reviewHome(): string {
9
11
  return process.env.MAGPIE_HOME ?? join(homedir(), '.magpie')
@@ -24,10 +26,10 @@ export async function listRuns(home?: string): Promise<RunInfo[]> {
24
26
  const path = join(root, id)
25
27
  const s = await stat(path).catch(() => null)
26
28
  if (!s?.isDirectory()) continue
27
- if (ACTIVE_PATTERN.test(id)) {
28
- runs.push({ id, archived: false, path })
29
- } else if (ARCHIVED_PATTERN.test(id)) {
29
+ if (ARCHIVED_PATTERN.test(id)) {
30
30
  runs.push({ id, archived: true, path })
31
+ } else if (ACTIVE_PATTERN.test(id)) {
32
+ runs.push({ id, archived: false, path })
31
33
  }
32
34
  }
33
35
  return runs
@@ -0,0 +1,228 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync } from 'node:fs'
3
+ import { copyFile, mkdtemp, realpath, rm, stat, utimes } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join, resolve } from 'node:path'
6
+
7
+ export type LocalBranchDiffInput = {
8
+ gitBin: string
9
+ repoPath: string
10
+ baseRef?: string
11
+ }
12
+
13
+ export type LocalBranchDiffResult =
14
+ | { ok: true; diff: string; baseRef: string; mergeBase: string; head: string }
15
+ | { ok: false; error: string }
16
+
17
+ export type LocalMeta = {
18
+ branch: string | null
19
+ baseRef: string
20
+ mergeBase: string
21
+ head: string
22
+ dirty: boolean
23
+ author: string
24
+ commits: Array<{ oid: string; headline: string; body: string }>
25
+ intentFile: string | null
26
+ }
27
+
28
+ export type LocalBranchMetaInput = {
29
+ gitBin: string
30
+ repoPath: string
31
+ baseRef: string
32
+ mergeBase: string
33
+ head: string
34
+ intentFile: string | null
35
+ }
36
+
37
+ export type LocalBranchMetaResult = { ok: true; meta: LocalMeta } | { ok: false; error: string }
38
+
39
+ async function git(
40
+ gitBin: string,
41
+ cwd: string,
42
+ args: string[],
43
+ extraEnv: Record<string, string> = {},
44
+ ): Promise<{ exit: number; stdout: string; stderr: string }> {
45
+ // Bun.spawn snapshots the env at startup unless given one, so pass the live
46
+ // env to honour GIT_CONFIG_* or HOME set after launch. GIT_OPTIONAL_LOCKS=0
47
+ // stops status refreshing the caller's index. quotePath=false keeps
48
+ // non-ASCII paths readable by the downstream header parsers.
49
+ const proc = Bun.spawn([gitBin, '-c', 'core.quotePath=false', ...args], {
50
+ cwd,
51
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', ...extraEnv },
52
+ stdout: 'pipe',
53
+ stderr: 'pipe',
54
+ })
55
+ const [stdout, stderr] = await Promise.all([
56
+ new Response(proc.stdout).text(),
57
+ new Response(proc.stderr).text(),
58
+ ])
59
+ const exit = await proc.exited
60
+ return { exit, stdout, stderr }
61
+ }
62
+
63
+ // Pin the patch format against user config (noprefix, mnemonicPrefix, external
64
+ // diff drivers, textconv) so the downstream parsers always see git's default.
65
+ const DIFF_FORMAT = [
66
+ '--no-color',
67
+ '--no-ext-diff',
68
+ '--no-textconv',
69
+ '--src-prefix=a/',
70
+ '--dst-prefix=b/',
71
+ ]
72
+
73
+ async function resolveCommit(gitBin: string, cwd: string, ref: string): Promise<string | null> {
74
+ const r = await git(gitBin, cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
75
+ const sha = r.stdout.trim()
76
+ return r.exit === 0 && sha.length > 0 ? sha : null
77
+ }
78
+
79
+ /**
80
+ * Diff the checked-out branch against its merge base with `baseRef`, including
81
+ * uncommitted edits and untracked (non-ignored) files. Read-only: untracked
82
+ * files go through `--no-index` rather than `git add -N`, so the caller's index
83
+ * is never touched, and nothing is fetched.
84
+ */
85
+ export async function localBranchDiff(input: LocalBranchDiffInput): Promise<LocalBranchDiffResult> {
86
+ const { gitBin, repoPath } = input
87
+ const baseRef = input.baseRef ?? 'origin/HEAD'
88
+
89
+ const top = await git(gitBin, repoPath, ['rev-parse', '--show-toplevel'])
90
+ if (top.exit !== 0) {
91
+ return { ok: false, error: `${repoPath} is not a git repository: ${top.stderr.trim()}` }
92
+ }
93
+ const toplevel = top.stdout.trim()
94
+ if ((await realpath(toplevel)) !== (await realpath(repoPath))) {
95
+ return { ok: false, error: `${repoPath} is not the repository root (${toplevel})` }
96
+ }
97
+
98
+ const base = await resolveCommit(gitBin, repoPath, baseRef)
99
+ if (!base) return { ok: false, error: `cannot resolve base ref ${baseRef} to a commit` }
100
+
101
+ const head = await resolveCommit(gitBin, repoPath, 'HEAD')
102
+ if (!head) return { ok: false, error: 'cannot resolve HEAD to a commit' }
103
+
104
+ const mb = await git(gitBin, repoPath, ['merge-base', base, head])
105
+ const mergeBase = mb.stdout.trim()
106
+ if (mb.exit !== 0 || !mergeBase) {
107
+ return { ok: false, error: `no merge base between ${baseRef} and HEAD: ${mb.stderr.trim()}` }
108
+ }
109
+
110
+ const indexPath = await git(gitBin, repoPath, ['rev-parse', '--git-path', 'index'])
111
+ if (indexPath.exit !== 0) {
112
+ return { ok: false, error: `git rev-parse --git-path index failed: ${indexPath.stderr.trim()}` }
113
+ }
114
+ const realIndex = resolve(repoPath, indexPath.stdout.trim())
115
+ const scratch = await mkdtemp(join(tmpdir(), 'magpie-index-'))
116
+ let tracked: { exit: number; stdout: string; stderr: string }
117
+ try {
118
+ // git refreshes stat info into the index on `git diff <commit>` regardless
119
+ // of optional locks, so that diff runs against a throwaway copy.
120
+ const indexEnv: Record<string, string> = {}
121
+ if (existsSync(realIndex)) {
122
+ const copy = join(scratch, 'index')
123
+ // git trusts cached stat data only for files older than the index file,
124
+ // so a fresh mtime on the copy would hide same-second edits. The stat
125
+ // comes first so a concurrent index rewrite can only pair newer bytes
126
+ // with an older mtime, which makes git recheck more, not less.
127
+ const original = await stat(realIndex)
128
+ await copyFile(realIndex, copy)
129
+ await utimes(copy, original.atime, original.mtime)
130
+ indexEnv.GIT_INDEX_FILE = copy
131
+ }
132
+ tracked = await git(
133
+ gitBin,
134
+ repoPath,
135
+ ['diff', ...DIFF_FORMAT, '--find-renames', mergeBase],
136
+ indexEnv,
137
+ )
138
+ } finally {
139
+ await rm(scratch, { recursive: true, force: true })
140
+ }
141
+ if (tracked.exit !== 0) {
142
+ return { ok: false, error: `git diff ${mergeBase} failed: ${tracked.stderr.trim()}` }
143
+ }
144
+
145
+ const others = await git(gitBin, repoPath, ['ls-files', '--others', '--exclude-standard', '-z'])
146
+ if (others.exit !== 0) {
147
+ return { ok: false, error: `git ls-files failed: ${others.stderr.trim()}` }
148
+ }
149
+ const untracked = others.stdout.split('\0').filter((p) => p.length > 0)
150
+
151
+ let diff = tracked.stdout
152
+ for (const path of untracked) {
153
+ // `--no-index` exits 1 when the files differ, which is always the case here.
154
+ const r = await git(gitBin, repoPath, [
155
+ 'diff',
156
+ ...DIFF_FORMAT,
157
+ '--no-index',
158
+ '--',
159
+ '/dev/null',
160
+ path,
161
+ ])
162
+ if (r.exit !== 1) {
163
+ return {
164
+ ok: false,
165
+ error: `git diff --no-index ${path} exited ${r.exit}: ${r.stderr.trim()}`,
166
+ }
167
+ }
168
+ diff += r.stdout
169
+ }
170
+
171
+ if (diff.length === 0) return { ok: false, error: `nothing to review against ${baseRef}` }
172
+ return { ok: true, diff, baseRef, mergeBase, head }
173
+ }
174
+
175
+ export async function localBranchMeta(input: LocalBranchMetaInput): Promise<LocalBranchMetaResult> {
176
+ const { gitBin, repoPath, baseRef, mergeBase, head, intentFile } = input
177
+
178
+ // Exit 1 with no output is a detached HEAD, not an error.
179
+ const sym = await git(gitBin, repoPath, ['symbolic-ref', '--short', '-q', 'HEAD'])
180
+ if (sym.exit !== 0 && sym.exit !== 1) {
181
+ return { ok: false, error: `git symbolic-ref failed: ${sym.stderr.trim()}` }
182
+ }
183
+ const branch = sym.exit === 0 ? sym.stdout.trim() : null
184
+
185
+ const status = await git(gitBin, repoPath, ['status', '--porcelain'])
186
+ if (status.exit !== 0) return { ok: false, error: `git status failed: ${status.stderr.trim()}` }
187
+
188
+ const name = await git(gitBin, repoPath, ['config', 'user.name'])
189
+ const author = name.stdout.trim()
190
+ if (name.exit !== 0 || !author) {
191
+ return { ok: false, error: 'git config user.name is not set; set it and retry' }
192
+ }
193
+
194
+ const log = await git(gitBin, repoPath, [
195
+ 'log',
196
+ '--format=%H%x1f%s%x1f%b%x1e',
197
+ `${mergeBase}..${head}`,
198
+ ])
199
+ if (log.exit !== 0) return { ok: false, error: `git log failed: ${log.stderr.trim()}` }
200
+ const commits: LocalMeta['commits'] = []
201
+ for (const record of log.stdout.split('\x1e')) {
202
+ const trimmed = record.replace(/^\n/, '')
203
+ if (!trimmed) continue
204
+ const [oid, headline, body] = trimmed.split('\x1f')
205
+ if (oid === undefined || headline === undefined || body === undefined) {
206
+ return { ok: false, error: `malformed git log record: ${JSON.stringify(trimmed)}` }
207
+ }
208
+ commits.push({ oid, headline, body: body.trim() })
209
+ }
210
+
211
+ return {
212
+ ok: true,
213
+ meta: {
214
+ branch,
215
+ baseRef,
216
+ mergeBase,
217
+ head,
218
+ dirty: status.stdout.trim().length > 0,
219
+ author,
220
+ commits,
221
+ intentFile,
222
+ },
223
+ }
224
+ }
225
+
226
+ export function diffSha256(diff: string): string {
227
+ return createHash('sha256').update(diff, 'utf8').digest('hex')
228
+ }
@@ -73,6 +73,7 @@ const FOCUS_LABEL: Record<FocusId, string> = {
73
73
  performance: 'Performance',
74
74
  'code-smells': 'Code Smells',
75
75
  architecture: 'Architecture',
76
+ checks: 'Checks',
76
77
  tests: 'Tests',
77
78
  }
78
79
 
@@ -10,6 +10,7 @@ const DOMAIN_LABELS: Record<string, string> = {
10
10
  performance: 'Perf',
11
11
  'code-smells': 'Smells',
12
12
  architecture: 'Arch',
13
+ checks: 'Checks',
13
14
  }
14
15
 
15
16
  const SEVERITY_LABEL: Record<string, string> = {
@@ -1,6 +1,8 @@
1
+ import { existsSync } from 'node:fs'
1
2
  import { readdir, stat } from 'node:fs/promises'
2
3
  import { isAbsolute, join } from 'node:path'
3
4
  import { reviewHome } from './housekeeping-cmd.ts'
5
+ import { readTarget } from './target.ts'
4
6
 
5
7
  export type ResolvedRun = {
6
8
  id: string
@@ -8,8 +10,15 @@ export type ResolvedRun = {
8
10
  archived: boolean
9
11
  }
10
12
 
13
+ // open and serve render the HTML report, which local runs never get, so only PR
14
+ // runs are candidates here. --list-runs still lists local runs for resume.
11
15
  const ACTIVE_PATTERN = /^pr-\d+-\d+$/
12
16
  const ARCHIVED_PATTERN = /^pr-(\d+)-\d+\.archived-(\d+)$/
17
+ const LOCAL_PATTERN = /^local-[A-Za-z0-9._-]+-\d+(?:\.archived-\d+)?$/
18
+
19
+ function localRunError(idOrPath: string): Error {
20
+ return new Error(`${idOrPath} is a local run; local runs have no report`)
21
+ }
13
22
 
14
23
  type Entry = { id: string; path: string; archived: boolean; mtimeMs: number }
15
24
 
@@ -25,10 +34,10 @@ async function listEntries(home: string): Promise<Entry[]> {
25
34
  const path = join(home, id)
26
35
  const s = await stat(path).catch(() => null)
27
36
  if (!s?.isDirectory()) continue
28
- if (ACTIVE_PATTERN.test(id)) {
29
- out.push({ id, path, archived: false, mtimeMs: s.mtimeMs })
30
- } else if (ARCHIVED_PATTERN.test(id)) {
37
+ if (ARCHIVED_PATTERN.test(id)) {
31
38
  out.push({ id, path, archived: true, mtimeMs: s.mtimeMs })
39
+ } else if (ACTIVE_PATTERN.test(id)) {
40
+ out.push({ id, path, archived: false, mtimeMs: s.mtimeMs })
32
41
  }
33
42
  }
34
43
  return out
@@ -50,11 +59,16 @@ export async function resolveRunDir(
50
59
  if (!(await isReviewDir(path))) {
51
60
  throw new Error(`Not a magpie run directory: ${path}`)
52
61
  }
62
+ // Without target.json the run predates local mode, so it is a PR run.
63
+ if (existsSync(join(path, 'target.json')) && (await readTarget(path)).mode === 'local') {
64
+ throw localRunError(path)
65
+ }
53
66
  const id = path.split('/').filter(Boolean).pop() ?? path
54
67
  return { id, path, archived: ARCHIVED_PATTERN.test(id) }
55
68
  }
56
69
 
57
70
  if (idOrPath) {
71
+ if (LOCAL_PATTERN.test(idOrPath)) throw localRunError(idOrPath)
58
72
  const path = join(root, idOrPath)
59
73
  if (!(await isReviewDir(path))) {
60
74
  throw new Error(`Run id not found: ${idOrPath} (looked in ${root})`)
@@ -15,6 +15,12 @@ export type StartServerInput = {
15
15
  host?: string
16
16
  }
17
17
 
18
+ const FINDINGS_HTML_RE = /^findings(-v\d+)?\.html$/
19
+
20
+ /**
21
+ * Once a findings page exists it is what the user came for, so it wins even
22
+ * though stage 8 re-renders progress after it; until then the newest page does.
23
+ */
18
24
  async function newestScreenPath(runDir: string): Promise<string | null> {
19
25
  const dir = join(runDir, 'screen')
20
26
  let entries: string[]
@@ -23,9 +29,11 @@ async function newestScreenPath(runDir: string): Promise<string | null> {
23
29
  } catch {
24
30
  return null
25
31
  }
32
+ const findings = entries.filter((name) => FINDINGS_HTML_RE.test(name))
33
+ const candidates = findings.length > 0 ? findings : entries
26
34
  let bestPath: string | null = null
27
35
  let bestMtime = -Infinity
28
- for (const name of entries) {
36
+ for (const name of candidates) {
29
37
  if (!name.endsWith('.html')) continue
30
38
  const path = join(dir, name)
31
39
  const s = await stat(path)