@wairon/cli 5.1.1-dev.111 → 5.1.1-dev.113

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.
@@ -114,7 +114,7 @@ schemas:
114
114
  - **Externals — the pin gates, live drift is visible**: declare a project this one consumes with `sdd_add_external` (alias; source `../sibling`, `hosted:<id>`, `<git url>` or `<git url>#<commit>`; optional `project`, `use`, `description`) instead of editing `.wai/project.yaml`. It refuses in one sentence what cannot be declared, takes the declaration back out when the producer contradicts it (another id, an unexported `use` name), and pins it by default. The owner's gate judges each external against its pin; `sdd_validate_tree` and `sdd_get_status` add ADVISORY live-drift findings (`EXTERNAL_LIVE_INCOMPATIBLE`, `EXTERNAL_DRIFTED`, `EXTERNAL_LIVE_UNCOMPARED`) that never make the tree invalid — adapt the uses, then the human re-pins. `wairon externals status` is the human's opt-in live gate.
115
115
  - **Growing a system — subsystems → parts → projects**: boundaries are earned. Keep subsystems in one folder until a piece needs its own folder or repository; then make it a **part** — `members: { scheduler: services/scheduler }` (or `../admin`, or `git-url#<commit>`), created by `sdd_add_member` (a part by default) or `sdd_externalize_subsystem`. A part's subsystems stay this project's own: local ids, the ordinary subsystem rules, this project's lock. Make a **project** — its own id, exports and lock, referenced as `alias::name` — only when it needs its own team, release, approval or public surface: `sdd_promote_member`, and `sdd_demote_member` to undo it. What a member is follows from its content (an id, an L0 or a lock); `as:` only asserts it.
116
116
  - **Members & the family's shape**: relocate a member with `sdd_move_member`. Every other change of a family's shape is a **family migration**: `sdd_attach_member` (an existing project becomes a member), `sdd_detach_member` / `sdd_adopt_member` (out of the family and back), `sdd_rename_project`, `sdd_rename_member_alias`, `sdd_internalize_member` (a member folded back in, its metadata sent to explicit homes) and `sdd_externalize_subsystem`. Call it with `dryRun: true` first and show the human the plan; applied, it writes every project it touches or none, and it never locks — it names the projects to re-lock.
117
- - **Renames keep a trace**: rename with `sdd_rename_component`, `sdd_rename_method` and `sdd_rename_type`, and move methods with `sdd_move_methods` — never by deleting and re-adding a spec or editing an id through `sdd_update_spec`. The tools rewrite every reference, record the old key in the element's rename trace (`previousIds`, a method's `previousNames`; the design export shows it as `formerly`, which is how a generator keeps its user's code across the rename), and keep a published name through `as`. A retired id or method name cannot be taken again (`id-retired`, `name-retired`), and a hand-edited trace that collides is `RENAME_TRACE_CONFLICT`. Never write a trace yourself; unsetting one releases its names at the cost that consumers read a delete plus an add.
117
+ - **Renames keep a trace**: rename with `sdd_rename_component`, `sdd_rename_method` and `sdd_rename_type` (a contract or an implementation on its own with `sdd_rename_spec`), move methods with `sdd_move_methods` and a component or a type to another subsystem with `sdd_move_spec` — never by deleting and re-adding a spec or editing an id or a subsystem through `sdd_update_spec`. The tools rewrite every reference, record the old key in the element's rename trace (`previousIds`, a method's `previousNames`; the design export shows it as `formerly`, which is how a generator keeps its user's code across the rename), and keep a published name through `as`. A retired id or method name cannot be taken again (`id-retired`, `name-retired`), and a hand-edited trace that collides is `RENAME_TRACE_CONFLICT`. Never write a trace yourself; unsetting one releases its names at the cost that consumers read a delete plus an add.
118
118
  - **targetLanguage**: set on L0 (`sdd_initialize_system`), override per L1 — enables language-aware validation (flow constructs the language lacks are flagged) and the brief's type mapping for implementers. Extension packs (`.wai/project.yaml` → `extensions.packs`) may register additional languages/platforms and custom profiles.
119
119
  - **technologies (L4)**: an external technology (database, vendor SDK, service) is abstracted as a component — wrapped by an Adapter behind an intent-language interface (inside a Repository for persistence; a standalone Adapter for an external API). Declare the binding on that component's L4: `technologies: [mysql]`. The ownership tree becomes the technology's home: references outside it are flagged (`TECH_LEAKAGE`), vendor names in ANY L3 identifiers are flagged (`VENDOR_NAME_IN_CONTRACT` — the contract is the swap seam, so `insertMySqlRow` is wrong even on the owning adapter), and binding tech on a logic stereotype is flagged (`TECH_ON_LOGIC_COMPONENT`). Swapping the technology then touches one L4.
