@wairon/cli 5.1.1-dev.11 → 5.1.1-dev.110
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/CONTRIBUTING.md +193 -0
- package/README.md +154 -41
- package/dist/cli/index.js +66474 -29775
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +43671 -0
- package/dist/index.js +53581 -21797
- package/dist/index.js.map +1 -1
- package/dist/templates/architect.yaml +15 -13
- package/dist/templates/domain-owner.yaml +5 -4
- package/dist/templates/skills/sdd-architect.md +44 -26
- package/dist/templates/skills/sdd-auditor.md +5 -4
- package/dist/templates/skills/sdd-delegate.md +43 -4
- package/dist/templates/skills/sdd-implement.md +83 -13
- package/dist/templates/skills/sdd-narrative.md +7 -4
- package/dist/templates/variants/logic-shapes.yaml +59 -0
- package/dist/templates/variants/portal-shapes.yaml +13 -0
- package/dist/webapp.html +771 -0
- package/docs/architecture.md +83 -0
- package/docs/cli.md +1208 -0
- package/docs/design/approval-baseline.md +257 -0
- package/docs/design/connecting-agent-entrypoint.md +167 -0
- package/docs/design/execution-budgets.md +199 -0
- package/docs/design/pack-scoping.md +581 -0
- package/docs/design/supervisor-doctrine.md +110 -0
- package/docs/design-export.md +313 -0
- package/docs/extending-wairon.md +616 -0
- package/docs/network.md +109 -0
- package/docs/requirements.md +65 -0
- package/docs/roadmap.md +133 -0
- package/docs/standards/INDEX.md +23 -0
- package/docs/standards/architecture.md +1042 -0
- package/docs/standards/language-bindings.md +120 -0
- package/docs/templates.md +61 -0
- package/docs/vision.md +59 -0
- package/docs/wairon-feature-requests-hosted-mcp.md +74 -0
- package/package.json +18 -5
- package/schemas/design-export-2.json +1186 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
# Pack scoping — the central store, project selection, and reproducibility
|
|
2
|
+
|
|
3
|
+
> Status: **implemented (A1–A10)**. Kept as the rationale record — the "problem"
|
|
4
|
+
> section below describes the behaviour as it was BEFORE this work (global packs
|
|
5
|
+
> auto-loading for every project), not as it is now. Written against wairon 5.1.0.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The problem (as it was)
|
|
10
|
+
|
|
11
|
+
wairon has two pack stores and no concept that connects them:
|
|
12
|
+
|
|
13
|
+
| Today | Behaviour |
|
|
14
|
+
|-------|-----------|
|
|
15
|
+
| `wairon pack add <source> --global` | copies into `~/.wairon/packs` (or `WAIRON_PACKS_DIR`) and **applies to every project on the machine**, registered in no project config |
|
|
16
|
+
| `wairon pack add <source>` | copies into `.wai/packs/<name>` **inside the repo** and registers a relative ref in `project.yaml → extensions.packs` |
|
|
17
|
+
|
|
18
|
+
Three distinct concepts are collapsed into those two commands:
|
|
19
|
+
|
|
20
|
+
1. **Installed** — the pack is available on this machine to choose from.
|
|
21
|
+
2. **Selected** — *this* project applies it.
|
|
22
|
+
3. **Bundled** — a committed copy so the selection resolves for a clone or CI.
|
|
23
|
+
|
|
24
|
+
`--global` fuses *installed* with *selected*: a pack governs projects that never
|
|
25
|
+
mentioned it. Project packs fuse *selected* with *bundled*: the only way to
|
|
26
|
+
select is to copy the whole pack into the repo. There is no way to say "installed
|
|
27
|
+
here, used by this one project."
|
|
28
|
+
|
|
29
|
+
Three consequences, all real:
|
|
30
|
+
|
|
31
|
+
- **Silent doctrine.** Whatever a contributor happens to have installed
|
|
32
|
+
machine-wide injects profiles, rules, assertions, and skills into every project
|
|
33
|
+
on that machine. The gate, `wairon skills list`, and the MCP
|
|
34
|
+
`instructions`/resources then differ per developer, and CI — which has no
|
|
35
|
+
global packs — validates against a different rule set than the author's laptop.
|
|
36
|
+
This bit this very repo: three tests in `tests/mcp/skill-resources.test.ts`
|
|
37
|
+
and one in `tests/core/extensions.test.ts` failed locally and passed in CI,
|
|
38
|
+
purely because of a globally installed pack.
|
|
39
|
+
- **`enforceReproducibility` is a dead flag.** Declared in
|
|
40
|
+
`models/project.ts:128` with `default(true)`, written by `commands/init.ts:557`
|
|
41
|
+
and `core/provision.ts:44` — and **read nowhere in `src/`**. It names precisely
|
|
42
|
+
the hole the global default punches, and enforces nothing.
|
|
43
|
+
- **`--pack` at init does not seed.** `cli/index.ts:85-90` simply calls
|
|
44
|
+
`addPack(source)` after `runInit`, identical to running `wairon pack add`
|
|
45
|
+
manually. Nothing records a machine-installed pack into a new project's config,
|
|
46
|
+
so "apply by default" can only be expressed as "apply to everything, always."
|
|
47
|
+
|
|
48
|
+
### Prior art: the hosted plane already models this correctly
|
|
49
|
+
|
|
50
|
+
`InstancePackPolicy` (`.wai/specs/types/instance_pack_policy.yaml`) already
|
|
51
|
+
carries `requiredGlobalPacks`, `defaultProjectPacks` ("declarative project packs
|
|
52
|
+
applied to newly initialized projects unless policy or caller opts out"),
|
|
53
|
+
`blockedPackNames`, and `allowedProfileIds`, driven by
|
|
54
|
+
`sdd_host_policy_evaluate` / `sdd_host_policy_reconcile`.
|
|
55
|
+
|
|
56
|
+
So pool + policy + apply-at-creation + reconcile **already ship** — on the hosted
|
|
57
|
+
side only. This design brings the local CLI in line with the model wairon
|
|
58
|
+
already enforces for hosted projects, rather than inventing a second one.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## The model
|
|
63
|
+
|
|
64
|
+
**One store per wairon install. Projects select from it by name. The store is the
|
|
65
|
+
runtime source; a bundle is an optional committed copy for CI and sharing.**
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
┌─ the pack store (per wairon install) ───────────────────────────┐
|
|
69
|
+
│ WAIRON_PACKS_DIR, else ~/.wairon/packs │
|
|
70
|
+
│ appenser/1.2.0/ sha256-9f2c… │
|
|
71
|
+
│ appenser/1.3.0/ sha256-04ab… │
|
|
72
|
+
│ org-doctrine/2.0.1/ sha256-7dd1… │
|
|
73
|
+
│ Installed ≠ applied. Nothing here governs a project by itself. │
|
|
74
|
+
└──────────────────────────────────────────────────────────────────┘
|
|
75
|
+
│ resolved by name@version (+ digest)
|
|
76
|
+
▼
|
|
77
|
+
┌─ the project selects ───────────────────────────────────────────┐
|
|
78
|
+
│ .wai/project.yaml → extensions.packs │
|
|
79
|
+
│ - name: appenser │
|
|
80
|
+
│ version: 1.2.0 │
|
|
81
|
+
│ Missing from the store ⇒ a LOUD finding, never silent silence. │
|
|
82
|
+
└──────────────────────────────────────────────────────────────────┘
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Store layout
|
|
86
|
+
|
|
87
|
+
`<store>/<name>/<version>/` — versioned, so two projects on one machine can
|
|
88
|
+
select different versions of the same pack. (Today's flat
|
|
89
|
+
`<store>/<file>.yaml` is read as `<name>@0.0.0-unversioned`; see Migration.)
|
|
90
|
+
|
|
91
|
+
### Selection in `project.yaml`
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
extensions:
|
|
95
|
+
packs:
|
|
96
|
+
- name: appenser # required — the only mandatory field
|
|
97
|
+
version: 1.2.0 # optional pin; omitted ⇒ latest installed
|
|
98
|
+
integrity: sha256-9f2c… # optional pin on pack content
|
|
99
|
+
source: https://…/appenser-{version}.wpack # recorded automatically; see Sources
|
|
100
|
+
bundle: true # optional: materialize into .wai/packs
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A project states **which packs and which profiles it applies** — nothing else
|
|
104
|
+
governs it. Legacy string entries (`.wai/packs/foo.yaml`, absolute paths, module
|
|
105
|
+
ids) keep loading, deprecated.
|
|
106
|
+
|
|
107
|
+
### Version resolution: latest by default, pin when you mean it
|
|
108
|
+
|
|
109
|
+
`wairon pack use appenser` records **just the name**, and the selection resolves
|
|
110
|
+
to the **highest version installed** in the store. Adding `version:` pins it.
|
|
111
|
+
That keeps the common case frictionless — install a newer pack, every project
|
|
112
|
+
picks it up.
|
|
113
|
+
|
|
114
|
+
Floating has a cost, so it is graded rather than forbidden:
|
|
115
|
+
|
|
116
|
+
| Context | An unpinned selection |
|
|
117
|
+
|---------|----------------------|
|
|
118
|
+
| `wairon validate`, `status`, MCP | **warning** — `UNPINNED_PACK_SELECTION` |
|
|
119
|
+
| `wairon validate --ci` | **error** (warnings are errors in CI) |
|
|
120
|
+
| `wairon lock` | **warning only** — see below |
|
|
121
|
+
|
|
122
|
+
This is the severity model wairon already uses for draft specs: relaxed while you
|
|
123
|
+
work, strict at the gate. `enforceReproducibility: true` (the existing default) is
|
|
124
|
+
what raises the warning — set it `false` to silence it entirely.
|
|
125
|
+
|
|
126
|
+
**As-built correction:** the original design said `lock` would refuse a floating
|
|
127
|
+
selection. It does not. `validateAsComplete` escalates only the *draft-gated*
|
|
128
|
+
completeness codes (it flips spec statuses in memory), and `runLock` refuses on
|
|
129
|
+
errors alone — so a warning does not block a freeze. Making `lock` refuse would
|
|
130
|
+
need the rule to know it is running as-complete, which is a separate change.
|
|
131
|
+
Today the practical guard is `validate --ci`, which every pipeline runs anyway.
|
|
132
|
+
A9 (the gate StateId) does cover the related risk: a lock records WHICH pack set
|
|
133
|
+
validated it, so a later doctrine change invalidates the lock regardless.
|
|
134
|
+
|
|
135
|
+
Version comparison reuses the `isNewer` comparator already in
|
|
136
|
+
`commands/update.ts:409` (lifted into core), so no semver dependency is added.
|
|
137
|
+
**Ranges** (`^1.2.0`) are deliberately out of scope for v1: exact pin, or latest.
|
|
138
|
+
|
|
139
|
+
### Resolution order
|
|
140
|
+
|
|
141
|
+
1. **Bundled copy** under `.wai/packs/<name>/<version>/`, when present — the
|
|
142
|
+
committed truth wins, so a clone and CI resolve with an empty store.
|
|
143
|
+
2. **The store**, matched on `name` + `version` (+ `integrity` when pinned).
|
|
144
|
+
3. Otherwise a finding — `PACK_NOT_INSTALLED`, error by default.
|
|
145
|
+
|
|
146
|
+
If both exist and their digests differ for the same `name@version`, that is
|
|
147
|
+
`PACK_STORE_DRIFT` (warning): the repo and the machine disagree about what the
|
|
148
|
+
pack contains. The bundle still applies — it is the repository's own copy — so the
|
|
149
|
+
warning exists to explain why an edit to the installed copy had no effect.
|
|
150
|
+
|
|
151
|
+
**A pinned `integrity` is verified on whichever path wins, recomputed from the
|
|
152
|
+
files.** Two things follow, and both matter:
|
|
153
|
+
|
|
154
|
+
- A committed bundle is *not* exempt. It overrides the store, which makes it the
|
|
155
|
+
most important place to honour a pin, not a place to skip it — a bundle carrying
|
|
156
|
+
different content while the project claims a digest would be doctrine
|
|
157
|
+
substitution that validates clean.
|
|
158
|
+
- The store's recorded digest (`.install.yaml`) is not trusted as the answer. A
|
|
159
|
+
recorded hash is only as good as the last writer, so a pack edited in place
|
|
160
|
+
would satisfy a pin it no longer matches. Recomputing costs a few small file
|
|
161
|
+
reads and only happens when a pin exists.
|
|
162
|
+
|
|
163
|
+
### Availability is enforced, not assumed
|
|
164
|
+
|
|
165
|
+
An unresolvable selection is an **error everywhere** — `validate`, `status`,
|
|
166
|
+
`lock`, `generate`, and every `sdd_*` MCP call. There is no "warn instead" knob:
|
|
167
|
+
a project whose declared doctrine is absent is misconfigured, and the job of the
|
|
168
|
+
message is to say so and say how to fix it. The finding carries the pack name,
|
|
169
|
+
the wanted version, the recorded `source`, and the exact commands:
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
✖ [PACK_NOT_INSTALLED] Project requires pack "appenser@1.2.0", which is not
|
|
173
|
+
installed in this wairon install (~/.wairon/packs).
|
|
174
|
+
Install every declared pack from its recorded source: wairon pack sync
|
|
175
|
+
Or install just this one: wairon pack install https://…/appenser-1.2.0.wpack
|
|
176
|
+
Or reconcile automatically: wairon doctor --fix
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`wairon doctor --fix` performs the same `sync` for missing packs, so the one
|
|
180
|
+
command a user already reaches for when something is off resolves this too.
|
|
181
|
+
|
|
182
|
+
This follows the rule the codebase already holds for `EXTENSION_LOAD_ERROR`:
|
|
183
|
+
**validation never silently runs without declared doctrine.** A weakened gate
|
|
184
|
+
must be loud, because a quiet gate reads as "clean."
|
|
185
|
+
|
|
186
|
+
### Sources — how a fresh machine knows where a pack comes from
|
|
187
|
+
|
|
188
|
+
A selection that names only `appenser` tells a fresh CI runner *what* it needs and
|
|
189
|
+
nothing about *where to get it*. So the origin is recorded and travels with the
|
|
190
|
+
project:
|
|
191
|
+
|
|
192
|
+
1. **The store keeps an install record per pack** —
|
|
193
|
+
`<store>/<name>/<version>/.install.yaml`: the origin (URL or path), the
|
|
194
|
+
content digest, the wairon version, and when it was installed.
|
|
195
|
+
2. **`wairon pack use` copies that origin into the selection automatically.** You
|
|
196
|
+
never type a URL; using a pack you installed from a release makes the project
|
|
197
|
+
self-describing.
|
|
198
|
+
3. **`wairon pack sync`** reads the selections, downloads each missing pack from
|
|
199
|
+
its `source`, verifies `integrity` when pinned, and installs into the store.
|
|
200
|
+
|
|
201
|
+
`source` supports a `{version}` placeholder so one recorded template serves both
|
|
202
|
+
pinned and future versions:
|
|
203
|
+
|
|
204
|
+
```yaml
|
|
205
|
+
- name: appenser
|
|
206
|
+
version: 1.2.0
|
|
207
|
+
source: https://github.com/org/appenser/releases/download/v{version}/appenser.wpack
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
For a **floating** selection, GitHub resolves "latest" with no API call and no
|
|
211
|
+
token, which keeps `sync` a plain download:
|
|
212
|
+
|
|
213
|
+
```yaml
|
|
214
|
+
- name: appenser
|
|
215
|
+
source: https://github.com/org/appenser/releases/latest/download/appenser.wpack
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`source` also expands `${VAR}` from the environment, so a private pack can carry
|
|
219
|
+
a token from a CI secret without committing it:
|
|
220
|
+
`https://${PACK_TOKEN}@git.internal/…/appenser.wpack`.
|
|
221
|
+
|
|
222
|
+
### Where do the URLs come from? wairon hosts nothing
|
|
223
|
+
|
|
224
|
+
There is no wairon-operated pack index, and this design does not add one — **wairon
|
|
225
|
+
core ships zero packs** (its profiles are built in), so there would be nothing in
|
|
226
|
+
it. What wairon provides instead is the convention plus the tooling that makes a
|
|
227
|
+
release asset the natural artifact:
|
|
228
|
+
|
|
229
|
+
`wairon pack build` already emits **`<name>-<version>.wpack`**
|
|
230
|
+
(`sdk/src/orchestrator.ts:62`) with integrity sealed in the envelope. That file is
|
|
231
|
+
a GitHub release asset as-is:
|
|
232
|
+
|
|
233
|
+
```sh
|
|
234
|
+
wairon pack build # → appenser-1.2.0.wpack
|
|
235
|
+
gh release create v1.2.0 appenser-1.2.0.wpack
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Both `source` shapes then work with no API call and no token:
|
|
239
|
+
|
|
240
|
+
| Selection | `source` |
|
|
241
|
+
|-----------|----------|
|
|
242
|
+
| pinned | `…/releases/download/v{version}/appenser-{version}.wpack` |
|
|
243
|
+
| floating | `…/releases/latest/download/appenser.wpack` |
|
|
244
|
+
|
|
245
|
+
A pack author publishes once; every consuming project gets the URL recorded for
|
|
246
|
+
free at `pack use`. No consumer ever types one.
|
|
247
|
+
|
|
248
|
+
### When there is no fetchable URL: bundle
|
|
249
|
+
|
|
250
|
+
Private packs, internal doctrine, air-gapped CI, and packs still under development
|
|
251
|
+
have no URL — and for those the answer is not "figure it out", it is
|
|
252
|
+
`bundle: true`. The committed copy under `.wai/packs/` resolves with an empty
|
|
253
|
+
store and no network at all. Every project has a working option regardless of how
|
|
254
|
+
its doctrine is distributed.
|
|
255
|
+
|
|
256
|
+
### The guard: an unfetchable origin must not be discovered in CI
|
|
257
|
+
|
|
258
|
+
The common development case is `wairon pack install ./appenser-1.2.0.wpack`, whose
|
|
259
|
+
recorded origin is a **local path** — worthless to a runner. Left alone, that
|
|
260
|
+
surfaces as a red CI job long after the fact. So:
|
|
261
|
+
|
|
262
|
+
- `wairon pack use` inspects the recorded origin and, when it is not fetchable,
|
|
263
|
+
says so immediately and points at the two fixes.
|
|
264
|
+
- The selection carries the finding `PACK_SOURCE_UNFETCHABLE` — warning during
|
|
265
|
+
development, **error under `--ci` and `lock`**, the same grading as
|
|
266
|
+
`UNPINNED_PACK_SELECTION`. A project cannot be frozen in a state a clone
|
|
267
|
+
cannot reproduce.
|
|
268
|
+
- `wairon pack use <name> --source <url>` records a URL explicitly when it
|
|
269
|
+
differs from where the pack was installed from; `--bundle` chooses the other
|
|
270
|
+
route.
|
|
271
|
+
|
|
272
|
+
**`pack install` must learn to take a URL.** Today it cannot: `resolveSourceUnit`
|
|
273
|
+
(`commands/packs.ts:67`) does `path.resolve(source)` and requires
|
|
274
|
+
`fs.existsSync`, so a URL fails with *"Pack source does not exist."* — the only
|
|
275
|
+
route is download-by-hand, then pass the local `.wpack`. Adding URL support is
|
|
276
|
+
part of A8; the archive is fetched to a temp file and handed to the existing,
|
|
277
|
+
already-hardened SDK extraction path (`inspectArchive` → reject code packs →
|
|
278
|
+
`extractPack` under hosted-strict limits), so nothing about pack validation
|
|
279
|
+
changes.
|
|
280
|
+
|
|
281
|
+
### No implicit network
|
|
282
|
+
|
|
283
|
+
Fetching happens **only** in explicit `wairon pack install <url>` and `wairon pack
|
|
284
|
+
sync` (including via `doctor --fix`). It is never triggered by `validate`,
|
|
285
|
+
`status`, `generate`, or an MCP tool call, because "works offline (core)" is a
|
|
286
|
+
stated architectural invariant (`docs/roadmap.md`). Resolution reads the store
|
|
287
|
+
and the bundle; nothing else touches the network.
|
|
288
|
+
|
|
289
|
+
### Bundling: the answer to CI and sharing
|
|
290
|
+
|
|
291
|
+
`bundle: true` (or `wairon pack bundle <name> [--all]`) materializes the pack
|
|
292
|
+
into `.wai/packs/<name>/<version>/` for committing. The bundle then satisfies the
|
|
293
|
+
selection with **zero machine setup** — which is what makes CI, a fresh clone,
|
|
294
|
+
and handing the repo to someone else work without out-of-band instructions.
|
|
295
|
+
|
|
296
|
+
The store stays the ergonomic default for day-to-day work; bundling is the switch
|
|
297
|
+
you throw when the repo must be self-sufficient.
|
|
298
|
+
|
|
299
|
+
### The StateId must cover the resolved pack set
|
|
300
|
+
|
|
301
|
+
**A gap in wairon today, independent of this design but made sharper by it.**
|
|
302
|
+
|
|
303
|
+
`computeStateId` (`core/statehash.ts:29-40`) hashes only the spec tree — system,
|
|
304
|
+
subsystems, components, interfaces, implementations, types. **The pack set is
|
|
305
|
+
invisible to it.** So the commit-scoped lock can be defeated through doctrine
|
|
306
|
+
instead of specs:
|
|
307
|
+
|
|
308
|
+
1. `wairon lock` validates the tree under `appenser@1.2.0` and records a lock
|
|
309
|
+
scoped to StateId `X`.
|
|
310
|
+
2. The pack selection floats to `1.3.0` (or a pack is uninstalled, or the store
|
|
311
|
+
is replaced) — the *rules that validated the tree* are now different or gone.
|
|
312
|
+
3. The spec tree never changed, so the StateId is still `X`. The lock still
|
|
313
|
+
matches. **Promotion proceeds.**
|
|
314
|
+
|
|
315
|
+
That is precisely the stale-approval / time-of-check-to-time-of-use hole the
|
|
316
|
+
commit-scoped lock exists to close (feature request §4), entering through the
|
|
317
|
+
doctrine door. Locking a tree means "these specs pass this gate", and the pack
|
|
318
|
+
set *is* the gate.
|
|
319
|
+
|
|
320
|
+
**Implemented** — as a *second* identity, not a change to the existing one, because
|
|
321
|
+
the same StateId also stamps surface snapshots and drives their freshness
|
|
322
|
+
comparison, where doctrine is irrelevant. Folding doctrine into the one function
|
|
323
|
+
would have marked every vendored contract snapshot stale on a pack bump.
|
|
324
|
+
|
|
325
|
+
| Function | Covers | Used by |
|
|
326
|
+
|----------|--------|---------|
|
|
327
|
+
| `computeStateId()` | spec tree | surface snapshot stamps, `computeStateIdAt` freshness, landscape snapshots |
|
|
328
|
+
| `computeGateStateId()` | spec tree **+** doctrine projection | `wairon lock`, hosted lock, the promote-time re-check |
|
|
329
|
+
|
|
330
|
+
The doctrine projection covers only what can change a **verdict**: pack
|
|
331
|
+
identities (name/version/scope), the merged profile and language tables, patterns,
|
|
332
|
+
guarantee tokens, assertions, and programmatic rule names with their codes. Pack
|
|
333
|
+
`skills` and `instructions` are excluded — agent-facing prose cannot alter a
|
|
334
|
+
verdict, and including it would invalidate every lock on a documentation tweak.
|
|
335
|
+
|
|
336
|
+
`algorithm: 'sha256+doctrine'` marks the gate flavour, and `stateIdEquals`
|
|
337
|
+
compares the algorithm — so a lock record written before doctrine coverage can
|
|
338
|
+
never satisfy a gate comparison. Old locks read as **stale** and force a re-lock
|
|
339
|
+
rather than silently passing: the migration fails closed, which resolves what was
|
|
340
|
+
listed here as an open question.
|
|
341
|
+
|
|
342
|
+
Note the projection digests the *merged, resolved* profile and language tables, so
|
|
343
|
+
an in-place edit of a local YAML pack at the same `name@version` still shifts the
|
|
344
|
+
identity — which a version pin alone could not catch.
|
|
345
|
+
|
|
346
|
+
### `enforceReproducibility` gets teeth
|
|
347
|
+
|
|
348
|
+
When `true` (already the default), an unpinned, unbundled selection raises
|
|
349
|
+
`UNPINNED_PACK_SELECTION` — a warning during development, an error under `--ci`
|
|
350
|
+
and `lock` (see Version resolution). A floating version resolved off a mutable
|
|
351
|
+
machine store is exactly what the flag was named to prevent; grading it by
|
|
352
|
+
context means the default can finally be enforced without blocking day-to-day
|
|
353
|
+
work.
|
|
354
|
+
|
|
355
|
+
### `applyByDefault` — your "default apply at creation"
|
|
356
|
+
|
|
357
|
+
A store pack may declare `applyByDefault: true` in its manifest. `wairon init`
|
|
358
|
+
then writes it into the new project's selection (name + version + digest) —
|
|
359
|
+
explicit in `project.yaml`, visible in review, and removable. Opt out with
|
|
360
|
+
`wairon init --no-default-packs`.
|
|
361
|
+
|
|
362
|
+
This is what a machine-wide install *should* mean: a default for projects you
|
|
363
|
+
create from now on, not retroactive authority over every project on disk.
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
## CLI surface
|
|
368
|
+
|
|
369
|
+
| Command | Meaning |
|
|
370
|
+
|---------|---------|
|
|
371
|
+
| `wairon pack install <source>` | into the store; applies to nothing |
|
|
372
|
+
| `wairon pack uninstall <name>[@version]` | remove from the store |
|
|
373
|
+
| `wairon pack use <name>[@version]` | select for THIS project (writes `project.yaml`); `--source <url>` to record a fetch URL, `--bundle` to commit a copy instead |
|
|
374
|
+
| `wairon pack unuse <name>` | deselect |
|
|
375
|
+
| `wairon pack list` | store contents + this project's selections + resolution status |
|
|
376
|
+
| `wairon pack which <name>` | identify: resolved path, version, digest, origin (store or bundle) |
|
|
377
|
+
| `wairon pack sync` | install every declared-but-missing pack from its `source` |
|
|
378
|
+
| `wairon pack bundle <name> \| --all` | materialize into `.wai/packs/` for commit |
|
|
379
|
+
|
|
380
|
+
Deprecated, kept working with a warning: `pack add --global` → `pack install`;
|
|
381
|
+
`pack add` → `pack use` + `pack bundle`.
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
## CI: a reusable wairon setup step
|
|
386
|
+
|
|
387
|
+
A fresh runner has an empty store, so the gate would fail closed on
|
|
388
|
+
`PACK_NOT_INSTALLED` — correct, but it needs a one-line fix. Ship a composite
|
|
389
|
+
action at `.github/actions/setup-wairon` so consumers do not hand-roll it:
|
|
390
|
+
|
|
391
|
+
```yaml
|
|
392
|
+
- uses: SYW-Apps/Waffle-AIron/.github/actions/setup-wairon@v<version> # the version you lock with
|
|
393
|
+
with:
|
|
394
|
+
version: 5.1.0 # pin the wairon binary; default = latest stable
|
|
395
|
+
packs: sync # sync (default) | none | an explicit source list
|
|
396
|
+
- run: wairon validate --ci
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
- **`packs: sync`** — runs `wairon pack sync`, which needs no arguments because
|
|
400
|
+
every selection carries its own recorded `source`. This is the path that makes
|
|
401
|
+
a cloned repo work with zero workflow configuration.
|
|
402
|
+
- **`packs: none`** — for a repo that bundles (`bundle: true`); the committed
|
|
403
|
+
copies resolve and nothing is fetched.
|
|
404
|
+
- **An explicit list** — the escape hatch for a pack whose source is not recorded
|
|
405
|
+
or needs credentials:
|
|
406
|
+
```yaml
|
|
407
|
+
packs: |
|
|
408
|
+
https://github.com/org/appenser/releases/download/v1.2.0/appenser.wpack
|
|
409
|
+
https://${{ secrets.PACK_TOKEN }}@git.internal/org/private-pack.wpack
|
|
410
|
+
```
|
|
411
|
+
Each entry is passed to `wairon pack install <source>` — the same command a
|
|
412
|
+
human runs, so there is no CI-only code path to diverge.
|
|
413
|
+
|
|
414
|
+
Cache the store on the resolved pack digests (`~/.wairon/packs`), so a warm run
|
|
415
|
+
skips the downloads entirely. wairon's own `.github/workflows/ci.yml` needs none
|
|
416
|
+
of this, because this repo sets `useGlobalPacks: false` and selects no packs —
|
|
417
|
+
which is the point: its gate depends on nothing outside the repo.
|
|
418
|
+
|
|
419
|
+
## Remaining gaps this design closes or names
|
|
420
|
+
|
|
421
|
+
- **Uninstalling a selected pack.** `wairon pack uninstall` must refuse (or warn
|
|
422
|
+
loudly) when a known project selects it; today nothing connects the two.
|
|
423
|
+
- **Transitive pack dependencies** — a pack depending on another pack — remain
|
|
424
|
+
**out of scope**. Packs are leaves; a bundle of doctrine that needs another
|
|
425
|
+
bundle should be one pack or two independent selections.
|
|
426
|
+
- **`onMissingPack` is deliberately not a field.** An absent declared pack always
|
|
427
|
+
errors, everywhere.
|
|
428
|
+
|
|
429
|
+
## New findings
|
|
430
|
+
|
|
431
|
+
| Code | Severity | Meaning |
|
|
432
|
+
|------|----------|---------|
|
|
433
|
+
| `PACK_NOT_INSTALLED` | error | selected pack absent from store and bundle |
|
|
434
|
+
| `PACK_VERSION_UNSATISFIED` | error | present, but no installed version satisfies the range |
|
|
435
|
+
| `PACK_INTEGRITY_MISMATCH` | error | resolved content does not match the pinned digest |
|
|
436
|
+
| `PACK_STORE_DRIFT` | warning | bundle and store disagree for the same `name@version`. The bundle applies, so the store copy is silently ignored — reported so "my edit had no effect" is explained rather than mysterious. Compared only when a store copy of that exact version exists, so bundling costs no extra I/O |
|
|
437
|
+
| `UNSELECTED_STORE_PACK` | doctor-only | installed but applied by no route — **built as a `doctor` report rather than a named code** (it is project configuration, not a spec finding, so it never needed to enter the rule registry) |
|
|
438
|
+
| `UNPINNED_PACK_SELECTION` | warning; error under `--ci` / `lock` | floating selection under `enforceReproducibility` |
|
|
439
|
+
| `PACK_SOURCE_UNFETCHABLE` | warning; error under `--ci` / `lock` | selection is neither bundled nor fetchable (e.g. a local-path origin) |
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## Migration
|
|
444
|
+
|
|
445
|
+
The dangerous direction is a gate going *quiet*. Every step below is loud.
|
|
446
|
+
|
|
447
|
+
1. **Legacy refs keep loading.** String entries in `extensions.packs` resolve
|
|
448
|
+
exactly as today, with a deprecation notice.
|
|
449
|
+
2. **Flat store entries** are read as `<name>@0.0.0-unversioned`, so an existing
|
|
450
|
+
`~/.wairon/packs/appenser/` keeps resolving; `pack install` rewrites to the
|
|
451
|
+
versioned layout.
|
|
452
|
+
3. **`useGlobalPacks` default flips to `false`.** The field is still honoured, so
|
|
453
|
+
a project that explicitly sets `true` is unaffected.
|
|
454
|
+
4. **`wairon doctor` reports the delta** — store packs that no project selected,
|
|
455
|
+
selections that do not resolve, and bundle/store drift. It lists the store
|
|
456
|
+
packs a project does not select and names `wairon pack use <name>` for each;
|
|
457
|
+
`doctor --fix` never selects them for you, because what happens to be
|
|
458
|
+
installed on one machine is no decision of the project's (a member's approval
|
|
459
|
+
drifted the day a `--fix` recorded a pack nobody chose).
|
|
460
|
+
5. **A project with no `extensions` block and a non-empty store** gets an
|
|
461
|
+
informational notice on `validate`/`status` naming exactly what stopped
|
|
462
|
+
applying and how to re-select it.
|
|
463
|
+
|
|
464
|
+
`tests/core/extensions.test.ts` pins the current pre-flip default in
|
|
465
|
+
`documents the current default: an omitted useGlobalPacks auto-loads global
|
|
466
|
+
packs`, so step 3 is a deliberate one-line test change, not drift.
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
470
|
+
## Hosted alignment
|
|
471
|
+
|
|
472
|
+
The instance store replaces the machine store; the selection model is unchanged.
|
|
473
|
+
`InstancePackPolicy.defaultProjectPacks` becomes the hosted spelling of
|
|
474
|
+
`applyByDefault`, and `requiredGlobalPacks` becomes a policy-injected selection
|
|
475
|
+
every project carries. `sdd_host_pack_install` installs into the instance store;
|
|
476
|
+
`sdd_host_policy_reconcile` already does the reconcile half.
|
|
477
|
+
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
## Open questions
|
|
481
|
+
|
|
482
|
+
- ~~**Digest scope.**~~ **Resolved:** the `.wpack` envelope already carries a
|
|
483
|
+
per-entry sha256 map plus a manifest digest, and `inspectArchive` reports
|
|
484
|
+
`integrityVerified` (`sdk/src/types.ts:32-34`, `pack_archive_info`). The store
|
|
485
|
+
digest is a canonical fold of that same per-entry map, computed identically for
|
|
486
|
+
a directory install — so an archive install, a directory install, and a bundle
|
|
487
|
+
of the same pack all compare equal. Nothing new to invent.
|
|
488
|
+
- **A named registry.** `source` templates cover the release-asset case, but every
|
|
489
|
+
project repeats the URL. Resolving `appenser@1.2.0` through an index is the
|
|
490
|
+
cleaner end state and a materially larger design — out of scope here rather
|
|
491
|
+
than half-specified. Worth revisiting only once more than a handful of packs
|
|
492
|
+
exist in the wild.
|
|
493
|
+
- **Profile restriction.** `projectType`/`profile` are already project-declared;
|
|
494
|
+
worth deciding whether a selection may also *restrict* which of a pack's
|
|
495
|
+
profiles are offered, mirroring hosted `allowedProfileIds`.
|
|
496
|
+
- ~~**StateId migration.**~~ **Resolved by the algorithm marker:** existing lock
|
|
497
|
+
records carry `algorithm: 'sha256'` and therefore never satisfy a
|
|
498
|
+
`'sha256+doctrine'` comparison, so they read as stale and force a re-lock
|
|
499
|
+
instead of silently passing. Correct (those locks were taken without doctrine
|
|
500
|
+
coverage and cannot be retro-verified) and it fails closed — still worth a
|
|
501
|
+
release-note line.
|
|
502
|
+
- ~~**Should the wairon version join the gate identity?**~~ **Resolved: no — the
|
|
503
|
+
rule REGISTRY does instead.** Hashing the version would invalidate every lock on
|
|
504
|
+
every patch; hashing nothing would let a minor that adds a rule leave locks
|
|
505
|
+
asserting they passed a gate that no longer exists. The identity keys on the
|
|
506
|
+
builtin rule set (names, codes, default severities), so a release touching no
|
|
507
|
+
rule keeps every lock valid and one that adds, removes, or re-grades a code
|
|
508
|
+
invalidates exactly the locks it should. The project's governing `projectType`
|
|
509
|
+
and `rules` config join it for the same reason — both decide verdicts.
|
|
510
|
+
Residual gap, accepted knowingly: a rule whose *implementation* grows stricter
|
|
511
|
+
without its name, codes, or default severity changing is not caught. Catching it
|
|
512
|
+
needs the version, at the cost of churning every lock on every release.
|
|
513
|
+
|
|
514
|
+
---
|
|
515
|
+
|
|
516
|
+
## Implementation order
|
|
517
|
+
|
|
518
|
+
A3 before A4 — name-based refs must exist before the default flips — and A7
|
|
519
|
+
with or before A4, so migration reporting lands before behaviour changes.
|
|
520
|
+
|
|
521
|
+
1. ~~**A1**~~ **DONE** — store model: versioned `<name>/<version>/` layout,
|
|
522
|
+
content digests, per-pack install records, `pack install`/`uninstall`/`which`,
|
|
523
|
+
and back-compat with the pre-versioned flat store (including teaching
|
|
524
|
+
`discoverPacks` the versioned layout, so an installed pack never silently
|
|
525
|
+
becomes invisible to the legacy auto-load path).
|
|
526
|
+
2. ~~**A2**~~ **DONE** — `pack use`/`unuse` + the `PackSelection` schema
|
|
527
|
+
(name-only, optional `version`/`integrity`/`source`/`bundle`). `use` copies the
|
|
528
|
+
store's recorded origin into the selection, so the project self-describes how
|
|
529
|
+
to obtain its doctrine, and warns when that origin is not fetchable.
|
|
530
|
+
3. ~~**A3**~~ **DONE** — name-based resolution with bundle-then-store order and
|
|
531
|
+
latest-installed when unpinned (landed with A2, because a selection you can
|
|
532
|
+
write but not resolve is worse than useless), plus the distinct finding codes
|
|
533
|
+
as a registered `pack-resolution` rule: `PACK_NOT_INSTALLED` (absent),
|
|
534
|
+
`PACK_VERSION_UNSATISFIED` (installed, but the pin is not — the message names
|
|
535
|
+
what *is* installed), and `PACK_INTEGRITY_MISMATCH` (content off the pinned
|
|
536
|
+
digest). All error severity, all in `wairon rules list` and therefore tunable
|
|
537
|
+
via `rules.sddRuleSeverity`, unlike the generic `EXTENSION_LOAD_ERROR` they
|
|
538
|
+
replaced. Resolution stays in the loader and the rule only surfaces what it
|
|
539
|
+
found, so the two can never disagree about whether a pack applies.
|
|
540
|
+
4. ~~**A7**~~ **DONE** — `wairon doctor` reports unresolvable selections,
|
|
541
|
+
installed-but-unapplied packs, and opted-in machine-wide packs, naming
|
|
542
|
+
`wairon pack use <name>` for each pack to select. `doctor --fix` no longer
|
|
543
|
+
records installed packs as selections: the machine's store is never the
|
|
544
|
+
project's choice.
|
|
545
|
+
5. ~~**A4**~~ **DONE** — `useGlobalPacks` defaults to `false`, behind the single
|
|
546
|
+
`GLOBAL_PACKS_DEFAULT` constant that every reader and config writer shares.
|
|
547
|
+
6. ~~**A5**~~ **DONE** — a store pack declaring `applyByDefault: true` is seeded
|
|
548
|
+
into the selections of projects created from then on by `wairon init`, as an
|
|
549
|
+
explicit name@version entry. A pack whose manifest will not load seeds nothing.
|
|
550
|
+
7. ~~**A6**~~ **DONE** — the flag was declared, defaulted to true, and read
|
|
551
|
+
NOWHERE. A new `pack-reproducibility` rule raises
|
|
552
|
+
`UNPINNED_PACK_SELECTION` and `PACK_SOURCE_UNFETCHABLE` as warnings while you
|
|
553
|
+
work and errors under `--ci`, so a one-line `pack use` stays frictionless
|
|
554
|
+
while CI refuses a pack set it cannot reproduce. A bundled selection is exempt:
|
|
555
|
+
its committed bytes ARE the pin.
|
|
556
|
+
8. ~~**A8**~~ **DONE** — `pack bundle` (a committed copy that resolves before the
|
|
557
|
+
store, pinning the selection's version so the bytes and the declared intent
|
|
558
|
+
cannot drift), URL support in `pack install` (fetched to a temp file, then the
|
|
559
|
+
identical SDK archive path — no second validation route), and `pack sync`,
|
|
560
|
+
which takes **no arguments** because every selection carries its own source.
|
|
561
|
+
`{version}` and `${VAR}` expansion make one recorded template serve pinned,
|
|
562
|
+
floating, and private-with-a-CI-token cases. Fetching lives only in these two
|
|
563
|
+
commands, never in resolution, so the offline invariant holds.
|
|
564
|
+
9. ~~**A9**~~ **DONE** — the gate StateId. Implemented as a *second* identity
|
|
565
|
+
rather than a change to the existing one: `computeStateId` still hashes the
|
|
566
|
+
spec tree alone (surface snapshot stamps, freshness comparisons), and
|
|
567
|
+
`computeGateStateId` hashes the tree plus the doctrine projection. `lock` and
|
|
568
|
+
the promote-time re-check use the gate flavour; `algorithm: 'sha256+doctrine'`
|
|
569
|
+
makes the two incomparable, so pre-upgrade lock records read as stale.
|
|
570
|
+
10. ~~**A10**~~ **DONE** — `.github/actions/setup-wairon`, with `packs: sync |
|
|
571
|
+
none | <explicit list>` and the store cached on `.wai/project.yaml` (that file
|
|
572
|
+
*is* the declaration of what the store must contain). Landing it exposed that
|
|
573
|
+
`install.sh` had no way to pin a version at all — it always fetched latest —
|
|
574
|
+
so the action's `version` input would have silently lied. `install.sh` now
|
|
575
|
+
honours `WAIRON_VERSION`, which is what makes a pinned CI install real.
|
|
576
|
+
(`install.ps1` still has no equivalent; a Windows runner cannot pin yet.)
|
|
577
|
+
|
|
578
|
+
A3 before A4 (name refs must exist before the default flips). A7 before A4
|
|
579
|
+
(reporting before behaviour). A8 before A10 (the action calls `pack sync`). A9 is
|
|
580
|
+
independent and can land at any point — it is a correctness fix that stands on its
|
|
581
|
+
own, so it need not wait for the rest.
|