@wairon/cli 5.1.1-dev.108 → 5.1.1-dev.109

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/docs/cli.md CHANGED
@@ -25,6 +25,16 @@ nothing and printing the exact `wairon member add <alias> <path> --project` to
25
25
  run from the parent (or: make the folder its own repository with `git init` and
26
26
  re-run `wairon init` for an independent project).
27
27
 
28
+ **What to commit.** All of `.wai/` — the specs, `project.yaml`, `lock.json`,
29
+ `externals/` and `externals.lock.yaml`, vendored packs: the gate reads committed
30
+ files only (a migration's scratch folder, `.wai/transactions/`, ignores itself).
31
+ The generated `CLAUDE.md`/`GEMINI.md`, `.claude/` (or `.gemini/`) skills and
32
+ `.mcp.json` hold no machine path: commit them and every clone's assistant starts
33
+ with them, or ignore them and run `wairon generate` after a clone — either way
34
+ `wairon generate` / `wairon doctor --fix` refreshes them.
35
+ wairon writes no `.gitignore` — your toolchain's own entries (`node_modules/`,
36
+ `dist/`, …) are all it needs.
37
+
28
38
  #### Which project a command acts on
29
39
  Every command, the MCP server (`wairon mcp serve`) and `wairon dev` bind the same
30
40
  project: from the folder you are in, the nearest folder whose `.wai/specs` holds
@@ -36,6 +46,15 @@ repository the walk is unbounded. Each command that acts on a project prints
36
46
  `project <id> at <root>` on stderr first, so `wairon export` and every `--json`
37
47
  output keep a pure stdout.
38
48
 
49
+ #### When a command refuses
50
+ A refusal is one `✖` line and a non-zero exit — never a Node stack trace, whatever
51
+ the input. A `.wai/project.yaml` that does not parse as YAML (most often a key
52
+ written twice, e.g. a second `rules:` block appended by hand) stops every command
53
+ the same way, naming the file, the line and the key —
54
+ `✖ .wai/project.yaml:33: duplicated mapping key "rules" — …` — and `wairon doctor`
55
+ diagnoses it too; no command runs on defaults instead. To report a bug, set
56
+ `WAIRON_DEBUG=1` and the stack is printed after the line.
57
+
39
58
  ### `wairon status [--subsystem <id>] [--no-recursive] [--all]`
40
59
  Print the SDD spec tree as a hierarchy. Its percentages measure **authoring
41
60
  progress** — 80% once a component's component, contract and implementation specs
@@ -76,7 +95,8 @@ warnings as errors (notices are printed and counted, never fatal).
76
95
  says so in one line, so a green member run is never read as a proven one.
77
96
  **In CI, run `wairon validate --ci` at the family root**, after installing
78
97
  the project's dependencies (`npm ci` or your package manager's equivalent —
79
- the gate reads TypeScript/JavaScript code with the project's own TypeScript;
98
+ the gate reads TypeScript/JavaScript code with the project's own TypeScript
99
+ when it is 5 or 6, and with the copy wairon ships otherwise;
80
100
  see [Using it in GitHub Actions](#using-it-in-github-actions)); a member in
81
101
  its own repository adds the root's run to its pipeline or relies on the root
82
102
  repository's.
@@ -116,9 +136,20 @@ warnings as errors (notices are printed and counted, never fatal).
116
136
  collaborator is collaboration whatever the import's form: when its type comes
117
137
  from a component the caller never declared, even through `import type`, it is
118
138
  `UNDECLARED_DEPENDENCY`; a Portal calling a write- or lifecycle-effect method
119
- of a Repository, Index, Store or Registry that way is
139
+ of a Repository, Index, Store or Registry is
120
140
  `PORTAL_WRITE_SHORTCUT_IN_CODE` (error, the code twin of
121
- `PORTAL_WRITE_SHORTCUT`); and a file importing a technology's package
141
+ `PORTAL_WRITE_SHORTCUT`). A Portal's calls are resolved by the TypeScript
142
+ type checker, so how the receiver is written does not matter: the call lands
143
+ on the method the checker resolves it to, and a call through an interface or
144
+ type — a port declared in the Portal's own contracts file included — lands on
145
+ every class of the project that realizes it (the ones that say `implements`
146
+ when any does, else every class the checker finds assignable). A call the
147
+ checker cannot resolve because its receiver is typed `any` or `unknown`, cast
148
+ to either, or typed nothing, under the name of a data component's write, is
149
+ `PORTAL_CALL_UNRESOLVED` (a warning, so `--ci` fails): give the receiver its
150
+ type. An Orchestrator (or any component) whose code calls a write- or
151
+ lifecycle-effect method of another component that its narrative never
152
+ claims is `UNDECLARED_WRITE_CALL`. And a file importing a technology's package
122
153
  outside the components that bind it is `TECH_LEAKAGE_IN_CODE`. A technology's
123
154
  packages are its declared tokens (its name, or its `matches`) plus the common
