@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,183 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { markdown, parseRoadmap } from '../adapters/markdown.ts'
6
+ import type { Config } from '../config.ts'
7
+ import { buildWorkItems, type HandoffInput } from '../handoff.ts'
8
+ import type { Capability, Requirement } from '../types.ts'
9
+
10
+ let root: string
11
+
12
+ const config = (): Config => ({
13
+ source: {
14
+ path: join(root, 'legacy'),
15
+ scope: 'x',
16
+ stack: 'unknown',
17
+ vcs: 'none',
18
+ basis: 'source-only',
19
+ },
20
+ target: {
21
+ name: 'target',
22
+ stack: 'unknown',
23
+ parity_test_path: 'tests/parity/{capability}/{fr_slug}.test.ts',
24
+ layout: {},
25
+ commands: {},
26
+ },
27
+ surfaces: ['routes'],
28
+ surfaceSingular: {},
29
+ closers: [],
30
+ handoff: { adapter: 'markdown' },
31
+ })
32
+
33
+ function cap(slug: string, title: string, elements: string[]): Capability {
34
+ return { slug, title, ns: slug.slice(0, 2).toUpperCase(), elements }
35
+ }
36
+
37
+ function req(id: string, capSlug: string, text: string, cites: string[] = []): Requirement {
38
+ return {
39
+ id,
40
+ cap: capSlug,
41
+ requirement: text,
42
+ actors: 'User',
43
+ objects: 'Thing',
44
+ rules: 'none',
45
+ origin: 'intended',
46
+ confidence: { kind: 'confirmed' },
47
+ citations: cites.map((c) => ({ kind: 'ledger' as const, id: c })),
48
+ parity: { kind: 'rubric', level: 'high' },
49
+ batch: 'b-1',
50
+ }
51
+ }
52
+
53
+ const CAPS = [
54
+ cap('billing', 'Billing', ['el-b']),
55
+ cap('user-management', 'User management', ['el-u']),
56
+ ]
57
+ const REQS = [
58
+ req('UM-001', 'user-management', 'Authenticate a user against stored credentials', ['el-b']),
59
+ req('UM-002', 'user-management', 'Lock an account after five failed attempts'),
60
+ req('BI-001', 'billing', 'Raise an invoice for a completed booking'),
61
+ ]
62
+
63
+ function input(caps = CAPS, reqs = REQS): HandoffInput {
64
+ return {
65
+ requirements: reqs,
66
+ capabilities: caps,
67
+ deltas: [],
68
+ config: config(),
69
+ root,
70
+ gitBin: 'git',
71
+ ghBin: 'gh',
72
+ }
73
+ }
74
+
75
+ const roadmapPath = (): string => join(root, 'docs', 'migrate', 'roadmap.md')
76
+
77
+ beforeEach(async () => {
78
+ root = await mkdtemp(join(tmpdir(), 'migrate-md-adapter-'))
79
+ await Bun.write(join(root, 'legacy', 'app.js'), '// legacy\n')
80
+ })
81
+
82
+ afterEach(async () => {
83
+ await rm(root, { recursive: true, force: true })
84
+ })
85
+
86
+ test('apply writes a roadmap in dependency order and one file per capability', async () => {
87
+ const items = buildWorkItems(CAPS, REQS)
88
+ const result = await markdown.apply(items, input())
89
+
90
+ expect(result.created.sort()).toEqual(['billing', 'user-management'])
91
+ expect(result.unchanged).toEqual([])
92
+ expect(result.refs['billing']).toBe('docs/migrate/capabilities/billing.md')
93
+
94
+ const roadmap = await readFile(roadmapPath(), 'utf8')
95
+ // billing owns the element user-management cites, so it comes first.
96
+ expect(roadmap.indexOf('Billing')).toBeLessThan(roadmap.indexOf('User management'))
97
+ expect(roadmap).toContain('- [ ] BI-001 Raise an invoice for a completed booking')
98
+ expect(roadmap).toContain('- [ ] UM-001 Authenticate a user against stored credentials')
99
+
100
+ const capFile = await readFile(
101
+ join(root, 'docs', 'migrate', 'capabilities', 'user-management.md'),
102
+ 'utf8',
103
+ )
104
+ expect(capFile).toContain('# User management')
105
+ expect(capFile).toContain('UM-001')
106
+ expect(capFile).toContain('billing')
107
+ })
108
+
109
+ test('re-applying preserves every ticked box and its date', async () => {
110
+ const items = buildWorkItems(CAPS, REQS)
111
+ await markdown.apply(items, input())
112
+
113
+ // The owner ticks two boxes by hand, one with a date and one without.
114
+ const before = await readFile(roadmapPath(), 'utf8')
115
+ await writeFile(
116
+ roadmapPath(),
117
+ before
118
+ .replace(
119
+ '- [ ] UM-001 Authenticate a user against stored credentials',
120
+ '- [x] UM-001 <!-- done:2026-08-12 --> Authenticate a user against stored credentials',
121
+ )
122
+ .replace('- [ ] BI-001 ', '- [x] BI-001 '),
123
+ )
124
+
125
+ // A new requirement lands and handoff runs again.
126
+ const grown = [...REQS, req('UM-003', 'user-management', 'Expire a session after idle time')]
127
+ await markdown.apply(buildWorkItems(CAPS, grown), input(CAPS, grown))
128
+
129
+ const after = await readFile(roadmapPath(), 'utf8')
130
+ expect(after).toContain(
131
+ '- [x] UM-001 <!-- done:2026-08-12 --> Authenticate a user against stored credentials',
132
+ )
133
+ expect(after).toContain('- [x] BI-001 Raise an invoice for a completed booking')
134
+ expect(after).toContain('- [ ] UM-003 Expire a session after idle time')
135
+ })
136
+
137
+ test('throughput reads ticked boxes, with a null date for an undated tick', async () => {
138
+ const items = buildWorkItems(CAPS, REQS)
139
+ await markdown.apply(items, input())
140
+ const before = await readFile(roadmapPath(), 'utf8')
141
+ await writeFile(
142
+ roadmapPath(),
143
+ before
144
+ .replace('- [ ] UM-001 ', '- [x] UM-001 <!-- done:2026-08-12 --> ')
145
+ .replace('- [ ] BI-001 ', '- [x] BI-001 '),
146
+ )
147
+
148
+ const t = await markdown.throughput?.(input())
149
+ expect(t?.completions).toEqual([
150
+ { fr: 'BI-001', doneAt: null },
151
+ { fr: 'UM-001', doneAt: '2026-08-12' },
152
+ ])
153
+ expect(t?.basis).toContain('roadmap')
154
+ })
155
+
156
+ test('throughput on a roadmap that was never written reports nothing built', async () => {
157
+ const t = await markdown.throughput?.(input())
158
+ expect(t?.completions).toEqual([])
159
+ })
160
+
161
+ test('a second apply over an unchanged store reports every item unchanged', async () => {
162
+ const items = buildWorkItems(CAPS, REQS)
163
+ await markdown.apply(items, input())
164
+ const second = await markdown.apply(items, input())
165
+ expect(second.created).toEqual([])
166
+ expect(second.updated).toEqual([])
167
+ expect(second.unchanged.sort()).toEqual(['billing', 'user-management'])
168
+ })
169
+
170
+ test('plan is apply-free: it writes nothing', async () => {
171
+ const items = await markdown.plan(input())
172
+ expect(items.map((i) => i.key)).toEqual(['billing', 'user-management'])
173
+ expect(await Bun.file(roadmapPath()).exists()).toBe(false)
174
+ })
175
+
176
+ test('a requirement whose text opens with a parenthesised date is not read as dated', () => {
177
+ // The date lives in an HTML comment precisely so ordinary requirement prose
178
+ // cannot fabricate one. Parenthesised, this parsed as a completion date and
179
+ // fed a number nobody recorded into the measured velocity.
180
+ const roadmap = '- [x] BI-001 (2026-08-12) Raise an invoice\n'
181
+ const state = parseRoadmap(roadmap)
182
+ expect(state.get('BI-001')).toEqual({ checked: true, date: null })
183
+ })
@@ -0,0 +1,332 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { applyRuling, renderReviewSheet } from '../adjudicate-cmd.ts'
6
+ import { parseQueueItem } from '../queue.ts'
7
+ import type { QueueItem } from '../types.ts'
8
+
9
+ const CLI = join(import.meta.dir, '..', '..', 'bin', 'migrate.ts')
10
+
11
+ let target: string
12
+ let source: string
13
+
14
+ async function migrate(args: string[]): Promise<{ code: number; out: string; err: string }> {
15
+ const proc = Bun.spawn(['bun', CLI, ...args], { cwd: target, stdout: 'pipe', stderr: 'pipe' })
16
+ const [out, err] = await Promise.all([
17
+ new Response(proc.stdout).text(),
18
+ new Response(proc.stderr).text(),
19
+ ])
20
+ await proc.exited
21
+ return { code: proc.exitCode ?? -1, out, err }
22
+ }
23
+
24
+ function item(id: string, severity: string, extra = ''): string {
25
+ return `---
26
+ id: ${id}
27
+ severity: ${severity}
28
+ status: open
29
+ ${extra}---
30
+
31
+ ## Evidence
32
+
33
+ Two tables both look like they own the join.
34
+
35
+ ## Options
36
+
37
+ 1. Give it to billing.
38
+ 2. Give it to scheduling.
39
+
40
+ ## Recommendation
41
+
42
+ Billing owns it, on the strength of the write sites.
43
+ `
44
+ }
45
+
46
+ async function addItem(id: string, severity: string, extra = ''): Promise<void> {
47
+ const path = join(target, `${id}.md`)
48
+ await writeFile(path, item(id, severity, extra))
49
+ const added = await migrate(['queue', 'add', path])
50
+ expect(added.code).toBe(0)
51
+ }
52
+
53
+ beforeEach(async () => {
54
+ target = await mkdtemp(join(tmpdir(), 'migrate-adjudicate-'))
55
+ source = join(target, 'legacy')
56
+ await Bun.write(join(source, 'app.js'), '// legacy\n')
57
+ const init = await migrate(['init', '--source', source, '--scope', 'x', '--name', 'target'])
58
+ expect(init.code).toBe(0)
59
+ })
60
+
61
+ afterEach(async () => {
62
+ await rm(target, { recursive: true, force: true })
63
+ })
64
+
65
+ // --- applyRuling, pure ---
66
+
67
+ test('applyRuling keeps unowned keys in place and the body byte-identical', () => {
68
+ const body = `
69
+ ## Evidence
70
+
71
+ A body with a --- line inside it:
72
+
73
+ ---
74
+
75
+ and a fenced block:
76
+
77
+ \`\`\`
78
+ status: open
79
+ ruling: not a real key
80
+ \`\`\`
81
+
82
+ ## Options
83
+
84
+ Only one.
85
+
86
+ ## Recommendation
87
+
88
+ Take it.
89
+ `
90
+ // `severity` deliberately sits AFTER `status`, the one owned key already
91
+ // present. With the owned key last, an implementation that simply dropped
92
+ // owned keys and appended them would produce the same output as one that
93
+ // replaces in place, and this test would assert nothing about position.
94
+ const before = `---
95
+ id: q-table-ownership
96
+ owner: dik
97
+ status: open
98
+ severity: critical
99
+ ---${body}`
100
+
101
+ const after = applyRuling(before, 'billing owns it', '2026-08-13')
102
+
103
+ // Body preserved byte for byte, including the --- line and the fence.
104
+ expect(after.slice(after.indexOf('\n---', 3) + 4)).toBe(body)
105
+ const fm = after.slice(4, after.indexOf('\n---', 3))
106
+ expect(fm.split('\n')).toEqual([
107
+ 'id: q-table-ownership',
108
+ 'owner: dik',
109
+ 'status: adjudicated',
110
+ 'severity: critical',
111
+ 'ruling: billing owns it',
112
+ 'adjudicated: 2026-08-13',
113
+ ])
114
+ })
115
+
116
+ test('applyRuling rewrites an existing ruling in place rather than appending a second', () => {
117
+ const before = `---
118
+ id: q-x
119
+ severity: minor
120
+ status: adjudicated
121
+ ruling: an earlier call
122
+ adjudicated: 2026-08-01
123
+ ---
124
+
125
+ ## Evidence
126
+
127
+ e
128
+
129
+ ## Options
130
+
131
+ o
132
+
133
+ ## Recommendation
134
+
135
+ r
136
+ `
137
+ const after = applyRuling(before, 'the revised call', '2026-08-13')
138
+ const fm = after.slice(4, after.indexOf('\n---', 3))
139
+ expect(fm.split('\n')).toEqual([
140
+ 'id: q-x',
141
+ 'severity: minor',
142
+ 'status: adjudicated',
143
+ 'ruling: the revised call',
144
+ 'adjudicated: 2026-08-13',
145
+ ])
146
+ expect(after.match(/^ruling:/gm)).toHaveLength(1)
147
+ })
148
+
149
+ test('applyRuling refuses a ruling that would break the frontmatter it writes into', () => {
150
+ const before = item('q-x', 'minor')
151
+ // A line break is the whole hazard: it lets the value inject further
152
+ // frontmatter keys, or a closing fence. A value of '---' is not a hazard,
153
+ // because it is written as `ruling: ---` and so never sits at line start.
154
+ expect(() => applyRuling(before, 'line one\nline two', '2026-08-13')).toThrow(/newline/)
155
+ expect(() => applyRuling(before, 'line one\rline two', '2026-08-13')).toThrow(/newline/)
156
+ expect(() => applyRuling(before, ' ', '2026-08-13')).toThrow(/empty/)
157
+ expect(applyRuling(before, '---', '2026-08-13')).toContain('ruling: ---')
158
+ })
159
+
160
+ // --- renderReviewSheet, pure ---
161
+
162
+ test('renderReviewSheet is severity-ordered, carries each recommendation, and counts open', () => {
163
+ const items: QueueItem[] = [
164
+ {
165
+ id: 'q-minor-thing',
166
+ severity: 'minor',
167
+ status: 'open',
168
+ evidence: 'e',
169
+ options: 'o',
170
+ recommendation: 'Leave it alone.\nA second line nobody needs here.',
171
+ path: 'p',
172
+ },
173
+ {
174
+ id: 'q-big-thing',
175
+ severity: 'critical',
176
+ status: 'adjudicated',
177
+ ruling: 'done already',
178
+ evidence: 'e',
179
+ options: 'o',
180
+ recommendation: 'Split the table.',
181
+ path: 'p',
182
+ },
183
+ ]
184
+ expect(renderReviewSheet(items)).toBe(
185
+ [
186
+ 'q-big-thing [critical] adjudicated - Split the table.',
187
+ 'q-minor-thing [minor] open - Leave it alone.',
188
+ '',
189
+ '1 open',
190
+ ].join('\n'),
191
+ )
192
+ })
193
+
194
+ // --- the verb ---
195
+
196
+ test('adjudicate with no id prints the review sheet', async () => {
197
+ await addItem('q-alpha', 'critical')
198
+ await addItem('q-beta', 'minor')
199
+
200
+ const sheet = await migrate(['adjudicate'])
201
+ expect(sheet.code).toBe(0)
202
+ expect(sheet.out).toContain('q-alpha [critical] open - Billing owns it')
203
+ expect(sheet.out).toContain('q-beta [minor] open')
204
+ expect(sheet.out).toContain('2 open')
205
+ })
206
+
207
+ test('adjudicate records a ruling, flips status, and records a batch', async () => {
208
+ await addItem('q-alpha', 'critical')
209
+
210
+ const ruled = await migrate(['adjudicate', 'q-alpha', '--ruling', 'billing owns it'])
211
+ expect(ruled.code).toBe(0)
212
+ expect(ruled.out).toContain('q-alpha')
213
+ expect(ruled.out).toContain('open -> adjudicated')
214
+ // The verb points at the writer that applies the consequence, since it does
215
+ // not touch the row files itself.
216
+ expect(ruled.out).toContain('migrate import')
217
+
218
+ const text = await readFile(join(target, '.migrate', 'queue', 'q-alpha.md'), 'utf8')
219
+ expect(text).toContain('status: adjudicated')
220
+ expect(text).toContain('ruling: billing owns it')
221
+ expect(text).toMatch(/adjudicated: \d{4}-\d{2}-\d{2}/)
222
+ // The body survived.
223
+ expect(text).toContain('Two tables both look like they own the join.')
224
+
225
+ const phases = JSON.parse(await readFile(join(target, '.migrate', 'phases.json'), 'utf8'))
226
+ expect(phases.phases.adjudicate.batches).toHaveLength(1)
227
+ expect(phases.phases.adjudicate.batches[0].id).toBe('b-adjudicate-q-alpha')
228
+ })
229
+
230
+ test('adjudicate refuses an unknown id as a usage error', async () => {
231
+ await addItem('q-alpha', 'critical')
232
+ const missing = await migrate(['adjudicate', 'q-nope', '--ruling', 'x'])
233
+ expect(missing.code).toBe(2)
234
+ expect(missing.err).toContain('q-nope')
235
+ })
236
+
237
+ test('adjudicate refuses to overwrite an existing ruling without --force', async () => {
238
+ await addItem('q-alpha', 'critical')
239
+ await migrate(['adjudicate', 'q-alpha', '--ruling', 'the first call'])
240
+
241
+ const again = await migrate(['adjudicate', 'q-alpha', '--ruling', 'a different call'])
242
+ expect(again.code).toBe(1)
243
+ // The existing ruling is printed, so the caller sees what they would have lost.
244
+ expect(again.err).toContain('the first call')
245
+ expect(again.err).toContain('--force')
246
+
247
+ const forced = await migrate(['adjudicate', 'q-alpha', '--ruling', 'a different call', '--force'])
248
+ expect(forced.code).toBe(0)
249
+ const text = await readFile(join(target, '.migrate', 'queue', 'q-alpha.md'), 'utf8')
250
+ expect(text).toContain('ruling: a different call')
251
+ })
252
+
253
+ test('adjudicate refuses a ruling containing a newline', async () => {
254
+ await addItem('q-alpha', 'critical')
255
+ const bad = await migrate(['adjudicate', 'q-alpha', '--ruling', 'one\ntwo'])
256
+ expect(bad.code).toBe(2)
257
+ expect(bad.err).toContain('newline')
258
+ })
259
+
260
+ test('adjudicate reports a queue file that will not parse as a content failure', async () => {
261
+ await addItem('q-alpha', 'critical')
262
+ // Break the file after it is in the store: an empty Options section.
263
+ const path = join(target, '.migrate', 'queue', 'q-alpha.md')
264
+ const text = await readFile(path, 'utf8')
265
+ await writeFile(path, text.replace('1. Give it to billing.\n2. Give it to scheduling.\n', ''))
266
+
267
+ const broken = await migrate(['adjudicate', 'q-alpha', '--ruling', 'x'])
268
+ expect(broken.code).toBe(1)
269
+ expect(broken.err).toContain('q-alpha')
270
+ })
271
+
272
+ test('an indented --- inside the frontmatter is refused, not silently mis-fenced', () => {
273
+ // queue.ts closes the block at any line STARTING with ---, so a line that
274
+ // only looks like a fence after trimming is body to the parser and was a
275
+ // fence here. The owned keys landed above the real fence, the parser's
276
+ // last-key-wins read still saw `status: open`, and the command reported
277
+ // success over an item that stayed open forever.
278
+ const before = `---
279
+ id: q-x
280
+ ---
281
+ severity: minor
282
+ status: open
283
+ ---
284
+
285
+ ## Evidence
286
+
287
+ e
288
+
289
+ ## Options
290
+
291
+ o
292
+
293
+ ## Recommendation
294
+
295
+ r
296
+ `
297
+ const after = applyRuling(before, 'settled', '2026-08-13')
298
+ // Whatever this produces, it must read back as adjudicated through the
299
+ // parser every other command uses.
300
+ const reparsed = parseQueueItem(after, 'q-x.md')
301
+ expect(reparsed.ok && reparsed.value.status).toBe('adjudicated')
302
+ })
303
+
304
+ test('a nested key named status is left alone', () => {
305
+ const before = `---
306
+ id: q-x
307
+ severity: minor
308
+ status: open
309
+ meta:
310
+ status: draft
311
+ ruling: none yet
312
+ ---
313
+
314
+ ## Evidence
315
+
316
+ e
317
+
318
+ ## Options
319
+
320
+ o
321
+
322
+ ## Recommendation
323
+
324
+ r
325
+ `
326
+ const after = applyRuling(before, 'settled', '2026-08-13')
327
+ const fm = after.slice(4, after.indexOf('\n---', 3))
328
+ expect(fm).toContain(' status: draft')
329
+ expect(fm).toContain(' ruling: none yet')
330
+ expect(fm.match(/^status:/gm)).toHaveLength(1)
331
+ expect(fm.match(/^ruling:/gm)).toHaveLength(1)
332
+ })
@@ -0,0 +1,179 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { parseAssumptions, validateAssumptions } from '../assumptions.ts'
3
+ import type { CapCoverage } from '../coverage.ts'
4
+
5
+ const GOOD = `---
6
+ attestedBy: Dik Rana
7
+ attestedDate: 2026-08-13
8
+ ---
9
+
10
+ # Forecast assumptions
11
+
12
+ Prose the parser ignores.
13
+
14
+ ## Territories
15
+
16
+ | capability | territory |
17
+ | --- | --- |
18
+ | user-management | established |
19
+ | billing | unknown-ground |
20
+
21
+ ## Multipliers
22
+
23
+ | territory | multiplier |
24
+ | --- | --- |
25
+ | established | 1.0 |
26
+ | unknown-ground | 2.5 |
27
+
28
+ ## Scenarios
29
+
30
+ | label | rate | streams | tax | note |
31
+ | --- | --- | --- | --- | --- |
32
+ | as-is | as-is | 1 | 0 | one stream, measured |
33
+ | pushing | active | 2 | 0.2 | two streams, coordination tax |
34
+ | target | 1.5 | 2 | 0 | owner target, nothing measures this |
35
+
36
+ ## Caveats
37
+
38
+ - The billing rewrite has no precedent in this codebase.
39
+ `
40
+
41
+ const cap = (slug: string, confirmedTotal: number): CapCoverage => ({
42
+ slug,
43
+ title: slug,
44
+ confirmedTotal,
45
+ covered: 0,
46
+ coveredIds: [],
47
+ uncoveredIds: [],
48
+ })
49
+
50
+ test('a complete file parses into territories, multipliers, scenarios and caveats', () => {
51
+ const a = parseAssumptions(GOOD, 'f.md')
52
+ expect(a.attestedBy).toBe('Dik Rana')
53
+ expect(a.attestedDate).toBe('2026-08-13')
54
+ expect(a.territories).toEqual({ 'user-management': 'established', billing: 'unknown-ground' })
55
+ expect(a.multipliers).toEqual({ established: 1, 'unknown-ground': 2.5 })
56
+ expect(a.scenarios).toEqual([
57
+ { label: 'as-is', rate: 'as-is', streams: 1, tax: 0, note: 'one stream, measured' },
58
+ {
59
+ label: 'pushing',
60
+ rate: 'active',
61
+ streams: 2,
62
+ tax: 0.2,
63
+ note: 'two streams, coordination tax',
64
+ },
65
+ { label: 'target', rate: 1.5, streams: 2, tax: 0, note: 'owner target, nothing measures this' },
66
+ ])
67
+ expect(a.caveats).toEqual(['The billing rewrite has no precedent in this codebase.'])
68
+ })
69
+
70
+ test('an unattested file is refused, naming the missing field', () => {
71
+ expect(() => parseAssumptions(GOOD.replace('attestedBy: Dik Rana\n', ''), 'f.md')).toThrow(
72
+ /attestedBy/,
73
+ )
74
+ expect(() => parseAssumptions(GOOD.replace('attestedDate: 2026-08-13\n', ''), 'f.md')).toThrow(
75
+ /attestedDate/,
76
+ )
77
+ })
78
+
79
+ test('a missing required section is refused by name', () => {
80
+ const noMultipliers = GOOD.replace(/## Multipliers[\s\S]*?\n\n## Scenarios/, '## Scenarios')
81
+ expect(() => parseAssumptions(noMultipliers, 'f.md')).toThrow(/Multipliers/)
82
+ })
83
+
84
+ test('a scenario rate must be as-is, active, or a positive number', () => {
85
+ const bad = GOOD.replace('| as-is | as-is | 1 | 0 |', '| as-is | sometimes | 1 | 0 |')
86
+ expect(() => parseAssumptions(bad, 'f.md')).toThrow(/rate/)
87
+ const zero = GOOD.replace('| target | 1.5 | 2 | 0 |', '| target | 0 | 2 | 0 |')
88
+ expect(() => parseAssumptions(zero, 'f.md')).toThrow(/rate/)
89
+ })
90
+
91
+ test('tax must sit in [0, 1)', () => {
92
+ expect(() =>
93
+ parseAssumptions(
94
+ GOOD.replace('| pushing | active | 2 | 0.2 |', '| pushing | active | 2 | 1 |'),
95
+ 'f.md',
96
+ ),
97
+ ).toThrow(/tax/)
98
+ expect(() =>
99
+ parseAssumptions(
100
+ GOOD.replace('| pushing | active | 2 | 0.2 |', '| pushing | active | 2 | -0.1 |'),
101
+ 'f.md',
102
+ ),
103
+ ).toThrow(/tax/)
104
+ // 0.9 is legal.
105
+ expect(
106
+ parseAssumptions(
107
+ GOOD.replace('| pushing | active | 2 | 0.2 |', '| pushing | active | 2 | 0.9 |'),
108
+ 'f.md',
109
+ ).scenarios[1]?.tax,
110
+ ).toBe(0.9)
111
+ })
112
+
113
+ test('streams must be positive', () => {
114
+ expect(() =>
115
+ parseAssumptions(
116
+ GOOD.replace('| as-is | as-is | 1 | 0 |', '| as-is | as-is | 0 | 0 |'),
117
+ 'f.md',
118
+ ),
119
+ ).toThrow(/streams/)
120
+ })
121
+
122
+ test('a multiplier must be a positive number', () => {
123
+ expect(() =>
124
+ parseAssumptions(GOOD.replace('| established | 1.0 |', '| established | 0 |'), 'f.md'),
125
+ ).toThrow(/multiplier/)
126
+ })
127
+
128
+ test('every parse failure names the file', () => {
129
+ expect(() => parseAssumptions('no frontmatter here', 'docs/x.md')).toThrow(/docs\/x\.md/)
130
+ })
131
+
132
+ // --- validation against measured coverage ---
133
+
134
+ test('a capability with confirmed requirements and no territory fails by name', () => {
135
+ const a = parseAssumptions(GOOD, 'f.md')
136
+ const errors = validateAssumptions(a, [cap('user-management', 2), cap('notifications', 5)])
137
+ expect(errors).toHaveLength(1)
138
+ expect(errors[0]).toContain('notifications')
139
+ })
140
+
141
+ test('a capability with no confirmed requirements needs no territory', () => {
142
+ const a = parseAssumptions(GOOD, 'f.md')
143
+ expect(validateAssumptions(a, [cap('user-management', 2), cap('notifications', 0)])).toEqual([])
144
+ })
145
+
146
+ test('a territory with no multiplier fails by name', () => {
147
+ const a = parseAssumptions(GOOD.replace('| unknown-ground | 2.5 |\n', ''), 'f.md')
148
+ const errors = validateAssumptions(a, [cap('user-management', 1), cap('billing', 1)])
149
+ expect(errors.some((e) => e.includes('unknown-ground'))).toBe(true)
150
+ })
151
+
152
+ test('zero scenarios fails', () => {
153
+ const a = parseAssumptions(
154
+ GOOD.replace(/\| as-is \| as-is[\s\S]*?nothing measures this \|\n/, ''),
155
+ 'f.md',
156
+ )
157
+ expect(a.scenarios).toEqual([])
158
+ expect(validateAssumptions(a, [cap('user-management', 1)])).toContain('no scenarios defined')
159
+ })
160
+
161
+ test('a table written without a separator row keeps every data row', () => {
162
+ // slice(2) dropped two lines positionally, so a missing separator silently
163
+ // ate the first data row: the owner attested two scenarios and got one, and
164
+ // the lost row is the as-is baseline the template puts first.
165
+ const noSeparator = GOOD.replace('| --- | --- | --- | --- | --- |\n', '')
166
+ const a = parseAssumptions(noSeparator, 'f.md')
167
+ expect(a.scenarios.map((s) => s.label)).toEqual(['as-is', 'pushing', 'target'])
168
+ })
169
+
170
+ test('an alignment separator is recognised wherever it sits', () => {
171
+ const aligned = GOOD.replace(
172
+ '| --- | --- |\n| user-management',
173
+ '| :--- | ---: |\n| user-management',
174
+ )
175
+ expect(parseAssumptions(aligned, 'f.md').territories).toEqual({
176
+ 'user-management': 'established',
177
+ billing: 'unknown-ground',
178
+ })
179
+ })