@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.
- package/README.md +2 -2
- package/dist/cli/index.js +14 -10
- package/package.json +1 -1
- package/skills/index.json +4 -4
- 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
- package/skills/sluice/SKILL.md +20 -7
- package/skills/sluice/references/deep-channel.md +20 -0
- package/skills/sluice/references/finish.md +4 -2
- package/skills/sluice/references/meter.md +38 -0
- package/skills/sluice/scripts/run-stats.sh +236 -0
- 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
|
+
}
|