@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,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.
|
|
4
|
-
"description": "Source-agnostic legacy migration mapping. Walks a legacy codebase through probe, enumerate, seam, extract, parity, and
|
|
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.
|