@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,151 @@
1
+ import { isCalendarDate } from './dates.ts'
2
+ import type { HandoffFile } from './handoff.ts'
3
+ import type { Requirement, Throughput } from './types.ts'
4
+
5
+ export type CapCoverage = {
6
+ slug: string
7
+ title: string
8
+ confirmedTotal: number
9
+ covered: number
10
+ coveredIds: string[]
11
+ uncoveredIds: string[]
12
+ }
13
+
14
+ export type CoverageReport = {
15
+ caps: CapCoverage[]
16
+ built: number
17
+ confirmed: number
18
+ // Completions that were counted as built but carry no date. They contribute
19
+ // to coverage and contribute nothing to forecast's measured rate, so the
20
+ // count is printed rather than folded away.
21
+ undated: number
22
+ // Completions naming a requirement the store does not have. Not a
23
+ // degradation: it means the emitted work and the store have diverged, so the
24
+ // caller treats it as a failure.
25
+ unknown: string[]
26
+ nonConfirmed: { slug: string; count: number }[]
27
+ // Capabilities the store has requirements in that the emitted order does not
28
+ // name: the handoff predates them, so the emitted work is out of date.
29
+ stale: string[]
30
+ basis: string
31
+ }
32
+
33
+ // The denominator is confirmed requirements only.
34
+ //
35
+ // This is the method's position rather than a choice made here: flow's own
36
+ // computeParity divides by `conf === "Confirmed"`, and reports the excluded
37
+ // count separately. Parity is a promise about behaviour the run confirmed, and
38
+ // holding a build team to an inferred requirement would assert a confidence
39
+ // the extract phase explicitly declined to claim. Handoff still emits every
40
+ // requirement, so nothing is hidden; it is only the denominator that narrows,
41
+ // and the exclusions are printed underneath.
42
+ export function computeCoverage(input: {
43
+ requirements: Requirement[]
44
+ handoff: HandoffFile
45
+ throughput: Throughput
46
+ }): CoverageReport {
47
+ const { requirements, handoff, throughput } = input
48
+ const known = new Set(requirements.map((r) => r.id))
49
+ const byId = new Map(requirements.map((r) => [r.id, r]))
50
+
51
+ const unknown: string[] = []
52
+ const builtIds = new Set<string>()
53
+ let undated = 0
54
+ for (const c of throughput.completions) {
55
+ if (!known.has(c.fr)) {
56
+ unknown.push(c.fr)
57
+ continue
58
+ }
59
+ builtIds.add(c.fr)
60
+ // A date that is not a real calendar day cannot date anything, so it is
61
+ // reported the same way a missing one is rather than counted as dated.
62
+ if (c.doneAt === null || !isCalendarDate(c.doneAt)) undated++
63
+ }
64
+
65
+ const titleOf = new Map(handoff.items.map((i) => [i.key, i.title]))
66
+ const caps: CapCoverage[] = []
67
+ const nonConfirmed: { slug: string; count: number }[] = []
68
+
69
+ // Every capability the STORE has requirements in, not only those the emitted
70
+ // order happens to name. Walking `basis.order` alone let a stale handoff.json
71
+ // narrow both numerator and denominator silently: a store with three
72
+ // confirmed requirements in a capability absent from `order` reported
73
+ // "built 7/7 confirmed requirements (100%)" while three were unbuilt and the
74
+ // capability appeared nowhere in the output. The order still decides the
75
+ // display sequence; anything it omits is appended and named as stale.
76
+ const inStore = [...new Set(requirements.map((r) => r.cap))]
77
+ const stale = inStore.filter((slug) => !handoff.basis.order.includes(slug)).sort()
78
+ for (const slug of [...handoff.basis.order, ...stale]) {
79
+ const own = requirements.filter((r) => r.cap === slug)
80
+ const confirmed = own.filter((r) => r.confidence.kind === 'confirmed')
81
+ const coveredIds = confirmed.filter((r) => builtIds.has(r.id)).map((r) => r.id)
82
+ const uncoveredIds = confirmed.filter((r) => !builtIds.has(r.id)).map((r) => r.id)
83
+ caps.push({
84
+ slug,
85
+ title: titleOf.get(slug) ?? slug,
86
+ confirmedTotal: confirmed.length,
87
+ covered: coveredIds.length,
88
+ coveredIds,
89
+ uncoveredIds,
90
+ })
91
+ const excluded = own.length - confirmed.length
92
+ if (excluded > 0) nonConfirmed.push({ slug, count: excluded })
93
+ }
94
+
95
+ // Counted off the per-capability rows rather than off builtIds, so a
96
+ // completion for a non-confirmed requirement cannot inflate the figure that
97
+ // sits above a breakdown which excludes it.
98
+ const built = caps.reduce((n, c) => n + c.covered, 0)
99
+ const confirmed = caps.reduce((n, c) => n + c.confirmedTotal, 0)
100
+
101
+ // A requirement whose capability never reached a work item is invisible to
102
+ // the loop above, which walks the emitted order. Gate 12 names it, so this
103
+ // does not report it again; it only avoids counting it.
104
+ void byId
105
+
106
+ return { caps, built, confirmed, undated, unknown, stale, nonConfirmed, basis: throughput.basis }
107
+ }
108
+
109
+ function pad(text: string, width: number): string {
110
+ return text.length >= width ? text : text + ' '.repeat(width - text.length)
111
+ }
112
+
113
+ export function renderCoverage(r: CoverageReport): string {
114
+ // Never round to a number the fraction beside it contradicts. Math.round
115
+ // printed 199/200 as 100% and 1/250 as 0%, and the percentage is the figure
116
+ // that gets quoted onward while the fraction stays behind.
117
+ const pct =
118
+ r.confirmed === 0
119
+ ? 0
120
+ : r.built === r.confirmed
121
+ ? 100
122
+ : r.built === 0
123
+ ? 0
124
+ : Math.min(99, Math.max(1, Math.round((r.built / r.confirmed) * 100)))
125
+ const lines = [
126
+ `built ${r.built}/${r.confirmed} confirmed requirements (${pct}%)`,
127
+ `evidence: ${r.basis}`,
128
+ ]
129
+ if (r.undated > 0) {
130
+ lines.push(
131
+ `undated: ${r.undated} completion(s) carry no date; they count as built and contribute nothing to forecast's measured rate`,
132
+ )
133
+ }
134
+ if (r.nonConfirmed.length > 0) {
135
+ const total = r.nonConfirmed.reduce((n, e) => n + e.count, 0)
136
+ const detail = r.nonConfirmed.map((e) => `${e.slug} ${e.count}`).join(', ')
137
+ lines.push(`excluded: ${total} non-confirmed (${detail})`)
138
+ }
139
+ if (r.stale.length > 0) {
140
+ lines.push(
141
+ `stale: ${r.stale.length} capability(ies) not in the emitted work (${r.stale.join(', ')}); re-run migrate handoff`,
142
+ )
143
+ }
144
+ lines.push('')
145
+ const width = Math.max(4, ...r.caps.map((c) => c.slug.length))
146
+ for (const c of r.caps) {
147
+ const done = c.confirmedTotal > 0 && c.covered === c.confirmedTotal ? ' done' : ''
148
+ lines.push(`${pad(c.slug, width)} ${c.covered}/${c.confirmedTotal}${done}`)
149
+ }
150
+ return lines.join('\n')
151
+ }
@@ -0,0 +1,17 @@
1
+ // A completion date arrives from an adapter's medium: a hand-written date on a
2
+ // roadmap line, a `closedAt` from an API. Nothing upstream guarantees it names
3
+ // a real day, and `Date.parse` answers NaN rather than throwing, so an
4
+ // unvalidated date propagates silently into arithmetic and comes back out as
5
+ // a rate of NaN printed where a number belongs.
6
+ //
7
+ // Checked against the round-trip rather than by a regex on the parts, because
8
+ // the shapes that matter are the ones a regex accepts and a calendar does not:
9
+ // 2026-08-32 is well-formed and does not exist, and 2026-02-31 quietly rolls
10
+ // forward to 3 March, which would stretch an era by a month with nothing
11
+ // visibly wrong.
12
+ export function isCalendarDate(value: string): boolean {
13
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
14
+ const parsed = new Date(`${value}T00:00:00Z`)
15
+ if (Number.isNaN(parsed.getTime())) return false
16
+ return parsed.toISOString().slice(0, 10) === value
17
+ }
@@ -0,0 +1,124 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { flow } from './adapters/flow.ts'
3
+ import { github } from './adapters/github.ts'
4
+ import { markdown } from './adapters/markdown.ts'
5
+ import { parseAssumptions, validateAssumptions } from './assumptions.ts'
6
+ import { loadConfig } from './config.ts'
7
+ import { computeCoverage } from './coverage.ts'
8
+ import { demandOf, project, renderForecast, velocities } from './forecast.ts'
9
+ import { type Adapter, type HandoffInput, loadHandoff } from './handoff.ts'
10
+ import { storePaths } from './paths.ts'
11
+ import { readRows, readTextFile } from './store.ts'
12
+ import type { Capability, Delta, Requirement } from './types.ts'
13
+
14
+ const ADAPTERS: Record<string, Adapter> = { markdown, github, flow }
15
+
16
+ function today(): string {
17
+ return new Date().toISOString().slice(0, 10)
18
+ }
19
+
20
+ export async function runForecast(opts: {
21
+ root: string
22
+ adapter?: string
23
+ gitBin?: string
24
+ ghBin?: string
25
+ now?: () => string
26
+ }): Promise<number> {
27
+ const cfg = await loadConfig(opts.root)
28
+ const paths = storePaths(opts.root)
29
+
30
+ const loaded = await loadHandoff(opts.root)
31
+ if (loaded.kind === 'invalid') {
32
+ for (const e of loaded.errors) process.stderr.write(`forecast: handoff.json ${e}\n`)
33
+ return 1
34
+ }
35
+ const handoff = loaded.kind === 'ok' ? loaded.value : null
36
+ if (!handoff) {
37
+ process.stderr.write(
38
+ 'forecast: no handoff.json in the store; run `migrate handoff` before projecting anything\n',
39
+ )
40
+ return 1
41
+ }
42
+
43
+ if (!existsSync(paths.forecastAssumptions)) {
44
+ // Required, not optional. A projection nobody signed is exactly the
45
+ // asserted number this method exists to refuse, so the file's absence is a
46
+ // refusal rather than a set of defaults.
47
+ process.stderr.write(
48
+ `forecast: no ${paths.forecastAssumptions}; copy templates/forecast-assumptions.md there and attest it\n`,
49
+ )
50
+ return 1
51
+ }
52
+
53
+ let assumptions: ReturnType<typeof parseAssumptions>
54
+ try {
55
+ assumptions = parseAssumptions(
56
+ await readTextFile(paths.forecastAssumptions),
57
+ paths.forecastAssumptions,
58
+ )
59
+ } catch (e) {
60
+ process.stderr.write(`forecast: ${(e as Error).message}\n`)
61
+ return 1
62
+ }
63
+
64
+ const name = opts.adapter ?? cfg.handoff.adapter
65
+ const adapter = ADAPTERS[name]
66
+ if (!adapter) {
67
+ process.stderr.write(
68
+ `forecast: unknown adapter ${name}; want one of ${Object.keys(ADAPTERS).sort().join(', ')}\n`,
69
+ )
70
+ return 2
71
+ }
72
+ if (!adapter.throughput) {
73
+ process.stderr.write(
74
+ `forecast: adapter ${name} reports no throughput, so there is nothing measured to project from\n`,
75
+ )
76
+ return 1
77
+ }
78
+
79
+ const input: HandoffInput = {
80
+ requirements: await readRows<Requirement>(paths.requirements),
81
+ capabilities: await readRows<Capability>(paths.capabilities),
82
+ deltas: await readRows<Delta>(paths.deltas),
83
+ config: cfg,
84
+ root: opts.root,
85
+ gitBin: opts.gitBin ?? 'git',
86
+ ghBin: opts.ghBin ?? 'gh',
87
+ }
88
+
89
+ let throughput: Awaited<ReturnType<NonNullable<Adapter['throughput']>>>
90
+ try {
91
+ throughput = await adapter.throughput(input)
92
+ } catch (e) {
93
+ process.stderr.write(`forecast: ${(e as Error).message}\n`)
94
+ return 1
95
+ }
96
+
97
+ const coverage = computeCoverage({ requirements: input.requirements, handoff, throughput })
98
+
99
+ // Validated against measured coverage rather than against itself: the file
100
+ // has to account for the capabilities the store actually has confirmed
101
+ // requirements in, not for whatever it happened to list.
102
+ const errors = validateAssumptions(assumptions, coverage.caps)
103
+ if (errors.length > 0) {
104
+ for (const e of errors) process.stderr.write(`forecast: ${e}\n`)
105
+ return 1
106
+ }
107
+
108
+ const now = (opts.now ?? today)()
109
+ const demand = demandOf(assumptions, coverage.caps)
110
+ const velocity = velocities(throughput.completions, now)
111
+ const projections = project({ assumptions, demand, velocity, today: now })
112
+
113
+ process.stdout.write(
114
+ `${renderForecast({
115
+ assumptions,
116
+ demand,
117
+ velocity,
118
+ projections,
119
+ today: now,
120
+ undated: coverage.undated,
121
+ })}\n`,
122
+ )
123
+ return 0
124
+ }
@@ -0,0 +1,264 @@
1
+ import type { Assumptions } from './assumptions.ts'
2
+ import type { CapCoverage } from './coverage.ts'
3
+ import { isCalendarDate } from './dates.ts'
4
+ import type { Completion, Rate } from './types.ts'
5
+
6
+ const DAY_MS = 86_400_000
7
+
8
+ export function addDays(date: string, days: number): string {
9
+ return new Date(Date.parse(`${date}T00:00:00Z`) + days * DAY_MS).toISOString().slice(0, 10)
10
+ }
11
+
12
+ export type TerritoryDemand = {
13
+ territory: string
14
+ capabilities: string[]
15
+ remainingRaw: number
16
+ multiplier: number
17
+ frEquivalents: number
18
+ }
19
+
20
+ export type Demand = {
21
+ territories: TerritoryDemand[]
22
+ remainingRaw: number
23
+ remainingWeighted: number
24
+ }
25
+
26
+ // Demand is reported both ways on purpose. `raw` is a count anyone can check
27
+ // against the store; `weighted` is that count through the owner's attested
28
+ // multipliers. Printing only the weighted figure would bury a judgment inside
29
+ // something that looks like a measurement.
30
+ //
31
+ // Territories come out in Multipliers-table order, which is the order the
32
+ // milestone sequence walks: the owner decides what gets finished first by
33
+ // deciding how to write that table.
34
+ export function demandOf(assumptions: Assumptions, coverage: CapCoverage[]): Demand {
35
+ const territories: TerritoryDemand[] = Object.entries(assumptions.multipliers).map(
36
+ ([territory, multiplier]) => {
37
+ const caps = coverage.filter((c) => assumptions.territories[c.slug] === territory)
38
+ const remainingRaw = caps.reduce((n, c) => n + (c.confirmedTotal - c.covered), 0)
39
+ return {
40
+ territory,
41
+ capabilities: caps.map((c) => c.slug),
42
+ remainingRaw,
43
+ multiplier,
44
+ frEquivalents: remainingRaw * multiplier,
45
+ }
46
+ },
47
+ )
48
+ return {
49
+ territories,
50
+ remainingRaw: territories.reduce((n, t) => n + t.remainingRaw, 0),
51
+ remainingWeighted: territories.reduce((n, t) => n + t.frEquivalents, 0),
52
+ }
53
+ }
54
+
55
+ const UNMEASURED = 'not enough dated completions to measure a rate; one point is not a rate'
56
+
57
+ // Two measured velocities from the same completions, and the difference
58
+ // between them is the honest uncertainty band. `as-is` divides by every
59
+ // calendar day since the first completion, quiet days included, and is the
60
+ // pessimistic base. `active` divides only by the days something completed, and
61
+ // is the optimistic one.
62
+ //
63
+ // The era runs from the earliest completion rather than from an attested
64
+ // baseline, so both stay measured and the assumptions file holds judgment
65
+ // only. Undated completions contribute to neither: they are counted as built
66
+ // by coverage and say nothing about pace.
67
+ export function velocities(completions: Completion[], today: string): { asIs: Rate; active: Rate } {
68
+ // A date that is not a real calendar day is dropped rather than parsed. Left
69
+ // in, `Date.parse` returns NaN, `Math.max(1, NaN)` is NaN, and because
70
+ // `NaN !== null` the whole null-propagation contract disengages and a rate
71
+ // of NaN gets printed where a number belongs.
72
+ const dates = completions
73
+ .map((c) => c.doneAt)
74
+ .filter((d): d is string => d !== null && isCalendarDate(d))
75
+ .sort()
76
+ if (dates.length < 2) {
77
+ return { asIs: { value: null, basis: UNMEASURED }, active: { value: null, basis: UNMEASURED } }
78
+ }
79
+ const first = dates[0] as string
80
+ const last = dates[dates.length - 1] as string
81
+ // The era is counted INCLUSIVELY, and runs to the later of today and the
82
+ // last completion. Both halves matter, and getting either wrong inverts the
83
+ // uncertainty band rather than merely shifting it.
84
+ //
85
+ // Inclusive because `activeDays` counts distinct dates, which is inclusive by
86
+ // construction: ten completions on ten consecutive days ending today gave an
87
+ // exclusive era of nine, so `asIs` (10/9) came out FASTER than `active`
88
+ // (10/10). asIs is the pessimistic floor and active the optimistic ceiling,
89
+ // so that made the band print its optimistic bound later than its
90
+ // pessimistic one. Counted inclusively, activeDays <= eraDays always holds,
91
+ // because every active day falls inside the era.
92
+ //
93
+ // Running to `max(today, last)` because a completion dated ahead of today is
94
+ // otherwise clamped to a one-day era, which turns a typo or a timezone edge
95
+ // into an enormous measured velocity. Extending the era is the honest
96
+ // reading: the run demonstrably spans that date.
97
+ const end = last > today ? last : today
98
+ const eraDays = (Date.parse(`${end}T00:00:00Z`) - Date.parse(`${first}T00:00:00Z`)) / DAY_MS + 1
99
+ const activeDays = new Set(dates).size
100
+ return {
101
+ asIs: {
102
+ value: dates.length / eraDays,
103
+ basis: `${dates.length} dated completion(s) over ${eraDays} calendar day(s) since ${first}, quiet days included`,
104
+ },
105
+ active: {
106
+ value: dates.length / activeDays,
107
+ basis: `${dates.length} dated completion(s) over ${activeDays} day(s) something completed`,
108
+ },
109
+ }
110
+ }
111
+
112
+ export type Projection = {
113
+ label: string
114
+ // The epistemic family. `as-is` and `active` extrapolate something measured;
115
+ // `target` scales a rate the owner attested and nothing backs. Views keep
116
+ // the two apart so an aspiration never reads as a fact.
117
+ basis: 'as-is' | 'active' | 'target'
118
+ streams: number
119
+ tax: number
120
+ note: string
121
+ ratePerStream: number | null
122
+ perDay: number | null
123
+ daysRaw: number | null
124
+ finishRaw: string | null
125
+ daysWeighted: number | null
126
+ finishWeighted: string | null
127
+ band: { optimistic: string | null; pessimistic: string | null } | null
128
+ milestones: { territory: string; finish: string | null }[]
129
+ }
130
+
131
+ // Null propagates. Every figure whose input is unmeasured comes out null and
132
+ // renders as omitted, rather than as a zero or a guess. That is what makes the
133
+ // no-dates case degrade cleanly: the flow adapter supplies coverage without
134
+ // dates, so measured rows project nothing and say why, while target rows still
135
+ // project because they never needed a measurement.
136
+ const daysFor = (remaining: number, rate: number | null): number | null =>
137
+ rate !== null && rate > 0 && remaining > 0 ? Math.ceil(remaining / rate) : null
138
+
139
+ export function project(input: {
140
+ assumptions: Assumptions
141
+ demand: Demand
142
+ velocity: { asIs: Rate; active: Rate }
143
+ today: string
144
+ }): Projection[] {
145
+ const { assumptions, demand, velocity, today } = input
146
+ const finishFor = (remaining: number, rate: number | null): string | null => {
147
+ const d = daysFor(remaining, rate)
148
+ return d === null ? null : addDays(today, d)
149
+ }
150
+
151
+ return assumptions.scenarios.map((s) => {
152
+ const scaled = (base: number | null): number | null =>
153
+ base === null ? null : base * s.streams * (1 - s.tax)
154
+ const basis =
155
+ typeof s.rate === 'number'
156
+ ? ('target' as const)
157
+ : s.rate === 'as-is'
158
+ ? ('as-is' as const)
159
+ : ('active' as const)
160
+ const ratePerStream =
161
+ typeof s.rate === 'number'
162
+ ? s.rate
163
+ : s.rate === 'as-is'
164
+ ? velocity.asIs.value
165
+ : velocity.active.value
166
+ const perDay = scaled(ratePerStream)
167
+
168
+ const milestones: { territory: string; finish: string | null }[] = []
169
+ let cumulative = 0
170
+ for (const t of demand.territories) {
171
+ if (t.remainingRaw <= 0) continue
172
+ cumulative += t.frEquivalents
173
+ milestones.push({ territory: t.territory, finish: finishFor(cumulative, perDay) })
174
+ }
175
+
176
+ return {
177
+ label: s.label,
178
+ basis,
179
+ streams: s.streams,
180
+ tax: s.tax,
181
+ note: s.note,
182
+ ratePerStream,
183
+ perDay,
184
+ daysRaw: daysFor(demand.remainingRaw, perDay),
185
+ finishRaw: finishFor(demand.remainingRaw, perDay),
186
+ daysWeighted: daysFor(demand.remainingWeighted, perDay),
187
+ finishWeighted: finishFor(demand.remainingWeighted, perDay),
188
+ // A target row has no measured spread behind it, so offering a band
189
+ // would dress an attested number as an observed range.
190
+ band:
191
+ typeof s.rate === 'number'
192
+ ? null
193
+ : {
194
+ optimistic: finishFor(demand.remainingWeighted, scaled(velocity.active.value)),
195
+ pessimistic: finishFor(demand.remainingWeighted, scaled(velocity.asIs.value)),
196
+ },
197
+ milestones,
198
+ }
199
+ })
200
+ }
201
+
202
+ export function renderForecast(input: {
203
+ assumptions: Assumptions
204
+ demand: Demand
205
+ velocity: { asIs: Rate; active: Rate }
206
+ projections: Projection[]
207
+ today: string
208
+ undated: number
209
+ }): string {
210
+ const { assumptions, demand, velocity, projections, today, undated } = input
211
+ const lines = [
212
+ `forecast from ${today}, attested by ${assumptions.attestedBy} on ${assumptions.attestedDate}`,
213
+ `remaining ${demand.remainingRaw} requirement(s), ${demand.remainingWeighted} weighted by attested multipliers`,
214
+ '',
215
+ 'measured velocity',
216
+ ` as-is ${velocity.asIs.value === null ? 'unmeasured' : `${velocity.asIs.value.toFixed(2)}/day`} (${velocity.asIs.basis})`,
217
+ ` active ${velocity.active.value === null ? 'unmeasured' : `${velocity.active.value.toFixed(2)}/day`} (${velocity.active.basis})`,
218
+ ]
219
+ if (undated > 0) {
220
+ lines.push(` ${undated} completion(s) carry no date and contribute to neither rate`)
221
+ }
222
+ lines.push('')
223
+
224
+ for (const p of projections) {
225
+ const kind =
226
+ p.basis === 'target'
227
+ ? 'target (owner-attested, nothing measures this)'
228
+ : `measured (${p.basis})`
229
+ lines.push(`${p.label}: ${kind}`)
230
+ lines.push(` ${p.streams} stream(s), tax ${p.tax}, ${p.note}`)
231
+ if (demand.remainingWeighted === 0) {
232
+ // Distinguished from the unmeasured case below on purpose. Both leave
233
+ // every date null, but "there is nothing left to build" and "there is no
234
+ // rate to build at" are opposite pieces of news, and printing the same
235
+ // "omitted" for each would hide which one the reader is looking at.
236
+ lines.push(' nothing remaining: every confirmed requirement is already built')
237
+ } else if (p.perDay === null) {
238
+ lines.push(' not projected: no measured rate to extrapolate from')
239
+ } else {
240
+ lines.push(` ${p.perDay.toFixed(2)} requirement(s)/day`)
241
+ lines.push(
242
+ ` raw ${p.finishRaw === null ? 'omitted' : `${p.finishRaw} (${p.daysRaw} day(s))`}`,
243
+ )
244
+ lines.push(
245
+ ` weighted ${p.finishWeighted === null ? 'omitted' : `${p.finishWeighted} (${p.daysWeighted} day(s))`}`,
246
+ )
247
+ if (p.band) {
248
+ lines.push(
249
+ ` band ${p.band.optimistic ?? 'omitted'} to ${p.band.pessimistic ?? 'omitted'}`,
250
+ )
251
+ }
252
+ for (const m of p.milestones) {
253
+ lines.push(` ${m.territory} done by ${m.finish ?? 'omitted'}`)
254
+ }
255
+ }
256
+ lines.push('')
257
+ }
258
+
259
+ if (assumptions.caveats.length > 0) {
260
+ lines.push('caveats')
261
+ for (const c of assumptions.caveats) lines.push(` - ${c}`)
262
+ }
263
+ return lines.join('\n')
264
+ }
@@ -0,0 +1,30 @@
1
+ import type { Violation } from '../types.ts'
2
+ import type { Gate } from './context.ts'
3
+
4
+ // Gate 11: adjudication. Every queue item carries an owner's ruling.
5
+ //
6
+ // The queue gate already refuses an `adjudicated` item with no ruling, and
7
+ // this gate checks the same thing independently rather than leaning on it. A
8
+ // gate whose soundness depends on another gate having run first is not
9
+ // independently a gate, and the two are asking different questions: the queue
10
+ // gate asks whether the file is well formed, this one asks whether the
11
+ // decision was made.
12
+ export const gate: Gate = (ctx): Violation[] => {
13
+ const violations: Violation[] = []
14
+ for (const item of ctx.queueItems) {
15
+ if (item.status !== 'adjudicated') {
16
+ violations.push({
17
+ gate: 'adjudication',
18
+ message: `${item.id} [${item.severity}] is still ${item.status}; every queue item needs a ruling before handoff`,
19
+ })
20
+ continue
21
+ }
22
+ if (!item.ruling || item.ruling.trim().length === 0) {
23
+ violations.push({
24
+ gate: 'adjudication',
25
+ message: `${item.id} [${item.severity}] is adjudicated with no ruling recorded`,
26
+ })
27
+ }
28
+ }
29
+ return violations
30
+ }