@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
@@ -0,0 +1,220 @@
1
+ # Phase 7: Handoff
2
+
3
+ ## Purpose
4
+
5
+ Emit the mapped requirements into whatever a delivery team actually works
6
+ from, in dependency order, and record what was emitted so progress can be
7
+ read back. Exit condition: `.migrate/handoff.json` exists and accounts for
8
+ every requirement, the adapter's artifacts are in the target, and `migrate
9
+ phase handoff --status done` has run. After that, plain `migrate check`,
10
+ with no `--phase`, exits 0, and that is what "the migration is mapped"
11
+ means.
12
+
13
+ This phase does not deliver anything. It hands over.
14
+
15
+ ## Inputs
16
+
17
+ - `.migrate/requirements.jsonl` and `capabilities.jsonl`: what to emit and
18
+ how to group it.
19
+ - `.migrate/config.toml`, `[handoff] adapter`: which medium. Overridable
20
+ per run with `--adapter`.
21
+ - Whatever the chosen adapter reaches: the target repo for `markdown`,
22
+ `gh` for `github`, a flow target's own tree for `flow`.
23
+
24
+ ## Procedure
25
+
26
+ **Choose the adapter once, in config, not per invocation.** `--adapter`
27
+ exists for trying one out and for reading coverage back through a different
28
+ medium than the one that emitted. Switching the configured adapter mid-run
29
+ does not migrate anything that was already emitted; it emits again,
30
+ somewhere else.
31
+
32
+ | Adapter | Emits | Reads progress from |
33
+ |---|---|---|
34
+ | `markdown` | `docs/migrate/roadmap.md` plus one file per capability | ticked checkboxes in the roadmap, dated in the file |
35
+ | `github` | a milestone per capability, an issue per requirement | closed issues, dated from `closedAt` |
36
+ | `flow` | `docs/modernisation/capability-map/<slug>.md`, and a fenced block under `## Proposed` in `docs/WORK.md` when the target has one | `flow parity --json` in the target, undated |
37
+
38
+ **Dry-run first.** `--dry-run` runs every refusal check and prints the plan
39
+ without writing anything at all, `handoff.json` included:
40
+
41
+ ```
42
+ migrate handoff --dry-run
43
+
44
+ plan:
45
+ user-directory (3 requirement(s))
46
+ handoff: dry run, 1 work item(s), nothing written
47
+ ```
48
+
49
+ A capability appears after every capability it cites, which is what
50
+ "dependency order" means here: capability A depends on B when a requirement
51
+ in A carries a ledger citation to an element the seam assigned to B. If the
52
+ graph has a cycle, its members are emitted in slug order and anything merely
53
+ blocked by that cycle still sorts normally behind it. The cycle is broken rather
54
+ than reported: `dependencyOrder` returns which capabilities were in one, but no
55
+ caller prints it today, so a cyclic seam is resolved silently.
56
+
57
+ **The refusals, and what each means.** Handoff will not emit while anything
58
+ is unresolved, and it names every blocker at once rather than one per run:
59
+
60
+ ```
61
+ migrate handoff
62
+
63
+ handoff: [run-state] phase adjudicate is pending; every phase through adjudicate must be done
64
+ handoff: [adjudication] q-mailer-unobservable [moderate] is still open; every queue item needs a ruling before handoff
65
+ handoff: UD-003 blocked by q-mailer-unobservable
66
+ handoff: refusing to emit with 3 blocker(s)
67
+ ```
68
+
69
+ The gate run behind this has citations and leaks both switched on, because
70
+ both are mandatory before handoff, and is bounded at `adjudicate` so that
71
+ gate 12 (which wants the `handoff.json` this command has not written yet)
72
+ cannot refuse the very run that would satisfy it.
73
+
74
+ **"Blocked" is measured against open items, not against the confidence
75
+ field.** A requirement blocks handoff when its `confidence` is `queued`, or
76
+ its `parity` is a `rubric` below `high`, **and** the queue item it points at
77
+ is still open. Once that item is adjudicated the decision is settled and the
78
+ requirement stops blocking, even though its confidence still reads `queued`.
79
+ This matters in practice: it means a ruling of "leave this one unconfirmed"
80
+ does not oblige you to re-import the row before you can hand over.
81
+
82
+ **Emit.**
83
+
84
+ ```
85
+ migrate handoff
86
+
87
+ handoff: adapter markdown, 1 work item(s), 3 requirement(s)
88
+ created 1
89
+ updated 0
90
+ unchanged 0
91
+ next: mark the phase done with `migrate phase handoff --status done`, then read progress back with `migrate coverage`
92
+ ```
93
+
94
+ Running it again over an unchanged store reports everything `unchanged` and
95
+ writes nothing new. Every adapter is idempotent, and each achieves it
96
+ differently: `markdown` compares rendered content, `github` finds its own
97
+ issues by a `<!-- migrate:fr=... -->` marker at the start of the body, `flow`
98
+ compares rendered content and then validates the result with the target's own
99
+ `flow map --check`. A run that rewrote a shared file (the roadmap, `WORK.md`)
100
+ reports its items `updated` rather than `unchanged`, so the status always
101
+ reflects whether anything in the target moved.
102
+
103
+ Each adapter owns only what it wrote. `github` regenerates the block above a
104
+ closing fence in an issue body and leaves anything you add beneath it alone;
105
+ `flow` rewrites only its own fenced block under `## Proposed` and never touches
106
+ the rest of `WORK.md`. Editing a file somebody else writes in is the reason
107
+ both boundaries are explicit rather than inferred from shape.
108
+
109
+ **`handoff.json` is the record, and it carries no timestamps.**
110
+
111
+ ```jsonc
112
+ {
113
+ "version": 1,
114
+ "adapter": "markdown",
115
+ "items": [
116
+ { "key": "user-directory", "title": "User Directory",
117
+ "frs": ["UD-001", "UD-002", "UD-003"], "dependsOn": [], "weight": 3 }
118
+ ],
119
+ "refs": { "user-directory": "docs/migrate/capabilities/user-directory.md" },
120
+ "basis": { "confirmed": 2, "emitted": 3, "order": ["user-directory"] }
121
+ }
122
+ ```
123
+
124
+ `emitted` is every requirement that reached a work item; `confirmed` is the
125
+ denominator `migrate coverage` divides by, and the two differ by exactly the
126
+ requirements handoff emits but parity does not hold the build to. The
127
+ absence of dates is deliberate: every date this tool reports is read at read
128
+ time from the adapter's medium, which is what lets two runs over one store
129
+ produce identical bytes.
130
+
131
+ **Using the `flow` adapter.** One constraint will catch you before anything
132
+ else does. The flow target derives a requirement-id pattern from each
133
+ capability's declared `ns` and rejects anything that does not match, so
134
+ `plan()` refuses first, by name:
135
+
136
+ ```
137
+ handoff: flow: 1 requirement id(s) do not match their capability's namespace pattern <ns>-NNN, which the flow target requires:
138
+ login-001 (capability user-management, ns UM)
139
+ ```
140
+
141
+ Nothing is written when that fires. Fix the ids in the store (re-import with
142
+ the corrected ids) rather than working around it: the alternative is a
143
+ capability file the target cannot parse, in a repo this tool does not own.
144
+ The adapter also translates on the way out, since the two vocabularies
145
+ differ: `accidental-candidate` becomes `poss-accidental`, and the three
146
+ confidence kinds become `Confirmed`, `Inferred` and `Speculative`.
147
+
148
+ ## The gate
149
+
150
+ Gate 12, `handoff`, asks whether the requirements actually reached the
151
+ emitted work. It checks that `handoff.json` exists and is well formed, that
152
+ every requirement appears in exactly one work item, that every `frs` entry
153
+ resolves to a requirement, that every `dependsOn` resolves to another work item
154
+ and is not the item itself, that no work-item key repeats, that every item has
155
+ a `refs` entry recording where it went, that `basis.order` and the work items
156
+ name the same set, and that the basis counts match the store.
157
+
158
+ Every requirement, not only the confirmed ones: an inferred requirement is
159
+ something the build team must see and decide about, so handoff emits it.
160
+ Confidence starts mattering at the coverage denominator, not here.
161
+
162
+ Like gate 11 it is phase-scoped, so it does not fire below `--phase handoff`
163
+ **unless `phases.json` already claims the phase is done**. A store whose own
164
+ state file says it reached handoff cannot hide an unemitted handoff by being
165
+ checked at an earlier terminus. Its honest limit is the same in kind as the run-state gate's: it
166
+ proves the emitted work covers the store's requirements. It cannot prove the
167
+ issues were read or the roadmap was believed.
168
+
169
+ ## Reading progress back
170
+
171
+ `migrate coverage` divides built by confirmed and names its evidence:
172
+
173
+ ```
174
+ built 2/2 confirmed requirements (100%)
175
+ evidence: markdown roadmap checkboxes, dated in file
176
+ excluded: 1 non-confirmed (user-directory 1)
177
+
178
+ user-directory 2/2 done
179
+ ```
180
+
181
+ `migrate forecast` needs an owner-attested `.migrate/forecast-assumptions.md`
182
+ (copy `templates/forecast-assumptions.md`) and refuses without one. It
183
+ projects from two measured velocities and labels every scenario as measured
184
+ or as an owner target, so an aspiration never reads as a fact. Both commands
185
+ are meant to be re-run as delivery proceeds; they are the only part of this
186
+ tool that keeps working after the mapping run ends.
187
+
188
+ ## Degradation
189
+
190
+ - **The adapter partially applied.** Re-run it. Every adapter is
191
+ idempotent, and finishing a partial apply by re-running is the designed
192
+ path. For `markdown` specifically, re-running preserves every ticked box
193
+ and its date, so picking up newly extracted requirements never costs the
194
+ owner their record of what was delivered.
195
+ - **No flow CLI in the target.** The `flow` adapter still emits, and says on
196
+ stderr that the capability files were not validated against the target's
197
+ own parser. An unvalidated emission must not read as a checked one.
198
+ - **`flow` coverage has no dates.** Expected. The flow target computes
199
+ covered from merged slices plus a baseline and keeps the dates in a slice
200
+ ledger this tool does not own, so every completion comes back undated.
201
+ Coverage still works; forecast's measured rows print `not projected` and
202
+ its target rows still project. Use `flow forecast` in the target for the
203
+ dated answer.
204
+ - **An adapter with no throughput at all.** `coverage` names it rather than
205
+ reporting zero built. "This adapter cannot tell you" and "nothing has been
206
+ delivered" are very different claims.
207
+ - **A completion naming a requirement the store does not have.** `coverage`
208
+ exits 1: the emitted work and the store have diverged, which is a real
209
+ problem rather than a degradation.
210
+
211
+ ## Commands
212
+
213
+ ```
214
+ migrate handoff --dry-run
215
+ migrate handoff [--adapter <markdown|github|flow>]
216
+ migrate phase handoff --status done
217
+ migrate check
218
+ migrate coverage
219
+ migrate forecast
220
+ ```
@@ -114,8 +114,8 @@ Running `migrate check --phase probe` here will not come back clean: the
114
114
  census gate reads the whole store regardless of `--phase`, so it reports
