@chrisdudek/yg 4.0.2 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -28,6 +28,13 @@ You verify whether source code satisfies a requirement.
28
28
  Below is a node (component) with its source files and one aspect (rule set).
29
29
  Check every rule in the aspect against the source code.
30
30
 
31
+ If source code contains a comment with the marker yg-suppress(<aspect-id>) where
32
+ <aspect-id> matches the aspect you are checking, treat the suppressed code as satisfied.
33
+ The marker must include a reason after the closing parenthesis. Do not validate the
34
+ reason \u2014 accept it as-is. The marker applies contextually to the surrounding code
35
+ (function, class, or block where it appears). If placed at file level, it applies to
36
+ the entire file.
37
+
31
38
  Respond with EXACTLY this JSON, nothing else:
32
39
  {"satisfied": true|false, "reason": "explanation with file:line references"}
33
40
  </task>
@@ -118,6 +125,7 @@ import { Command } from "commander";
118
125
 
119
126
  // src/cli/init.ts
120
127
  import chalk from "chalk";
128
+ import { existsSync } from "fs";
121
129
  import { mkdir as mkdir2, writeFile as writeFile4, readdir as readdir2, readFile as readFile4, stat as stat2 } from "fs/promises";
122
130
  import path5 from "path";
123
131
  import { fileURLToPath } from "url";
@@ -153,437 +161,237 @@ import { readFile, writeFile, mkdir } from "fs/promises";
153
161
  import path from "path";
154
162
 
155
163
  // src/templates/rules.ts
156
- var PROTOCOL = `## PROTOCOL
157
-
158
- <EXTREMELY-IMPORTANT>
159
- This is your operating manual for working in a Yggdrasil-managed repository.
160
-
161
- <critical_protocol>
162
- BEFORE reading, analyzing, or modifying ANY source file:
163
- \`yg context --file <path>\`
164
- Resolves owner, gives you the aspects this file must satisfy.
165
- Read the aspect content \`.md\` files \u2014 those are the rules the reviewer enforces.
166
-
167
- BEFORE creating a NEW source file:
168
- Identify which existing node the new file belongs to (by intent, not by filename).
169
- Run \`yg context --node <node-path>\` to load the context \u2014 especially aspect rules the new file must follow.
170
- If the file doesn't fit an existing node, create the node first (Step 2b below).
171
- If unsure which node: run \`yg context --file <path>\` \u2014 the CLI will list candidate nodes from the same directory.
172
- New files without graph context are the #1 source of convention violations.
164
+ var SYSTEM = `## SYSTEM
173
165
 