120
120
  - **lint.allow (per-spec suppression)**: any L1–L4/type spec may carry `lint: { allow: [{ code, reason }] }` — silences that WARNING (or NOTICE) code on that spec only (wairon's `#[allow]`). Errors are never locally suppressible; unknown codes and allows that no longer match anything are flagged. Prefer fixing — an allow is for a documented false positive or a deliberate, reviewable exception.
@@ -65,6 +65,13 @@ Point the subagent at that skill and spend the brief on what only you know:
65
65
  it: a paraphrase drifts, and the subagent cannot tell which copy is current.
66
66
  * **The premise you are asking them to act on**, stated *as* a premise, so it can
67
67
  be contradicted.
68
+ * **For a Portal, its handler shape** — the contract's own parameters per verb,
69
+ the handles its framework hands each function (to be named in the
70
+ implementation's `injectedParams`), and its `router` linkage (an entry of its
71
+ own file, `<module>#<name>`, or a central module) — quoted from the
72
+ implementation spec, with a pointer to `sdd-implement`'s **Handler shape** and
73
+ **Routers** rules. A subagent left to guess writes `(req, res)` handlers, and
74
+ the gate reads those as substitutions.
68
75
 
69
76
  One design decision never goes into a brief as an instruction: **an entry**. A
70
77
  subagent that meets an unreached Portal verb (`UNUSED_COMPONENT` /
@@ -184,6 +184,14 @@ All implementation work must strictly adhere to these rules:
184
184
  - A Portal's verbs are reached by the callers the design models or by the outside callers its `invokedBy` entry names. Serve exactly those: the Portal's L4 `router` is the entry its file exports for whatever process hosts it. Never add a verb, route or command the contract does not declare, and never ask for an entry to make an `UNUSED_*` finding go away — report the unreached verb instead.
185
185
  - Another project's library (an `InProcess` Portal) is imported and called directly from the component the narrative names — no wrapper Adapter unless the design has one. Keep pure and read logic to the library verbs whose `effect` the design allows.
186
186
  - Function names follow the contract's method names in the language's casing; a wire name that differs (an HTTP path, a CLI command, a JSON-RPC method) belongs to the endpoint binding — route it there, do not rename the function after it.
187
+ - **Handler shape — the green path for a framework handler** (no design change, no re-lock):
188
+ - A verb's function takes the contract's OWN parameters, in order, under the contract's names. `cancelOrder(req, res)` realizing `cancelOrder(orderId, customerId)` is reported as two substitutions (`UNREALIZED_PARAM` + `UNDECLARED_PARAM`), annotated or not; so is a request object, or any object sharing none of the record's fields, where the contract declares a record.
189
+ - What a framework hands the function besides those — a context, a request, a response, `next` — is wiring: name it in the implementation's `injectedParams` with `sdd_update_spec` (code linkage, outside the approval). It is matched with or without its leading underscore, at the START or the END of the list: `handler(ctx, req, orderId, customerId, res)` with `injectedParams: [ctx, req, res]` is green.
190
+ - Two honest shapes: (a) the router unpacks the path, query and body and calls `cancelOrder(orderId, customerId)` (handles injected around them if the framework insists); (b) the framework hands the handler the request alone — inject it and read each contract parameter off it **by its own name** (`req.params.orderId`, `req.query['limit']`, `const { orderId } = req.params`), which realizes the parameter through the handle. A contract parameter never read is still `UNREALIZED_PARAM`.
191
+ - An injection no handler takes is `UNUSED_INJECTED_PARAM` — drop it. A leading `_` marks a parameter the body provably never reads; a used `_secret` is judged like any other.
192
+ - `conformance: off` (on the implementation or one method) is for code wairon is not meant to compare with its contract — generated or vendored code. It switches the realization checks off (method, parameters, async, forward call claims), never the doctrine: an unnarrated write, a write shortcut and a call the analysis cannot follow are still reported.
193
+ - **Routers**: set the Portal implementation's `router` (code linkage) to the entry its file exports (`handleOrderRequest`), or to a central module's table or entry (`src/routes.ts#ROUTES`), or to the module alone (`src/routes.ts`). Write routes in a readable idiom: guards on `<request>.method` and `parts[i]`, or a route table — an array of `{ method, path }` objects or an object keyed `VERB /path` (template-literal keys over constants, and a table returned in place, are read). Table paths are written under the prefix the router strips, and that prefix must be the Portal's `basePath`.
194
+ - **Another project's code** is reached through what it exports — its member alias or a pinned external — or through a binding module the implementation declares in `bindings`. Importing a name straight out of its source tree that it does not export is `CROSS_PROJECT_SOURCE_IMPORT`.
187
195
  - **Strict Layer Isolation & No Persistence Shortcuts**:
188
196
  - A `Portal` must **never** depend directly on a `Store`, `Registry`, `Adapter` or `Query`. Passthrough READS may go through a `Repository`/`Index` facade; every WRITE must route through an `Orchestrator` (a Portal narrative call or dispatch-table binding that reaches a write-effect facade method is a `PORTAL_WRITE_SHORTCUT` error).
189
197
  - Held domain state always lives in a dedicated data component, never as fields inside an `Orchestrator`. Two sanctioned shapes: the RECOMMENDED `Repository` pattern (owns `Store` + `Registry` + `Index`; consumers depend on the facade), or — for genuinely simple state — a deliberately standalone `Store` (workflow-layer consumers only, acknowledged via `lint.allow` on `UNOWNED_STORE`). Do **not** combine Store/Registry/Index functionality into a single helper component, and never fold state into a consuming component because a link was refused.
package/docs/cli.md CHANGED
@@ -122,24 +122,51 @@ warnings as errors (notices are printed and counted, never fatal).
122
122
  client stub) names it on the implementation — `bindings:
123
123
  [src/routing/geo_binding.ts]` (code linkage like `sourcePath`: setting it
124
124
  never reopens the approval; `sdd_write_narrative` or `sdd_update_spec`).
125
- `validate` then compares the binding's exported declarations with the pinned
126
- snapshots of the externals that implementation's component reaches:
125
+ `validate` then compares the binding's exported declarations with what the
126
+ projects that implementation's component reaches publish — the pinned snapshot
127
+ of an external, or what a member exports now. A component reaches a project
128
+ through every `alias::name` its specs write, the types its contract's
129
+ params and returns name included, so a binding to a types-only contracts
130
+ library is compared too:
127
131
  `BINDING_DRIFT` (warning) for a method or function the pin renamed — `"tile_for_coordinate"
128
132
  was renamed to "tile_at" in geo::tiles — follow the rename` — or no longer
129
133
  exports, a parameter list whose arity or names differ (a parameter rename
130
- named), and a field the pinned type renamed, does not have or has that the
131
- binding lacks. A declaration is matched by the `alias::name` its doc comment
134
+ named), a type the producer renamed that the binding still declares (or types
135
+ a field with) under its former name, and a field the pinned type renamed, does
136
+ not have or has that the binding lacks. A verb the producer removed is named
137
+ when a doc comment ties the declaration to its interface (`/** geo::tiles */`,
138
+ or `/** geo::tiles.tile_at */` on a free function), and — for a free function
139
+ with no tag — when an earlier pin held it: a re-pin records each method the
140
+ producer removed since (`retired` in the snapshot), so `"vincenty_distance"
141
+ was removed from geo::tiles` is said instead of reading the declaration as the
142
+ binding's own. A declaration is matched by the `alias::name` its doc comment
132
143
  names (`/** geo::tiles */`), else by name (an `Api`/`Port`/`Client` suffix
133
144
  ignored), else by the pinned interface most of its methods belong to; names