115
115
  every declared surface's lens record and every declared closer's record as
116
116
  missing, correctly, because none of them exist yet. That is not a probe
117
- defect; it is the same "other nine gates read the whole store" behavior
118
- `SKILL.md` describes, and it is why probe's own close is the status flip
117
+ defect; it is the same "gates that are neither run-state nor phase-scoped
118
+ read the whole store" behavior `SKILL.md` describes, and it is why probe's own close is the status flip
119
119
  above, not a clean `check`.
120
120
 
121
121
  ## Degradation
@@ -7,9 +7,9 @@ resolve on its own: evidence, the real options, and a recommendation.
7
7
  Exit condition: every item filed anywhere in the run so far is
8
8
  grammatically valid, every id the referential-integrity gate actually
9
9
  checks resolves to a real queue file, and `migrate phase queue --status
10
- done` has run. It is not "the queue is empty": nothing in this milestone
11
- adjudicates an item, so a healthy run through this phase still ends with
12
- open items, deliberately.
10
+ done` has run. It is not "the queue is empty": nothing in *this* phase
11
+ adjudicates an item, so a healthy run through phase 5 still ends with open
12
+ items, deliberately. Phase 6 is where they get decided.
13
13
 
14
14
  ## Inputs
15
15
 
@@ -173,9 +173,12 @@ ambiguous-looking duplicate.
173
173
 
174
174
  ## What closes it
175
175
 
176
- There is no verb that empties the queue in this milestone; closing this
177
- phase means every item filed so far is well-formed and every reference the
178
- gate checks resolves, not that adjudication has happened. Run for real:
176
+ No verb empties the queue here; closing this phase means every item filed so
177
+ far is well-formed and every reference the gate checks resolves, not that
178
+ adjudication has happened. `migrate adjudicate` is phase 6's verb, and the
179
+ `adjudication` gate that requires a ruling on every item is phase-scoped, so
180
+ it stays silent until the checked terminus reaches `adjudicate`. Run for
181
+ real:
179
182
 
180
183
  ```
