@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,186 @@
1
+ import { flow } from './adapters/flow.ts'
2
+ import { github } from './adapters/github.ts'
3
+ import { markdown } from './adapters/markdown.ts'
4
+ import { runCheck } from './check.ts'
5
+ import { loadConfig } from './config.ts'
6
+ import {
7
+ type Adapter,
8
+ blockedRequirements,
9
+ type HandoffFile,
10
+ type HandoffInput,
11
+ saveHandoff,
12
+ } from './handoff.ts'
13
+ import { LockError, withStoreLock } from './lock.ts'
14
+ import { storePaths } from './paths.ts'
15
+ import { recordBatch } from './phases.ts'
16
+ import { loadQueue } from './queue.ts'
17
+ import { readRows } from './store.ts'
18
+ import type { Capability, Delta, Requirement, WorkItem } from './types.ts'
19
+
20
+ const ADAPTERS: Record<string, Adapter> = { markdown, github, flow }
21
+
22
+ export function adapterNames(): string[] {
23
+ return Object.keys(ADAPTERS).sort()
24
+ }
25
+
26
+ // Everything that must be true before any work item is emitted. Three sources,
27
+ // reported together so an operator sees the whole list rather than fixing one
28
+ // blocker at a time.
29
+ //
30
+ // The gate run is deliberately bounded at `adjudicate`. Citations and leaks are
31
+ // both mandatory before handoff, so they are switched on here rather than left
32
+ // to whatever the caller last used, but an unbounded run would also fire gate
33
+ // 12, which requires the handoff.json this command has not written yet. That
34
+ // would refuse every first handoff, permanently.
35
+ export async function preflight(root: string, opts: { gitBin?: string } = {}): Promise<string[]> {
36
+ const blockers: string[] = []
37
+
38
+ const check = await runCheck({
39
+ root,
40
+ citations: true,
41
+ leaks: true,
42
+ phase: 'adjudicate',
43
+ ...(opts.gitBin ? { gitBin: opts.gitBin } : {}),
44
+ })
45
+ for (const v of check.violations) blockers.push(`[${v.gate}] ${v.message}`)
46
+
47
+ // The open items themselves are not listed again here. The adjudication
48
+ // gate above already names each one, and bounding the gate run at
49
+ // `adjudicate` is what guarantees it ran. Repeating them made the refusal
50
+ // report one fact twice and read as more blockers than there were.
51
+ const paths = storePaths(root)
52
+ const { items } = await loadQueue(paths.queueDir)
53
+ const open = items.filter((i) => i.status === 'open')
54
+
55
+ const requirements = await readRows<Requirement>(paths.requirements)
56
+ for (const { fr, queue } of blockedRequirements(requirements, new Set(open.map((i) => i.id)))) {
57
+ blockers.push(`${fr} blocked by ${queue}`)
58
+ }
59
+
60
+ return blockers
61
+ }
62
+
63
+ function renderPlan(items: WorkItem[]): string {
64
+ const lines = ['plan:']
65
+ for (const item of items) {
66
+ const deps = item.dependsOn.length > 0 ? ` after ${item.dependsOn.join(', ')}` : ''
67
+ lines.push(` ${item.key} (${item.frs.length} requirement(s))${deps}`)
68
+ }
69
+ return lines.join('\n')
70
+ }
71
+
72
+ export async function runHandoff(opts: {
73
+ root: string
74
+ adapter?: string
75
+ dryRun?: boolean
76
+ gitBin?: string
77
+ ghBin?: string
78
+ forceUnlock?: boolean
79
+ }): Promise<number> {
80
+ const cfg = await loadConfig(opts.root)
81
+ const name = opts.adapter ?? cfg.handoff.adapter
82
+ const adapter = ADAPTERS[name]
83
+ if (!adapter) {
84
+ process.stderr.write(
85
+ `handoff: unknown adapter ${name}; want one of ${adapterNames().join(', ')}\n`,
86
+ )
87
+ return 2
88
+ }
89
+
90
+ const blockers = await preflight(opts.root, {
91
+ ...(opts.gitBin ? { gitBin: opts.gitBin } : {}),
92
+ })
93
+ if (blockers.length > 0) {
94
+ // Named individually rather than counted, because each one is a separate
95
+ // thing somebody has to go and do.
96
+ for (const b of blockers) process.stderr.write(`handoff: ${b}\n`)
97
+ process.stderr.write(`handoff: refusing to emit with ${blockers.length} blocker(s)\n`)
98
+ return 1
99
+ }
100
+
101
+ const paths = storePaths(opts.root)
102
+ const input: HandoffInput = {
103
+ requirements: await readRows<Requirement>(paths.requirements),
104
+ capabilities: await readRows<Capability>(paths.capabilities),
105
+ deltas: await readRows<Delta>(paths.deltas),
106
+ config: cfg,
107
+ root: opts.root,
108
+ gitBin: opts.gitBin ?? 'git',
109
+ ghBin: opts.ghBin ?? 'gh',
110
+ }
111
+
112
+ let items: WorkItem[]
113
+ try {
114
+ items = await adapter.plan(input)
115
+ } catch (e) {
116
+ // An adapter refuses in plan() precisely so nothing is half-written; the
117
+ // flow adapter's namespace check is the one that reaches here in practice.
118
+ process.stderr.write(`handoff: ${(e as Error).message}\n`)
119
+ return 1
120
+ }
121
+
122
+ if (opts.dryRun) {
123
+ // Nothing is written, not even handoff.json. A dry run that wrote the
124
+ // basis would satisfy gate 12 with nothing actually emitted.
125
+ process.stdout.write(`${renderPlan(items)}\n`)
126
+ process.stdout.write(`handoff: dry run, ${items.length} work item(s), nothing written\n`)
127
+ return 0
128
+ }
129
+
130
+ let result: Awaited<ReturnType<Adapter['apply']>>
131
+ try {
132
+ result = await adapter.apply(items, input)
133
+ } catch (e) {
134
+ process.stderr.write(`handoff: ${(e as Error).message}\n`)
135
+ return 1
136
+ }
137
+
138
+ const emitted = items.reduce((n, i) => n + i.frs.length, 0)
139
+ const confirmed = input.requirements.filter((r) => r.confidence.kind === 'confirmed').length
140
+ const file: HandoffFile = {
141
+ version: 1,
142
+ adapter: name,
143
+ items: items.map(({ key, title, frs, dependsOn, weight }) => ({
144
+ key,
145
+ title,
146
+ frs,
147
+ dependsOn,
148
+ weight,
149
+ })),
150
+ refs: result.refs,
151
+ basis: { confirmed, emitted, order: items.map((i) => i.key) },
152
+ }
153
+
154
+ try {
155
+ await withStoreLock(
156
+ opts.root,
157
+ async () => {
158
+ await saveHandoff(opts.root, file, cfg.source.path)
159
+ await recordBatch(
160
+ opts.root,
161
+ 'handoff',
162
+ { id: `b-handoff-${name}`, count: items.length },
163
+ cfg.source.path,
164
+ )
165
+ },
166
+ { cmd: 'handoff', ...(opts.forceUnlock ? { force: true } : {}) },
167
+ )
168
+ } catch (e) {
169
+ // Same class as every other writer: exit 3, which a caller can branch on
170
+ // to retry, rather than the generic guard's 2, which means "do not".
171
+ if (e instanceof LockError) {
172
+ process.stderr.write(`handoff: ${e.message}\n`)
173
+ return 3
174
+ }
175
+ throw e
176
+ }
177
+
178
+ process.stdout.write(
179
+ `handoff: adapter ${name}, ${items.length} work item(s), ${emitted} requirement(s)\n` +
180
+ ` created ${result.created.length}\n` +
181
+ ` updated ${result.updated.length}\n` +
182
+ ` unchanged ${result.unchanged.length}\n` +
183
+ `next: mark the phase done with \`migrate phase handoff --status done\`, then read progress back with \`migrate coverage\`\n`,
184
+ )
185
+ return 0
186
+ }
@@ -0,0 +1,330 @@
1
+ import { existsSync } from 'node:fs'
2
+ import type { Config } from './config.ts'
3
+ import { storePaths } from './paths.ts'
4
+ import { readJsonFile, writeAtomically } from './store.ts'
5
+ import type { ApplyResult, Capability, Delta, Requirement, Throughput, WorkItem } from './types.ts'
6
+ import { isRecord } from './validate.ts'
7
+
8
+ export type HandoffInput = {
9
+ requirements: Requirement[]
10
+ capabilities: Capability[]
11
+ deltas: Delta[]
12
+ config: Config
13
+ // What an adapter needs to reach the world. The two binary paths are the
14
+ // same injection seams the rest of the CLI uses, and without them the github
15
+ // adapter cannot be tested against a fake `gh` and the flow adapter cannot
16
+ // run the target's own checker.
17
+ root: string
18
+ gitBin: string
19
+ ghBin: string
20
+ }
21
+
22
+ export type Adapter = {
23
+ name: string
24
+ plan(input: HandoffInput): Promise<WorkItem[]>
25
+ apply(items: WorkItem[], input: HandoffInput): Promise<ApplyResult>
26
+ // Optional in the contract, but only one adapter is expected to decline: an
27
+ // adapter whose medium has no completion signal at all. `coverage` names an
28
+ // adapter that declines rather than reporting zero built, because those are
29
+ // very different claims.
30
+ throughput?(input: HandoffInput): Promise<Throughput>
31
+ }
32
+
33
+ // The body is prose, and it is regenerated on every plan(), so storing it
34
+ // would make handoff.json churn on wording changes that emitted nothing new.
35
+ // Everything gate 12 checks lives in the fields that remain.
36
+ export type StoredItem = Omit<WorkItem, 'body'>
37
+
38
+ export type HandoffFile = {
39
+ version: 1
40
+ adapter: string
41
+ items: StoredItem[]
42
+ refs: Record<string, string>
43
+ // The forecast basis. `confirmed` is coverage's denominator, `emitted` is
44
+ // every requirement that reached a work item, and the two differ by exactly
45
+ // the non-confirmed requirements that handoff still emits but parity does
46
+ // not hold the build to.
47
+ basis: { confirmed: number; emitted: number; order: string[] }
48
+ }
49
+
50
+ // Capability A depends on capability B when a requirement in A carries a
51
+ // ledger citation to an element the partition assigns to B. Directional, which
52
+ // is what plain citation overlap is not: overlap alone gives clusters, and a
53
+ // build team needs an order.
54
+ function dependencyEdges(caps: Capability[], reqs: Requirement[]): Map<string, Set<string>> {
55
+ const owner = new Map<string, string>()
56
+ for (const c of caps) {
57
+ for (const el of c.elements) owner.set(el, c.slug)
58
+ }
59
+ const deps = new Map<string, Set<string>>()
60
+ for (const c of caps) deps.set(c.slug, new Set())
61
+ for (const r of reqs) {
62
+ const from = deps.get(r.cap)
63
+ // A requirement naming a capability the partition does not have is a real
64
+ // defect, and the refs gate names it. Skipped rather than reported again
65
+ // here, because a dependency sort is not where that failure belongs.
66
+ if (!from) continue
67
+ for (const cite of r.citations) {
68
+ if (cite.kind !== 'ledger') continue
69
+ const to = owner.get(cite.id)
70
+ // A capability citing an element it owns itself is the normal case, not
71
+ // a self-dependency; an unowned element is one the refs gate names.
72
+ if (!to || to === r.cap) continue
73
+ from.add(to)
74
+ }
75
+ }
76
+ return deps
77
+ }
78
+
79
+ // Whether `from` can reach `to` following dependency edges within `scope`.
80
+ // Used to tell a genuine cycle member (it reaches itself) from a capability
81
+ // that is merely blocked by one.
82
+ function reaches(
83
+ from: string,
84
+ to: string,
85
+ deps: Map<string, Set<string>>,
86
+ scope: Set<string>,
87
+ ): boolean {
88
+ const seen = new Set<string>()
89
+ const stack = [...(deps.get(from) ?? [])].filter((d) => scope.has(d))
90
+ while (stack.length > 0) {
91
+ const next = stack.pop() as string
92
+ if (next === to) return true
93
+ if (seen.has(next)) continue
94
+ seen.add(next)
95
+ for (const d of deps.get(next) ?? []) {
96
+ if (scope.has(d)) stack.push(d)
97
+ }
98
+ }
99
+ return false
100
+ }
101
+
102
+ // Kahn's algorithm over slug-sorted candidates. When a pass emits nothing,
103
+ // every remaining capability is in a cycle: they are emitted in slug order and
104
+ // returned in `cycle` so the caller can report it. Deterministic in both
105
+ // branches, and independent of the order rows happen to sit in the store.
106
+ export function dependencyOrder(
107
+ caps: Capability[],
108
+ reqs: Requirement[],
109
+ ): { ordered: Capability[]; cycle: string[] } {
110
+ const deps = dependencyEdges(caps, reqs)
111
+ const bySlug = new Map(caps.map((c) => [c.slug, c]))
112
+ const ordered: Capability[] = []
113
+ const remaining = new Set(caps.map((c) => c.slug))
114
+ const cycle: string[] = []
115
+
116
+ while (remaining.size > 0) {
117
+ let progress = false
118
+ for (const slug of [...remaining].sort()) {
119
+ const blocked = [...(deps.get(slug) ?? [])].some((dep) => remaining.has(dep))
120
+ if (blocked) continue
121
+ const capability = bySlug.get(slug)
122
+ if (!capability) {
123
+ remaining.delete(slug)
124
+ continue
125
+ }
126
+ ordered.push(capability)
127
+ remaining.delete(slug)
128
+ progress = true
129
+ }
130
+ if (progress) continue
131
+ // Nothing could be emitted, so at least one cycle blocks the rest. Only the
132
+ // members of a cycle are broken out, and only one pass' worth, after which
133
+ // the loop resumes: dumping everything remaining reported capabilities that
134
+ // were merely downstream of a cycle as cycle members, and threw away a
135
+ // perfectly satisfiable order for them. A capability that depends on a
136
+ // cycle should still be emitted after it, not alongside it.
137
+ const stuck = [...remaining].sort()
138
+ const inCycle = stuck.filter((slug) => reaches(slug, slug, deps, remaining))
139
+ const release = inCycle.length > 0 ? inCycle : stuck
140
+ for (const slug of release) {
141
+ const capability = bySlug.get(slug)
142
+ if (capability) {
143
+ ordered.push(capability)
144
+ cycle.push(slug)
145
+ }
146
+ remaining.delete(slug)
147
+ }
148
+ }
149
+ return { ordered, cycle }
150
+ }
151
+
152
+ function renderBody(capability: Capability, reqs: Requirement[]): string {
153
+ const lines = [`${capability.title}`, '']
154
+ if (reqs.length === 0) {
155
+ lines.push('No requirements were extracted for this capability.')
156
+ } else {
157
+ for (const r of reqs) lines.push(`- ${r.id}: ${r.requirement}`)
158
+ }
159
+ return lines.join('\n')
160
+ }
161
+
162
+ // One work item per capability, in dependency order. `weight` is the plain
163
+ // requirement count; the territory multipliers that turn counts into effort
164
+ // live in the owner-attested assumptions file, not in the emitted basis,
165
+ // because a weighting the tool invents is the kind of number this method
166
+ // refuses to assert.
167
+ export function buildWorkItems(caps: Capability[], reqs: Requirement[]): WorkItem[] {
168
+ const { ordered } = dependencyOrder(caps, reqs)
169
+ const deps = dependencyEdges(caps, reqs)
170
+ const known = new Set(caps.map((c) => c.slug))
171
+ return ordered.map((capability) => {
172
+ const own = reqs.filter((r) => r.cap === capability.slug)
173
+ return {
174
+ key: capability.slug,
175
+ title: capability.title,
176
+ body: renderBody(capability, own),
177
+ frs: own.map((r) => r.id),
178
+ dependsOn: [...(deps.get(capability.slug) ?? [])].filter((d) => known.has(d)).sort(),
179
+ weight: own.length,
180
+ }
181
+ })
182
+ }
183
+
184
+ // A requirement is blocked when a decision it is waiting on has not been made
185
+ // yet. Two independent paths reach that state, and both are relative to an
186
+ // item that is still OPEN:
187
+ //
188
+ // - a `queued` confidence, meaning the extract phase could not settle what
189
+ // the requirement is, and
190
+ // - a sub-high `rubric` parity, meaning the parity phase could not settle
191
+ // how it will be proven.
192
+ //
193
+ // Relative to open items specifically, which is the whole subtlety. A queued
194
+ // confidence pointing at an item that has since been adjudicated is settled:
195
+ // the owner ruled, and the requirement must stop blocking handoff even though
196
+ // its confidence field still reads `queued`. Treating every queued confidence
197
+ // as a blocker would refuse handoff forever unless each one were re-imported
198
+ // with a new confidence first, which is work the ruling did not ask for.
199
+ //
200
+ // One requirement can be blocked from both directions at once and is reported
201
+ // once per path, because they are two different decisions to go and make.
202
+ export function blockedRequirements(
203
+ reqs: Requirement[],
204
+ openQueueIds: Set<string>,
205
+ ): { fr: string; queue: string }[] {
206
+ const blocked: { fr: string; queue: string }[] = []
207
+ for (const r of reqs) {
208
+ if (r.confidence.kind === 'queued' && openQueueIds.has(r.confidence.queue)) {
209
+ blocked.push({ fr: r.id, queue: r.confidence.queue })
210
+ }
211
+ if (r.parity?.kind === 'rubric' && r.parity.level !== 'high') {
212
+ if (openQueueIds.has(r.parity.queue)) blocked.push({ fr: r.id, queue: r.parity.queue })
213
+ }
214
+ }
215
+ return blocked
216
+ }
217
+
218
+ // handoff.json was the only store file in this codebase read through an
219
+ // unchecked cast, and this milestone gave it three consumers (the gate,
220
+ // coverage, forecast). `readRows` carries the same warning and every census row
221
+ // goes through `validateCensus` for exactly this reason: nothing stops a hand
222
+ // edit, a merge-conflict resolution, or a half-written file. Without this, `{}`
223
+ // or a truncated write reached `handoff.items.map` and crashed `migrate check`
224
+ // with an internal TypeError at exit 2, which claims the request was malformed
225
+ // when in fact a well-formed request found a bad store file.
226
+ export function validateHandoff(
227
+ raw: unknown,
228
+ ): { ok: true; value: HandoffFile } | { ok: false; errors: string[] } {
229
+ const errors: string[] = []
230
+ if (!isRecord(raw)) return { ok: false, errors: ['is not a JSON object'] }
231
+ const rec: Record<string, unknown> = raw
232
+ if (typeof rec.adapter !== 'string' || (rec.adapter as string).length === 0) {
233
+ errors.push('adapter must be a non-empty string')
234
+ }
235
+ if (!Array.isArray(rec.items)) errors.push('items must be an array')
236
+ else {
237
+ ;(rec.items as unknown[]).forEach((item: unknown, i: number) => {
238
+ if (!isRecord(item)) {
239
+ errors.push(`items[${i}] is not an object`)
240
+ return
241
+ }
242
+ if (typeof item.key !== 'string' || item.key.length === 0) {
243
+ errors.push(`items[${i}].key must be a non-empty string`)
244
+ }
245
+ if (typeof item.title !== 'string') errors.push(`items[${i}].title must be a string`)
246
+ if (!Array.isArray(item.frs) || (item.frs as unknown[]).some((f) => typeof f !== 'string')) {
247
+ errors.push(`items[${i}].frs must be an array of strings`)
248
+ }
249
+ if (
250
+ !Array.isArray(item.dependsOn) ||
251
+ (item.dependsOn as unknown[]).some((d) => typeof d !== 'string')
252
+ ) {
253
+ errors.push(`items[${i}].dependsOn must be an array of strings`)
254
+ }
255
+ if (typeof item.weight !== 'number' || !Number.isFinite(item.weight)) {
256
+ errors.push(`items[${i}].weight must be a number`)
257
+ }
258
+ })
259
+ }
260
+ if (!isRecord(rec.refs)) errors.push('refs must be an object')
261
+ else if (Object.values(rec.refs as Record<string, unknown>).some((v) => typeof v !== 'string')) {
262
+ errors.push('every refs value must be a string')
263
+ }
264
+ if (!isRecord(rec.basis)) errors.push('basis must be an object')
265
+ else {
266
+ for (const k of ['confirmed', 'emitted']) {
267
+ const v = (rec.basis as Record<string, unknown>)[k]
268
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
269
+ errors.push(`basis.${k} must be a number`)
270
+ }
271
+ }
272
+ const order = (rec.basis as Record<string, unknown>).order
273
+ if (!Array.isArray(order) || order.some((o) => typeof o !== 'string')) {
274
+ errors.push('basis.order must be an array of strings')
275
+ }
276
+ }
277
+ if (errors.length > 0) return { ok: false, errors }
278
+ return { ok: true, value: rec as unknown as HandoffFile }
279
+ }
280
+
281
+ export type LoadedHandoff =
282
+ | { kind: 'absent' }
283
+ | { kind: 'ok'; value: HandoffFile }
284
+ | { kind: 'invalid'; errors: string[] }
285
+
286
+ // Never throws. A file that cannot be read, parsed or validated comes back as
287
+ // `invalid` with the reason, so a caller can report it as a violation instead
288
+ // of dying on it.
289
+ export async function loadHandoff(root: string): Promise<LoadedHandoff> {
290
+ const path = storePaths(root).handoff
291
+ if (!existsSync(path)) return { kind: 'absent' }
292
+ let raw: unknown
293
+ try {
294
+ raw = await readJsonFile(path)
295
+ } catch (e) {
296
+ return { kind: 'invalid', errors: [(e as Error).message] }
297
+ }
298
+ const result = validateHandoff(raw)
299
+ return result.ok
300
+ ? { kind: 'ok', value: result.value }
301
+ : { kind: 'invalid', errors: result.errors }
302
+ }
303
+
304
+ // Written with sorted `refs` keys and a fixed field order, so two apply() runs
305
+ // over one store produce identical bytes. There are deliberately no timestamps
306
+ // anywhere in this file: every date this tool reports is read at read time,
307
+ // from the adapter's medium, which is also what keeps the file testable
308
+ // against a golden.
309
+ export async function saveHandoff(
310
+ root: string,
311
+ file: HandoffFile,
312
+ sourcePath: string,
313
+ ): Promise<void> {
314
+ const refs: Record<string, string> = {}
315
+ for (const key of Object.keys(file.refs).sort()) {
316
+ refs[key] = file.refs[key] ?? ''
317
+ }
318
+ const stable: HandoffFile = {
319
+ version: 1,
320
+ adapter: file.adapter,
321
+ items: file.items,
322
+ refs,
323
+ basis: file.basis,
324
+ }
325
+ await writeAtomically(
326
+ storePaths(root).handoff,
327
+ `${JSON.stringify(stable, null, 2)}\n`,
328
+ sourcePath,
329
+ )
330
+ }
@@ -17,6 +17,8 @@ export type StorePaths = {
17
17
  seamJson: string
18
18
  seamMd: string
19
19
  parityBasis: string
20
+ handoff: string
21
+ forecastAssumptions: string
20
22
  env: string
21
23
  }