134
145
  compare ignoring case and `_`, and a declaration nothing matches (a loader, an
135
146
  aggregate handle) is the binding's own. `BINDING_UNREAD` (notice) says when a
136
147
  binding cannot be compared: not written yet, outside the root, not
137
- TypeScript/JavaScript (the only languages read), or its component reaches
138
- neither a pinned external nor a member project. Return types are not
139
- compared. A binding into a **member** project (declared under `members`,
140
- never pinned) is compared the same way with what the member's L0 exports
141
- now, so a `rename-param` or `rename-field` in the member reaches its parent's
142
- binding at `validate` time as `BINDING_DRIFT` with the rename to follow.
148
+ TypeScript/JavaScript (the only languages read), declaring nothing the reader
149
+ compares (it names the export forms it cannot see into — the N-API
150
+ `module.exports = require('./build/Release/geo.node')` one-liner among them:
151
+ declare the producer's names in the binding itself), or its component reaches
152
+ neither a pinned external nor a member project that exports something. Both
153
+ ES module exports and CommonJS exports are read (`exports.X = { … }`,
154
+ `module.exports.X = function (…) {…}` or `= X`, each property of
155
+ `module.exports = { … }` — a shorthand `{ Client }` included —
156
+ `module.exports = class X { … }`, a TypeScript `export = X`, and JSDoc
157
+ `@typedef`/`@property`), a bare identifier through the module's own
158
+ declaration of it; a `_private` class or object member is not compared, and
159
+ an object tagged as a whole has each member judged against its tag. Return types are not compared. A binding into a contained **member**
160
+ project (declared under `members`, never pinned — named by its alias or its
161
+ project id) is compared the same way with what the member's L0 exports now
162
+ (its exported contracts and types, and the types they name), so a
163
+ `rename-param` or `rename-field` in the member reaches its parent's binding
164
+ at `validate` time as `BINDING_DRIFT` with the rename to follow; a member is
165
+ never told to pin — one that exports nothing is told to export there. A
166
+ member in a `../` sibling checkout is a contained one here. A git
167
+ member is pinned like an external (`wairon externals pin <alias>`) and
168
+ compared with that pin. A file of another project imported directly from its
169
+ source tree is not a binding: it is the producer's own code.
143
170
  - At a project that declares members, `validate` is the **family run**: every
144
171
  member's own gate, and this project's externals composed against their live
145
172
  producers. `--no-recursive` runs this project's gate alone; `--family` runs
@@ -176,7 +203,8 @@ warnings as errors (notices are printed and counted, never fatal).
176
203
  `MISSING_SOURCE_FILE` (error) and a contract method naming no file is
177
204
  `METHOD_SOURCE_PATH_MISSING` (warning). `rules.conformance.requireCode: true`
178
205
  in `project.yaml` reports the two notices as errors, for a CI that must say
179
- every designed implementation has code.
206
+ every designed implementation has code. It is code-conformance configuration,
207
+ outside the approval identity: flipping it never stales a lock (see `lock-check`).
180
208
  - **Types are linked to code too.** A type spec without a `sourcePath` is
181
209
  `MISSING_TYPE_SOURCE_PATH` — a notice before its subsystem has code, a
182
210
  warning after. Plan a type's `sourcePath` when you plan the implementations'.
@@ -241,6 +269,57 @@ warnings as errors (notices are printed and counted, never fatal).
241
269
  that Portal is realized by the link, never reported `UNREALIZED_DEPENDENCY`:
242
270
  never add an import across a network boundary to satisfy a check.
243
271
 
272
+ #### Framework handlers, injected handles and routers
273
+
274
+ The parameter check pairs a verb's contract parameters with the function that
275
+ realizes it, in order. A framework handler is green without any design change:
276
+
277
+ - **Take the contract's own parameters, under its names.** `cancelOrder(req, res)`
278
+ realizing `cancelOrder(orderId, customerId)` is two substitutions
279
+ (`UNREALIZED_PARAM` + `UNDECLARED_PARAM`), annotated or not: a parameter named
280
+ as a transport handle (req, res, ctx, request, response, next, reply) where the
281
+ contract declares an argument of its own is never a silent pairing. Neither is a
282
+ platform transport class (`IncomingMessage`, a fetch `Request`) or an object
283
+ sharing none of a record's fields where the contract declares that record; an
284
+ object holding every field under another name is a rename (`PARAM_NAME_MISMATCH`).
285
+ - **Declare what the framework hands you as wiring.** Name the handles in the
286
+ implementation's `injectedParams` (code linkage: `sdd_update_spec`, no
287
+ re-lock). A name matches with or without its leading `_`, at the start or the
288
+ end of the list — `handler(ctx, req, orderId, customerId, res)` with
289
+ `injectedParams: [ctx, req, res]`. An injection no handler takes is
290
+ `UNUSED_INJECTED_PARAM`.
291
+ - **Or read the parameters off the request.** Where the framework hands the
292
+ handler the request alone, inject it and read each contract parameter off it by
293
+ its own name (`req.params.orderId`, `req.query['limit']`,
294
+ `const { orderId } = req.params`): that realizes the parameter through the
295
+ handle. A parameter never read off it stays `UNREALIZED_PARAM`.
296
+ - **`conformance: off`** on an implementation or a method is for code wairon is
297
+ not meant to compare with its contract (generated or vendored code). It switches
298
+ the realization checks off — method, parameters, async, whether narrated calls
299
+ are found — and never the doctrine: an unnarrated write
300
+ (`UNDECLARED_WRITE_CALL`), a call the analysis cannot follow, a Portal write
301
+ shortcut and route coverage are judged whatever it says.
302
+
303
+ A Portal's `router` (code linkage) names the entry its own file exports
304
+ (`handleOrderRequest`), a central module's entry or table
305
+ (`src/routes.ts#ROUTES`), or a module alone (`src/routes.ts`, every route-bearing
306
+ export read). Several Portals may name one router; a route is declared when any
307
+ of them declares it. Guards on `<request>.method` and `parts[i]` are read, and so
308
+ are route tables — an array of `{ method, path }` objects or an object keyed
309
+ `VERB /path` (template-literal keys over constants included), bound to a const or
310
+ returned in place. A table is read under the ONE prefix its router strips off the
311
+ path (`path.slice(BASE.length)`), so that prefix must be the Portal's `basePath`:
312
+ another one is `UNDECLARED_ROUTE` + `UNROUTED_ENDPOINT`, naming both.
313
+
314
+ Another project's code is reached through what it publishes — its member alias, a
315
+ pinned external — or through a binding module the implementation declares in
316
+ `bindings`. An import that resolves into its source under a name it does not
317
+ export is `CROSS_PROJECT_SOURCE_IMPORT` (warning); one into a module realizing its
318
+ Store is the persistence shortcut past its surface. A claimed call into a
319
+ component whose code is in another language (a Rust member behind a binding) is
320
+ not judged by the TypeScript call graph: it is said once per project and language
321
+ as `CALL_ACROSS_LANGUAGE` (notice), never as an unrealized call.
322
+
244
323
  #### Technologies and their packages