181
184
  migrate phase queue --status done
@@ -202,21 +205,25 @@ queue` exits 0 with no violations at all, confirming this phase's own
202
205
  gates (`queue`, and the three `refs` fields above) were clean the whole
203
206
  time and only the unrelated census gap was ever holding exit 0 back.
204
207
 
205
- Plain `migrate check`, with no `--phase`, still cannot reach exit 0 in this
206
- version, exactly as `SKILL.md` says: `adjudicate` and `handoff` have no
207
- verb yet, so their phases stay `pending` forever this milestone, and
208
- `run-state` names both by hand:
208
+ Plain `migrate check`, with no `--phase`, still fails here, and should:
209
+ phases 6 and 7 have not run, so `run-state` names both, the `adjudication`
210
+ gate names every item nobody has ruled on, and the `handoff` gate reports
211
+ that nothing has been emitted.
209
212
 
210
213
  ```
211
214
  run-state:
212
215
  phase adjudicate is pending; every phase through handoff must be done
213
216
  phase handoff is pending; every phase through handoff must be done
217
+ adjudication:
218
+ q-reset-token-verify-missing [critical] is still open; every queue item needs a ruling before handoff
219
+ handoff:
220
+ no handoff.json in the store; handoff has not run, so nothing has reached a delivery medium
214
221
  ```
215
222
 
216
- `migrate check --phase queue` is the real terminus this milestone offers;
217
- `migrate status` afterward is the plainer read, and it is what actually
218
- hands off to phase 6: `5 open queue item(s) of 5`, `resume: adjudicate, no
219
- batches yet`.
223
+ `migrate check --phase queue` is this phase's terminus; plain `migrate
224
+ check` becomes reachable once phase 7 closes. `migrate status` afterward is
225
+ the plainer read, and it is what actually hands off to phase 6: `5 open
226
+ queue item(s) of 5`, `resume: adjudicate, no batches yet`.
220
227
 
221
228
  ## Degradation
222
229
 
@@ -118,10 +118,12 @@ resuming a crashed run a read rather than a recollection.
118
118
 
119
119
  Enforced. Every claim in this section was run.
120
120
 
121
- `import`, `census`, `phase --status`, and `reset` each take one lock over the
122
- whole store (`.migrate/.lock`) for the length of their read-modify-write. Those
123
- four are the whole set; every other command either only reads the store, or
124
- does not touch it. Without
121
+ `import`, `census`, `phase --status`, `reset`, `adjudicate` and `handoff` each
122
+ take one lock over the whole store (`.migrate/.lock`) for the length of their
123
+ read-modify-write. Those six are the whole set for the read-modify-write over a
124
+ whole store file. Every other command either only reads the store, or writes a
125
+ file nothing else contends for: `init` creates `config.toml`, `queue add` copies
126
+ one item in. Without
125
127
  it, two agents importing at once each read the same base file, and whichever
126
128
  one rewrites last silently discards the other's rows. The default wait is 30
127
129
  seconds, polling with backoff (25ms, growing by roughly 1.5x each attempt, up
@@ -135,7 +137,7 @@ and the answer is no) and `2` (a malformed request: a bad flag, a missing
135
137
  file). A lock failure says the request itself was fine; there is nothing
136
138
  about your `batch.json`, or about the `--phase` you named, to go back and
137
139
  inspect, regardless of which of the three ways below the lock failed. (Three
138
- ways the lock can fail, not three commands that take it: four commands do,
140
+ ways the lock can fail, not three commands that take it: six commands do,
139
141
  listed above.)
140
142
 
141
143
  That said, "retry" resolves the three underlying causes differently, and only
@@ -162,8 +164,8 @@ message text is exact):
162
164
  import: waiting for store lock (held by pid 51234 since 2026-08-07T09:14:02.001Z)
163
165
  ```
