@iceinvein/agent-skills 0.3.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 (61) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/skills/index.json +2 -2
  4. package/skills/migrate/README.md +35 -23
  5. package/skills/migrate/SKILL.md +75 -15
  6. package/skills/migrate/bin/migrate.ts +90 -0
  7. package/skills/migrate/docs/architecture.md +61 -26
  8. package/skills/migrate/docs/reference.md +53 -8
  9. package/skills/migrate/fixtures/fake-gh.ts +113 -0
  10. package/skills/migrate/fixtures/flow-target/docs/WORK.md +12 -0
  11. package/skills/migrate/fixtures/flow-target/docs/modernisation/capability-map/.gitkeep +0 -0
  12. package/skills/migrate/fixtures/flow-target/tools/flow/src/cli.ts +156 -0
  13. package/skills/migrate/package.json +1 -1
  14. package/skills/migrate/references/phases/adjudicate.md +161 -0
  15. package/skills/migrate/references/phases/handoff.md +220 -0
  16. package/skills/migrate/references/phases/probe.md +2 -2
  17. package/skills/migrate/references/phases/queue.md +21 -14
  18. package/skills/migrate/references/run-ops.md +17 -13
  19. package/skills/migrate/scripts/__tests__/adapter-flow.test.ts +290 -0
  20. package/skills/migrate/scripts/__tests__/adapter-github.test.ts +232 -0
  21. package/skills/migrate/scripts/__tests__/adapter-markdown.test.ts +183 -0
  22. package/skills/migrate/scripts/__tests__/adjudicate.test.ts +332 -0
  23. package/skills/migrate/scripts/__tests__/assumptions.test.ts +179 -0
  24. package/skills/migrate/scripts/__tests__/coverage.test.ts +192 -0
  25. package/skills/migrate/scripts/__tests__/e2e-express.test.ts +167 -7
  26. package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +9 -4
  27. package/skills/migrate/scripts/__tests__/forecast.test.ts +280 -0
  28. package/skills/migrate/scripts/__tests__/gates-handoff.test.ts +309 -0
  29. package/skills/migrate/scripts/__tests__/handoff-cmd.test.ts +308 -0
  30. package/skills/migrate/scripts/__tests__/handoff-order.test.ts +156 -0
  31. package/skills/migrate/scripts/adapters/flow.ts +280 -0
  32. package/skills/migrate/scripts/adapters/github.ts +260 -0
  33. package/skills/migrate/scripts/adapters/markdown.ts +175 -0
  34. package/skills/migrate/scripts/adjudicate-cmd.ts +243 -0
  35. package/skills/migrate/scripts/assumptions.ts +188 -0
  36. package/skills/migrate/scripts/check.ts +119 -320
  37. package/skills/migrate/scripts/coverage-cmd.ts +86 -0
  38. package/skills/migrate/scripts/coverage.ts +151 -0
  39. package/skills/migrate/scripts/dates.ts +17 -0
  40. package/skills/migrate/scripts/forecast-cmd.ts +124 -0
  41. package/skills/migrate/scripts/forecast.ts +264 -0
  42. package/skills/migrate/scripts/gates/adjudication.ts +30 -0
  43. package/skills/migrate/scripts/gates/census.ts +107 -0
  44. package/skills/migrate/scripts/gates/citations.ts +11 -0
  45. package/skills/migrate/scripts/gates/context.ts +76 -0
  46. package/skills/migrate/scripts/gates/coverage.ts +22 -0
  47. package/skills/migrate/scripts/gates/deltas.ts +15 -0
  48. package/skills/migrate/scripts/gates/handoff.ts +145 -0
  49. package/skills/migrate/scripts/gates/leaks.ts +11 -0
  50. package/skills/migrate/scripts/gates/parity.ts +15 -0
  51. package/skills/migrate/scripts/gates/queue.ts +9 -0
  52. package/skills/migrate/scripts/gates/refs.ts +97 -0
  53. package/skills/migrate/scripts/gates/run-state.ts +67 -0
  54. package/skills/migrate/scripts/gates/source.ts +28 -0
  55. package/skills/migrate/scripts/handoff-cmd.ts +186 -0
  56. package/skills/migrate/scripts/handoff.ts +330 -0
  57. package/skills/migrate/scripts/paths.ts +4 -0
  58. package/skills/migrate/scripts/types.ts +43 -0
  59. package/skills/migrate/scripts/validate.ts +12 -0
  60. package/skills/migrate/skill.json +2 -2
  61. package/skills/migrate/templates/forecast-assumptions.md +59 -0