124
155
  packages wairon knows for it — see [Technologies and their
@@ -153,6 +184,11 @@ write the technology as `{ name, matches }` (each `matches` token is a package
153
184
  too), or have a pack contribute `technologyPackages: { <technology>: [<package>,
154
185
  …] }`.
155
186
 
187
+ A package of a technology that **no** implementation binds is reported too:
188
+ the code uses a technology the design never placed, so every importer is
189
+ outside its (missing) home. Bind it on the data-layer component that owns the
190
+ vendor call (`technologies: [redis]`), and that component becomes its home.
191
+
156
192
  ### `wairon generate [--target <name>] [--domain <id>] [--domains <ids>] [--root] [--family] [--no-prune] [--global] [--dry-run]`
157
193
  Reconcile the generated guides, skills and context, and — only when the project
158
194
  opts in — write the agent files from the spec-derived topology.
@@ -194,7 +230,13 @@ approval lives in `.wai/lock.json`. Implementation gates on that approval —
194
230
  **What it certifies is the design.** Only design findings can refuse a lock.
195
231
  Code-conformance findings (the code↔spec checks) are recorded **beside** the
196
232
  claim in the record's `code` block, with the analyzer that produced them, and
197
- printed as `code: N error(s), … recorded beside the claim`. CI enforces them:
233
+ printed as `code: N error(s), … recorded beside the claim`, with the analysis
234
+ **grade** — the weakest any analyzed file got: `exact` (read with the project's
235
+ TypeScript compiler API: declarations, imports and calls resolved), `pattern`
236
+ (read through the language's declaration and import patterns — recognized, not
237
+ resolved), `generic` (word-boundary identifiers only, the floor for any
238
+ language), or `none` (no file analyzed yet: no code, or none the specs name).
239
+ CI enforces them:
198
240
  `wairon validate --ci` still fails on a code error. A design can be approved
199
241
  before its code exists.
200
242
 
@@ -331,12 +373,17 @@ merging is the thing that was reviewed.
331
373
 
332
374
  #### Using it in GitHub Actions
333
375
 
334
- This repository publishes it as a **reusable workflow**. **6.0.0 is the first
335
- release that ships the workflow and the `lock-check` command** — earlier releases
336
- have neither, and no `v6.0.0` tag exists before that release. On a dev build (and
337
- until 6.0.0 is out), pin the dev tag matching `wairon --version` instead: a CLI
338
- reporting `5.1.1-dev.107` pins `@v5.1.1-dev.107` with `wairon-version:
339
- '5.1.1-dev.107'`. Add one job to your own workflow:
376
+ This repository publishes it as a **reusable workflow**. Pin it — and the CLI it
377
+ installs — to **the version you lock with**: the one `wairon --version` prints
378
+ where you run `wairon lock`. Every published version has a git tag `v<version>`
379
+ and the same version on npm: a release `X.Y.Z` is `@vX.Y.Z` with
380
+ `wairon-version: 'X.Y.Z'`; a dev build `X.Y.Z-dev.N` is `@vX.Y.Z-dev.N` with
381
+ `wairon-version: 'X.Y.Z-dev.N'` (the npm `dev` dist-tag names the newest dev
382
+ build, but it moves — pin the exact version). Check that the version you pin is
383
+ on npm before you commit the job: `npm view @wairon/cli@<version> version`. The
384
+ workflow and the `lock-check` command first ship with 6.0.0 and its dev builds;
385
+ an older version has neither. Below, `<version>` stands for yours. Add one job
386
+ to your own workflow:
340
387
 
341
388
  ```yaml
342
389
  # .github/workflows/ci.yml in YOUR repository
@@ -344,9 +391,9 @@ on: [pull_request]
344
391
 
345
392
  jobs:
346
393
  approved-design:
347
- uses: SYW-Apps/Waffle-AIron/.github/workflows/lock-check.yml@v6.0.0
394
+ uses: SYW-Apps/Waffle-AIron/.github/workflows/lock-check.yml@v<version>
348
395
  with:
349
- wairon-version: '6.0.0'
396
+ wairon-version: '<version>'
350
397
  strict: true # a deleted .wai/lock.json fails instead of switching the gate off
351
398
  ```
352
399
 
@@ -354,10 +401,10 @@ With inputs (all optional):
354
401
 
355
402
  ```yaml
356
403
  approved-design:
357
- uses: SYW-Apps/Waffle-AIron/.github/workflows/lock-check.yml@v6.0.0
404
+ uses: SYW-Apps/Waffle-AIron/.github/workflows/lock-check.yml@v<version>
358
405
  with:
359
406
  working-directory: packages/api # where the .wai/ tree lives (default: .)
360
- wairon-version: '6.0.0' # version or npm dist-tag, 6.0.0 or later (default: latest)
407
+ wairon-version: '<version>' # the version you lock with, or an npm dist-tag (default: latest)
361
408
  strict: true # fail when nothing was approved — use it once you have locked (default: false)
362
409
  validate: true # also run `wairon validate --ci` there (default: true)
363
410
  install: auto # the project's dependencies before validate: auto | none | <command> (default: auto)
@@ -380,10 +427,13 @@ directory (`validate: false` turns it off). Point `working-directory` at the
380
427
  family run, which judges the network proofs no member's own gate can.
381
428
 
382
429
  **The project's dependencies come first.** The conformance gate reads
383
- TypeScript/JavaScript code with the project's own TypeScript compiler. A fresh
384
- checkout has none until the project's dependencies are installed, and then every
385
- file is analyzed below exact grade and `--ci` fails on `CONFORMANCE_DEGRADED` —
386
- a finding about the runner, not your design. So before `validate --ci` the job
430
+ TypeScript/JavaScript code with the project's own TypeScript compiler when it is
431
+ TypeScript 5 or 6, and with the copy wairon ships otherwise — a fresh checkout,
432
+ a project without TypeScript, or one on TypeScript 7 (whose native compiler has
433
+ no JavaScript API) is still read at exact grade, and `CONFORMANCE_DEGRADED` is
434
+ left for a file the compiler could not read at all. Installing the project's
435
+ dependencies keeps the analysis on the project's own compiler. So before
436
+ `validate --ci` the job
387
437
  installs them with the project's own package manager (`install: auto`, the
388
438
  default): nothing without a `package.json`; otherwise the nearest lockfile from
389
439
  `working-directory` up to the repository root decides — `package-lock.json` →
@@ -411,9 +461,9 @@ jobs:
411
461
  - uses: actions/setup-node@v4
412
462
  with:
413
463
  node-version: '20'
414
- - run: npm install --global @wairon/cli@6.0.0 # the version you lock with
464
+ - run: npm install --global @wairon/cli@<version> # the version you lock with (wairon --version)
415
465
  - run: wairon lock-check --strict
416
- - run: npm ci # the project's dependencies: the gate reads your code with your TypeScript
466
+ - run: npm ci # the project's dependencies: the gate reads your code with your TypeScript (5 or 6)
417
467
  - run: wairon validate --ci
418
468
  ```
419
469
 
@@ -1060,18 +1110,19 @@ return the impact of every pack they applied in their results.
1060
1110
  | `wairon externals add <alias> [<source>] [--project <id>] [--ref <ref>] [--dir <dir>] [--use a,b\|'*'] [--description <text>] [--no-pin] [--dry-run] [--json]` | Declare one external in `.wai/project.yaml`. The source is the location grammar members use: `../sibling`, `hosted:<id>`, `<git url>` or `<git url>#<commit>` (the commit is the ref the pin follows, fixed there); omit it when the family provides the producer. Text that is no location (braces, quotes, whitespace) is refused. The declaration is checked against the producer it reaches **before** anything is written — `--dry-run` included: one answering to another id, or not exporting a `use` name to this project, is refused naming the id, the closest exported names, or the narrower audience the name is exported to (see Export audiences). Pins by default; a producer that cannot be read leaves it declared and unpinned, saying why, and exits 2 (nothing was pinned). A refusal is one sentence naming the accepted form, and exits 1 |
1061
1111
  | `wairon externals use <alias> [--add a,b\|'*'] [--remove c] [--dry-run] [--json]` | Change one declared external's `use` imports, so the specs may name the producer's public names bare (e.g. so an `implements` of the producer's trait spells its types as the producer does — though `alias::name` and the bare imported name compare as one type either way). An added name the producer does not export to this project is refused. The pin is untouched. Exits 1 on a refusal |
1062
1112
  | `wairon externals remove <alias> [--dry-run] [--json]` | Remove one external: its declaration and its pin (`.wai/externals/<alias>.yaml` and its lock entry) together. An orphaned pin — one whose declaration was deleted by hand, which `externals list` shows — is removed too. Exits 1 when the alias is neither declared nor pinned |
1063
- | `wairon externals consumers [--json]` | From a producer's root: the family projects in reach that consume it — each with the alias and section it declares it under and the public names its specs use — so a producer sees who breaks before it changes its surface. A sibling checkout, git or hosted consumer outside the family declares its dependency on its own side and is not visible here |
1113
+ | `wairon externals consumers [--search <dirs...>] [--json]` | From a producer's root: the projects that consume it — each with the alias and section it declares it under and the public names its specs use — so a producer sees who breaks before it changes its surface. Without `--search`, the family read from the highest root in reach. `--search ..` (any folders) also scans those folders and the project roots directly under them for projects declaring this one as an external by a path that resolves here, or by git — a sibling checkout is found this way. A hosted consumer, or one on another machine, declares its dependency on its own side and is not visible here (`sdd_list_consumers` is the same answer for an assistant) |
1064
1114
  | `wairon externals pin [alias…] [--json]` | Pin declared externals into `.wai/externals/<alias>.yaml` and `.wai/externals.lock.yaml`. The snapshot is rewritten whenever anything it carries moved — not only the signatures the digest covers: a producer that added `abi: c`, changed a transport or a role, or recorded a rename is refreshed by a re-pin. Exits 1 when an alias could not be pinned (unresolved or unreachable — its previous pin stays) |
1065
1115
  | `wairon externals status [--json]` | Each pin compared with its live producer per used member — `unchanged`, `changed`, `renamed` (with the new name), `removed`, `unlocked`, `unavailable` — and each external's health (`incompatible`, `not compared`, `drifted`, `ok`); a pinned snapshot that no longer carries what the producer says (a stale `abi`, transport or role) is `drifted`, never `ok`, and names the stale facts. A use the lock does not hold is still compared with the live producer: gone from it, it is `removed` or `renamed`. Git producers are fetched. The opt-in **live** gate: exits 1 when any external is incompatible, 2 when nothing is incompatible but something could not be compared (never a pass), 0 otherwise |
1066
1116
  | `wairon externals list [--json]` | The declared externals, how each resolves and what is pinned; a malformed declaration, and an orphaned pin whose declaration is gone, are listed with their problem, never hidden |
1067
- | `wairon surface export \| import \| list [--audience <level>] [--format native\|openapi] [--portal <id>] [--out <path>] [--source <path>]` | Exchange a public surface document: export this project's (native snapshot or one OpenAPI document per portal), import one, or list them |
1117
+ | `wairon surface export \| import \| list [--audience <level>] [--format native\|openapi] [--portal <id>] [--out <path>] [--source <path>]` | Exchange a public surface document: export this project's (native snapshot or one OpenAPI 3.1 document per portal — a `{name}` segment of an endpoint path is a parameter `in: path`, the other params the query of a GET/DELETE or the JSON body of a POST/PUT/PATCH), import one, or list them. An unknown `--portal` is refused naming the portals the surface renders |
1118
+ | `wairon surface diff [--against <ref\|file>] [--json]` | The public-surface changelog: this project's export table now against the same table at its last **committed** approval (or at a git revision, or in a saved native snapshot from `surface export`) — every exported name and contract method `added`, `removed`, `renamed` (from its rename trace) or `changed` (signature or type shape), and how many a consumer may have to follow. What a producer writes release notes from before it re-locks; `wairon externals consumers --search <dir>` then says who uses what. Read-only. With no approval ever committed it says so (lock and commit first, or name `--against`). `sdd_surface_diff` is the same answer for an assistant |
1068
1119
  | `wairon produce <notion\|miro> [--page <id>] [--token <token>]` | Project the local spec tree to Notion or Miro (the token comes from `--token`, the environment, else a prompt; nothing is stored) |
1069
1120
 
1070
1121
  ## Tooling
1071
1122
 
1072
1123
  | Command | Description |
1073
1124
  |---------|-------------|
1074
- | `wairon dev [--port <port>] [--open]` | A local single-project dev server: the wairon web UI over the current project, no login or tenancy (loopback only) |
1125
+ | `wairon dev [--port <port>] [--open]` | A local single-project dev server: the wairon web UI over the current project, no login or tenancy (loopback only). It serves the same project over MCP (streamable HTTP) at `POST http://127.0.0.1:<port>/mcp?project=local` — the current project is registered as `local`; without `?project=local` the endpoint answers 403. For an AI tool on the same machine the stdio server (`wairon mcp serve`, what `wairon mcp install` registers) remains the usual route |
1075
1126
  | `wairon update [--check] [--channel <name>]` | Check/install the latest release; switch channel |
1076
1127
  | `wairon aliases list` | Show command aliases (`wai`) and their status |
1077
1128
  | `wairon aliases enable <name>` / `disable <name>` | Create / remove an alias |
@@ -0,0 +1,257 @@
1
+ # Approval baselines — what `lock` approves, and why it stopped writing
2
+
3
+ > Status: **implemented**. Written against wairon 5.1.x. The "problem" section
4
+ > describes the behaviour as it was BEFORE this work, not as it is now.
5
+
6
+ ---
7
+
8
+ ## The problem
9
+
10
+ `wairon lock` had grown two jobs fused into one. The first is the one it was
11
+ designed for: **a human reviews the design and approves it**. The second was
12
+ bookkeeping — and the bookkeeping is what people actually experienced.
13
+
14
+ Three symptoms, all the same cause:
15
+
16
+ **It flooded the working tree.** Approving ratcheted every spec's `status` from
17
+ `draft` to `complete` on disk. On this project's own tree that is **786 files**
18
+ rewritten for a decision that changed no design. A lock scoped to one subsystem
19
+ still rewrote everything it could reach, and the specs a human had actually
20
+ edited were buried under files whose content had not changed.
21
+
22
+ **It reported a verdict nobody could act on.** `wairon status` printed:
23
+
24
+ ```
25
+ Lock: STALE — the specs or the governing doctrine changed since 2026-08-12,
26
+ so this lock no longer holds. Re-run `wairon lock`.
27
+ ```
28
+
29
+ …on a tree validating **0 errors / 0 warnings**. The banner named nothing to
30
+ look at, offered no way to approve part of the change, and asked for work that
31
+ produced no new information. It could only ever say "something moved", because
32
+ a `StateId` is a hash: it answers *did anything change*, never *what*.
33
+
34
+ **It fought the maintainer's own workflow.** `.wai/phased_design.md` records the
35
+ blanket draft→complete freeze being reverted by hand **four times** (lines 18,
36
+ 27, 34, 41), once annotated *"product gap: lock needs phase awareness"*.
37
+
38
+ And a second gate had grown on top of the first — see
39
+ [the removed `promote` step](https://github.com/SYW-Apps/Waffle-AIron/blob/main/docs/design/hosted-mcp-server.md#4-state-scoped-lock).
40
+
41
+ ---
42
+
43
+ ## The model
44
+
45
+ One addition retires five concepts: **record what was approved per spec, not one
46
+ hash of the whole tree.**
47
+
48
+ A review needs two things — the current tree and the tree as it stood when
49
+ someone approved it. wairon had the first and only a whole-tree fingerprint of
50
+ the second. With **one content digest per spec file**, the question changes from
51
+ *"did anything move?"* to *"what moved?"*, which is the only form a human can
52
+ review.
53
+
54
+ `src/core/approval.ts` derives it; `.wai/lock.json` carries it. Three properties
55
+ are load-bearing.
56
+
57
+ ### It is committed, and it is one file
58
+
59
+ The approval rides in the lock record, which was always committed. That is what
60
+ lets a **teammate, a fresh clone and CI** see the same approval the approver saw
61
+ — an earlier revision of this design kept it in `~/.wairon/baselines/`, where
62
+ nobody else could read it, so every other checkout judged an approved tree as
63
+ unapproved.
64
+
65
+ Digests rather than content is what makes that affordable: for this project's
66
+ 786 specs, **~90 KB instead of ~2 MB**. And no consumer ever needed the content
67
+ — `diffAgainstApproval` and `settledSpecPaths` only ever ask whether a spec
68
+ still matches. For the content itself there is already git: the record is
69
+ committed, so `git diff <lock commit> -- .wai/specs` is the real diff, and
70
+ better.
71
+
72
+ Keys are written **sorted**, so re-approving a one-spec change produces a
73
+ **two-line diff** — the timestamp and that spec's digest — in a 96 KB file.
74
+ Measured on this repo. That is a good signal in a PR rather than noise, and it
75
+ is categorically different from the ratchet this replaced, which rewrote
76
+ hundreds of *spec* files for a decision that changed no design. A test asserts
77
+ the spec tree is byte-identical after `lock`.
78
+
79
+ Digests **normalize line endings** before hashing. This is not tidiness: git
80
+ rewrites line endings on checkout (`core.autocrlf`), so an approval taken on
81
+ Windows would otherwise report every spec as drifted on a Linux CI runner. The
82
+ defect was unreachable while the record was machine-local — sharing it is what
83
+ made it live.
84
+
85
+ ### It is per project root, and members are pinned by their subjects
86
+
87
+ Every `.wai` owns its own lock record, including each member project. A
88
+ parent's approval never freezes a member's in-flight work, a member cloned on
89
+ its own carries its approval with it, and a parent lock writes nothing below
90
+ itself.
91
+
92
+ What crosses between them is a **composition subject**: the `stateId` in the
93
+ member's own lock record, read and never recomputed. Each direct member's
94
+ subject is an input of the parent's gate identity, and the parent's record
95
+ (format 2) lists it under `members` with the member's state as the parent saw
96
+ it — `approved` (its lock matches its tree), `drifted` (it has a lock, its tree
97
+ changed) or `never` (no lock). Approval is transitive by hashing: a member's
98
+ subject already includes its own members' subjects, so nothing recurses into a
99
+ member's files. A member editing its own specs does not appear in the parent's
100
+ diff or move the parent's identity — the decisions are separate — until the
101
+ member re-locks; then its subject moves and the parent's approval reads stale.
102
+ `wairon status` prints the pin tree (each member's state from its own root,
103
+ and whether the parent's pin `matches`, has `moved`, or is `unpinned`).
104
+
105
+ A parent may require its members' approvals with
106
+ `composition.requireApprovedMembers: true`: its lock then refuses while a
107
+ direct member is drifted or never approved, naming each. By default it proceeds
108
+ and records their states.
109
+
110
+ This replaced a stage-1 `children` map that copied each child's `StateId` into
111
+ the parent's record without ever re-verifying it; `children` is still read for
112
+ one release and never written.
113
+
114
+ ### Settledness is derived, not stored
115
+
116
+ This is what replaced the on-disk `status` ratchet, and it needed care, because
117
+ the ratchet was doing real work. The MCP authoring tools **always** write
118
+ `status: 'draft'` (`src/mcp/server.ts:825, 1027, 1125, 1331`); draft specs get
119
+ `COMPLETENESS_RULES` findings downgraded from error to warning; and `lock` was
120
+ the only thing that ever promoted. Delete it naively and the gate goes
121
+ **permanently soft** — which is exactly how an `UNEXPECTED_IMPLEMENTATION_METHOD`
122
+ came to surface days late in real use.
123
+
124
+ The record already knows which specs were approved and which have moved, so a
125
+ spec that is *approved and unchanged* is presented to the rules as `complete` —
126
+ in memory, restored in `finally`, the same mechanism `treatAllAsComplete`
127
+ already used (`src/core/validation.ts`). Nothing is written.
128
+
129
+ Two things get strictly better than the ratchet:
130
+
131
+ - **It is bidirectional.** The on-disk version was one-way by design
132
+ (`allowStatusDemotion` exists to stop a re-add reopening a locked spec), so an
133
+ edited spec stayed marked complete and in-flux work kept being judged at full
134
+ strictness with no way back short of a manual demotion. A spec that drifts
135
+ after approval now returns to draft context by itself.
136
+ - **It cannot drift.** Derived state has no second copy to fall out of sync.
137
+
138
+ ---
139
+
140
+ ## What `lock` does now
141
+
142
+ 1. Capture the gate identity (own specs, design doctrine, declared inputs,
143
+ `composition`, each direct member's subject).
144
+ 2. Validate as complete (full strictness, no draft relaxation, mutates nothing).
145
+ Only the **design** half may refuse; code-conformance results are recorded
146
+ beside the claim (`code`, with the analyzer that produced them) and enforced
147
+ by `validate --ci`, not by the lock.
148
+ 3. Report what moved since the last approval, the code results, and each direct
149
+ member's state; refuse under `composition.requireApprovedMembers` if a member
150
+ is not approved.
151
+ 4. Ask.
152
+ 5. Confirm the gate identity is still the captured one (refuse, writing nothing,
153
+ if an input moved while the lock ran).
154
+ 6. Record the approval — a digest per own spec, `members`, `code` — on
155
+ `.wai/lock.json` (format 2), together with who approved and how that identity
156
+ was established.
157
+ 7. Regenerate this project's derived topology (`generate --family` walks the
158
+ members; a lock never does).
159
+
160
+ A hosted lock runs the same steps. A hosted lock *request* also records the
161
+ gate identity it was made about; if the tree moved after the request, the
162
+ approved execution refuses, writes nothing, and cancels the request with the
163
+ reason.
164
+
165
+ A scoped approval (`--subsystem x`) approves **only what it covers**; everything
166
+ outside keeps the approval it already had. Whole-tree capture under a scoped
167
+ flag would silently mark the rest of the tree reviewed, which is a worse bug
168
+ than the one being removed.
169
+
170
+ ---
171
+
172
+ ## Who approved
173
+
174
+ `lockedBy` is `{ id, name?, source }`, and the `source` is the point. A `hosted`
175
+ identity was **authenticated** by the instance that issued the caller's
176
+ credential. `git` and `os` are self-declared, read from the machine's own
177
+ config. Recording which keeps the record honest instead of leaving a bare name
178
+ to imply more than it can.
179
+
180
+ Locally the preference is the **git author identity**, because it is the one
181
+ identity the repository already attributes work to — a reviewer can match it
182
+ against the author of the commit that carries the lock, and that matching is the
183
+ whole value. The fallback is `user@hostname`, which is what Terraform records as
184
+ a lock's holder and what git itself synthesizes when no identity is configured; a
185
+ bare OS username is strictly worse, because in CI it is `runner` and identifies
186
+ nobody. Deliberately not a MAC address or other hardware id: modern systems
187
+ randomize MACs, they differ per interface and VPN, and collecting one is device
188
+ fingerprinting for a field that is not authentication anyway.
189
+
190
+ Hosted is the one surface where wairon genuinely authenticated the approver, and
191
+ it was **throwing that away** — `executeApprovedLock` wrote the constant
192
+ `admin:master` while its caller held a resolved principal. It now records the
193
+ subject. On the approval path the **decider** is recorded, not the requester:
194
+ the requester did not have the authority, and the decision is what conferred it.
195
+ The requester is not lost — the `ApprovalRequest` and the audit event carry them.
196
+
197
+ None of this is proof. The trust anchor is the commit that introduces
198
+ `lock.json` — signed commits, a protected branch, a reviewed PR. `lockedBy` is a
199
+ convenience copy that earns its place only where git does not follow: a
200
+ `.waitree` archive, a `remote push` into a hosted instance, a tarball.
201
+
202
+ ---
203
+
204
+ ## Reversals worth recording
205
+
206
+ **Keeping the approval outside the repo was wrong, and only sharing it showed
207
+ why.** The first revision put it in `~/.wairon/baselines/` on the reasoning that
208
+ "approving must not dirty the repo". That reasoning conflated two very different
209
+ writes: rewriting 786 *spec* files, and one lockfile changing the way lockfiles
210
+ do. The cost was that no teammate, clone or CI run could see the approval at
211
+ all. Moving it into the committed record — as digests, so it fits — keeps the
212
+ property that mattered and drops the one that did not.
213
+
214
+ **Two defects were only reachable once the record was shared.** Hashing raw
215
+ bytes meant a Windows approval reported all 786 specs as drifted on a Linux
216
+ checkout; digests now normalize line endings, and a test rewrites the whole
217
+ fixture tree to CRLF to hold that. And the hosted lock **published before
218
+ writing the record**, so the commit shipping the specs did not contain the
219
+ approval certifying them — harmless when the record was four bookkeeping fields,
220
+ not when it *is* the approval. Neither was observable while the approval sat on
221
+ one machine.
222
+
223
+ **The signature change was caught by a test, not by the compiler.** Adding an
224
+ `approver` parameter to `executeApprovedLock` silently re-bound an existing
225
+ three-argument call to the new slot. `tsconfig.json` excludes `tests/`, so
226
+ nothing typechecks the suite — vitest transpiles without checking. The test
227
+ failed loudly and the fix was obvious, but the compiler should have said so
228
+ first.
229
+
230
+ **`--subsystem` nearly became a silent whole-tree approval.** Removing the
231
+ ratchet left nothing scoping the capture. Two existing lock tests failed and
232
+ named it. Scoped capture was added rather than discovered later.
233
+
234
+ **The first e2e assertion was written before it could pass.** *"Approving writes
235
+ nothing into the project"* is the property the design exists for, so it was
236
+ asserted first; it failed on the status ratchet, was narrowed to what was true
237
+ that day with a comment saying where it stopped, and tightened to byte-identity
238
+ once the ratchet was gone. A test that encodes a temporary truth is worse than
239
+ one that admits its scope.
240
+
241
+ ---
242
+
243
+ ## Deliberately out of scope
244
+
245
+ **A local wairon account.** The CLI has no identity of its own: `remote attach`
246
+ stores a bearer token, but resolving it to a person would need a `whoami`
247
+ endpoint and a network call, and `lock` is deliberately an offline command. The
248
+ git identity is the honest local answer until that changes.
249
+
250
+ **Storing the approved content.** Digests cannot show what changed *inside* a
251
+ spec. Git already can, against the lock's own commit, so keeping a second copy
252
+ of the tree to duplicate it is not worth its weight.
253
+
254
+ **Separation of duties.** `promote` was the hook for "two people must sign off".
255
+ It never worked, and it was removed rather than kept as a placeholder. If it
256
+ returns, it belongs on the approval — where *"approved by X at StateId S"* is a
257
+ reviewable, committed fact — not as a status string nothing reads.
@@ -0,0 +1,167 @@
1
+ # Teaching the connecting agent — the MCP entrypoint
2
+
3
+ > Status: 7.1–7.5 all shipped. Written against wairon 5.1.0.
4
+ > Source request: "Teaching the connecting agent: wairon needs the entrypoint,
5
+ > packs only adjust it" (2026-07-27).
6
+
7
+ ---
8
+
9
+ ## The governing principle
10
+
11
+ **wairon owns the SDD model, so wairon must teach it.** If an agent connecting to
12
+ a wairon MCP server does not know what SDD is, what the stereotypes mean, or in
13
+ what order to use the `sdd_*` tools, supplying that is wairon's default — not
14
+ each wrapper's. A pack appends its **platform delta** ("and here is what each of
15
+ those means in Make.com") and never restates the model, which would drift on
16
+ every wairon release.
17
+
18
+ Every item below is shaped by that split.
19
+
20
+ ---
21
+
22
+ ## Shipped
23
+
24
+ ### 7.1 — `instructions` on `initialize`
25
+
26
+ `createMcpServer` returns MCP `instructions`, the protocol's field for "how to
27
+ use this server", which clients inject into the agent's system prompt. Composed
28
+ by `instructions_specialist` (`src/core/instructions.ts`), it carries the L0→L5
29
+ tree shape, the authoring order by tool name, the fact that the `sdd_*` schemas
30
+ are self-describing, the bound project's governing `projectType`/profile, the
31
+ loaded packs, and the directive to read `wairon-skill://sdd-architect` **before**
32
+ authoring — with a pointer per published skill resource.
33
+
34
+ Deliberately short and pointer-heavy (~2.9 KB for this repo; a test pins a 6 KB
35
+ ceiling): the depth lives in `templates/skills/*.md`, and this is the map that
36
+ sends an agent to read them.
37
+
38
+ Composed **per `createMcpServer` call**, so the hosted per-request scoped server
39
+ (`server/request.ts:652`) reports its own bound project rather than a snapshot.
40
+ Never throws — a failed pack load or an uninitialized project degrades to
41
+ wairon's own briefing, because a connecting agent must always be taught
42
+ something.
43
+
44
+ Path: `mcp_server` → `mcp_skills_adapter` → `skills_portal` →
45
+ `skills_resource_orchestrator` → `instructions_specialist`. `sdd_mcp` stays a
46
+ transport subsystem.
47
+
48
+ ### 7.2 — pack-contributed instruction blocks
49
+
50
+ `DeclarativePackSchema.instructions` accepts a bare string, one block, or a list,
51
+ normalized to `PackInstructionBlock[]`:
52
+
53
+ ```yaml
54
+ instructions:
55
+ - text: Applies to every project this pack governs.
56
+ - text: Router branches never appear in a spec — model them as narrative branch steps.
57
+ profile: [make-automation]
58
+ ```
59
+
60
+ Appended after wairon's text under `## From pack "<name>"`, in **pack load
61
+ order** — the one merged field whose order is semantic, so blocks are never
62
+ deduped or overwritten. `profile` scopes a block to the governing profile exactly
63
+ as declarative assertions scope theirs; a block scoped to a profile that cannot
64
+ be resolved is **withheld**, so platform doctrine never leaks into an unrelated
65
+ project.
66
+
67
+ ---
68
+
69
+ ## Shipped (continued)
70
+
71
+ ### 7.4 — skills as MCP prompts — SHIPPED
72
+
73
+ Resources are pull-only; prompts are what makes a skill *discoverable* to a human
74
+ driving an agent (as a slash command or attachable context).
75
+
76
+ - Register a prompts capability mirroring `listSkillResources()` — the SDK
77
+ exposes `registerPrompt`, so the set cannot drift from the resource mirror.
78
+ - Prompt name = skill id (`sdd-architect`, `appenser-make-implementer`); the
79
+ handler returns the skill markdown as a message.
80
+ - Resources stay for programmatic reads. Hosted inherits this via the shared
81
+ factory, as the resource mirror already does.
82
+ - Test over an in-memory transport (`client.listPrompts()` / `getPrompt()`),
83
+ matching `tests/mcp/skill-resources.test.ts`.
84
+
85
+ ### 7.5 — variant guidance over MCP — SHIPPED
86
+
87
+ `agent_resolver.buildVariantGuidance` (`src/core/agent_resolver.ts:216`) is the
88
+ right hook for platform implementation guidance — it attaches to the component
89
+ the implementer is holding, with no doctrine duplication. But it only reaches
90
+ **generated agent files**, so hosted/cloud agents never see it.
91
+
92
+ - **Lift the composition into `src/core/variants.ts`** so the agent renderer and
93
+ the MCP path share one implementation instead of a second copy that drifts.
94
+ - `sdd_get_spec` on a variant-tagged component returns the resolved variant
95
+ guidance and its same-variant siblings as a clearly-derived, **read-only**
96
+ field, documented in the tool description as not part of the spec.
97
+ - Naming the active variants in the `initialize` instructions was **not** built —
98
+ see the outcome note below. The request said "and/or", and the `sdd_get_spec`
99
+ half is the substantive one.
100
+
101
+ ### 7.3 — pack skills that EXTEND a builtin — SHIPPED
102
+
103
+ Today `PackSkillSchema` is `{id, source, targets}` and pack skills are namespaced
104
+ `<pack>-<id>` so they cannot shadow `sdd-*`. A platform delta can therefore only
105
+ exist as a **parallel** skill (`appenser-make-implementer` beside `sdd-implement`),
106
+ leaving the agent to notice both and reconcile them — and tempting every wrapper
107
+ to fork the builtin wholesale, which is worse for everyone.
108
+
109
+ ```yaml
110
+ skills:
111
+ - extends: sdd-implement # builtin skill name
112
+ source: skills/make-implementer/SKILL.md
113
+ - id: make-control-plane # unchanged: a genuinely new skill
114
+ source: skills/make-control-plane/SKILL.md
115
+ ```
116
+
117
+ - `PackSkillSchema` becomes a union; `extends` must name a builtin
118
+ (`SKILL_NAMES`), and an unknown target **fails the pack load loudly** — the
119
+ `assertions` precedent: an old wairon must never silently not-apply a newer
120
+ pack's doctrine.
121
+ - The pack section is appended to the builtin under `## Platform: <pack>`
122
+ (multiple packs in load order), on install **and** on `resources/read`. The
123
+ builtin stays wairon's, so an upgrade still updates it.
124
+ - An extending skill contributes **no separate resource** — it composes into the
125
+ builtin's descriptor.
126
+
127
+ **The trap:** `checkSkillFreshness` (`src/core/skills.ts:166`) byte-compares the
128
+ installed file against the raw builtin template. Left alone, every extended skill
129
+ reports permanently **stale** in `wairon doctor`. It must compare against the
130
+ *composed* expectation.
131
+
132
+ Also touched: `exportSddSkills` (write composed), `readSkillResource` /
133
+ `readResource` (return composed), `listSkillResources` (descriptors), and the
134
+ `pack_skill` type spec.
135
+
136
+ ---
137
+
138
+ ## Sequencing (as built)
139
+
140
+ 7.4 → 7.5 → 7.3, in ascending order of blast radius, after the pack-scoping work
141
+ (`docs/design/pack-scoping.md`) had settled the selection model — 7.3's
142
+ composition runs over whatever pack set resolution yields.
143
+
144
+ ---
145
+
146
+ ## Outcome notes
147
+
148
+ **7.5 — one half deliberately not built.** The request said "include the resolved
149
+ variant guidance in `sdd_get_spec` … **and/or** name the active variants in the
150
+ `initialize` instructions". The first is done. The second is not: reading the
151
+ variant registry from the instructions composer would be an `sdd_skills` →
152
+ `sdd_core` hop onto an internal Adapter, which the boundary rules refuse, and
153
+ routing it through the core portal would mean a barrel import cycle for what is
154
+ only a pointer. The substantive requirement — guidance reaching a hosted agent —
155
+ is satisfied by the tool that hands an implementer its component.
156
+
157
+ **7.3 — the trap was real.** `checkSkillFreshness` byte-compared installed skills
158
+ against the raw builtin template. Left alone, every extended skill would have
159
+ reported permanently *stale* in `wairon doctor`, telling users to run `generate`
160
+ on a loop. It now compares against what `exportSddSkills` would write.
161
+
162
+ **A latent bug found while testing 7.5.** `requireSpecs()` in `mcp/server.ts`
163
+ lazily required a relative path, which does not resolve under the test runner — so
164
+ every `sdd_*` tool built on it failed with a module error instead of running. No
165
+ test had ever driven one through an MCP client, so nothing caught it. The
166
+ remaining lazy requires in that file (loader / validation / provision) are the
167
+ same latent landmine for any future test that exercises those tools.