@iceinvein/agent-skills 0.2.0 → 0.4.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 (68) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/index.js +14 -10
  3. package/package.json +1 -1
  4. package/skills/index.json +4 -4
  5. package/skills/migrate/README.md +35 -23
  6. package/skills/migrate/SKILL.md +75 -15
  7. package/skills/migrate/bin/migrate.ts +90 -0
  8. package/skills/migrate/docs/architecture.md +61 -26
  9. package/skills/migrate/docs/reference.md +53 -8
  10. package/skills/migrate/fixtures/fake-gh.ts +113 -0
  11. package/skills/migrate/fixtures/flow-target/docs/WORK.md +12 -0
  12. package/skills/migrate/fixtures/flow-target/docs/modernisation/capability-map/.gitkeep +0 -0
  13. package/skills/migrate/fixtures/flow-target/tools/flow/src/cli.ts +156 -0
  14. package/skills/migrate/package.json +1 -1
  15. package/skills/migrate/references/phases/adjudicate.md +161 -0
  16. package/skills/migrate/references/phases/handoff.md +220 -0
  17. package/skills/migrate/references/phases/probe.md +2 -2
  18. package/skills/migrate/references/phases/queue.md +21 -14
  19. package/skills/migrate/references/run-ops.md +17 -13
  20. package/skills/migrate/scripts/__tests__/adapter-flow.test.ts +290 -0
  21. package/skills/migrate/scripts/__tests__/adapter-github.test.ts +232 -0
  22. package/skills/migrate/scripts/__tests__/adapter-markdown.test.ts +183 -0
  23. package/skills/migrate/scripts/__tests__/adjudicate.test.ts +332 -0
  24. package/skills/migrate/scripts/__tests__/assumptions.test.ts +179 -0
  25. package/skills/migrate/scripts/__tests__/coverage.test.ts +192 -0
  26. package/skills/migrate/scripts/__tests__/e2e-express.test.ts +167 -7
  27. package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +9 -4
  28. package/skills/migrate/scripts/__tests__/forecast.test.ts +280 -0
  29. package/skills/migrate/scripts/__tests__/gates-handoff.test.ts +309 -0
  30. package/skills/migrate/scripts/__tests__/handoff-cmd.test.ts +308 -0
  31. package/skills/migrate/scripts/__tests__/handoff-order.test.ts +156 -0
  32. package/skills/migrate/scripts/adapters/flow.ts +280 -0
  33. package/skills/migrate/scripts/adapters/github.ts +260 -0
  34. package/skills/migrate/scripts/adapters/markdown.ts +175 -0
  35. package/skills/migrate/scripts/adjudicate-cmd.ts +243 -0
  36. package/skills/migrate/scripts/assumptions.ts +188 -0
  37. package/skills/migrate/scripts/check.ts +119 -320
  38. package/skills/migrate/scripts/coverage-cmd.ts +86 -0
  39. package/skills/migrate/scripts/coverage.ts +151 -0
  40. package/skills/migrate/scripts/dates.ts +17 -0
  41. package/skills/migrate/scripts/forecast-cmd.ts +124 -0
  42. package/skills/migrate/scripts/forecast.ts +264 -0
  43. package/skills/migrate/scripts/gates/adjudication.ts +30 -0
  44. package/skills/migrate/scripts/gates/census.ts +107 -0
  45. package/skills/migrate/scripts/gates/citations.ts +11 -0
  46. package/skills/migrate/scripts/gates/context.ts +76 -0
  47. package/skills/migrate/scripts/gates/coverage.ts +22 -0
  48. package/skills/migrate/scripts/gates/deltas.ts +15 -0
  49. package/skills/migrate/scripts/gates/handoff.ts +145 -0
  50. package/skills/migrate/scripts/gates/leaks.ts +11 -0
  51. package/skills/migrate/scripts/gates/parity.ts +15 -0
  52. package/skills/migrate/scripts/gates/queue.ts +9 -0
  53. package/skills/migrate/scripts/gates/refs.ts +97 -0
  54. package/skills/migrate/scripts/gates/run-state.ts +67 -0
  55. package/skills/migrate/scripts/gates/source.ts +28 -0
  56. package/skills/migrate/scripts/handoff-cmd.ts +186 -0
  57. package/skills/migrate/scripts/handoff.ts +330 -0
  58. package/skills/migrate/scripts/paths.ts +4 -0
  59. package/skills/migrate/scripts/types.ts +43 -0
  60. package/skills/migrate/scripts/validate.ts +12 -0
  61. package/skills/migrate/skill.json +2 -2
  62. package/skills/migrate/templates/forecast-assumptions.md +59 -0
  63. package/skills/sluice/SKILL.md +20 -7
  64. package/skills/sluice/references/deep-channel.md +20 -0
  65. package/skills/sluice/references/finish.md +4 -2
  66. package/skills/sluice/references/meter.md +38 -0
  67. package/skills/sluice/scripts/run-stats.sh +236 -0
  68. package/skills/sluice/skill.json +4 -3