245
324
 
246
325
  `TECH_LEAKAGE_IN_CODE` compares each import's package with the packages of the
@@ -310,8 +389,16 @@ When any spec is still `draft` or `design`, the lock says how many of how many,
310
389
  per kind (subsystems, components, contracts, implementations, types), and that
311
390
  it approves them as they stand. Re-running it when nothing it records moved
312
391
  keeps the record on file — its `lockedAt` included — so `.wai/lock.json` stays
313
- unmodified; a change of approver, verdict, member pin or wairon release writes
314
- a new record.
392
+ unmodified; a change of approver, verdict or member pin writes a new record. A
393
+ new wairon release writes nothing by itself: when its built-in design rules are
394
+ the same as the ones the record was taken under (the record's `gateParts.release`
395
+ is unchanged), the approval simply still matches and the record — its release
396
+ stamp included — is left as it is; when they moved, the approval carries over
397
+ by re-validation and one `wairon lock` refreshes the stamp (see *Upgrading
398
+ wairon* under `lock-check`). Evidence recorded *beside* the approval (the code
399
+ results with the analyzer's version, the design counts, gate parts, a release
400
+ stamp that is missing, no version or newer than the running wairon) is refreshed
401
+ and named; the summary says when this run left the file untouched.
315
402
 
316
403
  **What it certifies is the design.** Only design findings can refuse a lock.
317
404
  Code-conformance findings (the code↔spec checks) are recorded **beside** the
@@ -391,8 +478,36 @@ a human is actually answering:
391
478
  + sdd_core/spec_index/.index.yaml
