@iceinvein/agent-skills 0.3.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 (61) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/skills/index.json +2 -2
  4. package/skills/migrate/README.md +35 -23
  5. package/skills/migrate/SKILL.md +75 -15
  6. package/skills/migrate/bin/migrate.ts +90 -0
  7. package/skills/migrate/docs/architecture.md +61 -26
  8. package/skills/migrate/docs/reference.md +53 -8
  9. package/skills/migrate/fixtures/fake-gh.ts +113 -0
  10. package/skills/migrate/fixtures/flow-target/docs/WORK.md +12 -0
  11. package/skills/migrate/fixtures/flow-target/docs/modernisation/capability-map/.gitkeep +0 -0
  12. package/skills/migrate/fixtures/flow-target/tools/flow/src/cli.ts +156 -0
  13. package/skills/migrate/package.json +1 -1
  14. package/skills/migrate/references/phases/adjudicate.md +161 -0
  15. package/skills/migrate/references/phases/handoff.md +220 -0
  16. package/skills/migrate/references/phases/probe.md +2 -2
  17. package/skills/migrate/references/phases/queue.md +21 -14
  18. package/skills/migrate/references/run-ops.md +17 -13
  19. package/skills/migrate/scripts/__tests__/adapter-flow.test.ts +290 -0
  20. package/skills/migrate/scripts/__tests__/adapter-github.test.ts +232 -0
  21. package/skills/migrate/scripts/__tests__/adapter-markdown.test.ts +183 -0
  22. package/skills/migrate/scripts/__tests__/adjudicate.test.ts +332 -0
  23. package/skills/migrate/scripts/__tests__/assumptions.test.ts +179 -0
  24. package/skills/migrate/scripts/__tests__/coverage.test.ts +192 -0
  25. package/skills/migrate/scripts/__tests__/e2e-express.test.ts +167 -7
  26. package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +9 -4
  27. package/skills/migrate/scripts/__tests__/forecast.test.ts +280 -0
  28. package/skills/migrate/scripts/__tests__/gates-handoff.test.ts +309 -0
  29. package/skills/migrate/scripts/__tests__/handoff-cmd.test.ts +308 -0
  30. package/skills/migrate/scripts/__tests__/handoff-order.test.ts +156 -0
  31. package/skills/migrate/scripts/adapters/flow.ts +280 -0
  32. package/skills/migrate/scripts/adapters/github.ts +260 -0
  33. package/skills/migrate/scripts/adapters/markdown.ts +175 -0
  34. package/skills/migrate/scripts/adjudicate-cmd.ts +243 -0
  35. package/skills/migrate/scripts/assumptions.ts +188 -0
  36. package/skills/migrate/scripts/check.ts +119 -320
  37. package/skills/migrate/scripts/coverage-cmd.ts +86 -0
  38. package/skills/migrate/scripts/coverage.ts +151 -0
  39. package/skills/migrate/scripts/dates.ts +17 -0
  40. package/skills/migrate/scripts/forecast-cmd.ts +124 -0
  41. package/skills/migrate/scripts/forecast.ts +264 -0
  42. package/skills/migrate/scripts/gates/adjudication.ts +30 -0
  43. package/skills/migrate/scripts/gates/census.ts +107 -0
  44. package/skills/migrate/scripts/gates/citations.ts +11 -0
  45. package/skills/migrate/scripts/gates/context.ts +76 -0
  46. package/skills/migrate/scripts/gates/coverage.ts +22 -0
  47. package/skills/migrate/scripts/gates/deltas.ts +15 -0
  48. package/skills/migrate/scripts/gates/handoff.ts +145 -0
  49. package/skills/migrate/scripts/gates/leaks.ts +11 -0
  50. package/skills/migrate/scripts/gates/parity.ts +15 -0
  51. package/skills/migrate/scripts/gates/queue.ts +9 -0
  52. package/skills/migrate/scripts/gates/refs.ts +97 -0
  53. package/skills/migrate/scripts/gates/run-state.ts +67 -0
  54. package/skills/migrate/scripts/gates/source.ts +28 -0
  55. package/skills/migrate/scripts/handoff-cmd.ts +186 -0
  56. package/skills/migrate/scripts/handoff.ts +330 -0
  57. package/skills/migrate/scripts/paths.ts +4 -0
  58. package/skills/migrate/scripts/types.ts +43 -0
  59. package/skills/migrate/scripts/validate.ts +12 -0
  60. package/skills/migrate/skill.json +2 -2
  61. package/skills/migrate/templates/forecast-assumptions.md +59 -0