@@ -16,7 +16,7 @@ For how it is built and how to extend it, see [architecture.md](architecture.md)
16
16
  | `0` | Success |
17
17
  | `1` | A content or domain failure in a well-formed request. The request was serviceable and the answer is no: a gate found violations, a census does not balance, a queue file is unparseable. |
18
18
  | `2` | A malformed or unusable request. The command could not begin: a missing flag value, an unknown phase, a file that is absent or not valid JSON, no store above the cwd, a config that will not load. |
19
- | `3` | The store lock is unavailable: another process holds it, a holder looks stale, or the lock file has failed to parse across five consecutive reads, and force-unlocking was not requested. `import`, `census`, `phase --status`, and `reset` can return this; retry, or pass `--force-unlock` once you have confirmed no other agent is writing. |
19
+ | `3` | The store lock is unavailable: another process holds it, a holder looks stale, or the lock file has failed to parse across five consecutive reads, and force-unlocking was not requested. `import`, `census`, `phase --status`, `reset`, `adjudicate` and `handoff` can return this; retry, or pass `--force-unlock` once you have confirmed no other agent is writing. |
20
20
 
21
21
  The split matters because an orchestrating agent should be able to tell "your
22
22
  generator is broken" from "your numbers are wrong" from "try again" without
@@ -30,9 +30,10 @@ mirror them exactly so there is no serialization layer.
30
30
  resolves inside `source.path`, following symlinks and case-insensitive volumes,
31
31
  and exits 2.
32
32
 
33
- **The store lock.** `import`, `census`, `phase --status`, and `reset` each hold
34
- one lock (`.migrate/.lock`) across their read-modify-write of the store, so two
35
- agents writing at once cannot silently drop each other's rows. A waiting caller polls
33
+ **The store lock.** `import`, `census`, `phase --status`, `reset`, `adjudicate`
34
+ and `handoff` each hold one lock (`.migrate/.lock`) across their read-modify-write
35
+ of the store, so two agents writing at once cannot silently drop each other's
36
+ rows. A waiting caller polls
36
37
  with backoff for up to 30 seconds by default. A holder confirmed no longer
37
38
  running is reported as stale rather than waited out further; so is a lock file