164
166
 
165
- `phase --status` is the one that does not print this line. It waits on the
166
- same lock with the same backoff, silently: run against a scratch store with a
167
+ `phase --status`, `adjudicate` and `handoff` do not print this line. All three
168
+ wait on the same lock with the same backoff, silently: run against a scratch store with a
167
169
  live holder released two seconds in, `migrate phase enumerate --status running`
168
170
  printed nothing at all until it succeeded, 1.85 seconds later. `reset` does
169
171
  print it, verified the same way against a live holder released two seconds in:
@@ -172,7 +174,7 @@ print it, verified the same way against a live holder released two seconds in:
172
174
  reset: waiting for store lock (held by pid 74987 since 2026-08-08T00:00:00.000Z)
173
175
  ```
174
176
 
175
- If the wait for any of the four ends in a timeout instead, all four report it
177
+ If the wait ends in a timeout instead, every lock-taker reports it
176
178
  the same way, with their own command name prefixed:
177
179
 
178
180
  ```
@@ -213,10 +215,12 @@ what you read here matches what you'll see on screen exactly.
213
215
 
214
216
  **`--force-unlock` is only appropriate after confirming the named pid is not
215
217
  running and no other agent is mid-write.** It exists on `import`, `census`,
216
- `phase`, and `reset`, the same four commands that take the lock at all.
217
- Verified against a scratch store holding a dead holder's lock: each of the four
218
- exits `3` without the flag and `0` with it. `reset` matters most here of the
219
- four, since it is the one whose whole job is deleting rows. What it actually
218
+ `phase`, `reset`, `adjudicate` and `handoff`, the same commands that take the
219
+ lock at all. Verified against a scratch store holding a dead holder's lock:
220
+ each of the original four exits `3` without the flag and `0` with it, and
221
+ `adjudicate` and `handoff` were brought onto the same exit code afterwards, so
222
+ all six behave alike. `reset`
223
+ matters most of them, since it is the one whose whole job is deleting rows. What it actually
220
224
  does is blunt: it unlinks the lock file