392
479
  ```
393
480
 
394
- `--yes` skips the confirmation (for CI); `--subsystem`
395
- limits the scope. A shell with no terminal and no `--yes` writes nothing.
481
+ `--yes` skips the confirmation (for CI). `--subsystem <id>` *re-approves* one
482
+ subsystem of an approved design: the identity a lock certifies is always the
483
+ whole design's, so it is refused as a first approval (run `wairon lock`), and
484
+ refused whenever a spec outside the subsystem — or an input the identity covers
485
+ besides the design (rule tuning, packs, network, a pin, a member's approval,
486
+ the release) — moved since the approval; when it passes, the rest stays approved
487
+ exactly as it was, and the record is the one a full lock would write. A
488
+ member project's subsystem (`--subsystem payments_svc::payments` at a parent) is
489
+ refused: a member's subsystem is approved at the member's own root
490
+ (`wairon lock --subsystem payments` in its folder), then the parent locks to pin
491
+ its new approval. When
492
+ nothing changed, `lock` leaves the approval on record as it is — its `lockedAt`
493
+ and restamp trace — and `.wai/lock.json` untouched by that run; only evidence
494
+ recorded *beside* the approval (the code results, the design counts, gate parts
495
+ that are missing or do not match the identity, a release stamp that is missing,
496
+ no version or newer than this wairon — replaced with this release) is refreshed,
497
+ and it says which. When a lock records the project's own inputs for the first
498
+ time (a first approval, or a record from before gate parts) or the rule tuning
499
+ or packs moved, it prints the inputs it records — projectType, each rule
500
+ severity override, the selected packs, a declared network, `composition` — so
501
+ the tuning you approve is shown, not only digested.
502
+
503
+ **In a family, lock bottom-up.** A parent's record pins each member's approval,
504
+ so lock every member that is not approved at its own root first, then the
505
+ parent: three projects cost three locks. A parent locked first records the
506
+ member as drifted, its `lock-check` fails until the member re-locks, and it
507
+ must lock again to pin the new approval — `lock` warns before it writes when a
508
+ direct member is not covered by its own approval.
509
+ Another approver locking an unchanged design records their own approval. A shell
510
+ with no terminal and no `--yes` writes nothing.
396
511
 
397
512
  ### `wairon lock-check [--strict]`
398
513
  The **merge gate**. One question, one exit code: *is the design in this working
@@ -406,8 +521,10 @@ record counts too, so commit it before you rely on a local pass.
406
521
  | **`locked`** — the approval still covers this design | pass (0) | pass (0) |
407
522
  | **`stale`** — the design moved past its approval | **fail (1)** | **fail (1)** |
408
523
  | **`unlocked`** — nothing was ever approved | pass, with a notice (0) | **fail (1)** |
409
- | no `.wai/specs` in this directory at all | pass, saying so (0) | **fail (1)** |
410
- | a member project has spec changes nobody approved at its own root | **fail (1)** | **fail (1)** |
524
+ | no `.wai/specs` in this directory at all, and no approval on record | pass, saying so (0) | **fail (1)** |
525
+ | no `.wai/specs` (or none left), while `.wai/lock.json` approves spec files | **fail (1)**: the tree was deleted | **fail (1)** |
526
+ | `.wai/project.yaml` missing while an approval is on record | **fail (1)**, naming the file | **fail (1)** |
527
+ | a member project's own approval does not cover it (spec changes, inputs that moved, a record that predates recorded inputs) | **fail (1)**, in the member's own terms | **fail (1)** |
411
528
  | a member project was never approved by anyone | pass, naming it as *not gated* (0) | **fail (1)** |
412
529
  | only a member's own approval moved (it re-locked) | **fail (1)**, naming the member: re-lock here to pin its new approval | **fail (1)** |
413
530
 
@@ -426,6 +543,10 @@ in that member's folder. A member that re-locked is expected (its own approver
426
543
  signed it off), and the root is judged by whether its pin of the member is still
427
544
  the member's approved one: after a member re-locks it is not, and the verdict says
428
545
  exactly that — no own spec moved, run `wairon lock` here to pin the new approval.
546
+ A member whose release stamp `wairon lock` only *refreshed* (a restamp) has not
547
+ re-approved anything: the root pins the subject of the member's human approval,
548
+ which the restamp keeps (`restamped.subject`), so a family carries an upgrade
549
+ over at every root without one re-approval.
429
550
 
430
551
  **Upgrading wairon never stales an approval by itself.** A lock records each
431
552
  input of its gate identity on its own (`gateParts`: the design, this release's
@@ -443,11 +564,38 @@ errors or warnings, the approval is stale for exactly those: *the new release
443
564
  finds N issue(s) in the approved design: …*. The project's own rule tuning,
444
565
  packs, network and pins stay in the identity: changing one still stales the
445
566
  approval, and the verdict names the input that moved (*the network declaration*
446
- after `wairon network declare`). A record from before gate parts (taken by a
447
- release that did not record them) is judged the same way when another release took it, no own
448
- spec file moved since and every member pin is unchanged; its own rule tuning,
449
- packs and pins it can only prove inside its digest, so one `wairon lock` (which
450
- records the parts) closes that gap.
567
+ after `wairon network declare`). Only a record that *proves* the project's own inputs unchanged
568
+ carries over. A record from before gate parts (every record written before wairon recorded them) cannot — its rule tuning, packs and pins are only inside its digest — so
569
+ it never carries over automatically: it is stale with one sentence, *approved
570
+ under <old> before wairon recorded its inputs separately — re-lock once to record
571
+ them; later upgrades then carry over*, and `wairon lock` takes a real approval
572
+ (never a restamp). The same holds for gate parts that do not break down the
573
+ `stateId` beside them (they are ignored, with a notice; when the identity still
574
+ matches, `wairon lock` rewrites them keeping the approval), and for a release
575
+ stamp that is no wairon version or is *newer* than the running wairon when the
576
+ identity no longer matches — a record *written by a newer wairon* is never
577
+ re-judged by an older one. When the identity still matches, the identity
578
+ decides: the approval holds, and every surface (`lock-check`, `status`,
579
+ `sdd_get_status`, `validate`) reports the stamp as a notice (`its release stamp
580
+ "banana" is no wairon version`, `… says wairon 9.9.9 took it, a newer release than
581
+ this one`); `wairon lock` replaces it with the running release. A matching
582
+ record that carries no gate parts at all gets the same notice `{}` gets, and
583
+ `lock` rewrites them. A `lock.json` that names one key twice (what a careless
584
+ conflict resolution leaves) cannot be read, like one with conflict markers.
585
+
586
+ **What the identity covers, and what it leaves out.** The project's own *design*
587
+ rule tuning — `projectType`, `sddRuleSeverity` overrides of design rules, the
588
+ other design settings under `rules` — its pack selection, network declaration
589
+ and `composition` are inputs: changing one stales the approval. A selected pack
590
+ that cannot load (not installed, not vendored) leaves the approval *owed* —
591
+ `lock-check` fails, naming the pack — because the doctrine it was judged under
592
+ cannot be read. **Code-conformance configuration is deliberately outside the
593
+ identity:** `rules.conformance` (`requireCode` included) and severity overrides
594
+ of rules that judge code govern how the *code* is held to the design, not what
595
+ the design is, so flipping `requireCode` changes what `validate --ci` fails on
596
+ and never stales an approval — exactly as adding a `sourcePath` never does. The
597
+ analyzer's doctrine is recorded beside the claim (`code.analyzer.doctrineDigest`)
598
+ instead.
451
599
 
452
600
  **It is optional by construction.** By default only a moved approval (or a
453
601
  member's unapproved changes) refuses, and neither can happen in a project that
@@ -905,11 +1053,18 @@ from the params (a method whose `signatureFrom` names this one follows by
905
1053
  itself), and an HTTP endpoint path placeholder `{old}` (or `/:old`) is
906
1054
  respelled on the method's own binding and on every method whose
907
1055
  `signatureFrom` takes its signature — the URL a caller sends is unchanged.
908
- Prose is never rewritten. It names the exports publishing the method and every
909
- consumer that calls it there (the family in reach, members included).
1056
+ Prose is never rewritten. It names the exports publishing the method — and
1057
+ every export publishing a verb that forwards it through `signatureFrom` (the
1058
+ Portal verb an architect writes for each orchestrator method) — and every
1059
+ consumer that calls it there (the family in reach, members included), a
1060
+ consumer whose call still names a former method name included. Asked of a
1061
+ Portal verb that takes its signature from another of this project's methods,
1062
+ it renames the parameter on that source and says so: the rename reaches the
1063
+ verb through its `signatureFrom`.
910
1064
  `--dry-run` prints all of that and writes nothing; `--search <dirs...>` also
911
1065
  scans these folders for consumer checkouts outside the family. It refuses, writing nothing, a component, method or parameter that
912
- does not exist (or a parameter the method takes from a `signatureFrom`), a name
1066
+ does not exist (or a parameter the method takes from another project's method or
1067
+ a signature type — the refusal names that source), a name
913
1068
  that is not an identifier or that the target language reserves for a parameter
914
1069
  (`class` in TypeScript, `type` in Rust), and a name another parameter of the
915
1070
  method holds or retired. The MCP twin is `sdd_rename_param` (with `dryRun`
@@ -966,10 +1121,14 @@ content — `as:` only asserts it (a contradiction is `MEMBER_KIND_MISMATCH`):
966
1121
  `partOf: { project: <parent id>, path: <parent root> }`.
967
1122
  - A **project** is an independent boundary: its tree declares an id, holds an L0
968
1123
  or carries a lock. It is reached only as `alias::name` through its L0 exports,
969
- judged by its own gate and locked at its own root. A project stored outside
970
- this project (`../x`, git, `hosted:`) is a **referenced** project: this
971
- project's gate judges it against its pin (`wairon externals pin`), and the
972
- family run opens it where it is stored and composes it per use.
1124
+ judged by its own gate and locked at its own root. A project contained in this
1125
+ project, or in a `../x` sibling checkout beside it, is followed by the family
1126
+ scan and composed **live**: its exports are read where it is stored, its own
1127
+ gate runs in the family run, and nothing is pinned (`wairon externals pin`
1128
+ says so for a member alias). A project fetched from git or a `hosted:` record
1129
+ is a **referenced** project: this project's gate judges it against its pin
1130
+ (`wairon externals pin`), and the family run opens it where it is stored and
1131
+ composes it per use. A hosted instance never reads a `../` source.
973
1132
 
974
1133
  Boundaries are earned: grow a system from subsystems, to parts when a piece
975
1134
  needs its own folder or repository, to projects only when it needs its own team,
@@ -1041,14 +1200,14 @@ widen it (`EXPORT_WIDENS_AUDIENCE`).
1041
1200
 
1042
1201
  | Command | Description |
1043
1202
  |---------|-------------|
1044
- | `wairon member add <alias> <source> [--project] [--description <text>]` | Create a member at `<source>` (`path`, `../path` or `git-url[#commit]`) and declare it by the shorthand. A **part** by default: a specs folder (and, outside this project, its `partOf`). With `--project` a project: its `project.yaml` declaring `<alias>` as its id and its L0, each only when absent. A git member is never scaffolded: it is pinned at the commit given, else the default branch head, and its content decides what it is |
1203
+ | `wairon member add <alias> <source> [--project] [--description <text>]` | Create a member at `<source>` (`path`, `../path` or `git-url[#commit]`) and declare it by the shorthand. A **part** by default: a specs folder (and, outside this project, its `partOf`). With `--project` a project: its `project.yaml` declaring `<alias>` as its id and its L0, each only when absent. A git member is never scaffolded: it is pinned at the commit given, else the default branch head, and its content decides what it is. A git project is read from outside its family, at audience `instance`: one whose L0 exports its names only more narrowly (`project`, `department`) is refused, naming the audience it exports at and the one it is read at — widen the producer's export, or make it a path member. Then pin it (`wairon externals pin <alias>`), and a pin of a name exported too narrowly says so instead of calling it gone |
1045
1204
  | `wairon member promote <alias> [--id <id>]` | Make a part an independent project **in place**: its id, an L0 exporting exactly what this project uses of it, references across the new boundary respelled `alias::name` (`<parent id>::name` the other way), the parent declared as its external, pins on both sides. Refused for a part fetched from git, a trustedLink that would cross the boundary, a component used across it that its subsystem does not publish, or an id already taken |
1046
1205
  | `wairon member demote <alias> [--home <subsystem>] [--packs adopt\|drop]` | Make a project member a part **in place** — promote's inverse: its own metadata goes home (as `internalize` sends it), its L0, lock and pins end, references across the old boundary become local ids. Refused while another family project consumes it, or for a member fetched from git |
1047
1206
  | `wairon member update <alias> [--ref <ref> \| --commit <sha>] [--report]` | Move a git member's pinned commit to its ref's head (or the ref or commit given), printing the spec files it adds, changes and removes; `--report` writes nothing. The only way a git member's content changes; this project's lock reads stale until re-locked when the content moved |
1048
1207
  | `wairon member move <alias> <path>` | Move a contained member's directory and point its `members` entry there (a legacy L1 mount is moved into `members` first) |
1049
- | `wairon member attach <alias> <path> [--description <text>]` | Make the **existing** project at `<path>` a member, keeping its L0, subsystems, packs and lock; its id is declared when it only defaulted one (the id its lock approved, else its effective id). Refused when its id collides with a family project's |
1208
+ | `wairon member attach <alias> <path> [--description <text>]` | Make the **existing** project at `<path>` a member, keeping its L0, subsystems, packs and lock; its id is declared when it only defaulted one (the id its lock approved, else its effective id). `<path>` reads as `member add` reads a location: inside this project, or a sibling with a leading `../`. Refused when its id collides with a family project's, and for any other path out of this project (`not-contained`) |
1050
1209
  | `wairon member detach <alias> [--widen]` | Take a member out of the family: this project and every family consumer reach it as an external by `source.path`, this project's pinned. Refused (`audience-too-narrow`, each export named with its users) while a family project uses a name the member exports only to the family; `--widen` instead widens exactly those used exports to `instance` in the member's L0, each shown in the plan |
1051
- | `wairon member adopt <alias>` | Make this project's external found by a path inside it a member again — detach's inverse |
1210
+ | `wairon member adopt <alias>` | Make this project's external found by path a member again — detach's inverse. Its source reads as `member attach` reads a location: inside this project, or a sibling with a leading `../` (a sibling member is composed live like a contained one, never pinned) |
1052
1211
  | `wairon member rename-alias <old> <new>` | Rename one alias of this project (a member or an external) and respell this project's references through it; no member or sibling changes |
1053
1212
  | `wairon member internalize <alias> [--into <subsystem>] [--packs adopt\|drop] [--export <name>…]` | Fold a member into this project's own specs folder. A **part** is a storage move: its subsystems move in and nothing else changes. A **project** is demoted first — its own metadata goes to a home (its L0 vision onto the `--into` subsystem, its boundaries, requirements and databases into this L0, its language, profile and depth onto the moved subsystems, its members and externals into this configuration, its packs adopted or dropped), what has no home (its lock, pins, derived outputs) is deleted and listed, and every family project that consumed it is re-pointed here |
1054
1213
  | `wairon project rename <new-id> [--project <alias path>]` | Move a project's id — this project's, or a member's named by its alias path — and every reference to the old id family-wide; the old id is kept in `previousIds`. Lists every project it writes to re-lock |
@@ -1081,7 +1240,10 @@ the file changes, the projects to re-lock), then apply it all or nothing.
1081
1240
  `--yes` answers, and a shell with no terminal and no `--yes` writes nothing. A
