@iceinvein/agent-skills 0.1.39 → 0.2.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.
- package/README.md +18 -2
- package/dist/cli/index.js +105 -28
- package/package.json +1 -1
- package/skills/index.json +14 -2
- package/skills/magpie/README.md +2 -1
- package/skills/magpie/SKILL.md +138 -45
- package/skills/magpie/bin/magpie.ts +43 -0
- package/skills/magpie/fixtures/example-pr/brief.json +18 -0
- package/skills/magpie/fixtures/fake-gh-nodiff.sh +38 -0
- package/skills/magpie/fixtures/fake-gh.sh +14 -0
- package/skills/magpie/package.json +1 -1
- package/skills/magpie/references/peer-review.md +7 -2
- package/skills/magpie/references/scout.md +90 -0
- package/skills/magpie/references/specialists.md +117 -6
- package/skills/magpie/scripts/__tests__/cli.test.ts +101 -1
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +187 -0
- package/skills/magpie/scripts/__tests__/diff-chunks.test.ts +51 -0
- package/skills/magpie/scripts/__tests__/filter-diff-preservation.test.ts +54 -0
- package/skills/magpie/scripts/__tests__/findings-files.test.ts +35 -0
- package/skills/magpie/scripts/__tests__/gh.test.ts +90 -0
- package/skills/magpie/scripts/__tests__/git-diff.test.ts +83 -0
- package/skills/magpie/scripts/__tests__/helpers/git-fixture.ts +47 -0
- package/skills/magpie/scripts/__tests__/path-filter.test.ts +27 -0
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +16 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +45 -0
- package/skills/magpie/scripts/__tests__/render-cmd.test.ts +173 -1
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +151 -1
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +42 -0
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +83 -1
- package/skills/magpie/scripts/__tests__/shard.test.ts +165 -0
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +207 -7
- package/skills/magpie/scripts/__tests__/types.test.ts +47 -0
- package/skills/magpie/scripts/dedupe-cmd.ts +58 -3
- package/skills/magpie/scripts/diff-chunks.ts +28 -0
- package/skills/magpie/scripts/findings-files.ts +32 -0
- package/skills/magpie/scripts/gh.ts +68 -14
- package/skills/magpie/scripts/git-diff.ts +111 -0
- package/skills/magpie/scripts/path-filter.ts +9 -5
- package/skills/magpie/scripts/preview-cmd.ts +11 -1
- package/skills/magpie/scripts/refresh.ts +32 -3
- package/skills/magpie/scripts/render-cmd.ts +35 -11
- package/skills/magpie/scripts/render-findings.ts +78 -2
- package/skills/magpie/scripts/render-progress.ts +6 -1
- package/skills/magpie/scripts/setup-cmd.ts +38 -1
- package/skills/magpie/scripts/shard.ts +171 -0
- package/skills/magpie/scripts/status-cmd.ts +7 -4
- package/skills/magpie/scripts/types.ts +50 -0
- package/skills/magpie/skill.json +2 -2
- package/skills/magpie/templates/styles.css +66 -0
- package/skills/migrate/README.md +194 -0
- package/skills/migrate/SKILL.md +197 -0
- package/skills/migrate/bin/migrate +15 -0
- package/skills/migrate/bin/migrate.ts +309 -0
- package/skills/migrate/biome.json +35 -0
- package/skills/migrate/bun.lock +24 -0
- package/skills/migrate/docs/architecture.md +294 -0
- package/skills/migrate/docs/reference.md +590 -0
- package/skills/migrate/fixtures/tiny-express/GROUND-TRUTH.md +39 -0
- package/skills/migrate/fixtures/tiny-express/app.js +29 -0
- package/skills/migrate/fixtures/tiny-express/cron.js +6 -0
- package/skills/migrate/fixtures/tiny-express/reports/daily-users.json +6 -0
- package/skills/migrate/fixtures/tiny-express/schema.sql +12 -0
- package/skills/migrate/fixtures/tiny-express/settings.json +4 -0
- package/skills/migrate/fixtures/tiny-express/views/users.html +9 -0
- package/skills/migrate/fixtures/tiny-webforms/Controllers/UsersController.cs +68 -0
- package/skills/migrate/fixtures/tiny-webforms/Default.aspx +7 -0
- package/skills/migrate/fixtures/tiny-webforms/Default.aspx.cs +14 -0
- package/skills/migrate/fixtures/tiny-webforms/GROUND-TRUTH.md +50 -0
- package/skills/migrate/fixtures/tiny-webforms/Integrations/BillingClient.cs +16 -0
- package/skills/migrate/fixtures/tiny-webforms/Jobs/NightlyDigestJob.cs +33 -0
- package/skills/migrate/fixtures/tiny-webforms/Reports/DailyUsers.rdl +11 -0
- package/skills/migrate/fixtures/tiny-webforms/Schema.sql +12 -0
- package/skills/migrate/fixtures/tiny-webforms/Site.master +16 -0
- package/skills/migrate/fixtures/tiny-webforms/Users.aspx +8 -0
- package/skills/migrate/fixtures/tiny-webforms/Users.aspx.cs +14 -0
- package/skills/migrate/fixtures/tiny-webforms/web.config +10 -0
- package/skills/migrate/install.sh +68 -0
- package/skills/migrate/package.json +17 -0
- package/skills/migrate/references/phases/enumerate.md +291 -0
- package/skills/migrate/references/phases/extract.md +652 -0
- package/skills/migrate/references/phases/parity.md +275 -0
- package/skills/migrate/references/phases/probe.md +135 -0
- package/skills/migrate/references/phases/queue.md +242 -0
- package/skills/migrate/references/phases/seam.md +416 -0
- package/skills/migrate/references/recipes/README.md +116 -0
- package/skills/migrate/references/recipes/aspnet.md +287 -0
- package/skills/migrate/references/run-ops.md +280 -0
- package/skills/migrate/scripts/__tests__/census.test.ts +775 -0
- package/skills/migrate/scripts/__tests__/check.test.ts +458 -0
- package/skills/migrate/scripts/__tests__/citations.test.ts +156 -0
- package/skills/migrate/scripts/__tests__/cli.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/concurrency.test.ts +164 -0
- package/skills/migrate/scripts/__tests__/config.test.ts +112 -0
- package/skills/migrate/scripts/__tests__/e2e-express.test.ts +1093 -0
- package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +1276 -0
- package/skills/migrate/scripts/__tests__/e2e.test.ts +320 -0
- package/skills/migrate/scripts/__tests__/ids.test.ts +38 -0
- package/skills/migrate/scripts/__tests__/import.test.ts +155 -0
- package/skills/migrate/scripts/__tests__/init.test.ts +192 -0
- package/skills/migrate/scripts/__tests__/leaks.test.ts +176 -0
- package/skills/migrate/scripts/__tests__/lock.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/paths.test.ts +129 -0
- package/skills/migrate/scripts/__tests__/phase-cmd.test.ts +151 -0
- package/skills/migrate/scripts/__tests__/phases.test.ts +70 -0
- package/skills/migrate/scripts/__tests__/queue.test.ts +475 -0
- package/skills/migrate/scripts/__tests__/report.test.ts +150 -0
- package/skills/migrate/scripts/__tests__/run-state.test.ts +136 -0
- package/skills/migrate/scripts/__tests__/status-reset.test.ts +318 -0
- package/skills/migrate/scripts/__tests__/store.test.ts +132 -0
- package/skills/migrate/scripts/__tests__/validate.test.ts +54 -0
- package/skills/migrate/scripts/census-cmd.ts +109 -0
- package/skills/migrate/scripts/census.ts +342 -0
- package/skills/migrate/scripts/check-cmd.ts +24 -0
- package/skills/migrate/scripts/check.ts +376 -0
- package/skills/migrate/scripts/citations.ts +92 -0
- package/skills/migrate/scripts/config.ts +237 -0
- package/skills/migrate/scripts/ids.ts +31 -0
- package/skills/migrate/scripts/import-cmd.ts +141 -0
- package/skills/migrate/scripts/init-cmd.ts +118 -0
- package/skills/migrate/scripts/leaks.ts +184 -0
- package/skills/migrate/scripts/lock.ts +188 -0
- package/skills/migrate/scripts/paths.ts +103 -0
- package/skills/migrate/scripts/phase-cmd.ts +63 -0
- package/skills/migrate/scripts/phases.ts +113 -0
- package/skills/migrate/scripts/queue-cmd.ts +98 -0
- package/skills/migrate/scripts/queue.ts +258 -0
- package/skills/migrate/scripts/report-cmd.ts +47 -0
- package/skills/migrate/scripts/report.ts +131 -0
- package/skills/migrate/scripts/reset-cmd.ts +120 -0
- package/skills/migrate/scripts/status-cmd.ts +52 -0
- package/skills/migrate/scripts/store.ts +159 -0
- package/skills/migrate/scripts/types.ts +137 -0
- package/skills/migrate/scripts/validate.ts +221 -0
- package/skills/migrate/skill.json +33 -0
- package/skills/migrate/templates/config.toml +27 -0
- package/skills/migrate/templates/queue-item.md +17 -0
- package/skills/migrate/tsconfig.json +18 -0
- package/skills/migrate/uninstall.sh +31 -0
- package/skills/sluice/SKILL.md +82 -0
- package/skills/sluice/references/deep-channel.md +94 -0
- package/skills/sluice/references/finish.md +35 -0
- package/skills/sluice/references/intent.md +29 -0
- package/skills/sluice/references/review.md +42 -0
- package/skills/sluice/references/root-cause.md +38 -0
- package/skills/sluice/references/show-or-say.md +36 -0
- package/skills/sluice/references/test-first.md +35 -0
- package/skills/sluice/references/verify.md +26 -0
- package/skills/sluice/skill.json +32 -0
|
@@ -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,18 +105,73 @@ test('SKILL.md sends each stage to the reference file it needs', async () => {
|
|
|
89
105
|
const next = text.indexOf('\n### ', start + heading.length)
|
|
90
106
|
return text.slice(start, next === -1 ? undefined : next)
|
|
91
107
|
}
|
|
92
|
-
expect(section('### 3.
|
|
93
|
-
expect(section('###
|
|
94
|
-
expect(section('### 6.
|
|
108
|
+
expect(section('### 3. Context')).toContain('references/scout.md')
|
|
109
|
+
expect(section('### 4. Specialists')).toContain('references/specialists.md')
|
|
110
|
+
expect(section('### 6. Critic')).toContain('references/critic.md')
|
|
111
|
+
expect(section('### 7. Peer review')).toContain('references/peer-review.md')
|
|
95
112
|
})
|
|
96
113
|
|
|
97
114
|
test('SKILL.md no longer inlines the prompt bodies it moved out', async () => {
|
|
98
115
|
const text = await readFile(SKILL, 'utf8')
|
|
99
|
-
for (const tag of ['magpie-specialist-', 'magpie-critic', 'magpie-peer-review']) {
|
|
116
|
+
for (const tag of ['magpie-specialist-', 'magpie-critic', 'magpie-peer-review', 'magpie-scout']) {
|
|
100
117
|
expect(text).not.toContain(`\`\`\`${tag}`)
|
|
101
118
|
}
|
|
102
119
|
// The walkthrough is the always-read part; keep it small enough to be cheap.
|
|
103
|
-
|
|
120
|
+
// Raised from 2600 when the sharded-dispatch and fallback-diff prose was added:
|
|
121
|
+
// that's real, load-bearing procedure, not bloat.
|
|
122
|
+
expect(text.split(/\s+/).length).toBeLessThan(3000)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('SKILL.md never instructs the agent to approve indexing', async () => {
|
|
126
|
+
const text = await readFile(SKILL, 'utf8')
|
|
127
|
+
// A full index is a consent-gated GPU pass. The context stage degrades instead.
|
|
128
|
+
expect(text).toContain('approve_indexing')
|
|
129
|
+
expect(text).toMatch(/never call `approve_indexing`|do not call `approve_indexing`/i)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('SKILL.md logs codeIntelligence on both the done and skipped context outcomes', async () => {
|
|
133
|
+
const text = await readFile(SKILL, 'utf8')
|
|
134
|
+
const start = text.indexOf('### 3. Context')
|
|
135
|
+
expect(start).toBeGreaterThan(-1)
|
|
136
|
+
const section = text.slice(start, text.indexOf('\n### 4.', start))
|
|
137
|
+
// The bind probe's result is known by the time either log line is written,
|
|
138
|
+
// regardless of whether the scout produced a brief; specialists read this key
|
|
139
|
+
// to decide whether to include the codebase-intelligence block.
|
|
140
|
+
const doneEntry = section.match(/\{stage: context, status: done[^}]*\}/)
|
|
141
|
+
const skippedEntry = section.match(/\{stage: context, status: skipped[^}]*\}/)
|
|
142
|
+
expect(doneEntry?.[0]).toContain('codeIntelligence')
|
|
143
|
+
expect(skippedEntry?.[0]).toContain('codeIntelligence')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('SKILL.md rebinds the code-intelligence session at cleanup', async () => {
|
|
147
|
+
const text = await readFile(SKILL, 'utf8')
|
|
148
|
+
const start = text.indexOf('### 10. Cleanup')
|
|
149
|
+
expect(start).toBeGreaterThan(-1)
|
|
150
|
+
const section = text.slice(start, text.indexOf('\n## ', start))
|
|
151
|
+
// Binding is per session with no per-call override, so a run that ends without
|
|
152
|
+
// rebinding leaves the session pointed at a worktree that no longer exists.
|
|
153
|
+
expect(section).toContain('bind_workspace')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('SKILL.md rebinds before cleanup on the abort path too', async () => {
|
|
157
|
+
const text = await readFile(SKILL, 'utf8')
|
|
158
|
+
const start = text.indexOf('## Aborting')
|
|
159
|
+
expect(start).toBeGreaterThan(-1)
|
|
160
|
+
const section = text.slice(start)
|
|
161
|
+
// Stage 3 bound the session to the worktree; `abort` deletes that worktree via
|
|
162
|
+
// `magpie cleanup`, so it must rebind first or leave the session dangling.
|
|
163
|
+
expect(section).toMatch(/rebind.*(\$REPO|stage 10)/i)
|
|
164
|
+
expect(section).toContain('magpie cleanup')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('SKILL.md rebinds before the stage-4 all-specialists-failed hard stop', async () => {
|
|
168
|
+
const text = await readFile(SKILL, 'utf8')
|
|
169
|
+
const start = text.indexOf('### 4. Specialists')
|
|
170
|
+
expect(start).toBeGreaterThan(-1)
|
|
171
|
+
const section = text.slice(start, text.indexOf('\n### 5.', start))
|
|
172
|
+
// This path stops the run without calling cleanup, but a later resume or
|
|
173
|
+
// abort must not find the session still pointed at the worktree.
|
|
174
|
+
expect(section).toMatch(/rebind.*(\$REPO|stage 10)/i)
|
|
104
175
|
})
|
|
105
176
|
|
|
106
177
|
test('styles.css declares a prefers-color-scheme:dark block that overrides core tokens', async () => {
|
|
@@ -195,7 +266,8 @@ test('SKILL.md does not gate resume on state/server-info', async () => {
|
|
|
195
266
|
expect(section).not.toMatch(/if .*server-info.* exists/i)
|
|
196
267
|
// Resuming must restart the server; the old one is gone.
|
|
197
268
|
expect(section).toContain('magpie serve')
|
|
198
|
-
// `context`
|
|
269
|
+
// `context` re-runs on resume too (bind probe plus a conditional scout
|
|
270
|
+
// dispatch); the resume section must still call it out explicitly.
|
|
199
271
|
expect(section).toContain('context')
|
|
200
272
|
})
|
|
201
273
|
|
|
@@ -222,3 +294,131 @@ test('SKILL.md has the stage walkthrough', async () => {
|
|
|
222
294
|
expect(text).toMatch(/magpie render/)
|
|
223
295
|
expect(text).toMatch(/magpie cleanup/)
|
|
224
296
|
})
|
|
297
|
+
|
|
298
|
+
test('references/specialists.md carries the codebase-intelligence block', async () => {
|
|
299
|
+
const text = await readFile(ref('specialists.md'), 'utf8')
|
|
300
|
+
expect(text).toContain('```magpie-codebase-intelligence')
|
|
301
|
+
const start = text.indexOf('```magpie-codebase-intelligence')
|
|
302
|
+
const block = text.slice(start, text.indexOf('```', start + 32))
|
|
303
|
+
expect(block).toContain('bind_workspace')
|
|
304
|
+
expect(block).toContain('indexing_in_progress')
|
|
305
|
+
// The one operation a specialist must never perform.
|
|
306
|
+
expect(block).toMatch(/never call `approve_indexing`|do not call `approve_indexing`/i)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test('every focus block names the code-intelligence tool for its focus', async () => {
|
|
310
|
+
const text = await readFile(ref('specialists.md'), 'utf8')
|
|
311
|
+
const tools: Record<(typeof FOCUSES)[number], string> = {
|
|
312
|
+
security: 'trace_data_flow',
|
|
313
|
+
bugs: 'get_call_hierarchy',
|
|
314
|
+
performance: 'find_affected_code',
|
|
315
|
+
'code-smells': 'search_code',
|
|
316
|
+
architecture: 'explore_dependency_graph',
|
|
317
|
+
}
|
|
318
|
+
for (const focus of FOCUSES) {
|
|
319
|
+
const fence = `\`\`\`magpie-specialist-${focus}`
|
|
320
|
+
const start = text.indexOf(fence)
|
|
321
|
+
const block = text.slice(start, text.indexOf('```', start + fence.length))
|
|
322
|
+
expect(block).toContain(tools[focus])
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('the output contract tells specialists to look before they hedge', async () => {
|
|
327
|
+
const text = await readFile(ref('specialists.md'), 'utf8')
|
|
328
|
+
const contract = text.slice(0, text.indexOf('```magpie-specialist-'))
|
|
329
|
+
expect(contract).toContain('Needs verification:')
|
|
330
|
+
expect(contract).toMatch(/look before .*hedg/i)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
test('the output contract documents both findings filenames', async () => {
|
|
334
|
+
const text = await readFile(ref('specialists.md'), 'utf8')
|
|
335
|
+
const contract = text.slice(0, text.indexOf('```magpie-specialist-'))
|
|
336
|
+
// The pre-existing assertion must keep holding.
|
|
337
|
+
expect(contract).toMatch(/findings\/<focus>\.json/)
|
|
338
|
+
expect(contract).toMatch(/findings\/<focus>\.shard-<n>\.json/)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('the run header documents the shard lines', async () => {
|
|
342
|
+
const text = await readFile(ref('specialists.md'), 'utf8')
|
|
343
|
+
expect(text).toContain('Shard: <n> of <N>')
|
|
344
|
+
expect(text).toContain('shards/shard-<n>.patch')
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('specialists are told excluded files are still reachable', async () => {
|
|
348
|
+
const text = await readFile(ref('specialists.md'), 'utf8')
|
|
349
|
+
expect(text).toContain('diff.full.patch')
|
|
350
|
+
expect(text).toContain('excluded-files.json')
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
test('stage 4 pins the shard field on the specialist log entry', async () => {
|
|
354
|
+
const text = await readFile(SKILL, 'utf8')
|
|
355
|
+
const start = text.indexOf('### 4. Specialists')
|
|
356
|
+
expect(start).toBeGreaterThan(-1)
|
|
357
|
+
const section = text.slice(start, text.indexOf('\n### 5.', start))
|
|
358
|
+
// render-cmd sums per-focus findings counts keyed on this field. Drop it from a
|
|
359
|
+
// sharded run and every shard of a focus lands in one 'all' bucket,
|
|
360
|
+
// last-write-wins, so the progress page under-reports by up to the shard count
|
|
361
|
+
// with no error anywhere. This prose is the only thing that produces the field.
|
|
362
|
+
expect(section).toMatch(/\{stage: specialist,[^}]*\bshard: <n>/)
|
|
363
|
+
// ... and the unsharded path must still omit it, or 'all' would never be used.
|
|
364
|
+
expect(section).toMatch(/omit `shard`/i)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('stage 4 reconciles the expected findings files before dedupe', async () => {
|
|
368
|
+
const text = await readFile(SKILL, 'utf8')
|
|
369
|
+
const start = text.indexOf('### 4. Specialists')
|
|
370
|
+
const section = text.slice(start, text.indexOf('\n### 5.', start))
|
|
371
|
+
// Stage 4 fails only when every specialist fails, so losing 1 of 30 agents
|
|
372
|
+
// otherwise renders a report indistinguishable from a complete one.
|
|
373
|
+
expect(section).toMatch(/findings\/<focus>\.shard-<n>\.json/)
|
|
374
|
+
expect(section).toMatch(/5 × <shard count>/)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
test('the stage-4 gate offers only options the pipeline can carry out', async () => {
|
|
378
|
+
const text = await readFile(SKILL, 'utf8')
|
|
379
|
+
const start = text.indexOf('**More than four shards')
|
|
380
|
+
expect(start).toBeGreaterThan(-1)
|
|
381
|
+
const gate = text.slice(start, text.indexOf('\n\n', start))
|
|
382
|
+
// Option 2: --max-files is what binds on a PR of many small files, so naming
|
|
383
|
+
// only --budget sends the user back to an unchanged shard count.
|
|
384
|
+
expect(gate).toContain('--budget')
|
|
385
|
+
expect(gate).toContain('--max-files')
|
|
386
|
+
// Option 3 must name a mechanism that exists. There is no unreviewed marker in
|
|
387
|
+
// the report, so the record is a diagnostic log entry, on a stage outside
|
|
388
|
+
// status-cmd's ORDER ladder and never `status: error`.
|
|
389
|
+
const { ORDER } = await import('../status-cmd.ts')
|
|
390
|
+
const stage = gate.match(/\{stage: ([a-z-]+), status: ([a-z-]+)/)
|
|
391
|
+
expect(stage?.[1]).toBeDefined()
|
|
392
|
+
expect(ORDER as readonly string[]).not.toContain(stage?.[1])
|
|
393
|
+
expect(stage?.[2]).not.toBe('error')
|
|
394
|
+
expect(gate).toMatch(/skipped/)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test('the excluded-files note is part of the specialist prompt, not commentary', async () => {
|
|
398
|
+
const spec = await readFile(ref('specialists.md'), 'utf8')
|
|
399
|
+
// Every other specialist-directed line lives in a fenced block; this one used to
|
|
400
|
+
// sit unfenced between two numbered assembly parts, where an assembling agent
|
|
401
|
+
// reads it as a note to itself and the specialist never sees it.
|
|
402
|
+
const fences = [...spec.matchAll(/^```[a-z-]*\n([\s\S]*?)^```/gm)].map((m) => m[1] ?? '')
|
|
403
|
+
const inFence = fences.some((body) => body.includes('diff.full.patch'))
|
|
404
|
+
expect(inFence).toBe(true)
|
|
405
|
+
// And it must be a numbered part of the assembly list, not floating text.
|
|
406
|
+
expect(spec).toMatch(/^\d+\. The excluded-files block below/m)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
test('the specialist assembly list is numbered contiguously from 1', async () => {
|
|
410
|
+
const spec = await readFile(ref('specialists.md'), 'utf8')
|
|
411
|
+
// Anchor on the heading, not on item 3's inline `## Output Contract` reference.
|
|
412
|
+
const preamble = spec.slice(0, spec.indexOf('\n## Output Contract'))
|
|
413
|
+
const numbers = [...preamble.matchAll(/^(\d+)\. /gm)].map((m) => Number(m[1]))
|
|
414
|
+
expect(numbers.length).toBeGreaterThan(4)
|
|
415
|
+
expect(numbers).toEqual(numbers.map((_, i) => i + 1))
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test('SKILL.md documents the shard manifest and the fan-out gate', async () => {
|
|
419
|
+
const text = await readFile(SKILL, 'utf8')
|
|
420
|
+
expect(text).toContain('shards/manifest.json')
|
|
421
|
+
expect(text).toContain('diff.patch')
|
|
422
|
+
// The confirmation gate above four shards is the design's only interactive stop.
|
|
423
|
+
expect(text).toMatch(/more than four shards/i)
|
|
424
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { annotateChangedLines } from './changed-lines.ts'
|
|
4
4
|
import { deduplicateFindings } from './dedupe.ts'
|
|
5
5
|
import { verifyEvidence } from './evidence-filter.ts'
|
|
6
|
+
import { namespaceId, parseFindingsFilename } from './findings-files.ts'
|
|
6
7
|
import { DEFAULT_THRESHOLD, scoreRisk } from './score.ts'
|
|
7
8
|
import { FOCUS_IDS, parseFinding, type ReviewFinding } from './types.ts'
|
|
8
9
|
|
|
@@ -16,6 +17,49 @@ export type RunDedupeOptions = {
|
|
|
16
17
|
threshold?: number
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/**
|
|
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.
|
|
24
|
+
*/
|
|
25
|
+
const SPECIALIST_FOCUS_IDS = FOCUS_IDS.filter((id) => id !== 'tests')
|
|
26
|
+
|
|
27
|
+
type Coverage = { expected: number; missing: string[] }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reconcile the findings files on disk against the `(focus, shard)` pairs the
|
|
31
|
+
* shard manifest implies. Stage 4 fails the run only when *every* specialist
|
|
32
|
+
* fails, so a sharded run that lost one agent out of thirty still logs
|
|
33
|
+
* `specialists: done` and renders a report indistinguishable from a complete
|
|
34
|
+
* one. Returns null when there is nothing to reconcile against (no manifest, an
|
|
35
|
+
* unreadable one, or a manifest with no shards, which is what an empty or
|
|
36
|
+
* fully-filtered diff produces), leaving pre-sharder runs behaving exactly as
|
|
37
|
+
* they did before.
|
|
38
|
+
*/
|
|
39
|
+
async function reconcileCoverage(runDir: string, present: Set<string>): Promise<Coverage | null> {
|
|
40
|
+
let shards: Array<Record<string, unknown>>
|
|
41
|
+
try {
|
|
42
|
+
const manifest = (await Bun.file(join(runDir, 'shards', 'manifest.json')).json()) as {
|
|
43
|
+
shards?: unknown
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(manifest.shards) || manifest.shards.length === 0) return null
|
|
46
|
+
shards = manifest.shards as Array<Record<string, unknown>>
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
// A single-shard manifest means stage 4 took the unsharded path, so its
|
|
51
|
+
// specialists write `<focus>.json` with no shard suffix.
|
|
52
|
+
const sharded = shards.length > 1
|
|
53
|
+
const expected: string[] = []
|
|
54
|
+
for (const [i, shard] of shards.entries()) {
|
|
55
|
+
const id = typeof shard?.id === 'number' ? shard.id : i + 1
|
|
56
|
+
for (const focus of SPECIALIST_FOCUS_IDS) {
|
|
57
|
+
expected.push(sharded ? `${focus}.shard-${id}.json` : `${focus}.json`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { expected: expected.length, missing: expected.filter((name) => !present.has(name)) }
|
|
61
|
+
}
|
|
62
|
+
|
|
19
63
|
export async function runDedupe(runDir: string, options: RunDedupeOptions = {}): Promise<number> {
|
|
20
64
|
const threshold = options.threshold ?? DEFAULT_THRESHOLD
|
|
21
65
|
const findingsDir = join(runDir, 'findings')
|
|
@@ -29,8 +73,8 @@ export async function runDedupe(runDir: string, options: RunDedupeOptions = {}):
|
|
|
29
73
|
|
|
30
74
|
for (const name of files) {
|
|
31
75
|
if (!name.endsWith('.json')) continue
|
|
32
|
-
const
|
|
33
|
-
if (!FOCUS_IDS.includes(focus as (typeof FOCUS_IDS)[number])) {
|
|
76
|
+
const parsed = parseFindingsFilename(name)
|
|
77
|
+
if (!parsed || !FOCUS_IDS.includes(parsed.focus as (typeof FOCUS_IDS)[number])) {
|
|
34
78
|
await logLine(runDir, {
|
|
35
79
|
stage: 'dedupe',
|
|
36
80
|
status: 'skip',
|
|
@@ -39,6 +83,7 @@ export async function runDedupe(runDir: string, options: RunDedupeOptions = {}):
|
|
|
39
83
|
})
|
|
40
84
|
continue
|
|
41
85
|
}
|
|
86
|
+
const { focus, shard } = parsed
|
|
42
87
|
const path = join(findingsDir, name)
|
|
43
88
|
let raw: unknown
|
|
44
89
|
try {
|
|
@@ -63,7 +108,8 @@ export async function runDedupe(runDir: string, options: RunDedupeOptions = {}):
|
|
|
63
108
|
}
|
|
64
109
|
for (const item of raw) {
|
|
65
110
|
try {
|
|
66
|
-
|
|
111
|
+
const finding = parseFinding(item)
|
|
112
|
+
collected.push({ ...finding, id: namespaceId(finding.id, focus, shard) })
|
|
67
113
|
} catch (err) {
|
|
68
114
|
await logLine(runDir, {
|
|
69
115
|
stage: 'dedupe',
|
|
@@ -107,6 +153,14 @@ export async function runDedupe(runDir: string, options: RunDedupeOptions = {}):
|
|
|
107
153
|
)}\n`,
|
|
108
154
|
)
|
|
109
155
|
}
|
|
156
|
+
const coverage = await reconcileCoverage(runDir, new Set(files))
|
|
157
|
+
if (coverage) {
|
|
158
|
+
process.stdout.write(
|
|
159
|
+
coverage.missing.length === 0
|
|
160
|
+
? `dedupe: all ${coverage.expected} expected findings files present\n`
|
|
161
|
+
: `dedupe: ${coverage.missing.length} of ${coverage.expected} expected findings files missing (re-dispatch those specialists): ${coverage.missing.join(', ')}\n`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
110
164
|
await logLine(runDir, {
|
|
111
165
|
stage: 'dedupe',
|
|
112
166
|
status: 'done',
|
|
@@ -118,6 +172,7 @@ export async function runDedupe(runDir: string, options: RunDedupeOptions = {}):
|
|
|
118
172
|
skipped: evidence.skipped,
|
|
119
173
|
dropped: evidence.dropped.length,
|
|
120
174
|
},
|
|
175
|
+
...(coverage ? { coverage } : {}),
|
|
121
176
|
})
|
|
122
177
|
return 0
|
|
123
178
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type FileChunk = { path: string; text: string; lines: number }
|
|
2
|
+
|
|
3
|
+
export const FILE_HEADER = /^diff --git a\/(.+?) b\/(.+?)$/m
|
|
4
|
+
|
|
5
|
+
/** Split a unified diff on file boundaries. The first element may be a preamble
|
|
6
|
+
* that is not a file chunk; callers decide what to do with it. */
|
|
7
|
+
export function splitRawChunks(diff: string): string[] {
|
|
8
|
+
return diff.split(/^(?=diff --git )/m)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The b-side path of a file chunk, falling back to the a-side for deletions. */
|
|
12
|
+
export function chunkPath(chunk: string): string | null {
|
|
13
|
+
const m = chunk.match(FILE_HEADER)
|
|
14
|
+
return m?.[2] ?? m?.[1] ?? null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** File chunks only, with their paths and patch-line counts resolved. */
|
|
18
|
+
export function splitFileChunks(diff: string): FileChunk[] {
|
|
19
|
+
if (!diff.trim()) return []
|
|
20
|
+
const out: FileChunk[] = []
|
|
21
|
+
for (const chunk of splitRawChunks(diff)) {
|
|
22
|
+
if (!chunk.startsWith('diff --git ')) continue
|
|
23
|
+
const path = chunkPath(chunk)
|
|
24
|
+
if (!path) continue
|
|
25
|
+
out.push({ path, text: chunk, lines: chunk.split('\n').length })
|
|
26
|
+
}
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A specialist writes `findings/<focus>.json`, or `findings/<focus>.shard-<n>.json`
|
|
3
|
+
* when stage 4 fanned the focus out across shards. Returns null for anything
|
|
4
|
+
* else, which the caller logs and skips.
|
|
5
|
+
*/
|
|
6
|
+
export function parseFindingsFilename(
|
|
7
|
+
name: string,
|
|
8
|
+
): { focus: string; shard: number | null } | null {
|
|
9
|
+
if (!name.endsWith('.json')) return null
|
|
10
|
+
const stem = name.slice(0, -'.json'.length)
|
|
11
|
+
const parts = stem.split('.')
|
|
12
|
+
const focus = parts[0]
|
|
13
|
+
if (!focus) return null
|
|
14
|
+
if (parts.length === 1) return { focus, shard: null }
|
|
15
|
+
if (parts.length > 2) return null
|
|
16
|
+
const m = /^shard-(\d+)$/.exec(parts[1] ?? '')
|
|
17
|
+
if (!m) return null
|
|
18
|
+
return { focus, shard: Number(m[1]) }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every shard's security specialist mints `security-1` for its first finding,
|
|
23
|
+
* so a five-shard run yields five findings sharing one id, and the report and
|
|
24
|
+
* post stages key on id. Rewriting deterministically here beats asking five
|
|
25
|
+
* prompts to encode the shard themselves.
|
|
26
|
+
*/
|
|
27
|
+
export function namespaceId(id: string, focus: string, shard: number | null): string {
|
|
28
|
+
if (shard === null) return id
|
|
29
|
+
const tag = `s${shard}`
|
|
30
|
+
const prefix = `${focus}-`
|
|
31
|
+
return id.startsWith(prefix) ? `${prefix}${tag}-${id.slice(prefix.length)}` : `${tag}-${id}`
|
|
32
|
+
}
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import { writeFile } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import { localPrDiff } from './git-diff.ts'
|
|
3
4
|
|
|
4
5
|
export type FetchPrInput = {
|
|
5
6
|
ghBin: string
|
|
6
7
|
prNumber: number
|
|
7
8
|
runDir: string
|
|
8
|
-
/** Working directory for the gh
|
|
9
|
+
/** Working directory for the gh and git invocations. Defaults to process.cwd(). */
|
|
9
10
|
cwd?: string
|
|
11
|
+
/** git binary for the local-diff fallback. Defaults to `git` on PATH. */
|
|
12
|
+
gitBin?: string
|
|
13
|
+
/** Extra environment for the gh invocation. Test seam for the fake gh fixtures. */
|
|
14
|
+
env?: Record<string, string>
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
export type FetchPrResult =
|
|
13
|
-
| { ok: true; prJsonPath: string; diffPath: string }
|
|
18
|
+
| { ok: true; prJsonPath: string; diffPath: string; source: 'gh' | 'git'; mergeBase?: string }
|
|
14
19
|
| { ok: false; error: string }
|
|
15
20
|
|
|
16
|
-
const PR_VIEW_FIELDS = [
|
|
21
|
+
export const PR_VIEW_FIELDS = [
|
|
17
22
|
'number',
|
|
18
23
|
'title',
|
|
19
24
|
'headRefName',
|
|
@@ -26,11 +31,28 @@ const PR_VIEW_FIELDS = [
|
|
|
26
31
|
// depending on cwd (the worktree is gone after cleanup).
|
|
27
32
|
'url',
|
|
28
33
|
'files',
|
|
34
|
+
// Intent evidence for the scout's brief, and issue links for the report header.
|
|
35
|
+
'commits',
|
|
36
|
+
'closingIssuesReferences',
|
|
29
37
|
].join(',')
|
|
30
38
|
|
|
39
|
+
function prFileCount(viewStdout: string): number {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(viewStdout) as { files?: unknown }
|
|
42
|
+
return Array.isArray(parsed.files) ? parsed.files.length : 0
|
|
43
|
+
} catch {
|
|
44
|
+
return 0
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
export async function fetchPr(input: FetchPrInput): Promise<FetchPrResult> {
|
|
32
49
|
const { ghBin, prNumber, runDir, cwd } = input
|
|
33
|
-
const spawnOpts = {
|
|
50
|
+
const spawnOpts = {
|
|
51
|
+
stdout: 'pipe' as const,
|
|
52
|
+
stderr: 'pipe' as const,
|
|
53
|
+
...(cwd ? { cwd } : {}),
|
|
54
|
+
...(input.env ? { env: { ...process.env, ...input.env } } : {}),
|
|
55
|
+
}
|
|
34
56
|
const view = Bun.spawn(
|
|
35
57
|
[ghBin, 'pr', 'view', String(prNumber), '--json', PR_VIEW_FIELDS],
|
|
36
58
|
spawnOpts,
|
|
@@ -45,20 +67,52 @@ export async function fetchPr(input: FetchPrInput): Promise<FetchPrResult> {
|
|
|
45
67
|
}
|
|
46
68
|
}
|
|
47
69
|
|
|
70
|
+
const prJsonPath = join(runDir, 'pr.json')
|
|
71
|
+
const diffPath = join(runDir, 'diff.patch')
|
|
72
|
+
await writeFile(prJsonPath, viewStdout)
|
|
73
|
+
|
|
48
74
|
const diff = Bun.spawn([ghBin, 'pr', 'diff', String(prNumber)], spawnOpts)
|
|
49
75
|
const diffStdout = await new Response(diff.stdout).text()
|
|
50
76
|
const diffStderr = await new Response(diff.stderr).text()
|
|
51
77
|
const diffExit = await diff.exited
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
78
|
+
|
|
79
|
+
// GitHub refuses the `.diff` media type above roughly 300 files (HTTP 406),
|
|
80
|
+
// and a truncating proxy can answer 200 with nothing at all. Reviewing an
|
|
81
|
+
// empty diff as though it were the PR is the worse failure of the two, so
|
|
82
|
+
// both conditions fall through to the local clone.
|
|
83
|
+
const emptyForNonEmptyPr = diffStdout.trim() === '' && prFileCount(viewStdout) > 0
|
|
84
|
+
if (diffExit === 0 && !emptyForNonEmptyPr) {
|
|
85
|
+
await writeFile(diffPath, diffStdout)
|
|
86
|
+
return { ok: true, prJsonPath, diffPath, source: 'gh' }
|
|
57
87
|
}
|
|
58
88
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
89
|
+
const ghError =
|
|
90
|
+
diffExit !== 0
|
|
91
|
+
? `gh pr diff exit ${diffExit}: ${diffStderr.trim()}`
|
|
92
|
+
: 'gh pr diff returned an empty diff for a PR with changed files'
|
|
93
|
+
|
|
94
|
+
let pr: { baseRefName?: unknown; headRefOid?: unknown }
|
|
95
|
+
try {
|
|
96
|
+
pr = JSON.parse(viewStdout) as { baseRefName?: unknown; headRefOid?: unknown }
|
|
97
|
+
} catch (err) {
|
|
98
|
+
return { ok: false, error: `${ghError}; pr.json is unparseable: ${String(err)}` }
|
|
99
|
+
}
|
|
100
|
+
const baseRefName = typeof pr.baseRefName === 'string' ? pr.baseRefName : ''
|
|
101
|
+
const headRefOid = typeof pr.headRefOid === 'string' ? pr.headRefOid : ''
|
|
102
|
+
if (!baseRefName || !headRefOid) {
|
|
103
|
+
return { ok: false, error: `${ghError}; pr.json has no baseRefName/headRefOid to diff from` }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const local = await localPrDiff({
|
|
107
|
+
gitBin: input.gitBin ?? 'git',
|
|
108
|
+
repoPath: cwd ?? process.cwd(),
|
|
109
|
+
prNumber,
|
|
110
|
+
baseRefName,
|
|
111
|
+
headRefOid,
|
|
112
|
+
})
|
|
113
|
+
if (!local.ok) {
|
|
114
|
+
return { ok: false, error: `${ghError}; local fallback failed: ${local.error}` }
|
|
115
|
+
}
|
|
116
|
+
await writeFile(diffPath, local.diff)
|
|
117
|
+
return { ok: true, prJsonPath, diffPath, source: 'git', mergeBase: local.mergeBase }
|
|
64
118
|
}
|