@@ -0,0 +1,280 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import type { Adapter, HandoffInput } from '../handoff.ts'
5
+ import { buildWorkItems } from '../handoff.ts'
6
+ import { readTextFile, writeAtomically } from '../store.ts'
7
+ import type {
8
+ ApplyResult,
9
+ Completion,
10
+ Confidence,
11
+ Requirement,
12
+ Throughput,
13
+ WorkItem,
14
+ } from '../types.ts'
15
+
16
+ // Everything in this file is written against the flow target's own parser:
17
+ // quartex/Nexus at c2464ac, plugins/stack/templates/tools/flow/src/
18
+ // capability.ts. That parser is an executable specification of what a
19
+ // capability file must look like, and each rule it enforces is cited at the
20
+ // line it comes from. scripts/__tests__/adapter-flow.test.ts asserts each rule
21
+ // independently, so if the target's grammar moves, the drift is visible here
22
+ // rather than surfacing as a parse error inside someone else's repo.
23
+ const CAP_DIR = join('docs', 'modernisation', 'capability-map')
24
+ const WORK = join('docs', 'WORK.md')
25
+ const FLOW_CLI = join('tools', 'flow', 'src', 'cli.ts')
26
+ const PROPOSED = /^## Proposed\s*$/m
27
+
28
+ // capability.ts:6. The store's three confidence kinds map onto the target's
29
+ // three vocabulary terms; `queued` becomes Speculative, which is what respec's
30
+ // own handoff does.
31
+ function confidenceFor(c: Confidence): string {
32
+ if (c.kind === 'confirmed') return 'Confirmed'
33
+ if (c.kind === 'inferred') return 'Inferred'
34
+ return 'Speculative'
35
+ }
36
+
37
+ // capability.ts:9. The target admits intended, poss-accidental and cruft; the
38
+ // store says intended or accidental-candidate. Emitting the store's spelling
39
+ // would produce a file the target refuses to parse.
40
+ function originFor(origin: Requirement['origin']): string {
41
+ return origin === 'accidental-candidate' ? 'poss-accidental' : 'intended'
42
+ }
43
+
44
+ const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
45
+ // capability.ts:42. The pattern is derived per capability from its declared
46
+ // `ns`, so it also rejects an id that belongs to a different namespace.
47
+ const frIdPattern = (ns: string): RegExp => new RegExp(`^${escapeRegex(ns)}-\\d{3}$`)
48
+
49
+ // capability.ts:48 splits sections on a line beginning '## ', and every cell
50
+ // sits on one table row, so a newline anywhere in a value would both break the
51
+ // row and risk opening a section. Collapsed rather than escaped, because the
52
+ // target's grammar has no way to represent a multi-line cell at all.
53
+ function cell(text: string): string {
54
+ return text
55
+ .replace(/\s*\n\s*/g, ' ')
56
+ .replace(/\|/g, '\\|')
57
+ .trim()
58
+ }
59
+
60
+ function renderCapability(item: WorkItem, ns: string, reqs: Requirement[]): string {
61
+ const own = reqs.filter((r) => r.cap === item.key)
62
+ const lines = [
63
+ '---',
64
+ `cap: ${item.key}`,
65
+ `ns: ${ns}`,
66
+ `title: ${item.title}`,
67
+ // capability.ts:3. A capability handed over has not been started.
68
+ 'status: todo',
69
+ '---',
70
+ '',
71
+ // capability.ts:44. All three sections are required and the target checks
72
+ // for their presence, not their content.
73
+ '## Functional requirements',
74
+ '',
75
+ '| id | requirement | actors | objects | rules | confidence | origin |',
76
+ '| --- | --- | --- | --- | --- | --- | --- |',
77
+ ]
78
+ for (const r of own) {
79
+ // capability.ts:74. Exactly seven cells, in this order.
80
+ lines.push(
81
+ `| ${r.id} | ${cell(r.requirement)} | ${cell(r.actors)} | ${cell(r.objects)} | ${cell(r.rules)} | ${confidenceFor(r.confidence)} | ${originFor(r.origin)} |`,
82
+ )
83
+ }
84
+ lines.push(
85
+ '',
86
+ '## Built',
87
+ '',
88
+ '(none)',
89
+ '',
90
+ '## Remaining',
91
+ '',
92
+ 'All functional requirements.',
93
+ '',
94
+ )
95
+ return lines.join('\n')
96
+ }
97
+
98
+ function nsFor(input: HandoffInput, key: string): string {
99
+ return input.capabilities.find((c) => c.slug === key)?.ns ?? ''
100
+ }
101
+
102
+ // Refuses in plan(), before anything is written. The store's requirement ids
103
+ // are free text; the target derives a pattern from each capability's declared
104
+ // namespace and rejects anything else. Catching it here is the adapter earning
105
+ // its keep: the alternative is a half-written capability-map directory and a
106
+ // parse error in a repo this tool does not own.
107
+ function assertIdGrammar(items: WorkItem[], input: HandoffInput): void {
108
+ const bad: string[] = []
109
+ for (const item of items) {
110
+ const ns = nsFor(input, item.key)
111
+ const pattern = frIdPattern(ns)
112
+ for (const fr of item.frs) {
113
+ if (!pattern.test(fr)) bad.push(`${fr} (capability ${item.key}, ns ${ns})`)
114
+ }
115
+ }
116
+ if (bad.length > 0) {
117
+ throw new Error(
118
+ `flow: ${bad.length} requirement id(s) do not match their capability's namespace pattern <ns>-NNN, which the flow target requires:\n ${bad.join('\n ')}`,
119
+ )
120
+ }
121
+ }
122
+
123
+ async function readIfPresent(path: string): Promise<string | null> {
124
+ try {
125
+ return await readTextFile(path)
126
+ } catch {
127
+ return null
128
+ }
129
+ }
130
+
131
+ // The adapter's own lines are fenced by an HTML comment pair, and ONLY the
132
+ // region between them is ever rewritten.
133
+ //
134
+ // The previous version stripped every line under `## Proposed` matching
135
+ // `- [something]` before re-adding its own, on the assumption that such a line
136
+ // could only be adapter output. It cannot: `- [W07] Replace the auth provider`
137
+ // is exactly the notation the target's own WORK.md teaches, so a team keeping
138
+ // a shortlist there lost it on the first handoff. The fence makes ownership
139
+ // explicit rather than inferred from shape, which is the only way to edit a
140
+ // file somebody else writes in.
141
+ const FENCE_OPEN = '<!-- migrate:proposed -->'
142
+ const FENCE_CLOSE = '<!-- /migrate:proposed -->'
143
+
144
+ function updateWorkLedger(text: string, items: WorkItem[]): string {
145
+ const block = [
146
+ FENCE_OPEN,
147
+ ...items.map((i) => `- [${i.key}] ${i.title} (${i.frs.length} FRs)`),
148
+ FENCE_CLOSE,
149
+ ].join('\n')
150
+
151
+ const open = text.indexOf(FENCE_OPEN)
152
+ const close = text.indexOf(FENCE_CLOSE)
153
+ if (open !== -1 && close > open) {
154
+ return text.slice(0, open) + block + text.slice(close + FENCE_CLOSE.length)
155
+ }
156
+
157
+ let out = text
158
+ if (!PROPOSED.test(out)) {
159
+ out = `${out.replace(/\n*$/, '')}\n\n## Proposed\n`
160
+ }
161
+ const at = PROPOSED.exec(out)
162
+ if (!at) return out
163
+ // Inserted directly under the heading, ahead of whatever the team already
164
+ // keeps there. Nothing outside the fence is read, moved or removed, so a
165
+ // heading of any level below this point is simply none of the adapter's
166
+ // business: the earlier `\n## ` scan for a section end was both wrong (it
167
+ // missed `#` and `###`) and unnecessary once ownership is explicit.
168
+ const headEnd = at.index + at[0].length
169
+ return `${out.slice(0, headEnd)}\n\n${block}\n${out.slice(headEnd)}`
170
+ }
171
+
172
+ type FlowCoverage = { cap: string; coveredIds: string[] }
173
+
174
+ async function runFlow(
175
+ input: HandoffInput,
176
+ args: string[],
177
+ ): Promise<{ code: number; out: string; err: string }> {
178
+ const proc = Bun.spawn(['bun', join(input.root, FLOW_CLI), ...args], {
179
+ cwd: input.root,
180
+ stdout: 'pipe',
181
+ stderr: 'pipe',
182
+ })
183
+ const [out, err] = await Promise.all([
184
+ new Response(proc.stdout).text(),
185
+ new Response(proc.stderr).text(),
186
+ ])
187
+ await proc.exited
188
+ return { code: proc.exitCode ?? -1, out, err }
189
+ }
190
+
191
+ export const flow: Adapter = {
192
+ name: 'flow',
193
+
194
+ async plan(input: HandoffInput): Promise<WorkItem[]> {
195
+ const items = buildWorkItems(input.capabilities, input.requirements)
196
+ assertIdGrammar(items, input)
197
+ return items
198
+ },
199
+
200
+ async apply(items: WorkItem[], input: HandoffInput): Promise<ApplyResult> {
201
+ assertIdGrammar(items, input)
202
+ const src = input.config.source.path
203
+ await mkdir(join(input.root, CAP_DIR), { recursive: true })
204
+
205
+ const created: string[] = []
206
+ const updated: string[] = []
207
+ const unchanged: string[] = []
208
+ const refs: Record<string, string> = {}
209
+
210
+ for (const item of items) {
211
+ const rel = join(CAP_DIR, `${item.key}.md`)
212
+ const path = join(input.root, rel)
213
+ const next = renderCapability(item, nsFor(input, item.key), input.requirements)
214
+ const before = await readIfPresent(path)
215
+ if (before === null) created.push(item.key)
216
+ else if (before !== next) updated.push(item.key)
217
+ else unchanged.push(item.key)
218
+ if (before !== next) await writeAtomically(path, next, src)
219
+ refs[item.key] = rel
220
+ }
221
+
222
+ const workPath = join(input.root, WORK)
223
+ const work = await readIfPresent(workPath)
224
+ let workChanged = false
225
+ if (work !== null) {
226
+ const nextWork = updateWorkLedger(work, items)
227
+ if (nextWork !== work) {
228
+ await writeAtomically(workPath, nextWork, src)
229
+ workChanged = true
230
+ }
231
+ }
232
+ // A run that rewrote WORK.md has not left the target unchanged, whatever
233
+ // the capability files did. Reporting every item `unchanged` while a file
234
+ // was rewritten makes the idempotency claim untestable from the result.
235
+ if (workChanged && created.length === 0) {
236
+ for (const key of unchanged.splice(0, unchanged.length)) updated.push(key)
237
+ }
238
+
239
+ // The oracle. When the target carries its own flow CLI, the emission is
240
+ // validated by the parser that will actually read it, and its failure is
241
+ // reported verbatim rather than paraphrased. When it does not, that is
242
+ // said plainly: an unvalidated emission must not read as a checked one.
243
+ if (existsSync(join(input.root, FLOW_CLI))) {
244
+ const map = await runFlow(input, ['map'])
245
+ if (map.code !== 0) throw new Error(`flow map failed:\n${map.err || map.out}`)
246
+ const check = await runFlow(input, ['map', '--check'])
247
+ if (check.code !== 0) throw new Error(`flow map --check failed:\n${check.err || check.out}`)
248
+ } else {
249
+ process.stderr.write(
250
+ `flow: no ${FLOW_CLI} in the target, so the emitted capability files were not validated against the target's own parser\n`,
251
+ )
252
+ }
253
+
254
+ return { created, updated, unchanged, refs }
255
+ },
256
+
257
+ async throughput(input: HandoffInput): Promise<Throughput> {
258
+ const basis =
259
+ 'flow parity --json in the target, coveredIds (undated: the flow slice ledger holds the dates)'
260
+ if (!existsSync(join(input.root, FLOW_CLI))) {
261
+ throw new Error(
262
+ `flow: no ${FLOW_CLI} in the target, so coverage cannot be read back; run \`flow parity\` there instead`,
263
+ )
264
+ }
265
+ const result = await runFlow(input, ['parity', '--json'])
266
+ if (result.code !== 0)
267
+ throw new Error(`flow parity --json failed:\n${result.err || result.out}`)
268
+ const coverage = JSON.parse(result.out) as FlowCoverage[]
269
+ const completions: Completion[] = []
270
+ for (const entry of coverage) {
271
+ // Every date this adapter could report would be invented: the target
272
+ // computes covered from merged slices plus a baseline and keeps the
273
+ // dates in a slice ledger. Undated is the honest answer, and forecast
274
+ // says so rather than projecting from nothing.
275
+ for (const fr of entry.coveredIds) completions.push({ fr, doneAt: null })
276
+ }
277
+ completions.sort((a, b) => a.fr.localeCompare(b.fr))
278
+ return { completions, basis }
279
+ },
280
+ }
@@ -0,0 +1,260 @@
1
+ import type { Adapter, HandoffInput } from '../handoff.ts'
2
+ import { buildWorkItems } from '../handoff.ts'
3
+ import type { ApplyResult, Completion, Requirement, Throughput, WorkItem } from '../types.ts'
4
+
5
+ // The marker is the whole identity mechanism for an issue. It sits in the
6
+ // body, so one `issue list` resolves every requirement in a single call, and
7
+ // it keeps working after handoff.json is lost or was never committed. That is
8
+ // why `refs` records only the milestone: with one issue per requirement, a
9
+ // stored ref per issue would be N lookups to learn what one list already says,
10
+ // and it would be the copy that goes stale.
11
+ // Anchored to the START of the body, and paired with a closing fence.
12
+ //
13
+ // Unanchored, any issue that merely mentioned a marker was claimed as that
14
+ // requirement's issue and then overwritten wholesale: an issue whose body said
15
+ // "related to <!-- migrate:fr=UM-001 --> which is not this issue" lost a week
16
+ // of repro steps on the next handoff. The adapter's own generated body
17
+ // advertises the marker, so documenting the mechanism was enough to get
18
+ // hijacked. Anchoring means only a body this adapter wrote can be claimed.
19
+ const MARKER = /^<!-- migrate:fr=([^\s>]+) -->/
20
+ const BODY_END = '<!-- /migrate -->'
21
+
22
+ type GhIssue = {
23
+ number: number
24
+ title: string
25
+ body: string
26
+ state: string
27
+ closedAt: string | null
28
+ }
29
+ type GhMilestone = { number: number; title: string }
30
+
31
+ async function gh(input: HandoffInput, args: string[]): Promise<string> {
32
+ const proc = Bun.spawn([input.ghBin, ...args], {
33
+ cwd: input.root,
34
+ stdout: 'pipe',
35
+ stderr: 'pipe',
36
+ })
37
+ const [out, err] = await Promise.all([
38
+ new Response(proc.stdout).text(),
39
+ new Response(proc.stderr).text(),
40
+ ])
41
+ await proc.exited
42
+ if (proc.exitCode !== 0) {
43
+ // The failure is reported with gh's own stderr rather than a paraphrase:
44
+ // an auth prompt, a rate limit and a missing repo all arrive here and the
45
+ // operator needs to tell them apart.
46
+ throw new Error(`gh ${args[0]} ${args[1] ?? ''} failed: ${err.trim() || out.trim()}`)
47
+ }
48
+ return out
49
+ }
50
+
51
+ function issueTitle(req: Requirement): string {
52
+ return `${req.id} ${req.requirement}`
53
+ }
54
+
55
+ function issueBody(req: Requirement, item: WorkItem): string {
56
+ return [
57
+ `<!-- migrate:fr=${req.id} -->`,
58
+ '',
59
+ `Capability: ${item.title} (${item.key})`,
60
+ '',
61
+ req.requirement,
62
+ '',
63
+ '| field | value |',
64
+ '| --- | --- |',
65
+ `| actors | ${req.actors} |`,
66
+ `| objects | ${req.objects} |`,
67
+ `| rules | ${req.rules} |`,
68
+ `| confidence | ${req.confidence.kind} |`,
69
+ `| origin | ${req.origin} |`,
70
+ '',
71
+ 'Emitted by `migrate handoff --adapter github`. The marker above is how a',
72
+ 're-run finds this issue again; removing it will produce a duplicate.',
73
+ 'Everything above the line below is regenerated on each run; anything you',
74
+ 'write beneath it is yours and is preserved.',
75
+ BODY_END,
76
+ ].join('\n')
77
+ }
78
+
79
+ // Everything after the closing fence belongs to whoever wrote it. A re-run
80
+ // regenerates the block above it and puts this back byte for byte; without it,
81
+ // any human annotation on a migration issue was reverted by the next handoff,
82
+ // which made the idempotency claim true only for issues nobody had touched.
83
+ // Nothing this function returns is ever generated by the adapter, so a second
84
+ // apply over an untouched issue reproduces the body exactly.
85
+ function humanTail(body: string): string {
86
+ const at = body.indexOf(BODY_END)
87
+ return at === -1 ? '' : body.slice(at + BODY_END.length)
88
+ }
89
+
90
+ async function listMilestones(input: HandoffInput, slug: string): Promise<GhMilestone[]> {
91
+ const raw = await gh(input, ['api', `repos/${slug}/milestones?state=all`])
92
+ return JSON.parse(raw) as GhMilestone[]
93
+ }
94
+
95
+ // Two capabilities can legitimately share a title; they can never share a
96
+ // slug. Keying the milestone on the title collapsed both onto one milestone
97
+ // and pointed both refs at it.
98
+ function milestoneTitle(item: WorkItem): string {
99
+ return `${item.title} (${item.key})`
100
+ }
101
+
102
+ const ISSUE_LIMIT = 500
103
+
104
+ async function listIssues(input: HandoffInput): Promise<GhIssue[]> {
105
+ const raw = await gh(input, [
106
+ 'issue',
107
+ 'list',
108
+ '--state',
109
+ 'all',
110
+ '--limit',
111
+ String(ISSUE_LIMIT),
112
+ '--json',
113
+ 'number,title,body,state,closedAt',
114
+ ])
115
+ return JSON.parse(raw) as GhIssue[]
116
+ }
117
+
118
+ async function repoSlug(input: HandoffInput): Promise<string> {
119
+ const raw = await gh(input, ['repo', 'view', '--json', 'nameWithOwner'])
120
+ return (JSON.parse(raw) as { nameWithOwner: string }).nameWithOwner
121
+ }
122
+
123
+ function indexByMarker(issues: GhIssue[]): {
124
+ byFr: Map<string, GhIssue>
125
+ duplicates: string[]
126
+ } {
127
+ const byFr = new Map<string, GhIssue>()
128
+ const duplicates: string[] = []
129
+ for (const issue of issues) {
130
+ const m = MARKER.exec(issue.body ?? '')
131
+ const fr = m?.[1]
132
+ if (!fr) continue
133
+ // Two issues under one marker used to mean the last silently won, leaving
134
+ // the other to drift stale forever with no diagnostic.
135
+ if (byFr.has(fr)) duplicates.push(fr)
136
+ else byFr.set(fr, issue)
137
+ }
138
+ return { byFr, duplicates }
139
+ }
140
+
141
+ export const github: Adapter = {
142
+ name: 'github',
143
+
144
+ async plan(input: HandoffInput): Promise<WorkItem[]> {
145
+ return buildWorkItems(input.capabilities, input.requirements)
146
+ },
147
+
148
+ async apply(items: WorkItem[], input: HandoffInput): Promise<ApplyResult> {
149
+ const slug = await repoSlug(input)
150
+ const milestones = await listMilestones(input, slug)
151
+ const byTitle = new Map(milestones.map((m) => [m.title, m]))
152
+ const issues = await listIssues(input)
153
+ if (issues.length >= ISSUE_LIMIT) {
154
+ // The listing is a hard window with no paging. Silently short, every
155
+ // requirement outside it would be re-filed as a duplicate on each run.
156
+ throw new Error(
157
+ `github: the repository returned ${issues.length} issues, at or above the ${ISSUE_LIMIT} listing limit, so existing migration issues may fall outside the window and be duplicated; narrow the repository or raise the limit before re-running`,
158
+ )
159
+ }
160
+ const { byFr, duplicates } = indexByMarker(issues)
161
+ for (const fr of duplicates) {
162
+ process.stderr.write(
163
+ `github: more than one issue carries the marker for ${fr}; only the first is updated, and the others will drift\n`,
164
+ )
165
+ }
166
+ const byId = new Map(input.requirements.map((r) => [r.id, r]))
167
+
168
+ const created: string[] = []
169
+ const updated: string[] = []
170
+ const unchanged: string[] = []
171
+ const refs: Record<string, string> = {}
172
+
173
+ for (const item of items) {
174
+ let milestoneCreated = false
175
+ let milestone = byTitle.get(milestoneTitle(item))
176
+ if (!milestone) {
177
+ const raw = await gh(input, [
178
+ 'api',
179
+ `repos/${slug}/milestones`,
180
+ '-X',
181
+ 'POST',
182
+ '-f',
183
+ `title=${milestoneTitle(item)}`,
184
+ '-f',
185
+ `description=${item.body.split('\n')[0] ?? ''}`,
186
+ ])
187
+ milestone = JSON.parse(raw) as GhMilestone
188
+ byTitle.set(milestoneTitle(item), milestone)
189
+ milestoneCreated = true
190
+ }
191
+ refs[item.key] = `milestone:${milestone.number}`
192
+
193
+ let touched = false
194
+ for (const fr of item.frs) {
195
+ const req = byId.get(fr)
196
+ if (!req) continue
197
+ const existing = byFr.get(fr)
198
+ // The human tail is carried across, so an update replaces only the
199
+ // block this adapter owns.
200
+ const body = issueBody(req, item) + humanTail(existing?.body ?? '')
201
+ if (!existing) {
202
+ await gh(input, [
203
+ 'issue',
204
+ 'create',
205
+ '--title',
206
+ issueTitle(req),
207
+ '--body',
208
+ body,
209
+ '--milestone',
210
+ milestoneTitle(item),
211
+ ])
212
+ touched = true
213
+ continue
214
+ }
215
+ if (existing.body !== body) {
216
+ await gh(input, [
217
+ 'issue',
218
+ 'edit',
219
+ String(existing.number),
220
+ '--body',
221
+ body,
222
+ '--milestone',
223
+ milestoneTitle(item),
224
+ ])
225
+ touched = true
226
+ }
227
+ }
228
+
229
+ if (milestoneCreated) created.push(item.key)
230
+ else if (touched) updated.push(item.key)
231
+ else unchanged.push(item.key)
232
+ }
233
+
234
+ return { created, updated, unchanged, refs }
235
+ },
236
+
237
+ async throughput(input: HandoffInput): Promise<Throughput> {
238
+ const issues = await listIssues(input)
239
+ const known = new Set(input.requirements.map((r) => r.id))
240
+ const completions: Completion[] = []
241
+ // Deduped by requirement: `velocities` divides a completion count by
242
+ // elapsed days, so two closed issues under one marker would inflate the
243
+ // measured delivery rate even though coverage funnels through a Set and
244
+ // would not have noticed.
245
+ const seen = new Set<string>()
246
+ for (const issue of issues) {
247
+ if (issue.state.toUpperCase() !== 'CLOSED') continue
248
+ const fr = MARKER.exec(issue.body ?? '')?.[1]
249
+ if (fr && seen.has(fr)) continue
250
+ // An issue with no marker was filed by someone else and says nothing
251
+ // about this migration; one whose marker names a requirement the store
252
+ // does not have is reported by coverage rather than silently counted.
253
+ if (!fr || !known.has(fr)) continue
254
+ seen.add(fr)
255
+ completions.push({ fr, doneAt: issue.closedAt ? issue.closedAt.slice(0, 10) : null })
256
+ }
257
+ completions.sort((a, b) => a.fr.localeCompare(b.fr))
258
+ return { completions, basis: 'github issues closed, dated from closedAt' }
259
+ },
260
+ }