@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/index.json +2 -2
- 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
|
@@ -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 "
|
|
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
|
|
11
|
-
adjudicates an item, so a healthy run through
|
|
12
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
217
|
-
`
|
|
218
|
-
hands off to phase 6: `5 open
|
|
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 `
|
|
122
|
-
whole store (`.migrate/.lock`) for the length of their
|
|
123
|
-
|
|
124
|
-
|
|
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:
|
|
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`
|
|
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
|
|
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 `
|
|
217
|
-
Verified against a scratch store holding a dead holder's lock:
|
|
218
|
-
exits `3` without the flag and `0` with it
|
|
219
|
-
|
|
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
|
|
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
|
+
})
|