@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,175 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import type { Adapter, HandoffInput } from '../handoff.ts'
4
+ import { buildWorkItems } from '../handoff.ts'
5
+ import { readTextFile, writeAtomically } from '../store.ts'
6
+ import type { ApplyResult, Completion, Requirement, Throughput, WorkItem } from '../types.ts'
7
+
8
+ const OUT_DIR = join('docs', 'migrate')
9
+ const CAP_DIR = join(OUT_DIR, 'capabilities')
10
+ const ROADMAP = join(OUT_DIR, 'roadmap.md')
11
+
12
+ // The roadmap's checkbox line is both what this adapter writes and what it
13
+ // reads back, so the grammar is stated once and used from both directions.
14
+ //
15
+ // The date sits in an HTML comment rather than in parentheses after the id.
16
+ // Parenthesised, it was indistinguishable from requirement text that happens
17
+ // to open with one: a requirement reading "(2026-08-12) Raise an invoice"
18
+ // parsed as a dated completion and fed a fabricated date straight into the
19
+ // measured velocity, against the roadmap header's own promise that an undated
20
+ // tick contributes nothing to the rate. A comment cannot be produced by
21
+ // ordinary requirement prose, and an owner adding a date by hand copies the
22
+ // form from the line above it.
23
+ const LINE = /^- \[([ xX])\] (\S+)(?:\s+<!-- done:(\d{4}-\d{2}-\d{2}) -->)?\s*(.*)$/
24
+
25
+ export type RoadmapState = Map<string, { checked: boolean; date: string | null }>
26
+
27
+ export function parseRoadmap(text: string): RoadmapState {
28
+ const state: RoadmapState = new Map()
29
+ for (const line of text.split('\n')) {
30
+ const m = LINE.exec(line.trim())
31
+ if (!m) continue
32
+ const [, box, fr, date] = m
33
+ if (!fr) continue
34
+ state.set(fr, { checked: box?.toLowerCase() === 'x', date: date ?? null })
35
+ // A duplicated id would otherwise let the last line silently win.
36
+ }
37
+ return state
38
+ }
39
+
40
+ function checkboxLine(req: Requirement, state: RoadmapState): string {
41
+ const prior = state.get(req.id)
42
+ const box = prior?.checked ? 'x' : ' '
43
+ const date = prior?.checked && prior.date ? ` <!-- done:${prior.date} -->` : ''
44
+ return `- [${box}] ${req.id}${date} ${req.requirement}`
45
+ }
46
+
47
+ function renderRoadmap(items: WorkItem[], reqs: Requirement[], state: RoadmapState): string {
48
+ const lines = [
49
+ '# Migration roadmap',
50
+ '',
51
+ 'Generated by `migrate handoff --adapter markdown`, in dependency order:',
52
+ 'a capability appears after every capability it cites.',
53
+ '',
54
+ 'Tick a box when the requirement is delivered, and record the date the',
55
+ 'same way the line above it does: `<!-- done:YYYY-MM-DD -->` straight',
56
+ 'after the id. `migrate coverage` reads this file, and `migrate forecast`',
57
+ 'needs the dates, so an undated tick counts as built but contributes',
58
+ 'nothing to the measured rate.',
59
+ '',
60
+ ]
61
+ items.forEach((item, i) => {
62
+ const own = reqs.filter((r) => r.cap === item.key)
63
+ lines.push(`## ${i + 1}. ${item.title}`)
64
+ lines.push('')
65
+ lines.push(`Depends on: ${item.dependsOn.length > 0 ? item.dependsOn.join(', ') : 'nothing'}`)
66
+ lines.push(`Detail: ${join(CAP_DIR, `${item.key}.md`)}`)
67
+ lines.push('')
68
+ if (own.length === 0) lines.push('No requirements were extracted for this capability.')
69
+ else for (const r of own) lines.push(checkboxLine(r, state))
70
+ lines.push('')
71
+ })
72
+ return lines.join('\n')
73
+ }
74
+
75
+ function cell(text: string): string {
76
+ return text.replace(/\|/g, '\\|').replace(/\n/g, ' ')
77
+ }
78
+
79
+ function renderCapability(item: WorkItem, reqs: Requirement[]): string {
80
+ const own = reqs.filter((r) => r.cap === item.key)
81
+ const lines = [
82
+ `# ${item.title}`,
83
+ '',
84
+ `- Key: ${item.key}`,
85
+ `- Depends on: ${item.dependsOn.length > 0 ? item.dependsOn.join(', ') : 'nothing'}`,
86
+ `- Requirements: ${item.weight}`,
87
+ '',
88
+ '## Functional requirements',
89
+ '',
90
+ '| id | requirement | actors | objects | rules | confidence | origin |',
91
+ '| --- | --- | --- | --- | --- | --- | --- |',
92
+ ]
93
+ for (const r of own) {
94
+ lines.push(
95
+ `| ${r.id} | ${cell(r.requirement)} | ${cell(r.actors)} | ${cell(r.objects)} | ${cell(r.rules)} | ${r.confidence.kind} | ${r.origin} |`,
96
+ )
97
+ }
98
+ lines.push('')
99
+ return lines.join('\n')
100
+ }
101
+
102
+ async function readIfPresent(path: string): Promise<string | null> {
103
+ try {
104
+ return await readTextFile(path)
105
+ } catch {
106
+ return null
107
+ }
108
+ }
109
+
110
+ export const markdown: Adapter = {
111
+ name: 'markdown',
112
+
113
+ async plan(input: HandoffInput): Promise<WorkItem[]> {
114
+ return buildWorkItems(input.capabilities, input.requirements)
115
+ },
116
+
117
+ async apply(items: WorkItem[], input: HandoffInput): Promise<ApplyResult> {
118
+ const src = input.config.source.path
119
+ await mkdir(join(input.root, CAP_DIR), { recursive: true })
120
+
121
+ // The roadmap is read before it is written, and every ticked box and its
122
+ // date is carried forward. Without this, running handoff a second time to
123
+ // pick up newly extracted requirements would silently erase the owner's
124
+ // record of what has been delivered. Re-running to finish a partial apply
125
+ // is behaviour the degradation contract requires, so it must not be the
126
+ // operation that loses data.
127
+ const roadmapPath = join(input.root, ROADMAP)
128
+ const existingRoadmap = await readIfPresent(roadmapPath)
129
+ const state = existingRoadmap ? parseRoadmap(existingRoadmap) : new Map()
130
+
131
+ const created: string[] = []
132
+ const updated: string[] = []
133
+ const unchanged: string[] = []
134
+ const refs: Record<string, string> = {}
135
+
136
+ for (const item of items) {
137
+ const rel = join(CAP_DIR, `${item.key}.md`)
138
+ const path = join(input.root, rel)
139
+ const next = renderCapability(item, input.requirements)
140
+ const before = await readIfPresent(path)
141
+ if (before === null) created.push(item.key)
142
+ else if (before !== next) updated.push(item.key)
143
+ else unchanged.push(item.key)
144
+ if (before !== next) await writeAtomically(path, next, src)
145
+ refs[item.key] = rel
146
+ }
147
+
148
+ const nextRoadmap = renderRoadmap(items, input.requirements, state)
149
+ if (existingRoadmap !== nextRoadmap) {
150
+ await writeAtomically(roadmapPath, nextRoadmap, src)
151
+ // The roadmap is part of what this adapter emits, so a run that rewrote
152
+ // it has not left everything unchanged. Without this the result could
153
+ // report every item `unchanged` while the roadmap was in fact replaced.
154
+ if (created.length === 0) {
155
+ for (const key of unchanged.splice(0, unchanged.length)) updated.push(key)
156
+ }
157
+ }
158
+
159
+ return { created, updated, unchanged, refs }
160
+ },
161
+
162
+ async throughput(input: HandoffInput): Promise<Throughput> {
163
+ const text = await readIfPresent(join(input.root, ROADMAP))
164
+ const basis = 'markdown roadmap checkboxes, dated in file'
165
+ if (text === null) return { completions: [], basis }
166
+ const completions: Completion[] = []
167
+ for (const [fr, entry] of parseRoadmap(text)) {
168
+ if (entry.checked) completions.push({ fr, doneAt: entry.date })
169
+ }
170
+ // Sorted by requirement id so two reads of one file agree, and so a
171
+ // coverage figure does not depend on where a line sits in the document.
172
+ completions.sort((a, b) => a.fr.localeCompare(b.fr))
173
+ return { completions, basis }
174
+ },
175
+ }
@@ -0,0 +1,243 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { loadConfig } from './config.ts'
4
+ import { LockError, withStoreLock } from './lock.ts'
5
+ import { storePaths } from './paths.ts'
6
+ import { recordBatch } from './phases.ts'
7
+ import { loadQueue, parseQueueItem } from './queue.ts'
8
+ import { readTextFile, writeAtomically } from './store.ts'
9
+ import type { QueueItem, Severity } from './types.ts'
10
+
11
+ const SEVERITIES: Severity[] = ['critical', 'moderate', 'minor']
12
+
13
+ // The three keys this command owns. Everything else in a queue item's
14
+ // frontmatter belongs to whoever wrote the item and passes through untouched.
15
+ const OWNED = ['status', 'ruling', 'adjudicated'] as const
16
+
17
+ // The review sheet exists so an owner can adjudicate a whole queue in one
18
+ // sitting, which is what phase 6 asks for. Pulling each item's first
19
+ // recommendation line into the list is the part that makes one pass possible:
20
+ // without it the owner opens every file to find out what they are deciding.
21
+ // Only the first line, because a recommendation may run to paragraphs and this
22
+ // is an index, not the document.
23
+ export function renderReviewSheet(items: QueueItem[]): string {
24
+ const sorted = [...items].sort((a, b) => {
25
+ const bySeverity = SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity)
26
+ return bySeverity !== 0 ? bySeverity : a.id.localeCompare(b.id)
27
+ })
28
+ const lines = sorted.map((item) => {
29
+ const head = `${item.id} [${item.severity}] ${item.status}`
30
+ const first = item.recommendation.split('\n')[0]?.trim() ?? ''
31
+ return first ? `${head} - ${first}` : head
32
+ })
33
+ const open = items.filter((i) => i.status === 'open').length
34
+ return [...lines, '', `${open} open`].join('\n')
35
+ }
36
+
37
+ // Rewrites only the three owned frontmatter keys, in place, and returns the
38
+ // whole file. Two properties are load-bearing and both are asserted by test:
39
+ //
40
+ // Keys this command does not own keep their original position, rather than the
41
+ // block being regenerated from a parsed map. A queue item's frontmatter is
42
+ // hand-authored and may carry whatever the run found useful; reordering it
43
+ // would show up as noise in every diff of an adjudicated item.
44
+ //
45
+ // The body is returned byte for byte. It is the audit record of why a ruling
46
+ // was made, and a rewrite that reflows it destroys the thing being audited.
47
+ // That is also why this works on lines rather than round-tripping through
48
+ // parseQueueItem: the parser is lossy by design.
49
+ export function applyRuling(text: string, ruling: string, date: string): string {
50
+ if (ruling.trim().length === 0) {
51
+ throw new Error('a ruling cannot be empty')
52
+ }
53
+ if (/[\n\r]/.test(ruling)) {
54
+ // A line break would let the value inject further frontmatter keys, or a
55
+ // closing fence, into the block it is written into. A value containing
56
+ // ':' or '---' is harmless by comparison: neither can reach line start.
57
+ throw new Error('a ruling cannot contain a newline')
58
+ }
59
+
60
+ // Normalised exactly as queue.ts normalises on read, and the fence located
61
+ // by the same rule it uses (a line STARTING with ---, not a line equal to it
62
+ // after trimming). The two disagreeing was not cosmetic: an indented ` ---`
63
+ // inside the frontmatter was invisible to the parser and taken as the fence
64
+ // here, so the owned keys were written above the real fence and the parser's
65
+ // last-key-wins read still saw `status: open`. `migrate adjudicate` printed
66
+ // "open -> adjudicated" and recorded a batch while the item stayed open
67
+ // forever, unreachable even with --force because its status never changed.
68
+ const normalized = text.startsWith('\uFEFF') ? text.slice(1) : text
69
+ const unix = normalized.replace(/\r\n/g, '\n')
70
+ const lines = unix.split('\n')
71
+ if (lines[0] !== '---') {
72
+ throw new Error('missing --- frontmatter block')
73
+ }
74
+ const close = lines.findIndex((line, i) => i > 0 && line.startsWith('---'))
75
+ if (close === -1) {
76
+ throw new Error('unterminated --- frontmatter block')
77
+ }
78
+
79
+ const updates = new Map<string, string>([
80
+ ['status', 'adjudicated'],
81
+ ['ruling', ruling],
82
+ ['adjudicated', date],
83
+ ])
84
+ const owned = new Set<string>(OWNED)
85
+ const seen = new Set<string>()
86
+ const rewritten: string[] = []
87
+ for (const line of lines.slice(1, close)) {
88
+ const sep = line.indexOf(':')
89
+ // An indented key belongs to a nested map, not to this document's top
90
+ // level. Trimming before comparing rewrote ` status: draft` under a
91
+ // `meta:` key into a de-indented top-level `status: adjudicated`, leaving
92
+ // two `status:` lines and a destroyed `meta` block.
93
+ if (sep === -1 || line !== line.trimStart()) {
94
+ rewritten.push(line)
95
+ continue
96
+ }
97
+ const key = line.slice(0, sep).trim()
98
+ seen.add(key)
99
+ if (owned.has(key)) rewritten.push(`${key}: ${updates.get(key)}`)
100
+ else rewritten.push(line)
101
+ }
102
+ for (const key of OWNED) {
103
+ if (!seen.has(key)) rewritten.push(`${key}: ${updates.get(key)}`)
104
+ }
105
+
106
+ return ['---', ...rewritten, ...lines.slice(close)].join('\n')
107
+ }
108
+
109
+ function today(): string {
110
+ return new Date().toISOString().slice(0, 10)
111
+ }
112
+
113
+ export async function runAdjudicate(opts: {
114
+ root: string
115
+ id?: string
116
+ ruling?: string
117
+ force?: boolean
118
+ forceUnlock?: boolean
119
+ now?: () => string
120
+ }): Promise<number> {
121
+ const cfg = await loadConfig(opts.root)
122
+ const paths = storePaths(opts.root)
123
+ const { items, errors } = await loadQueue(paths.queueDir)
124
+
125
+ if (!opts.id) {
126
+ for (const e of errors) process.stderr.write(`adjudicate: ${e}\n`)
127
+ process.stdout.write(`${renderReviewSheet(items)}\n`)
128
+ return errors.length > 0 ? 1 : 0
129
+ }
130
+
131
+ if (opts.ruling === undefined) {
132
+ process.stderr.write('adjudicate: want --ruling <text> when an id is given\n')
133
+ return 2
134
+ }
135
+
136
+ const item = items.find((i) => i.id === opts.id)
137
+ if (!item) {
138
+ // Two different failures reach here and they are not the same class. A
139
+ // file that exists but will not parse is a real item whose content is
140
+ // broken: a content failure, and the parse errors are what the caller
141
+ // needs. An id with no file at all was never an item, so the request
142
+ // could not be serviced as posed: a usage error, the same code `queue
143
+ // add` returns for a file it cannot read.
144
+ const path = join(paths.queueDir, `${opts.id}.md`)
145
+ if (existsSync(path)) {
146
+ for (const e of errors) {
147
+ if (e.includes(path)) process.stderr.write(`adjudicate: ${e}\n`)
148
+ }
149
+ return 1
150
+ }
151
+ process.stderr.write(`adjudicate: no queue item ${opts.id}\n`)
152
+ return 2
153
+ }
154
+
155
+ if (item.status === 'adjudicated' && !opts.force) {
156
+ // The existing ruling is printed rather than merely referred to, so the
157
+ // caller can see what a --force would have replaced. An owner's recorded
158
+ // decision is not something a re-run should quietly overwrite, which is
159
+ // the one place this deliberately diverges from respec's applyRuling.
160
+ process.stderr.write(
161
+ `adjudicate: ${item.id} is already adjudicated: ${item.ruling ?? '<no ruling recorded>'}\n` +
162
+ 'adjudicate: pass --force to replace it\n',
163
+ )
164
+ return 1
165
+ }
166
+
167
+ let text: string
168
+ try {
169
+ text = await readTextFile(item.path)
170
+ } catch (e) {
171
+ // The item was in the index a moment ago; its file vanishing between load
172
+ // and write is a domain failure on an otherwise well-formed request.
173
+ process.stderr.write(`adjudicate: ${(e as Error).message}\n`)
174
+ return 1
175
+ }
176
+
177
+ let next: string
178
+ try {
179
+ next = applyRuling(text, opts.ruling, (opts.now ?? today)())
180
+ // Checked against the parser every other command reads this file with,
181
+ // rather than trusted. A write that reports success while leaving the item
182
+ // open or unparseable is worse than a refusal, because the queue gate then
183
+ // blocks handoff over a decision the owner believes they recorded.
184
+ const reparsed = parseQueueItem(next, item.path)
185
+ if (!reparsed.ok) {
186
+ throw new Error(
187
+ `the rewritten file would not parse (${reparsed.errors.join('; ')}); the item's frontmatter is shaped in a way this command cannot safely edit`,
188
+ )
189
+ }
190
+ if (reparsed.value.status !== 'adjudicated' || reparsed.value.ruling !== opts.ruling) {
191
+ throw new Error(
192
+ "the rewritten file does not read back as adjudicated; the item's frontmatter is shaped in a way this command cannot safely edit",
193
+ )
194
+ }
195
+ } catch (e) {
196
+ // Every applyRuling throw is about the ruling text or the file's own
197
+ // frontmatter fence, both of which mean the request was malformed.
198
+ process.stderr.write(`adjudicate: ${(e as Error).message}\n`)
199
+ return 2
200
+ }
201
+
202
+ const before = item.status
203
+ try {
204
+ await withStoreLock(
205
+ opts.root,
206
+ async () => {
207
+ await writeAtomically(item.path, next, cfg.source.path)
208
+ // The batch is what lets the run-state gate see that phase 6 ran at
209
+ // all, the same role census records play for enumerate and extract.
210
+ // Keyed by item id so re-ruling one does not add a second entry.
211
+ await recordBatch(
212
+ opts.root,
213
+ 'adjudicate',
214
+ { id: `b-adjudicate-${item.id}`, count: 1 },
215
+ cfg.source.path,
216
+ )
217
+ },
218
+ { cmd: 'adjudicate', ...(opts.forceUnlock ? { force: true } : {}) },
219
+ )
220
+ } catch (e) {
221
+ // Same class as every other writer: the request is fine and would succeed
222
+ // on retry, so it gets exit 3 rather than falling through to the generic
223
+ // guard at 2, which an orchestrator reads as "do not retry".
224
+ if (e instanceof LockError) {
225
+ process.stderr.write(`adjudicate: ${e.message}\n`)
226
+ return 3
227
+ }
228
+ throw e
229
+ }
230
+
231
+ process.stdout.write(
232
+ `adjudicate: ${item.id}\n` +
233
+ ` status ${before} -> adjudicated\n` +
234
+ ` ruling recorded\n` +
235
+ // This command deliberately writes no row file, so the consequence of
236
+ // the ruling (an element's disposition, a requirement's confidence) is
237
+ // still outstanding and goes through the writer that already validates
238
+ // it. Printing the command is how the phase manual's instruction
239
+ // reaches the operator at the moment it applies.
240
+ 'next: apply the consequence with `migrate import`\n',
241
+ )
242
+ return 0
243
+ }
@@ -0,0 +1,188 @@
1
+ import type { CapCoverage } from './coverage.ts'
2
+
3
+ // The owner-attested half of forecasting. Everything measured lives in the
4
+ // store and in the adapter's throughput; everything here is a judgment, and
5
+ // the file carries who made it and when.
6
+ //
7
+ // The shape follows the flow target's forecast-assumptions.md (quartex/Nexus
8
+ // at c2464ac, plugins/stack/templates/docs/modernisation/), because the
9
+ // territory-and-multiplier model is both less to attest and more honest than
10
+ // the per-requirement weighting table the parent spec first described: a
11
+ // campaign names a handful of territories rather than a number per
12
+ // requirement.
13
+ //
14
+ // One section of flow's file is deliberately absent. Its Economics table
15
+ // carries dollar rates and token budgets derived from slice telemetry, which
16
+ // this tool does not have and must not pretend to.
17
+
18
+ export type Scenario = {
19
+ label: string
20
+ // `as-is` and `active` extrapolate a measured velocity. A positive number is
21
+ // an owner's target that no measurement backs, and the projection labels it
22
+ // so, which is the whole point of admitting all three.
23
+ rate: 'as-is' | 'active' | number
24
+ streams: number
25
+ tax: number
26
+ note: string
27
+ }
28
+
29
+ export type Assumptions = {
30
+ attestedBy: string
31
+ attestedDate: string
32
+ territories: Record<string, string>
33
+ multipliers: Record<string, number>
34
+ scenarios: Scenario[]
35
+ caveats: string[]
36
+ }
37
+
38
+ function parseFrontmatter(raw: string): { data: Record<string, string>; body: string } {
39
+ const lines = raw.split('\n')
40
+ if (lines[0]?.trim() !== '---') return { data: {}, body: raw }
41
+ const close = lines.findIndex((l, i) => i > 0 && l.trim() === '---')
42
+ if (close === -1) return { data: {}, body: raw }
43
+ const data: Record<string, string> = {}
44
+ for (const line of lines.slice(1, close)) {
45
+ const at = line.indexOf(':')
46
+ if (at === -1) continue
47
+ data[line.slice(0, at).trim()] = line.slice(at + 1).trim()
48
+ }
49
+ return { data, body: lines.slice(close + 1).join('\n') }
50
+ }
51
+
52
+ function extractSections(body: string): Record<string, string> {
53
+ const sections: Record<string, string> = {}
54
+ const parts = body.split(/\n## /).map((p, i) => (i === 0 ? p : `## ${p}`))
55
+ for (const part of parts) {
56
+ const m = part.match(/^## (.+)\n/)
57
+ if (!m?.[1]) continue
58
+ sections[m[1].trim()] = part.slice(m[0].length).trim()
59
+ }
60
+ return sections
61
+ }
62
+
63
+ // Markdown table rows minus the header and separator, cells trimmed. The file
64
+ // is documentation and input at once, which is why it is a table rather than
65
+ // TOML: the owner reads it as often as the tool does.
66
+ // A markdown separator row is recognised rather than assumed to be the second
67
+ // line. Dropping two lines positionally meant a table written without a
68
+ // separator silently lost its first data row, which by the template's own
69
+ // convention is the as-is measured baseline: the owner attested two scenarios
70
+ // and got one, with no error anywhere.
71
+ function isSeparator(cells: string[]): boolean {
72
+ return cells.length > 0 && cells.every((c) => /^:?-{1,}:?$/.test(c))
73
+ }
74
+
75
+ function tableRows(block: string): string[][] {
76
+ const rows = block
77
+ .split('\n')
78
+ .map((l) => l.trim())
79
+ .filter((l) => l.startsWith('|'))
80
+ .map((l) =>
81
+ l
82
+ .split('|')
83
+ .slice(1, -1)
84
+ .map((c) => c.trim()),
85
+ )
86
+ // The first row is always the header. Anything after it that is a separator
87
+ // is structure, not data, wherever it sits.
88
+ return rows.slice(1).filter((cells) => !isSeparator(cells))
89
+ }
90
+
91
+ export function parseAssumptions(raw: string, path: string): Assumptions {
92
+ const { data, body } = parseFrontmatter(raw)
93
+ for (const field of ['attestedBy', 'attestedDate']) {
94
+ if (!data[field]) {
95
+ throw new Error(`forecast assumptions: missing ${field} in ${path}`)
96
+ }
97
+ }
98
+ const sections = extractSections(body)
99
+ for (const name of ['Territories', 'Multipliers', 'Scenarios']) {
100
+ if (sections[name] === undefined) {
101
+ throw new Error(`forecast assumptions: missing section "${name}" in ${path}`)
102
+ }
103
+ }
104
+
105
+ const territories: Record<string, string> = {}
106
+ for (const row of tableRows(sections.Territories ?? '')) {
107
+ if (row.length !== 2 || !row[0] || !row[1]) {
108
+ throw new Error(`forecast assumptions: bad territory row in ${path}: [${row.join(' | ')}]`)
109
+ }
110
+ territories[row[0]] = row[1]
111
+ }
112
+
113
+ const multipliers: Record<string, number> = {}
114
+ for (const row of tableRows(sections.Multipliers ?? '')) {
115
+ const value = Number(row[1])
116
+ if (row.length !== 2 || !row[0] || !Number.isFinite(value) || value <= 0) {
117
+ throw new Error(`forecast assumptions: bad multiplier row in ${path}: [${row.join(' | ')}]`)
118
+ }
119
+ multipliers[row[0]] = value
120
+ }
121
+
122
+ const scenarios: Scenario[] = tableRows(sections.Scenarios ?? '').map((row) => {
123
+ const [label, rateRaw, streamsRaw, taxRaw, note] = row
124
+ if (row.length !== 5 || !label) {
125
+ throw new Error(`forecast assumptions: bad scenario row in ${path}: [${row.join(' | ')}]`)
126
+ }
127
+ let rate: Scenario['rate']
128
+ if (rateRaw === 'as-is' || rateRaw === 'active') {
129
+ rate = rateRaw
130
+ } else {
131
+ const n = Number(rateRaw)
132
+ if (!Number.isFinite(n) || n <= 0) {
133
+ throw new Error(
134
+ `forecast assumptions: scenario "${label}" rate must be as-is, active, or a positive requirements-per-day number in ${path}`,
135
+ )
136
+ }
137
+ rate = n
138
+ }
139
+ const streams = Number(streamsRaw)
140
+ if (!Number.isFinite(streams) || streams <= 0) {
141
+ throw new Error(
142
+ `forecast assumptions: scenario "${label}" streams must be a positive number in ${path}`,
143
+ )
144
+ }
145
+ const tax = Number(taxRaw)
146
+ if (!Number.isFinite(tax) || tax < 0 || tax >= 1) {
147
+ throw new Error(`forecast assumptions: scenario "${label}" tax must be in [0, 1) in ${path}`)
148
+ }
149
+ return { label, rate, streams, tax, note: note ?? '' }
150
+ })
151
+
152
+ const caveats = (sections.Caveats ?? '')
153
+ .split('\n')
154
+ .map((l) => l.trim())
155
+ .filter((l) => l.startsWith('- '))
156
+ .map((l) => l.slice(2))
157
+
158
+ return {
159
+ attestedBy: data.attestedBy ?? '',
160
+ attestedDate: data.attestedDate ?? '',
161
+ territories,
162
+ multipliers,
163
+ scenarios,
164
+ caveats,
165
+ }
166
+ }
167
+
168
+ // Validated against measured coverage, not against itself. A capability with
169
+ // nothing confirmed needs no territory, because it contributes nothing to
170
+ // demand; one with confirmed requirements and no territory would silently
171
+ // weigh whatever the fallback happened to be.
172
+ export function validateAssumptions(a: Assumptions, coverage: CapCoverage[]): string[] {
173
+ const errors: string[] = []
174
+ for (const c of coverage) {
175
+ if (c.confirmedTotal > 0 && !a.territories[c.slug]) {
176
+ errors.push(
177
+ `capability ${c.slug} has ${c.confirmedTotal} confirmed requirement(s) but no territory in the forecast assumptions`,
178
+ )
179
+ }
180
+ }
181
+ for (const [slug, territory] of Object.entries(a.territories)) {
182
+ if (!(territory in a.multipliers)) {
183
+ errors.push(`territory "${territory}" (${slug}) has no multiplier`)
184
+ }
185
+ }
186
+ if (a.scenarios.length === 0) errors.push('no scenarios defined')
187
+ return errors
188
+ }