174
- All triggers apply regardless of what instructed the task \u2014 skills, plans, workflows, user requests.
175
- The graph captures architectural constraints that source files cannot \u2014 without it, you will write code that violates cross-cutting requirements.
176
- </critical_protocol>
166
+ Yggdrasil is continuous architecture enforcement. A graph in \`.yggdrasil/\` describes the architecture. An LLM reviewer verifies source code against it at approve time. If code violates a rule, the reviewer rejects it.
177
167
 
178
- Every rule below is mandatory \u2014 no skill, plan, workflow, or instruction overrides these requirements.
179
- </EXTREMELY-IMPORTANT>
168
+ The CLI (\`yg\`) reads and validates \u2014 it never modifies files. You create and edit graph files manually. The CLI guides you: every error message says WHAT happened, WHY it matters, and the NEXT command to run. \`suggestedNext\` at the end of \`yg check\` gives one concrete step. Follow it.
180
169
 
181
- Yggdrasil is continuous architecture enforcement stored in \`.yggdrasil/\`. It maps the repository and verifies source code against architectural rules (aspects) at approve time.
182
-
183
- ### Quick Start
170
+ ### Graph Elements
184
171
 
185
172
  \`\`\`
186
- EVERY conversation: yg check \u2014 read the full report, follow CLI guidance.
187
- CLI tells you what broke, why, and the next command to run.
188
- check failures block commits and CI. Resolve all errors before committing.
189
-
190
- BEFORE any source file interaction (read, modify, OR create):
191
- yg context --file <path> (existing file: resolves owner)
192
- yg context --node <path> (new file: load target node context)
193
- Read aspect content.md files \u2014 those are the rules the reviewer enforces.
194
- For blast radius: also run yg impact --file <path>.
195
-
196
- AFTER modifying:
197
- yg check \u2014 fix all errors
198
- yg approve --node <owner> \u2014 reviewer verifies aspects vs source code
199
-
200
- ALWAYS: establish graph coverage before modifying code.
201
- ALWAYS: run yg context --file before reading source.
202
- ALWAYS: run yg impact before assessing blast radius.
203
- ALWAYS: ask before resolving ambiguity.
204
- WHEN UNSURE: ask the user. Do not guess. Do not assume.
205
-
206
- How CLI guides you:
207
- Every error message follows: WHAT happened \u2192 WHY it's a problem \u2192 NEXT command.
208
- suggestedNext at the end of check gives one concrete step + remaining scale.
209
- Follow it. Re-run check after each fix.
173
+ .yggdrasil/
174
+ yg-architecture.yaml \u2190 node type definitions, default aspects per type, allowed relations
175
+ yg-config.yaml \u2190 reviewer config, quality thresholds, parallelism
176
+ model/ \u2190 nodes: what exists \u2014 hierarchy, relations, file mappings
177
+ aspects/ \u2190 aspects: what must be satisfied \u2014 enforceable rules
178
+ flows/ \u2190 flows: business processes with node participation
179
+ schemas/ \u2190 YAML schemas \u2014 read before creating any graph element
180
+ .drift-state/ \u2190 generated by CLI; never edit manually
210
181
  \`\`\`
211
182
 
212
- ### Modify Source Code
213
-
214
- You are not allowed to edit or create source code without establishing graph coverage first.
215
-
216
- **Step 1** \u2014 Get context: \`yg context --file <path>\` (resolves owner automatically)
217
-
218
- **Step 2a** \u2014 Owner found: execute checklist:
219
-
220
- - [ ] 1. \`yg context --file <path>\` \u2014 note all aspects in "Must satisfy"
221
- - [ ] 2. **Read aspect content files.** For every aspect in "Must satisfy": open and read its content \`.md\` files. The aspect description is not sufficient \u2014 the content files contain the actual enforcement rules. \`yg approve\` (step 6) delegates to a reviewer that checks source code against these rules and rejects non-compliant code.
222
- - [ ] 3. Assess blast radius: \`yg impact --node <node_path>\`
223
- - [ ] 4. Modify source code \u2014 satisfy the aspect rules
224
- - [ ] 5. Run \`yg check\` \u2014 follow CLI's suggested next command (if unfixable after 3 attempts \u2192 stop, report to user)
225
- - [ ] 5b. If you split, merged, or renamed a node: run \`yg flows\` and update any flow \`nodes\` lists that referenced the old node path.
226
- - [ ] 5c. **Aspect check** \u2014 did you just apply a pattern that also exists in other files? If the node has no aspect for it and you saw the same pattern in 3+ files, create the aspect now.
227
- - [ ] 6. Run \`yg approve --node <node_path>\` \u2014 reviewer verifies aspects vs source code
228
-
229
- **Step 2b** \u2014 Owner not found: establish coverage first. Present options to the user:
230
-
231
- *Partially mapped* (file unmapped but inside a mapped module): ask whether to add to existing node or create new one.
232
-
233
- *Existing code:*
183
+ **Nodes** \u2014 components. \`model/<path>/yg-node.yaml\` with name, type, description, mapping (source files), relations, aspects, ports. Nodes nest by directory \u2014 children inherit parent aspects.
234
184
 
235
- - Option A \u2014 Proper node: create node(s), map files, write description in \`yg-node.yaml\`
236
- - Option B \u2014 Abort
185
+ **Aspects** \u2014 enforceable rules. \`aspects/<id>/yg-aspect.yaml\` + content \`.md\` files. The content files are what the reviewer checks against source code. An aspect can declare \`implies: [other-aspect]\` \u2014 implied aspects are included recursively (must be acyclic).
237
186
 
238
- *Greenfield (new code):* Only Option A. Follow the graph-first workflow:
187
+ **Flows** \u2014 business processes. \`flows/<name>/yg-flow.yaml\` with name, description, nodes (participants), aspects. Flow-level aspects propagate to all participants. Descendants of a declared participant are automatically included \u2014 adding a parent node to a flow covers all its children.
239
188
 
240
- 1. Create aspects first (cross-cutting requirements the new code must satisfy)
241
- 2. Create flows if the code participates in a business process (with flow-level aspects)
242
- 3. Create nodes: \`yg-node.yaml\` with description, mapping, relations, aspects
243
- 4. Review the context package (\`yg context\`) \u2014 aspects are the specification
244
- 5. Implement code that satisfies aspect rules. Every source file must be mapped.
245
- 6. \`yg check\`, \`yg approve\`
189
+ **Relations** \u2014 typed dependencies between nodes. Six types: \`calls\`, \`uses\`, \`extends\`, \`implements\` (structural) and \`emits\`, \`listens\` (event-based). Event relations must be paired \u2014 if A emits to B, B must have a listens from A. Architecture controls which relation types are allowed between which node types.
246
190
 
247
- **Node sizing rule:** One node per cohesive feature area, NOT per directory. If a node would map >10 source files or cover >3 distinct user workflows, split it into child nodes.
191
+ **Ports** \u2014 named entry points on a node with required aspects. A node declares ports to say "consumers of this endpoint must satisfy these aspects." Consumers reference ports via \`consumes\` on their relation. The consumed port's aspects become effective on the consumer (channel 6). If a target has ports, the consumer must declare which it consumes \u2014 otherwise check warns about missing port contracts.
248
192
 
249
- **Why sizing matters for enforcement:** The reviewer verifies aspects against ALL source files in a node. A node with too many files forces the reviewer to evaluate aspects across too much code \u2014 it may reject compliant code because it lacks focused context. Smaller nodes (2-5 source files) give the reviewer enough context to verify accurately. Design nodes so that every mapped file is relevant to every aspect on that node.
193
+ **Architecture** \u2014 \`yg-architecture.yaml\` defines the vocabulary: node types, default aspects per type, allowed parent types, allowed relation targets per type. This is the foundation \u2014 read it when starting work on a new repo. Changes require user confirmation. Structure details in \`schemas/yg-architecture.yaml\`.
250
194
 
251
- **\`wide-node\` warning:** \`yg check\` emits a \`wide-node\` warning when a node with aspects maps more source files than \`quality.max_mapping_source_files\` (default: 10). This warning means: the reviewer WILL struggle with this node. Split it before running \`yg approve\` \u2014 otherwise expect false rejections.
195
+ ### How Aspects Reach a Node \u2014 7 Channels + Applicability Filter
252
196
 
253
- After the user chooses, return to Step 1 and follow Step 2a.
254
-
255
- ### Working from External Specifications
256
-
257
- When the user provides external documents (specs, PRDs, design docs, reference docs) as input for implementation:
258
-
259
- 1. **Read ALL spec documents BEFORE writing any code.** Understand the full scope.
260
- 2. **Extract enforceable requirements as aspects FIRST** \u2014 these are the rules the reviewer will check.
261
- 3. **The graph enforces architecture; external docs are INPUT to the graph, not a parallel source of truth.**
262
- 4. **Non-enforceable knowledge** (business strategy, personas, pricing) is not captured in the graph. Enforceable rules go to aspects.
263
-
264
- ### Conversation Lifecycle
197
+ Aspects accumulate from multiple sources simultaneously. The reviewer checks ALL of them \u2014 the node must satisfy every aspect regardless of origin.
265
198
 
266
199
  \`\`\`
267
- START (every conversation, before any work):
268
- - [ ] 1. yg check \u2192 read full report
269
- - [ ] 2. Fix any errors before starting work
270
- No exceptions. You cannot know if a file is mapped without running yg.
271
-
272
- UNDERSTANDING any source file (questions, research, OR planning):
273
- - [ ] 1. yg context --file <path>
274
- Mapped \u2192 read structured text output. Aspect content files are listed with "read:" prefix \u2014 read them.
275
- Unmapped \u2192 use file analysis, state it is not graph-backed.
276
- Never use grep or raw file reads as primary understanding when graph coverage exists.
277
-
278
- BEFORE reasoning about source code, state which graph context you loaded:
279
- "graph: <node_path>" if mapped, "graph: unmapped" if not.
280
-
281
- WRAP-UP (user signals "done", "wrap up", "that's enough"):
282
- - [ ] 1. yg check \u2192 fix all errors
283
- - [ ] 2. Report: which nodes and files were changed
200
+ EXAMPLE: node "orders/handler" (type: command, child of "orders")
201
+
202
+ Channel 1: OWN \u2014 node.aspects: [input-validation]
203
+ Channel 2: ANCESTOR \u2014 parent "orders" has aspects: [audit-logging]
204
+ Channel 3: OWN TYPE \u2014 architecture says type "command" \u2192 [cli-command-contract]
205
+ Channel 4: ANCESTOR TYPE \u2014 parent "orders" type "module" \u2192 [] (no defaults here)
206
+ Channel 5: FLOWS \u2014 flow "order-processing" includes "orders" \u2192 flow aspects: [deterministic]
207
+ Channel 6: PORTS \u2014 relation to "payments/service" consumes port "charge" \u2192 [correlation-tracking]
208
+ Channel 7: IMPLIED \u2014 aspect "audit-logging" implies: [diagnostic-logging]
209
+
210
+ EFFECTIVE ASPECTS for "orders/handler":
211
+ input-validation \u2190 own
212
+ audit-logging \u2190 parent "orders"
213
+ cli-command-contract \u2190 architecture type "command"
214
+ deterministic \u2190 flow "order-processing" (via parent "orders")
215
+ correlation-tracking \u2190 port "charge" on "payments/service"
216
+ diagnostic-logging \u2190 implied by "audit-logging"
284
217
  \`\`\`
285
218
 
286
- ### Modify Graph
287
-
288
- - [ ] 1. Read the relevant schema from \`schemas/\` before touching any YAML
289
- - [ ] 2. Before changing an aspect or flow, check blast radius: \`yg impact --aspect <id>\` or \`yg impact --flow <name>\` \u2014 understand which nodes are affected before modifying shared rules or processes
290
- - [ ] 3. Make changes
291
- - [ ] 4. Run \`yg check\` immediately \u2014 fix all errors
292
- - [ ] 5. Verify affected source files are consistent \u2014 update if needed
293
- - [ ] 6. Run \`yg approve\` for affected nodes
294
-
295
- ### Architecture Ownership
219
+ Consequences of this cascade:
220
+ - Adding an aspect to a parent applies it to ALL children. Check impact first: \`yg impact --aspect <id>\`.
221
+ - Adding a node to a flow with aspects means that node must satisfy flow aspects.
222
+ - Architecture default aspects apply to every node of that type automatically.
223
+ - Implies chains expand recursively. Cycles are forbidden \u2014 CLI detects them.
296
224
 
297
- \`yg-architecture.yaml\` defines which node types exist, what each type means, and which relations are allowed between types. **Every change to this file requires user confirmation** \u2014 it defines the vocabulary and constraints for the entire graph.
225
+ ### Applicability Filter (\`when\`) \u2014 Evaluated on Every Channel
298
226
 
299
- **On a new or empty repo:** Do NOT accept the defaults silently. Read \`yg-architecture.yaml\`, present the current types and relation rules to the user, and ask: "Does this type system fit your project, or should we adjust it?" The default types are starting points, not answers.
227
+ Seven channels propagate aspects onto nodes. A separate mechanism \u2014 the \`when\`
228
+ predicate \u2014 filters applicability. Every channel's attachment passes through
229
+ \`when\` before the aspect becomes effective. The predicate is evaluated by the
230
+ CLI against the graph (deterministic, no LLM call); if false, the aspect is
231
+ silently skipped on that node.
300
232
 
301
- **Before creating nodes with relations:** Read \`yg-architecture.yaml\` to check which relation types are allowed between the source and target node types. Do not guess \u2014 if the architecture does not allow \`service\` to call \`data\`, you cannot create that relation. The options are:
302
- 1. Use an allowed relation type
303
- 2. Ask the user whether to update the architecture (explain the constraint)
304
- 3. Change the node type to one that allows the needed relation
233
+ \`when\` can be declared globally on the aspect (applies across channels) or
234
+ per attach site (per channel instance). Global and attach-site \`when\` combine
235
+ via AND on each channel path. Multiple channels deliver independently \u2014 the
236
+ aspect is effective if any channel's path passes both its global and
237
+ attach-site filter.
305
238
 
306
- **Never silently modify \`yg-architecture.yaml\`.** If a relation is forbidden, do NOT add the target type to the allowed list without asking the user first. Present the constraint, explain the options, let the user decide.
239
+ Use \`when\` when an aspect is meaningful for only a subset of nodes under a
240
+ common attach channel. Example: \`external-api-error-mapping\` attached to
241
+ type \`command\` but only applicable when the node calls \`service-client\` \u2014
242
+ declare on the aspect:
307
243
 
308
- ### Delegating to Subagents
244
+ when:
245
+ relations:
246
+ calls:
247
+ target_type: service-client
309
248
 
310
- When you delegate work to a subagent (any subprocess, tool agent, or spawned assistant), the subagent does NOT inherit your Yggdrasil knowledge. Before any other instruction, the subagent MUST:
249
+ Domain-neutral examples: a \`pii-encryption\` aspect attached to all
250
+ repositories, but only applicable when the repository stores a field of
251
+ type \`user-profile\`; an \`idempotency-key\` aspect required only for commands
252
+ that emit events; a \`database-migration-review\` aspect only for nodes with
253
+ \`has_mapping: true\` pointing at \`db/migrations/\`.
311
254
 
312
- 1. Read \`.yggdrasil/agent-rules.md\` \u2014 this is the complete operating manual
313
- 2. Follow the Quick Start Protocol from that file before touching any mapped code
255
+ ### Reviewer
314
256
 
315
- Include this as the FIRST instruction in every subagent prompt:
257
+ The reviewer is an LLM invoked by \`yg approve\`. It receives: the aspect's content.md + all source files of the node. It checks every rule from content.md against the code.
316
258
 
317
- \`\`\`
318
- BEFORE doing anything else: read .yggdrasil/agent-rules.md and follow its protocol.
319
- DELIVERABLES \u2014 all required, incomplete work will be rejected:
320
- 1. Working source code
321
- 2. Graph nodes for every new/modified source file
322
- 3. \`yg check\` passing
323
- \`\`\`
259
+ - **Approved** \u2192 baseline recorded, drift cleared.
260
+ - **Refused** \u2192 violation report with what and where. Fix the code, re-run approve.
324
261
 
325
- A subagent that delivers code without corresponding graph updates has not completed its task.`;
326
- var REFERENCE = `## REFERENCE
262
+ Three approve modes: \`--node <path>\` (one or more nodes), \`--aspect <id>\` (batch all nodes affected by this aspect change), \`--flow <name>\` (batch all nodes in this flow). Batch at most 3-5 nodes per invocation \u2014 the reviewer loses accuracy with too many. Use \`--dry-run\` to preview the reviewer prompt without making an LLM call.
327
263
 
328
- ### Graph Structure
264
+ ### Drift and Cascade
329
265
 
330
- \`\`\`
331
- .yggdrasil/
332
- yg-config.yaml \u2190 project config: reviewer, quality thresholds, parallel
333
- yg-architecture.yaml \u2190 node type definitions, default aspects per type
334
- model/ \u2190 what exists: nodes, hierarchy, relations, file mappings
335
- aspects/ \u2190 what must: cross-cutting requirements \u2014 the ONLY enforcement rules
336
- flows/ \u2190 why and in what process: business processes with node participation
337
- schemas/ \u2190 YAML schemas \u2014 read before creating any graph element
338
- .drift-state/ \u2190 generated by CLI; never edit manually
339
- \`\`\`
266
+ Drift = source code or upstream context changed since the last approve. The reviewer must verify again. \`yg check\` detects two kinds:
340
267
 
341
- Key facts:
268
+ - **Source drift** \u2014 mapped source files were modified. Fix: \`yg approve --node <path>\`.
269
+ - **Upstream drift (cascade)** \u2014 an aspect, parent node, flow, or dependency changed. This cascades: one aspect change can cause drift in every node that uses it. Fix: \`yg approve --aspect <id>\` or approve affected nodes individually.
342
270
 
343
- - **Hierarchy:** nodes nest in \`model/\`. Children inherit parent aspects. Parent aspects flow to children automatically. **Consequence:** before nesting nodes under a parent, check which aspects the parent has \u2014 every child must satisfy ALL of them. If an aspect applies to the parent but not to a specific child, either move the aspect to the children that need it, or make the child a top-level node instead.
344
- - **Aspect id = directory path** under \`aspects/\`. Each aspect has \`yg-aspect.yaml\` + content \`.md\` files. Content files contain enforcement rules checked by the reviewer. No automatic parent-child \u2014 use \`implies\` explicitly.
345
- - **Flows = business processes.** A flow describes what happens in the world, not code sequences. Flow aspects propagate to all participants.
346
- - **Nodes = \`yg-node.yaml\` only.** Name, type, description, mapping, relations, aspects, ports. No \`.md\` files in nodes.
271
+ Cascade is the cost multiplier. Before changing a widely-used aspect, run \`yg impact --aspect <id>\` to see how many nodes will need re-approval. Each is a separate LLM call.
347
272
 
348
- **Node type guidance:** Each type in \`yg-architecture.yaml node_types\` has a \`description\` that tells you when to use it. Check the project's architecture file for the full list and descriptions. Common types: \`module\` (business logic), \`service\` (providing functionality), \`library\` (shared utilities), \`infrastructure\` (guards, middleware, interceptors \u2014 invisible in call graphs but affect blast radius).
273
+ If you modify code without reading the aspect content files (\`yg context --file\` \u2192 follow the \`read:\` paths), you will likely write code that violates rules you didn't know about. The reviewer will reject it. You will have to read the aspects anyway, then rewrite. Double cost.
349
274
 
350
- ### Aspect Distribution Channels
275
+ Do not interrupt \`yg approve\` \u2014 it processes each aspect across all source files. Interrupting leaves drift state unrecorded. Always read the full raw output \u2014 no \`| grep\`, \`| head\`, \`| tail\`. The reviewer already ran; the output is the return on that cost.
351
276
 
352
- Every graph dimension is a distribution channel for aspects to nodes:
277
+ ### CLI Commands
353
278
 
354
- | Channel | How aspects reach nodes |
279
+ | Command | Purpose |
355
280
  |---|---|
356
- | Direct | \`node.aspects\` in yg-node.yaml |
357
- | Type | Architecture defines default aspects per node type |
358
- | Hierarchy | Parent aspects inherited by children |
359
- | Port | Consumer must satisfy port-required aspects |
360
- | Flow | Participants inherit flow-level aspects |
281
+ | \`yg check\` | Unified gate \u2014 drift, validation, coverage, completeness. Blocks CI. |
282
+ | \`yg context --file <path>\` | Show owning node, effective aspects (\`read:\` paths), dependencies |
283
+ | \`yg context --node <path>\` | Show node overview \u2014 aspects, flows, dependents, source files |
284
+ | \`yg approve --node <path> [<path2>...]\` | Run reviewer on one or more nodes |
285
+ | \`yg approve --aspect <id>\` | Batch approve all nodes affected by this aspect change |
286
+ | \`yg approve --flow <name>\` | Batch approve all nodes in this flow |
287
+ | \`yg approve --dry-run --node <path>\` | Preview reviewer prompt without LLM call |
288
+ | \`yg impact --node <path>\` | Blast radius \u2014 dependents, flows, cascade scope |
289
+ | \`yg impact --file <path>\` | Blast radius for a specific file |
290
+ | \`yg impact --aspect <id>\` | All nodes affected by this aspect |
291
+ | \`yg impact --flow <name>\` | All nodes in this flow |
292
+ | \`yg tree [--root <path>] [--depth <n>]\` | Browse graph structure \u2014 all nodes with type and description |
293
+ | \`yg aspects\` | List all aspects with usage counts and sources |
294
+ | \`yg flows\` | List all flows with participants and aspects |
295
+ | \`yg owner --file <path>\` | Find which node owns a source file |
296
+ | \`yg init\` | Bootstrap or refresh \`.yggdrasil/\` setup |
361
297
 
362
- ### Context Assembly
298
+ ### Impact and Cost
363
299
 
364
- Two context commands serve different purposes:
300
+ Every graph change has blast radius. \`yg impact\` shows how many nodes are affected. Each affected node is a separate reviewer call (LLM request) during approve. An aspect touching 20 nodes = 20 LLM calls = real cost.
365
301
 
366
- - **\`yg context --node <path>\`** \u2014 node overview: aspects, flows, dependents
367
- - **\`yg context --file <path>\`** \u2014 per-file: aspects to satisfy, consumed dependencies
302
+ When code doesn't match an aspect, three options:
368
303
 
369
- **Reading context:** Both commands output structured text. Aspect content file paths appear with a \`read:\` prefix \u2014 read each one to get the enforcement rules.
304
+ | Option | When | Cost |
305
+ |---|---|---|
306
+ | **Change code** \u2014 conform to aspect | Aspect is correct, code violates it | Proportional to files needing fixes |
307
+ | **Change aspect** \u2014 conform to code | Aspect is too narrow or wrong, code is correct | \`yg impact --aspect\` \u2192 re-approve ALL nodes with this aspect |
308
+ | **Suppress** \u2014 \`yg-suppress\` waiver | Known tech debt, refactor not now | Zero approve cost, consciously accepted risk |
370
309
 
371
- \`yg context --node <path>\` outputs:
372
- - **Header** \u2014 node path, description, type
373
- - **Source files** \u2014 files owned by this node
374
- - **Must satisfy** \u2014 aspects with paths to content.md files
375
- - **Participates in** \u2014 flows
376
- - **Dependencies** \u2014 nodes this node depends on
377
- - **Dependents** \u2014 count of nodes that depend on this one (consequence framing for blast radius)
378
- - **Parent** \u2014 parent node
310
+ This is a cost/impact trade-off. Assess, propose the option to the user, let them decide. Never choose silently \u2014 especially for options 2 and 3.`;
311
+ var DECISIONS = `## DECISIONS
379
312
 
380
- \`yg context --file <path>\` outputs:
381
- - **Owner** \u2014 node path and type (or "unmapped" with candidate nodes)
382
- - **Must satisfy** \u2014 aspects with paths to content.md files
383
- - **Dependencies consumed** \u2014 what this file uses from each dependency
384
- - **Node context** \u2014 back-pointer: run \`yg context --node\` for full node overview
313
+ ### Workflow
385
314
 
386
- Read ALL aspect content files listed \u2014 the cost is low, the risk of skipping is high.
315
+ **Start of conversation:** \`yg check\`. If errors \u2014 fix before any other work. \`yg check\` failures block commits and CI. Nothing passes until check is clean.
387
316
 
388
- ### Information Routing
317
+ **Before touching a source file:** \`yg context --file <path>\`. Read the aspect content files listed under \`read:\`. These are the rules the reviewer will check your code against. For blast radius: \`yg impact --file <path>\`.
389
318
 
390
- When you encounter information, route it to the correct location:
319
+ **After modifying code:** \`yg check\` \u2192 fix errors \u2192 \`yg approve --node <path>\`. Approve is part of the change \u2014 the change is not done until approve passes. Do not defer approval.
391
320
 
392
- - **Enforceable cross-cutting rule** \u2192 aspect (\`aspects/<id>/\` with \`yg-aspect.yaml\` + content \`.md\` files). If applies to ALL nodes of a type \u2192 architecture default aspects.
393
- - **Business process with participants** \u2192 flow (\`flows/<name>/\` with \`yg-flow.yaml\`). Process-level requirements \u2192 flow aspects.
394
- - **Node identity** \u2192 \`description\` field in \`yg-node.yaml\` (1-2 sentences).
395
- - **Already visible in source code** \u2192 not captured in the graph.
396
- - **Non-enforceable knowledge** (business strategy, personas, design decisions) \u2192 not captured in the graph. If it can be made enforceable, write it as an aspect.
321
+ **End of conversation:** \`yg check\` \u2014 resolve all drift. \`yg check\` failures block CI. If drift remains, the build breaks.
397
322
 
398
- ### Quick Routing Table
323
+ **Unmapped files:** \`yg context --file\` will say if a file has no owner and suggest candidates. Either add it to an existing node's mapping or create a new node. Code without graph coverage works but is not verified \u2014 inform the user and propose options.
399
324
 
400
- | What you have | Where it goes |
401
- |---|---|
402
- | Cross-cutting rule (3+ nodes) | Aspect content.md |
403
- | Architectural invariant for a node type | Architecture default aspect |
404
- | Business process participation | Flow (\`yg-flow.yaml nodes\`) |
405
- | Process-level requirement | Flow \`aspects\` + aspect directory |
406
- | Node identity (brief) | \`description\` in yg-node.yaml |
407
- | Already visible in source code or config files | Not captured |
408
- | Non-enforceable knowledge | Not captured |
325
+ **Greenfield (no nodes yet):** Graph before code. Create architecture types, aspects, and nodes first \u2014 they are the specification. Then implement code that satisfies the aspects. \`yg check\` will guide you through coverage gaps.
409
326
 
410
- ### Creating Aspects
327
+ ### When to Create Graph Elements
411
328
 
412
- - [ ] 1. Read \`schemas/yg-aspect.yaml\`
413
- - [ ] 2. Create \`aspects/<id>/\` directory
414
- - [ ] 3. Write \`yg-aspect.yaml\` \u2014 name, description, optional implies
415
- - [ ] 4. Write content \`.md\` files: WHAT must be satisfied + WHY (user's words, do not invent)
416
- - [ ] 5. \`yg check\`
329
+ **Aspect** \u2014 when the same pattern appears in 3+ files AND the reviewer can verify it against source code. Both conditions. "Every handler logs audit trail" \u2014 pattern + verifiable = aspect. "Code should be readable" \u2014 not verifiable, not an aspect. Read \`schemas/yg-aspect.yaml\` before creating. Content \`.md\` files state WHAT must be satisfied and WHY \u2014 use the user's words, never invent rationale. Things that do NOT become aspects: knowledge already visible in source code (imports, config), non-enforceable knowledge (business strategy, personas, pricing), and conventions the reviewer cannot check against code.
417
330
 
418
- Test: "Does this requirement apply to more than one node?" Yes \u2192 aspect. "Can the reviewer check it against source code?" Yes \u2192 aspect. Both must be true.
331
+ **Flow** \u2014 when you see a sequence of steps toward a business goal. Not code call sequences \u2014 real-world processes. "User places an order" = flow. "Handler calls service" = relation between nodes. Read \`schemas/yg-flow.yaml\` before creating.
419
332
 
420
- ### Creating Flows
333
+ **Node** \u2014 one per cohesive feature area. Not per directory, not per file. If a node would map >10 source files or cover >3 distinct workflows, split into children. Why: the reviewer sees ALL files in a node. Too many files = reviewer loses context and produces false rejections. Aim for 2-5 source files per node with aspects. Read \`schemas/yg-node.yaml\` before creating.
421
334
 
422
- - [ ] 1. Read \`schemas/yg-flow.yaml\`
423
- - [ ] 2. Create \`flows/<name>/\` directory
424
- - [ ] 3. Write \`yg-flow.yaml\` \u2014 name, description, nodes (participant list), and flow-level aspects
425
- - [ ] 4. \`yg check\`
335
+ **Architecture change** \u2014 when existing types don't fit the project structure. Always confirm with the user. Never silently modify \`yg-architecture.yaml\`. If a relation between types is forbidden, present the constraint and let the user decide: use an allowed relation type, change the node type, or update the architecture.
426
336
 
427
- Test: "Does this describe what happens in the world, or only in the software?" If only software \u2014 rewrite.
337
+ **\`when\` predicate on an aspect or attach site** \u2014 when the aspect applies to
338
+ only a subset of nodes under a common attach channel. Prefer \`when\` over
339
+ splitting node types (proliferation of types). Prefer \`when\` over leaving
340
+ the decision to the reviewer textually inside \`content.md\`; \`when\` is
341
+ deterministic, has zero LLM cost, and keeps the graph as the source of
342
+ truth for applicability.
428
343
 
429
- **Flow identification heuristic:** If a spec, conversation, or code reveals a sequence of steps toward a business goal \u2014 it IS a flow. This applies to multi-actor processes AND single-actor workflows.
344
+ ### Aspect Discovery
430
345
 
431
- ### Ports
346
+ Aspects emerge from patterns \u2014 greenfield and brownfield:
432
347
 
433
- Nodes can declare typed ports \u2014 named entry points with required aspects:
348
+ - After working on 3+ files in the same area: are you applying the same pattern? If yes, create an aspect.
349
+ - Watch for "invisible" aspects: audit logging, webhook dispatch, auth guards, job dispatch \u2014 cross-cutting but easy to miss.
350
+ - Brownfield: same utility called in 3+ files = aspect waiting to be created.
434
351
 
435
- \`\`\`yaml
436
- ports:
437
- charge:
438
- description: "Charge payment"
439
- aspects: [correlation-tracking]
440
- \`\`\`
352
+ ### Delegating to Subagents
441
353
 
442
- Consumers reference ports via consumes on relations:
354
+ Subagents don't inherit Yggdrasil knowledge. First instruction in every subagent prompt:
443
355
 
444
- \`\`\`yaml
445
- relations:
446
- - target: payments/service
447
- type: calls
448
- consumes: [charge]
356
+ \`\`\`
357
+ BEFORE doing anything else: read .yggdrasil/agent-rules.md and follow its protocol.
358
+ DELIVERABLES \u2014 all required, incomplete work will be rejected:
359
+ 1. Working source code
360
+ 2. Graph nodes for every new/modified source file
361
+ 3. \`yg check\` passing
449
362
  \`\`\`
450
363
 
451
- At check time: \`port-missing-consumes\` fires if target has ports but consumer has no consumes. \`port-undefined\` fires if consumes references undefined port. \`consumes-without-ports\` fires if consumes is declared but target has no ports.
452
- At approve time: Reviewer verifies consumer satisfies port-required aspects.
453
-
454
- ### CLI Commands
455
-
456
- Core: \`yg check\`, \`yg context --node/--file\`, \`yg impact --node/--file/--aspect/--flow\`, \`yg approve --node/--aspect/--flow\`
457
- Navigation: \`yg tree [--root <path>] [--depth <n>]\`, \`yg aspects\`, \`yg flows\`, \`yg owner --file\`
458
- Setup: \`yg init\`
459
- Debug: \`yg approve --dry-run --node <path>\` \u2014 preview reviewer prompt without LLM call
460
-
461
- ### Error Categories
462
-
463
- CLI groups errors into categories. Each message tells you what happened, why,
464
- and what command to run next.
465
-
466
- - **Drift (\`source-drift\`, \`upstream-drift\`):** source files or upstream context changed since last approve. Run approve workflow.
467
- - **Structural (\`yaml-invalid\`, \`config-invalid\`, \`relation-broken\`, etc.):** YAML broken or graph inconsistent. Fix the YAML.
468
- - **Coverage (\`unmapped-files\`, \`mapping-path-missing\`):** source files not mapped. Bootstrap workflow.
469
- - **Completeness (\`description-missing\`):** required fields missing. Add them.
470
- - **Architecture (\`aspect-undefined\`, \`relation-target-forbidden\`, \`port-*\`, etc.):** references broken or contracts violated. Fix references.
471
- - **Semantic (\`aspect-violation\`, approve only):** Reviewer found aspects not satisfied in source code.
472
-
473
- Follow the CLI's suggested next command.
474
-
475
- ### Approve Enforcement
476
-
477
- Approve is the architecture enforcement gate. Binary \u2014 no flags, no negotiation.
478
-
479
- **How it works:**
480
- 1. Source or upstream context changed \u2192 run reviewer \u2192 reviewer checks each aspect's content.md against source code
481
- 2. Reviewer satisfied \u2192 \`approved\`, new baseline recorded
482
- 3. Reviewer not satisfied \u2192 \`refused\` with \`aspect-violation\` \u2014 fix source code and re-run
483
-
484
- **Three modes:**
485
-
486
- - \`yg approve --node <path> [<path2> ...]\` \u2014 one or more node paths. Multiple paths run as a batch.
487
- - \`yg approve --aspect <id>\` \u2014 batch approve all cascade nodes caused by this aspect change.
488
- - \`yg approve --flow <name>\` \u2014 batch approve all cascade nodes caused by this flow change.
489
-
490
- Batch mode runs approvals in parallel (up to \`parallel\` config limit). Use batch when \`yg check\` suggests it in \`suggestedNext\`.
491
-
492
- **Do NOT interrupt \`yg approve\`.** When reviewer is configured, approve calls the reviewer for every aspect across every source file \u2014 this takes time and is intentional. Interrupting it leaves drift state unrecorded and forces a re-run.
493
-
494
- **Always read the FULL raw output of \`yg approve\`.** Every aspect result, every error message \u2014 read it all. The reviewer already ran and the cost is paid; the output is the return on that investment.
495
-
496
- Always run the command without \`| grep\`, \`| head\`, \`| tail\`, or any filter that discards lines. Saving to a file and reading it (\`tee\`) is fine \u2014 that preserves all data. The rule is: all reviewer output must reach you unmodified.
497
-
498
- Always batch at most 3-5 nodes per approve invocation. This is a maximum, not a suggestion.
499
-
500
- **When reviewer rejects \u2014 decision tree:**
501
-
502
- 1. **Code violates aspect** \u2192 fix the code. This is the common case.
503
- 2. **Code is compliant but aspect wording is ambiguous** \u2192 fix the aspect content.md to be clearer. The escape hatch is improving the rule, not bypassing enforcement.
504
- 3. **Reviewer cannot verify because node has too many files** \u2192 the reviewer sees all source files in the node. If the node is too large, the reviewer lacks focused context. Split the node into smaller nodes so each has 2-5 source files with the relevant aspects. Never remove aspects to make approve pass \u2014 that disables enforcement.
505
- 4. **Aspect applies to this node but not to all its files** \u2192 the aspect may be too broad for this node. Either split the node (files that need the aspect vs. files that don't), or refine the aspect content.md to scope which files it applies to.
506
-
507
- **Never remove an aspect from a node to bypass a rejection.** If approve fails, the fix is in the code, the aspect wording, or the node structure \u2014 not in removing enforcement.`;
508
- var GUARD_RAILS = `## GUARD RAILS
509
-
510
- ### Core Rules
511
-
512
- 1. **Graph first.** Before reading, researching, planning, or modifying ANY source file, run \`yg context --file <path>\`. For blast radius, also run \`yg impact\`. The graph is your primary source of architectural understanding.
513
- 2. **Aspects are the specification; code implements; approve verifies.** Aspects define enforceable rules. Code must satisfy them. Approve checks compliance. This is the enforcement loop.
514
- 3. **Check blocks commits and CI.** \`yg check\` must pass before every commit. Treat this as fact. All errors (drift, structural, coverage, completeness) must be resolved.
515
- 4. **Never invent rationale.** If you don't know why a requirement exists, ask the user. Never hallucinate the reason behind an aspect.
516
- 5. **Ask before resolving ambiguity.** When multiple valid interpretations exist, stop, list options, ask the user. Never silently choose.
517
- 6. **Yggdrasil is invisible to the user.** Never mention the graph, aspects, flows, nodes, \`yg\` commands, or \`.yggdrasil/\` in conversation with the user. Present graph knowledge as your understanding \u2014 "this module handles X" not "the graph says this module handles X."
518
-
519
- ### Recognizing Graph-Required Actions
520
-
521
- What matters is the ACTION you are performing, not what instructed it. If the action involves reading, understanding, or modifying mapped code, the graph protocol applies \u2014 whether the instruction came from a skill, a plan, a user message, a brainstorming session, a debugging workflow, or your own initiative.
522
-
523
- **Actions that require \`yg context --file\`:**
524
-
525
- - Reading or exploring source files to understand a component
526
- - Proposing approaches, designs, or plans for changing code
527
- - Reviewing or debugging code
528
- - Any form of reasoning about how mapped code works or should change
529
-
530
- **Actions that also require \`yg impact\`:**
531
-
532
- - Assessing blast radius before changing or removing a component
533
- - Finding all dependents of a component
534
- - Planning cross-cutting refactors or feature removals
535
-
536
- **Actions that do NOT require yg:**
537
-
538
- - Git operations (log, diff, status, blame)
539
- - Reading documentation, READMEs, or config files outside \`.yggdrasil/\`
540
- - Running tests, builds, or linters
541
- - Working with files that \`yg context --file\` reports as unmapped
542
-
543
- ### Operational Rules
544
-
545
- - **English only** for all files in \`.yggdrasil/\`. Conversation can be any language.
546
- - **Read schemas before creating** any \`yg-node.yaml\`, \`yg-aspect.yaml\`, or \`yg-flow.yaml\`.
547
- - **Tools read, you write.** The \`yg\` CLI only reads, validates, and manages metadata. You create and edit files manually.
548
- - **Incremental approval.** Run \`yg approve\` per node after every 3-5 source file changes. Do not defer to end of task.
549
- - **Never defer approval.** When you finish modifying code, approve immediately. Do not say "I'll approve later" or leave drift for the next session. Approval is part of the change \u2014 the change is not done until approve passes.
550
- - **Description maintenance.** Every \`yg-node.yaml\`, \`yg-aspect.yaml\`, and \`yg-flow.yaml\` has a \`description\` field. Write it when creating new elements. Update it when the element's identity or purpose changes.
551
-
552
- ### Aspect Discovery During Implementation
553
-
554
- Aspects emerge from patterns \u2014 in greenfield AND brownfield:
555
-
556
- - **After working on 3+ files in the same area, pause and check:** Are you applying the same pattern repeatedly? If YES, stop and create an aspect NOW.
557
- - **Watch for "invisible" aspects:** Patterns that don't feel "architectural" but ARE cross-cutting: audit logging on every mutation, webhook dispatch after state changes, job dispatch for async operations, authorization guards on every endpoint.
558
- - **Brownfield trigger:** When you read existing code and see the same utility called in 3+ files, that IS an aspect waiting to be created.
364
+ Code without graph updates = incomplete work.
559
365
 
560
- ### Bootstrap Mode
366
+ ### \`yg-suppress\` \u2014 Inline Aspect Waiver
561
367
 
562
- Trigger: \`yg check\` shows \`unmapped-files\` with high uncovered file count, or 0 nodes.
368
+ Source code comments with \`yg-suppress(<aspect-path>) <reason>\` waive a specific aspect. The reviewer honors these unconditionally.
563
369
 
564
- - [ ] 1. Identify the active work area (files the user wants to modify)
565
- - [ ] 2. Create nodes for areas you will work on (with aspects for enforcement)
566
- - [ ] 3. Create minimal nodes (no aspects) for areas you will NOT work on \u2014 provides coverage without enforcement
567
- - [ ] 4. Scan for cross-cutting patterns \u2192 create aspects
568
- - [ ] 5. Ask user about business processes \u2192 create flows if applicable
569
- - [ ] 6. \`yg check\`, \`yg approve\` per node
570
- - [ ] 7. Proceed with user's original request
370
+ \`\`\`
371
+ // yg-suppress(cqrs/single-responsibility) brownfield handler, refactor planned
372
+ # yg-suppress(security/input-validation) static config, no user input
373
+ <!-- yg-suppress(accessibility/aria-labels) generated markup, tracked in JIRA-456 -->
374
+ \`\`\`
571
375
 
572
- Constraint: Focus on the active area. Expand incrementally.
376
+ - You may propose a suppress when you see brownfield code or known tech debt violating an aspect
377
+ - You MUST NEVER write a suppress without explicit user confirmation \u2014 no exceptions
378
+ - Provide the correct aspect-path from graph context, ask the user for the reason
379
+ - You do not invent reasons \u2014 the user provides or approves them
380
+ - The marker applies contextually to surrounding code (function, class, block). At file level, it applies to the entire file.
573
381
 
574
382
  ### Escape Hatch
575
383
 
576
- If the user explicitly requests a code-only change, comply but:
384
+ If the user explicitly requests a code-only change without graph updates: comply, but warn that this creates drift. \`yg check\` will catch it \u2014 and CI will block until it's resolved. Do not run \`yg approve\` \u2014 leave the drift visible.
577
385
 
578
- - Warn: "This creates drift. Run \`yg check\` next session to reconcile."
579
- - Do NOT run \`yg approve\` \u2014 leave the drift visible.
386
+ ### Operational Notes
580
387
 
581
- <critical_protocol>
582
- BEFORE reading, analyzing, or modifying ANY source file:
583
- \`yg context --file <path>\`
584
- One command. No exceptions. No "I'll do it later." No "this is just analysis."
585
- </critical_protocol>`;
586
- var AGENT_RULES_CONTENT = [PROTOCOL, REFERENCE, GUARD_RAILS].join("\n\n---\n\n") + "\n";
388
+ - English only for all files in \`.yggdrasil/\`. Conversation can be any language.
389
+ - Read the relevant schema from \`schemas/\` before creating any YAML file.
390
+ - Every \`yg-node.yaml\`, \`yg-aspect.yaml\`, and \`yg-flow.yaml\` needs a \`description\`. Write it when creating, update it when purpose changes.
391
+ - When renaming or splitting a node: run \`yg flows\` and update any flow \`nodes\` lists that reference the old path. \`yg check\` will catch broken references but it's faster to fix them proactively.
392
+ - When unsure about anything: ask the user. Do not guess. Do not assume.
393
+ - Never invent rationale for aspects. If you don't know why a requirement exists, ask.`;
394
+ var AGENT_RULES_CONTENT = [SYSTEM, DECISIONS].join("\n\n---\n\n") + "\n";
587
395
 
