@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.
Files changed (37) hide show
  1. package/CONTRIBUTING.md +193 -0
  2. package/README.md +154 -41
  3. package/dist/cli/index.js +66474 -29775
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/index.d.ts +43671 -0
  6. package/dist/index.js +53581 -21797
  7. package/dist/index.js.map +1 -1
  8. package/dist/templates/architect.yaml +15 -13
  9. package/dist/templates/domain-owner.yaml +5 -4
  10. package/dist/templates/skills/sdd-architect.md +44 -26
  11. package/dist/templates/skills/sdd-auditor.md +5 -4
  12. package/dist/templates/skills/sdd-delegate.md +43 -4
  13. package/dist/templates/skills/sdd-implement.md +83 -13
  14. package/dist/templates/skills/sdd-narrative.md +7 -4
  15. package/dist/templates/variants/logic-shapes.yaml +59 -0
  16. package/dist/templates/variants/portal-shapes.yaml +13 -0
  17. package/dist/webapp.html +771 -0
  18. package/docs/architecture.md +83 -0
  19. package/docs/cli.md +1208 -0
  20. package/docs/design/approval-baseline.md +257 -0
  21. package/docs/design/connecting-agent-entrypoint.md +167 -0
  22. package/docs/design/execution-budgets.md +199 -0
  23. package/docs/design/pack-scoping.md +581 -0
  24. package/docs/design/supervisor-doctrine.md +110 -0
  25. package/docs/design-export.md +313 -0
  26. package/docs/extending-wairon.md +616 -0
  27. package/docs/network.md +109 -0
  28. package/docs/requirements.md +65 -0
  29. package/docs/roadmap.md +133 -0
  30. package/docs/standards/INDEX.md +23 -0
  31. package/docs/standards/architecture.md +1042 -0
  32. package/docs/standards/language-bindings.md +120 -0
  33. package/docs/templates.md +61 -0
  34. package/docs/vision.md +59 -0
  35. package/docs/wairon-feature-requests-hosted-mcp.md +74 -0
  36. package/package.json +18 -5
  37. 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.