38
39
  that fails to parse on five consecutive reads (a lock file that is merely
@@ -164,7 +165,7 @@ unsigned, which is what stops exclusions accreting silently.
164
165
 
165
166
  ### Capabilities
166
167
 
167
- `capabilities.jsonl` has **no import path in this milestone**. Write it
168
+ `capabilities.jsonl` has **no import path**. Write it
168
169
  directly, one JSON object per line:
169
170
 
170
171
  ```json
@@ -484,7 +485,7 @@ Recommend (c); usage suggests it is deprecated.
484
485
  containing `##` lines. An unclosed fence is a loud error.
485
486
  - BOM and CRLF are handled.
486
487
 
487
- ## The ten gates
488
+ ## The twelve gates
488
489
 
489
490
  `migrate check` reports violations grouped by gate, always in this order. Every
490
491
  message names the specific offending row, path or id; there is no aggregate
@@ -508,18 +509,26 @@ The summary line is always printed, passing or failing:
508
509
  | `leaks` | **Opt-in, `--leaks`.** No value from `.migrate/.env` appears in a committed artifact or anywhere in git history. Messages name the variable and file, never the value. |
509
510
  | `source` | The source checkout has no uncommitted changes, when it is a git repo. |
510
511
  | `run-state` | Every phase through the checked terminus must be `done` in `phases.json`; a phase `done` while its immediate predecessor is still `pending` fails regardless of terminus; a lens or closer census naming a `batch` that `phases.json` never recorded committing (in `enumerate` or `extract` respectively) fails by name. |
512
+ | `adjudication` | **Phase-scoped, from `--phase adjudicate` onward.** Every queue item is `adjudicated` and carries a non-empty ruling. Names each item that is not, with its severity. |
513
+ | `handoff` | **Phase-scoped, from `--phase handoff` onward.** `handoff.json` exists; every requirement appears in some work item's `frs`; every `frs` entry resolves to a requirement; every `dependsOn` resolves to another work item's key; `basis.emitted` and `basis.confirmed` match the store. |
511
514
 
512
515
  **`check` is strict mid-run by design.** The census gate wants a record for
513
516
  every declared surface and closer, so it does not pass until a run is finished.
514
517
  Grouping by gate is what lets you tell an expected mid-run gap from a real
515
518
  defect.
516
519
 
517
- **`--phase <p>` narrows only the run-state gate, not the other nine.** Without
520
+ **Two gates are phase-scoped; the other ten always run.**
521
+ `adjudication` and `handoff` describe phases 6 and 7, so they are skipped when
522
+ the checked terminus has not reached them. Without that, `migrate check --phase
523
+ queue` would be red for an entire mid-run campaign, which is exactly what the
524
+ posture split exists to prevent. No other gate has an entry in that map.
525
+
526
+ **`--phase <p>` otherwise narrows only the run-state gate.** Without
518
527
  `--phase`, `run-state` requires every phase through `handoff` to be `done`, so
519
528
  exit 0 means the whole migration is complete. With `--phase enumerate`, it
520
529
  requires only `probe` and `enumerate` to be `done`, which is the mid-run
521
530
  posture: a coverage or census gap past that point still fails on its own gate,
522
- exactly as it would without `--phase`, because those nine gates read the store,
531
+ exactly as it would without `--phase`, because those gates read the store,
523
532
  not the phase you named. Verified against a fresh store: `check --phase probe`
524
533
  reports one `run-state` violation (`probe`); plain `check` reports eight, one
525
534
  per phase.
@@ -588,3 +597,39 @@ is escaped so free text containing pipes or newlines cannot break a table.
588
597
  **`migrate status`**
589
598
  Read-only. Phase state, store counts, the gate summary line, and a resume
590
599
  pointer naming the first non-done phase and its last recorded batch.
600
+
601
+ **`migrate adjudicate [<id>] [--ruling <text>] [--force]`**
602
+ With no id, prints the review sheet: every queue item, severity first, each
603
+ carrying the first line of its recommendation, closing with an open count. With
604
+ an id and a ruling, rewrites that item's frontmatter: `status` becomes
605
+ `adjudicated`, `ruling` is set, `adjudicated` gets today's date. Keys the
606
+ command does not own keep their position and the body round-trips byte for
607
+ byte. Writes no row file: the ruling's consequence goes through `migrate
608
+ import`, and the command prints that next step. Refuses an unknown id at 2, an
609
+ unparseable file at 1, an already-ruled item at 1 without `--force` (printing
610
+ the existing ruling), and a ruling containing a line break at 2.
611
+
612
+ **`migrate handoff [--adapter <markdown|github|flow>] [--dry-run]`**
613
+ Emits one work item per capability, in dependency order, through the configured
614
+ adapter, and writes `.migrate/handoff.json`. Refuses while the gate (citations
615
+ and leaks both on, bounded at `adjudicate`) has violations, while any queue item
616
+ is open, or while any requirement is blocked by an open item, naming every
617
+ blocker at once. `--dry-run` prints the plan and writes nothing at all,
618
+ `handoff.json` included. Adapters are idempotent: a second run over an unchanged
619
+ store reports everything `unchanged`.
620
+
621
+ **`migrate coverage [--adapter <name>]`**
622
+ Built over **confirmed** requirements, read back through the adapter, with the
623
+ non-confirmed exclusions reported per capability and the evidence named. Exits 1
624
+ when `handoff.json` is absent (handoff has not run), when the adapter reports no
625
+ throughput (named, rather than reported as zero built), and when a completion
626
+ names a requirement the store does not have (the emitted work and the store have
627
+ diverged).
628
+
629
+ **`migrate forecast [--adapter <name>]`**
630
+ Projects remaining work from two measured velocities and an owner-attested
631
+ `.migrate/forecast-assumptions.md`, which is required: it refuses without one.
632
+ Each scenario is labelled measured (`as-is` or `active`, carrying an uncertainty
633
+ band) or an owner target (carrying none). Every figure whose input is unmeasured
634
+ prints as omitted rather than as a zero, and a finished campaign says so rather
635
+ than omitting every date.
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env bun
2
+ // A stateful stand-in for `gh`, covering exactly the six invocations the
3
+ // github adapter makes. Magpie's fake-gh.sh is a static case statement, which
4
+ // is right for a tool that only reads; this adapter creates issues and then
5
+ // has to find them again on the next run, so idempotency cannot be tested
6
+ // against a fixture that forgets what it was told.
7
+ //
8
+ // State lives at <cwd>/gh-state.json and the invocation log at
9
+ // <cwd>/gh-log.txt. Both are keyed off the working directory rather than off
10
+ // environment variables, because the adapter already runs gh with cwd set to
11
+ // the target root and a spawned child does not reliably pick up an env var a
12
+ // test set on its own process after startup.
13
+ //
14
+ // Nothing here validates arguments the way real gh does. It exists to record
15
+ // what the adapter asked for and to answer consistently, not to be a
16
+ // specification of gh.
17
+
18
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
19
+ import { join } from 'node:path'
20
+
21
+ type Milestone = { number: number; title: string; description: string }
22
+ type Issue = {
23
+ number: number
24
+ title: string
25
+ body: string
26
+ state: 'OPEN' | 'CLOSED'
27
+ closedAt: string | null
28
+ milestone: string | null
29
+ }
30
+ type State = { milestones: Milestone[]; issues: Issue[] }
31
+
32
+ const statePath = join(process.cwd(), 'gh-state.json')
33
+ const logPath = join(process.cwd(), 'gh-log.txt')
34
+ const argv = process.argv.slice(2)
35
+
36
+ appendFileSync(logPath, `${argv.join(' ')}\n`)
37
+
38
+ function load(): State {
39
+ if (!existsSync(statePath)) return { milestones: [], issues: [] }
40
+ return JSON.parse(readFileSync(statePath, 'utf8')) as State
41
+ }
42
+ function save(state: State): void {
43
+ writeFileSync(statePath, JSON.stringify(state, null, 2))
44
+ }
45
+ function flag(name: string): string | undefined {
46
+ const at = argv.indexOf(name)
47
+ return at === -1 ? undefined : argv[at + 1]
48
+ }
49
+ // `gh api -f key=value` repeated; returns the value for a given key.
50
+ function field(key: string): string | undefined {
51
+ for (let i = 0; i < argv.length; i++) {
52
+ if (argv[i] !== '-f') continue
53
+ const pair = argv[i + 1] ?? ''
54
+ if (pair.startsWith(`${key}=`)) return pair.slice(key.length + 1)
55
+ }
56
+ return undefined
57
+ }
58
+
59
+ const state = load()
60
+ const out = (v: unknown): void => {
61
+ process.stdout.write(`${typeof v === 'string' ? v : JSON.stringify(v)}\n`)
62
+ }
63
+
64
+ if (argv[0] === 'repo' && argv[1] === 'view') {
65
+ out({ nameWithOwner: 'acme/target' })
66
+ } else if (argv[0] === 'api' && (argv[2] === '-X' ? argv[3] : '') === 'POST') {
67
+ const title = field('title') ?? ''
68
+ const description = field('description') ?? ''
69
+ const number = state.milestones.length + 1
70
+ state.milestones.push({ number, title, description })
71
+ save(state)
72
+ out({ number, title, description })
73
+ } else if (argv[0] === 'api') {
74
+ out(state.milestones)
75
+ } else if (argv[0] === 'issue' && argv[1] === 'list') {
76
+ out(
77
+ state.issues.map((i) => ({
78
+ number: i.number,
79
+ title: i.title,
80
+ body: i.body,
81
+ state: i.state,
82
+ closedAt: i.closedAt,
83
+ })),
84
+ )
85
+ } else if (argv[0] === 'issue' && argv[1] === 'create') {
86
+ const number = 100 + state.issues.length
87
+ state.issues.push({
88
+ number,
89
+ title: flag('--title') ?? '',
90
+ body: flag('--body') ?? '',
91
+ state: 'OPEN',
92
+ closedAt: null,
93
+ milestone: flag('--milestone') ?? null,
94
+ })
95
+ save(state)
96
+ out(`https://github.com/acme/target/issues/${number}`)
97
+ } else if (argv[0] === 'issue' && argv[1] === 'edit') {
98
+ const number = Number(argv[2])
99
+ const issue = state.issues.find((i) => i.number === number)
100
+ if (!issue) {
101
+ process.stderr.write(`no issue ${number}\n`)
102
+ process.exit(1)
103
+ }
104
+ const body = flag('--body')
105
+ if (body !== undefined) issue.body = body
106
+ const milestone = flag('--milestone')
107
+ if (milestone !== undefined) issue.milestone = milestone
108
+ save(state)
109
+ out(`https://github.com/acme/target/issues/${number}`)
110
+ } else {
111
+ process.stderr.write(`fake-gh: unhandled invocation: ${argv.join(' ')}\n`)
112
+ process.exit(1)
113
+ }
@@ -0,0 +1,12 @@
1
+ # Work
2
+
3
+ The delivery ledger for this target. `migrate handoff --adapter flow` appends
4
+ capabilities under `## Proposed`; everything else here belongs to the team.
5
+
6
+ ## In progress
7
+
8
+ - [W01] Wire the deployment pipeline
9
+
10
+ ## Done
11
+
12
+ - [W00] Repository scaffolding
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env bun
2
+ // A faithful subset of the flow target's own capability parser, standing in
3
+ // for `tools/flow/src/cli.ts` in a real Nexus `stack` target.
4
+ //
5
+ // This is NOT the target. It is a vendored subset of the rules in
6
+ // quartex/Nexus at c2464ac, plugins/stack/templates/tools/flow/src/
7
+ // capability.ts, reproduced so the migrate flow adapter has something to be
8
+ // checked against. Every rule below cites the line it came from, and the
9
+ // conformance test in scripts/__tests__/adapter-flow.test.ts asserts each one
10
+ // independently, so a rule drifting in the real target shows up as a test that
11
+ // no longer describes reality rather than as silence.
12
+ //
13
+ // Commands: `map` (no-op regeneration), `map --check` (validate every
14
+ // capability file), `parity --json` (emit CapabilityCoverage[]).
15
+
16
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+
19
+ const CAP_STATUSES = ['done', 'partial', 'minimal', 'todo'] // capability.ts:3
20
+ const CONFIDENCES = ['Confirmed', 'Inferred', 'Speculative'] // capability.ts:6
21
+ const ORIGINS = ['intended', 'poss-accidental', 'cruft'] // capability.ts:9
22
+ const REQUIRED_FRONTMATTER = ['cap', 'ns', 'title', 'status'] // capability.ts:43
23
+ const REQUIRED_SECTIONS = ['Functional requirements', 'Built', 'Remaining'] // capability.ts:44
24
+
25
+ const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
26
+ // capability.ts:42
27
+ const frIdPattern = (ns: string): RegExp => new RegExp(`^${escapeRegex(ns)}-\\d{3}$`)
28
+
29
+ type Fr = { id: string; conf: string; origin: string }
30
+ type Capability = { cap: string; ns: string; title: string; status: string; frs: Fr[]; built: string }
31
+
32
+ function parseFrontmatter(raw: string): { data: Record<string, string>; body: string } {
33
+ const lines = raw.split('\n')
34
+ if (lines[0]?.trim() !== '---') return { data: {}, body: raw }
35
+ const close = lines.findIndex((l, i) => i > 0 && l.trim() === '---')
36
+ if (close === -1) return { data: {}, body: raw }
37
+ const data: Record<string, string> = {}
38
+ for (const line of lines.slice(1, close)) {
39
+ const at = line.indexOf(':')
40
+ if (at === -1) continue
41
+ data[line.slice(0, at).trim()] = line.slice(at + 1).trim()
42
+ }
43
+ return { data, body: lines.slice(close + 1).join('\n') }
44
+ }
45
+
46
+ // capability.ts:48. Sections are split on a line beginning '## ', which is why
47
+ // an emitter must never put '## ' at the start of a line inside a table.
48
+ function extractSections(body: string): Record<string, string> {
49
+ const sections: Record<string, string> = {}
50
+ const parts = body.split(/\n## /).map((p, i) => (i === 0 ? p : `## ${p}`))
51
+ for (const part of parts) {
52
+ const m = part.match(/^## (.+)\n/)
53
+ if (!m?.[1]) continue
54
+ sections[m[1].trim()] = part.slice(m[0].length).trim()
55
+ }
56
+ return sections
57
+ }
58
+
59
+ function parseFrTable(block: string, path: string, ns: string): Fr[] {
60
+ const frId = frIdPattern(ns)
61
+ const lines = block
62
+ .split('\n')
63
+ .map((l) => l.trim())
64
+ .filter((l) => l.startsWith('|'))
65
+ const seen = new Set<string>()
66
+ return lines.slice(2).map((line) => {
67
+ const cells = line
68
+ .split('|')
69
+ .slice(1, -1)
70
+ .map((c) => c.trim())
71
+ const [id, , , , , conf, origin] = cells
72
+ if (cells.length !== 7 || !id) throw new Error(`malformed FR row: ${id ?? line} in ${path}`) // capability.ts:74
73
+ if (!frId.test(id)) throw new Error(`invalid FR id: ${id} in ${path}`)
74
+ if (seen.has(id)) throw new Error(`duplicate FR id: ${id} in ${path}`)
75
+ seen.add(id)
76
+ if (!CONFIDENCES.includes(conf ?? '')) throw new Error(`invalid confidence: ${conf} in ${path}`)
77
+ if (!ORIGINS.includes(origin ?? '')) throw new Error(`invalid origin: ${origin} in ${path}`)
78
+ return { id, conf: conf ?? '', origin: origin ?? '' }
79
+ })
80
+ }
81
+
82
+ export function parseCapability(raw: string, path: string): Capability {
83
+ const { data, body } = parseFrontmatter(raw)
84
+ for (const field of REQUIRED_FRONTMATTER) {
85
+ if (!data[field]) throw new Error(`missing field: ${field} in ${path}`)
86
+ }
87
+ if (!CAP_STATUSES.includes(data.status ?? '')) {
88
+ throw new Error(`invalid status: ${data.status} in ${path}`)
89
+ }
90
+ const sections = extractSections(body)
91
+ for (const name of REQUIRED_SECTIONS) {
92
+ if (sections[name] === undefined) throw new Error(`missing section: ${name} in ${path}`)
93
+ }
94
+ return {
95
+ cap: data.cap ?? '',
96
+ ns: data.ns ?? '',
97
+ title: data.title ?? '',
98
+ status: data.status ?? '',
99
+ built: sections.Built ?? '',
100
+ frs: parseFrTable(sections['Functional requirements'] ?? '', path, data.ns ?? ''),
101
+ }
102
+ }
103
+
104
+ function loadAll(cwd: string): Capability[] {
105
+ const dir = join(cwd, 'docs/modernisation/capability-map')
106
+ if (!existsSync(dir)) return []
107
+ return readdirSync(dir)
108
+ .filter((n) => n.endsWith('.md') && n !== 'pre-ledger-baseline.md')
109
+ .sort()
110
+ .map((n) => parseCapability(readFileSync(join(dir, n), 'utf8'), n))
111
+ }
112
+
113
+ const argv = process.argv.slice(2)
114
+ const cwd = process.cwd()
115
+
116
+ try {
117
+ if (argv[0] === 'map' && argv[1] === '--check') {
118
+ loadAll(cwd)
119
+ process.stdout.write('map: ok\n')
120
+ } else if (argv[0] === 'map') {
121
+ loadAll(cwd)
122
+ process.stdout.write('map: ok (regenerated)\n')
123
+ } else if (argv[0] === 'parity' && argv[1] === '--json') {
124
+ // The real computeParity (parity.ts:17) derives covered ids from merged
125
+ // slices plus a baseline, which needs a slice ledger this fixture has no
126
+ // reason to carry. The emitted shape is the real one; the derivation reads
127
+ // the `## Built` section instead, so a test can drive coverage.
128
+ const caps = loadAll(cwd)
129
+ process.stdout.write(
130
+ `${JSON.stringify(
131
+ caps.map((c) => {
132
+ const built = new Set(c.built.split(/[\s,]+/).filter((t) => t.length > 0))
133
+ const confirmed = c.frs.filter((f) => f.conf === 'Confirmed')
134
+ return {
135
+ cap: c.cap,
136
+ ns: c.ns,
137
+ title: c.title,
138
+ status: c.status,
139
+ confirmedTotal: confirmed.length,
140
+ covered: confirmed.filter((f) => built.has(f.id)).length,
141
+ coveredIds: confirmed.filter((f) => built.has(f.id)).map((f) => f.id),
142
+ uncoveredIds: confirmed.filter((f) => !built.has(f.id)).map((f) => f.id),
143
+ }
144
+ }),
145
+ null,
146
+ 2,
147
+ )}\n`,
148
+ )
149
+ } else {
150
+ process.stderr.write(`flow: unhandled command: ${argv.join(' ')}\n`)
151
+ process.exit(1)
152
+ }
153
+ } catch (e) {
154
+ process.stderr.write(`${(e as Error).message}\n`)
155
+ process.exit(1)
156
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "migrate",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,161 @@
1
+ # Phase 6: Adjudicate
2
+
3
+ ## Purpose
4
+
5
+ Get an owner's decision on every open queue item, in one pass, and record
6
+ each ruling where the gate reads it. Exit condition: every item in
7
+ `.migrate/queue/` has `status: adjudicated` with a non-empty `ruling`, every
8
+ downstream consequence of those rulings has been imported into the store,
9
+ and `migrate phase adjudicate --status done` has run.
10
+
11
+ This is the phase where the run stops being a mapping exercise. Nothing
12
+ before it needed a human to decide anything; nothing after it can proceed
13
+ until they have.
14
+
15
+ ## Inputs
16
+
17
+ - `.migrate/queue/*.md`, everything filed by any earlier phase. Phase 5
18
+ made sure they are well-formed; this phase gets them decided.
19
+ - The store, because most rulings have a consequence in it: an element's
20
+ `disposition`, a requirement's `confidence`, a requirement's `parity`.
21
+ - The owner. This is the one phase whose input is not in the repository.
22
+
23
+ ## Procedure
24
+
25
+ **Read the whole list before ruling on anything.** `migrate adjudicate` with
26
+ no arguments prints the review sheet: every item, severity first, with the
27
+ first line of its recommendation alongside. That last part is what makes one
28
+ sitting possible, and it is the reason to start here rather than opening
29
+ files:
30
+
31
+ ```
32
+ migrate adjudicate
33
+
34
+ q-mailer-unobservable [moderate] open - Recommend (b): nothing in the checkout shows this ever delivered mail.
35
+
36
+ 1 open
37
+ ```
38
+
39
+ Items are ordered `critical`, `moderate`, `minor`, then by id, the same
40
+ order `queue list` uses. Work down it.
41
+
42
+ **Draft a ruling for each, then present them together.** The agent's job is
43
+ to draft, not to decide. A drafted ruling that the owner accepts unchanged
44
+ is a good outcome; a drafted ruling nobody read is the failure this phase
45
+ exists to prevent. Present the whole set at once so the owner sees the
46
+ shape of what they are agreeing to, rather than being walked through four
47
+ separate decisions with no view of how they interact.
48
+
49
+ **Record each approved ruling.** One command per item:
50
+
51
+ ```
52
+ migrate adjudicate q-mailer-unobservable \
53
+ --ruling "out of scope until an operator confirms delivery ever worked"
54
+
55
+ adjudicate: q-mailer-unobservable
56
+ status open -> adjudicated
57
+ ruling recorded
58
+ next: apply the consequence with `migrate import`
59
+ ```
60
+
61
+ The file's frontmatter afterwards, with the body untouched:
62
+
63
+ ```
64
+ ---
65
+ id: q-mailer-unobservable
66
+ severity: moderate
67
+ status: adjudicated
68
+ ruling: out of scope until an operator confirms delivery ever worked
69
+ adjudicated: 2026-08-13
70
+ ---
71
+ ```
72
+
73
+ Three things about that write are worth knowing before you rely on them.
74
+ Keys the command does not own keep their position, so an item carrying its
75
+ own extra frontmatter is not reordered. The body round-trips byte for byte,
76
+ because it is the audit record of *why* the ruling was made and a rewrite
77
+ that reflows it destroys the thing being audited. And the ruling is a
78
+ single frontmatter line, so a ruling containing a newline is refused at
79
+ exit 2 rather than written into a block it would corrupt. Keep rulings to
80
+ one sentence; the reasoning belongs in the body, which already has it.
81
+
82
+ **The verb does not touch the row files.** This is the part most likely to
83
+ catch you out. `adjudicate` writes queue frontmatter and nothing else. A
84
+ ruling that puts an element out of scope, or settles a requirement's
85
+ confidence, or fixes a parity plan, is applied by `migrate import`, exactly
86
+ as in phases 3 and 4. That is why the command prints `next: apply the
87
+ consequence with 'migrate import'` on every success, and why this phase is
88
+ not finished when the last item flips to `adjudicated`.
89
+
90
+ Worked through: the ruling above says the mailer requirement is out of
91
+ scope until someone confirms delivery. `UD-003` currently carries
92
+ `confidence: {"kind": "queued", "queue": "q-mailer-unobservable"}`. The
93
+ ruling does not change that by itself. If the decision is to leave the
94
+ requirement recorded but unconfirmed, nothing further is needed and it
95
+ simply stays outside the confirmed denominator that `migrate coverage`
96
+ divides by. If the decision is that it is now settled, re-import the row
97
+ with the confidence the ruling gives it.
98
+
99
+ **Re-ruling refuses.** An item that already carries a ruling is not
100
+ silently overwritten:
101
+
102
+ ```
103
+ migrate adjudicate q-mailer-unobservable --ruling "changed my mind"
104
+
105
+ adjudicate: q-mailer-unobservable is already adjudicated: out of scope until an operator confirms delivery ever worked
106
+ adjudicate: pass --force to replace it
107
+ ```
108
+
109
+ Exit 1, and the existing ruling is printed so you can see what `--force`
110
+ would have replaced. An owner's recorded decision is not something a re-run
111
+ that meant no harm should be able to discard.
112
+
113
+ ## The gate
114
+
115
+ Gate 11, `adjudication`, checks exactly one thing: every queue item is
116
+ `adjudicated` and carries a non-empty ruling. It names each item that is
117
+ not, with its severity.
118
+
119
+ The gate is phase-scoped: it does not fire when the checked terminus is below
120
+ `adjudicate`, so `migrate check --phase queue` stays clean for a run that has
121
+ not reached this phase yet. From `migrate check --phase adjudicate` onward it
122
+ applies, and it also applies at any terminus once `phases.json` marks the
123
+ adjudicate phase done, so a store cannot claim the phase and then be checked
124
+ below it.
125
+
126
+ An open item on a store that is otherwise complete reads:
127
+
128
+ ```
129
+ adjudication:
130
+ q-mailer-unobservable [moderate] is still open; every queue item needs a ruling before handoff
131
+ ```
132
+
133
+ ## Degradation
134
+
135
+ - **The owner is unavailable.** Do not rule on their behalf and do not mark
136
+ the phase done. There is no partial-credit state here: an unruled item
137
+ blocks handoff, which is the correct outcome, because handing a build team
138
+ work whose open questions nobody answered is what this gate exists to
139
+ stop. Leave the phase `running` and say so in the handoff notes.
140
+ - **An item turns out to need no decision.** It still needs a ruling. Record
141
+ the reason it needed nothing (`no action: the endpoint was removed before
142
+ this run started`); the gate wants a decision recorded, not a change made.
143
+ - **A ruling that changes an earlier phase's output.** Apply it with the
144
+ same `migrate import` that phase used, then re-run `migrate check --phase
145
+ adjudicate`. Re-importing is an upsert by id, so it updates rather than
146
+ duplicating.
147
+ - **A queue file that will not parse.** `adjudicate <id>` on it exits 1 and
148
+ names the grammar failure; fix the file with phase 5's grammar and try
149
+ again. An id with no file at all is exit 2, a different class: the request
150
+ never resolved to an item.
151
+
152
+ ## Commands
153
+
154
+ ```
155
+ migrate adjudicate
156
+ migrate adjudicate <id> --ruling "<text>"
157
+ migrate adjudicate <id> --ruling "<text>" --force
158
+ migrate import <elements|reqs|deltas> <batch.json>
159
+ migrate check --phase adjudicate
160
+ migrate phase adjudicate --status done
161
+ ```