588
396
  // src/templates/platform.ts
589
397
  var AGENT_RULES_IMPORT = "@.yggdrasil/agent-rules.md";
@@ -1363,11 +1171,31 @@ var MIGRATIONS = [
1363
1171
  }
1364
1172
  ];
1365
1173
 
1174
+ // src/formatters/message-builder.ts
1175
+ function buildIssueMessage(msg) {
1176
+ return `${msg.what}
1177
+ ${msg.why}
1178
+ ${msg.next}`;
1179
+ }
1180
+
1366
1181
  // src/cli/init.ts
1182
+ function getPackageRoot() {
1183
+ let dir = path5.dirname(fileURLToPath(import.meta.url));
1184
+ while (dir !== path5.dirname(dir)) {
1185
+ if (existsSync(path5.join(dir, "package.json"))) {
1186
+ return dir;
1187
+ }
1188
+ dir = path5.dirname(dir);
1189
+ }
1190
+ throw new Error("Could not locate package root (no package.json found walking up from init module).");
1191
+ }
1367
1192
  function getGraphSchemasDir() {
1368
- const currentDir = path5.dirname(fileURLToPath(import.meta.url));
1369
- const packageRoot = path5.join(currentDir, "..");
1370
- return path5.join(packageRoot, "graph-schemas");
1193
+ return path5.join(getPackageRoot(), "graph-schemas");
1194
+ }
1195
+ async function getCliVersion() {
1196
+ const pkgPath = path5.join(getPackageRoot(), "package.json");
1197
+ const pkg2 = JSON.parse(await readFile4(pkgPath, "utf-8"));
1198
+ return pkg2.version;
1371
1199
  }