@@ -0,0 +1,309 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { runCheck } from '../check.ts'
6
+ import type { HandoffFile } from '../handoff.ts'
7
+ import type { Requirement, Violation } from '../types.ts'
8
+
9
+ let root: string
10
+ let source: string
11
+
12
+ const PHASES = ['probe', 'enumerate', 'seam', 'extract', 'parity', 'queue', 'adjudicate', 'handoff']
13
+
14
+ function req(id: string, over: Partial<Requirement> = {}): Requirement {
15
+ return {
16
+ id,
17
+ cap: 'user-management',
18
+ requirement: `requirement ${id}`,
19
+ actors: 'User',
20
+ objects: 'Thing',
21
+ rules: 'none',
22
+ origin: 'intended',
23
+ confidence: { kind: 'confirmed' },
24
+ citations: [{ kind: 'src', path: 'app.js', lines: [1, 1] }],
25
+ parity: { kind: 'rubric', level: 'high' },
26
+ batch: 'b-extract-1',
27
+ ...over,
28
+ }
29
+ }
30
+
31
+ const HANDOFF: HandoffFile = {
32
+ version: 1,
33
+ adapter: 'markdown',
34
+ items: [
35
+ {
36
+ key: 'user-management',
37
+ title: 'User management',
38
+ frs: ['UM-001', 'UM-002'],
39
+ dependsOn: [],
40
+ weight: 2,
41
+ },
42
+ ],
43
+ refs: { 'user-management': 'docs/migrate/capabilities/user-management.md' },
44
+ basis: { confirmed: 2, emitted: 2, order: ['user-management'] },
45
+ }
46
+
47
+ async function jsonl(name: string, rows: unknown[]): Promise<void> {
48
+ await writeFile(join(root, '.migrate', name), rows.map((r) => `${JSON.stringify(r)}\n`).join(''))
49
+ }
50
+
51
+ async function store(
52
+ over: {
53
+ requirements?: Requirement[]
54
+ handoff?: HandoffFile | null
55
+ queueStatus?: string
56
+ ruling?: boolean
57
+ } = {},
58
+ ): Promise<void> {
59
+ await mkdir(join(root, '.migrate', 'queue'), { recursive: true })
60
+ await writeFile(
61
+ join(root, '.migrate', 'config.toml'),
62
+ [
63
+ '[source]',
64
+ `path = "${source}"`,
65
+ 'scope = "gate fixture"',
66
+ 'stack = "unknown"',
67
+ 'vcs = "none"',
68
+ 'basis = "source-only"',
69
+ '',
70
+ '[target]',
71
+ 'name = "target"',
72
+ 'stack = "unknown"',
73
+ 'parity_test_path = "tests/parity/{capability}/{fr_slug}.test.ts"',
74
+ '',
75
+ '[surfaces]',
76
+ 'types = ["routes"]',
77
+ '',
78
+ '[closers]',
79
+ 'set = []',
80
+ '',
81
+ ].join('\n'),
82
+ )
83
+ const requirements = over.requirements ?? [req('UM-001'), req('UM-002')]
84
+ await jsonl('elements.jsonl', [
85
+ {
86
+ id: 'route-get-users',
87
+ surface: 'routes',
88
+ element: 'GET /users',
89
+ found_by: ['code'],
90
+ disposition: { kind: 'mapped', fr: 'UM-001' },
91
+ refs: [],
92
+ lens: 'code',
93
+ batch: 'b-routes-1',
94
+ notes: '',
95
+ },
96
+ ])
97
+ await jsonl('requirements.jsonl', requirements)
98
+ await jsonl('capabilities.jsonl', [
99
+ { slug: 'user-management', title: 'User management', ns: 'UM', elements: ['route-get-users'] },
100
+ ])
101
+ await jsonl('census.jsonl', [
102
+ {
103
+ kind: 'lens',
104
+ surface: 'routes',
105
+ phase: 'enumerate',
106
+ directions: {
107
+ code: { count: 1, evidence: 'grep app.get' },
108
+ nav: { count: 1, evidence: 'walked the router' },
109
+ },
110
+ total: 1,
111
+ in_ledger: 0,
112
+ added: 1,
113
+ skipped: [],
114
+ queued: [],
115
+ batch: 'b-routes-1',
116
+ },
117
+ ])
118
+ const status = over.queueStatus ?? 'adjudicated'
119
+ await writeFile(
120
+ join(root, '.migrate', 'queue', 'q-open-question.md'),
121
+ [
122
+ '---',
123
+ 'id: q-open-question',
124
+ 'severity: moderate',
125
+ `status: ${status}`,
126
+ ...(status === 'adjudicated' && over.ruling !== false ? ['ruling: settled'] : []),
127
+ '---',
128
+ '',
129
+ '## Evidence',
130
+ '',
131
+ 'e',
132
+ '',
133
+ '## Options',
134
+ '',
135
+ 'o',
136
+ '',
137
+ '## Recommendation',
138
+ '',
139
+ 'r',
140
+ '',
141
+ ].join('\n'),
142
+ )
143
+ const phases: Record<string, unknown> = {}
144
+ for (const p of PHASES) {
145
+ phases[p] = {
146
+ status: 'done',
147
+ batches:
148
+ p === 'enumerate'
149
+ ? [{ id: 'b-routes-1', count: 1 }]
150
+ : p === 'extract'
151
+ ? [{ id: 'b-extract-1', count: requirements.length }]
152
+ : [],
153
+ pending: [],
154
+ }
155
+ }
156
+ await writeFile(
157
+ join(root, '.migrate', 'phases.json'),
158
+ JSON.stringify({ version: 1, phases }, null, 2),
159
+ )
160
+ const handoff = over.handoff === undefined ? HANDOFF : over.handoff
161
+ if (handoff) {
162
+ await writeFile(join(root, '.migrate', 'handoff.json'), `${JSON.stringify(handoff, null, 2)}\n`)
163
+ }
164
+ }
165
+
166
+ // Marks every phase up to and including `last` as done and the rest pending.
167
+ async function setPhasesThrough(last: string): Promise<void> {
168
+ const phases: Record<string, unknown> = {}
169
+ for (const p of PHASES) {
170
+ phases[p] = {
171
+ status: PHASES.indexOf(p) <= PHASES.indexOf(last) ? 'done' : 'pending',
172
+ batches:
173
+ p === 'enumerate'
174
+ ? [{ id: 'b-routes-1', count: 1 }]
175
+ : p === 'extract'
176
+ ? [{ id: 'b-extract-1', count: 2 }]
177
+ : [],
178
+ pending: [],
179
+ }
180
+ }
181
+ await writeFile(
182
+ join(root, '.migrate', 'phases.json'),
183
+ JSON.stringify({ version: 1, phases }, null, 2),
184
+ )
185
+ }
186
+
187
+ const of = (violations: Violation[], gate: string): string[] =>
188
+ violations.filter((v) => v.gate === gate).map((v) => v.message)
189
+
190
+ beforeEach(async () => {
191
+ root = await mkdtemp(join(tmpdir(), 'migrate-gates-'))
192
+ source = join(root, 'legacy')
193
+ await Bun.write(join(source, 'app.js'), '// legacy\n')
194
+ })
195
+
196
+ afterEach(async () => {
197
+ await rm(root, { recursive: true, force: true })
198
+ })
199
+
200
+ test('a complete store passes both new gates', async () => {
201
+ await store()
202
+ const { violations } = await runCheck({ root })
203
+ expect(of(violations, 'adjudication')).toEqual([])
204
+ expect(of(violations, 'handoff')).toEqual([])
205
+ })
206
+
207
+ // --- gate 11, adjudication ---
208
+
209
+ test('gate 11 names each open queue item with its severity', async () => {
210
+ await store({ queueStatus: 'open' })
211
+ const { violations } = await runCheck({ root })
212
+ const messages = of(violations, 'adjudication')
213
+ expect(messages).toHaveLength(1)
214
+ expect(messages[0]).toContain('q-open-question')
215
+ expect(messages[0]).toContain('moderate')
216
+ })
217
+
218
+ // --- gate 12, handoff ---
219
+
220
+ test('gate 12 fails when handoff has never run', async () => {
221
+ await store({ handoff: null })
222
+ const { violations } = await runCheck({ root })
223
+ const messages = of(violations, 'handoff')
224
+ expect(messages).toHaveLength(1)
225
+ expect(messages[0]).toContain('handoff.json')
226
+ })
227
+
228
+ test('gate 12 names a requirement that reached no work item', async () => {
229
+ await store({ requirements: [req('UM-001'), req('UM-002'), req('UM-003')] })
230
+ const { violations } = await runCheck({ root })
231
+ const messages = of(violations, 'handoff')
232
+ expect(messages.some((m) => m.includes('UM-003'))).toBe(true)
233
+ })
234
+
235
+ test('gate 12 names a work item entry that resolves to no requirement', async () => {
236
+ await store({
237
+ handoff: {
238
+ ...HANDOFF,
239
+ items: [{ ...HANDOFF.items[0], frs: ['UM-001', 'UM-002', 'UM-999'] } as never],
240
+ },
241
+ })
242
+ const { violations } = await runCheck({ root })
243
+ expect(of(violations, 'handoff').some((m) => m.includes('UM-999'))).toBe(true)
244
+ })
245
+
246
+ test('gate 12 names a dependsOn that resolves to no work item', async () => {
247
+ await store({
248
+ handoff: {
249
+ ...HANDOFF,
250
+ items: [{ ...HANDOFF.items[0], dependsOn: ['nowhere'] } as never],
251
+ },
252
+ })
253
+ const { violations } = await runCheck({ root })
254
+ expect(of(violations, 'handoff').some((m) => m.includes('nowhere'))).toBe(true)
255
+ })
256
+
257
+ test('gate 12 names a basis whose counts disagree with the store', async () => {
258
+ await store({
259
+ handoff: { ...HANDOFF, basis: { confirmed: 99, emitted: 2, order: ['user-management'] } },
260
+ })
261
+ const { violations } = await runCheck({ root })
262
+ expect(of(violations, 'handoff').some((m) => m.includes('confirmed'))).toBe(true)
263
+ })
264
+
265
+ test('gate 12 counts only confirmed requirements in the basis denominator', async () => {
266
+ // One inferred requirement: still emitted, still in a work item, but not part
267
+ // of the confirmed denominator that coverage divides by.
268
+ await store({
269
+ requirements: [req('UM-001'), req('UM-002', { confidence: { kind: 'inferred' } })],
270
+ handoff: { ...HANDOFF, basis: { confirmed: 1, emitted: 2, order: ['user-management'] } },
271
+ })
272
+ const { violations } = await runCheck({ root })
273
+ expect(of(violations, 'handoff')).toEqual([])
274
+ })
275
+
276
+ // --- the phase-scope rule ---
277
+
278
+ test('both gates are skipped below their phase and fire at it', async () => {
279
+ await store({ handoff: null, queueStatus: 'open' })
280
+ // The phases past `queue` must be pending for the skip to apply at all: a
281
+ // store whose own state file claims it reached handoff cannot hide an
282
+ // unemitted handoff by being checked at an earlier terminus.
283
+ await setPhasesThrough('queue')
284
+
285
+ // At --phase queue neither gate describes work the run claims to have
286
+ // reached, so neither fires. This is what keeps mid-run checking usable.
287
+ const mid = await runCheck({ root, phase: 'queue' })
288
+ expect(of(mid.violations, 'adjudication')).toEqual([])
289
+ expect(of(mid.violations, 'handoff')).toEqual([])
290
+
291
+ // At --phase adjudicate the adjudication gate applies but handoff does not.
292
+ const late = await runCheck({ root, phase: 'adjudicate' })
293
+ expect(of(late.violations, 'adjudication')).toHaveLength(1)
294
+ expect(of(late.violations, 'handoff')).toEqual([])
295
+
296
+ // Unbounded, both apply.
297
+ const full = await runCheck({ root })
298
+ expect(of(full.violations, 'adjudication')).toHaveLength(1)
299
+ expect(of(full.violations, 'handoff')).toHaveLength(1)
300
+ })
301
+
302
+ test('a store that stops at the queue still passes check --phase queue', async () => {
303
+ // The Milestone 2 posture: no adjudication, no handoff, and the phases after
304
+ // queue still pending. Adding two gates must not narrow this.
305
+ await store({ handoff: null, queueStatus: 'open' })
306
+ await setPhasesThrough('queue')
307
+ const { violations } = await runCheck({ root, phase: 'queue' })
308
+ expect(violations).toEqual([])
309
+ })
@@ -0,0 +1,308 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { blockedRequirements } from '../handoff.ts'
6
+ import { runHandoff } from '../handoff-cmd.ts'
7
+ import type { Requirement } from '../types.ts'
8
+
9
+ let root: string
10
+ let source: string
11
+
12
+ function req(id: string, over: Partial<Requirement> = {}): Requirement {
13
+ return {
14
+ id,
15
+ cap: 'user-management',
16
+ requirement: `requirement ${id}`,
17
+ actors: 'User',
18
+ objects: 'Thing',
19
+ rules: 'none',
20
+ origin: 'intended',
21
+ confidence: { kind: 'confirmed' },
22
+ citations: [{ kind: 'src', path: 'app.js', lines: [1, 1] }],
23
+ parity: { kind: 'rubric', level: 'high' },
24
+ batch: 'b-extract-1',
25
+ ...over,
26
+ }
27
+ }
28
+
29
+ const PHASES = ['probe', 'enumerate', 'seam', 'extract', 'parity', 'queue', 'adjudicate', 'handoff']
30
+
31
+ async function jsonl(name: string, rows: unknown[]): Promise<void> {
32
+ await writeFile(join(root, '.migrate', name), rows.map((r) => `${JSON.stringify(r)}\n`).join(''))
33
+ }
34
+
35
+ // A complete, gate-clean store with one declared surface and no closers, so a
36
+ // handoff test does not have to satisfy eight lens censuses to reach the thing
37
+ // it is actually asserting.
38
+ async function store(over: { requirements?: Requirement[]; queueStatus?: string } = {}) {
39
+ await mkdir(join(root, '.migrate', 'queue'), { recursive: true })
40
+ await writeFile(
41
+ join(root, '.migrate', 'config.toml'),
42
+ [
43
+ '[source]',
44
+ `path = "${source}"`,
45
+ 'scope = "handoff fixture"',
46
+ 'stack = "unknown"',
47
+ 'vcs = "none"',
48
+ 'basis = "source-only"',
49
+ '',
50
+ '[target]',
51
+ 'name = "target"',
52
+ 'stack = "unknown"',
53
+ 'parity_test_path = "tests/parity/{capability}/{fr_slug}.test.ts"',
54
+ '',
55
+ '[surfaces]',
56
+ 'types = ["routes"]',
57
+ '',
58
+ '[closers]',
59
+ 'set = []',
60
+ '',
61
+ '[handoff]',
62
+ 'adapter = "markdown"',
63
+ '',
64
+ ].join('\n'),
65
+ )
66
+
67
+ const requirements = over.requirements ?? [req('UM-001'), req('UM-002')]
68
+ await jsonl('elements.jsonl', [
69
+ {
70
+ id: 'route-get-users',
71
+ surface: 'routes',
72
+ element: 'GET /users',
73
+ found_by: ['code'],
74
+ disposition: { kind: 'mapped', fr: 'UM-001' },
75
+ refs: [],
76
+ lens: 'code',
77
+ batch: 'b-routes-1',
78
+ notes: '',
79
+ },
80
+ ])
81
+ await jsonl('requirements.jsonl', requirements)
82
+ await jsonl('capabilities.jsonl', [
83
+ {
84
+ slug: 'user-management',
85
+ title: 'User management',
86
+ ns: 'UM',
87
+ elements: ['route-get-users'],
88
+ },
89
+ ])
90
+ await jsonl('census.jsonl', [
91
+ {
92
+ kind: 'lens',
93
+ surface: 'routes',
94
+ phase: 'enumerate',
95
+ directions: {
96
+ code: { count: 1, evidence: 'grep app.get' },
97
+ nav: { count: 1, evidence: 'walked the router' },
98
+ },
99
+ total: 1,
100
+ in_ledger: 0,
101
+ added: 1,
102
+ skipped: [],
103
+ queued: [],
104
+ batch: 'b-routes-1',
105
+ },
106
+ ])
107
+
108
+ await writeFile(
109
+ join(root, '.migrate', 'queue', 'q-open-question.md'),
110
+ [
111
+ '---',
112
+ 'id: q-open-question',
113
+ 'severity: moderate',
114
+ `status: ${over.queueStatus ?? 'adjudicated'}`,
115
+ ...(over.queueStatus === 'open' ? [] : ['ruling: settled in favour of billing']),
116
+ '---',
117
+ '',
118
+ '## Evidence',
119
+ '',
120
+ 'e',
121
+ '',
122
+ '## Options',
123
+ '',
124
+ 'o',
125
+ '',
126
+ '## Recommendation',
127
+ '',
128
+ 'r',
129
+ '',
130
+ ].join('\n'),
131
+ )
132
+
133
+ const phases: Record<string, unknown> = {}
134
+ for (const p of PHASES) {
135
+ phases[p] = {
136
+ status: 'done',
137
+ batches:
138
+ p === 'enumerate'
139
+ ? [{ id: 'b-routes-1', count: 1 }]
140
+ : p === 'extract'
141
+ ? [{ id: 'b-extract-1', count: requirements.length }]
142
+ : [],
143
+ pending: [],
144
+ }
145
+ }
146
+ // handoff has not run yet: this is the command under test.
147
+ phases.handoff = { status: 'pending', batches: [], pending: [] }
148
+ await writeFile(
149
+ join(root, '.migrate', 'phases.json'),
150
+ JSON.stringify({ version: 1, phases }, null, 2),
151
+ )
152
+ }
153
+
154
+ beforeEach(async () => {
155
+ root = await mkdtemp(join(tmpdir(), 'migrate-handoff-'))
156
+ source = join(root, 'legacy')
157
+ await Bun.write(join(source, 'app.js'), '// legacy\n')
158
+ })
159
+
160
+ afterEach(async () => {
161
+ await rm(root, { recursive: true, force: true })
162
+ })
163
+
164
+ // --- blockedRequirements, pure ---
165
+
166
+ test('a queued confidence blocks only while its queue item is open', () => {
167
+ const queued = req('UM-001', { confidence: { kind: 'queued', queue: 'q-x' } })
168
+
169
+ expect(blockedRequirements([queued], new Set(['q-x']))).toEqual([{ fr: 'UM-001', queue: 'q-x' }])
170
+ // The correction this milestone exists to make. Once the item is
171
+ // adjudicated it is no longer open, the decision is settled, and the
172
+ // requirement must stop blocking handoff even though its confidence still
173
+ // reads `queued`. Treating any queued confidence as a blocker would refuse
174
+ // handoff forever unless every such requirement were re-imported first.
175
+ expect(blockedRequirements([queued], new Set())).toEqual([])
176
+ })
177
+
178
+ test('a sub-high rubric parity blocks on its own queue item', () => {
179
+ const moderate = req('UM-002', {
180
+ parity: { kind: 'rubric', level: 'moderate', queue: 'q-y' },
181
+ })
182
+ expect(blockedRequirements([moderate], new Set(['q-y']))).toEqual([
183
+ { fr: 'UM-002', queue: 'q-y' },
184
+ ])
185
+ expect(blockedRequirements([moderate], new Set())).toEqual([])
186
+ })
187
+
188
+ test('a high rubric and a differential parity never block', () => {
189
+ const high = req('UM-003')
190
+ const differential = req('UM-004', { parity: { kind: 'differential', ref: 't.test.ts' } })
191
+ expect(blockedRequirements([high, differential], new Set(['q-x']))).toEqual([])
192
+ })
193
+
194
+ test('one requirement blocked from both directions is reported once per path', () => {
195
+ const both = req('UM-005', {
196
+ confidence: { kind: 'queued', queue: 'q-a' },
197
+ parity: { kind: 'rubric', level: 'low', queue: 'q-b' },
198
+ })
199
+ expect(blockedRequirements([both], new Set(['q-a', 'q-b']))).toEqual([
200
+ { fr: 'UM-005', queue: 'q-a' },
201
+ { fr: 'UM-005', queue: 'q-b' },
202
+ ])
203
+ })
204
+
205
+ // --- the verb ---
206
+
207
+ test('handoff writes handoff.json and records the phase batch', async () => {
208
+ await store()
209
+ const code = await runHandoff({ root })
210
+ expect(code).toBe(0)
211
+
212
+ const file = JSON.parse(await readFile(join(root, '.migrate', 'handoff.json'), 'utf8'))
213
+ expect(file.adapter).toBe('markdown')
214
+ expect(file.items).toHaveLength(1)
215
+ expect(file.items[0].frs).toEqual(['UM-001', 'UM-002'])
216
+ expect(file.basis).toEqual({ confirmed: 2, emitted: 2, order: ['user-management'] })
217
+ // The stored item drops the rendered body, which is regenerated on every
218
+ // plan() and would otherwise churn the file on wording alone.
219
+ expect(file.items[0].body).toBeUndefined()
220
+
221
+ const phases = JSON.parse(await readFile(join(root, '.migrate', 'phases.json'), 'utf8'))
222
+ expect(phases.phases.handoff.batches).toHaveLength(1)
223
+ })
224
+
225
+ test('handoff.json is byte-identical across two runs over one store', async () => {
226
+ await store()
227
+ await runHandoff({ root })
228
+ const first = await readFile(join(root, '.migrate', 'handoff.json'), 'utf8')
229
+ await runHandoff({ root })
230
+ const second = await readFile(join(root, '.migrate', 'handoff.json'), 'utf8')
231
+ expect(second).toBe(first)
232
+ })
233
+
234
+ test('handoff refuses while a queue item is open, naming it', async () => {
235
+ await store({ queueStatus: 'open' })
236
+ const code = await runHandoff({ root })
237
+ expect(code).toBe(1)
238
+ expect(await Bun.file(join(root, '.migrate', 'handoff.json')).exists()).toBe(false)
239
+ })
240
+
241
+ test('handoff refuses a requirement blocked by an open item, naming both', async () => {
242
+ await store({
243
+ requirements: [
244
+ req('UM-001'),
245
+ req('UM-002', { confidence: { kind: 'queued', queue: 'q-open-question' } }),
246
+ ],
247
+ queueStatus: 'open',
248
+ })
249
+ // Asserting the message, not just the exit code. Gate 11 already fails an
250
+ // open queue item, so a test that only checked `code === 1` passed even with
251
+ // blockedRequirements stubbed out to return nothing, and the "naming both"
252
+ // in this test's own name went unverified.
253
+ const written: string[] = []
254
+ const original = process.stderr.write.bind(process.stderr)
255
+ process.stderr.write = ((chunk: string) => {
256
+ written.push(String(chunk))
257
+ return true
258
+ }) as typeof process.stderr.write
259
+ let code: number
260
+ try {
261
+ code = await runHandoff({ root })
262
+ } finally {
263
+ process.stderr.write = original
264
+ }
265
+ expect(code).toBe(1)
266
+ expect(written.join('')).toContain('UM-002 blocked by q-open-question')
267
+ })
268
+
269
+ test('a corrupt handoff.json is a violation, not a crash', async () => {
270
+ await store()
271
+ await writeFile(join(root, '.migrate', 'handoff.json'), '{"items": "not an array"}')
272
+ const { runCheck } = await import('../check.ts')
273
+ // Bounded checks must still work: the file is read only for the gate that
274
+ // needs it, so a corrupt one cannot break `check --phase probe`, `status` or
275
+ // `report`.
276
+ const early = await runCheck({ root, phase: 'probe' })
277
+ expect(early.violations.filter((v) => v.gate === 'handoff')).toEqual([])
278
+ const full = await runCheck({ root })
279
+ const named = full.violations.filter((v) => v.gate === 'handoff')
280
+ expect(named.length).toBeGreaterThan(0)
281
+ expect(named[0]?.message).toContain('handoff.json')
282
+ })
283
+
284
+ test('--dry-run runs the preflight and writes nothing', async () => {
285
+ await store()
286
+ const code = await runHandoff({ root, dryRun: true })
287
+ expect(code).toBe(0)
288
+ expect(await Bun.file(join(root, '.migrate', 'handoff.json')).exists()).toBe(false)
289
+ expect(await Bun.file(join(root, 'docs', 'migrate', 'roadmap.md')).exists()).toBe(false)
290
+ })
291
+
292
+ test('--dry-run still refuses on an open queue item', async () => {
293
+ await store({ queueStatus: 'open' })
294
+ expect(await runHandoff({ root, dryRun: true })).toBe(1)
295
+ })
296
+
297
+ test('an unknown adapter is a usage error naming the three that exist', async () => {
298
+ await store()
299
+ const code = await runHandoff({ root, adapter: 'jira' })
300
+ expect(code).toBe(2)
301
+ })
302
+
303
+ test('the preflight is bounded at adjudicate, so gate 12 cannot block the run that satisfies it', async () => {
304
+ // handoff.json does not exist yet, and gate 12 requires it. An unbounded
305
+ // preflight would therefore refuse every first handoff, forever.
306
+ await store()
307
+ expect(await runHandoff({ root })).toBe(0)
308
+ })