1082
1241
  refused plan exits non-zero and writes nothing. The plan is computed by running
1083
1242
  the verb's writes on a private copy of the family's `.wai` trees (its parts'
1084
- included); applying it stages every change with a backup under each project's
1243
+ included), and each project it changes is validated on that copy too: every design finding
1244
+ the result will have that the project does not report now is a note — *after
1245
+ attach: 2 new error(s) the projects it changes do not report now: …* — so a
1246
+ migration never turns an approved project red unannounced; applying it stages every change with a backup under each project's
1085
1247
  `.wai/transactions/<id>/` (never committed) and swaps them in, restoring every
1086
1248
  backup on any failure. A crash mid-swap leaves a journal: `wairon status` and
1087
1249
  `wairon validate` show it as a notice, and `wairon doctor --fix` rolls it back.
@@ -1165,21 +1327,31 @@ project, so it holds nothing machine-specific: it runs `wairon mcp serve` from t
1165
1327
  and the server attaches to the project it is started in. A `--global` registration is
1166
1328
  machine-wide and names the running CLI by its absolute path.
1167
1329
 
1168
- The local server offers 47 tools:
1330
+ The local server offers 49 tools:
1169
1331
 
1170
1332
  | Group | Tools |
1171
1333
  |-------|-------|
1172
1334
  | Topology (read) | `listAgents`, `getAgent`, `listDomains`, `validateTopology`, `getProjectConfig` |
