@iceinvein/agent-skills 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli/index.js +14 -10
- package/package.json +1 -1
- package/skills/index.json +4 -4
- package/skills/migrate/README.md +35 -23
- package/skills/migrate/SKILL.md +75 -15
- package/skills/migrate/bin/migrate.ts +90 -0
- package/skills/migrate/docs/architecture.md +61 -26
- package/skills/migrate/docs/reference.md +53 -8
- package/skills/migrate/fixtures/fake-gh.ts +113 -0
- package/skills/migrate/fixtures/flow-target/docs/WORK.md +12 -0
- package/skills/migrate/fixtures/flow-target/docs/modernisation/capability-map/.gitkeep +0 -0
- package/skills/migrate/fixtures/flow-target/tools/flow/src/cli.ts +156 -0
- package/skills/migrate/package.json +1 -1
- package/skills/migrate/references/phases/adjudicate.md +161 -0
- package/skills/migrate/references/phases/handoff.md +220 -0
- package/skills/migrate/references/phases/probe.md +2 -2
- package/skills/migrate/references/phases/queue.md +21 -14
- package/skills/migrate/references/run-ops.md +17 -13
- package/skills/migrate/scripts/__tests__/adapter-flow.test.ts +290 -0
- package/skills/migrate/scripts/__tests__/adapter-github.test.ts +232 -0
- package/skills/migrate/scripts/__tests__/adapter-markdown.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/adjudicate.test.ts +332 -0
- package/skills/migrate/scripts/__tests__/assumptions.test.ts +179 -0
- package/skills/migrate/scripts/__tests__/coverage.test.ts +192 -0
- package/skills/migrate/scripts/__tests__/e2e-express.test.ts +167 -7
- package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +9 -4
- package/skills/migrate/scripts/__tests__/forecast.test.ts +280 -0
- package/skills/migrate/scripts/__tests__/gates-handoff.test.ts +309 -0
- package/skills/migrate/scripts/__tests__/handoff-cmd.test.ts +308 -0
- package/skills/migrate/scripts/__tests__/handoff-order.test.ts +156 -0
- package/skills/migrate/scripts/adapters/flow.ts +280 -0
- package/skills/migrate/scripts/adapters/github.ts +260 -0
- package/skills/migrate/scripts/adapters/markdown.ts +175 -0
- package/skills/migrate/scripts/adjudicate-cmd.ts +243 -0
- package/skills/migrate/scripts/assumptions.ts +188 -0
- package/skills/migrate/scripts/check.ts +119 -320
- package/skills/migrate/scripts/coverage-cmd.ts +86 -0
- package/skills/migrate/scripts/coverage.ts +151 -0
- package/skills/migrate/scripts/dates.ts +17 -0
- package/skills/migrate/scripts/forecast-cmd.ts +124 -0
- package/skills/migrate/scripts/forecast.ts +264 -0
- package/skills/migrate/scripts/gates/adjudication.ts +30 -0
- package/skills/migrate/scripts/gates/census.ts +107 -0
- package/skills/migrate/scripts/gates/citations.ts +11 -0
- package/skills/migrate/scripts/gates/context.ts +76 -0
- package/skills/migrate/scripts/gates/coverage.ts +22 -0
- package/skills/migrate/scripts/gates/deltas.ts +15 -0
- package/skills/migrate/scripts/gates/handoff.ts +145 -0
- package/skills/migrate/scripts/gates/leaks.ts +11 -0
- package/skills/migrate/scripts/gates/parity.ts +15 -0
- package/skills/migrate/scripts/gates/queue.ts +9 -0
- package/skills/migrate/scripts/gates/refs.ts +97 -0
- package/skills/migrate/scripts/gates/run-state.ts +67 -0
- package/skills/migrate/scripts/gates/source.ts +28 -0
- package/skills/migrate/scripts/handoff-cmd.ts +186 -0
- package/skills/migrate/scripts/handoff.ts +330 -0
- package/skills/migrate/scripts/paths.ts +4 -0
- package/skills/migrate/scripts/types.ts +43 -0
- package/skills/migrate/scripts/validate.ts +12 -0
- package/skills/migrate/skill.json +2 -2
- package/skills/migrate/templates/forecast-assumptions.md +59 -0
- package/skills/sluice/SKILL.md +20 -7
- package/skills/sluice/references/deep-channel.md +20 -0
- package/skills/sluice/references/finish.md +4 -2
- package/skills/sluice/references/meter.md +38 -0
- package/skills/sluice/scripts/run-stats.sh +236 -0
- package/skills/sluice/skill.json +4 -3
|
@@ -7,9 +7,9 @@ file formats and gate behaviour, see [reference.md](reference.md).
|
|
|
7
7
|
|
|
8
8
|
Three parts, one rule that decides which part gets a given piece of work.
|
|
9
9
|
|
|
10
|
-
- **The skill** (`SKILL.md`, plus the
|
|
11
|
-
`references/phases/` and the cross-cutting `references/run-ops.md
|
|
12
|
-
|
|
10
|
+
- **The skill** (`SKILL.md`, plus the eight phase manuals under
|
|
11
|
+
`references/phases/` and the cross-cutting `references/run-ops.md`) holds
|
|
12
|
+
judgment: what to look for in a legacy codebase,
|
|
13
13
|
how to decide a requirement is confirmed rather than inferred, when to
|
|
14
14
|
escalate to the queue.
|
|
15
15
|
- **The CLI** (`bin/`, `scripts/`) holds anything that must not be
|
|
@@ -40,14 +40,22 @@ scripts/
|
|
|
40
40
|
config.ts config.toml load and write, TOML escaping
|
|
41
41
|
store.ts JSONL read, atomic write, id upsert, file readers
|
|
42
42
|
lock.ts store lock: serialises the read-modify-write in import,
|
|
43
|
-
census, phase --status and
|
|
43
|
+
census, phase --status, reset, adjudicate and handoff
|
|
44
44
|
phases.ts phases.json state and committed batches
|
|
45
45
|
validate.ts per-row shape validation shared by import and check
|
|
46
46
|
census.ts census kinds, balance and bounds invariants, subject identity
|
|
47
47
|
citations.ts resolves src refs against the source tree
|
|
48
48
|
leaks.ts scans artifacts and git history for env values
|
|
49
49
|
queue.ts queue item parsing and grammar
|
|
50
|
-
check.ts
|
|
50
|
+
check.ts loads the store once, runs GATE_ORDER, applies the
|
|
51
|
+
phase-scope rule; holds no gate logic itself
|
|
52
|
+
gates/context.ts GateContext: everything a gate may read
|
|
53
|
+
gates/<gate>.ts one module per gate, twelve of them
|
|
54
|
+
handoff.ts adapter contract, dependency order, preflight, handoff.json
|
|
55
|
+
adapters/*.ts markdown, github, flow: one per delivery medium
|
|
56
|
+
coverage.ts built over confirmed, per capability
|
|
57
|
+
assumptions.ts parses and validates forecast-assumptions.md
|
|
58
|
+
forecast.ts measured velocities, demand, scenario projection
|
|
51
59
|
report.ts markdown rendering
|
|
52
60
|
*-cmd.ts one per subcommand; argument handling and orchestration
|
|
53
61
|
```
|
|
@@ -109,7 +117,7 @@ and `init` shipped exactly that bug in Milestone 2.
|
|
|
109
117
|
renames, and cleans up the temp file on failure without masking the original
|
|
110
118
|
error. A fixed temp name was tried first and lost data under concurrent writes.
|
|
111
119
|
|
|
112
|
-
**The read-modify-write around a store file is lock-serialised.**
|
|
120
|
+
**The read-modify-write around a store file is lock-serialised.** Six commands
|
|
113
121
|
do one. `import` and `census` each read a whole store file, upsert or replace
|
|
114
122
|
rows, and rewrite the whole file; both also commit a batch into `phases.json`,
|
|
115
123
|
via `recordBatch`, inside the same lock. `phase --status` does its own
|
|
@@ -127,7 +135,7 @@ one rename can still discard the other's rows.
|
|
|
127
135
|
section must not take it. That is why `savePhases` and `recordBatch` are
|
|
128
136
|
lock-free while `setPhaseStatus` is not, and why `reset-cmd.ts` calls
|
|
129
137
|
`savePhases` rather than `setPhaseStatus` to move its phase back to `pending`.
|
|
130
|
-
Check this before adding a call inside any of
|
|
138
|
+
Check this before adding a call inside any of them.
|
|
131
139
|
|
|
132
140
|
`census-cmd.ts` orders its two writes deliberately: it commits the batch into
|
|
133
141
|
`phases.json` first, and writes `census.jsonl` second. If the process is
|
|
@@ -139,7 +147,7 @@ orphan census row would be worse: a record naming a batch that was never
|
|
|
139
147
|
actually committed, which is exactly the mismatch the run-state gate exists to
|
|
140
148
|
catch.
|
|
141
149
|
|
|
142
|
-
`lock.ts`'s `withStoreLock` wraps each of these
|
|
150
|
+
`lock.ts`'s `withStoreLock` wraps each of these write paths in one lock
|
|
143
151
|
file for the whole store (`.migrate/.lock`, `O_EXCL` create, bounded retry with
|
|
144
152
|
backoff). It distinguishes a lock file that is merely absent or momentarily
|
|
145
153
|
empty (never counted against a corruption budget) from one that is genuinely
|
|
@@ -180,20 +188,43 @@ store.
|
|
|
180
188
|
|
|
181
189
|
### Add a gate
|
|
182
190
|
|
|
183
|
-
Gates live
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
191
|
+
Gates live one per module under `scripts/gates/`, each exporting
|
|
192
|
+
`export const gate: Gate` over the shared `GateContext` that `check.ts` builds
|
|
193
|
+
once per run. A gate is a pure function of that record: it may not read the
|
|
194
|
+
store itself, which is what keeps the store parsed exactly once no matter how
|
|
195
|
+
many gates consult it, and what stops a gate depending on another having run
|
|
196
|
+
first. Add the name to `GATE_ORDER`, the module to `GATES`, and, only if the
|
|
197
|
+
gate describes work a mid-run terminus has not reached, an entry to
|
|
198
|
+
`GATE_PHASE`.
|
|
199
|
+
|
|
200
|
+
1. Write `scripts/gates/<name>.ts` exporting `export const gate: Gate`, and
|
|
201
|
+
add the name to `GATE_ORDER`, which fixes its position in the report, and
|
|
202
|
+
the module to `GATES`.
|
|
203
|
+
2. Return violations that name the specific offending row, path or id. An
|
|
187
204
|
aggregate "check failed" is never acceptable; the message is what an agent
|
|
188
205
|
acts on without a human.
|
|
189
|
-
3. If the gate is expensive, make it opt-in behind a flag like `--leaks`,
|
|
190
|
-
have `check-cmd.ts` pass it through. If it is cheap
|
|
191
|
-
default instead, follow citations: on unless the caller
|
|
192
|
-
`--no-citations
|
|
193
|
-
|
|
194
|
-
|
|
206
|
+
3. If the gate is expensive, make it opt-in behind a flag like `--leaks`, read
|
|
207
|
+
off `GateContext`, and have `check-cmd.ts` pass it through. If it is cheap
|
|
208
|
+
enough to want on by default instead, follow citations: on unless the caller
|
|
209
|
+
passes `--no-citations`.
|
|
210
|
+
4. Only if the gate describes work a mid-run terminus has not reached, add an
|
|
211
|
+
entry to `GATE_PHASE`. This is a narrow escape hatch, not the norm: nine of
|
|
212
|
+
the twelve gates read the whole store regardless of `--phase`, and a tenth,
|
|
213
|
+
run-state, is bounded by the terminus rather than skipped. A defect is a
|
|
214
|
+
defect whenever it is found.
|
|
215
|
+
5. Add tests for both directions. A gate that produces false failures is worse
|
|
195
216
|
than no gate, because it makes `check` ignorable.
|
|
196
217
|
|
|
218
|
+
### Add a handoff adapter
|
|
219
|
+
|
|
220
|
+
`scripts/adapters/<name>.ts` exporting an `Adapter`, registered in the
|
|
221
|
+
`ADAPTERS` map in `handoff-cmd.ts`, `coverage-cmd.ts` and `forecast-cmd.ts`.
|
|
222
|
+
An adapter reaches the world only through `HandoffInput`'s `root`, `gitBin` and
|
|
223
|
+
`ghBin`, which is what makes `github` testable against `fixtures/fake-gh.ts`
|
|
224
|
+
and `flow` against `fixtures/flow-target/`. `apply()` must be idempotent, and
|
|
225
|
+
must not destroy progress the medium is carrying: the markdown adapter reads
|
|
226
|
+
its own roadmap before regenerating it for exactly that reason.
|
|
227
|
+
|
|
197
228
|
### Add a census kind
|
|
198
229
|
|
|
199
230
|
1. Add the variant to the `Census` union in `types.ts`.
|
|
@@ -219,7 +250,7 @@ This is the main source-genericity lever: a COBOL source declares `programs`,
|
|
|
219
250
|
`bun run typecheck` (tsc). All three must be clean.
|
|
220
251
|
|
|
221
252
|
**A test that passes against a broken implementation is treated as a defect**,
|
|
222
|
-
not a minor style issue. Two shipped during
|
|
253
|
+
not a minor style issue. Two shipped during Milestone 2 and both were caught
|
|
223
254
|
only by mutation. When you add a regression test, verify it fails against the
|
|
224
255
|
unfixed code: revert the source file with
|
|
225
256
|
`git checkout <sha> -- <path>`, run the test, observe the failure, then restore
|
|
@@ -254,13 +285,17 @@ too:
|
|
|
254
285
|
surfaces, three elements) and shows the gate failing on unaccounted elements
|
|
255
286
|
before it passes. That arc is the point: a test that only demonstrates the
|
|
256
287
|
passing state would be worth much less.
|
|
257
|
-
- `e2e-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
288
|
+
- `e2e-webforms.test.ts` drives the whole of `tiny-webforms` probe through
|
|
289
|
+
queue: `init`, `import`, `census`, `phase`, `queue add`, `queue list`, and
|
|
290
|
+
`check`, ending green at `migrate check --phase queue`.
|
|
291
|
+
- `e2e-express.test.ts` carries on past that terminus through `adjudicate`,
|
|
292
|
+
`handoff`, `coverage` and `forecast`, and closes on plain `migrate check` at
|
|
293
|
+
exit 0. Some real run has to be able to reach the unbounded gate, or it
|
|
294
|
+
asserts nothing.
|
|
295
|
+
|
|
296
|
+
Both parse `GROUND-TRUTH.md` for their element rows rather than hand-copying
|
|
297
|
+
them, so fixture and test cannot drift, and both close on a mutation showing the
|
|
298
|
+
terminus assertion is load-bearing.
|
|
264
299
|
|
|
265
300
|
When you change a fixture, the tests that read it will tell you; when you
|
|
266
301
|
change a manual the tests follow (`seam.md`'s clustering procedure,
|
|
@@ -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 `
|
|
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`,
|
|
34
|
-
one lock (`.migrate/.lock`) across their read-modify-write
|
|
35
|
-
agents writing at once cannot silently drop each other's
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
@@ -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
|
+
}
|