1372
1200
  async function refreshSchemas(yggRoot) {
1373
1201
  const schemasDir = path5.join(yggRoot, "schemas");
@@ -1661,6 +1489,25 @@ async function createYggdrasilStructure(projectRoot, yggRoot, platform) {
1661
1489
  await writeFile4(path5.join(yggRoot, ".gitignore"), GITIGNORE_CONTENT, "utf-8");
1662
1490
  await installRulesForPlatform(projectRoot, platform);
1663
1491
  }
1492
+ async function runVersionUpgrade(projectRoot, yggRoot, fromVersion, toVersion, platform) {
1493
+ const migrationResults = await runMigrations(fromVersion, MIGRATIONS, yggRoot);
1494
+ await updateConfigVersion(yggRoot, toVersion);
1495
+ await refreshSchemas(yggRoot);
1496
+ const architecturePath = path5.join(yggRoot, "yg-architecture.yaml");
1497
+ try {
1498
+ await stat2(architecturePath);
1499
+ } catch {
1500
+ await writeFile4(architecturePath, DEFAULT_ARCHITECTURE, "utf-8");
1501
+ }
1502
+ const rulesPath = await installRulesForPlatform(projectRoot, platform);
1503
+ const migrationActions = [];
1504
+ const migrationWarnings = [];
1505
+ for (const r of migrationResults) {
1506
+ migrationActions.push(...r.actions);
1507
+ migrationWarnings.push(...r.warnings);
1508
+ }
1509
+ return { rulesPath, migrationActions, migrationWarnings };
1510
+ }
1664
1511
  async function existingInit(projectRoot) {
1665
1512
  const yggRoot = path5.join(projectRoot, ".yggdrasil");
1666
1513
  if (!isTTY()) {
@@ -1669,35 +1516,30 @@ async function existingInit(projectRoot) {
1669
1516
  }
1670
1517
  p.intro(chalk.bold("Yggdrasil Configuration"));
1671
1518
  const currentVersion = await detectVersion(yggRoot);
1672
- const cliVersion = "4.0.0";
1519
+ const cliVersion = await getCliVersion();
1673
1520
  if (currentVersion && currentVersion !== cliVersion) {
1674
- const migrate = await p.confirm({
1675
- message: `Graph version ${currentVersion} detected \u2014 CLI is ${cliVersion}. Run migration?`,
1676
- initialValue: true
1677
- });
1678
- assertNotCancelled(migrate);
1679
- if (migrate) {
1680
- const s = p.spinner();
1681
- s.start("Running migrations...");
1682
- const results = await runMigrations(currentVersion, MIGRATIONS, yggRoot);
1683
- await updateConfigVersion(yggRoot, cliVersion);
1684
- await refreshSchemas(yggRoot);
1685
- s.stop("Migration complete.");
1686
- for (const result of results) {
1687
- for (const action2 of result.actions) {
1688
- p.log.info(action2);
1689
- }
1690
- for (const warning of result.warnings) {
1691
- p.log.warning(warning);
1692
- }
1693
- }
1694
- p.log.step("Next steps:");
1695
- p.log.info("1. Run yg init again to configure reviewer (if not set)");
1696
- p.log.info("2. Run yg check to verify graph integrity");
1697
- p.log.info("3. Run yg approve on all nodes to establish baselines");
1698
- p.outro(chalk.green(`Migrated from ${currentVersion} to ${cliVersion}.`));
1699
- return;
1700
- }
1521
+ p.log.step(`Graph version ${currentVersion} detected \u2014 CLI is ${cliVersion}. Upgrade required.`);
1522
+ p.log.info("Select the agent platform so rules and schemas advance together.");
1523
+ const platform = await promptPlatform();
1524
+ const s = p.spinner();
1525
+ s.start("Running migrations and installing rules...");
1526
+ const result = await runVersionUpgrade(projectRoot, yggRoot, currentVersion, cliVersion, platform);
1527
+ s.stop("Upgrade complete.");
1528
+ for (const action2 of result.migrationActions) {
1529
+ p.log.info(action2);
1530
+ }
1531
+ for (const warning of result.migrationWarnings) {
1532
+ p.log.warning(warning);
1533
+ }
1534
+ p.log.step("Next steps:");
1535
+ p.log.info("1. Run yg check to verify graph integrity");
1536
+ p.log.info("2. Run yg approve on all nodes to establish baselines");
1537
+ p.outro(
1538
+ chalk.green(
1539
+ `Migrated from ${currentVersion} to ${cliVersion}. Rules installed: ${path5.relative(projectRoot, result.rulesPath)}`
1540
+ )
1541
+ );
1542
+ return;
1701
1543
  }
1702
1544
  const action = await p.select({
1703
1545
  message: "What would you like to do?",
@@ -1711,15 +1553,9 @@ async function existingInit(projectRoot) {
1711
1553
  switch (action) {
1712
1554
  case "upgrade": {
1713
1555
  const platform = await promptPlatform();
1714
- await refreshSchemas(yggRoot);
1715
- const architecturePath = path5.join(yggRoot, "yg-architecture.yaml");
1716
- try {
1717
- await stat2(architecturePath);
1718
- } catch {
1719
- await writeFile4(architecturePath, DEFAULT_ARCHITECTURE, "utf-8");
1720
- }
1721
- const rulesPath = await installRulesForPlatform(projectRoot, platform);
1722
- p.outro(chalk.green(`Rules and schemas refreshed: ${path5.relative(projectRoot, rulesPath)}`));
1556
+ const fromVersion = currentVersion ?? cliVersion;
1557
+ const result = await runVersionUpgrade(projectRoot, yggRoot, fromVersion, cliVersion, platform);
1558
+ p.outro(chalk.green(`Rules and schemas refreshed: ${path5.relative(projectRoot, result.rulesPath)}`));
1723
1559
  break;
1724
1560
  }
1725
1561
  case "reviewer": {
@@ -1760,16 +1596,27 @@ function registerInitCommand(program2) {
1760
1596
  process.stderr.write(chalk.red("Error: No .yggdrasil/ directory found. Run 'yg init' first.\n"));
1761
1597
  process.exit(1);
1762
1598
  }
1763
- await refreshSchemas(yggRoot);
1764
- const architecturePath = path5.join(yggRoot, "yg-architecture.yaml");
1765
- try {
1766
- await stat2(architecturePath);
1767
- } catch {
1768
- await writeFile4(architecturePath, DEFAULT_ARCHITECTURE, "utf-8");
1599
+ const toVersion = await getCliVersion();
1600
+ const fromVersion = await detectVersion(yggRoot);
1601
+ if (fromVersion === null) {
1602
+ process.stderr.write(chalk.red(buildIssueMessage({
1603
+ what: "No graph version detected.",
1604
+ why: ".yggdrasil/yg-config.yaml is missing a 'version:' field, so --upgrade cannot determine which migrations to run.",
1605
+ next: "Run 'yg init' interactively once to record the current version, then retry 'yg init --upgrade --platform <name>'."
1606
+ }) + "\n"));
1607
+ process.exit(1);
1769
1608
  }
1770
- const rulesPath = await installRulesForPlatform(projectRoot, options.platform);
1771
- process.stdout.write(`Rules and schemas refreshed: ${path5.relative(projectRoot, rulesPath)}
1772
- `);
1609
+ const result = await runVersionUpgrade(
1610
+ projectRoot,
1611
+ yggRoot,
1612
+ fromVersion,
1613
+ toVersion,
1614
+ options.platform
1615
+ );
1616
+ process.stdout.write(
1617
+ `Rules and schemas refreshed: ${path5.relative(projectRoot, result.rulesPath)}
1618
+ `
1619
+ );
1773
1620
  return;
1774
1621
  }
1775
1622
  let exists = false;
@@ -1926,7 +1773,219 @@ function normalizeProviderConfig(providerName, pc, generalConfig, filename) {
1926
1773
  // src/io/node-parser.ts
1927
1774
  import { readFile as readFile6 } from "fs/promises";
1928
1775
  import { parse as parseYaml4 } from "yaml";
1929
- var RELATION_TYPES = [
1776
+
1777
+ // src/io/when-parser.ts
1778
+ var RELATION_TYPES = /* @__PURE__ */ new Set([
1779
+ "calls",
1780
+ "uses",
1781
+ "extends",
1782
+ "implements",
1783
+ "emits",
1784
+ "listens"
1785
+ ]);
1786
+ var ATOMIC_KEYS = /* @__PURE__ */ new Set(["relations", "descendants", "node"]);
1787
+ var BOOLEAN_KEYS = /* @__PURE__ */ new Set(["all_of", "any_of", "not"]);
1788
+ function parseWhen(raw, ctx) {
1789
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1790
+ throw new Error(`${ctx}: when must be a YAML mapping`);
1791
+ }
1792
+ const keys = Object.keys(raw);
1793
+ if (keys.length === 0) {
1794
+ throw new Error(`${ctx}: when mapping must not be empty`);
1795
+ }
1796
+ const booleanKeys = keys.filter((k) => BOOLEAN_KEYS.has(k));
1797
+ const atomicKeys = keys.filter((k) => ATOMIC_KEYS.has(k));
1798
+ const unknownKeys = keys.filter((k) => !BOOLEAN_KEYS.has(k) && !ATOMIC_KEYS.has(k));
1799
+ if (unknownKeys.length > 0) {
1800
+ throw new Error(`${ctx}: unknown when operator '${unknownKeys[0]}' (expected one of: all_of, any_of, not, relations, descendants, node)`);
1801
+ }
1802
+ if (booleanKeys.length > 0 && atomicKeys.length > 0) {
1803
+ throw new Error(`${ctx}: when cannot mix boolean operators with atomic clauses at the same level`);
1804
+ }
1805
+ if (booleanKeys.length > 1) {
1806
+ throw new Error(`${ctx}: when can have at most one boolean operator at a level (got: ${booleanKeys.join(", ")})`);
1807
+ }
1808
+ if (booleanKeys.length === 1) {
1809
+ return parseBoolean(raw, booleanKeys[0], ctx);
1810
+ }
1811
+ return parseAtomic(raw, ctx);
1812
+ }
1813
+ function parseBoolean(raw, key, ctx) {
1814
+ const val = raw[key];
1815
+ if (key === "not") {
1816
+ return { not: parseWhen(val, `${ctx}/not`) };
1817
+ }
1818
+ if (!Array.isArray(val)) {
1819
+ throw new Error(`${ctx}: '${key}' must be an array`);
1820
+ }
1821
+ if (val.length === 0) {
1822
+ throw new Error(`${ctx}: '${key}' array must not be empty`);
1823
+ }
1824
+ const items = val.map((v, i) => parseWhen(v, `${ctx}/${key}[${i}]`));
1825
+ return key === "all_of" ? { all_of: items } : { any_of: items };
1826
+ }
1827
+ function parseAtomic(raw, ctx) {
1828
+ const result = {};
1829
+ if ("relations" in raw) {
1830
+ result.relations = parseRelationClause(raw.relations, `${ctx}/relations`);
1831
+ }
1832
+ if ("descendants" in raw) {
1833
+ result.descendants = parseDescendantsClause(raw.descendants, `${ctx}/descendants`);
1834
+ }
1835
+ if ("node" in raw) {
1836
+ result.node = parseNodeClause(raw.node, `${ctx}/node`);
1837
+ }
1838
+ return result;
1839
+ }
1840
+ function parseRelationClause(raw, ctx) {
1841
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1842
+ throw new Error(`${ctx}: relations must be a YAML mapping keyed by relation type`);
1843
+ }
1844
+ const entries = Object.entries(raw);
1845
+ if (entries.length === 0) {
1846
+ throw new Error(`${ctx}: relations mapping must not be empty`);
1847
+ }
1848
+ const out = {};
1849
+ for (const [relType, match] of entries) {
1850
+ if (!RELATION_TYPES.has(relType)) {
1851
+ throw new Error(`${ctx}: unknown relation type '${relType}' (valid: ${Array.from(RELATION_TYPES).join(", ")})`);
1852
+ }
1853
+ out[relType] = parseRelationMatch(match, `${ctx}/${relType}`);
1854
+ }
1855
+ return out;
1856
+ }
1857
+ function parseRelationMatch(raw, ctx) {
1858
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1859
+ throw new Error(`${ctx}: must be a YAML mapping`);
1860
+ }
1861
+ const obj = raw;
1862
+ const allowed = /* @__PURE__ */ new Set(["target_type", "target", "consumes_port"]);
1863
+ for (const k of Object.keys(obj)) {
1864
+ if (!allowed.has(k)) {
1865
+ throw new Error(`${ctx}: unknown field '${k}' (allowed: target_type, target, consumes_port)`);
1866
+ }
1867
+ }
1868
+ const out = {};
1869
+ if ("target_type" in obj) {
1870
+ if (typeof obj.target_type !== "string" || obj.target_type.trim() === "") {
1871
+ throw new Error(`${ctx}: target_type must be a non-empty string`);
1872
+ }
1873
+ out.target_type = obj.target_type.trim();
1874
+ }
1875
+ if ("target" in obj) {
1876
+ if (typeof obj.target !== "string" || obj.target.trim() === "") {
1877
+ throw new Error(`${ctx}: target must be a non-empty string (node path relative to model/)`);
1878
+ }
1879
+ out.target = obj.target.trim();
1880
+ }
1881
+ if ("consumes_port" in obj) {
1882
+ if (typeof obj.consumes_port !== "string" || obj.consumes_port.trim() === "") {
1883
+ throw new Error(`${ctx}: consumes_port must be a non-empty string`);
1884
+ }
1885
+ out.consumes_port = obj.consumes_port.trim();
1886
+ }
1887
+ if (Object.keys(out).length === 0) {
1888
+ throw new Error(`${ctx}: at least one of target_type, target, consumes_port must be present`);
1889
+ }
1890
+ return out;
1891
+ }
1892
+ function parseDescendantsClause(raw, ctx) {
1893
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1894
+ throw new Error(`${ctx}: descendants must be a YAML mapping`);
1895
+ }
1896
+ const obj = raw;
1897
+ const allowed = /* @__PURE__ */ new Set(["relations", "type", "has_port"]);
1898
+ for (const k of Object.keys(obj)) {
1899
+ if (!allowed.has(k)) {
1900
+ throw new Error(`${ctx}: unknown field '${k}' (allowed: relations, type, has_port)`);
1901
+ }
1902
+ }
1903
+ const out = {};
1904
+ if ("relations" in obj) {
1905
+ out.relations = parseRelationClause(obj.relations, `${ctx}/relations`);
1906
+ }
1907
+ if ("type" in obj) {
1908
+ if (typeof obj.type !== "string" || obj.type.trim() === "") {
1909
+ throw new Error(`${ctx}: type must be a non-empty string`);
1910
+ }
1911
+ out.type = obj.type.trim();
1912
+ }
1913
+ if ("has_port" in obj) {
1914
+ if (typeof obj.has_port !== "string" || obj.has_port.trim() === "") {
1915
+ throw new Error(`${ctx}: has_port must be a non-empty string`);
1916
+ }
1917
+ out.has_port = obj.has_port.trim();
1918
+ }
1919
+ if (Object.keys(out).length === 0) {
1920
+ throw new Error(`${ctx}: at least one of relations, type, has_port must be present`);
1921
+ }
1922
+ return out;
1923
+ }
1924
+ function parseNodeClause(raw, ctx) {
1925
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1926
+ throw new Error(`${ctx}: node must be a YAML mapping`);
1927
+ }
1928
+ const obj = raw;
1929
+ const allowed = /* @__PURE__ */ new Set(["type", "has_port", "has_mapping"]);
1930
+ for (const k of Object.keys(obj)) {
1931
+ if (!allowed.has(k)) {
1932
+ throw new Error(`${ctx}: unknown field '${k}' (allowed: type, has_port, has_mapping)`);
1933
+ }
1934
+ }
1935
+ const out = {};
1936
+ if ("type" in obj) {
1937
+ if (typeof obj.type !== "string" || obj.type.trim() === "") {
1938
+ throw new Error(`${ctx}: type must be a non-empty string`);
1939
+ }
1940
+ out.type = obj.type.trim();
1941
+ }
1942
+ if ("has_port" in obj) {
1943
+ if (typeof obj.has_port !== "string" || obj.has_port.trim() === "") {
1944
+ throw new Error(`${ctx}: has_port must be a non-empty string`);
1945
+ }
1946
+ out.has_port = obj.has_port.trim();
1947
+ }
1948
+ if ("has_mapping" in obj) {
1949
+ if (typeof obj.has_mapping !== "boolean") {
1950
+ throw new Error(`${ctx}: has_mapping must be a boolean`);
1951
+ }
1952
+ out.has_mapping = obj.has_mapping;
1953
+ }
1954
+ if (Object.keys(out).length === 0) {
1955
+ throw new Error(`${ctx}: at least one of type, has_port, has_mapping must be present`);
1956
+ }
1957
+ return out;
1958
+ }
1959
+ function parseAspectAttachment(raw, ctx) {
1960
+ if (typeof raw === "string") {
1961
+ const id = raw.trim();
1962
+ if (id === "") {
1963
+ throw new Error(`${ctx}: aspect id must be a non-empty string`);
1964
+ }
1965
+ return { id };
1966
+ }
1967
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
1968
+ const obj = raw;
1969
+ if (typeof obj.id !== "string" || obj.id.trim() === "") {
1970
+ throw new Error(`${ctx}: object form requires 'id' as a non-empty string`);
1971
+ }
1972
+ const result = { id: obj.id.trim() };
1973
+ const allowed = /* @__PURE__ */ new Set(["id", "when"]);
1974
+ for (const k of Object.keys(obj)) {
1975
+ if (!allowed.has(k)) {
1976
+ throw new Error(`${ctx}: unknown field '${k}' in aspect attachment (allowed: id, when)`);
1977
+ }
1978
+ }
1979
+ if ("when" in obj) {
1980
+ result.when = parseWhen(obj.when, `${ctx}/when`);
1981
+ }
1982
+ return result;
1983
+ }
1984
+ throw new Error(`${ctx}: aspect attachment must be a string or an object with 'id' (and optional 'when')`);
1985
+ }
1986
+
1987
+ // src/io/node-parser.ts
1988
+ var RELATION_TYPES2 = [
1930
1989
  "uses",
1931
1990
  "calls",
1932
1991
  "extends",
@@ -1935,7 +1994,7 @@ var RELATION_TYPES = [
1935
1994
  "listens"
1936
1995
  ];
1937
1996
  function isValidRelationType(t) {
1938
- return typeof t === "string" && RELATION_TYPES.includes(t);
1997
+ return typeof t === "string" && RELATION_TYPES2.includes(t);
1939
1998
  }
1940
1999
  async function parseNodeYaml(filePath) {
1941
2000
  const content = await readFile6(filePath, "utf-8");
@@ -1952,52 +2011,43 @@ async function parseNodeYaml(filePath) {
1952
2011
  const description = typeof raw.description === "string" ? raw.description.trim() : void 0;
1953
2012
  const relations = parseRelations(raw.relations, filePath);
1954
2013
  const mapping = parseMapping(raw.mapping, filePath);
1955
- const aspects = parseAspects(raw.aspects, filePath);
2014
+ const aspectsResult = parseAspects(raw.aspects, filePath);
1956
2015
  const ports = parsePorts(raw.ports, filePath);
1957
2016
  return {
1958
2017
  name: raw.name.trim(),
1959
2018
  type: raw.type.trim(),
1960
2019
  description,
1961
- aspects,
2020
+ aspects: aspectsResult.aspects,
2021
+ ...aspectsResult.aspectWhens && { aspectWhens: aspectsResult.aspectWhens },
1962
2022
  relations: relations.length > 0 ? relations : void 0,
1963
2023
  mapping,
1964
2024
  ports
1965
2025
  };
1966
2026
  }
1967
2027
  function parseAspects(raw, filePath) {
1968
- if (raw === void 0 || raw === null) return void 0;
2028
+ if (raw === void 0 || raw === null) return {};
1969
2029
  if (!Array.isArray(raw)) {
1970
2030
  throw new Error(`yg-node.yaml at ${filePath}: 'aspects' must be an array`);
1971
2031
  }
1972
- if (raw.length === 0) return void 0;
1973
- const result = [];
1974
- const seenAspects = /* @__PURE__ */ new Set();
2032
+ if (raw.length === 0) return {};
2033
+ const aspects = [];
2034
+ let aspectWhens;
2035
+ const seen = /* @__PURE__ */ new Set();
1975
2036
  for (let i = 0; i < raw.length; i++) {
1976
- const item = raw[i];
1977
- let aspectId;
1978
- if (typeof item === "string") {
1979
- aspectId = item.trim();
1980
- if (aspectId === "") {
1981
- throw new Error(
1982
- `yg-node.yaml at ${filePath}: aspects[${i}] must be a non-empty string`
1983
- );
1984
- }
1985
- } else if (typeof item === "object" && item !== null) {
1986
- throw new Error(
1987
- `yg-node.yaml at ${filePath}: aspects must be an array of strings.`
1988
- );
1989
- } else {
1990
- throw new Error(`yg-node.yaml at ${filePath}: aspects[${i}] must be a string`);
2037
+ const parsed = parseAspectAttachment(
2038
+ raw[i],
2039
+ `yg-node.yaml at ${filePath}: aspects[${i}]`
2040
+ );
2041
+ if (seen.has(parsed.id)) {
2042
+ throw new Error(`yg-node.yaml at ${filePath}: duplicate aspect '${parsed.id}' in aspects list`);
1991
2043
  }
1992
- if (seenAspects.has(aspectId)) {
1993
- throw new Error(
1994
- `yg-node.yaml at ${filePath}: duplicate aspect '${aspectId}' in aspects list`
1995
- );
2044
+ seen.add(parsed.id);
2045
+ aspects.push(parsed.id);
2046
+ if (parsed.when) {
2047
+ (aspectWhens ??= {})[parsed.id] = parsed.when;
1996
2048
  }
1997
- seenAspects.add(aspectId);
1998
- result.push(aspectId);
1999
2049
  }
2000
- return result.length > 0 ? result : void 0;
2050
+ return { aspects: aspects.length > 0 ? aspects : void 0, aspectWhens };
2001
2051
  }
2002
2052
  function parseRelations(raw, filePath) {
2003
2053
  if (raw === void 0) return [];
@@ -2089,13 +2139,28 @@ function parsePorts(rawPorts, filePath) {
2089
2139
  if (!Array.isArray(obj.aspects)) {
2090
2140
  throw new Error(`yg-node.yaml at ${filePath}: ports.${name}.aspects must be an array`);
2091
2141
  }
2092
- const aspects = obj.aspects.map((a, i) => {
2093
- if (typeof a !== "string" || a.trim() === "") {
2094
- throw new Error(`yg-node.yaml at ${filePath}: ports.${name}.aspects[${i}] must be a non-empty string`);
2142
+ const portAspects = [];
2143
+ let portAspectWhens;
2144
+ const seenPortAspects = /* @__PURE__ */ new Set();
2145
+ for (let i = 0; i < obj.aspects.length; i++) {
2146
+ const parsed = parseAspectAttachment(
2147
+ obj.aspects[i],
2148
+ `yg-node.yaml at ${filePath}: ports.${name}.aspects[${i}]`
2149
+ );
2150
+ if (seenPortAspects.has(parsed.id)) {
2151
+ throw new Error(`yg-node.yaml at ${filePath}: ports.${name}.aspects has duplicate '${parsed.id}'`);
2095
2152
  }
2096
- return a.trim();
2097
- });
2098
- ports[name] = { description: obj.description.trim(), aspects };
2153
+ seenPortAspects.add(parsed.id);
2154
+ portAspects.push(parsed.id);
2155
+ if (parsed.when) {
2156
+ (portAspectWhens ??= {})[parsed.id] = parsed.when;
2157
+ }
2158
+ }
2159
+ ports[name] = {
2160
+ description: obj.description.trim(),
2161
+ aspects: portAspects,
2162
+ ...portAspectWhens && { aspectWhens: portAspectWhens }
2163
+ };
2099
2164
  }
2100
2165
  return Object.keys(ports).length > 0 ? ports : void 0;
2101
2166
  }
@@ -2146,17 +2211,34 @@ async function parseAspect(aspectDir, aspectYamlPath, id) {
2146
2211
  const description = typeof raw.description === "string" ? raw.description.trim() : void 0;
2147
2212
  const artifacts = await readArtifacts(aspectDir, ["yg-aspect.yaml"]);
2148
2213
  let implies;
2214
+ let impliesWhens;
2149
2215
  if (raw.implies !== void 0) {
2150
2216
  if (!Array.isArray(raw.implies)) {
2151
- throw new Error(`yg-aspect.yaml at ${aspectYamlPath}: 'implies' must be an array of strings`);
2217
+ throw new Error(`yg-aspect.yaml at ${aspectYamlPath}: 'implies' must be an array`);
2152
2218
  }
2153
- implies = raw.implies.filter((t) => typeof t === "string");
2219
+ implies = [];
2220
+ for (let i = 0; i < raw.implies.length; i++) {
2221
+ const parsed = parseAspectAttachment(
2222
+ raw.implies[i],
2223
+ `yg-aspect.yaml at ${aspectYamlPath}: implies[${i}]`
2224
+ );
2225
+ implies.push(parsed.id);
2226
+ if (parsed.when) {
2227
+ (impliesWhens ??= {})[parsed.id] = parsed.when;
2228
+ }
2229
+ }
2230
+ }
2231
+ let when;
2232
+ if (raw.when !== void 0) {
2233
+ when = parseWhen(raw.when, `yg-aspect.yaml at ${aspectYamlPath}: when`);
2154
2234
  }
2155
2235
  return {
2156
2236
  name: raw.name.trim(),
2157
2237
  id: idTrimmed,
2158
2238
  description,
2159
2239
  implies,
2240
+ ...impliesWhens && { impliesWhens },
2241
+ ...when && { when },
2160
2242
  artifacts
2161
2243
  };
2162
2244
  }
@@ -2188,19 +2270,30 @@ async function parseFlow(flowDir, flowYamlPath) {
2188
2270
  );
2189
2271
  }
2190
2272
  let aspects;
2273
+ let aspectWhens;
2191
2274
  if (raw.aspects !== void 0) {
2192
2275
  if (!Array.isArray(raw.aspects)) {
2193
2276
  throw new Error(`yg-flow.yaml at ${flowYamlPath}: 'aspects' must be an array of strings`);
2194
2277
  }
2195
- const aspectTags = raw.aspects.filter((a) => typeof a === "string");
2196
- aspects = aspectTags.length > 0 ? aspectTags : [];
2278
+ aspects = [];
2279
+ for (let i = 0; i < raw.aspects.length; i++) {
2280
+ const parsed = parseAspectAttachment(
2281
+ raw.aspects[i],
2282
+ `yg-flow.yaml at ${flowYamlPath}: aspects[${i}]`
2283
+ );
2284
+ aspects.push(parsed.id);
2285
+ if (parsed.when) {
2286
+ (aspectWhens ??= {})[parsed.id] = parsed.when;
2287
+ }
2288
+ }
2197
2289
  }
2198
2290
  return {
2199
2291
  path: path8.basename(flowDir),
2200
2292
  name: raw.name.trim(),
2201
2293
  description,
2202
2294
  nodes: nodePaths,
2203
- ...aspects !== void 0 && { aspects }
2295
+ ...aspects !== void 0 && { aspects },
2296
+ ...aspectWhens && { aspectWhens }
2204
2297
  };
2205
2298
  }
2206
2299
 
@@ -2247,12 +2340,28 @@ async function parseArchitecture(filePath) {
2247
2340
  `yg-architecture.yaml: node type '${typeName}' has unknown field 'integration_aspects'. Use ports on the target node instead.`
2248
2341
  );
2249
2342
  }
2250
- const aspects = Array.isArray(entry.aspects) ? entry.aspects.filter((t) => typeof t === "string") : void 0;
2343
+ let aspects;
2344
+ let aspectWhens;
2345
+ if (Array.isArray(entry.aspects)) {
2346
+ aspects = [];
2347
+ for (let i = 0; i < entry.aspects.length; i++) {
2348
+ const parsed = parseAspectAttachment(
2349
+ entry.aspects[i],
2350
+ `yg-architecture.yaml: node_types.${typeName}.aspects[${i}]`
2351
+ );
2352
+ aspects.push(parsed.id);
2353
+ if (parsed.when) {
2354
+ (aspectWhens ??= {})[parsed.id] = parsed.when;
2355
+ }
2356
+ }
2357
+ if (aspects.length === 0) aspects = void 0;
2358
+ }
2251
2359
  const parents = Array.isArray(entry.parents) ? entry.parents.filter((t) => typeof t === "string") : void 0;
2252
2360
  const relations = parseRelations2(entry.relations, typeName);
2253
2361
  nodeTypes[typeName] = {
2254
2362
  description: entry.description,
2255
- aspects: aspects && aspects.length > 0 ? aspects : void 0,
2363
+ aspects,
2364
+ ...aspectWhens && { aspectWhens },
2256
2365
  parents: parents && parents.length > 0 ? parents : void 0,
2257
2366
  relations
2258
2367
  };
@@ -2529,57 +2638,191 @@ async function loadSchemas(schemasDir) {
2529
2638
  import { readFile as readFile13 } from "fs/promises";
2530
2639
  import path12 from "path";
2531
2640
 
2641
+ // src/core/when-evaluator.ts
2642
+ function evaluateWhen(predicate, node, graph) {
2643
+ if ("all_of" in predicate) {
2644
+ return predicate.all_of.every((p2) => evaluateWhen(p2, node, graph));
2645
+ }
2646
+ if ("any_of" in predicate) {
2647
+ return predicate.any_of.some((p2) => evaluateWhen(p2, node, graph));
2648
+ }
2649
+ if ("not" in predicate) {
2650
+ return !evaluateWhen(predicate.not, node, graph);
2651
+ }
2652
+ return evaluateAtomic(predicate, node, graph);
2653
+ }
2654
+ function evaluateAtomic(clause, node, graph) {
2655
+ if (clause.relations) {
2656
+ if (!evaluateRelationClause(clause.relations, node.meta.relations ?? [], graph)) return false;
2657
+ }
2658
+ if (clause.descendants) {
2659
+ if (!evaluateDescendantsClause(clause.descendants, node, graph)) return false;
2660
+ }
2661
+ if (clause.node) {
2662
+ if (!evaluateNodeClause(clause.node, node)) return false;
2663
+ }
2664
+ return true;
2665
+ }
2666
+ function evaluateRelationClause(rc, relations, graph) {
2667
+ for (const [relType, match] of Object.entries(rc)) {
2668
+ if (!match) continue;
2669
+ const candidates = relations.filter((r) => r.type === relType);
2670
+ if (!candidates.some((r) => matchesRelation(r, match, graph))) {
2671
+ return false;
2672
+ }
2673
+ }
2674
+ return true;
2675
+ }
2676
+ function matchesRelation(r, match, graph) {
2677
+ if (match.target !== void 0 && r.target !== match.target) return false;
2678
+ if (match.target_type !== void 0) {
2679
+ const tgt = graph.nodes.get(r.target);
2680
+ if (!tgt || tgt.meta.type !== match.target_type) return false;
2681
+ }
2682
+ if (match.consumes_port !== void 0) {
2683
+ if (!r.consumes || !r.consumes.includes(match.consumes_port)) return false;
2684
+ }
2685
+ return true;
2686
+ }
2687
+ function evaluateDescendantsClause(dc, node, graph) {
2688
+ const descendants = collectDescendants(node);
2689
+ if (descendants.length === 0) return false;
2690
+ if (dc.type !== void 0) {
2691
+ if (!descendants.some((d) => d.meta.type === dc.type)) return false;
2692
+ }
2693
+ if (dc.has_port !== void 0) {
2694
+ if (!descendants.some((d) => d.meta.ports && Object.prototype.hasOwnProperty.call(d.meta.ports, dc.has_port))) {
2695
+ return false;
2696
+ }
2697
+ }
2698
+ if (dc.relations) {
2699
+ if (!descendants.some((d) => evaluateRelationClause(dc.relations, d.meta.relations ?? [], graph))) {
2700
+ return false;
2701
+ }
2702
+ }
2703
+ return true;
2704
+ }
2705
+ function evaluateNodeClause(nc, node) {
2706
+ if (nc.type !== void 0 && node.meta.type !== nc.type) return false;
2707
+ if (nc.has_port !== void 0) {
2708
+ if (!node.meta.ports || !Object.prototype.hasOwnProperty.call(node.meta.ports, nc.has_port)) return false;
2709
+ }
2710
+ if (nc.has_mapping !== void 0) {
2711
+ const has = (node.meta.mapping?.length ?? 0) > 0;
2712
+ if (has !== nc.has_mapping) return false;
2713
+ }
2714
+ return true;
2715
+ }
2716
+ function collectDescendants(node) {
2717
+ const out = [];
2718
+ const queue = [...node.children];
2719
+ while (queue.length > 0) {
2720
+ const curr = queue.shift();
2721
+ out.push(curr);
2722
+ for (const c of curr.children) queue.push(c);
2723
+ }
2724
+ return out;
2725
+ }
2726
+
2532
2727
  // src/core/effective-aspects.ts
2533
2728
  function computeEffectiveAspects(node, graph) {
2534
- const raw = /* @__PURE__ */ new Set();
2729
+ const direct = /* @__PURE__ */ new Set();
2730
+ const ancestors = collectAncestors(node);
2731
+ const tryAdd = (aspectId, attachWhen) => {
2732
+ const aspectDef = graph.aspects.find((a) => a.id === aspectId);
2733
+ const globalWhen = aspectDef?.when;
2734
+ if (globalWhen && !evaluateWhen(globalWhen, node, graph)) {
2735
+ debugWrite(`[effective-aspects] node '${node.path}' aspect '${aspectId}' filtered: global when=false`);
2736
+ return;
2737
+ }
2738
+ if (attachWhen && !evaluateWhen(attachWhen, node, graph)) {
2739
+ debugWrite(`[effective-aspects] node '${node.path}' aspect '${aspectId}' filtered: attach-site when=false`);
2740
+ return;
2741
+ }
2742
+ direct.add(aspectId);
2743
+ };
2535
2744
  for (const id of node.meta.aspects ?? []) {
2536
- raw.add(id);
2745
+ tryAdd(id, node.meta.aspectWhens?.[id]);
2537
2746
  }
2538
- const ancestors = collectAncestors(node);
2539
2747
  for (const ancestor of ancestors) {
2540
2748
  for (const id of ancestor.meta.aspects ?? []) {
2541
- raw.add(id);
2749
+ tryAdd(id, ancestor.meta.aspectWhens?.[id]);
2542
2750
  }
2543
2751
  }
2544
2752
  if (graph.architecture) {
2545
2753
  const typeDef = graph.architecture.node_types[node.meta.type];
2546
2754
  for (const id of typeDef?.aspects ?? []) {
2547
- raw.add(id);
2755
+ tryAdd(id, typeDef?.aspectWhens?.[id]);
2548
2756
  }
2549
2757
  }
2550
2758
  if (graph.architecture) {
2551
2759
  for (const ancestor of ancestors) {
2552
2760
  const typeDef = graph.architecture.node_types[ancestor.meta.type];
2553
2761
  for (const id of typeDef?.aspects ?? []) {
2554
- raw.add(id);
2762
+ tryAdd(id, typeDef?.aspectWhens?.[id]);
2555
2763
  }
2556
2764
  }
2557
2765
  }
2558
2766
  const allPaths = /* @__PURE__ */ new Set([node.path, ...ancestors.map((a) => a.path)]);
2559
2767
  for (const flow of graph.flows) {
2560
- if (flow.nodes.some((n) => allPaths.has(n))) {
2561
- for (const id of flow.aspects ?? []) {
2562
- raw.add(id);
2563
- }
2768
+ if (!flow.nodes.some((n) => allPaths.has(n))) continue;
2769
+ for (const id of flow.aspects ?? []) {
2770
+ tryAdd(id, flow.aspectWhens?.[id]);
2564
2771
  }
2565
2772
  }
2566
2773
  if (node.meta.relations) {
2567
2774
  for (const relation of node.meta.relations) {
2568
2775
  const targetNode = graph.nodes.get(relation.target);
2569
2776
  if (!targetNode) continue;
2570
- if (relation.consumes && targetNode.meta.ports) {
2571
- for (const portName of relation.consumes) {
2572
- const port = targetNode.meta.ports[portName];
2573
- if (port?.aspects) {
2574
- for (const id of port.aspects) {
2575
- raw.add(id);
2576
- }
2577
- }
2777
+ if (!relation.consumes || !targetNode.meta.ports) continue;
2778
+ for (const portName of relation.consumes) {
2779
+ const port = targetNode.meta.ports[portName];
2780
+ if (!port?.aspects) continue;
2781
+ for (const id of port.aspects) {
2782
+ tryAdd(id, port.aspectWhens?.[id]);
2578
2783
  }
2579
2784
  }
2580
2785
  }
2581
2786
  }
2582
- return expandImplies(raw, graph);
2787
+ return expandImpliesFiltered(direct, node, graph);
2788
+ }
2789
+ function expandImpliesFiltered(directIds, node, graph) {
2790
+ const idToAspect = /* @__PURE__ */ new Map();
2791
+ for (const a of graph.aspects) idToAspect.set(a.id, a);
2792
+ const result = /* @__PURE__ */ new Set();
2793
+ const visited = /* @__PURE__ */ new Set();
2794
+ const stack = /* @__PURE__ */ new Set();
2795
+ const visit = (id, implierId) => {
2796
+ if (stack.has(id)) {
2797
+ throw new Error(`Aspect implies cycle detected involving aspect '${id}'`);
2798
+ }
2799
+ if (visited.has(id)) return;
2800
+ const aspectDef = idToAspect.get(id);
2801
+ if (aspectDef?.when && !evaluateWhen(aspectDef.when, node, graph)) {
2802
+ debugWrite(`[effective-aspects] node '${node.path}' aspect '${id}' filtered: global when=false (implies path)`);
2803
+ return;
2804
+ }
2805
+ if (implierId) {
2806
+ const implierDef = idToAspect.get(implierId);
2807
+ const perImplies = implierDef?.impliesWhens?.[id];
2808
+ if (perImplies && !evaluateWhen(perImplies, node, graph)) {
2809
+ debugWrite(`[effective-aspects] node '${node.path}' aspect '${id}' filtered: impliesWhens from '${implierId}' is false`);
2810
+ return;
2811
+ }
2812
+ }
2813
+ stack.add(id);
2814
+ visited.add(id);
2815
+ result.add(id);
2816
+ const implies = aspectDef?.implies;
2817
+ if (implies) {
2818
+ for (const implied of implies) {
2819
+ visit(implied, id);
2820
+ }
2821
+ }
2822
+ stack.delete(id);
2823
+ };
2824
+ for (const id of directIds) visit(id, null);
2825
+ return result;
2583
2826
  }
2584
2827
  function getAspectSource(aspectId, node, graph) {
2585
2828
  if (node.meta.aspects?.includes(aspectId)) {
@@ -2646,37 +2889,6 @@ function collectAncestors(node) {
2646
2889
  }
2647
2890
  return ancestors;
2648
2891
  }
2649
- function expandImplies(aspectIds, graph) {
2650
- const idToImplies = /* @__PURE__ */ new Map();
2651
- for (const aspect of graph.aspects) {
2652
- if (aspect.implies) {
2653
- idToImplies.set(aspect.id, aspect.implies);
2654
- }
2655
- }
2656
- const result = /* @__PURE__ */ new Set();
2657
- const visited = /* @__PURE__ */ new Set();
2658
- const stack = /* @__PURE__ */ new Set();
2659
- function collect(id) {
2660
- if (stack.has(id)) {
2661
- throw new Error(`Aspect implies cycle detected involving aspect '${id}'`);
2662
- }
2663
- if (visited.has(id)) return;
2664
- stack.add(id);
2665
- visited.add(id);
2666
- result.add(id);
2667
- const implies = idToImplies.get(id);
2668
- if (implies) {
2669
- for (const implied of implies) {
2670
- collect(implied);
2671
- }
2672
- }
2673
- stack.delete(id);
2674
- }
2675
- for (const id of aspectIds) {
2676
- collect(id);
2677
- }
2678
- return result;
2679
- }
2680
2892
 
2681
2893
  // src/core/context-builder.ts
2682
2894
  var STRUCTURAL_RELATION_TYPES = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements"]);
@@ -3069,13 +3281,6 @@ async function expandMappingPaths(projectRoot, mappingPaths) {
3069
3281
  return result;
3070
3282
  }
3071
3283
 
3072
- // src/formatters/message-builder.ts
3073
- function buildIssueMessage(msg) {
3074
- return `${msg.what}
3075
- ${msg.why}
3076
- ${msg.next}`;
3077
- }
3078
-
3079
3284
  // src/core/validator.ts
3080
3285
  async function validate(graph, scope = "all") {
3081
3286
  const issues = [];
@@ -3126,6 +3331,7 @@ async function validate(graph, scope = "all") {
3126
3331
  issues.push(...checkPortAspectsDefined(graph));
3127
3332
  issues.push(...checkPortConsumes(graph));
3128
3333
  issues.push(...checkOrphanedAspects(graph));
3334
+ issues.push(...checkWhenReferences(graph));
3129
3335
  let filtered = issues;
3130
3336
  let nodesScanned = graph.nodes.size;
3131
3337
  const normalizedScope = scope.trim().replace(/\\/g, "/").replace(/\/+$/, "");
@@ -3943,6 +4149,141 @@ function checkOrphanedAspects(graph) {
3943
4149
  }
3944
4150
  return issues;
3945
4151
  }
4152
+ function checkWhenReferences(graph) {
4153
+ const issues = [];
4154
+ const knownTypes = new Set(Object.keys(graph.architecture?.node_types ?? {}));
4155
+ const visitPredicate = (p2, ctx) => {
4156
+ if ("all_of" in p2) {
4157
+ p2.all_of.forEach((c, i) => visitPredicate(c, `${ctx}/all_of[${i}]`));
4158
+ return;
4159
+ }
4160
+ if ("any_of" in p2) {
4161
+ p2.any_of.forEach((c, i) => visitPredicate(c, `${ctx}/any_of[${i}]`));
4162
+ return;
4163
+ }
4164
+ if ("not" in p2) {
4165
+ visitPredicate(p2.not, `${ctx}/not`);
4166
+ return;
4167
+ }
4168
+ visitAtomic(p2, ctx);
4169
+ };
4170
+ const visitAtomic = (a, ctx) => {
4171
+ if (a.relations) visitRelationClause(a.relations, `${ctx}/relations`);
4172
+ if (a.descendants) visitDescendantsClause(a.descendants, `${ctx}/descendants`);
4173
+ if (a.node) visitNodeClause(a.node, `${ctx}/node`);
4174
+ };
4175
+ const visitRelationClause = (rc, ctx) => {
4176
+ for (const [relType, match] of Object.entries(rc)) {
4177
+ if (!match) continue;
4178
+ if (match.target_type !== void 0 && !knownTypes.has(match.target_type)) {
4179
+ issues.push({
4180
+ severity: "error",
4181
+ code: "when-unknown-type",
4182
+ rule: "when-unknown-type",
4183
+ message: buildIssueMessage({
4184
+ what: `Unknown node type '${match.target_type}' in when at ${ctx}/${relType}.target_type.`,
4185
+ why: "The predicate references a type that is not defined in yg-architecture.yaml; it will never evaluate.",
4186
+ next: `Fix the type name or define it in yg-architecture.yaml. Known types: ${Array.from(knownTypes).join(", ")}.`
4187
+ })
4188
+ });
4189
+ }
4190
+ if (match.target !== void 0 && !graph.nodes.has(match.target)) {
4191
+ issues.push({
4192
+ severity: "error",
4193
+ code: "when-unknown-node",
4194
+ rule: "when-unknown-node",
4195
+ message: buildIssueMessage({
4196
+ what: `Referenced node '${match.target}' in when at ${ctx}/${relType}.target does not exist.`,
4197
+ why: "The predicate targets a node that is not in the graph.",
4198
+ next: `Fix the node path or add the node under .yggdrasil/model/.`
4199
+ })
4200
+ });
4201
+ }
4202
+ if (match.consumes_port !== void 0 && match.target !== void 0) {
4203
+ const tgt = graph.nodes.get(match.target);
4204
+ if (tgt && !(tgt.meta.ports && match.consumes_port in tgt.meta.ports)) {
4205
+ issues.push({
4206
+ severity: "error",
4207
+ code: "when-unknown-port",
4208
+ rule: "when-unknown-port",
4209
+ message: buildIssueMessage({
4210
+ what: `Port '${match.consumes_port}' is not declared on node '${match.target}' in when at ${ctx}/${relType}.consumes_port.`,
4211
+ why: "The predicate references a port that does not exist on the target node.",
4212
+ next: `Fix the port name or add it to .yggdrasil/model/${match.target}/yg-node.yaml.`
4213
+ })
4214
+ });
4215
+ }
4216
+ }
4217
+ }
4218
+ };
4219
+ const visitDescendantsClause = (dc, ctx) => {
4220
+ if (dc.relations) visitRelationClause(dc.relations, `${ctx}/relations`);
4221
+ if (dc.type !== void 0 && !knownTypes.has(dc.type)) {
4222
+ issues.push({
4223
+ severity: "error",
4224
+ code: "when-unknown-type",
4225
+ rule: "when-unknown-type",
4226
+ message: buildIssueMessage({
4227
+ what: `Unknown node type '${dc.type}' in when at ${ctx}/type.`,
4228
+ why: "The predicate references a type that is not defined in yg-architecture.yaml.",
4229
+ next: `Fix the type name or define it in yg-architecture.yaml. Known types: ${Array.from(knownTypes).join(", ")}.`
4230
+ })
4231
+ });
4232
+ }
4233
+ };
4234
+ const visitNodeClause = (nc, ctx) => {
4235
+ if (nc.type !== void 0 && !knownTypes.has(nc.type)) {
4236
+ issues.push({
4237
+ severity: "error",
4238
+ code: "when-unknown-type",
4239
+ rule: "when-unknown-type",
4240
+ message: buildIssueMessage({
4241
+ what: `Unknown node type '${nc.type}' in when at ${ctx}/type.`,
4242
+ why: "The predicate references a type that is not defined in yg-architecture.yaml.",
4243
+ next: `Fix the type name or define it in yg-architecture.yaml. Known types: ${Array.from(knownTypes).join(", ")}.`
4244
+ })
4245
+ });
4246
+ }
4247
+ };
4248
+ for (const aspect of graph.aspects) {
4249
+ if (aspect.when) visitPredicate(aspect.when, `aspect '${aspect.id}' when`);
4250
+ if (aspect.impliesWhens) {
4251
+ for (const [targetId, pred] of Object.entries(aspect.impliesWhens)) {
4252
+ visitPredicate(pred, `aspect '${aspect.id}' implies[${targetId}] when`);
4253
+ }
4254
+ }
4255
+ }
4256
+ if (graph.architecture) {
4257
+ for (const [typeName, typeDef] of Object.entries(graph.architecture.node_types)) {
4258
+ if (!typeDef.aspectWhens) continue;
4259
+ for (const [aspectId, pred] of Object.entries(typeDef.aspectWhens)) {
4260
+ visitPredicate(pred, `architecture node_types.${typeName} aspectWhens[${aspectId}]`);
4261
+ }
4262
+ }
4263
+ }
4264
+ for (const [nodePath, node] of graph.nodes) {
4265
+ if (node.meta.aspectWhens) {
4266
+ for (const [aspectId, pred] of Object.entries(node.meta.aspectWhens)) {
4267
+ visitPredicate(pred, `node '${nodePath}' aspectWhens[${aspectId}]`);
4268
+ }
4269
+ }
4270
+ if (node.meta.ports) {
4271
+ for (const [portName, portDef] of Object.entries(node.meta.ports)) {
4272
+ if (!portDef.aspectWhens) continue;
4273
+ for (const [aspectId, pred] of Object.entries(portDef.aspectWhens)) {
4274
+ visitPredicate(pred, `node '${nodePath}' ports.${portName} aspectWhens[${aspectId}]`);
4275
+ }
4276
+ }
4277
+ }
4278
+ }
4279
+ for (const flow of graph.flows) {
4280
+ if (!flow.aspectWhens) continue;
4281
+ for (const [aspectId, pred] of Object.entries(flow.aspectWhens)) {
4282
+ visitPredicate(pred, `flow '${flow.path}' aspectWhens[${aspectId}]`);
4283
+ }
4284
+ }
4285
+ return issues;
4286
+ }
3946
4287
 
3947
4288
  // src/cli/owner.ts
3948
4289
  import path15 from "path";
@@ -4240,12 +4581,14 @@ async function writeNodeDriftState(yggRoot, nodePath, nodeState) {
4240
4581
  const content = JSON.stringify(nodeState, null, 2) + "\n";
4241
4582
  await writeFile5(filePath, content, "utf-8");
4242
4583
  }
4243
- async function garbageCollectDriftState(yggRoot, validNodePaths) {
4584
+ async function garbageCollectDriftState(yggRoot, validNodePaths, shouldKeep) {
4244
4585
  const driftDir = path16.join(yggRoot, DRIFT_STATE_DIR);
4245
4586
  const allNodePaths = await scanJsonFiles(driftDir, driftDir);
4246
4587
  const removed = [];
4247
4588
  for (const nodePath of allNodePaths) {
4248
- if (!validNodePaths.has(nodePath)) {
4589
+ const inGraph = validNodePaths.has(nodePath);
4590
+ const keep = inGraph && (shouldKeep ? shouldKeep(nodePath) : true);
4591
+ if (!keep) {
4249
4592
  const filePath = nodeStatePath(yggRoot, nodePath);
4250
4593
  await rm2(filePath);
4251
4594
  await removeEmptyParents(filePath, driftDir);
@@ -4524,7 +4867,16 @@ function getChildMappingExclusions(graph, nodePath) {
4524
4867
  }
4525
4868
  async function runGC(graph) {
4526
4869
  const validPaths = new Set(graph.nodes.keys());
4527
- return garbageCollectDriftState(graph.rootPath, validPaths);
4870
+ return garbageCollectDriftState(
4871
+ graph.rootPath,
4872
+ validPaths,
4873
+ (nodePath) => {
4874
+ const node = graph.nodes.get(nodePath);
4875
+ if (!node) return false;
4876
+ const effective = computeEffectiveAspects(node, graph);
4877
+ return effective.size > 0;
4878
+ }
4879
+ );
4528
4880
  }
4529
4881
  async function commitApproval(yggRoot, result) {
4530
4882
  if (result.pendingDriftState) {
@@ -4824,6 +5176,15 @@ async function runCheck(graph, gitTrackedFiles) {
4824
5176
  coverageIssue = buildCoverageIssue(uncovered, totalFiles);
4825
5177
  }
4826
5178
  const orphanedPaths = await detectOrphanedDriftState(graph);
5179
+ await garbageCollectDriftState(
5180
+ graph.rootPath,
5181
+ new Set(graph.nodes.keys()),
5182
+ (nodePath) => {
5183
+ const node = graph.nodes.get(nodePath);
5184
+ if (!node) return false;
5185
+ return computeEffectiveAspects(node, graph).size > 0;
5186
+ }
5187
+ );
4827
5188
  const yggRelative = path19.relative(path19.dirname(graph.rootPath), graph.rootPath).replace(/\\/g, "/").replace(/\/+$/, "");
4828
5189
  const orphanWarnings = orphanedPaths.map((p2) => ({
4829
5190
  severity: "warning",
@@ -5472,26 +5833,57 @@ async function loadSecrets(rootPath, providerName) {
5472
5833
  return void 0;
5473
5834
  }
5474
5835
  const raw = parseYaml9(content);
5475
- if (!raw) return void 0;
5476
- if (raw.reviewer && typeof raw.reviewer === "object") {
5477
- const reviewerRaw = raw.reviewer;
5478
- if (!providerName) return void 0;
5479
- const providerKey = providerName;
5480
- const providerSection = reviewerRaw[providerKey];
5481
- if (!providerSection || typeof providerSection !== "object") return void 0;
5482
- return extractSecretFields(providerSection);
5483
- }
5484
- return void 0;
5485
- }
5486
- function extractSecretFields(raw) {
5836
+ if (raw === null || raw === void 0) return void 0;
5837
+ if (typeof raw !== "object" || Array.isArray(raw)) {
5838
+ throw new Error(`yg-secrets.yaml: top level must be a YAML mapping`);
5839
+ }
5840
+ const rawObj = raw;
5841
+ if (rawObj.reviewer === void 0) return void 0;
5842
+ if (typeof rawObj.reviewer !== "object" || rawObj.reviewer === null || Array.isArray(rawObj.reviewer)) {
5843
+ throw new Error(`yg-secrets.yaml: 'reviewer' must be a YAML mapping`);
5844
+ }
5845
+ if (!providerName) return void 0;
5846
+ const reviewerRaw = rawObj.reviewer;
5847
+ const providerSection = reviewerRaw[providerName];
5848
+ if (providerSection === void 0) return void 0;
5849
+ if (typeof providerSection !== "object" || providerSection === null || Array.isArray(providerSection)) {
5850
+ throw new Error(`yg-secrets.yaml: 'reviewer.${providerName}' must be a YAML mapping`);
5851
+ }
5852
+ return extractSecretFields(providerSection, providerName);
5853
+ }
5854
+ function extractSecretFields(raw, providerName) {
5855
+ const ctx = (field) => `yg-secrets.yaml at reviewer.${providerName}.${field}`;
5487
5856
  const partial = {};
5488
- if (typeof raw.api_key === "string") partial.api_key = raw.api_key;
5489
- if (typeof raw.provider === "string") partial.provider = raw.provider;
5490
- if (typeof raw.model === "string") partial.model = raw.model;
5491
- if (typeof raw.endpoint === "string") partial.endpoint = raw.endpoint;
5492
- if (typeof raw.temperature === "number") partial.temperature = raw.temperature;
5493
- if (typeof raw.consensus === "number") partial.consensus = raw.consensus;
5494
- if (raw.max_tokens !== void 0) partial.max_tokens = raw.max_tokens;
5857
+ if (raw.api_key !== void 0) {
5858
+ if (typeof raw.api_key !== "string") throw new Error(`${ctx("api_key")}: must be a string`);
5859
+ partial.api_key = raw.api_key;
5860
+ }
5861
+ if (raw.provider !== void 0) {
5862
+ if (typeof raw.provider !== "string") throw new Error(`${ctx("provider")}: must be a string`);
5863
+ partial.provider = raw.provider;
5864
+ }
5865
+ if (raw.model !== void 0) {
5866
+ if (typeof raw.model !== "string") throw new Error(`${ctx("model")}: must be a string`);
5867
+ partial.model = raw.model;
5868
+ }
5869
+ if (raw.endpoint !== void 0) {
5870
+ if (typeof raw.endpoint !== "string") throw new Error(`${ctx("endpoint")}: must be a string`);
5871
+ partial.endpoint = raw.endpoint;
5872
+ }
5873
+ if (raw.temperature !== void 0) {
5874
+ if (typeof raw.temperature !== "number") throw new Error(`${ctx("temperature")}: must be a number`);
5875
+ partial.temperature = raw.temperature;
5876
+ }
5877
+ if (raw.consensus !== void 0) {
5878
+ if (typeof raw.consensus !== "number") throw new Error(`${ctx("consensus")}: must be a number`);
5879
+ partial.consensus = raw.consensus;
5880
+ }
5881
+ if (raw.max_tokens !== void 0) {
5882
+ if (typeof raw.max_tokens !== "number" && raw.max_tokens !== "auto") {
5883
+ throw new Error(`${ctx("max_tokens")}: must be a number or 'auto'`);
5884
+ }
5885
+ partial.max_tokens = raw.max_tokens;
5886
+ }
5495
5887
  return Object.keys(partial).length > 0 ? partial : void 0;
5496
5888
  }
5497
5889
  function mergeLlmConfig(base, secrets) {
@@ -5988,7 +6380,7 @@ function buildTransitiveChains(targetNode, direct, allDependents, reverse) {
5988
6380
  }
5989
6381
  return chains.sort();
5990
6382
  }
5991
- function collectDescendants(graph, nodePath) {
6383
+ function collectDescendants2(graph, nodePath) {
5992
6384
  const node = graph.nodes.get(nodePath);
5993
6385
  if (!node) return [];
5994
6386
  const result = [];
@@ -6142,7 +6534,7 @@ async function handleFlowImpact(graph, flowName) {
6142
6534
  for (const nodePath of flow.nodes) {
6143
6535
  if (graph.nodes.has(nodePath)) {
6144
6536
  participants.add(nodePath);
6145
- for (const desc of collectDescendants(graph, nodePath)) {
6537
+ for (const desc of collectDescendants2(graph, nodePath)) {
6146
6538
  participants.add(desc);
6147
6539
  }
6148
6540
  }
@@ -6318,7 +6710,7 @@ function registerImpactCommand(program2) {
6318
6710
  `);
6319
6711
  }
6320
6712
  }
6321
- const descendants = collectDescendants(graph, nodePath);
6713
+ const descendants = collectDescendants2(graph, nodePath);
6322
6714
  if (descendants.length > 0) {
6323
6715
  process.stdout.write("\nDescendants (hierarchy impact):\n");
6324
6716
  for (const desc of descendants) {