22
24
 
@@ -35,6 +37,8 @@ export function storePaths(root: string): StorePaths {
35
37
  seamJson: join(dir, 'seam.json'),
36
38
  seamMd: join(dir, 'seam.md'),
37
39
  parityBasis: join(dir, 'parity-basis.md'),
40
+ handoff: join(dir, 'handoff.json'),
41
+ forecastAssumptions: join(dir, 'forecast-assumptions.md'),
38
42
  env: join(dir, '.env'),
39
43
  }
40
44
  }
@@ -135,3 +135,46 @@ export type QueueItem = {
135
135
  }
136
136
 
137
137
  export type Violation = { gate: string; message: string }
138
+
139
+ // A unit of dependency-ordered work handed to a delivery medium. One work item
140
+ // is one capability, uniformly across every adapter, because `dependsOn` is
141
+ // only meaningful at that granularity: the store holds no per-requirement
142
+ // edges of any kind. An adapter is free to expand one item into several
143
+ // artifacts, and the github adapter deliberately does, into a milestone plus
144
+ // an issue per requirement.
145
+ export type WorkItem = {
146
+ // Stable across runs, so apply() is idempotent. The capability slug.
147
+ key: string
148
+ title: string
149
+ body: string
150
+ frs: string[]
151
+ dependsOn: string[]
152
+ weight: number
153
+ }
154
+
155
+ // What apply() did, keyed by work-item key. `refs` maps a key to whatever
156
+ // external identity the adapter minted (an issue number, a file path), and it
157
+ // is what makes a second apply() an update rather than a duplicate.
158
+ export type ApplyResult = {
159
+ created: string[]
160
+ updated: string[]
161
+ unchanged: string[]
162
+ refs: Record<string, string>
163
+ }
164
+
165
+ // `doneAt` is nullable because not every medium dates a completion. The github
166
+ // adapter reads closedAt and always has one; the flow adapter reads coverage
167
+ // from the target's own parity command, which reports which requirements are
168
+ // covered and not when. Coverage counts an undated completion as built and
169
+ // says how many were undated; forecast excludes them from its rate.
170
+ export type Completion = { fr: string; doneAt: string | null }
171
+
172
+ // The completions plus a sentence naming where they came from. The basis
173
+ // travels with the data rather than being reconstructed by the caller, so a
174
+ // figure derived from it can always print its own provenance.
175
+ export type Throughput = { completions: Completion[]; basis: string }
176
+
177
+ // A measured or attested number that may not exist. A null value propagates
178
+ // through every figure derived from it, and those figures print as omitted
179
+ // rather than as a guess.
180
+ export type Rate = { value: number | null; basis: string }
@@ -130,6 +130,18 @@ export function validateRequirement(row: unknown, _cfg: Config): Validated<Requi
130
130
  const where = `requirement ${id || '<no id>'}`
