@iceinvein/agent-skills 0.22.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/bug-hunter/SKILL.md +8 -1
- package/skills/bug-hunter/evals/README.md +6 -1
- package/skills/bug-hunter/evals/callers-tree-unchanged/graders/output-ignores-itself.md +11 -0
- package/skills/bug-hunter/evals/rules.json +8 -0
- package/skills/bug-hunter/skill.json +1 -1
- package/skills/index.json +6 -6
- package/skills/magpie/README.md +5 -3
- package/skills/magpie/SKILL.md +41 -15
- package/skills/magpie/bin/magpie.ts +83 -7
- package/skills/magpie/evals/README.md +7 -4
- package/skills/magpie/evals/local-mode-hands-back/case.yaml +4 -0
- package/skills/magpie/evals/local-mode-hands-back/fixture.sh +300 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/branch-was-reachable.md +5 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/cleanup-after-handoff.md +6 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/handoff-was-run.md +6 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/nothing-served-or-posted.md +7 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/reply-lists-both-findings.md +10 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/skill-fired.md +5 -0
- package/skills/magpie/evals/local-mode-hands-back/prompt.md +11 -0
- package/skills/magpie/evals/quality/README.md +73 -2
- package/skills/magpie/evals/quality/__tests__/recall.test.ts +234 -0
- package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +164 -0
- package/skills/magpie/evals/quality/corpus.ts +4 -1
- package/skills/magpie/evals/quality/recall.ts +249 -0
- package/skills/magpie/evals/quality/replay-critic.ts +3 -0
- package/skills/magpie/evals/quality/replay-full.ts +168 -6
- package/skills/magpie/evals/rules.json +9 -1
- package/skills/magpie/references/checks.md +78 -0
- package/skills/magpie/references/peer-review.md +7 -0
- package/skills/magpie/references/scout.md +12 -5
- package/skills/magpie/references/specialists.md +10 -8
- package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +94 -0
- package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +18 -0
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +80 -1
- package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +319 -0
- package/skills/magpie/scripts/__tests__/housekeeping-cmd.test.ts +14 -0
- package/skills/magpie/scripts/__tests__/local-diff.test.ts +275 -0
- package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -0
- package/skills/magpie/scripts/__tests__/resolve-run.test.ts +28 -0
- package/skills/magpie/scripts/__tests__/server.test.ts +30 -1
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +324 -1
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +8 -5
- package/skills/magpie/scripts/__tests__/status-cmd.test.ts +44 -0
- package/skills/magpie/scripts/__tests__/target.test.ts +84 -0
- package/skills/magpie/scripts/__tests__/types.test.ts +2 -1
- package/skills/magpie/scripts/cleanup-cmd.ts +5 -1
- package/skills/magpie/scripts/critic-cmd.ts +2 -1
- package/skills/magpie/scripts/dedupe-cmd.ts +15 -5
- package/skills/magpie/scripts/handoff-cmd.ts +132 -0
- package/skills/magpie/scripts/housekeeping-cmd.ts +7 -5
- package/skills/magpie/scripts/local-diff.ts +228 -0
- package/skills/magpie/scripts/post-cmd.ts +1 -0
- package/skills/magpie/scripts/render-annotation.ts +1 -0
- package/skills/magpie/scripts/resolve-run.ts +17 -3
- package/skills/magpie/scripts/server.ts +9 -1
- package/skills/magpie/scripts/setup-cmd.ts +241 -49
- package/skills/magpie/scripts/status-cmd.ts +22 -6
- package/skills/magpie/scripts/target.ts +56 -0
- package/skills/magpie/scripts/types.ts +1 -0
- package/skills/magpie/skill.json +2 -2
- package/skills/scope-estimate/SKILL.md +1 -1
- package/skills/scope-estimate/skill.json +16 -5
- package/skills/sluice/SKILL.md +6 -2
- package/skills/sluice/evals/README.md +10 -2
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/case.yaml +4 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/fixture.sh +108 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/deploy-names-its-steps.md +9 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/kept-status-by-changing-code.md +12 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/sluice-fired.md +5 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/status-test-unchanged.md +6 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/prompt.md +11 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/case.yaml +4 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/fixture.sh +73 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/proved-after-the-last-edit.md +18 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/sluice-fired.md +5 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/test-expects-the-new-line.md +5 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/updated-without-asking.md +11 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/prompt.md +11 -0
- package/skills/sluice/evals/rules.json +11 -0
- package/skills/sluice/references/review.md +6 -4
- package/skills/sluice/references/test-first.md +24 -4
- package/skills/sluice/scripts/content-digest.sh +1 -1
- package/skills/sluice/scripts/run-stats.sh +14 -0
- package/skills/sluice/scripts/session-start.sh +44 -2
- package/skills/sluice/scripts/status.sh +293 -0
- package/skills/sluice/scripts/stop-guard.sh +80 -6
- package/skills/sluice/scripts/test-changes.sh +135 -0
- package/skills/sluice/skill.json +2 -2
|
@@ -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:
|
|
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`
|
|
22
|
-
* too, but
|
|
23
|
-
*
|
|
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,
|
|
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
|
-
|
|
6
|
-
|
|
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 (
|
|
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
|
+
}
|
|
@@ -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 (
|
|
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
|
|
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)
|