1173
- | Authoring | `sdd_initialize_system`, `sdd_add_subsystem`, `sdd_set_public_interfaces`, `sdd_add_component`, `sdd_define_interface`, `sdd_set_endpoints`, `sdd_write_narrative`, `sdd_add_type`, `sdd_update_spec`, `sdd_delete_spec` |
1335
+ | Authoring | `sdd_initialize_system`, `sdd_add_subsystem`, `sdd_set_public_interfaces`, `sdd_add_component`, `sdd_define_interface`, `sdd_set_endpoints` (dry run), `sdd_write_narrative`, `sdd_add_type` (dry run), `sdd_update_spec` (dry run, search), `sdd_delete_spec` (dry run, force, search) |
1174
1336
  | Reading and checking | `sdd_get_spec`, `sdd_get_status`, `sdd_validate_tree` |
1175
- | Renames and moves (in this tree) — each with `dryRun` | `sdd_rename_component`, `sdd_rename_method`, `sdd_rename_param`, `sdd_rename_type`, `sdd_rename_field`, `sdd_move_methods` |
1176
- | Members and family migrations (each takes `dryRun`) | `sdd_add_member`, `sdd_move_member`, `sdd_externalize_subsystem`, `sdd_promote_member`, `sdd_demote_member`, `sdd_internalize_member`, `sdd_attach_member`, `sdd_detach_member`, `sdd_adopt_member`, `sdd_rename_project`, `sdd_rename_member_alias` |
1337
+ | Renames and moves (in this tree) — each with `dryRun` | `sdd_rename_component`, `sdd_rename_method`, `sdd_rename_param`, `sdd_rename_type`, `sdd_rename_field`, `sdd_rename_spec` (a contract or an implementation on its own), `sdd_move_methods`, `sdd_move_spec` (a component or a type to another subsystem) |
1338
+ | Members | `sdd_add_member`, `sdd_move_member` |
1339
+ | Family migrations (each takes `dryRun`) | `sdd_externalize_subsystem`, `sdd_promote_member`, `sdd_demote_member`, `sdd_internalize_member`, `sdd_attach_member`, `sdd_detach_member`, `sdd_adopt_member`, `sdd_rename_project`, `sdd_rename_member_alias` |
1177
1340
  | Externals | `sdd_add_external`, `sdd_update_external`, `sdd_remove_external`, `sdd_pin_externals`, `sdd_get_externals_status` |
