@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
@@ -755,7 +755,7 @@ function readTomlString(text: string, key: string): string {
755
755
  return match[1]
756
756
  }
757
757
 
758
- test('contract-only run driven probe through queue, ending green at check --phase queue', async () => {
758
+ test('contract-only run driven probe through handoff, ending green at a plain check', async () => {
759
759
  const groundTruthPath = join(source, 'GROUND-TRUTH.md')
760
760
  const rows = await parseGroundTruth(groundTruthPath)
761
761
  expect(rows.length).toBeGreaterThan(0)
@@ -1012,8 +1012,8 @@ test('contract-only run driven probe through queue, ending green at check --phas
1012
1012
 
1013
1013
  // 10. Phase 5, queue. Every item this run owed was filed in the pass that
1014
1014
  // named it, so this phase closes on the status flip, exactly as queue.md
1015
- // says: closing is not "the queue is empty", since nothing in this milestone
1016
- // adjudicates an item.
1015
+ // says: closing is not "the queue is empty", since phase 5 itself never
1016
+ // adjudicates an item. Phase 6, below, is where they get decided.
1017
1017
  const listed = await migrate(['queue', 'list', '--open'])
1018
1018
  expect(listed.code).toBe(0)
1019
1019
  expect(listed.out).toContain(`${Object.keys(QUEUE_ITEMS).length + 1} item(s)`)
@@ -1025,12 +1025,13 @@ test('contract-only run driven probe through queue, ending green at check --phas
1025
1025
  expect(green.out).not.toContain('Violations')
1026
1026
  expect(green.code).toBe(0)
1027
1027
 
1028
- // 12. Plain `migrate check` gates every phase through handoff, and fails on
1029
- // exactly the two that have no verb in this milestone. The violation count
1030
- // is what makes "exactly" an assertion rather than a hope.
1028
+ // 12. Plain `migrate check` gates every phase through handoff. A run that
1029
+ // stops at the queue fails it on three distinct fronts, and naming each one
1030
+ // is what makes this an assertion rather than a hope: the two phases still
1031
+ // pending, every queue item nobody has ruled on, and the handoff that never
1032
+ // emitted anything.
1031
1033
  const full = await migrate(['check'])
1032
1034
  expect(full.code).toBe(1)
1033
- expect(full.out).toContain('Violations (2):')
1034
1035
  expect(full.out).toContain(' run-state:')
1035
1036
  expect(full.out).toContain(
1036
1037
  ' phase adjudicate is pending; every phase through handoff must be done',
@@ -1038,6 +1039,10 @@ test('contract-only run driven probe through queue, ending green at check --phas
1038
1039
  expect(full.out).toContain(
1039
1040
  ' phase handoff is pending; every phase through handoff must be done',
1040
1041
  )
1042
+ expect(full.out).toContain(' adjudication:')
1043
+ expect(full.out).toContain('is still open; every queue item needs a ruling before handoff')
1044
+ expect(full.out).toContain(' handoff:')
1045
+ expect(full.out).toContain('no handoff.json in the store')
1041
1046
 
1042
1047
  // 13. The terminus assertion in step 11 is load-bearing, shown by mutation
1043
1048
  // rather than asserted: nulling every requirement's parity plan is a phase-4
@@ -1090,4 +1095,159 @@ test('contract-only run driven probe through queue, ending green at check --phas
1090
1095
  expect(afterRemoval.out).toContain(
1091
1096
  `lens census for ${mutatedSurface} claims in_ledger 0 + added ${claimed} = ${claimed} element(s) in the ledger, but elements.jsonl has ${actual}`,
1092
1097
  )
1098
+ await writeFile(elementsPath, elementsText)
1099
+ expect((await migrate(['check', '--phase', 'queue'])).code).toBe(0)
1100
+
1101
+ // 15. Phase 6, adjudicate. The review sheet first, because that is how the
1102
+ // phase is meant to be worked: one pass over every open item with its
1103
+ // recommendation in view, rather than opening four files.
1104
+ const sheet = await migrate(['adjudicate'])
1105
+ expect(sheet.code).toBe(0)
1106
+ expect(sheet.out).toContain(`${Object.keys(QUEUE_ITEMS).length + 1} open`)
1107
+ for (const id of [...Object.keys(QUEUE_ITEMS), QUEUE_ID]) {
1108
+ expect(sheet.out).toContain(`${id} [`)
1109
+ }
1110
+
1111
+ const RULINGS: Record<string, string> = {
1112
+ 'q-express-user-list-source':
1113
+ 'the empty list is the real behaviour; the table stays unread and unmapped',
1114
+ 'q-express-mailer-delivery-unobservable':
1115
+ 'accept the rubric at moderate; delivery is unobservable from the source alone',
1116
+ 'q-express-users-table-unwired': 'the table is in scope and stays mapped to UD-002',
1117
+ [QUEUE_ID]: 'the scaffold row is enumeration noise and is skipped by name',
1118
+ }
1119
+ for (const [id, ruling] of Object.entries(RULINGS)) {
1120
+ const ruled = await migrate(['adjudicate', id, '--ruling', ruling])
1121
+ expect(ruled.code).toBe(0)
1122
+ expect(ruled.out).toContain('open -> adjudicated')
1123
+ }
1124
+ // Re-ruling one of them refuses without --force, so an owner's recorded
1125
+ // decision cannot be replaced by a re-run that meant no harm.
1126
+ const reruled = await migrate(['adjudicate', QUEUE_ID, '--ruling', 'something else'])
1127
+ expect(reruled.code).toBe(1)
1128
+ expect(reruled.err).toContain('--force')
1129
+
1130
+ expect((await migrate(['adjudicate'])).out).toContain('0 open')
1131
+ expect((await migrate(['phase', 'adjudicate', '--status', 'done'])).code).toBe(0)
1132
+
1133
+ // 16. Phase 7, handoff. UD-006 still carries a `queued` confidence, and its
1134
+ // queue item is now adjudicated, so it no longer blocks: that is the whole
1135
+ // point of measuring blockers against open items rather than against the
1136
+ // confidence field.
1137
+ const dry = await migrate(['handoff', '--dry-run'])
1138
+ expect(dry.code).toBe(0)
1139
+ expect(dry.out).toContain('plan:')
1140
+ expect(dry.out).toContain('nothing written')
1141
+ expect(await Bun.file(join(storeDir, 'handoff.json')).exists()).toBe(false)
1142
+
1143
+ const emitted = await migrate(['handoff'])
1144
+ expect(emitted.code).toBe(0)
1145
+ expect(emitted.out).toContain('adapter markdown')
1146
+
1147
+ const handoffFile = JSON.parse(await readFile(join(storeDir, 'handoff.json'), 'utf8'))
1148
+ expect(handoffFile.basis.emitted).toBe(REQUIREMENTS.length)
1149
+ // Every capability the seam declared reached a work item.
1150
+ expect(handoffFile.items).toHaveLength(CAPABILITIES.length)
1151
+ const roadmapPath = join(target, 'docs', 'migrate', 'roadmap.md')
1152
+ const roadmap = await readFile(roadmapPath, 'utf8')
1153
+ for (const r of REQUIREMENTS) expect(roadmap).toContain(`- [ ] ${r.id} `)
1154
+
1155
+ expect((await migrate(['phase', 'handoff', '--status', 'done'])).code).toBe(0)
1156
+
1157
+ // 17. The milestone's acceptance proof. Plain `migrate check`, with no
1158
+ // --phase, gating every phase through handoff, exits 0. This is the first
1159
+ // time in the project's history that the unbounded gate can pass at all.
1160
+ const complete = await migrate(['check'])
1161
+ expect(complete.out).not.toContain('Violations')
1162
+ expect(complete.code).toBe(0)
1163
+
1164
+ // 18. Coverage, read back through the adapter that emitted the work. Two
1165
+ // boxes ticked by hand, one dated and one not, which is what an owner
1166
+ // actually does to a roadmap.
1167
+ const built = ['UD-001', 'UD-002']
1168
+ let ticked = roadmap
1169
+ ticked = ticked.replace(`- [ ] ${built[0]} `, `- [x] ${built[0]} <!-- done:2026-08-10 --> `)
1170
+ ticked = ticked.replace(`- [ ] ${built[1]} `, `- [x] ${built[1]} <!-- done:2026-08-12 --> `)
1171
+ await writeFile(roadmapPath, ticked)
1172
+
1173
+ const coverage = await migrate(['coverage'])
1174
+ expect(coverage.code).toBe(0)
1175
+ expect(coverage.out).toContain('evidence: markdown roadmap checkboxes, dated in file')
1176
+ // The fixture carries two non-confirmed requirements, UD-006 (`queued`) and
1177
+ // one inferred. Both sit outside the confirmed denominator and are reported
1178
+ // as exclusions rather than counted against delivery: parity is a promise
1179
+ // about behaviour the run confirmed.
1180
+ expect(coverage.out).toContain(
1181
+ 'excluded: 2 non-confirmed (user-directory 1, welcome-notification 1)',
1182
+ )
1183
+ expect(coverage.out).toMatch(/built 2\/\d+ confirmed requirements/)
1184
+
1185
+ // Re-running handoff after the boxes were ticked must not erase them. This
1186
+ // is the data-loss regression the markdown adapter exists to avoid, checked
1187
+ // here against a real store rather than a unit fixture.
1188
+ expect((await migrate(['handoff'])).code).toBe(0)
1189
+ const afterRerun = await readFile(roadmapPath, 'utf8')
1190
+ expect(afterRerun).toContain(`- [x] ${built[0]} <!-- done:2026-08-10 --> `)
1191
+ expect(afterRerun).toContain(`- [x] ${built[1]} <!-- done:2026-08-12 --> `)
1192
+
1193
+ // 19. Forecast. It refuses before the owner has attested anything, which is
1194
+ // the difference between a projection and a guess.
1195
+ const unattested = await migrate(['forecast'])
1196
+ expect(unattested.code).toBe(1)
1197
+ expect(unattested.err).toContain('forecast-assumptions.md')
1198
+
1199
+ await writeFile(
1200
+ join(storeDir, 'forecast-assumptions.md'),
1201
+ [
1202
+ '---',
1203
+ 'attestedBy: e2e',
1204
+ 'attestedDate: 2026-08-13',
1205
+ '---',
1206
+ '',
1207
+ '## Territories',
1208
+ '',
1209
+ '| capability | territory |',
1210
+ '| --- | --- |',
1211
+ ...CAPABILITIES.map((c) => `| ${c.slug} | established |`),
1212
+ '',
1213
+ '## Multipliers',
1214
+ '',
1215
+ '| territory | multiplier |',
1216
+ '| --- | --- |',
1217
+ '| established | 1.0 |',
1218
+ '',
1219
+ '## Scenarios',
1220
+ '',
1221
+ '| label | rate | streams | tax | note |',
1222
+ '| --- | --- | --- | --- | --- |',
1223
+ '| steady | as-is | 1 | 0 | measured |',
1224
+ '| target | 2 | 1 | 0 | owner target |',
1225
+ '',
1226
+ '## Caveats',
1227
+ '',
1228
+ '- The fixture is not a real campaign.',
1229
+ '',
1230
+ ].join('\n'),
1231
+ )
1232
+
1233
+ const forecast = await migrate(['forecast'])
1234
+ expect(forecast.code).toBe(0)
1235
+ expect(forecast.out).toContain('attested by e2e on 2026-08-13')
1236
+ // Two dated completions is exactly the minimum, so the measured rows
1237
+ // project; the target row is labelled as owner-attested either way.
1238
+ expect(forecast.out).toContain('target (owner-attested, nothing measures this)')
1239
+ expect(forecast.out).toContain('The fixture is not a real campaign.')
1240
+
1241
+ // 20. The acceptance proof is load-bearing, shown by mutation: reopening one
1242
+ // queue item must break the plain check that step 17 asserted, and only on
1243
+ // the adjudication gate.
1244
+ const reopenPath = join(storeDir, 'queue', `${QUEUE_ID}.md`)
1245
+ const ruledText = await readFile(reopenPath, 'utf8')
1246
+ await writeFile(reopenPath, ruledText.replace('status: adjudicated', 'status: open'))
1247
+ const reopened = await migrate(['check'])
1248
+ expect(reopened.code).toBe(1)
1249
+ expect(reopened.out).toContain(' adjudication:')
1250
+ expect(reopened.out).toContain(QUEUE_ID)
1251
+ await writeFile(reopenPath, ruledText)
1252
+ expect((await migrate(['check'])).code).toBe(0)
1093
1253
  })
@@ -1208,12 +1208,13 @@ test('aspnet recipe run driven probe through queue, ending green at check --phas
1208
1208
  expect(green.out).not.toContain('Violations')
1209
1209
  expect(green.code).toBe(0)
1210
1210
 
1211
- // 13. Plain `migrate check` gates every phase through handoff, and fails on
1212
- // exactly the two that have no verb in this milestone. The violation count
1213
- // is what makes "exactly" an assertion rather than a hope.
1211
+ // 13. Plain `migrate check` gates every phase through handoff. A run that
1212
+ // stops at the queue fails it on three distinct fronts, and naming each one
1213
+ // is what makes this an assertion rather than a hope: the two phases still
1214
+ // pending, every queue item nobody has ruled on, and the handoff that never
1215
+ // emitted anything.
1214
1216
  const full = await migrate(['check'])
1215
1217
  expect(full.code).toBe(1)
1216
- expect(full.out).toContain('Violations (2):')
1217
1218
  expect(full.out).toContain(' run-state:')
1218
1219
  expect(full.out).toContain(
1219
1220
  ' phase adjudicate is pending; every phase through handoff must be done',
@@ -1221,6 +1222,10 @@ test('aspnet recipe run driven probe through queue, ending green at check --phas
1221
1222
  expect(full.out).toContain(
1222
1223
  ' phase handoff is pending; every phase through handoff must be done',
1223
1224
  )
1225
+ expect(full.out).toContain(' adjudication:')
1226
+ expect(full.out).toContain('is still open; every queue item needs a ruling before handoff')
1227
+ expect(full.out).toContain(' handoff:')
1228
+ expect(full.out).toContain('no handoff.json in the store')
1224
1229
 
1225
1230
  // 14. The terminus assertion in step 12 is load-bearing, shown by mutation
1226
1231
  // rather than asserted: nulling every requirement's parity plan is a phase-4
@@ -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
+ })