131
131
 
132
132
  if (!id) errors.push('requirement: missing id')
133
+ // Elements have had an id grammar since Milestone 1; requirements were
134
+ // checked only for presence, so `migrate import reqs` accepted "BI 001" and
135
+ // "BI-002 (draft)" at exit 0. A requirement id travels into places that
136
+ // cannot represent whitespace: the markdown roadmap keys its checkbox lines
137
+ // on a single whitespace-delimited token, so an id with a space in it
138
+ // silently reverted the owner's ticked box on the next handoff, and the flow
139
+ // target rejects anything outside `<ns>-NNN` outright.
140
+ else if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)) {
141
+ errors.push(
142
+ `${where}: id must be letters, digits, dot, underscore or hyphen with no whitespace, since it is used as a key in emitted work items`,
143
+ )
144
+ }
133
145
  if (!str(r.cap) || r.cap.length === 0) errors.push(`${where}: missing cap`)
134
146
  if (!str(r.requirement) || r.requirement.length === 0)
135
147
  errors.push(`${where}: missing requirement text`)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "migrate",
3
- "version": "0.2.0",
4
- "description": "Source-agnostic legacy migration mapping. Walks a legacy codebase through probe, enumerate, seam, extract, parity, and queue, building an auditable requirements ledger with mandatory citations and a `migrate check` gate in place of self-reported completeness. Use when the user asks to migrate, re-specify, replatform, or map a legacy system onto a new stack, or to resume, check, or report on a mapping run already under way.",
3
+ "version": "0.3.0",
4
+ "description": "Source-agnostic legacy migration mapping. Walks a legacy codebase through probe, enumerate, seam, extract, parity, queue, adjudicate and handoff, building an auditable requirements ledger with mandatory citations and a `migrate check` gate in place of self-reported completeness. Use when the user asks to migrate, re-specify, replatform, or map a legacy system onto a new stack, to hand mapped requirements to a delivery team, or to resume, check, report, or forecast a mapping run already under way.",
5
5
  "author": "iceinvein",
