@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 { expect, test } from 'bun:test'
2
+ import { parseAssumptions } from '../assumptions.ts'
3
+ import type { CapCoverage } from '../coverage.ts'
4
+ import { addDays, demandOf, project, renderForecast, velocities } from '../forecast.ts'
5
+ import type { Completion } from '../types.ts'
6
+
7
+ const ASSUMPTIONS = parseAssumptions(
8
+ `---
9
+ attestedBy: Dik Rana
10
+ attestedDate: 2026-08-13
11
+ ---
12
+
13
+ ## Territories
14
+
15
+ | capability | territory |
16
+ | --- | --- |
17
+ | user-management | established |
18
+ | billing | unknown-ground |
19
+
20
+ ## Multipliers
21
+
22
+ | territory | multiplier |
23
+ | --- | --- |
24
+ | established | 1.0 |
25
+ | unknown-ground | 2.5 |
26
+
27
+ ## Scenarios
28
+
29
+ | label | rate | streams | tax | note |
30
+ | --- | --- | --- | --- | --- |
31
+ | steady | as-is | 1 | 0 | one stream at the measured pace |
32
+ | target | 2 | 1 | 0 | owner target, nothing measures this |
33
+
34
+ ## Caveats
35
+
36
+ - Billing has no precedent here.
37
+ `,
38
+ 'f.md',
39
+ )
40
+
41
+ // billing: 3 confirmed, 1 covered, 2 remaining. user-management: 2 confirmed,
42
+ // 0 covered, 2 remaining. Raw demand 4; weighted 2*2.5 + 2*1.0 = 7.
43
+ const COVERAGE: CapCoverage[] = [
44
+ {
45
+ slug: 'billing',
46
+ title: 'Billing',
47
+ confirmedTotal: 3,
48
+ covered: 1,
49
+ coveredIds: ['BI-001'],
50
+ uncoveredIds: ['BI-002', 'BI-003'],
51
+ },
52
+ {
53
+ slug: 'user-management',
54
+ title: 'User management',
55
+ confirmedTotal: 2,
56
+ covered: 0,
57
+ coveredIds: [],
58
+ uncoveredIds: ['UM-001', 'UM-002'],
59
+ },
60
+ ]
61
+
62
+ const TODAY = '2026-08-11'
63
+ const DATED: Completion[] = [
64
+ { fr: 'BI-001', doneAt: '2026-08-01' },
65
+ { fr: 'BI-004', doneAt: '2026-08-03' },
66
+ { fr: 'BI-005', doneAt: '2026-08-05' },
67
+ ]
68
+
69
+ test('addDays crosses a month boundary', () => {
70
+ expect(addDays('2026-08-11', 24)).toBe('2026-09-04')
71
+ })
72
+
73
+ test('demand is reported both raw and weighted by attested multiplier', () => {
74
+ const d = demandOf(ASSUMPTIONS, COVERAGE)
75
+ expect(d.remainingRaw).toBe(4)
76
+ expect(d.remainingWeighted).toBe(7)
77
+ // Territories come out in Multipliers-table order, which is what the
78
+ // milestone sequence walks.
79
+ expect(d.territories.map((t) => t.territory)).toEqual(['established', 'unknown-ground'])
80
+ expect(d.territories[1]?.frEquivalents).toBe(5)
81
+ })
82
+
83
+ test('two measured velocities: calendar days and active days', () => {
84
+ const v = velocities(DATED, TODAY)
85
+ // Three completions over eleven calendar days, 2026-08-01 to 2026-08-11
86
+ // counted inclusively.
87
+ expect(v.asIs.value).toBeCloseTo(3 / 11, 10)
88
+ expect(v.asIs.basis).toContain('quiet days included')
89
+ // Three completions over three days on which something completed.
90
+ expect(v.active.value).toBe(1)
91
+ expect(v.active.basis).toContain('something completed')
92
+ })
93
+
94
+ test('as-is is never faster than active, which is what keeps the band the right way round', () => {
95
+ // asIs is the pessimistic floor and active the optimistic ceiling. If asIs
96
+ // can exceed active the band prints its optimistic bound later than its
97
+ // pessimistic one, which is worse than not printing a band at all. The
98
+ // dense-cadence case is where an exclusive era made that happen: one
99
+ // completion per day, ending today.
100
+ const dense: Completion[] = Array.from({ length: 10 }, (_, i) => ({
101
+ fr: `X-${i}`,
102
+ doneAt: `2026-08-${String(i + 1).padStart(2, '0')}`,
103
+ }))
104
+ const v = velocities(dense, '2026-08-10')
105
+ expect(v.asIs.value).toBeLessThanOrEqual(v.active.value ?? 0)
106
+
107
+ // And a completion dated ahead of today extends the era rather than being
108
+ // clamped to a single day, which used to turn a typo into a huge velocity.
109
+ const future = velocities(
110
+ [
111
+ { fr: 'A', doneAt: '2026-08-20' },
112
+ { fr: 'B', doneAt: '2026-08-22' },
113
+ ],
114
+ '2026-08-11',
115
+ )
116
+ expect(future.asIs.value).toBeCloseTo(2 / 3, 10)
117
+ expect(future.asIs.value).toBeLessThanOrEqual(future.active.value ?? 0)
118
+ })
119
+
120
+ test('a date that is not a real calendar day is dropped rather than parsed as NaN', () => {
121
+ const v = velocities(
122
+ [
123
+ { fr: 'A', doneAt: '2026-08-32' },
124
+ { fr: 'B', doneAt: '2026-09-01' },
125
+ { fr: 'C', doneAt: '2026-09-03' },
126
+ ],
127
+ '2026-09-05',
128
+ )
129
+ // Two usable dates remain, so a rate is still measurable and is not NaN.
130
+ expect(Number.isNaN(v.asIs.value ?? 0)).toBe(false)
131
+ expect(v.asIs.value).toBeCloseTo(2 / 5, 10)
132
+ })
133
+
134
+ test('fewer than two dated completions leaves both velocities unmeasured', () => {
135
+ const one = velocities([{ fr: 'BI-001', doneAt: '2026-08-01' }], TODAY)
136
+ expect(one.asIs.value).toBeNull()
137
+ expect(one.active.value).toBeNull()
138
+ expect(one.asIs.basis).toContain('one point is not a rate')
139
+
140
+ // Undated completions never contribute to a rate, so three of them are still
141
+ // nothing to measure from.
142
+ const undated = velocities(
143
+ [
144
+ { fr: 'A', doneAt: null },
145
+ { fr: 'B', doneAt: null },
146
+ { fr: 'C', doneAt: null },
147
+ ],
148
+ TODAY,
149
+ )
150
+ expect(undated.asIs.value).toBeNull()
151
+ })
152
+
153
+ test('a measured scenario projects raw and weighted finishes and carries a band', () => {
154
+ const projections = project({
155
+ assumptions: ASSUMPTIONS,
156
+ demand: demandOf(ASSUMPTIONS, COVERAGE),
157
+ velocity: velocities(DATED, TODAY),
158
+ today: TODAY,
159
+ })
160
+ const steady = projections[0]
161
+ expect(steady?.basis).toBe('as-is')
162
+ expect(steady?.perDay).toBeCloseTo(3 / 11, 10)
163
+ // 4 raw at 3/11 per day is 15 days; 7 weighted is 26.
164
+ expect(steady?.daysRaw).toBe(15)
165
+ expect(steady?.finishRaw).toBe('2026-08-26')
166
+ expect(steady?.daysWeighted).toBe(26)
167
+ expect(steady?.finishWeighted).toBe('2026-09-06')
168
+ // The band applies the same streams and tax to both measured bases, and its
169
+ // optimistic end is never later than its pessimistic one.
170
+ expect(steady?.band).toEqual({ optimistic: '2026-08-18', pessimistic: '2026-09-06' })
171
+ // Milestones accumulate weighted demand in Multipliers order.
172
+ expect(steady?.milestones).toEqual([
173
+ { territory: 'established', finish: '2026-08-19' },
174
+ { territory: 'unknown-ground', finish: '2026-09-06' },
175
+ ])
176
+ })
177
+
178
+ test('an owner target projects but carries no band, and is labelled as a target', () => {
179
+ const projections = project({
180
+ assumptions: ASSUMPTIONS,
181
+ demand: demandOf(ASSUMPTIONS, COVERAGE),
182
+ velocity: velocities(DATED, TODAY),
183
+ today: TODAY,
184
+ })
185
+ const target = projections[1]
186
+ expect(target?.basis).toBe('target')
187
+ expect(target?.perDay).toBe(2)
188
+ expect(target?.daysWeighted).toBe(4)
189
+ expect(target?.finishWeighted).toBe('2026-08-15')
190
+ // No measured spread backs an attested rate, so no band is offered.
191
+ expect(target?.band).toBeNull()
192
+ })
193
+
194
+ test('with no measured velocity a target still projects and a measured row does not', () => {
195
+ // This is the flow adapter's situation exactly: coverage but no dates.
196
+ const projections = project({
197
+ assumptions: ASSUMPTIONS,
198
+ demand: demandOf(ASSUMPTIONS, COVERAGE),
199
+ velocity: velocities([], TODAY),
200
+ today: TODAY,
201
+ })
202
+ expect(projections[0]?.perDay).toBeNull()
203
+ expect(projections[0]?.finishWeighted).toBeNull()
204
+ // Still a band, with both ends unmeasurable. An absent band means a target
205
+ // row, which is a different claim from a measured row nothing can date yet.
206
+ expect(projections[0]?.band).toEqual({ optimistic: null, pessimistic: null })
207
+ expect(projections[1]?.finishWeighted).toBe('2026-08-15')
208
+ })
209
+
210
+ test('the rendered forecast separates measured rows from targets and prints caveats', () => {
211
+ const out = renderForecast({
212
+ assumptions: ASSUMPTIONS,
213
+ demand: demandOf(ASSUMPTIONS, COVERAGE),
214
+ velocity: velocities(DATED, TODAY),
215
+ projections: project({
216
+ assumptions: ASSUMPTIONS,
217
+ demand: demandOf(ASSUMPTIONS, COVERAGE),
218
+ velocity: velocities(DATED, TODAY),
219
+ today: TODAY,
220
+ }),
221
+ today: TODAY,
222
+ undated: 2,
223
+ })
224
+ expect(out).toContain('attested by Dik Rana on 2026-08-13')
225
+ expect(out).toContain('remaining 4 requirement(s), 7 weighted')
226
+ expect(out).toContain('steady')
227
+ expect(out).toContain('measured')
228
+ expect(out).toContain('target (owner-attested, nothing measures this)')
229
+ expect(out).toContain('Billing has no precedent here.')
230
+ expect(out).toContain('2 completion(s) carry no date')
231
+ })
232
+
233
+ test('an unprojectable measured row says so rather than printing nothing', () => {
234
+ const out = renderForecast({
235
+ assumptions: ASSUMPTIONS,
236
+ demand: demandOf(ASSUMPTIONS, COVERAGE),
237
+ velocity: velocities([], TODAY),
238
+ projections: project({
239
+ assumptions: ASSUMPTIONS,
240
+ demand: demandOf(ASSUMPTIONS, COVERAGE),
241
+ velocity: velocities([], TODAY),
242
+ today: TODAY,
243
+ }),
244
+ today: TODAY,
245
+ undated: 0,
246
+ })
247
+ expect(out).toContain('not projected')
248
+ })
249
+
250
+ test('a finished campaign says nothing remains rather than omitting every date', () => {
251
+ // Zero remaining and no measured rate both leave every date null, but they
252
+ // are opposite pieces of news and must not print the same way.
253
+ const done: CapCoverage[] = [
254
+ {
255
+ slug: 'billing',
256
+ title: 'Billing',
257
+ confirmedTotal: 3,
258
+ covered: 3,
259
+ coveredIds: ['BI-001', 'BI-002', 'BI-003'],
260
+ uncoveredIds: [],
261
+ },
262
+ ]
263
+ const demand = demandOf(ASSUMPTIONS, done)
264
+ expect(demand.remainingWeighted).toBe(0)
265
+ const out = renderForecast({
266
+ assumptions: ASSUMPTIONS,
267
+ demand,
268
+ velocity: velocities(DATED, TODAY),
269
+ projections: project({
270
+ assumptions: ASSUMPTIONS,
271
+ demand,
272
+ velocity: velocities(DATED, TODAY),
273
+ today: TODAY,
274
+ }),
275
+ today: TODAY,
276
+ undated: 0,
277
+ })
278
+ expect(out).toContain('nothing remaining: every confirmed requirement is already built')
279
+ expect(out).not.toContain('not projected')
280
+ })
@@ -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
+ })