1178
1341
  | Public surface (the producer's side) | `sdd_list_consumers`, `sdd_surface_diff` |
1179
1342
  | Network | `sdd_set_network`, `sdd_get_network_flows`, `sdd_explain_flow` |
1180
1343
  | Packs | `sdd_pack_impact` |
1181
1344
  | Delegation | `sdd_get_agent_brief` (the live brief for one agent; also served as the `wairon-agent://` resource) |
1182
1345
 
1346
+ Every tool description ends with its argument names (`Arguments: id, newId, dryRun?.`). Every write
1347
+ tool refuses a spec of another project — a member's spec read through this tree is written from the
1348
+ member's own root, and the refusal names its folder and the call to make there. `sdd_delete_spec` and
1349
+ a contract method removed through `sdd_update_spec` report `published` (the public names a consumer
1350
+ reaches what goes through) and `breaks` (the consumers in reach, and in the `search` folders, that use
1351
+ one), as the renames do; a removed contract method takes its implementation entries with it
1352
+ (`cascaded`) and is refused while another spec still calls it. A delta never changes a spec's id or
1353
+ subsystem: `sdd_rename_spec` and `sdd_move_spec` do, with every reference and published entry.
1354
+
1183
1355
  A hosted server adds its own `sdd_host_*` and `sdd_landscape_*` tools; see the
1184
1356
  [hosted server guide](https://github.com/SYW-Apps/Waffle-AIron/blob/main/docs/design/hosted-mcp-server.md).
1185
1357
 
@@ -1294,8 +1466,8 @@ return the impact of every pack they applied in their results.
1294
1466
  | `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) |
1295
1467
  | `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 |
1296
1468
  | `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 |
1297
- | `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), import one, or list them. OpenAPI defaults to the `project` audience, so every HTTP Portal of the project's own is described — one its L0 export table never exports included (a wider `--audience` narrows to what the table shares there); the native snapshot defaults to `instance`. In the OpenAPI document a path placeholder — `{name}`, or the Express spelling `:name`, which is rewritten to `{name}` — is a parameter `in: path`, the other params the query of a GET/DELETE or the JSON body of a POST/PUT/PATCH. The Portal's `basePath` is joined into every path (the document carries no `servers` entry: where it is served is deployment, not design). The parameter that carries the credential the Portal's `auth` binds (a bearer `token`, say) is left out of the parameters and the body — `security` describes it — and named under `x-wairon-credential-param` so a wairon reader can restore it. Each operation answers the verb's conventional success code — `204` with no content for a method returning nothing, `201` for a POST, else `200` — and a method returning `result<T, E>` also answers a `default` error response carrying E's schema (`surface import` reads both back). A `custom` auth becomes an `apiKey` scheme only when the Portal's `auth.name` (with `auth.in`, default header) names where the credential travels; naming none, it is an `http` scheme `custom` with the design's description — no header name is invented. Two operations of one Portal on the same verb and path (placeholders compared by position) are refused naming both, never dropped (`validate` reports them as `ENDPOINT_ROUTE_DUPLICATE`). Every status line goes to stderr, so `surface export --format openapi > api.json` writes the JSON alone; an export that publishes nothing, or of a design `validate` refuses (its error count and codes), is a warning, never a ✔. An unknown `--portal` is refused naming the portals the surface renders |
1298
- | `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`). An L0 export entry added since that publishes nothing (a wildcard over a subsystem that publishes nothing) is listed as such, never read as no change. `--against` takes a native snapshot or a git revision: an OpenAPI document, or a file the native schema does not read, is refused in one line. `sdd_surface_diff` is the same answer for an assistant |
1469
+ | `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), import one, or list them. OpenAPI defaults to the `project` audience, so every HTTP Portal of the project's own is described — one its L0 export table never exports included (a wider `--audience` narrows to what the table shares there); the native snapshot defaults to `instance`. In the OpenAPI document a path placeholder — `{name}`, or the Express spelling `:name`, which is rewritten to `{name}` — is a parameter `in: path`, the other params the query of a GET/DELETE or the JSON body of a POST/PUT/PATCH — the one object-typed param left IS the body, as a client sends it (named under `x-wairon-body-param`, so `surface import` puts it back), and several are the properties of a body object. The Portal's `basePath` is joined into every path (the document carries no `servers` entry: where it is served is deployment, not design). The parameter that carries the credential the Portal's `auth` binds (a bearer `token`, say) is left out of the parameters and the body — `security` describes it — and named under `x-wairon-credential-param` so a wairon reader can restore it. Each operation answers the success `status` its endpoint states (`sdd_set_endpoints` `status`: `202` Accepted, a `3xx` redirect with a `Location` header and no body, a `201` for a workflow verb that files something), else its conventional code — `204` with no content for a method returning nothing; `201 Created` for a POST that creates: a method whose `effect` is `lifecycle`, or one whose effect is undeclared, `write` or `io` and whose name says it creates (`createHabit`, `placeOrder`, `signUp`); else `200` (a cancel, an archive, a log-in) — and a method returning `result<T, E>` also answers a `default` error response carrying E's schema (`surface import` reads both back). A `custom` auth becomes an `apiKey` scheme only when the Portal's `auth.name` (with `auth.in`, default header) names where the credential travels; naming none, it is an `http` scheme `custom` with the design's description — no header name is invented. Two operations of one Portal on the same verb and path (placeholders compared by position) are refused naming both, never dropped (`validate` reports them as `ENDPOINT_ROUTE_DUPLICATE`). A type another project publishes resolves from its pin, or — for a member project — from what the member exports now; a type nothing resolves is named in a warning (`The document has no schema for N type(s) it names: …`), never passed under a ✔. The components are exactly the types the operations reach, each ONCE — a member's type reached under its alias, its project id or another producer's closure is one component, keyed `<alias>.<id>` (a qualified id's `::` written `.`, so every key matches `^[a-zA-Z0-9._-]+$`); a field or parameter typed `T?` is not `required` (it stays nullable), and a field's description is carried on its property. Every status line goes to stderr, so `surface export --format openapi > api.json` writes the JSON alone; an export that publishes nothing, or of a design `validate` refuses (its error count and codes), is a warning, never a ✔. An unknown `--portal` is refused naming the portals the surface renders |
1470
+ | `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. A field renamed per its trace keeps its own row beside its type's rename (traced, or a changed public name of the same definition — never "removed + added"), and an exported type reshaped only by a rename it embeds says which (`embeds renamed field "money.currency" → "currencyCode"`). Against a revision it also covers the project's own HTTP Portals (its service API, exported or not), with every type they name from a pinned external or a member project expanded from the pin or member of each side: a re-pin that reshapes the project's own wire format (`GET /routes/:id/tiles` answering a renamed field) is a change of its own surface, named on the verb. 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`). An L0 export entry added since that publishes nothing (a wildcard over a subsystem that publishes nothing) is listed as such, never read as no change. `--against` takes a native snapshot or a git revision: an OpenAPI document, or a file the native schema does not read, is refused in one line. `sdd_surface_diff` is the same answer for an assistant |
1299
1471
  | `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) |
1300
1472
 
1301
1473
  ## Tooling
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wairon/cli",
3
- "version": "5.1.1-dev.111",
3
+ "version": "5.1.1-dev.113",
4
4
  "description": "SYW Waffle AIron — CLI for managing AI coding agent topology across projects",
5
5
  "keywords": [
6
6
  "ai",