6
6
  "type": "prompt",
7
7
  "tools": ["claude"],
@@ -0,0 +1,59 @@
1
+ ---
2
+ attestedBy: <owner-name>
3
+ attestedDate: <YYYY-MM-DD>
4
+ ---
5
+
6
+ # Forecast assumptions
7
+
8
+ Owner-attested judgment inputs for `migrate forecast`. Everything measured
9
+ lives in the store and in the adapter's throughput; everything here is a
10
+ decision. Re-attest (edit and commit) when a judgment changes.
11
+
12
+ Copy this file to `.migrate/forecast-assumptions.md` and fill it in. `migrate
13
+ forecast` refuses without it, because a projection nobody signed is exactly the
14
+ asserted number this method exists to refuse.
15
+
16
+ ## Territories
17
+
18
+ One row per capability that has confirmed requirements. A territory is a name
19
+ for how hard the ground is, and it is the unit of attested difficulty: a
20
+ campaign names a handful of them rather than a weight per requirement. Every
21
+ territory named here needs a multiplier row below.
22
+
23
+ | capability | territory |
24
+ | --- | --- |
25
+
26
+ ## Multipliers
27
+
28
+ How much one requirement in that territory costs relative to a baseline of 1.0.
29
+ Positive numbers only.
30
+
31
+ | territory | multiplier |
32
+ | --- | --- |
33
+ | established | 1.0 |
34
+
35
+ ## Scenarios
36
+
37
+ `rate` is one of three things, and the projection labels which:
38
+
39
+ - `as-is`, the measured velocity over calendar days since the first recorded
40
+ completion, quiet days included. The pessimistic measured base.
41
+ - `active`, the measured velocity over the days something actually completed.
42
+ The optimistic measured base.
43
+ - a positive number, requirements per day per stream, attested by the owner.
44
+ Nothing measures this, the projection labels it a target, and it carries no
45
+ uncertainty band.
46
+
47
+ `streams` is how many can run in parallel. `tax` is the coordination overhead,
48
+ in `[0, 1)`, taken off the combined rate. `note` says where the figure came
49
+ from; on a target row it is the only record of that.
50
+
51
+ | label | rate | streams | tax | note |
52
+ | --- | --- | --- | --- | --- |
53
+ | as-is | as-is | 1 | 0 | placeholder: re-attest before relying on projections |
54
+
55
+ ## Caveats
56
+
57
+ Printed verbatim under every projection.
58
+
59
+ - Skeleton values: attest real territories, multipliers and scenarios before use.