@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/index.json +2 -2
- package/skills/migrate/README.md +35 -23
- package/skills/migrate/SKILL.md +75 -15
- package/skills/migrate/bin/migrate.ts +90 -0
- package/skills/migrate/docs/architecture.md +61 -26
- package/skills/migrate/docs/reference.md +53 -8
- package/skills/migrate/fixtures/fake-gh.ts +113 -0
- package/skills/migrate/fixtures/flow-target/docs/WORK.md +12 -0
- package/skills/migrate/fixtures/flow-target/docs/modernisation/capability-map/.gitkeep +0 -0
- package/skills/migrate/fixtures/flow-target/tools/flow/src/cli.ts +156 -0
- package/skills/migrate/package.json +1 -1
- package/skills/migrate/references/phases/adjudicate.md +161 -0
- package/skills/migrate/references/phases/handoff.md +220 -0
- package/skills/migrate/references/phases/probe.md +2 -2
- package/skills/migrate/references/phases/queue.md +21 -14
- package/skills/migrate/references/run-ops.md +17 -13
- package/skills/migrate/scripts/__tests__/adapter-flow.test.ts +290 -0
- package/skills/migrate/scripts/__tests__/adapter-github.test.ts +232 -0
- package/skills/migrate/scripts/__tests__/adapter-markdown.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/adjudicate.test.ts +332 -0
- package/skills/migrate/scripts/__tests__/assumptions.test.ts +179 -0
- package/skills/migrate/scripts/__tests__/coverage.test.ts +192 -0
- package/skills/migrate/scripts/__tests__/e2e-express.test.ts +167 -7
- package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +9 -4
- package/skills/migrate/scripts/__tests__/forecast.test.ts +280 -0
- package/skills/migrate/scripts/__tests__/gates-handoff.test.ts +309 -0
- package/skills/migrate/scripts/__tests__/handoff-cmd.test.ts +308 -0
- package/skills/migrate/scripts/__tests__/handoff-order.test.ts +156 -0
- package/skills/migrate/scripts/adapters/flow.ts +280 -0
- package/skills/migrate/scripts/adapters/github.ts +260 -0
- package/skills/migrate/scripts/adapters/markdown.ts +175 -0
- package/skills/migrate/scripts/adjudicate-cmd.ts +243 -0
- package/skills/migrate/scripts/assumptions.ts +188 -0
- package/skills/migrate/scripts/check.ts +119 -320
- package/skills/migrate/scripts/coverage-cmd.ts +86 -0
- package/skills/migrate/scripts/coverage.ts +151 -0
- package/skills/migrate/scripts/dates.ts +17 -0
- package/skills/migrate/scripts/forecast-cmd.ts +124 -0
- package/skills/migrate/scripts/forecast.ts +264 -0
- package/skills/migrate/scripts/gates/adjudication.ts +30 -0
- package/skills/migrate/scripts/gates/census.ts +107 -0
- package/skills/migrate/scripts/gates/citations.ts +11 -0
- package/skills/migrate/scripts/gates/context.ts +76 -0
- package/skills/migrate/scripts/gates/coverage.ts +22 -0
- package/skills/migrate/scripts/gates/deltas.ts +15 -0
- package/skills/migrate/scripts/gates/handoff.ts +145 -0
- package/skills/migrate/scripts/gates/leaks.ts +11 -0
- package/skills/migrate/scripts/gates/parity.ts +15 -0
- package/skills/migrate/scripts/gates/queue.ts +9 -0
- package/skills/migrate/scripts/gates/refs.ts +97 -0
- package/skills/migrate/scripts/gates/run-state.ts +67 -0
- package/skills/migrate/scripts/gates/source.ts +28 -0
- package/skills/migrate/scripts/handoff-cmd.ts +186 -0
- package/skills/migrate/scripts/handoff.ts +330 -0
- package/skills/migrate/scripts/paths.ts +4 -0
- package/skills/migrate/scripts/types.ts +43 -0
- package/skills/migrate/scripts/validate.ts +12 -0
- package/skills/migrate/skill.json +2 -2
- 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
|
|
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
|
|
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
|
|
1029
|
-
//
|
|
1030
|
-
// is what makes
|
|
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
|
|
1212
|
-
//
|
|
1213
|
-
// is what makes
|
|
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
|
+
})
|