221
225
  unconditionally, before this process even checks who, if anyone, holds it.
222
226
  The CLI does not verify staleness for you when you pass this flag; the
@@ -277,4 +281,4 @@ itself is being validated against a benchmark rather than run against a live
277
281
  migration, the agent producing the mapping and the agent scoring it against
278
282
  ground truth must not be the same agent, and must not share context that
279
283
  would let one see the other's answer. This is a v1 documented discipline,
280
- not a CLI verb; `migrate` has no benchmark command in this milestone.
284
+ not a CLI verb; `migrate` has no benchmark command.
@@ -0,0 +1,290 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { flow } from '../adapters/flow.ts'
6
+ import type { Config } from '../config.ts'
7
+ import { buildWorkItems, type HandoffInput } from '../handoff.ts'
8
+ import type { Capability, Requirement } from '../types.ts'
9
+
10
+ const FIXTURE = join(import.meta.dir, '..', '..', 'fixtures', 'flow-target')
11
+
12
+ let root: string
13
+
14
+ const config = (): Config => ({
15
+ source: {
16
+ path: join(root, 'legacy'),
17
+ scope: 'x',
18
+ stack: 'unknown',
19
+ vcs: 'none',
20
+ basis: 'source-only',
21
+ },
22
+ target: {
23
+ name: 'target',
24
+ stack: 'unknown',
25
+ parity_test_path: 'tests/parity/{capability}/{fr_slug}.test.ts',
26
+ layout: {},
27
+ commands: {},
28
+ },
29
+ surfaces: ['routes'],
30
+ surfaceSingular: {},
31
+ closers: [],
32
+ handoff: { adapter: 'flow' },
33
+ })
34
+
35
+ function cap(slug: string, title: string, ns: string, elements: string[]): Capability {
36
+ return { slug, title, ns, elements }
37
+ }
38
+
39
+ function req(
40
+ id: string,
41
+ capSlug: string,
42
+ text: string,
43
+ over: Partial<Requirement> = {},
44
+ ): Requirement {
45
+ return {
46
+ id,
47
+ cap: capSlug,
48
+ requirement: text,
49
+ actors: 'User',
50
+ objects: 'Thing',
51
+ rules: 'none',
52
+ origin: 'intended',
53
+ confidence: { kind: 'confirmed' },
54
+ citations: [],
55
+ parity: { kind: 'rubric', level: 'high' },
56
+ batch: 'b-1',
57
+ ...over,
58
+ }
59
+ }
60
+
61
+ const CAPS = [
62
+ cap('billing', 'Billing', 'BI', ['el-b']),
63
+ cap('user-management', 'User management', 'UM', ['el-u']),
64
+ ]
65
+ const REQS = [
66
+ req('UM-001', 'user-management', 'Authenticate a user'),
67
+ req('UM-002', 'user-management', 'Lock an account', {
68
+ confidence: { kind: 'inferred' },
69
+ origin: 'accidental-candidate',
70
+ }),
71
+ req('BI-001', 'billing', 'Raise an invoice'),
72
+ ]
73
+
74
+ function input(caps = CAPS, reqs = REQS): HandoffInput {
75
+ return {
76
+ requirements: reqs,
77
+ capabilities: caps,
78
+ deltas: [],
79
+ config: config(),
80
+ root,
81
+ gitBin: 'git',
82
+ ghBin: 'gh',
83
+ }
84
+ }
85
+
86
+ const capPath = (slug: string): string =>
87
+ join(root, 'docs', 'modernisation', 'capability-map', `${slug}.md`)
88
+
89
+ async function flowCli(args: string[]): Promise<{ code: number; out: string; err: string }> {
90
+ const proc = Bun.spawn(['bun', join(root, 'tools', 'flow', 'src', 'cli.ts'), ...args], {
91
+ cwd: root,
92
+ stdout: 'pipe',
93
+ stderr: 'pipe',
94
+ })
95
+ const [out, err] = await Promise.all([
96
+ new Response(proc.stdout).text(),
97
+ new Response(proc.stderr).text(),
98
+ ])
99
+ await proc.exited
100
+ return { code: proc.exitCode ?? -1, out, err }
101
+ }
102
+
103
+ beforeEach(async () => {
104
+ root = await mkdtemp(join(tmpdir(), 'migrate-flow-adapter-'))
105
+ await cp(FIXTURE, root, { recursive: true })
106
+ await Bun.write(join(root, 'legacy', 'app.js'), '// legacy\n')
107
+ })
108
+
109
+ afterEach(async () => {
110
+ await rm(root, { recursive: true, force: true })
111
+ })
112
+
113
+ test('apply emits capability files the target parser accepts', async () => {
114
+ const result = await flow.apply(buildWorkItems(CAPS, REQS), input())
115
+ expect(result.created.sort()).toEqual(['billing', 'user-management'])
116
+
117
+ // The oracle: the target's own checker, run over what the adapter just wrote.
118
+ const check = await flowCli(['map', '--check'])
119
+ expect(check.err).toBe('')
120
+ expect(check.code).toBe(0)
121
+ })
122
+
123
+ // --- grammar conformance, one assertion per rule in the target's parser ---
124
+ // Each row cites quartex/Nexus at c2464ac,
125
+ // plugins/stack/templates/tools/flow/src/capability.ts.
126
+
127
+ test('capability.ts:43 every required frontmatter field is present', async () => {
128
+ await flow.apply(buildWorkItems(CAPS, REQS), input())
129
+ const text = await readFile(capPath('billing'), 'utf8')
130
+ for (const field of ['cap:', 'ns:', 'title:', 'status:']) {
131
+ expect(text).toContain(field)
132
+ }
133
+ })
134
+
135
+ test('capability.ts:3 status is one the target admits', async () => {
136
+ await flow.apply(buildWorkItems(CAPS, REQS), input())
137
+ const text = await readFile(capPath('billing'), 'utf8')
138
+ expect(text).toContain('status: todo')
139
+ })
140
+
141
+ test('capability.ts:44 all three required sections are emitted', async () => {
142
+ await flow.apply(buildWorkItems(CAPS, REQS), input())
143
+ const text = await readFile(capPath('billing'), 'utf8')
144
+ expect(text).toContain('## Functional requirements')
145
+ expect(text).toContain('## Built')
146
+ expect(text).toContain('## Remaining')
147
+ })
148
+
149
+ test('capability.ts:6 confidence is mapped into the target vocabulary', async () => {
150
+ await flow.apply(buildWorkItems(CAPS, REQS), input())
151
+ const text = await readFile(capPath('user-management'), 'utf8')
152
+ expect(text).toContain('| Confirmed |')
153
+ expect(text).toContain('| Inferred |')
154
+ expect(text).not.toContain('confirmed |')
155
+ })
156
+
157
+ test('capability.ts:9 accidental-candidate is mapped to poss-accidental', async () => {
158
+ await flow.apply(buildWorkItems(CAPS, REQS), input())
159
+ const text = await readFile(capPath('user-management'), 'utf8')
160
+ expect(text).toContain('poss-accidental')
161
+ expect(text).not.toContain('accidental-candidate')
162
+ })
163
+
164
+ test('capability.ts:74 every FR row has exactly seven cells', async () => {
165
+ await flow.apply(buildWorkItems(CAPS, REQS), input())
166
+ const text = await readFile(capPath('user-management'), 'utf8')
167
+ const rows = text
168
+ .split('\n')
169
+ .filter((l) => l.startsWith('| UM-'))
170
+ .map((l) => l.split('|').slice(1, -1))
171
+ expect(rows).toHaveLength(2)
172
+ for (const row of rows) expect(row).toHaveLength(7)
173
+ })
174
+
175
+ test('capability.ts:48 no "## " line is emitted inside a table cell', async () => {
176
+ // A requirement whose text would open a section if it reached line start.
177
+ const sneaky = [req('BI-001', 'billing', 'Handle the\n## Built case')]
178
+ await flow.apply(
179
+ buildWorkItems([CAPS[0] as Capability], sneaky),
180
+ input([CAPS[0] as Capability], sneaky),
181
+ )
182
+ const check = await flowCli(['map', '--check'])
183
+ expect(check.code).toBe(0)
184
+ })
185
+
186
+ test('capability.ts:42 an FR id that does not match its namespace refuses before writing', async () => {
187
+ const bad = [req('login-001', 'user-management', 'Authenticate a user')]
188
+ const caps = [CAPS[1] as Capability]
189
+ await expect(flow.plan(input(caps, bad))).rejects.toThrow(/login-001/)
190
+ // Nothing was written: the refusal happens in plan(), not part way through apply().
191
+ expect(await Bun.file(capPath('user-management')).exists()).toBe(false)
192
+ })
193
+
194
+ // --- WORK.md ---
195
+
196
+ test('apply appends under an existing ## Proposed section and creates it when absent', async () => {
197
+ await flow.apply(buildWorkItems(CAPS, REQS), input())
198
+ const first = await readFile(join(root, 'docs', 'WORK.md'), 'utf8')
199
+ expect(first).toContain('## Proposed')
200
+ expect(first).toContain('- [billing] Billing (1 FRs)')
201
+ expect(first).toContain('- [user-management] User management (2 FRs)')
202
+ // The team's own sections survive.
203
+ expect(first).toContain('- [W01] Wire the deployment pipeline')
204
+
205
+ // Re-running does not stack a second copy of the same lines.
206
+ await flow.apply(buildWorkItems(CAPS, REQS), input())
207
+ const second = await readFile(join(root, 'docs', 'WORK.md'), 'utf8')
208
+ expect(second.match(/- \[billing\]/g)).toHaveLength(1)
209
+ })
210
+
211
+ // --- throughput and degradation ---
212
+
213
+ test('throughput reads coveredIds from the target parity command, undated', async () => {
214
+ await flow.apply(buildWorkItems(CAPS, REQS), input())
215
+ // The target records delivery in the capability file's Built section.
216
+ const text = await readFile(capPath('billing'), 'utf8')
217
+ await writeFile(capPath('billing'), text.replace('## Built\n\n(none)', '## Built\n\nBI-001'))
218
+
219
+ const t = await flow.throughput?.(input())
220
+ expect(t?.completions).toEqual([{ fr: 'BI-001', doneAt: null }])
221
+ expect(t?.basis).toContain('flow parity')
222
+ })
223
+
224
+ test('a target with no flow CLI reports that the emission was not validated', async () => {
225
+ await rm(join(root, 'tools'), { recursive: true, force: true })
226
+ const result = await flow.apply(buildWorkItems(CAPS, REQS), input())
227
+ expect(result.created.sort()).toEqual(['billing', 'user-management'])
228
+ expect(result.refs['billing']).toContain('capability-map/billing.md')
229
+ })
230
+
231
+ test('a capability file the target rejects fails apply with the target’s own message', async () => {
232
+ // The fixture CLI is the oracle; break its input and the adapter must
233
+ // surface the failure rather than reporting a clean emission.
234
+ await flow.apply(buildWorkItems(CAPS, REQS), input())
235
+ await writeFile(capPath('billing'), '---\ncap: billing\n---\n\nnothing else\n')
236
+ const check = await flowCli(['map', '--check'])
237
+ expect(check.code).toBe(1)
238
+ expect(check.err).toContain('missing field')
239
+ })
240
+
241
+ test("the team's own lines under ## Proposed survive a handoff", async () => {
242
+ // The previous version stripped every `- [something]` line under the
243
+ // heading before adding its own, on the assumption that shape meant
244
+ // ownership. `- [W07]` is exactly the notation the target's own WORK.md
245
+ // teaches, so a team keeping a shortlist there lost it on the first run.
246
+ const workPath = join(root, 'docs', 'WORK.md')
247
+ await writeFile(
248
+ workPath,
249
+ [
250
+ '# Work',
251
+ '',
252
+ '## Proposed',
253
+ '',
254
+ '- [W07] Replace the auth provider',
255
+ '- [W08] Split the reporting service',
256
+ '',
257
+ 'Notes: W07 is blocked until Q3.',
258
+ '',
259
+ '### Detail',
260
+ '',
261
+ 'Some prose.',
262
+ '',
263
+ '# Appendix',
264
+ '',
265
+ '- [A1] An appendix item',
266
+ '',
267
+ ].join('\n'),
268
+ )
269
+
270
+ await flow.apply(buildWorkItems(CAPS, REQS), input())
271
+ const after = await readFile(workPath, 'utf8')
272
+
273
+ for (const kept of [
274
+ '- [W07] Replace the auth provider',
275
+ '- [W08] Split the reporting service',
276
+ 'Notes: W07 is blocked until Q3.',
277
+ '### Detail',
278
+ '# Appendix',
279
+ '- [A1] An appendix item',
280
+ ]) {
281
+ expect(after).toContain(kept)
282
+ }
283
+ expect(after).toContain('- [billing] Billing (1 FRs)')
284
+
285
+ // And a second run replaces only its own fenced block rather than stacking.
286
+ await flow.apply(buildWorkItems(CAPS, REQS), input())
287
+ const twice = await readFile(workPath, 'utf8')
288
+ expect(twice.match(/- \[billing\]/g)).toHaveLength(1)
289
+ expect(twice).toContain('- [W07] Replace the auth provider')
290
+ })