@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,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
|
+
}
|