@chrisdudek/yg 0.3.4 → 1.1.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
@@ -17,12 +17,10 @@ stack:
17
17
 
18
18
  standards: ""
19
19
 
20
- tags: []
21
-
22
20
  node_types:
23
- - module
24
- - service
25
- - library
21
+ - name: module
22
+ - name: service
23
+ - name: library
26
24
 
27
25
  artifacts:
28
26
  responsibility.md:
@@ -56,21 +54,12 @@ artifacts:
56
54
  required: never
57
55
  description: "Local design decisions and rationale \u2014 choices specific to this node, not system-wide"
58
56
 
59
- knowledge_categories:
60
- - name: decisions
61
- description: "Global semantic decisions and their rationale"
62
- - name: patterns
63
- description: "Implementation conventions with examples"
64
- - name: invariants
65
- description: "System truths that must never be violated"
66
-
67
57
  quality:
68
58
  min_artifact_length: 50
69
59
  max_direct_relations: 10
70
60
  context_budget:
71
61
  warning: 10000
72
62
  error: 20000
73
- knowledge_staleness_days: 90
74
63
  `;
75
64
 
76
65
  // src/templates/platform.ts
@@ -78,244 +67,319 @@ import { readFile, writeFile, mkdir } from "fs/promises";
78
67
  import path from "path";
79
68
 
80
69
  // src/templates/rules.ts
81
- var AGENT_RULES_CONTENT = `# Yggdrasil - System Semantic Memory (Operating Manual)
82
-
83
- You are working in a repository managed by Yggdrasil.
84
- Yggdrasil is a persistent, structured semantic memory graph stored in \`.yggdrasil/\`. It maps the repository, dictates system rules, and assembles implementation contexts.
85
-
86
- **Why:** AI agents degrade with project size \u2014 too little context breaks contracts, too much context drowns in noise. The graph is persistent semantic memory: agents need exactly the right tokens for the task, not the whole repo. Small, precise context beats massive noisy. A repository with Yggdrasil is self-aware.
87
-
88
- THIS PROMPT IS YOUR ENTIRE OPERATING MANUAL. Read it carefully. Follow it strictly.
89
-
90
- ---
91
-
92
- ## 0. ATOMIC INVARIANT \u2014 OVERRIDES EVERYTHING
93
-
94
- **CODE \u2194 GRAPH ARE ONE.**
95
- - Code changed \u2192 graph artifacts MUST be updated IN THE SAME RESPONSE. No exceptions.
96
- - Graph changed \u2192 source files MUST be verified/updated IN THE SAME RESPONSE. No exceptions.
97
- - There is NO "code-only" response. There is NO "graph-only" response (unless explicitly a dry-run plan).
98
- - If you complete a response and one side is changed but the other is not, you have VIOLATED the core contract of Yggdrasil.
99
-
100
- ---
101
-
102
- ## 1. CORE PRINCIPLES (NON-NEGOTIABLE)
103
-
104
- 1. **Graph First, Always:** Before answering a question, modifying code, or planning a feature, you MUST consult the graph.
105
- 2. **Context is Sufficient:** If you feel the need to randomly explore source files to understand what a node should do, the graph is incomplete. **Fix the graph** (add decisions, interface details, constraints). Do not bypass the graph by reading raw code.
106
- 3. **Graph is Intended Truth:** If the code and graph diverge, the graph is the truth. If a code change is deliberate, update the graph to match.
107
- 4. **Exhaustive Coverage:** Every source file MUST belong to exactly one graph node. No orphaned files.
108
- 5. **Tools Read, You Write:** The \`yg\` CLI tools only read, validate, and manage metadata. YOU must create and edit graph directories, \`.yaml\` files, and \`.md\` artifacts manually.
109
- 6. **English Only for Artifacts:** All graph artifact files (filenames from \`config.artifacts\`, in the same directory as \`node.yaml\`) MUST be written in English. Conversation can be in the user's language.
110
- 7. **Never Touch Operational Metadata:** NEVER manually edit \`.yggdrasil/.drift-state\` or \`.yggdrasil/.journal.yaml\`.
111
- 8. **Ask, Never Infer:** If graph and code diverge in a way with multiple valid resolutions, or if a required decision is ambiguous \u2014 STOP. State the ambiguity. List interpretations. Ask the user to decide. Never silently choose. Never patch without confirmation. When you stop, always explain the context and available options clearly so the user can make an informed choice.
112
-
113
- ---
114
-
115
- ## 1.5 FAILURE STATES
70
+ var CORE_PROTOCOL = `## CORE PROTOCOL
71
+
72
+ Yggdrasil is persistent semantic memory stored in \`.yggdrasil/\`. It maps the repository and provides deterministic implementation context for every node. This document is your complete operating manual. Follow it strictly.
73
+
74
+ ### Quick Start Protocol
75
+
76
+ \`\`\`
77
+ BEFORE reading, researching, planning, OR modifying ANY mapped file:
78
+ 1. yg owner --file <path>
79
+ 2. yg build-context --node <owner>
80
+ The context package is your primary source of understanding.
81
+ Raw file reads are for implementation details WITHIN a node you
82
+ already understand from its context package.
83
+ If the context package seems insufficient \u2014 enrich the graph.
84
+ Do not bypass it.
85
+
86
+ AFTER modifying:
87
+ 3. Update graph artifacts to reflect changes
88
+ 4. yg validate \u2014 fix all errors
89
+ 5. yg drift-sync --node <owner>
90
+
91
+ EVERY conversation start:
92
+ yg preflight \u2192 act on findings (see Operations)
93
+
94
+ NEVER: modify code without graph coverage.
95
+ NEVER: read mapped source files to understand a component without
96
+ running yg build-context first \u2014 the graph captures intent,
97
+ constraints, and relations that source files cannot.
98
+ NEVER: invent rationale, business rules, or decisions.
99
+ NEVER: auto-resolve drift without asking the user.
100
+ WHEN UNSURE: ask the user. Never guess. Never assume.
101
+ \`\`\`
102
+
103
+ ### Five Core Rules
116
104
 
117
- You have fundamentally broken Yggdrasil if you do any of the following:
118
- - \u274C You modified source code without having run \`yg owner --file <path>\` for each modified file in the SAME response.
119
- - \u274C You modified source code without updating graph artifacts in the SAME response.
120
- - \u274C You modified graph files without verifying/updating source code alignment in the SAME response.
121
- - \u274C You resolved a code\u2194graph inconsistency without asking the user first.
122
- - \u274C You created or edited a graph element without reading its schema in \`.yggdrasil/templates/\`.
123
- - \u274C You ran \`yg drift-sync\` before updating graph artifacts.
124
- - \u274C You ran \`yg drift-sync\` after a graph-only change without verifying source files.
125
- - \u274C You used Blackbox coverage for greenfield/new code.
126
- - \u274C You answered a question about a mapped file/area without running \`yg build-context\` when the path was known.
105
+ 1. **Graph first.** Before reading, researching, planning, or modifying mapped files, run \`yg owner\` and \`yg build-context\`. Always. The context package \u2014 not raw source \u2014 is your primary source of understanding.
106
+ 2. **Code and graph are one.** Code changed \u2192 graph updated in the same response. Graph changed \u2192 source verified in the same response. No exceptions.
107
+ 3. **Never invent why.** The graph captures human intent. If you don't know why something was decided, ask. Never hallucinate rationale.
108
+ 4. **Always capture why \u2014 especially why NOT.** When the user explains a reason, record it in the graph immediately. When a design choice is made, also record rejected alternatives: "Chose X over Y because Z." Rejected alternatives are the highest-value information \u2014 invisible in code and irrecoverable once forgotten. Conversation evaporates; graph persists.
109
+ 5. **Ask before resolving ambiguity.** When multiple valid interpretations exist, stop, list options, ask the user. Never silently choose.
127
110
 
128
- ---
129
-
130
- ## 2. CONVERSATION LIFECYCLE (YOUR HABITS)
131
-
132
- You do not need explicit "session" commands. Follow these conversational triggers:
133
-
134
- ### A. Preflight (First message of the conversation)
135
- Always execute these commands before doing anything else. *(Exception: If the user's request is clearly read-only, run ONLY step 1).* **Read-only** means the user asks only for explanation, clarification, analysis, or evaluation \u2014 no code or graph modification will occur. Examples: "explain this", "evaluate X", "what does Y do?", "analyze Z". If unsure, run full preflight.
136
- 1. \`yg journal-read\` -> If entries exist, consolidate them into the graph, then \`yg journal-archive\`.
137
- 2. \`yg drift\` -> If **drift** (code changed, graph baseline stale): run \`yg drift-sync\` for each affected node. Do NOT ask the user \u2014 they do not need to know this step. If **missing** or **unmaterialized**: report and ask the user how to proceed.
138
- 3. \`yg status\` -> Report graph health.
139
- 4. \`yg validate\` -> If W008 stale-knowledge appears, update the knowledge artifacts to reflect current node state.
140
-
141
- ### B. Answering Questions (When a specific file or area is known)
142
- When the user asks a question and you know (or can infer) which file or area of the codebase it concerns:
143
- 1. Run \`yg owner --file <path>\` for the relevant file(s).
144
- 2. **If owner FOUND:** Run \`yg build-context --node <node_path>\` and base your answer on that context. Do NOT answer from grep/search alone \u2014 the graph provides intent, constraints, and relations that yield better answers.
145
- 3. **If owner NOT FOUND:** The file is outside the graph (e.g. third-party code, user's theme/plugin, unmapped area). You may answer from grep/search, but state that the answer is not graph-based.
146
-
147
- This applies even when you are **not modifying files** \u2014 e.g. when providing code snippets to paste elsewhere, explaining behavior, or suggesting hooks. If the question touches mapped code, build-context first.
148
-
149
- ### C. Session Verification (Wrap-up)
150
- Triggered by phrases like: "we're done", "wrap up", "that's enough", "done", "ok".
151
- **Note: The graph should ALREADY be up to date. If the graph requires massive updates at this stage, YOU HAVE FAILED.**
152
- 1. If iterative journal mode was used: consolidate notes to the graph, then \`yg journal-archive\`.
153
- 2. \`yg drift\` -> If drift detected, run \`yg drift-sync\` for each affected node. Do NOT ask \u2014 absorb automatically.
154
- 3. \`yg validate\` -> Fix any structural errors.
155
- 4. Report exactly what nodes and files were changed.
156
-
157
- ---
158
-
159
- ## 3. WORKFLOW: MODIFYING OR CREATING FILES (Code-First)
160
-
161
- You are NOT ALLOWED to edit or create source code without establishing graph coverage first.
162
-
163
- **Gate:** Before using any tool that modifies files, you MUST have run \`yg owner --file <path>\` for each file you intend to modify. If you have not \u2014 run it first, then proceed. No exceptions. Gate applies to **source files** (files outside \`.yggdrasil/\`). For graph files (\`.yggdrasil/model/\`, \`.yggdrasil/aspects/\`, etc.), follow the Graph Modification Checklist in section 4 instead.
111
+ ### Failure States
164
112
 
165
- **Step 1: Check coverage** -> Run \`yg owner --file <path>\`
113
+ You have broken Yggdrasil if you do any of the following:
114
+
115
+ - \u274C Modified source code without running \`yg owner --file <path>\` first.
116
+ - \u274C Modified source code without updating graph artifacts in the same response.
117
+ - \u274C Modified graph files without verifying source code alignment in the same response.
118
+ - \u274C Resolved a code-graph inconsistency without asking the user first.
119
+ - \u274C Created or edited a graph element without reading its schema in \`schemas/\` first.
120
+ - \u274C Ran \`yg drift-sync\` before updating graph artifacts.
121
+ - \u274C Wrote a flow description that describes code sequences instead of a business process.
122
+ - \u274C Used an aspect identifier that has no corresponding \`aspects/\` directory.
123
+ - \u274C Placed a cross-cutting requirement in a local node artifact instead of an aspect.
124
+ - \u274C Invented a rationale, business rule, or architectural decision.
125
+ - \u274C Used blackbox coverage for greenfield (new) code.
126
+ - \u274C Answered a question about a mapped file without running \`yg build-context\` first.
127
+ - \u274C Read mapped source files to plan or research changes without running \`yg build-context\` first.
128
+ - \u274C Deferred \`yg drift-sync\` to the end of a multi-step task instead of running it incrementally after each logical group of changes.
129
+ - \u274C Recorded a design decision without documenting which alternatives were rejected and why.
130
+
131
+ ### Escape Hatch
166
132
 
167
- **Step 2: If Owner FOUND (The Execution Checklist)**
168
- Whenever you write or edit source code, you MUST output this exact checklist in your response to the user, and execute each step BEFORE finishing your turn:
133
+ If the user explicitly requests a code-only change, comply but:
169
134
 
170
- - [ ] 1. Read Specification (ran \`yg build-context\`)
171
- - [ ] 2. Modify Source Code
172
- - [ ] 3. Sync Graph Artifacts (manually edit the node's artifact files IMMEDIATELY to match new code behavior)
173
- - [ ] 4. Baseline Hash (ran \`yg drift-sync\` ONLY AFTER updating the graph)
135
+ - Warn: "This creates drift. Run \`yg drift\` next session to reconcile."
136
+ - Do NOT run \`yg drift-sync\` \u2014 leave the drift visible.
137
+
138
+ ### Environment Check
174
139
 
175
- **Step 3: If Owner NOT FOUND (Uncovered Area)**
176
- STOP. Do not modify the code. First determine: **Is this greenfield or existing code?**
177
-
178
- * **If GREENFIELD (empty directory, new project):** Do NOT offer blackbox. Create proper nodes (reverse engineering or upfront design) before implementing.
179
- * **If PARTIALLY MAPPED (file is unmapped, but lives inside a mapped module):** Stop and ask the user if this file should be added to the existing node or if a new node is required.
180
- * **If EXISTING CODE (legacy, third-party):** Present the user with 3 options and wait:
181
- * **Option 1: Reverse Engineering:** Create/extend standard nodes to map the area fully before modifying.
182
- * **Option 2: Blackbox Coverage:** Create a \`blackbox: true\` node to establish ownership without deep semantic exploration.
183
- * **Option 3: Abort/Change Plan:** Do not touch the file.
184
-
185
- **Reverse engineering order:** When reverse-engineering an area, create graph elements in this order: (1) aspects, (2) flows, (3) knowledge elements, (4) model nodes. Never create model nodes before cross-cutting rules and shared wisdom exist \u2014 they depend on them.
186
-
187
- ---
140
+ Before preflight:
188
141
 
189
- ## 4. WORKFLOW: MODIFYING THE GRAPH & BLAST RADIUS (Graph-First)
142
+ - Verify \`yg\` CLI is available. If not found, inform user and stop.
143
+ - If \`yg preflight\` shows 0 nodes \u2192 enter BOOTSTRAP MODE (see Operations).
144
+ - If drift report shows >10 drifted nodes \u2192 report scope to user, ask which area to prioritize. Do not resolve all at once.`;
145
+ var OPERATIONS = `## OPERATIONS
190
146
 
191
- When adding features, changing architecture, or doing graph-first design:
147
+ ### Conversation Lifecycle
192
148
 
193
- 1. **Check Blast Radius:** Before modifying a node that others depend on, run \`yg impact --node <node_path> --simulate\`. Report the impact to the user.
194
- 2. **Read Config & Templates:**
195
- * Check \`.yggdrasil/config.yaml\` for allowed \`node_types\` and \`tags\`.
196
- * **CRITICAL:** ALWAYS read the schema in \`.yggdrasil/templates/\` for the element type (node.yaml, aspect.yaml, flow.yaml, knowledge.yaml) before creating or editing it.
197
- 3. **Validate & Fix:** Run \`yg validate\`. You must fix all E-codes (Errors).
198
- 4. **Token Economy & W-codes:**
199
- * W005/W006: Context package too large. Consider splitting the node.
200
- * W008: Stale semantic memory. Update knowledge artifacts.
149
+ \`\`\`
150
+ PREFLIGHT (every conversation, before any work):
151
+ - [ ] 1. yg preflight \u2192 read unified report
152
+ - [ ] 2. If journal entries: consolidate to graph, then yg journal-archive
153
+ - [ ] 3. If drift: resolve per Drift Resolution, then yg drift-sync per node
154
+ - [ ] 4. If validation errors: fix, re-run yg validate
155
+ Exception: read-only requests (explain, analyze) \u2014 skip preflight.
201
156
 
202
- **Graph Modification Checklist**
203
- Whenever you change the graph structure or semantics, you MUST output and execute this exact checklist:
157
+ UNDERSTANDING mapped code (questions, research, OR planning):
158
+ - [ ] 1. yg owner --file <path>
159
+ - [ ] 2. Owner found \u2192 yg build-context --node <path>. Use context package as primary source.
160
+ - [ ] 3. Owner not found \u2192 use file analysis, state it is not graph-backed.
161
+ Never use grep or raw file reads as primary understanding when graph coverage exists.
162
+ Raw reads supplement the context package \u2014 they do not replace it.
204
163
 
205
- - [ ] 1. Read schema from \`.yggdrasil/templates/\` (node.yaml, aspect.yaml, flow.yaml, or knowledge.yaml for the element type)
206
- - [ ] 2. Edit graph files (\`node.yaml\`, artifacts)
207
- - [ ] 3. Verify corresponding source files exist and their behavior matches updated artifacts
208
- - [ ] 4. Validate (ran \`yg validate\` \u2014 fix all Errors)
209
- - [ ] 5. Baseline Hash (ran \`yg drift-sync\` ONLY AFTER steps 2-3 are confirmed)
164
+ WRAP-UP (user signals "done", "wrap up", "that's enough"):
165
+ - [ ] 1. Consolidate journal if used \u2192 yg journal-archive
166
+ - [ ] 2. yg drift --drifted-only \u2192 resolve
167
+ - [ ] 3. yg validate \u2192 fix errors
168
+ - [ ] 4. Report: which nodes and files were changed
169
+ \`\`\`
210
170
 
211
- **Journaling (Iterative Mode Scope):**
212
- * **Default:** Write changes directly to graph files immediately. Do not defer.
213
- * **Opt-in:** ONLY if the user says "use iterative mode" or "use journal". Once activated, it remains active for the ENTIRE conversation until wrap-up. Use \`yg journal-add --note "..."\` to buffer intent.
171
+ ### Modify Source Code
214
172
 
215
- ---
173
+ You are not allowed to edit or create source code without establishing graph coverage first.
216
174
 
217
- ## 5. PATH CONVENTIONS (CRITICAL)
175
+ **Step 1** \u2014 Check coverage: \`yg owner --file <path>\`
218
176
 
219
- To avoid broken references (\`E004\`, \`E005\`), use correct relative paths:
220
- * **Node paths** (used in CLI, relations, flow nodes): Relative to \`.yggdrasil/model/\` (e.g., \`orders/order-service\`).
221
- * **File paths** (used in mapping, \`yg owner\`): Relative to the repository root (e.g., \`src/modules/orders/order.service.ts\`).
222
- * **Knowledge paths** (used in node explicit refs): Relative to \`.yggdrasil/knowledge/\` (e.g., \`decisions/001-event-sourcing\`).
177
+ **Step 2a** \u2014 Owner found: execute checklist:
223
178
 
224
- ---
179
+ - [ ] 1. Read specification: \`yg build-context --node <node_path>\`
180
+ - [ ] 2. Assess blast radius: \`yg impact --node <node_path>\` \u2014 review dependents, descendants, and co-aspect nodes before changing interfaces or shared behavior
181
+ - [ ] 3. Modify source code
182
+ - [ ] 4. Sync graph artifacts \u2014 edit artifact files to reflect the changes
183
+ - [ ] 5. Run \`yg validate\` \u2014 fix all errors (if unfixable after 3 attempts \u2192 stop, report to user)
184
+ - [ ] 6. Run \`yg drift-sync --node <node_path>\` \u2014 only after graph and code are both current
225
185
 
226
- ## 6. GRAPH STRUCTURE, CONFIG & TEMPLATES CHEAT SHEET
186
+ **Step 2b** \u2014 Owner not found: establish coverage first. Present options to the user:
227
187
 
228
- The graph lives entirely under \`.yggdrasil/\`. You NEVER guess structure. You MUST ALWAYS read the corresponding schema reference in \`.yggdrasil/templates/\` before creating or editing any graph file.
188
+ *Partially mapped* (file unmapped but inside a mapped module): ask whether to add to existing node or create new one.
229
189
 
230
- * **\`.yggdrasil/config.yaml\`**: Defines \`node_types\`, \`tags\`, \`artifacts\`, \`knowledge_categories\`.
231
- * **\`.yggdrasil/templates/\`**: Schemas for each graph layer \u2014 \`node.yaml\`, \`aspect.yaml\`, \`flow.yaml\`, \`knowledge.yaml\`.
232
- * **\`.yggdrasil/model/\`**: Node tree. Each node is a directory with \`node.yaml\` and artifact files.
233
- * **\`.yggdrasil/aspects/\`**: Cross-cutting rules. Directory contains \`aspect.yaml\` and \`.md\` content.
234
- * **\`.yggdrasil/flows/\`**: End-to-end processes. Directory contains \`flow.yaml\` and \`.md\` content.
235
- * **\`.yggdrasil/knowledge/\`**: Repo-wide wisdom. Directory contains \`knowledge.yaml\` and \`.md\` content.
190
+ *Existing code:*
236
191
 
237
- ---
192
+ - Option A \u2014 Full node: create node(s), map files, write artifacts from code analysis
193
+ - Option B \u2014 Blackbox: create a blackbox node at agreed granularity
194
+ - Option C \u2014 Abort
238
195
 
239
- ## 7. CONTEXT ASSEMBLY & KNOWLEDGE DECONSTRUCTION (HOW TO MAP FILES)
196
+ *Greenfield (new code):* Only Option A. Blackbox is forbidden for new code. Follow the graph-first workflow:
240
197
 
241
- Your ultimate goal when describing a file or node is **Context Reproducibility**. A future agent reading ONLY the output of \`yg build-context\` for this node must be able to perfectly reconstruct the source code's behavior, constraints, environment, and purpose.
198
+ 1. Create aspects first (cross-cutting requirements the new code must satisfy)
199
+ 2. Create flows if the code participates in a business process
200
+ 3. Create nodes with full artifacts \u2014 responsibility, constraints, decisions, interface, logic
201
+ 4. Review the context package (\`yg build-context\`) \u2014 it is now the behavioral specification
202
+ 5. Implement code that satisfies the specification
203
+ 6. The graph specifies WHAT and WHY; the code implements HOW (framework APIs, library choices)
242
204
 
243
- However, you must NOT dump all knowledge into a single file. Yggdrasil's context package is **multi-layered** and hierarchically assembled. When you map existing code or design new code, you must deconstruct the knowledge and place it at the correct abstraction layer so the engine can mechanically reassemble it.
205
+ After the user chooses, return to Step 1 and follow Step 2a.
244
206
 
245
- ### CRITICAL RULE: CAPTURE INTENT, BUT NEVER INVENT IT
246
- The graph is not just a structural map; it is the semantic meaning of the system. Code explains "what" and "how". The graph MUST explain "WHY".
207
+ ### Modify Graph
247
208
 
248
- 1. **ALWAYS Capture the User's "Why":** If the user explains the business reason, intent, or rationale behind a request (e.g., "We need to do X because Y"), you MUST permanently record this reasoning in the relevant graph artifacts (from \`config.artifacts\` that fit the content). Do not let the conversation context evaporate.
249
- 2. **NEVER Invent the "Why":** Artifacts that imply human judgment (e.g. local decisions, \`knowledge/invariants\`) must reflect ACTUAL human choices.
250
- 3. **NO Hallucinations:** You MUST NEVER infer or hallucinate a rationale, an architectural decision, or a business rule.
251
- 4. **Ask if Missing:** If the user requests a significant architectural or business logic change but does not provide the rationale, you MUST ask them "Why are we making this change?" before documenting the decision in the graph.
209
+ - [ ] 1. Read the relevant schema from \`schemas/\` before touching any YAML
210
+ - [ ] 2. Before changing an aspect or flow, check scope: \`yg impact --aspect <id>\` or \`yg impact --flow <name>\` \u2014 understand which nodes are affected before modifying shared rules or processes
211
+ - [ ] 3. Make changes
212
+ - [ ] 4. Run \`yg validate\` immediately \u2014 fix all errors
213
+ - [ ] 5. Verify affected source files are consistent \u2014 update if needed
214
+ - [ ] 6. Run \`yg drift-sync\` for affected nodes
252
215
 
253
- When mapping a file, execute this mental routing:
216
+ ### Reverse Engineering
254
217
 
255
- ### Layer 1: Unit Identity (Local Node Artifacts)
256
- * **What goes here:** Things exclusively true for this specific node.
257
- * **Routing:** **DO NOT ASSUME FILE NAMES.** You MUST read \`.yggdrasil/config.yaml\` (the \`artifacts\` section) to see the exact allowed filenames for the current project and their requirement conditions (e.g., \`required: always\` vs \`when: has_incoming_relations\`). Write local node knowledge ONLY into these configured files next to \`node.yaml\`.
258
- * For each artifact in \`config.artifacts\`, use its \`description\` to decide what content belongs there. Create optional artifacts (those with \`required: never\`) when the node has matching content. Extract from source; do not invent.
218
+ **Order:** aspects (cross-cutting patterns) \u2192 flows (business processes) \u2192 model nodes. Never create nodes before aspects and flows are understood.
259
219
 
260
- **Subagents:** When mapping a node, for each optional artifact in config, ask: "Does the source contain content matching this artifact's description?" If yes, create it. Do not invent \u2014 extract only what is implemented.
220
+ Per area checklist:
261
221
 
262
- ### Optional Artifacts \u2014 Explicit Consideration
222
+ - [ ] 1. \`yg owner --file <path>\` \u2014 confirm no coverage
223
+ - [ ] 2. Determine node granularity \u2014 propose to user if unclear
224
+ - [ ] 3. Create node directory, read \`schemas/node.yaml\`, create \`node.yaml\`
225
+ - [ ] 4. Analyze source \u2014 for each artifact type in \`config.artifacts\`: extract content, do not invent
226
+ - [ ] 5. Identify relations \u2014 add to \`node.yaml\`
227
+ - [ ] 6. Identify cross-cutting requirements \u2014 add matching aspects, create if needed
228
+ - [ ] 7. Identify business process participation \u2014 add to flow, ask user if process unclear
229
+ - [ ] 8. \`yg validate\` \u2014 fix errors
230
+ - [ ] 9. \`yg drift-sync --node <path>\`
263
231
 
264
- When creating or editing a graph node, or mapping source files to a node, after fulfilling required artifacts, read \`config.yaml\` and for each artifact with \`required: never\`, ask: "Does this node contain content that matches this artifact's description?" If yes, create it. Base decisions on source code analysis, not file names or structure.
232
+ **When to ask:**
265
233
 
266
- **Interpretation of \`required: never\`:** The artifact is optional for validation, not forbidden. Create it when the node has content that fits its description in config.
234
+ - Business process unclear: "This code appears to be part of a larger process. Can you describe what it means from a business perspective?"
235
+ - Constraint without rationale: "I see [constraint X]. Do you know why this exists? I want to record the reason, not just the rule."
236
+ - Unexplained architectural choice: "I see [approach X]. What was the reason for this choice?"
237
+ - Decision without alternatives: "You chose [X]. What alternatives did you consider, and why did you reject them?" Record the answer in \`decisions.md\`.
267
238
 
268
- **Interpretation of "don't be over-eager":** Do not invent content, do not document what is not in the code, do not create empty or trivial artifacts. It does NOT mean: avoid adding optional artifacts when they add value based on code analysis.
239
+ ### Bootstrap Mode
269
240
 
270
- **Post-node checklist:** After completing work on a node, for each optional artifact (from \`config.artifacts\` where \`required: never\`), check: does this node have content for it? If yes, create it. If uncertain, propose with brief justification rather than silently skipping.
241
+ Trigger: \`yg preflight\` shows 0 nodes, or no nodes cover the active work area.
271
242
 
272
- ### Layer 2: Surroundings (Relations & Flows)
273
- * **What goes here:** How this node interacts with others. You must not duplicate external interfaces locally.
274
- * **Routing:**
275
- * If it calls another module: Add an outgoing structural \`relation\` in \`node.yaml\`. (The engine will automatically fetch the target's structural-context artifacts: responsibility, interface, constraints, errors).
276
- * If it participates in an end-to-end process: Do not explain the whole process locally. Ensure the node is listed in \`.yggdrasil/flows/<flow_name>/flow.yaml\`. The engine will attach the flow knowledge automatically.
277
- * **Flows \u2014 writing flow content:** When creating or editing flow artifacts (e.g. \`description.md\` in \`flows/<name>/\`), write business-first: describe the process from user/business perspective. Technical details only as inserts when they clarify the flow. Not technical-first with business inserts.
243
+ - [ ] 1. Identify the active work area (files the user wants to modify)
244
+ - [ ] 2. Scan for cross-cutting patterns \u2192 create aspects
245
+ - [ ] 3. Ask user about business processes \u2192 create flows if applicable
246
+ - [ ] 4. Propose node structure for the area
247
+ - [ ] 5. Create node(s) with initial artifacts, map files
248
+ - [ ] 6. \`yg validate\`, \`yg drift-sync\`
249
+ - [ ] 7. Proceed with user's original request
278
250
 
279
- ### Layer 3: Domain Context (Hierarchy)
280
- * **What goes here:** Business rules shared by a family of nodes.
281
- * **Routing:** Do not repeat module-wide rules in every child node. Place the child node directory *inside* a parent Module Node directory. Write the shared rules in the parent's configured artifacts. The engine inherently passes parent context to children.
251
+ Constraint: Do NOT map the entire repository. Focus on the active area. Expand incrementally.
282
252
 
283
- ### Layer 4: Cross-Cutting Rules (Aspects)
284
- * **What goes here:** Horizontal requirements like logging, auth, rate-limiting, or specific frameworks.
285
- * **Routing:** Do NOT write generic rules like "This node must log all errors" in local artifacts. Instead, read \`config.yaml\` for available \`tags\`. Add the relevant tag (e.g., \`requires-audit\`) to \`node.yaml\`. The engine will automatically attach the aspect knowledge.
253
+ ### Drift Resolution
286
254
 
287
- ### Layer 5: Long-Term Memory (Knowledge Elements)
288
- * **What goes here:** Global architectural decisions, design patterns, and systemic invariants.
289
- * **Routing:** Read \`config.yaml\` (the \`knowledge_categories\` section) to know what categories exist.
290
- * If the file implements a standard pattern: Do not describe the pattern locally. Add a \`knowledge\` reference in \`node.yaml\` to the existing pattern.
291
- * If the file reveals an undocumented global invariant or decision: Ask the user to confirm it. If confirmed, create it under \`.yggdrasil/knowledge/<category>/\` so all future nodes inherit it.
255
+ Always ask the user before resolving drift. Never auto-resolve.
292
256
 
293
- **THE COMPLETENESS CHECK:**
294
- Before finishing a mapping, ask yourself: *"If I delete the source file and give another agent ONLY the output of \`yg build-context\`, can they recreate it perfectly based on the configured artifacts, AND will they understand EXACTLY WHY this code exists and why it was designed this way?"*
295
- - If no -> You missed a local constraint, a relation, or you failed to capture the user's provided rationale.
296
- - If yes, but the local files are bloated -> You failed to deconstruct knowledge into Tags, Aspects, Flows, and Hierarchy. Fix the routing.
257
+ - **Source drift** (source files changed) \u2192 update graph artifacts to match source, then \`yg drift-sync\`
258
+ - **Graph drift** (graph artifacts changed) \u2192 review affected source, update if needed, then \`yg drift-sync\`
259
+ - **Full drift** (both changed) \u2192 present both sides to user, ask which direction wins
260
+ - **Missing** \u2192 ask: re-materialize or remove mapping?
261
+ - **Unmaterialized** \u2192 ask user how to proceed
297
262
 
298
- ---
263
+ Threshold: >10 drifted nodes \u2192 ask user which area to prioritize. Do not resolve all at once.
299
264
 
300
- ## 8. CLI TOOLS REFERENCE (\`yg\`)
265
+ ### Error Recovery
301
266
 
302
- Always use these exact commands.
267
+ - **\`yg\` not found** \u2192 inform user: "yg CLI is not installed or not in PATH." Stop.
268
+ - **Unfixable validate errors** \u2192 if not resolved after 3 attempts, stop and report to user. Do not loop.
269
+ - **Budget exceeded** \u2192 if \`yg build-context\` exits with error (context package exceeds budget), warn user: "This node should be split." Do not proceed with implementation.
270
+ - **Corrupted \`.yggdrasil/\` files** \u2192 report to user. Do not attempt repair.
271
+ - **Incremental sync** \u2192 run \`yg drift-sync\` every 3-5 source files during multi-file tasks. Do not defer to end.`;
272
+ var KNOWLEDGE_BASE = `## KNOWLEDGE BASE
303
273
 
304
- * \`yg owner --file <file_path>\` -> Find owning node.
305
- * \`yg build-context --node <node_path>\` -> Assemble strict specification.
306
- * \`yg tree [--root <node_path>] [--depth N]\` -> Print graph structure.
307
- * \`yg deps --node <node_path> [--type structural|event|all]\` -> Show dependencies.
308
- * \`yg impact --node <node_path> --simulate\` -> Simulate blast radius.
309
- * \`yg status\` -> Graph health metrics.
310
- * \`yg validate [--scope <node_path>|all]\` -> Compile/check graph. Run after EVERY graph edit.
311
- * \`yg drift [--scope <node_path>|all]\` -> Check code vs graph baseline.
312
- * \`yg drift-sync --node <node_path>\` -> Save current file hash as new baseline. Run ONLY after ensuring graph artifacts match the code.
274
+ ### Graph Structure
313
275
 
314
- *(Iterative mode only)*
315
- * \`yg journal-read\`
316
- * \`yg journal-add --note "<content>" [--target <node_path>]\`
317
- * \`yg journal-archive\`
276
+ \`\`\`
277
+ .yggdrasil/
278
+ config.yaml \u2190 vocabulary, stack, node types, artifact rules, required aspects
279
+ model/ \u2190 what exists: nodes, hierarchy, relations, file mappings
280
+ aspects/ \u2190 what must: cross-cutting requirements with rationale and guidance
281
+ flows/ \u2190 why and in what process: business processes with node participation
282
+ schemas/ \u2190 YAML schemas \u2014 read before creating any graph element
283
+ .drift-state \u2190 generated by CLI; never edit manually
284
+ .journal.yaml \u2190 generated by CLI; never edit manually
285
+ \`\`\`
286
+
287
+ Key facts:
288
+
289
+ - **Hierarchy:** nodes nest in \`model/\`. Children inherit parent context. Do not repeat parent content in children.
290
+ - **Aspect id = directory path** under \`aspects/\`. Each aspect has \`aspect.yaml\` + content \`.md\` files. No automatic parent-child \u2014 use \`implies\` explicitly.
291
+ - **Flows = business processes.** A flow describes what happens in the world, not code sequences. Flow aspects propagate to all participants.
292
+
293
+ ### Context Assembly
294
+
295
+ Run \`yg build-context --node <path>\` to get the deterministic context package for a node. Trust the package \u2014 it assembles global config, hierarchy, own artifacts, aspects, and relational context. If the package is insufficient, enrich the graph. Do not bypass it with raw file exploration.
296
+
297
+ ### Information Routing
298
+
299
+ When you encounter information, route it to the correct location:
300
+
301
+ - **Specific to this node** \u2192 local node artifact (check \`config.yaml artifacts\` for available types)
302
+ - **Rule for many nodes** \u2192 aspect (\`aspects/<id>/\` with \`aspect.yaml\` + content \`.md\` files). If applies to ALL nodes of a type \u2192 \`node_types[*].required_aspects\` in \`config.yaml\`
303
+ - **Business process** \u2192 flow (\`flows/<name>/\` with \`flow.yaml\` + \`description.md\`). Ask user if process unclear.
304
+ - **Shared across a domain** \u2192 parent node artifact. Children receive it through hierarchy.
305
+ - **Technology stack or standard** \u2192 \`config.yaml\` under \`stack\` or \`standards\` (+ \`rationale\` field)
306
+ - **Decision (why + why NOT):** one node \u2192 \`decisions.md\` with format "Chose X over Y because Z"; category of nodes \u2192 aspect content files; tech choice \u2192 \`config.yaml\` rationale field. Always include rejected alternatives \u2014 they are the highest-value graph content.
307
+
308
+ ### Creating Aspects
309
+
310
+ - [ ] 1. Read \`schemas/aspect.yaml\`
311
+ - [ ] 2. Create \`aspects/<id>/\` directory
312
+ - [ ] 3. Write \`aspect.yaml\` \u2014 name, optional description, optional implies
313
+ - [ ] 4. Write content \`.md\` files: WHAT must be satisfied + WHY (user's words, do not invent)
314
+ - [ ] 5. \`yg validate\`
315
+
316
+ Test: "Does this requirement apply to more than one node?" Yes \u2192 aspect. No \u2192 local artifact.
317
+
318
+ **Aspect identification heuristic:** If the same pattern, constraint, or rule appears in 3+ places, it is a candidate aspect. Aspects fall into natural categories:
319
+
320
+ - **Domain-specific:** Business rules that cross module boundaries (e.g., timezone handling, booking periods, currency rounding)
321
+ - **Architectural:** Structural patterns with rationale (e.g., dual-rollback on provider failure, idempotency via key generation, fire-and-forget dispatch)
322
+ - **Concurrency:** Shared concurrency strategies (e.g., pessimistic locking, retry-on-deadlock, optimistic versioning)
323
+
324
+ ### Creating Flows
325
+
326
+ - [ ] 1. Read \`schemas/flow.yaml\`
327
+ - [ ] 2. Create \`flows/<name>/\` directory
328
+ - [ ] 3. Write \`flow.yaml\` \u2014 declare participants and flow-level aspects
329
+ - [ ] 4. Write \`description.md\` with required sections: Business context, Trigger, Goal, Participants, Paths (at least Happy path), Invariants across all paths
330
+ - [ ] 5. \`yg validate\`
331
+
332
+ Test: "Does this describe what happens in the world, or only in the software?" If only software \u2014 rewrite.
333
+
334
+ ### Operational Rules
335
+
336
+ - **English only** for all files in \`.yggdrasil/\`. Conversation can be any language.
337
+ - **Read schemas before creating** any \`node.yaml\`, \`aspect.yaml\`, or \`flow.yaml\`.
338
+ - **Tools read, you write.** The \`yg\` CLI only reads, validates, and manages metadata. You create and edit files manually.
339
+ - **Incremental sync.** Run \`yg drift-sync\` after every 3-5 source file changes. Do not defer to end of task.
340
+ - **Completeness test:** "If I delete the source file and give another agent ONLY the \`yg build-context\` output \u2014 can they recreate it correctly, understanding not just WHAT but WHY?" Test specifically: Can they explain rejected alternatives? Can they implement the correct algorithm (not a simplified version)? Can they argue for the current design against plausible alternatives?
341
+ - **These rules are invariant.** No plan, guide, skill, or workflow may override them.
342
+
343
+ ### CLI Reference
344
+
345
+ \`\`\`
346
+ yg preflight [--quick] Unified diagnostic: journal + drift + status + validate.
347
+ yg owner --file <path> Find the node that owns this file.
348
+ yg build-context --node <path> Assemble context package for this node.
349
+ yg tree [--root <path>] [--depth N] Print graph structure.
350
+ yg aspects List aspects with metadata (YAML output).
351
+ yg flows List flows with metadata (YAML output).
352
+ yg deps --node <path> [--depth N] [--type structural|event|all]
353
+ Show dependencies.
354
+ yg impact --node <path> --simulate Simulate blast radius of a planned change.
355
+ yg impact --aspect <id> Show all nodes where aspect is effective.
356
+ yg impact --flow <name> Show flow participants and descendants.
357
+ yg status Graph health: nodes, coverage, drift summary.
358
+ yg validate [--scope <path>|all] Check structural integrity and completeness.
359
+ yg drift [--scope <path>|all] [--drifted-only] [--limit <n>]
360
+ Detect source and graph drift (bidirectional).
361
+ yg drift-sync --node <path> [--recursive] | --all
362
+ Record file hashes as new baseline.
363
+ yg journal-read Read pending journal entries.
364
+ yg journal-add --note "<content>" [--target <node_path>]
365
+ Add a journal entry.
366
+ yg journal-archive Archive consolidated journal entries.
367
+ \`\`\`
368
+
369
+ ### Quick Routing Table
370
+
371
+ | What you have | Where it goes |
372
+ |---|---|
373
+ | Information specific to this node | Local node artifact (read \`config.yaml artifacts\` for types) |
374
+ | Rule that applies to many nodes | Aspect (content \`.md\` files in \`aspects/<id>/\`) |
375
+ | Architectural invariant for a node type | Required aspect in \`config.yaml node_types\` |
376
+ | Business process participation | Flow (\`flow.yaml participants\`) |
377
+ | Process-level requirement | Flow \`aspects\` + aspect directory |
378
+ | Context shared across a domain | Parent node artifact |
379
+ | Technology stack | \`config.yaml stack\` (+ \`rationale\` field) |
380
+ | Global coding standards | \`config.yaml standards\` |
318
381
  `;
382
+ var AGENT_RULES_CONTENT = [CORE_PROTOCOL, OPERATIONS, KNOWLEDGE_BASE].join("\n\n---\n\n");
319
383
 
320
384
  // src/templates/platform.ts
321
385
  var AGENT_RULES_IMPORT = "@.yggdrasil/agent-rules.md";
@@ -558,12 +622,13 @@ function escapeRegex(s) {
558
622
  }
559
623
 
560
624
  // src/cli/init.ts
561
- function getGraphTemplatesDir() {
625
+ function getGraphSchemasDir() {
562
626
  const currentDir = path2.dirname(fileURLToPath(import.meta.url));
563
627
  const packageRoot = path2.join(currentDir, "..");
564
- return path2.join(packageRoot, "graph-templates");
628
+ return path2.join(packageRoot, "graph-schemas");
565
629
  }
566
630
  var GITIGNORE_CONTENT = `.journal.yaml
631
+ .drift-state
567
632
  journals-archive/
568
633
  `;
569
634
  function registerInitCommand(program2) {
@@ -609,23 +674,20 @@ function registerInitCommand(program2) {
609
674
  await mkdir2(path2.join(yggRoot, "model"), { recursive: true });
610
675
  await mkdir2(path2.join(yggRoot, "aspects"), { recursive: true });
611
676
  await mkdir2(path2.join(yggRoot, "flows"), { recursive: true });
612
- await mkdir2(path2.join(yggRoot, "knowledge", "decisions"), { recursive: true });
613
- await mkdir2(path2.join(yggRoot, "knowledge", "patterns"), { recursive: true });
614
- await mkdir2(path2.join(yggRoot, "knowledge", "invariants"), { recursive: true });
615
- const templatesDir = path2.join(yggRoot, "templates");
616
- await mkdir2(templatesDir, { recursive: true });
617
- const graphTemplatesDir = getGraphTemplatesDir();
677
+ const schemasDir = path2.join(yggRoot, "schemas");
678
+ await mkdir2(schemasDir, { recursive: true });
679
+ const graphSchemasDir = getGraphSchemasDir();
618
680
  try {
619
- const entries = await readdir(graphTemplatesDir, { withFileTypes: true });
620
- const templateFiles = entries.filter((e) => e.isFile()).map((e) => e.name);
621
- for (const file of templateFiles) {
622
- const srcPath = path2.join(graphTemplatesDir, file);
681
+ const entries = await readdir(graphSchemasDir, { withFileTypes: true });
682
+ const schemaFiles = entries.filter((e) => e.isFile()).map((e) => e.name);
683
+ for (const file of schemaFiles) {
684
+ const srcPath = path2.join(graphSchemasDir, file);
623
685
  const content = await readFile2(srcPath, "utf-8");
624
- await writeFile2(path2.join(templatesDir, file), content, "utf-8");
686
+ await writeFile2(path2.join(schemasDir, file), content, "utf-8");
625
687
  }
626
688
  } catch (err) {
627
689
  process.stderr.write(
628
- `Warning: Could not copy graph templates from ${graphTemplatesDir}: ${err.message}
690
+ `Warning: Could not copy graph schemas from ${graphSchemasDir}: ${err.message}
629
691
  `
630
692
  );
631
693
  }
@@ -639,10 +701,7 @@ function registerInitCommand(program2) {
639
701
  process.stdout.write(" .yggdrasil/model/\n");
640
702
  process.stdout.write(" .yggdrasil/aspects/\n");
641
703
  process.stdout.write(" .yggdrasil/flows/\n");
642
- process.stdout.write(" .yggdrasil/knowledge/ (decisions, patterns, invariants)\n");
643
- process.stdout.write(
644
- " .yggdrasil/templates/ (node, aspect, flow, knowledge)\n"
645
- );
704
+ process.stdout.write(" .yggdrasil/schemas/ (config, node, aspect, flow)\n");
646
705
  process.stdout.write(` ${path2.relative(projectRoot, rulesPath)} (rules)
647
706
 
648
707
  `);
@@ -654,8 +713,8 @@ function registerInitCommand(program2) {
654
713
  }
655
714
 
656
715
  // src/core/graph-loader.ts
657
- import { readdir as readdir3 } from "fs/promises";
658
- import path6 from "path";
716
+ import { readdir as readdir3, readFile as readFile9 } from "fs/promises";
717
+ import path7 from "path";
659
718
 
660
719
  // src/io/config-parser.ts
661
720
  import { readFile as readFile3 } from "fs/promises";
@@ -663,27 +722,45 @@ import { parse as parseYaml } from "yaml";
663
722
  var DEFAULT_QUALITY = {
664
723
  min_artifact_length: 50,
665
724
  max_direct_relations: 10,
666
- context_budget: { warning: 1e4, error: 2e4 },
667
- knowledge_staleness_days: 90
725
+ context_budget: { warning: 1e4, error: 2e4 }
668
726
  };
669
727
  async function parseConfig(filePath) {
670
728
  const content = await readFile3(filePath, "utf-8");
671
729
  const raw = parseYaml(content);
730
+ if (!raw || typeof raw !== "object") {
731
+ throw new Error(`config.yaml: file is empty or not a valid YAML mapping`);
732
+ }
672
733
  if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
673
734
  throw new Error(`config.yaml: missing or invalid 'name' field`);
674
735
  }
675
- const nodeTypes = raw.node_types;
676
- if (!Array.isArray(nodeTypes) || nodeTypes.length === 0) {
736
+ const nodeTypesRaw = raw.node_types;
737
+ if (!Array.isArray(nodeTypesRaw) || nodeTypesRaw.length === 0) {
677
738
  throw new Error(`config.yaml: 'node_types' must be a non-empty array`);
678
739
  }
740
+ const nodeTypes = nodeTypesRaw.map((item) => {
741
+ if (typeof item === "string") {
742
+ return { name: item };
743
+ }
744
+ if (typeof item === "object" && item !== null && "name" in item && typeof item.name === "string") {
745
+ const obj = item;
746
+ const requiredAspects = Array.isArray(obj.required_aspects) ? obj.required_aspects.filter((t) => typeof t === "string") : Array.isArray(obj.required_tags) ? obj.required_tags.filter((t) => typeof t === "string") : void 0;
747
+ return {
748
+ name: obj.name,
749
+ required_aspects: requiredAspects && requiredAspects.length > 0 ? requiredAspects : void 0
750
+ };
751
+ }
752
+ throw new Error(
753
+ `config.yaml: node_types entry must be string or { name, required_aspects? }`
754
+ );
755
+ });
679
756
  const artifacts = raw.artifacts;
680
757
  if (!artifacts || typeof artifacts !== "object" || Array.isArray(artifacts) || Object.keys(artifacts).length === 0) {
681
758
  throw new Error(`config.yaml: 'artifacts' must be a non-empty object`);
682
759
  }
683
760
  const artifactsMap = {};
684
761
  for (const [key, val] of Object.entries(artifacts)) {
685
- if (key === "node") {
686
- throw new Error(`config.yaml: artifact name 'node' is reserved`);
762
+ if (key === "node.yaml") {
763
+ throw new Error(`config.yaml: artifact name 'node.yaml' is reserved`);
687
764
  }
688
765
  const a = val;
689
766
  const required = a.required;
@@ -692,10 +769,10 @@ async function parseConfig(filePath) {
692
769
  }
693
770
  if (typeof required === "object" && required && "when" in required) {
694
771
  const when = required.when;
695
- const validWhen = when === "has_incoming_relations" || when === "has_outgoing_relations" || typeof when === "string" && when.startsWith("has_tag:");
772
+ const validWhen = when === "has_incoming_relations" || when === "has_outgoing_relations" || typeof when === "string" && (when.startsWith("has_aspect:") || when.startsWith("has_tag:"));
696
773
  if (!validWhen) {
697
774
  throw new Error(
698
- `config.yaml: artifact '${key}' has invalid 'required.when': must be has_incoming_relations, has_outgoing_relations, or has_tag:<name>`
775
+ `config.yaml: artifact '${key}' has invalid 'required.when': must be has_incoming_relations, has_outgoing_relations, or has_aspect:<name>`
699
776
  );
700
777
  }
701
778
  }
@@ -705,24 +782,6 @@ async function parseConfig(filePath) {
705
782
  structural_context: a.structural_context ?? false
706
783
  };
707
784
  }
708
- if (!("knowledge_categories" in raw)) {
709
- throw new Error(
710
- `config.yaml: missing 'knowledge_categories' field (required, may be empty list)`
711
- );
712
- }
713
- const knowledgeCategoriesRaw = raw.knowledge_categories;
714
- if (!Array.isArray(knowledgeCategoriesRaw)) {
715
- throw new Error(`config.yaml: 'knowledge_categories' must be an array`);
716
- }
717
- const knowledgeCategories = knowledgeCategoriesRaw;
718
- const categoryNames = /* @__PURE__ */ new Set();
719
- for (const kc of knowledgeCategories) {
720
- if (!kc?.name || typeof kc.name !== "string") continue;
721
- if (categoryNames.has(kc.name)) {
722
- throw new Error(`config.yaml: duplicate knowledge category '${kc.name}'`);
723
- }
724
- categoryNames.add(kc.name);
725
- }
726
785
  const qualityRaw = raw.quality;
727
786
  const quality = qualityRaw ? {
728
787
  min_artifact_length: qualityRaw.min_artifact_length ?? DEFAULT_QUALITY.min_artifact_length,
@@ -730,30 +789,19 @@ async function parseConfig(filePath) {
730
789
  context_budget: {
731
790
  warning: qualityRaw.context_budget?.warning ?? DEFAULT_QUALITY.context_budget.warning,
732
791
  error: qualityRaw.context_budget?.error ?? DEFAULT_QUALITY.context_budget.error
733
- },
734
- knowledge_staleness_days: qualityRaw.knowledge_staleness_days ?? DEFAULT_QUALITY.knowledge_staleness_days
792
+ }
735
793
  } : DEFAULT_QUALITY;
736
794
  if (quality.context_budget.error < quality.context_budget.warning) {
737
795
  throw new Error(
738
796
  `config.yaml: quality.context_budget.error (${quality.context_budget.error}) must be >= warning (${quality.context_budget.warning})`
739
797
  );
740
798
  }
741
- if (!("tags" in raw)) {
742
- throw new Error(`config.yaml: missing 'tags' field (required, may be empty list)`);
743
- }
744
- const tags = raw.tags;
745
- if (!Array.isArray(tags)) {
746
- throw new Error(`config.yaml: 'tags' must be an array`);
747
- }
748
- const tagsList = tags.filter((t) => typeof t === "string");
749
799
  return {
750
800
  name: raw.name.trim(),
751
801
  stack: raw.stack ?? {},
752
802
  standards: typeof raw.standards === "string" ? raw.standards : "",
753
- tags: tagsList,
754
803
  node_types: nodeTypes,
755
804
  artifacts: artifactsMap,
756
- knowledge_categories: knowledgeCategories.filter((kc) => kc?.name),
757
805
  quality
758
806
  };
759
807
  }
@@ -775,6 +823,9 @@ function isValidRelationType(t) {
775
823
  async function parseNodeYaml(filePath) {
776
824
  const content = await readFile4(filePath, "utf-8");
777
825
  const raw = parseYaml2(content);
826
+ if (!raw || typeof raw !== "object") {
827
+ throw new Error(`node.yaml at ${filePath}: file is empty or not a valid YAML mapping`);
828
+ }
778
829
  if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
779
830
  throw new Error(`node.yaml at ${filePath}: missing or empty 'name'`);
780
831
  }
@@ -786,10 +837,9 @@ async function parseNodeYaml(filePath) {
786
837
  return {
787
838
  name: raw.name.trim(),
788
839
  type: raw.type.trim(),
789
- tags: parseStringArray(raw.tags),
840
+ aspects: parseStringArray(raw.aspects) ?? parseStringArray(raw.tags),
790
841
  blackbox: raw.blackbox ?? false,
791
842
  relations: relations.length > 0 ? relations : void 0,
792
- knowledge: parseStringArray(raw.knowledge),
793
843
  mapping
794
844
  };
795
845
  }
@@ -889,29 +939,47 @@ async function readArtifacts(dirPath, excludeFiles = ["node.yaml"], includeFiles
889
939
  }
890
940
 
891
941
  // src/io/aspect-parser.ts
892
- async function parseAspect(aspectDir, aspectYamlPath) {
942
+ async function parseAspect(aspectDir, aspectYamlPath, id) {
943
+ const idTrimmed = id?.trim() ?? "";
944
+ if (!idTrimmed) {
945
+ throw new Error(`Aspect id must be non-empty (relative path in aspects/)`);
946
+ }
893
947
  const content = await readFile6(aspectYamlPath, "utf-8");
894
948
  const raw = parseYaml3(content);
949
+ if (!raw || typeof raw !== "object") {
950
+ throw new Error(`Aspect file ${aspectYamlPath}: file is empty or not a valid YAML mapping`);
951
+ }
895
952
  if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
896
953
  throw new Error(`Aspect file ${aspectYamlPath}: missing or empty 'name'`);
897
954
  }
898
- if (!raw.tag || typeof raw.tag !== "string" || raw.tag.trim() === "") {
899
- throw new Error(`Aspect file ${aspectYamlPath}: missing or empty 'tag'`);
900
- }
955
+ const description = typeof raw.description === "string" ? raw.description.trim() : void 0;
901
956
  const artifacts = await readArtifacts(aspectDir, ["aspect.yaml"]);
957
+ let implies;
958
+ if (raw.implies !== void 0) {
959
+ if (!Array.isArray(raw.implies)) {
960
+ throw new Error(`Aspect file ${aspectYamlPath}: 'implies' must be an array of strings`);
961
+ }
962
+ implies = raw.implies.filter((t) => typeof t === "string");
963
+ }
902
964
  return {
903
965
  name: raw.name.trim(),
904
- tag: raw.tag.trim(),
966
+ id: idTrimmed,
967
+ description,
968
+ implies,
905
969
  artifacts
906
970
  };
907
971
  }
908
972
 
909
973
  // src/io/flow-parser.ts
910
974
  import { readFile as readFile7 } from "fs/promises";
975
+ import path4 from "path";
911
976
  import { parse as parseYaml4 } from "yaml";
912
977
  async function parseFlow(flowDir, flowYamlPath) {
913
978
  const content = await readFile7(flowYamlPath, "utf-8");
914
979
  const raw = parseYaml4(content);
980
+ if (!raw || typeof raw !== "object") {
981
+ throw new Error(`flow.yaml at ${flowYamlPath}: file is empty or not a valid YAML mapping`);
982
+ }
915
983
  if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
916
984
  throw new Error(`flow.yaml at ${flowYamlPath}: missing or empty 'name'`);
917
985
  }
@@ -923,79 +991,44 @@ async function parseFlow(flowDir, flowYamlPath) {
923
991
  if (nodePaths.length === 0) {
924
992
  throw new Error(`flow.yaml at ${flowYamlPath}: 'nodes' must contain string node paths`);
925
993
  }
926
- const knowledge = Array.isArray(raw.knowledge) ? raw.knowledge.filter((k) => typeof k === "string") : void 0;
994
+ let aspects;
995
+ if (raw.aspects !== void 0) {
996
+ if (!Array.isArray(raw.aspects)) {
997
+ throw new Error(`flow.yaml at ${flowYamlPath}: 'aspects' must be an array of strings`);
998
+ }
999
+ const aspectTags = raw.aspects.filter((a) => typeof a === "string");
1000
+ aspects = aspectTags.length > 0 ? aspectTags : [];
1001
+ }
927
1002
  const artifacts = await readArtifacts(flowDir, ["flow.yaml"]);
928
1003
  return {
1004
+ path: path4.basename(flowDir),
929
1005
  name: raw.name.trim(),
930
1006
  nodes: nodePaths,
931
- knowledge,
1007
+ ...aspects !== void 0 && { aspects },
932
1008
  artifacts
933
1009
  };
934
1010
  }
935
1011
 
936
- // src/io/knowledge-parser.ts
1012
+ // src/io/schema-parser.ts
937
1013
  import { readFile as readFile8 } from "fs/promises";
1014
+ import path5 from "path";
938
1015
  import { parse as parseYaml5 } from "yaml";
939
- async function parseKnowledge(knowledgeDir, knowledgeYamlPath, category, relativePath) {
940
- const content = await readFile8(knowledgeYamlPath, "utf-8");
941
- const raw = parseYaml5(content);
942
- if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
943
- throw new Error(`knowledge.yaml at ${knowledgeYamlPath}: missing or empty 'name'`);
944
- }
945
- const scope = parseScope(raw.scope, knowledgeYamlPath);
946
- const artifacts = await readArtifacts(knowledgeDir, ["knowledge.yaml"]);
947
- return {
948
- name: raw.name.trim(),
949
- scope,
950
- category,
951
- path: relativePath,
952
- artifacts
953
- };
954
- }
955
- function parseScope(raw, filePath) {
956
- if (raw === "global") {
957
- return "global";
958
- }
959
- if (raw && typeof raw === "object") {
960
- const obj = raw;
961
- if (Array.isArray(obj.tags)) {
962
- const tags = obj.tags.filter((t) => typeof t === "string");
963
- if (tags.length === 0) {
964
- throw new Error(`knowledge.yaml at ${filePath}: scope.tags must be a non-empty array`);
965
- }
966
- return { tags };
967
- }
968
- if (Array.isArray(obj.nodes)) {
969
- const nodes = obj.nodes.filter((n) => typeof n === "string");
970
- if (nodes.length === 0) {
971
- throw new Error(`knowledge.yaml at ${filePath}: scope.nodes must be a non-empty array`);
972
- }
973
- return { nodes };
974
- }
975
- }
976
- throw new Error(`knowledge.yaml at ${filePath}: invalid 'scope' value`);
977
- }
978
-
979
- // src/io/template-parser.ts
980
- import { readFile as readFile9 } from "fs/promises";
981
- import path4 from "path";
982
- import { parse as parseYaml6 } from "yaml";
983
1016
  async function parseSchema(filePath) {
984
- const content = await readFile9(filePath, "utf-8");
985
- parseYaml6(content);
986
- const schemaType = path4.basename(filePath, path4.extname(filePath));
1017
+ const content = await readFile8(filePath, "utf-8");
1018
+ parseYaml5(content);
1019
+ const schemaType = path5.basename(filePath, path5.extname(filePath));
987
1020
  return { schemaType };
988
1021
  }
989
1022
 
990
1023
  // src/utils/paths.ts
991
- import path5 from "path";
1024
+ import path6 from "path";
992
1025
  import { fileURLToPath as fileURLToPath2 } from "url";
993
1026
  import { stat as stat2 } from "fs/promises";
994
1027
  async function findYggRoot(projectRoot) {
995
- let current = path5.resolve(projectRoot);
996
- const root = path5.parse(current).root;
1028
+ let current = path6.resolve(projectRoot);
1029
+ const root = path6.parse(current).root;
997
1030
  while (true) {
998
- const yggPath = path5.join(current, ".yggdrasil");
1031
+ const yggPath = path6.join(current, ".yggdrasil");
999
1032
  try {
1000
1033
  const st = await stat2(yggPath);
1001
1034
  if (!st.isDirectory()) {
@@ -1009,7 +1042,7 @@ async function findYggRoot(projectRoot) {
1009
1042
  if (current === root) {
1010
1043
  throw new Error(`No .yggdrasil/ directory found. Run 'yg init' first.`, { cause: err });
1011
1044
  }
1012
- current = path5.dirname(current);
1045
+ current = path6.dirname(current);
1013
1046
  continue;
1014
1047
  }
1015
1048
  throw err;
@@ -1025,41 +1058,42 @@ function normalizeProjectRelativePath(projectRoot, rawPath) {
1025
1058
  if (normalizedInput.length === 0) {
1026
1059
  throw new Error("Path cannot be empty");
1027
1060
  }
1028
- const absolute = path5.resolve(projectRoot, normalizedInput);
1029
- const relative = path5.relative(projectRoot, absolute);
1030
- const isOutside = relative.startsWith("..") || path5.isAbsolute(relative);
1061
+ const absolute = path6.resolve(projectRoot, normalizedInput);
1062
+ const relative = path6.relative(projectRoot, absolute);
1063
+ const isOutside = relative.startsWith("..") || path6.isAbsolute(relative);
1031
1064
  if (isOutside) {
1032
1065
  throw new Error(`Path is outside project root: ${rawPath}`);
1033
1066
  }
1034
- return relative.split(path5.sep).join("/");
1067
+ return relative.split(path6.sep).join("/");
1068
+ }
1069
+ function projectRootFromGraph(yggRootPath) {
1070
+ return path6.dirname(yggRootPath);
1035
1071
  }
1036
1072
 
1037
1073
  // src/core/graph-loader.ts
1038
1074
  function toModelPath(absolutePath, modelDir) {
1039
- return path6.relative(modelDir, absolutePath).split(path6.sep).join("/");
1075
+ return path7.relative(modelDir, absolutePath).split(path7.sep).join("/");
1040
1076
  }
1041
1077
  var FALLBACK_CONFIG = {
1042
1078
  name: "",
1043
1079
  stack: {},
1044
1080
  standards: "",
1045
- tags: [],
1046
1081
  node_types: [],
1047
- artifacts: {},
1048
- knowledge_categories: []
1082
+ artifacts: {}
1049
1083
  };
1050
1084
  async function loadGraph(projectRoot, options = {}) {
1051
1085
  const yggRoot = await findYggRoot(projectRoot);
1052
1086
  let configError;
1053
1087
  let config = FALLBACK_CONFIG;
1054
1088
  try {
1055
- config = await parseConfig(path6.join(yggRoot, "config.yaml"));
1089
+ config = await parseConfig(path7.join(yggRoot, "config.yaml"));
1056
1090
  } catch (error) {
1057
1091
  if (!options.tolerateInvalidConfig) {
1058
1092
  throw error;
1059
1093
  }
1060
1094
  configError = error.message;
1061
1095
  }
1062
- const modelDir = path6.join(yggRoot, "model");
1096
+ const modelDir = path7.join(yggRoot, "model");
1063
1097
  const nodes = /* @__PURE__ */ new Map();
1064
1098
  const nodeParseErrors = [];
1065
1099
  const artifactFilenames = Object.keys(config.artifacts ?? {});
@@ -1073,13 +1107,9 @@ async function loadGraph(projectRoot, options = {}) {
1073
1107
  }
1074
1108
  throw err;
1075
1109
  }
1076
- const aspects = await loadAspects(path6.join(yggRoot, "aspects"));
1077
- const flows = await loadFlows(path6.join(yggRoot, "flows"));
1078
- const knowledge = await loadKnowledge(
1079
- path6.join(yggRoot, "knowledge"),
1080
- config.knowledge_categories
1081
- );
1082
- const schemas = await loadSchemas(path6.join(yggRoot, "templates"));
1110
+ const aspects = await loadAspects(path7.join(yggRoot, "aspects"));
1111
+ const flows = await loadFlows(path7.join(yggRoot, "flows"));
1112
+ const schemas = await loadSchemas(path7.join(yggRoot, "schemas"));
1083
1113
  return {
1084
1114
  config,
1085
1115
  configError,
@@ -1087,7 +1117,6 @@ async function loadGraph(projectRoot, options = {}) {
1087
1117
  nodes,
1088
1118
  aspects,
1089
1119
  flows,
1090
- knowledge,
1091
1120
  schemas,
1092
1121
  rootPath: yggRoot
1093
1122
  };
@@ -1100,9 +1129,12 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
1100
1129
  }
1101
1130
  if (hasNodeYaml) {
1102
1131
  const graphPath = toModelPath(dirPath, modelDir);
1132
+ const nodeYamlPath = path7.join(dirPath, "node.yaml");
1103
1133
  let meta;
1134
+ let nodeYamlRaw;
1104
1135
  try {
1105
- meta = await parseNodeYaml(path6.join(dirPath, "node.yaml"));
1136
+ nodeYamlRaw = await readFile9(nodeYamlPath, "utf-8");
1137
+ meta = await parseNodeYaml(nodeYamlPath);
1106
1138
  } catch (err) {
1107
1139
  nodeParseErrors.push({
1108
1140
  nodePath: graphPath,
@@ -1114,6 +1146,7 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
1114
1146
  const node = {
1115
1147
  path: graphPath,
1116
1148
  meta,
1149
+ nodeYamlRaw,
1117
1150
  artifacts,
1118
1151
  children: [],
1119
1152
  parent
@@ -1126,7 +1159,7 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
1126
1159
  if (!entry.isDirectory()) continue;
1127
1160
  if (entry.name.startsWith(".")) continue;
1128
1161
  await scanModelDirectory(
1129
- path6.join(dirPath, entry.name),
1162
+ path7.join(dirPath, entry.name),
1130
1163
  modelDir,
1131
1164
  node,
1132
1165
  nodes,
@@ -1139,7 +1172,7 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
1139
1172
  if (!entry.isDirectory()) continue;
1140
1173
  if (entry.name.startsWith(".")) continue;
1141
1174
  await scanModelDirectory(
1142
- path6.join(dirPath, entry.name),
1175
+ path7.join(dirPath, entry.name),
1143
1176
  modelDir,
1144
1177
  null,
1145
1178
  nodes,
@@ -1151,27 +1184,36 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
1151
1184
  }
1152
1185
  async function loadAspects(aspectsDir) {
1153
1186
  try {
1154
- const entries = await readdir3(aspectsDir, { withFileTypes: true });
1155
1187
  const aspects = [];
1156
- for (const entry of entries) {
1157
- if (!entry.isDirectory()) continue;
1158
- const aspectYamlPath = path6.join(aspectsDir, entry.name, "aspect.yaml");
1159
- const aspect = await parseAspect(path6.join(aspectsDir, entry.name), aspectYamlPath);
1160
- aspects.push(aspect);
1161
- }
1188
+ await scanAspectsDirectory(aspectsDir, aspectsDir, aspects);
1162
1189
  return aspects;
1163
1190
  } catch {
1164
1191
  return [];
1165
1192
  }
1166
1193
  }
1194
+ async function scanAspectsDirectory(dirPath, aspectsRoot, aspects) {
1195
+ const entries = await readdir3(dirPath, { withFileTypes: true });
1196
+ const hasAspectYaml = entries.some((e) => e.isFile() && e.name === "aspect.yaml");
1197
+ if (hasAspectYaml) {
1198
+ const id = path7.relative(aspectsRoot, dirPath).split(path7.sep).join("/");
1199
+ const aspectYamlPath = path7.join(dirPath, "aspect.yaml");
1200
+ const aspect = await parseAspect(dirPath, aspectYamlPath, id);
1201
+ aspects.push(aspect);
1202
+ }
1203
+ for (const entry of entries) {
1204
+ if (!entry.isDirectory()) continue;
1205
+ if (entry.name.startsWith(".")) continue;
1206
+ await scanAspectsDirectory(path7.join(dirPath, entry.name), aspectsRoot, aspects);
1207
+ }
1208
+ }
1167
1209
  async function loadFlows(flowsDir) {
1168
1210
  try {
1169
1211
  const entries = await readdir3(flowsDir, { withFileTypes: true });
1170
1212
  const flows = [];
1171
1213
  for (const entry of entries) {
1172
1214
  if (!entry.isDirectory()) continue;
1173
- const flowYamlPath = path6.join(flowsDir, entry.name, "flow.yaml");
1174
- const flow = await parseFlow(path6.join(flowsDir, entry.name), flowYamlPath);
1215
+ const flowYamlPath = path7.join(flowsDir, entry.name, "flow.yaml");
1216
+ const flow = await parseFlow(path7.join(flowsDir, entry.name), flowYamlPath);
1175
1217
  flows.push(flow);
1176
1218
  }
1177
1219
  return flows;
@@ -1179,37 +1221,14 @@ async function loadFlows(flowsDir) {
1179
1221
  return [];
1180
1222
  }
1181
1223
  }
1182
- async function loadKnowledge(knowledgeDir, categories) {
1183
- const items = [];
1184
- const categorySet = new Set(categories.map((c) => c.name));
1185
- try {
1186
- const catEntries = await readdir3(knowledgeDir, { withFileTypes: true });
1187
- for (const catEntry of catEntries) {
1188
- if (!catEntry.isDirectory()) continue;
1189
- if (!categorySet.has(catEntry.name)) continue;
1190
- const catPath = path6.join(knowledgeDir, catEntry.name);
1191
- const itemEntries = await readdir3(catPath, { withFileTypes: true });
1192
- for (const itemEntry of itemEntries) {
1193
- if (!itemEntry.isDirectory()) continue;
1194
- const itemDir = path6.join(catPath, itemEntry.name);
1195
- const knowledgeYamlPath = path6.join(itemDir, "knowledge.yaml");
1196
- const relativePath = `${catEntry.name}/${itemEntry.name}`;
1197
- const item = await parseKnowledge(itemDir, knowledgeYamlPath, catEntry.name, relativePath);
1198
- items.push(item);
1199
- }
1200
- }
1201
- } catch {
1202
- }
1203
- return items;
1204
- }
1205
- async function loadSchemas(templatesDir) {
1224
+ async function loadSchemas(schemasDir) {
1206
1225
  try {
1207
- const entries = await readdir3(templatesDir, { withFileTypes: true });
1226
+ const entries = await readdir3(schemasDir, { withFileTypes: true });
1208
1227
  const schemas = [];
1209
1228
  for (const entry of entries) {
1210
1229
  if (!entry.isFile()) continue;
1211
1230
  if (!entry.name.endsWith(".yaml") && !entry.name.endsWith(".yml")) continue;
1212
- const s = await parseSchema(path6.join(templatesDir, entry.name));
1231
+ const s = await parseSchema(path7.join(schemasDir, entry.name));
1213
1232
  schemas.push(s);
1214
1233
  }
1215
1234
  return schemas;
@@ -1220,7 +1239,7 @@ async function loadSchemas(templatesDir) {
1220
1239
 
1221
1240
  // src/core/context-builder.ts
1222
1241
  import { readFile as readFile10 } from "fs/promises";
1223
- import path7 from "path";
1242
+ import path8 from "path";
1224
1243
 
1225
1244
  // src/utils/tokens.ts
1226
1245
  function estimateTokens(text) {
@@ -1235,47 +1254,42 @@ async function buildContext(graph, nodePath) {
1235
1254
  if (!node) {
1236
1255
  throw new Error(`Node not found: ${nodePath}`);
1237
1256
  }
1238
- const nodeTags = new Set(node.meta.tags ?? []);
1239
- const seenKnowledge = /* @__PURE__ */ new Set();
1240
1257
  const layers = [];
1241
1258
  layers.push(buildGlobalLayer(graph.config));
1242
- for (const k of collectKnowledgeItems(graph, nodePath, nodeTags, seenKnowledge)) {
1243
- layers.push(buildKnowledgeLayer(k));
1244
- }
1245
1259
  const ancestors = collectAncestors(node);
1246
1260
  for (const ancestor of ancestors) {
1247
- layers.push(buildHierarchyLayer(ancestor, graph.config));
1261
+ layers.push(buildHierarchyLayer(ancestor, graph.config, graph));
1248
1262
  }
1249
- layers.push(await buildOwnLayer(node, graph.config, graph.rootPath));
1263
+ layers.push(await buildOwnLayer(node, graph.config, graph.rootPath, graph));
1264
+ const ancestorPaths = new Set(ancestors.map((a) => a.path));
1250
1265
  for (const relation of node.meta.relations ?? []) {
1251
1266
  const target = graph.nodes.get(relation.target);
1252
1267
  if (!target) {
1253
1268
  throw new Error(`Broken relation: ${nodePath} -> ${relation.target} (target not found)`);
1254
1269
  }
1270
+ if (ancestorPaths.has(relation.target)) continue;
1255
1271
  if (STRUCTURAL_RELATION_TYPES.has(relation.type)) {
1256
1272
  layers.push(buildStructuralRelationLayer(target, relation, graph.config));
1257
1273
  } else if (EVENT_RELATION_TYPES.has(relation.type)) {
1258
1274
  layers.push(buildEventRelationLayer(target, relation));
1259
1275
  }
1260
1276
  }
1261
- for (const tag of nodeTags) {
1262
- for (const aspect of graph.aspects) {
1263
- if (aspect.tag === tag) {
1264
- layers.push(buildAspectLayer(aspect));
1265
- }
1266
- }
1267
- }
1268
1277
  for (const flow of collectParticipatingFlows(graph, node)) {
1269
- layers.push(buildFlowLayer(flow));
1270
- for (const kPath of flow.knowledge ?? []) {
1271
- const norm = kPath.replace(/\/$/, "");
1272
- const k = graph.knowledge.find((item) => item.path === norm || item.path === kPath);
1273
- if (k && !seenKnowledge.has(k.path)) {
1274
- seenKnowledge.add(k.path);
1275
- layers.push(buildKnowledgeLayer(k, true));
1278
+ layers.push(buildFlowLayer(flow, graph));
1279
+ }
1280
+ const allAspectIds = /* @__PURE__ */ new Set();
1281
+ for (const l of layers) {
1282
+ const aspects = l.attrs?.aspects;
1283
+ if (aspects) {
1284
+ for (const id of aspects.split(",").map((t) => t.trim()).filter(Boolean)) {
1285
+ allAspectIds.add(id);
1276
1286
  }
1277
1287
  }
1278
1288
  }
1289
+ const aspectsToInclude = resolveAspects(allAspectIds, graph.aspects);
1290
+ for (const aspect of aspectsToInclude) {
1291
+ layers.push(buildAspectLayer(aspect));
1292
+ }
1279
1293
  const fullText = layers.map((l) => l.content).join("\n\n");
1280
1294
  const tokenCount = estimateTokens(fullText);
1281
1295
  const mapping = normalizeMappingPaths(node.meta.mapping);
@@ -1289,47 +1303,46 @@ async function buildContext(graph, nodePath) {
1289
1303
  tokenCount
1290
1304
  };
1291
1305
  }
1292
- function collectKnowledgeItems(graph, nodePath, nodeTags, seenKnowledge) {
1293
- const result = [];
1294
- for (const k of graph.knowledge) {
1295
- if (k.scope === "global" && !seenKnowledge.has(k.path)) {
1296
- seenKnowledge.add(k.path);
1297
- result.push(k);
1298
- }
1306
+ function collectParticipatingFlows(graph, node) {
1307
+ const paths = /* @__PURE__ */ new Set([node.path, ...collectAncestors(node).map((a) => a.path)]);
1308
+ return graph.flows.filter((f) => f.nodes.some((n) => paths.has(n)));
1309
+ }
1310
+ function expandAspects(aspectIds, aspects) {
1311
+ const idToAspect = /* @__PURE__ */ new Map();
1312
+ for (const a of aspects) {
1313
+ idToAspect.set(a.id, a);
1299
1314
  }
1300
- for (const k of graph.knowledge) {
1301
- if (typeof k.scope === "object" && "tags" in k.scope) {
1302
- const overlap = k.scope.tags.some((t) => nodeTags.has(t));
1303
- if (overlap && !seenKnowledge.has(k.path)) {
1304
- seenKnowledge.add(k.path);
1305
- result.push(k);
1306
- }
1315
+ const result = [];
1316
+ const visited = /* @__PURE__ */ new Set();
1317
+ const stack = /* @__PURE__ */ new Set();
1318
+ function collect(id) {
1319
+ if (stack.has(id)) {
1320
+ throw new Error(`Aspect implies cycle detected involving aspect '${id}'`);
1307
1321
  }
1308
- }
1309
- for (const k of graph.knowledge) {
1310
- if (typeof k.scope === "object" && "nodes" in k.scope) {
1311
- if (k.scope.nodes.includes(nodePath) && !seenKnowledge.has(k.path)) {
1312
- seenKnowledge.add(k.path);
1313
- result.push(k);
1322
+ if (visited.has(id)) return;
1323
+ stack.add(id);
1324
+ visited.add(id);
1325
+ result.push(id);
1326
+ const aspect = idToAspect.get(id);
1327
+ if (aspect) {
1328
+ for (const implied of aspect.implies ?? []) {
1329
+ collect(implied);
1314
1330
  }
1315
1331
  }
1332
+ stack.delete(id);
1316
1333
  }
1317
- const node = graph.nodes.get(nodePath);
1318
- if (node?.meta.knowledge) {
1319
- for (const kPath of node.meta.knowledge) {
1320
- const norm = kPath.replace(/\/$/, "");
1321
- const k = graph.knowledge.find((item) => item.path === norm || item.path === kPath);
1322
- if (k && !seenKnowledge.has(k.path)) {
1323
- seenKnowledge.add(k.path);
1324
- result.push(k);
1325
- }
1326
- }
1334
+ for (const id of aspectIds) {
1335
+ collect(id);
1327
1336
  }
1328
1337
  return result;
1329
1338
  }
1330
- function collectParticipatingFlows(graph, node) {
1331
- const paths = /* @__PURE__ */ new Set([node.path, ...collectAncestors(node).map((a) => a.path)]);
1332
- return graph.flows.filter((f) => f.nodes.some((n) => paths.has(n)));
1339
+ function resolveAspects(aspectIds, aspects) {
1340
+ const idToAspect = /* @__PURE__ */ new Map();
1341
+ for (const a of aspects) {
1342
+ idToAspect.set(a.id, a);
1343
+ }
1344
+ const expandedIds = expandAspects([...aspectIds], aspects);
1345
+ return expandedIds.map((id) => idToAspect.get(id)).filter((a) => a !== void 0);
1333
1346
  }
1334
1347
  function buildGlobalLayer(config) {
1335
1348
  let content = `**Project:** ${config.name}
@@ -1347,41 +1360,39 @@ ${config.standards || "(none)"}
1347
1360
  `;
1348
1361
  return { type: "global", label: "Global Context", content };
1349
1362
  }
1350
- function buildKnowledgeLayer(k, fromFlow) {
1351
- const categoryLabel = k.category.charAt(0).toUpperCase() + k.category.slice(1);
1352
- const content = k.artifacts.map((a) => `### ${a.filename}
1353
- ${a.content}`).join("\n\n");
1354
- const label = fromFlow ? `Long-term Memory (from flow): ${k.name}` : `${categoryLabel}: ${k.name}`;
1355
- return {
1356
- type: "knowledge",
1357
- label,
1358
- content
1359
- };
1360
- }
1361
1363
  function filterArtifactsByConfig(artifacts, config) {
1362
1364
  const allowed = new Set(Object.keys(config.artifacts ?? {}));
1363
1365
  return artifacts.filter((a) => allowed.has(a.filename));
1364
1366
  }
1365
- function buildHierarchyLayer(ancestor, config) {
1367
+ function buildHierarchyLayer(ancestor, config, graph) {
1366
1368
  const filtered = filterArtifactsByConfig(ancestor.artifacts, config);
1367
1369
  const content = filtered.map((a) => `### ${a.filename}
1368
1370
  ${a.content}`).join("\n\n");
1371
+ const nodeAspects = ancestor.meta.aspects ?? [];
1372
+ const expanded = expandAspects(nodeAspects, graph.aspects);
1373
+ const attrs = expanded.length > 0 ? { aspects: expanded.join(",") } : void 0;
1369
1374
  return {
1370
1375
  type: "hierarchy",
1371
1376
  label: `Module Context (${ancestor.path}/)`,
1372
- content
1377
+ content,
1378
+ attrs
1373
1379
  };
1374
1380
  }
1375
- async function buildOwnLayer(node, config, graphRootPath) {
1381
+ async function buildOwnLayer(node, config, graphRootPath, graph) {
1376
1382
  const parts = [];
1377
- const nodeYamlPath = path7.join(graphRootPath, "model", node.path, "node.yaml");
1378
- try {
1379
- const nodeYamlContent = await readFile10(nodeYamlPath, "utf-8");
1383
+ if (node.nodeYamlRaw) {
1380
1384
  parts.push(`### node.yaml
1385
+ ${node.nodeYamlRaw.trim()}`);
1386
+ } else {
1387
+ const nodeYamlPath = path8.join(graphRootPath, "model", node.path, "node.yaml");
1388
+ try {
1389
+ const nodeYamlContent = await readFile10(nodeYamlPath, "utf-8");
1390
+ parts.push(`### node.yaml
1381
1391
  ${nodeYamlContent.trim()}`);
1382
- } catch {
1383
- parts.push(`### node.yaml
1392
+ } catch {
1393
+ parts.push(`### node.yaml
1384
1394
  (not found)`);
1395
+ }
1385
1396
  }
1386
1397
  const filtered = filterArtifactsByConfig(node.artifacts, config);
1387
1398
  for (const a of filtered) {
@@ -1389,10 +1400,14 @@ ${nodeYamlContent.trim()}`);
1389
1400
  ${a.content}`);
1390
1401
  }
1391
1402
  const content = parts.join("\n\n");
1403
+ const nodeAspects = node.meta.aspects ?? [];
1404
+ const expanded = expandAspects(nodeAspects, graph.aspects);
1405
+ const attrs = expanded.length > 0 ? { aspects: expanded.join(",") } : void 0;
1392
1406
  return {
1393
1407
  type: "own",
1394
1408
  label: `Node: ${node.meta.name}`,
1395
- content
1409
+ content,
1410
+ attrs
1396
1411
  };
1397
1412
  }
1398
1413
  function buildStructuralRelationLayer(target, relation, config) {
@@ -1420,10 +1435,17 @@ ${a.content}`).join("\n\n");
1420
1435
  content += filtered.map((a) => `### ${a.filename}
1421
1436
  ${a.content}`).join("\n\n");
1422
1437
  }
1438
+ const attrs = {
1439
+ target: target.path,
1440
+ type: relation.type
1441
+ };
1442
+ if (relation.consumes?.length) attrs.consumes = relation.consumes.join(", ");
1443
+ if (relation.failure) attrs.failure = relation.failure;
1423
1444
  return {
1424
1445
  type: "relational",
1425
1446
  label: `Dependency: ${target.meta.name} (${relation.type}) \u2014 ${target.path}`,
1426
- content: content.trim()
1447
+ content: content.trim(),
1448
+ attrs
1427
1449
  };
1428
1450
  }
1429
1451
  function buildEventRelationLayer(target, relation) {
@@ -1436,10 +1458,17 @@ You listen for ${eventName}.`;
1436
1458
  content += `
1437
1459
  Consumes: ${relation.consumes.join(", ")}`;
1438
1460
  }
1461
+ const attrs = {
1462
+ target: target.path,
1463
+ type: relation.type,
1464
+ "event-name": eventName
1465
+ };
1466
+ if (relation.consumes?.length) attrs.consumes = relation.consumes.join(", ");
1439
1467
  return {
1440
1468
  type: "relational",
1441
1469
  label: `Event: ${eventName} [${relation.type}]`,
1442
- content
1470
+ content,
1471
+ attrs
1443
1472
  };
1444
1473
  }
1445
1474
  function buildAspectLayer(aspect) {
@@ -1447,17 +1476,21 @@ function buildAspectLayer(aspect) {
1447
1476
  ${a.content}`).join("\n\n");
1448
1477
  return {
1449
1478
  type: "aspects",
1450
- label: `${aspect.name} (tag: ${aspect.tag})`,
1479
+ label: `${aspect.name} (aspect: ${aspect.id})`,
1451
1480
  content
1452
1481
  };
1453
1482
  }
1454
- function buildFlowLayer(flow) {
1483
+ function buildFlowLayer(flow, graph) {
1455
1484
  const content = flow.artifacts.map((a) => `### ${a.filename}
1456
1485
  ${a.content}`).join("\n\n");
1486
+ const flowAspects = flow.aspects ?? [];
1487
+ const expanded = expandAspects(flowAspects, graph.aspects);
1488
+ const attrs = expanded.length > 0 ? { aspects: expanded.join(",") } : void 0;
1457
1489
  return {
1458
1490
  type: "flows",
1459
1491
  label: `Flow: ${flow.name}`,
1460
- content: content || "(no artifacts)"
1492
+ content: content || "(no artifacts)",
1493
+ attrs
1461
1494
  };
1462
1495
  }
1463
1496
  function buildSections(layers, mapping) {
@@ -1471,12 +1504,16 @@ function buildSections(layers, mapping) {
1471
1504
  }
1472
1505
  return [
1473
1506
  { key: "Global", layers: layers.filter((l) => l.type === "global") },
1474
- { key: "Knowledge", layers: layers.filter((l) => l.type === "knowledge") },
1475
1507
  { key: "Hierarchy", layers: layers.filter((l) => l.type === "hierarchy") },
1476
1508
  { key: "OwnArtifacts", layers: ownLayers },
1477
- { key: "Dependencies", layers: layers.filter((l) => l.type === "relational") },
1478
1509
  { key: "Aspects", layers: layers.filter((l) => l.type === "aspects") },
1479
- { key: "Flows", layers: layers.filter((l) => l.type === "flows") }
1510
+ {
1511
+ key: "Relational",
1512
+ layers: [
1513
+ ...layers.filter((l) => l.type === "relational"),
1514
+ ...layers.filter((l) => l.type === "flows")
1515
+ ]
1516
+ }
1480
1517
  ];
1481
1518
  }
1482
1519
  function collectAncestors(node) {
@@ -1488,30 +1525,27 @@ function collectAncestors(node) {
1488
1525
  }
1489
1526
  return ancestors;
1490
1527
  }
1491
-
1492
- // src/core/validator.ts
1493
- import { readdir as readdir4 } from "fs/promises";
1494
- import path9 from "path";
1495
-
1496
- // src/utils/git.ts
1497
- import { execSync } from "child_process";
1498
- import path8 from "path";
1499
- function getLastCommitTimestamp(projectRoot, relativePath) {
1500
- const normalized = path8.normalize(relativePath).replace(/\\/g, "/");
1501
- try {
1502
- const out = execSync(`git log -1 --format=%ct -- "${normalized}"`, {
1503
- cwd: projectRoot,
1504
- encoding: "utf-8",
1505
- stdio: ["pipe", "pipe", "pipe"]
1506
- });
1507
- const ts = parseInt(out.trim(), 10);
1508
- return Number.isNaN(ts) ? null : ts;
1509
- } catch {
1510
- return null;
1528
+ function collectEffectiveAspectIds(graph, nodePath) {
1529
+ const node = graph.nodes.get(nodePath);
1530
+ if (!node) return /* @__PURE__ */ new Set();
1531
+ const raw = new Set(node.meta.aspects ?? []);
1532
+ let ancestor = node.parent;
1533
+ while (ancestor) {
1534
+ for (const id of ancestor.meta.aspects ?? []) raw.add(id);
1535
+ ancestor = ancestor.parent;
1536
+ }
1537
+ const ancestorPaths = /* @__PURE__ */ new Set([nodePath, ...collectAncestors(node).map((a) => a.path)]);
1538
+ for (const flow of graph.flows) {
1539
+ if (flow.nodes.some((n) => ancestorPaths.has(n))) {
1540
+ for (const id of flow.aspects ?? []) raw.add(id);
1541
+ }
1511
1542
  }
1543
+ return new Set(expandAspects([...raw], graph.aspects));
1512
1544
  }
1513
1545
 
1514
1546
  // src/core/validator.ts
1547
+ import { readdir as readdir4 } from "fs/promises";
1548
+ import path9 from "path";
1515
1549
  var RESERVED_DIRS = /* @__PURE__ */ new Set();
1516
1550
  async function validate(graph, scope = "all") {
1517
1551
  const issues = [];
@@ -1534,46 +1568,60 @@ async function validate(graph, scope = "all") {
1534
1568
  }
1535
1569
  if (!graph.configError) {
1536
1570
  issues.push(...checkNodeTypes(graph));
1537
- issues.push(...checkTagsDefined(graph));
1538
- issues.push(...checkAspectTags(graph));
1539
- issues.push(...checkAspectTagUniqueness(graph));
1571
+ issues.push(...checkAspectsDefined(graph));
1572
+ issues.push(...checkAspectIds(graph));
1573
+ issues.push(...checkAspectIdUniqueness(graph));
1574
+ issues.push(...checkImpliedAspectsExist(graph));
1575
+ issues.push(...checkImpliesNoCycles(graph));
1576
+ issues.push(...checkRequiredAspectsCoverage(graph));
1540
1577
  issues.push(...checkRequiredArtifacts(graph));
1541
- issues.push(...await checkUnknownKnowledgeCategories(graph));
1542
1578
  issues.push(...checkInvalidArtifactConditions(graph));
1543
- issues.push(...checkScopeTagsDefined(graph));
1544
- issues.push(...await checkMissingPatternExamples(graph));
1545
1579
  issues.push(...await checkContextBudget(graph));
1546
1580
  issues.push(...checkHighFanOut(graph));
1547
- issues.push(...await checkStaleKnowledge(graph));
1548
1581
  }
1549
1582
  issues.push(...checkSchemas(graph));
1550
1583
  issues.push(...checkRelationTargets(graph));
1551
1584
  issues.push(...checkNoCycles(graph));
1552
1585
  issues.push(...checkMappingOverlap(graph));
1553
- issues.push(...checkBrokenKnowledgeRefs(graph));
1586
+ issues.push(...await checkMappingPathsExist(graph));
1554
1587
  issues.push(...checkBrokenFlowRefs(graph));
1555
- issues.push(...checkBrokenScopeRefs(graph));
1588
+ issues.push(...checkFlowAspectIds(graph));
1556
1589
  issues.push(...await checkDirectoriesHaveNodeYaml(graph));
1557
1590
  issues.push(...await checkShallowArtifacts(graph));
1558
- issues.push(...await checkUnreachableKnowledge(graph));
1559
1591
  issues.push(...checkUnpairedEvents(graph));
1560
1592
  let filtered = issues;
1561
1593
  let nodesScanned = graph.nodes.size;
1562
1594
  if (scope !== "all" && scope.trim()) {
1563
1595
  if (!graph.nodes.has(scope)) {
1596
+ const parseError = (graph.nodeParseErrors ?? []).find(
1597
+ (e) => e.nodePath === scope || scope.startsWith(e.nodePath + "/")
1598
+ );
1599
+ if (parseError) {
1600
+ return {
1601
+ issues: [{
1602
+ severity: "error",
1603
+ code: "E001",
1604
+ rule: "invalid-node-yaml",
1605
+ message: parseError.message,
1606
+ nodePath: parseError.nodePath
1607
+ }],
1608
+ nodesScanned: 0
1609
+ };
1610
+ }
1564
1611
  return {
1565
1612
  issues: [{ severity: "error", rule: "invalid-scope", message: `Node not found: ${scope}` }],
1566
1613
  nodesScanned: 0
1567
1614
  };
1568
1615
  }
1569
- filtered = issues.filter((i) => !i.nodePath || i.nodePath === scope);
1570
- nodesScanned = 1;
1616
+ const scopePrefix = scope + "/";
1617
+ filtered = issues.filter((i) => !i.nodePath || i.nodePath === scope || i.nodePath.startsWith(scopePrefix));
1618
+ nodesScanned = [...graph.nodes.keys()].filter((p) => p === scope || p.startsWith(scopePrefix)).length;
1571
1619
  }
1572
1620
  return { issues: filtered, nodesScanned };
1573
1621
  }
1574
1622
  function checkNodeTypes(graph) {
1575
1623
  const issues = [];
1576
- const allowedTypes = new Set(graph.config.node_types ?? []);
1624
+ const allowedTypes = new Set((graph.config.node_types ?? []).map((t) => t.name));
1577
1625
  for (const [nodePath, node] of graph.nodes) {
1578
1626
  if (!allowedTypes.has(node.meta.type)) {
1579
1627
  issues.push({
@@ -1636,17 +1684,17 @@ function checkRelationTargets(graph) {
1636
1684
  }
1637
1685
  return issues;
1638
1686
  }
1639
- function checkTagsDefined(graph) {
1687
+ function checkAspectsDefined(graph) {
1640
1688
  const issues = [];
1641
- const definedTags = new Set(graph.config.tags ?? []);
1689
+ const validAspectIds = new Set(graph.aspects.map((a) => a.id));
1642
1690
  for (const [nodePath, node] of graph.nodes) {
1643
- for (const tag of node.meta.tags ?? []) {
1644
- if (!definedTags.has(tag)) {
1691
+ for (const aspectId of node.meta.aspects ?? []) {
1692
+ if (!validAspectIds.has(aspectId)) {
1645
1693
  issues.push({
1646
1694
  severity: "error",
1647
1695
  code: "E003",
1648
- rule: "unknown-tag",
1649
- message: `Tag '${tag}' not defined in config.yaml`,
1696
+ rule: "unknown-aspect",
1697
+ message: `Aspect '${aspectId}' has no corresponding directory in aspects/`,
1650
1698
  nodePath
1651
1699
  });
1652
1700
  }
@@ -1654,40 +1702,124 @@ function checkTagsDefined(graph) {
1654
1702
  }
1655
1703
  return issues;
1656
1704
  }
1657
- function checkAspectTags(graph) {
1658
- const issues = [];
1659
- const definedTags = new Set(graph.config.tags ?? []);
1660
- for (const aspect of graph.aspects) {
1661
- if (!definedTags.has(aspect.tag)) {
1662
- issues.push({
1663
- severity: "error",
1664
- code: "E007",
1665
- rule: "broken-aspect-tag",
1666
- message: `Aspect '${aspect.name}' references undefined tag '${aspect.tag}'`
1667
- });
1668
- }
1669
- }
1670
- return issues;
1705
+ function checkAspectIds(_graph) {
1706
+ return [];
1671
1707
  }
1672
- function checkAspectTagUniqueness(graph) {
1708
+ function checkAspectIdUniqueness(graph) {
1673
1709
  const issues = [];
1674
- const byTag = /* @__PURE__ */ new Map();
1710
+ const byId = /* @__PURE__ */ new Map();
1675
1711
  for (const aspect of graph.aspects) {
1676
- const names = byTag.get(aspect.tag) ?? [];
1712
+ const names = byId.get(aspect.id) ?? [];
1677
1713
  names.push(aspect.name);
1678
- byTag.set(aspect.tag, names);
1714
+ byId.set(aspect.id, names);
1679
1715
  }
1680
- for (const [tag, names] of byTag) {
1716
+ for (const [id, names] of byId) {
1681
1717
  if (names.length <= 1) continue;
1682
1718
  issues.push({
1683
1719
  severity: "error",
1684
1720
  code: "E014",
1685
1721
  rule: "duplicate-aspect-binding",
1686
- message: `Tag '${tag}' is bound to multiple aspects (${names.join(", ")})`
1722
+ message: `Aspect '${id}' is bound to multiple aspects (${names.join(", ")})`
1687
1723
  });
1688
1724
  }
1689
1725
  return issues;
1690
1726
  }
1727
+ function checkImpliedAspectsExist(graph) {
1728
+ const issues = [];
1729
+ const idToAspect = /* @__PURE__ */ new Map();
1730
+ for (const a of graph.aspects) {
1731
+ idToAspect.set(a.id, { name: a.name });
1732
+ }
1733
+ for (const aspect of graph.aspects) {
1734
+ for (const impliedId of aspect.implies ?? []) {
1735
+ if (!idToAspect.has(impliedId)) {
1736
+ issues.push({
1737
+ severity: "error",
1738
+ code: "E016",
1739
+ rule: "implied-aspect-missing",
1740
+ message: `Aspect '${aspect.name}' implies '${impliedId}' but no aspect with that id exists in aspects/`
1741
+ });
1742
+ }
1743
+ }
1744
+ }
1745
+ return issues;
1746
+ }
1747
+ function checkImpliesNoCycles(graph) {
1748
+ const idToAspect = /* @__PURE__ */ new Map();
1749
+ for (const a of graph.aspects) {
1750
+ idToAspect.set(a.id, { implies: a.implies });
1751
+ }
1752
+ const WHITE = 0;
1753
+ const GRAY = 1;
1754
+ const BLACK = 2;
1755
+ const color = /* @__PURE__ */ new Map();
1756
+ for (const id of idToAspect.keys()) color.set(id, WHITE);
1757
+ const issues = [];
1758
+ function dfs(id, pathArr) {
1759
+ color.set(id, GRAY);
1760
+ pathArr.push(id);
1761
+ const aspect = idToAspect.get(id);
1762
+ for (const implied of aspect?.implies ?? []) {
1763
+ if (color.get(implied) === GRAY) {
1764
+ const cycle = pathArr.slice(pathArr.indexOf(implied)).concat(implied);
1765
+ issues.push({
1766
+ severity: "error",
1767
+ code: "E017",
1768
+ rule: "aspect-implies-cycle",
1769
+ message: `Aspect implies cycle: ${cycle.join(" \u2192 ")}`
1770
+ });
1771
+ pathArr.pop();
1772
+ color.set(id, BLACK);
1773
+ return true;
1774
+ }
1775
+ if (color.get(implied) === WHITE && dfs(implied, pathArr)) {
1776
+ pathArr.pop();
1777
+ color.set(id, BLACK);
1778
+ return true;
1779
+ }
1780
+ }
1781
+ pathArr.pop();
1782
+ color.set(id, BLACK);
1783
+ return false;
1784
+ }
1785
+ for (const id of idToAspect.keys()) {
1786
+ if (color.get(id) === WHITE) {
1787
+ dfs(id, []);
1788
+ }
1789
+ }
1790
+ return issues;
1791
+ }
1792
+ function checkRequiredAspectsCoverage(graph) {
1793
+ const issues = [];
1794
+ const typeConfig = new Map(
1795
+ (graph.config.node_types ?? []).map((t) => [t.name, t.required_aspects ?? []])
1796
+ );
1797
+ for (const [nodePath, node] of graph.nodes) {
1798
+ if (node.meta.blackbox) continue;
1799
+ const requiredAspects = typeConfig.get(node.meta.type);
1800
+ if (!requiredAspects || requiredAspects.length === 0) continue;
1801
+ const nodeAspects = node.meta.aspects ?? [];
1802
+ let effectiveAspects;
1803
+ try {
1804
+ effectiveAspects = resolveAspects(nodeAspects, graph.aspects);
1805
+ } catch {
1806
+ continue;
1807
+ }
1808
+ const effectiveAspectIds = new Set(effectiveAspects.map((a) => a.id));
1809
+ for (const required of requiredAspects) {
1810
+ if (!effectiveAspectIds.has(required)) {
1811
+ issues.push({
1812
+ severity: "warning",
1813
+ code: "W011",
1814
+ rule: "missing-required-aspect-coverage",
1815
+ message: `Node '${nodePath}' (type: ${node.meta.type}) missing required aspect coverage for '${required}'`,
1816
+ nodePath
1817
+ });
1818
+ }
1819
+ }
1820
+ }
1821
+ return issues;
1822
+ }
1691
1823
  function checkNoCycles(graph) {
1692
1824
  const WHITE = 0;
1693
1825
  const GRAY = 1;
@@ -1740,6 +1872,9 @@ function arePathsOverlapping(pathA, pathB) {
1740
1872
  if (pathA === pathB) return true;
1741
1873
  return pathA.startsWith(pathB + "/") || pathB.startsWith(pathA + "/");
1742
1874
  }
1875
+ function isAncestorNode(possibleAncestor, possibleDescendant) {
1876
+ return possibleDescendant.startsWith(possibleAncestor + "/");
1877
+ }
1743
1878
  function checkMappingOverlap(graph) {
1744
1879
  const issues = [];
1745
1880
  const ownership = [];
@@ -1755,6 +1890,9 @@ function checkMappingOverlap(graph) {
1755
1890
  const candidate = ownership[nestedIndex];
1756
1891
  if (current.nodePath === candidate.nodePath) continue;
1757
1892
  if (!arePathsOverlapping(current.mappingPath, candidate.mappingPath)) continue;
1893
+ const isContainment = current.mappingPath !== candidate.mappingPath;
1894
+ const isHierarchical = isAncestorNode(current.nodePath, candidate.nodePath) || isAncestorNode(candidate.nodePath, current.nodePath);
1895
+ if (isContainment && isHierarchical) continue;
1758
1896
  issues.push({
1759
1897
  severity: "error",
1760
1898
  code: "E009",
@@ -1766,6 +1904,29 @@ function checkMappingOverlap(graph) {
1766
1904
  }
1767
1905
  return issues;
1768
1906
  }
1907
+ async function checkMappingPathsExist(graph) {
1908
+ const issues = [];
1909
+ const projectRoot = path9.dirname(graph.rootPath);
1910
+ const { access: access4 } = await import("fs/promises");
1911
+ for (const [nodePath, node] of graph.nodes) {
1912
+ const mappingPaths = normalizeMappingPaths(node.meta.mapping);
1913
+ for (const mp of mappingPaths) {
1914
+ const absPath = path9.join(projectRoot, mp);
1915
+ try {
1916
+ await access4(absPath);
1917
+ } catch {
1918
+ issues.push({
1919
+ severity: "warning",
1920
+ code: "W012",
1921
+ rule: "mapping-path-missing",
1922
+ message: `Mapping path '${mp}' does not exist on disk`,
1923
+ nodePath
1924
+ });
1925
+ }
1926
+ }
1927
+ }
1928
+ return issues;
1929
+ }
1769
1930
  function getIncomingRelationSources(graph, nodePath) {
1770
1931
  const sources = [];
1771
1932
  for (const [srcPath, node] of graph.nodes) {
@@ -1789,9 +1950,10 @@ function artifactRequiredReason(graph, nodePath, node, required) {
1789
1950
  const count = node.meta.relations?.length ?? 0;
1790
1951
  return count > 0 ? `${count} outgoing relation(s)` : null;
1791
1952
  }
1792
- if (when.startsWith("has_tag:")) {
1793
- const tag = when.slice(8);
1794
- return (node.meta.tags ?? []).includes(tag) ? `node has tag '${tag}'` : null;
1953
+ if (when.startsWith("has_aspect:") || when.startsWith("has_tag:")) {
1954
+ const prefix = when.startsWith("has_aspect:") ? "has_aspect:" : "has_tag:";
1955
+ const aspectId = when.slice(prefix.length);
1956
+ return (node.meta.aspects ?? []).includes(aspectId) ? `node has aspect '${aspectId}'` : null;
1795
1957
  }
1796
1958
  return null;
1797
1959
  }
@@ -1831,29 +1993,9 @@ function checkRequiredArtifacts(graph) {
1831
1993
  }
1832
1994
  return issues;
1833
1995
  }
1834
- function checkBrokenKnowledgeRefs(graph) {
1835
- const issues = [];
1836
- const knowledgePaths = new Set(graph.knowledge.map((k) => k.path));
1837
- for (const [nodePath, node] of graph.nodes) {
1838
- for (const kPath of node.meta.knowledge ?? []) {
1839
- const norm = kPath.replace(/\/$/, "");
1840
- if (!knowledgePaths.has(norm) && !knowledgePaths.has(kPath)) {
1841
- issues.push({
1842
- severity: "error",
1843
- code: "E005",
1844
- rule: "broken-knowledge-ref",
1845
- message: `Knowledge ref '${kPath}' does not resolve to existing knowledge item`,
1846
- nodePath
1847
- });
1848
- }
1849
- }
1850
- }
1851
- return issues;
1852
- }
1853
1996
  function checkBrokenFlowRefs(graph) {
1854
1997
  const issues = [];
1855
1998
  const nodePaths = new Set(graph.nodes.keys());
1856
- const knowledgePaths = new Set(graph.knowledge.map((k) => k.path));
1857
1999
  for (const flow of graph.flows) {
1858
2000
  for (const n of flow.nodes) {
1859
2001
  if (!nodePaths.has(n)) {
@@ -1865,107 +2007,43 @@ function checkBrokenFlowRefs(graph) {
1865
2007
  });
1866
2008
  }
1867
2009
  }
1868
- for (const kPath of flow.knowledge ?? []) {
1869
- const norm = kPath.replace(/\/$/, "");
1870
- if (!knowledgePaths.has(norm) && !knowledgePaths.has(kPath)) {
2010
+ }
2011
+ return issues;
2012
+ }
2013
+ function checkFlowAspectIds(graph) {
2014
+ const issues = [];
2015
+ const validAspectIds = new Set(graph.aspects.map((a) => a.id));
2016
+ for (const flow of graph.flows) {
2017
+ for (const aspectId of flow.aspects ?? []) {
2018
+ if (!validAspectIds.has(aspectId)) {
1871
2019
  issues.push({
1872
2020
  severity: "error",
1873
- code: "E005",
1874
- rule: "broken-knowledge-ref",
1875
- message: `Flow '${flow.name}' references non-existent knowledge '${kPath}'`,
1876
- nodePath: `flows/${flow.name}`
2021
+ code: "E007",
2022
+ rule: "broken-aspect-ref",
2023
+ message: `Flow '${flow.name}' references aspect '${aspectId}' but no aspect with that id exists in aspects/`
1877
2024
  });
1878
2025
  }
1879
2026
  }
1880
2027
  }
1881
2028
  return issues;
1882
2029
  }
1883
- function checkBrokenScopeRefs(graph) {
2030
+ function checkInvalidArtifactConditions(graph) {
1884
2031
  const issues = [];
1885
- const nodePaths = new Set(graph.nodes.keys());
1886
- for (const k of graph.knowledge) {
1887
- if (typeof k.scope === "object" && "nodes" in k.scope) {
1888
- for (const n of k.scope.nodes) {
1889
- if (!nodePaths.has(n)) {
1890
- issues.push({
1891
- severity: "error",
1892
- code: "E008",
1893
- rule: "broken-scope-ref",
1894
- message: `Knowledge '${k.path}' scope references non-existent node '${n}'`
1895
- });
1896
- }
1897
- }
1898
- }
1899
- }
1900
- return issues;
1901
- }
1902
- function checkScopeTagsDefined(graph) {
1903
- const issues = [];
1904
- const definedTags = new Set(graph.config.tags ?? []);
1905
- for (const k of graph.knowledge) {
1906
- if (typeof k.scope !== "object" || !("tags" in k.scope)) continue;
1907
- for (const tag of k.scope.tags) {
1908
- if (definedTags.has(tag)) continue;
1909
- issues.push({
1910
- severity: "error",
1911
- code: "E008",
1912
- rule: "broken-scope-ref",
1913
- message: `Knowledge '${k.path}' scope references undefined tag '${tag}'`
1914
- });
1915
- }
1916
- }
1917
- return issues;
1918
- }
1919
- async function checkUnknownKnowledgeCategories(graph) {
1920
- const issues = [];
1921
- const categorySet = new Set((graph.config.knowledge_categories ?? []).map((c) => c.name));
1922
- const knowledgeDir = path9.join(graph.rootPath, "knowledge");
1923
- const existingDirs = /* @__PURE__ */ new Set();
1924
- try {
1925
- const entries = await readdir4(knowledgeDir, { withFileTypes: true });
1926
- for (const e of entries) {
1927
- if (!e.isDirectory()) continue;
1928
- if (e.name.startsWith(".")) continue;
1929
- existingDirs.add(e.name);
1930
- if (!categorySet.has(e.name)) {
1931
- issues.push({
1932
- severity: "error",
1933
- code: "E011",
1934
- rule: "unknown-knowledge-category",
1935
- message: `Directory knowledge/${e.name}/ does not match any config.knowledge_categories`
1936
- });
1937
- }
1938
- }
1939
- } catch {
1940
- }
1941
- for (const cat of graph.config.knowledge_categories ?? []) {
1942
- if (!existingDirs.has(cat.name)) {
1943
- issues.push({
1944
- severity: "error",
1945
- code: "E017",
1946
- rule: "missing-knowledge-category-dir",
1947
- message: `Category '${cat.name}' in config has no knowledge/${cat.name}/ directory`
1948
- });
1949
- }
1950
- }
1951
- return issues;
1952
- }
1953
- function checkInvalidArtifactConditions(graph) {
1954
- const issues = [];
1955
- const definedTags = new Set(graph.config.tags ?? []);
1956
- const artifacts = graph.config.artifacts ?? {};
1957
- for (const [artifactName, config] of Object.entries(artifacts)) {
1958
- const required = config.required;
1959
- if (typeof required === "object" && required && "when" in required) {
1960
- const when = required.when;
1961
- if (when.startsWith("has_tag:")) {
1962
- const tag = when.slice(8);
1963
- if (!definedTags.has(tag)) {
2032
+ const validAspectIds = new Set(graph.aspects.map((a) => a.id));
2033
+ const artifacts = graph.config.artifacts ?? {};
2034
+ for (const [artifactName, config] of Object.entries(artifacts)) {
2035
+ const required = config.required;
2036
+ if (typeof required === "object" && required && "when" in required) {
2037
+ const when = required.when;
2038
+ if (when.startsWith("has_aspect:") || when.startsWith("has_tag:")) {
2039
+ const prefix = when.startsWith("has_aspect:") ? "has_aspect:" : "has_tag:";
2040
+ const aspectId = when.slice(prefix.length);
2041
+ if (!validAspectIds.has(aspectId)) {
1964
2042
  issues.push({
1965
2043
  severity: "error",
1966
2044
  code: "E013",
1967
2045
  rule: "invalid-artifact-condition",
1968
- message: `Artifact '${artifactName}' condition has_tag:${tag} references undefined tag`
2046
+ message: `Artifact '${artifactName}' condition has_aspect:${aspectId} has no corresponding aspect in aspects/`
1969
2047
  });
1970
2048
  }
1971
2049
  }
@@ -1983,7 +2061,7 @@ async function checkShallowArtifacts(graph) {
1983
2061
  severity: "warning",
1984
2062
  code: "W002",
1985
2063
  rule: "shallow-artifact",
1986
- message: `Artifact '${art.filename}' is below minimum length (${art.content.length} < ${minLen})`,
2064
+ message: `Artifact '${art.filename}' is below minimum length (${art.content.trim().length} < ${minLen})`,
1987
2065
  nodePath
1988
2066
  });
1989
2067
  }
@@ -1991,99 +2069,6 @@ async function checkShallowArtifacts(graph) {
1991
2069
  }
1992
2070
  return issues;
1993
2071
  }
1994
- async function checkUnreachableKnowledge(graph) {
1995
- const issues = [];
1996
- const nodePaths = new Set(graph.nodes.keys());
1997
- const nodeTags = /* @__PURE__ */ new Map();
1998
- for (const [p, n] of graph.nodes) {
1999
- nodeTags.set(p, new Set(n.meta.tags ?? []));
2000
- }
2001
- const knowledgeReachable = /* @__PURE__ */ new Set();
2002
- for (const k of graph.knowledge) {
2003
- if (k.scope === "global") {
2004
- knowledgeReachable.add(k.path);
2005
- continue;
2006
- }
2007
- if (typeof k.scope === "object" && "tags" in k.scope) {
2008
- for (const [, tags] of nodeTags) {
2009
- if (k.scope.tags.some((t) => tags.has(t))) {
2010
- knowledgeReachable.add(k.path);
2011
- break;
2012
- }
2013
- }
2014
- }
2015
- if (typeof k.scope === "object" && "nodes" in k.scope) {
2016
- if (k.scope.nodes.some((n) => nodePaths.has(n))) {
2017
- knowledgeReachable.add(k.path);
2018
- }
2019
- }
2020
- }
2021
- for (const [, node] of graph.nodes) {
2022
- for (const kPath of node.meta.knowledge ?? []) {
2023
- const k = graph.knowledge.find(
2024
- (i) => i.path === kPath || i.path === kPath.replace(/\/$/, "")
2025
- );
2026
- if (k) knowledgeReachable.add(k.path);
2027
- }
2028
- }
2029
- for (const flow of graph.flows) {
2030
- for (const kPath of flow.knowledge ?? []) {
2031
- const k = graph.knowledge.find(
2032
- (i) => i.path === kPath || i.path === kPath.replace(/\/$/, "")
2033
- );
2034
- if (k) knowledgeReachable.add(k.path);
2035
- }
2036
- }
2037
- for (const k of graph.knowledge) {
2038
- if (!knowledgeReachable.has(k.path)) {
2039
- issues.push({
2040
- severity: "warning",
2041
- code: "W003",
2042
- rule: "unreachable-knowledge",
2043
- message: `Knowledge '${k.path}' does not reach any context package`
2044
- });
2045
- }
2046
- }
2047
- return issues;
2048
- }
2049
- async function checkMissingPatternExamples(graph) {
2050
- const issues = [];
2051
- const hasPatterns = (graph.config.knowledge_categories ?? []).some((c) => c.name === "patterns");
2052
- if (!hasPatterns) return issues;
2053
- const patternsDir = path9.join(graph.rootPath, "knowledge", "patterns");
2054
- try {
2055
- const entries = await readdir4(patternsDir, { withFileTypes: true });
2056
- const exampleExtensions = /* @__PURE__ */ new Set([
2057
- ".ts",
2058
- ".js",
2059
- ".tsx",
2060
- ".jsx",
2061
- ".py",
2062
- ".go",
2063
- ".rs",
2064
- ".java",
2065
- ".kt"
2066
- ]);
2067
- for (const e of entries) {
2068
- if (!e.isDirectory()) continue;
2069
- const itemDir = path9.join(patternsDir, e.name);
2070
- const itemEntries = await readdir4(itemDir, { withFileTypes: true });
2071
- const hasExample = itemEntries.some(
2072
- (f) => f.isFile() && f.name !== "knowledge.yaml" && (f.name.startsWith("example") || exampleExtensions.has(path9.extname(f.name).toLowerCase()))
2073
- );
2074
- if (!hasExample) {
2075
- issues.push({
2076
- severity: "warning",
2077
- code: "W004",
2078
- rule: "missing-example",
2079
- message: `Pattern 'patterns/${e.name}' has no example file`
2080
- });
2081
- }
2082
- }
2083
- } catch {
2084
- }
2085
- return issues;
2086
- }
2087
2072
  function checkHighFanOut(graph) {
2088
2073
  const issues = [];
2089
2074
  const maxRel = graph.config.quality?.max_direct_relations ?? 10;
@@ -2101,57 +2086,6 @@ function checkHighFanOut(graph) {
2101
2086
  }
2102
2087
  return issues;
2103
2088
  }
2104
- function getNodesInScope(k, graph) {
2105
- if (k.scope === "global") {
2106
- return [...graph.nodes.keys()];
2107
- }
2108
- if (typeof k.scope === "object" && "nodes" in k.scope && k.scope.nodes) {
2109
- return k.scope.nodes.filter((p) => graph.nodes.has(p));
2110
- }
2111
- if (typeof k.scope === "object" && "tags" in k.scope && k.scope.tags) {
2112
- const tagSet = new Set(k.scope.tags);
2113
- return [...graph.nodes.keys()].filter((p) => {
2114
- const node = graph.nodes.get(p);
2115
- return (node.meta.tags ?? []).some((t) => tagSet.has(t));
2116
- });
2117
- }
2118
- return [];
2119
- }
2120
- async function checkStaleKnowledge(graph) {
2121
- const issues = [];
2122
- const stalenessDays = graph.config.quality?.knowledge_staleness_days ?? 90;
2123
- const projectRoot = path9.dirname(graph.rootPath);
2124
- const yggRel = path9.relative(projectRoot, graph.rootPath).replace(/\\/g, "/") || ".yggdrasil";
2125
- for (const k of graph.knowledge) {
2126
- const scopeNodes = getNodesInScope(k, graph);
2127
- if (scopeNodes.length === 0) continue;
2128
- const kPath = `${yggRel}/knowledge/${k.path}`;
2129
- const tK = getLastCommitTimestamp(projectRoot, kPath);
2130
- if (tK === null) continue;
2131
- let maxTp = 0;
2132
- let latestNode = "";
2133
- for (const nodePath of scopeNodes) {
2134
- const nodePathRel = `${yggRel}/model/${nodePath}`;
2135
- const tP = getLastCommitTimestamp(projectRoot, nodePathRel);
2136
- if (tP !== null && tP > maxTp) {
2137
- maxTp = tP;
2138
- latestNode = nodePath;
2139
- }
2140
- }
2141
- if (maxTp === 0) continue;
2142
- const diffDays = (maxTp - tK) / (60 * 60 * 24);
2143
- if (diffDays > stalenessDays) {
2144
- issues.push({
2145
- severity: "warning",
2146
- code: "W008",
2147
- rule: "stale-knowledge",
2148
- message: `Knowledge '${k.path}' may be stale: node '${latestNode}' modified ${Math.floor(diffDays)} days later (Git commits)`,
2149
- nodePath: latestNode
2150
- });
2151
- }
2152
- }
2153
- return issues;
2154
- }
2155
2089
  function checkUnpairedEvents(graph) {
2156
2090
  const issues = [];
2157
2091
  const emitsTo = /* @__PURE__ */ new Map();
@@ -2200,7 +2134,7 @@ function checkUnpairedEvents(graph) {
2200
2134
  }
2201
2135
  return issues;
2202
2136
  }
2203
- var REQUIRED_SCHEMAS = ["node", "aspect", "flow", "knowledge"];
2137
+ var REQUIRED_SCHEMAS = ["node", "aspect", "flow"];
2204
2138
  function checkSchemas(graph) {
2205
2139
  const issues = [];
2206
2140
  const present = new Set(graph.schemas.map((s) => s.schemaType));
@@ -2210,7 +2144,7 @@ function checkSchemas(graph) {
2210
2144
  severity: "warning",
2211
2145
  code: "W010",
2212
2146
  rule: "missing-schema",
2213
- message: `Schema '${required}.yaml' missing from .yggdrasil/templates/`
2147
+ message: `Schema '${required}.yaml' missing from .yggdrasil/schemas/`
2214
2148
  });
2215
2149
  }
2216
2150
  }
@@ -2224,16 +2158,27 @@ async function checkDirectoriesHaveNodeYaml(graph) {
2224
2158
  const hasNodeYaml = entries.some((e) => e.isFile() && e.name === "node.yaml");
2225
2159
  const dirName = path9.basename(dirPath);
2226
2160
  if (RESERVED_DIRS.has(dirName)) return;
2227
- const hasContent = entries.some((e) => e.isFile()) || entries.some((e) => e.isDirectory());
2161
+ const hasFiles = entries.some((e) => e.isFile());
2162
+ const hasSubdirs = entries.some((e) => e.isDirectory() && !RESERVED_DIRS.has(e.name) && !e.name.startsWith("."));
2228
2163
  const graphPath = segments.join("/");
2229
- if (hasContent && !hasNodeYaml && graphPath !== "") {
2230
- issues.push({
2231
- severity: "error",
2232
- code: "E015",
2233
- rule: "missing-node-yaml",
2234
- message: `Directory '${graphPath}' has content but no node.yaml`,
2235
- nodePath: graphPath
2236
- });
2164
+ if (!hasNodeYaml && graphPath !== "") {
2165
+ if (hasFiles) {
2166
+ issues.push({
2167
+ severity: "error",
2168
+ code: "E015",
2169
+ rule: "missing-node-yaml",
2170
+ message: `Directory '${graphPath}' has files but no node.yaml`,
2171
+ nodePath: graphPath
2172
+ });
2173
+ } else if (hasSubdirs) {
2174
+ issues.push({
2175
+ severity: "warning",
2176
+ code: "W013",
2177
+ rule: "directory-without-node",
2178
+ message: `Directory '${graphPath}' has subdirectories but no node.yaml \u2014 consider creating a node`,
2179
+ nodePath: graphPath
2180
+ });
2181
+ }
2237
2182
  }
2238
2183
  for (const entry of entries) {
2239
2184
  if (!entry.isDirectory()) continue;
@@ -2255,26 +2200,26 @@ async function checkDirectoriesHaveNodeYaml(graph) {
2255
2200
  }
2256
2201
  async function checkContextBudget(graph) {
2257
2202
  const issues = [];
2258
- const warningThreshold = graph.config.quality?.context_budget.warning ?? 5e3;
2259
- const errorThreshold = graph.config.quality?.context_budget.error ?? 1e4;
2203
+ const warningThreshold = graph.config.quality?.context_budget.warning ?? 1e4;
2204
+ const errorThreshold = graph.config.quality?.context_budget.error ?? 2e4;
2260
2205
  for (const [nodePath, node] of graph.nodes) {
2261
2206
  if (node.meta.blackbox) continue;
2262
2207
  try {
2263
- const pkg = await buildContext(graph, nodePath);
2264
- if (pkg.tokenCount >= errorThreshold) {
2208
+ const pkg2 = await buildContext(graph, nodePath);
2209
+ if (pkg2.tokenCount >= errorThreshold) {
2265
2210
  issues.push({
2266
2211
  severity: "warning",
2267
2212
  code: "W006",
2268
2213
  rule: "budget-error",
2269
- message: `Context is ${pkg.tokenCount.toLocaleString()} tokens (error threshold: ${errorThreshold.toLocaleString()}) \u2014 blocks materialization, node must be split`,
2214
+ message: `Context is ${pkg2.tokenCount.toLocaleString()} tokens (error threshold: ${errorThreshold.toLocaleString()}) \u2014 blocks materialization, node must be split`,
2270
2215
  nodePath
2271
2216
  });
2272
- } else if (pkg.tokenCount >= warningThreshold) {
2217
+ } else if (pkg2.tokenCount >= warningThreshold) {
2273
2218
  issues.push({
2274
2219
  severity: "warning",
2275
2220
  code: "W005",
2276
2221
  rule: "budget-warning",
2277
- message: `Context is ${pkg.tokenCount.toLocaleString()} tokens (warning threshold: ${warningThreshold.toLocaleString()}). Consider splitting the node or reducing dependencies.`,
2222
+ message: `Context is ${pkg2.tokenCount.toLocaleString()} tokens (warning threshold: ${warningThreshold.toLocaleString()}). Consider splitting the node or reducing dependencies.`,
2278
2223
  nodePath
2279
2224
  });
2280
2225
  }
@@ -2284,75 +2229,133 @@ async function checkContextBudget(graph) {
2284
2229
  return issues;
2285
2230
  }
2286
2231
 
2287
- // src/formatters/markdown.ts
2288
- function formatContextMarkdown(pkg) {
2289
- let md = "";
2290
- md += `# Context Package: ${pkg.nodeName}
2291
- `;
2292
- md += `# Path: ${pkg.nodePath}
2293
- `;
2294
- md += `# Generated: ${(/* @__PURE__ */ new Date()).toISOString()}
2295
-
2296
- `;
2297
- md += `---
2298
-
2299
- `;
2300
- for (const section of pkg.sections) {
2301
- if (section.layers.length === 0) continue;
2302
- md += `## ${section.key}
2232
+ // src/formatters/context-text.ts
2233
+ function escapeAttr(val) {
2234
+ return val.replace(/"/g, "&quot;");
2235
+ }
2236
+ function formatLayer(layer) {
2237
+ switch (layer.type) {
2238
+ case "global":
2239
+ return `<global>
2240
+ ${layer.content}
2241
+ </global>`;
2242
+ case "hierarchy": {
2243
+ const pathMatch = layer.label.match(/\((.+)\/\)/);
2244
+ const pathAttr = pathMatch ? ` path="${escapeAttr(pathMatch[1])}"` : "";
2245
+ const aspectsAttr = layer.attrs?.aspects ? ` aspects="${escapeAttr(layer.attrs.aspects)}"` : "";
2246
+ return `<hierarchy${pathAttr}${aspectsAttr}>
2247
+ ${layer.content}
2248
+ </hierarchy>`;
2249
+ }
2250
+ case "own": {
2251
+ if (layer.label === "Materialization Target") {
2252
+ return `<materialization-target paths="${escapeAttr(layer.content)}" />`;
2253
+ }
2254
+ const ownAspectsAttr = layer.attrs?.aspects ? ` aspects="${escapeAttr(layer.attrs.aspects)}"` : "";
2255
+ return `<own-artifacts${ownAspectsAttr}>
2256
+ ${layer.content}
2257
+ </own-artifacts>`;
2258
+ }
2259
+ case "aspects": {
2260
+ const nameMatch = layer.label.match(/^(.+?) \(aspect: (.+)\)$/);
2261
+ const name = nameMatch ? escapeAttr(nameMatch[1]) : "";
2262
+ const id = nameMatch ? escapeAttr(nameMatch[2]) : "";
2263
+ return `<aspect name="${name}" id="${id}">
2264
+ ${layer.content}
2265
+ </aspect>`;
2266
+ }
2267
+ case "relational": {
2268
+ const attrs = layer.attrs ?? {};
2269
+ const attrStr = Object.entries(attrs).map(([k, v]) => ` ${k}="${escapeAttr(v)}"`).join("");
2270
+ const tagName = attrs.type && ["emits", "listens"].includes(attrs.type) ? "event" : "dependency";
2271
+ return `<${tagName}${attrStr}>
2272
+ ${layer.content}
2273
+ </${tagName}>`;
2274
+ }
2275
+ case "flows": {
2276
+ const flowName = layer.label.replace(/^Flow: /, "").trim();
2277
+ const flowAspectsAttr = layer.attrs?.aspects ? ` aspects="${escapeAttr(layer.attrs.aspects)}"` : "";
2278
+ return `<flow name="${escapeAttr(flowName)}"${flowAspectsAttr}>
2279
+ ${layer.content}
2280
+ </flow>`;
2281
+ }
2282
+ default:
2283
+ return layer.content;
2284
+ }
2285
+ }
2286
+ function formatContextText(pkg2) {
2287
+ const attrs = [
2288
+ `node-path="${escapeAttr(pkg2.nodePath)}"`,
2289
+ `node-name="${escapeAttr(pkg2.nodeName)}"`,
2290
+ `token-count="${pkg2.tokenCount}"`
2291
+ ].join(" ");
2292
+ let out = `<context-package ${attrs}>
2303
2293
 
2304
2294
  `;
2295
+ for (const section of pkg2.sections) {
2305
2296
  for (const layer of section.layers) {
2306
- md += `### ${layer.label}
2307
-
2308
- `;
2309
- md += layer.content;
2310
- md += `
2311
-
2312
- `;
2297
+ out += formatLayer(layer) + "\n\n";
2313
2298
  }
2314
- md += `---
2315
-
2316
- `;
2317
2299
  }
2318
- md += `Context size: ${pkg.tokenCount.toLocaleString()} tokens
2319
- `;
2320
- md += `Layers: ${pkg.layers.map((l) => l.type).join(", ")}
2321
- `;
2322
- return md;
2300
+ out += "</context-package>";
2301
+ return out;
2323
2302
  }
2324
2303
 
2325
2304
  // src/cli/build-context.ts
2305
+ function collectRelevantNodePaths(graph, nodePath) {
2306
+ const relevant = /* @__PURE__ */ new Set();
2307
+ const node = graph.nodes.get(nodePath);
2308
+ if (!node) return relevant;
2309
+ relevant.add(nodePath);
2310
+ for (const ancestor of collectAncestors(node)) {
2311
+ relevant.add(ancestor.path);
2312
+ }
2313
+ for (const rel of node.meta.relations ?? []) {
2314
+ relevant.add(rel.target);
2315
+ }
2316
+ return relevant;
2317
+ }
2326
2318
  function registerBuildCommand(program2) {
2327
2319
  program2.command("build-context").description("Assemble a context package for one node").requiredOption("--node <node-path>", "Node path relative to .yggdrasil/model/").action(async (options) => {
2328
2320
  try {
2329
2321
  const graph = await loadGraph(process.cwd());
2322
+ const nodePath = options.node.trim().replace(/^\.\//, "").replace(/\/$/, "");
2323
+ const relevantNodes = collectRelevantNodePaths(graph, nodePath);
2330
2324
  const validationResult = await validate(graph, "all");
2331
- const structuralErrors = validationResult.issues.filter(
2332
- (issue) => issue.severity === "error"
2325
+ const relevantErrors = validationResult.issues.filter(
2326
+ (issue) => issue.severity === "error" && // Global errors (no nodePath) always block — e.g., E012 invalid config
2327
+ (!issue.nodePath || relevantNodes.has(issue.nodePath))
2333
2328
  );
2334
- if (structuralErrors.length > 0) {
2335
- process.stderr.write(
2336
- `Error: build-context requires a structurally valid graph (${structuralErrors.length} errors found).
2337
- `
2338
- );
2329
+ if (relevantErrors.length > 0) {
2330
+ const totalErrors = validationResult.issues.filter((i) => i.severity === "error").length;
2331
+ const skippedErrors = totalErrors - relevantErrors.length;
2332
+ let msg = `Error: build-context blocked by ${relevantErrors.length} error(s) affecting this node's context.
2333
+ `;
2334
+ if (skippedErrors > 0) {
2335
+ msg += `(${skippedErrors} unrelated error(s) in other nodes ignored.)
2336
+ `;
2337
+ }
2338
+ for (const err of relevantErrors) {
2339
+ const loc = err.nodePath ? `${err.nodePath}: ` : "";
2340
+ msg += ` ${err.code ?? ""} ${loc}${err.message}
2341
+ `;
2342
+ }
2343
+ process.stderr.write(msg);
2339
2344
  process.exit(1);
2340
2345
  }
2341
- const nodePath = options.node.trim().replace(/\/$/, "");
2342
- const pkg = await buildContext(graph, nodePath);
2343
- const warningThreshold = graph.config.quality?.context_budget.warning ?? 5e3;
2344
- const errorThreshold = graph.config.quality?.context_budget.error ?? 1e4;
2345
- const budgetStatus = pkg.tokenCount >= errorThreshold ? "error" : pkg.tokenCount >= warningThreshold ? "warning" : "ok";
2346
- let output = formatContextMarkdown(pkg);
2346
+ const pkg2 = await buildContext(graph, nodePath);
2347
+ const warningThreshold = graph.config.quality?.context_budget.warning ?? 1e4;
2348
+ const errorThreshold = graph.config.quality?.context_budget.error ?? 2e4;
2349
+ const budgetStatus = pkg2.tokenCount >= errorThreshold ? "error" : pkg2.tokenCount >= warningThreshold ? "warning" : "ok";
2350
+ let output = formatContextText(pkg2);
2347
2351
  output += `Budget status: ${budgetStatus}
2348
2352
  `;
2349
2353
  process.stdout.write(output);
2350
2354
  if (budgetStatus === "error") {
2351
2355
  process.stderr.write(
2352
- `Error: context package exceeds error budget (${pkg.tokenCount} >= ${errorThreshold}).
2356
+ `Warning: context package exceeds error budget (${pkg2.tokenCount} >= ${errorThreshold}). Consider splitting the node.
2353
2357
  `
2354
2358
  );
2355
- process.exit(1);
2356
2359
  }
2357
2360
  } catch (error) {
2358
2361
  process.stderr.write(`Error: ${error.message}
@@ -2368,7 +2371,8 @@ function registerValidateCommand(program2) {
2368
2371
  program2.command("validate").description("Validate graph structural integrity and completeness signals").option("--scope <scope>", "Scope: all or node-path (default: all)", "all").action(async (options) => {
2369
2372
  try {
2370
2373
  const graph = await loadGraph(process.cwd(), { tolerateInvalidConfig: true });
2371
- const scope = (options.scope ?? "all").trim() || "all";
2374
+ const rawScope = (options.scope ?? "all").trim() || "all";
2375
+ const scope = rawScope === "all" ? "all" : rawScope.replace(/^\.\//, "").replace(/\/+$/, "");
2372
2376
  const result = await validate(graph, scope);
2373
2377
  process.stdout.write(`${result.nodesScanned} nodes scanned
2374
2378
 
@@ -2411,40 +2415,33 @@ import chalk2 from "chalk";
2411
2415
 
2412
2416
  // src/io/drift-state-store.ts
2413
2417
  import { readFile as readFile11, writeFile as writeFile3 } from "fs/promises";
2414
- import { parse as parseYaml7, stringify as stringifyYaml } from "yaml";
2415
2418
  import path10 from "path";
2419
+ import { parse as yamlParse } from "yaml";
2416
2420
  var DRIFT_STATE_FILE = ".drift-state";
2417
- function getCanonicalHash(entry) {
2418
- return typeof entry === "string" ? entry : entry.hash;
2419
- }
2420
- function getFileHashes(entry) {
2421
- return typeof entry === "object" ? entry.files : void 0;
2422
- }
2423
2421
  async function readDriftState(yggRoot) {
2424
- const filePath = path10.join(yggRoot, DRIFT_STATE_FILE);
2425
2422
  try {
2426
- const content = await readFile11(filePath, "utf-8");
2427
- const raw = parseYaml7(content);
2428
- if (raw && typeof raw === "object" && !Array.isArray(raw)) {
2429
- const result = {};
2430
- for (const [k, v] of Object.entries(raw)) {
2431
- if (typeof k === "string" && typeof v === "string") {
2432
- result[k] = v;
2433
- } else if (typeof k === "string" && typeof v === "object" && v !== null && "hash" in v) {
2434
- result[k] = v;
2435
- }
2423
+ const content = await readFile11(path10.join(yggRoot, DRIFT_STATE_FILE), "utf-8");
2424
+ let raw;
2425
+ try {
2426
+ raw = JSON.parse(content);
2427
+ } catch {
2428
+ raw = yamlParse(content);
2429
+ }
2430
+ if (!raw || typeof raw !== "object") return {};
2431
+ const state = {};
2432
+ for (const [key, value] of Object.entries(raw)) {
2433
+ if (typeof value === "object" && value !== null && "hash" in value) {
2434
+ state[key] = value;
2436
2435
  }
2437
- return result;
2438
2436
  }
2439
- return {};
2437
+ return state;
2440
2438
  } catch {
2441
2439
  return {};
2442
2440
  }
2443
2441
  }
2444
2442
  async function writeDriftState(yggRoot, state) {
2445
- const filePath = path10.join(yggRoot, DRIFT_STATE_FILE);
2446
- const content = stringifyYaml(state);
2447
- await writeFile3(filePath, content, "utf-8");
2443
+ const content = JSON.stringify(state);
2444
+ await writeFile3(path10.join(yggRoot, DRIFT_STATE_FILE), content, "utf-8");
2448
2445
  }
2449
2446
 
2450
2447
  // src/utils/hash.ts
@@ -2458,139 +2455,238 @@ async function hashFile(filePath) {
2458
2455
  const content = await readFile12(filePath);
2459
2456
  return createHash("sha256").update(content).digest("hex");
2460
2457
  }
2461
- async function hashPath(targetPath, options = {}) {
2462
- const projectRoot = options.projectRoot ? path11.resolve(options.projectRoot) : void 0;
2463
- const gitignoreMatcher = await loadGitignoreMatcher(projectRoot);
2464
- const targetStat = await stat3(targetPath);
2465
- if (targetStat.isFile()) {
2466
- if (isIgnoredPath(targetPath, projectRoot, gitignoreMatcher)) {
2467
- return hashString("");
2468
- }
2469
- return hashFile(targetPath);
2458
+ async function loadRootGitignoreStack(projectRoot) {
2459
+ if (!projectRoot) return [];
2460
+ try {
2461
+ const content = await readFile12(path11.join(projectRoot, ".gitignore"), "utf-8");
2462
+ const matcher = ignoreFactory();
2463
+ matcher.add(content);
2464
+ return [{ basePath: projectRoot, matcher }];
2465
+ } catch {
2466
+ return [];
2470
2467
  }
2471
- if (targetStat.isDirectory()) {
2472
- const fileHashes = await collectDirectoryFileHashes(targetPath, targetPath, {
2473
- projectRoot,
2474
- gitignoreMatcher
2475
- });
2476
- const digestInput = fileHashes.sort((a, b) => a.path.localeCompare(b.path)).map((entry) => `${entry.path}:${entry.hash}`).join("\n");
2477
- return hashString(digestInput);
2468
+ }
2469
+ function isIgnoredByStack(candidatePath, stack) {
2470
+ for (const { basePath, matcher } of stack) {
2471
+ const relativePath = path11.relative(basePath, candidatePath);
2472
+ if (relativePath === "" || relativePath.startsWith("..")) continue;
2473
+ if (matcher.ignores(relativePath) || matcher.ignores(relativePath + "/")) return true;
2478
2474
  }
2479
- throw new Error(`Unsupported mapping path type: ${targetPath}`);
2475
+ return false;
2480
2476
  }
2481
- async function collectDirectoryFileHashes(directoryPath, rootDirectoryPath, options) {
2482
- const entries = await readdir5(directoryPath, { withFileTypes: true });
2483
- const result = [];
2484
- for (const entry of entries) {
2485
- const absoluteChildPath = path11.join(directoryPath, entry.name);
2486
- if (isIgnoredPath(absoluteChildPath, options.projectRoot, options.gitignoreMatcher)) {
2487
- continue;
2488
- }
2489
- if (entry.isDirectory()) {
2490
- const nested = await collectDirectoryFileHashes(
2491
- absoluteChildPath,
2492
- rootDirectoryPath,
2493
- options
2494
- );
2495
- for (const nestedEntry of nested) {
2496
- result.push({
2497
- path: path11.relative(rootDirectoryPath, path11.join(absoluteChildPath, nestedEntry.path)),
2498
- hash: nestedEntry.hash
2477
+ function hashString(content) {
2478
+ return createHash("sha256").update(content).digest("hex");
2479
+ }
2480
+ async function hashTrackedFiles(projectRoot, trackedFiles, storedFileData, excludePrefixes) {
2481
+ const fileHashes = {};
2482
+ const fileMtimes = {};
2483
+ const gitignoreStack = await loadRootGitignoreStack(projectRoot);
2484
+ const allFiles = [];
2485
+ for (const tf of trackedFiles) {
2486
+ const absPath = path11.join(projectRoot, tf.path);
2487
+ try {
2488
+ const st = await stat3(absPath);
2489
+ if (st.isDirectory()) {
2490
+ const dirEntries = await collectDirectoryFilePaths(absPath, absPath, {
2491
+ projectRoot,
2492
+ gitignoreStack
2499
2493
  });
2494
+ for (const entry of dirEntries) {
2495
+ allFiles.push({
2496
+ relPath: path11.join(tf.path, entry.relPath).replace(/\\/g, "/"),
2497
+ absPath: entry.absPath,
2498
+ mtimeMs: entry.mtimeMs
2499
+ });
2500
+ }
2501
+ } else {
2502
+ allFiles.push({ relPath: tf.path, absPath, mtimeMs: st.mtimeMs });
2500
2503
  }
2504
+ } catch {
2501
2505
  continue;
2502
2506
  }
2503
- if (!entry.isFile()) {
2504
- continue;
2507
+ }
2508
+ const filtered = excludePrefixes?.length ? allFiles.filter((entry) => !excludePrefixes.some((prefix) => entry.relPath === prefix || entry.relPath.startsWith(prefix + "/"))) : allFiles;
2509
+ const dirty = [];
2510
+ for (const entry of filtered) {
2511
+ const storedMtime = storedFileData?.mtimes[entry.relPath];
2512
+ const storedHash = storedFileData?.hashes[entry.relPath];
2513
+ if (storedMtime !== void 0 && storedHash !== void 0 && entry.mtimeMs === storedMtime) {
2514
+ fileHashes[entry.relPath] = storedHash;
2515
+ } else {
2516
+ dirty.push(entry);
2505
2517
  }
2506
- result.push({
2507
- path: path11.relative(rootDirectoryPath, absoluteChildPath),
2508
- hash: await hashFile(absoluteChildPath)
2509
- });
2518
+ fileMtimes[entry.relPath] = entry.mtimeMs;
2510
2519
  }
2511
- return result;
2512
- }
2513
- async function loadGitignoreMatcher(projectRoot) {
2514
- if (!projectRoot) {
2515
- return void 0;
2520
+ const BATCH_SIZE = 256;
2521
+ for (let i = 0; i < dirty.length; i += BATCH_SIZE) {
2522
+ const batch = dirty.slice(i, i + BATCH_SIZE);
2523
+ const hashes = await Promise.all(batch.map((e) => hashFile(e.absPath)));
2524
+ for (let j = 0; j < batch.length; j++) {
2525
+ fileHashes[batch[j].relPath] = hashes[j];
2526
+ }
2516
2527
  }
2528
+ const sorted = Object.entries(fileHashes).sort(([a], [b]) => a.localeCompare(b));
2529
+ const digest = sorted.map(([p, h]) => `${p}:${h}`).join("\n");
2530
+ const canonicalHash = hashString(digest);
2531
+ return { canonicalHash, fileHashes, fileMtimes };
2532
+ }
2533
+ async function collectDirectoryFilePaths(directoryPath, rootDirectoryPath, options) {
2534
+ let stack = options.gitignoreStack ?? [];
2517
2535
  try {
2518
- const gitignorePath = path11.join(projectRoot, ".gitignore");
2519
- const gitignoreContent = await readFile12(gitignorePath, "utf-8");
2520
- const matcher = ignoreFactory();
2521
- matcher.add(gitignoreContent);
2522
- return matcher;
2536
+ const localContent = await readFile12(path11.join(directoryPath, ".gitignore"), "utf-8");
2537
+ const localMatcher = ignoreFactory();
2538
+ localMatcher.add(localContent);
2539
+ stack = [...stack, { basePath: directoryPath, matcher: localMatcher }];
2523
2540
  } catch {
2524
- return void 0;
2525
2541
  }
2542
+ const entries = await readdir5(directoryPath, { withFileTypes: true });
2543
+ const dirs = [];
2544
+ const files = [];
2545
+ for (const entry of entries) {
2546
+ const absoluteChildPath = path11.join(directoryPath, entry.name);
2547
+ if (isIgnoredByStack(absoluteChildPath, stack)) continue;
2548
+ if (entry.isDirectory()) dirs.push(absoluteChildPath);
2549
+ else if (entry.isFile()) files.push(absoluteChildPath);
2550
+ }
2551
+ const [dirResults, fileStats] = await Promise.all([
2552
+ Promise.all(dirs.map((d) => collectDirectoryFilePaths(d, rootDirectoryPath, {
2553
+ projectRoot: options.projectRoot,
2554
+ gitignoreStack: stack
2555
+ }))),
2556
+ Promise.all(files.map(async (f) => {
2557
+ const fileStat = await stat3(f);
2558
+ return {
2559
+ relPath: path11.relative(rootDirectoryPath, f),
2560
+ absPath: f,
2561
+ mtimeMs: fileStat.mtimeMs
2562
+ };
2563
+ }))
2564
+ ]);
2565
+ const result = [];
2566
+ for (const nested of dirResults) result.push(...nested);
2567
+ result.push(...fileStats);
2568
+ return result;
2526
2569
  }
2527
- function isIgnoredPath(candidatePath, projectRoot, matcher) {
2528
- if (!projectRoot || !matcher) {
2529
- return false;
2570
+
2571
+ // src/core/context-files.ts
2572
+ import path12 from "path";
2573
+ var STRUCTURAL_RELATION_TYPES2 = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements"]);
2574
+ function collectTrackedFiles(node, graph) {
2575
+ const seen = /* @__PURE__ */ new Set();
2576
+ const result = [];
2577
+ const projectRoot = path12.dirname(graph.rootPath);
2578
+ const yggPrefix = path12.relative(projectRoot, graph.rootPath);
2579
+ const yggPrefixNormalized = yggPrefix.split(path12.sep).join("/");
2580
+ const configArtifactKeys = new Set(Object.keys(graph.config.artifacts ?? {}));
2581
+ function addFile(filePath, category) {
2582
+ if (seen.has(filePath)) return;
2583
+ seen.add(filePath);
2584
+ result.push({ path: filePath, category });
2530
2585
  }
2531
- const relativePath = path11.relative(projectRoot, candidatePath);
2532
- if (relativePath === "" || relativePath.startsWith("..")) {
2533
- return false;
2586
+ function graphPath(...segments) {
2587
+ return [yggPrefixNormalized, ...segments].join("/");
2534
2588
  }
2535
- return matcher.ignores(relativePath) || matcher.ignores(relativePath + "/");
2536
- }
2537
- function hashString(content) {
2538
- return createHash("sha256").update(content).digest("hex");
2539
- }
2540
- async function perFileHashes(projectRoot, mapping) {
2541
- const root = path11.resolve(projectRoot);
2542
- const paths = mapping.paths ?? [];
2543
- if (paths.length === 0) return [];
2544
- const result = [];
2545
- const gitignoreMatcher = await loadGitignoreMatcher(root);
2546
- for (const p of paths) {
2547
- const absPath = path11.join(root, p);
2548
- const st = await stat3(absPath);
2549
- if (st.isFile()) {
2550
- result.push({ path: p, hash: await hashFile(absPath) });
2551
- } else if (st.isDirectory()) {
2552
- const hashes = await collectDirectoryFileHashes(absPath, absPath, {
2553
- projectRoot: root,
2554
- gitignoreMatcher
2555
- });
2556
- for (const h of hashes) {
2557
- result.push({
2558
- path: path11.join(p, h.path).split(path11.sep).join("/"),
2559
- hash: h.hash
2560
- });
2589
+ function addNodeFiles(n) {
2590
+ addFile(graphPath("model", n.path, "node.yaml"), "graph");
2591
+ for (const art of n.artifacts) {
2592
+ if (configArtifactKeys.has(art.filename)) {
2593
+ addFile(graphPath("model", n.path, art.filename), "graph");
2561
2594
  }
2562
2595
  }
2563
2596
  }
2564
- return result;
2565
- }
2566
- async function hashForMapping(projectRoot, mapping) {
2567
- const root = path11.resolve(projectRoot);
2568
- const paths = mapping.paths ?? [];
2569
- if (paths.length === 0) throw new Error("Invalid mapping for hash: no paths");
2570
- const pairs = [];
2571
- for (const p of paths) {
2572
- const absPath = path11.join(root, p);
2573
- const st = await stat3(absPath);
2574
- if (st.isFile()) {
2575
- pairs.push({ path: p, hash: await hashFile(absPath) });
2576
- } else if (st.isDirectory()) {
2577
- const dirHash = await hashPath(absPath, { projectRoot: root });
2578
- pairs.push({ path: p, hash: dirHash });
2597
+ addNodeFiles(node);
2598
+ const ancestors = collectAncestors(node);
2599
+ for (const ancestor of ancestors) {
2600
+ addNodeFiles(ancestor);
2601
+ }
2602
+ const allAspectIds = /* @__PURE__ */ new Set();
2603
+ for (const id of node.meta.aspects ?? []) {
2604
+ allAspectIds.add(id);
2605
+ }
2606
+ for (const ancestor of ancestors) {
2607
+ for (const id of ancestor.meta.aspects ?? []) {
2608
+ allAspectIds.add(id);
2579
2609
  }
2580
2610
  }
2581
- const digestInput = pairs.sort((a, b) => a.path.localeCompare(b.path)).map((e) => `${e.path}:${e.hash}`).join("\n");
2582
- return createHash("sha256").update(digestInput).digest("hex");
2611
+ const participatingFlows = collectParticipatingFlows2(graph, node, ancestors);
2612
+ for (const flow of participatingFlows) {
2613
+ for (const id of flow.aspects ?? []) {
2614
+ allAspectIds.add(id);
2615
+ }
2616
+ }
2617
+ const resolvedAspects = resolveAspects(allAspectIds, graph.aspects);
2618
+ for (const aspect of resolvedAspects) {
2619
+ addFile(graphPath("aspects", aspect.id, "aspect.yaml"), "graph");
2620
+ for (const art of aspect.artifacts) {
2621
+ addFile(graphPath("aspects", aspect.id, art.filename), "graph");
2622
+ }
2623
+ }
2624
+ for (const relation of node.meta.relations ?? []) {
2625
+ if (!STRUCTURAL_RELATION_TYPES2.has(relation.type)) continue;
2626
+ const target = graph.nodes.get(relation.target);
2627
+ if (!target) continue;
2628
+ const structuralFilenames = Object.entries(graph.config.artifacts ?? {}).filter(([, c]) => c.structural_context).map(([filename]) => filename);
2629
+ const structuralArts = structuralFilenames.filter(
2630
+ (filename) => target.artifacts.some((a) => a.filename === filename)
2631
+ );
2632
+ if (structuralArts.length > 0) {
2633
+ for (const filename of structuralArts) {
2634
+ addFile(graphPath("model", target.path, filename), "graph");
2635
+ }
2636
+ } else {
2637
+ for (const art of target.artifacts) {
2638
+ if (configArtifactKeys.has(art.filename)) {
2639
+ addFile(graphPath("model", target.path, art.filename), "graph");
2640
+ }
2641
+ }
2642
+ }
2643
+ }
2644
+ for (const flow of participatingFlows) {
2645
+ addFile(graphPath("flows", flow.path, "flow.yaml"), "graph");
2646
+ for (const art of flow.artifacts) {
2647
+ addFile(graphPath("flows", flow.path, art.filename), "graph");
2648
+ }
2649
+ }
2650
+ const mappingPaths = normalizeMappingPaths(node.meta.mapping);
2651
+ for (const p of mappingPaths) {
2652
+ addFile(p, "source");
2653
+ }
2654
+ return result;
2655
+ }
2656
+ function collectParticipatingFlows2(graph, node, ancestors) {
2657
+ const paths = /* @__PURE__ */ new Set([node.path, ...ancestors.map((a) => a.path)]);
2658
+ return graph.flows.filter((f) => f.nodes.some((n) => paths.has(n)));
2583
2659
  }
2584
2660
 
2585
2661
  // src/core/drift-detector.ts
2586
2662
  import { access } from "fs/promises";
2587
- import path12 from "path";
2663
+ import path13 from "path";
2664
+ function getChildMappingExclusions(graph, nodePath) {
2665
+ const node = graph.nodes.get(nodePath);
2666
+ if (!node) return [];
2667
+ const parentMappings = normalizeMappingPaths(node.meta.mapping);
2668
+ if (parentMappings.length === 0) return [];
2669
+ const exclusions = [];
2670
+ for (const [childPath, childNode] of graph.nodes) {
2671
+ if (childPath === nodePath) continue;
2672
+ if (!childPath.startsWith(nodePath + "/")) continue;
2673
+ const childMappings = normalizeMappingPaths(childNode.meta.mapping);
2674
+ for (const cm of childMappings) {
2675
+ for (const pm of parentMappings) {
2676
+ if (cm === pm || cm.startsWith(pm + "/")) {
2677
+ exclusions.push(cm);
2678
+ }
2679
+ }
2680
+ }
2681
+ }
2682
+ return exclusions;
2683
+ }
2588
2684
  async function detectDrift(graph, filterNodePath) {
2589
- const projectRoot = path12.dirname(graph.rootPath);
2685
+ const projectRoot = path13.dirname(graph.rootPath);
2590
2686
  const driftState = await readDriftState(graph.rootPath);
2591
2687
  const entries = [];
2592
2688
  for (const [nodePath, node] of graph.nodes) {
2593
- if (filterNodePath && nodePath !== filterNodePath) continue;
2689
+ if (filterNodePath && nodePath !== filterNodePath && !nodePath.startsWith(filterNodePath + "/")) continue;
2594
2690
  const mapping = node.meta.mapping;
2595
2691
  if (!mapping) continue;
2596
2692
  const mappingPaths = normalizeMappingPaths(mapping);
@@ -2600,67 +2696,82 @@ async function detectDrift(graph, filterNodePath) {
2600
2696
  const allMissing = await allPathsMissing(projectRoot, mappingPaths);
2601
2697
  entries.push({
2602
2698
  nodePath,
2603
- mappingPaths,
2604
- status: allMissing ? "unmaterialized" : "drift",
2699
+ status: allMissing ? "unmaterialized" : "source-drift",
2605
2700
  details: allMissing ? "No drift state recorded, files do not exist" : "No drift state recorded, files exist (run drift-sync after materialization)"
2606
2701
  });
2607
2702
  continue;
2608
2703
  }
2609
- const storedHash = getCanonicalHash(storedEntry);
2610
- let status = "ok";
2611
- let details = "";
2612
- try {
2613
- const currentHash = await hashForMapping(projectRoot, mapping);
2614
- if (currentHash !== storedHash) {
2615
- status = "drift";
2616
- const changedFiles = await diagnoseChangedFiles(
2617
- projectRoot,
2618
- mapping,
2619
- getFileHashes(storedEntry)
2620
- );
2621
- details = changedFiles.length > 0 ? `Changed files: ${changedFiles.join(", ")}` : "File(s) modified since last sync";
2704
+ const sourceFilesMissing = await allPathsMissing(projectRoot, mappingPaths);
2705
+ if (sourceFilesMissing) {
2706
+ entries.push({
2707
+ nodePath,
2708
+ status: "missing",
2709
+ details: "All source mapping paths are missing"
2710
+ });
2711
+ continue;
2712
+ }
2713
+ const trackedFiles = collectTrackedFiles(node, graph);
2714
+ const excludePrefixes = getChildMappingExclusions(graph, nodePath);
2715
+ const storedFileData = storedEntry.files ? { hashes: storedEntry.files, mtimes: storedEntry.mtimes ?? {} } : void 0;
2716
+ const { canonicalHash, fileHashes } = await hashTrackedFiles(projectRoot, trackedFiles, storedFileData, excludePrefixes);
2717
+ if (canonicalHash === storedEntry.hash) {
2718
+ entries.push({ nodePath, status: "ok" });
2719
+ continue;
2720
+ }
2721
+ const changedFiles = [];
2722
+ const storedFiles = storedEntry.files;
2723
+ for (const [filePath, hash] of Object.entries(fileHashes)) {
2724
+ const storedHash = storedFiles[filePath];
2725
+ if (!storedHash || storedHash !== hash) {
2726
+ changedFiles.push({
2727
+ filePath,
2728
+ category: categorizeFile(filePath, graph.rootPath, projectRoot)
2729
+ });
2622
2730
  }
2623
- } catch {
2624
- status = "missing";
2625
- details = "Mapped path(s) do not exist";
2626
2731
  }
2627
- entries.push({ nodePath, mappingPaths, status, details });
2732
+ for (const storedPath of Object.keys(storedFiles)) {
2733
+ if (!(storedPath in fileHashes)) {
2734
+ changedFiles.push({
2735
+ filePath: `${storedPath} (deleted)`,
2736
+ category: categorizeFile(storedPath, graph.rootPath, projectRoot)
2737
+ });
2738
+ }
2739
+ }
2740
+ const hasSourceChanges = changedFiles.some((f) => f.category === "source");
2741
+ const hasGraphChanges = changedFiles.some((f) => f.category === "graph");
2742
+ let status;
2743
+ if (hasSourceChanges && hasGraphChanges) {
2744
+ status = "full-drift";
2745
+ } else if (hasGraphChanges) {
2746
+ status = "graph-drift";
2747
+ } else if (hasSourceChanges) {
2748
+ status = "source-drift";
2749
+ } else {
2750
+ status = "source-drift";
2751
+ }
2752
+ const details = changedFiles.length > 0 ? `Changed files: ${changedFiles.map((f) => f.filePath).join(", ")}` : "File(s) modified since last sync";
2753
+ entries.push({ nodePath, status, details, changedFiles });
2628
2754
  }
2629
2755
  return {
2630
2756
  entries,
2631
2757
  totalChecked: entries.length,
2632
2758
  okCount: entries.filter((e) => e.status === "ok").length,
2633
- driftCount: entries.filter((e) => e.status === "drift").length,
2759
+ sourceDriftCount: entries.filter((e) => e.status === "source-drift").length,
2760
+ graphDriftCount: entries.filter((e) => e.status === "graph-drift").length,
2761
+ fullDriftCount: entries.filter((e) => e.status === "full-drift").length,
2634
2762
  missingCount: entries.filter((e) => e.status === "missing").length,
2635
2763
  unmaterializedCount: entries.filter((e) => e.status === "unmaterialized").length
2636
2764
  };
2637
2765
  }
2638
- async function diagnoseChangedFiles(projectRoot, mapping, storedFileHashes) {
2639
- try {
2640
- const currentHashes = await perFileHashes(projectRoot, mapping);
2641
- if (!storedFileHashes) {
2642
- return currentHashes.map((h) => h.path).sort();
2643
- }
2644
- const changed = [];
2645
- const storedPaths = new Set(Object.keys(storedFileHashes));
2646
- for (const { path: filePath, hash } of currentHashes) {
2647
- const stored = storedFileHashes[filePath];
2648
- if (!stored || stored !== hash) {
2649
- changed.push(filePath);
2650
- }
2651
- storedPaths.delete(filePath);
2652
- }
2653
- for (const removed of storedPaths) {
2654
- changed.push(`${removed} (deleted)`);
2655
- }
2656
- return changed.sort();
2657
- } catch {
2658
- return [];
2659
- }
2766
+ function categorizeFile(filePath, _rootPath, projectRoot) {
2767
+ const yggPrefix = path13.relative(projectRoot, _rootPath);
2768
+ const normalizedPrefix = yggPrefix.split(path13.sep).join("/");
2769
+ const normalizedFilePath = filePath.replace(/\\/g, "/");
2770
+ return normalizedFilePath.startsWith(normalizedPrefix) ? "graph" : "source";
2660
2771
  }
2661
2772
  async function allPathsMissing(projectRoot, mappingPaths) {
2662
2773
  for (const mp of mappingPaths) {
2663
- const absPath = path12.join(projectRoot, mp);
2774
+ const absPath = path13.join(projectRoot, mp);
2664
2775
  try {
2665
2776
  await access(absPath);
2666
2777
  return false;
@@ -2670,82 +2781,53 @@ async function allPathsMissing(projectRoot, mappingPaths) {
2670
2781
  return true;
2671
2782
  }
2672
2783
  async function syncDriftState(graph, nodePath) {
2673
- const projectRoot = path12.dirname(graph.rootPath);
2784
+ const projectRoot = path13.dirname(graph.rootPath);
2674
2785
  const node = graph.nodes.get(nodePath);
2675
2786
  if (!node) throw new Error(`Node not found: ${nodePath}`);
2676
- const mapping = node.meta.mapping;
2677
- if (!mapping) throw new Error(`Node has no mapping: ${nodePath}`);
2678
- const currentHash = await hashForMapping(projectRoot, mapping);
2679
- const driftState = await readDriftState(graph.rootPath);
2680
- const previousEntry = driftState[nodePath];
2681
- const previousHash = previousEntry ? getCanonicalHash(previousEntry) : void 0;
2682
- const fileHashes = await perFileHashes(projectRoot, mapping);
2683
- const files = {};
2684
- for (const fh of fileHashes) {
2685
- files[fh.path] = fh.hash;
2787
+ if (!node.meta.mapping) throw new Error(`Node has no mapping: ${nodePath}`);
2788
+ const trackedFiles = collectTrackedFiles(node, graph);
2789
+ const excludePrefixes = getChildMappingExclusions(graph, nodePath);
2790
+ const existingState = await readDriftState(graph.rootPath);
2791
+ const existingEntry = existingState[nodePath];
2792
+ const storedFileData = existingEntry?.files ? { hashes: existingEntry.files, mtimes: existingEntry.mtimes ?? {} } : void 0;
2793
+ const { canonicalHash, fileHashes, fileMtimes } = await hashTrackedFiles(projectRoot, trackedFiles, storedFileData, excludePrefixes);
2794
+ const previousHash = existingEntry?.hash;
2795
+ existingState[nodePath] = { hash: canonicalHash, files: fileHashes, mtimes: fileMtimes };
2796
+ for (const key of Object.keys(existingState)) {
2797
+ if (!graph.nodes.has(key)) {
2798
+ delete existingState[key];
2799
+ }
2686
2800
  }
2687
- const newEntry = { hash: currentHash, files };
2688
- driftState[nodePath] = newEntry;
2689
- await writeDriftState(graph.rootPath, driftState);
2690
- return { previousHash, currentHash };
2801
+ await writeDriftState(graph.rootPath, existingState);
2802
+ return { previousHash, currentHash: canonicalHash };
2691
2803
  }
2692
2804
 
2693
2805
  // src/cli/drift.ts
2694
2806
  function registerDriftCommand(program2) {
2695
- program2.command("drift").description("Detect divergence between graph and code").option("--scope <scope>", "Scope: all or node-path (default: all)", "all").action(async (options) => {
2807
+ program2.command("drift").description("Detect divergences between graph and mapped files").option("--scope <scope>", 'Scope: "all" or node path', "all").option("--drifted-only", "Show only nodes with drift (hide ok entries)").option("--limit <n>", "Maximum number of entries to show per section", parseInt).action(async (opts) => {
2696
2808
  try {
2697
2809
  const graph = await loadGraph(process.cwd());
2698
- const scope = (options.scope ?? "all").trim() || "all";
2699
- if (scope && scope !== "all" && !graph.nodes.has(scope)) {
2700
- process.stderr.write(`Error: Node not found: ${scope}
2810
+ const rawScope = (opts.scope ?? "all").trim() || "all";
2811
+ const scope = rawScope === "all" ? "all" : rawScope.replace(/^\.\//, "").replace(/\/+$/, "");
2812
+ if (scope !== "all") {
2813
+ const node = graph.nodes.get(scope);
2814
+ if (!node) {
2815
+ process.stderr.write(`Error: Node not found: ${scope}
2701
2816
  `);
2702
- process.exit(1);
2703
- }
2704
- if (scope && scope !== "all") {
2705
- const scopedNode = graph.nodes.get(scope);
2706
- if (!scopedNode.meta.mapping) {
2707
- process.stderr.write(
2708
- `Error: Node has no mapping (does not participate in drift detection): ${options.scope}
2709
- `
2710
- );
2711
- process.exit(1);
2712
- }
2713
- }
2714
- const scopeNode = scope === "all" ? void 0 : scope;
2715
- const report = await detectDrift(graph, scopeNode);
2716
- process.stdout.write("Drift:\n");
2717
- for (const entry of report.entries) {
2718
- const paths = entry.mappingPaths.join(", ");
2719
- switch (entry.status) {
2720
- case "ok":
2721
- process.stdout.write(chalk2.green(` ok ${entry.nodePath} -> ${paths}
2722
- `));
2723
- break;
2724
- case "drift":
2725
- process.stdout.write(chalk2.red(` drift ${entry.nodePath} -> ${paths}
2726
- `));
2727
- if (entry.details) process.stdout.write(` ${entry.details}
2817
+ process.exit(1);
2818
+ }
2819
+ const hasAnyMapping = node.meta.mapping || [...graph.nodes.entries()].some(([p, n]) => p.startsWith(scope + "/") && n.meta.mapping);
2820
+ if (!hasAnyMapping) {
2821
+ process.stderr.write(`Error: Node has no mapping: ${scope}
2728
2822
  `);
2729
- break;
2730
- case "missing":
2731
- process.stdout.write(chalk2.yellow(` missing ${entry.nodePath} -> ${paths}
2732
- `));
2733
- break;
2734
- case "unmaterialized":
2735
- process.stdout.write(chalk2.dim(` unmat. ${entry.nodePath} -> ${paths}
2736
- `));
2737
- break;
2823
+ process.exit(1);
2738
2824
  }
2739
2825
  }
2740
- process.stdout.write(
2741
- `
2742
- Summary: ${report.driftCount} drift, ${report.missingCount} missing, ${report.unmaterializedCount} unmaterialized, ${report.okCount} ok
2743
- `
2744
- );
2745
- if (report.driftCount > 0 || report.missingCount > 0 || report.unmaterializedCount > 0) {
2746
- process.exit(1);
2747
- }
2748
- process.exit(0);
2826
+ const scopeNode = scope === "all" ? void 0 : scope;
2827
+ const report = await detectDrift(graph, scopeNode);
2828
+ printReport(report, opts.driftedOnly ?? false, opts.limit);
2829
+ const hasIssues = report.sourceDriftCount > 0 || report.graphDriftCount > 0 || report.fullDriftCount > 0 || report.missingCount > 0 || report.unmaterializedCount > 0;
2830
+ process.exit(hasIssues ? 1 : 0);
2749
2831
  } catch (error) {
2750
2832
  process.stderr.write(`Error: ${error.message}
2751
2833
  `);
@@ -2753,21 +2835,151 @@ Summary: ${report.driftCount} drift, ${report.missingCount} missing, ${report.un
2753
2835
  }
2754
2836
  });
2755
2837
  }
2838
+ function printReport(report, driftedOnly, limit) {
2839
+ const sourceEntries = classifyForSection(report.entries, "source", driftedOnly);
2840
+ const graphEntries = classifyForSection(report.entries, "graph", driftedOnly);
2841
+ const sourceShown = limit !== void 0 ? sourceEntries.slice(0, limit) : sourceEntries;
2842
+ const graphShown = limit !== void 0 ? graphEntries.slice(0, limit) : graphEntries;
2843
+ process.stdout.write("Source drift:\n");
2844
+ printSectionEntries(sourceShown, "source");
2845
+ if (limit !== void 0 && sourceEntries.length > limit) {
2846
+ process.stdout.write(chalk2.dim(` ... ${sourceEntries.length - limit} more (${sourceEntries.length} total)
2847
+ `));
2848
+ }
2849
+ process.stdout.write("\nGraph drift:\n");
2850
+ printSectionEntries(graphShown, "graph");
2851
+ if (limit !== void 0 && graphEntries.length > limit) {
2852
+ process.stdout.write(chalk2.dim(` ... ${graphEntries.length - limit} more (${graphEntries.length} total)
2853
+ `));
2854
+ }
2855
+ const parts = [
2856
+ `${report.sourceDriftCount} source-drift`,
2857
+ `${report.graphDriftCount} graph-drift`,
2858
+ `${report.fullDriftCount} full-drift`,
2859
+ `${report.missingCount} missing`,
2860
+ `${report.unmaterializedCount} unmaterialized`
2861
+ ];
2862
+ let summary = `
2863
+ Summary: ${parts.join(", ")}`;
2864
+ if (driftedOnly && report.okCount > 0) {
2865
+ summary += ` (${report.okCount} ok hidden)`;
2866
+ } else {
2867
+ summary += `, ${report.okCount} ok`;
2868
+ }
2869
+ process.stdout.write(summary + "\n");
2870
+ }
2871
+ function classifyForSection(entries, section, driftedOnly) {
2872
+ return entries.filter((entry) => {
2873
+ if (section === "source") {
2874
+ if (entry.status === "graph-drift") return false;
2875
+ if (entry.status === "ok" && driftedOnly) return false;
2876
+ return true;
2877
+ } else {
2878
+ if (entry.status === "source-drift" || entry.status === "missing" || entry.status === "unmaterialized")
2879
+ return false;
2880
+ if (entry.status === "ok" && driftedOnly) return false;
2881
+ return true;
2882
+ }
2883
+ });
2884
+ }
2885
+ function printSectionEntries(entries, section) {
2886
+ if (entries.length === 0) {
2887
+ process.stdout.write(chalk2.dim(" (none)\n"));
2888
+ return;
2889
+ }
2890
+ for (const entry of entries) {
2891
+ printEntryLine(entry);
2892
+ printChangedFiles(entry, section);
2893
+ }
2894
+ }
2895
+ function printEntryLine(entry) {
2896
+ const pad = 13;
2897
+ switch (entry.status) {
2898
+ case "ok":
2899
+ process.stdout.write(chalk2.green(` ${"[ok]".padEnd(pad)}${entry.nodePath}
2900
+ `));
2901
+ break;
2902
+ case "source-drift":
2903
+ process.stdout.write(chalk2.red(` ${"[drift]".padEnd(pad)}${entry.nodePath}
2904
+ `));
2905
+ break;
2906
+ case "graph-drift":
2907
+ process.stdout.write(chalk2.magenta(` ${"[drift]".padEnd(pad)}${entry.nodePath}
2908
+ `));
2909
+ break;
2910
+ case "full-drift":
2911
+ process.stdout.write(chalk2.red(` ${"[drift]".padEnd(pad)}${entry.nodePath}
2912
+ `));
2913
+ break;
2914
+ case "missing":
2915
+ process.stdout.write(chalk2.yellow(` ${"[missing]".padEnd(pad)}${entry.nodePath}
2916
+ `));
2917
+ break;
2918
+ case "unmaterialized":
2919
+ process.stdout.write(chalk2.dim(` ${"[unmat.]".padEnd(pad)}${entry.nodePath}
2920
+ `));
2921
+ break;
2922
+ }
2923
+ }
2924
+ function printChangedFiles(entry, section) {
2925
+ if (!entry.changedFiles || entry.changedFiles.length === 0) return;
2926
+ const indent = " ".repeat(15);
2927
+ const relevantFiles = entry.changedFiles.filter((f) => {
2928
+ if (section === "source") return f.category === "source";
2929
+ return f.category === "graph";
2930
+ });
2931
+ for (const file of relevantFiles) {
2932
+ process.stdout.write(chalk2.dim(`${indent}${file.filePath} (changed)
2933
+ `));
2934
+ }
2935
+ }
2756
2936
 
2757
2937
  // src/cli/drift-sync.ts
2758
2938
  import chalk3 from "chalk";
2759
2939
  function registerDriftSyncCommand(program2) {
2760
- program2.command("drift-sync").description("Record current file hash after resolving drift").requiredOption("--node <path>", "Node path to sync").action(async (options) => {
2940
+ program2.command("drift-sync").description("Record current file hash after resolving drift").option("--node <path>", "Node path to sync").option("--recursive", "Also sync all descendant nodes").option("--all", "Sync all nodes with mappings").action(async (options) => {
2761
2941
  try {
2942
+ if (!options.node && !options.all) {
2943
+ process.stderr.write("Error: either '--node <path>' or '--all' is required\n");
2944
+ process.exit(1);
2945
+ }
2762
2946
  const graph = await loadGraph(process.cwd());
2763
- const nodePath = options.node.trim().replace(/\/$/, "");
2764
- const { previousHash, currentHash } = await syncDriftState(graph, nodePath);
2765
- process.stdout.write(chalk3.green(`Synchronized: ${nodePath}
2947
+ let nodesToSync;
2948
+ if (options.all) {
2949
+ nodesToSync = [...graph.nodes.entries()].filter(([, n]) => normalizeMappingPaths(n.meta.mapping).length > 0).map(([p]) => p).sort();
2950
+ } else {
2951
+ const nodePath = options.node.trim().replace(/^\.\//, "").replace(/\/+$/, "");
2952
+ if (!graph.nodes.has(nodePath)) {
2953
+ await syncDriftState(graph, nodePath);
2954
+ return;
2955
+ }
2956
+ nodesToSync = [nodePath];
2957
+ if (options.recursive) {
2958
+ const prefix = nodePath + "/";
2959
+ for (const [p] of graph.nodes) {
2960
+ if (p.startsWith(prefix)) {
2961
+ nodesToSync.push(p);
2962
+ }
2963
+ }
2964
+ nodesToSync.sort();
2965
+ }
2966
+ }
2967
+ for (const np of nodesToSync) {
2968
+ const node = graph.nodes.get(np);
2969
+ if (normalizeMappingPaths(node.meta.mapping).length === 0) {
2970
+ if (!options.all && !options.recursive && np === options.node) {
2971
+ await syncDriftState(graph, np);
2972
+ }
2973
+ continue;
2974
+ }
2975
+ const { previousHash, currentHash } = await syncDriftState(graph, np);
2976
+ process.stdout.write(chalk3.green(`Synchronized: ${np}
2766
2977
  `));
2767
- process.stdout.write(
2768
- ` Hash: ${previousHash ? previousHash.slice(0, 8) : "none"} -> ${currentHash.slice(0, 8)}
2978
+ process.stdout.write(
2979
+ ` Hash: ${previousHash ? previousHash.slice(0, 8) : "none"} -> ${currentHash.slice(0, 8)}
2769
2980
  `
2770
- );
2981
+ );
2982
+ }
2771
2983
  } catch (error) {
2772
2984
  process.stderr.write(`Error: ${error.message}
2773
2985
  `);
@@ -2790,20 +3002,40 @@ function registerStatusCommand(program2) {
2790
3002
  let structuralRelations = 0;
2791
3003
  let eventRelations = 0;
2792
3004
  const structuralTypes = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements"]);
3005
+ let maxRelCount = 0;
3006
+ let maxRelNode = "";
2793
3007
  for (const node of graph.nodes.values()) {
3008
+ const relCount = (node.meta.relations ?? []).length;
3009
+ if (relCount > maxRelCount) {
3010
+ maxRelCount = relCount;
3011
+ maxRelNode = node.path;
3012
+ }
2794
3013
  for (const rel of node.meta.relations ?? []) {
2795
3014
  if (structuralTypes.has(rel.type)) structuralRelations += 1;
2796
3015
  else eventRelations += 1;
2797
3016
  }
2798
3017
  }
2799
3018
  const flowCount = graph.flows.length;
2800
- const knowledgeCount = graph.knowledge.length;
2801
3019
  const drift = await detectDrift(graph);
2802
3020
  const validation = await validate(graph, "all");
2803
3021
  const errorCount = validation.issues.filter((issue) => issue.severity === "error").length;
2804
3022
  const warningCount = validation.issues.filter(
2805
3023
  (issue) => issue.severity === "warning"
2806
3024
  ).length;
3025
+ const configuredArtifactTypes = Object.keys(graph.config.artifacts ?? {});
3026
+ const totalSlots = graph.nodes.size * configuredArtifactTypes.length;
3027
+ let filledSlots = 0;
3028
+ let mappedNodeCount = 0;
3029
+ for (const node of graph.nodes.values()) {
3030
+ const allowed = new Set(configuredArtifactTypes);
3031
+ filledSlots += node.artifacts.filter((a) => allowed.has(a.filename)).length;
3032
+ if (normalizeMappingPaths(node.meta.mapping).length > 0) mappedNodeCount++;
3033
+ }
3034
+ let aspectCoveredNodes = 0;
3035
+ for (const node of graph.nodes.values()) {
3036
+ const effective = collectEffectiveAspectIds(graph, node.path);
3037
+ if (effective.size > 0) aspectCoveredNodes++;
3038
+ }
2807
3039
  process.stdout.write(`Graph: ${graph.config.name}
2808
3040
  `);
2809
3041
  const pluralize = (word, count) => count === 1 ? word : word.endsWith("y") ? word.slice(0, -1) + "ies" : word + "s";
@@ -2817,15 +3049,37 @@ function registerStatusCommand(program2) {
2817
3049
  `
2818
3050
  );
2819
3051
  process.stdout.write(
2820
- `Aspects: ${graph.aspects.length} Flows: ${flowCount} Knowledge: ${knowledgeCount}
3052
+ `Aspects: ${graph.aspects.length} Flows: ${flowCount}
2821
3053
  `
2822
3054
  );
2823
3055
  process.stdout.write(
2824
- `Drift: ${drift.driftCount} drift, ${drift.missingCount} missing, ${drift.unmaterializedCount} unmaterialized, ${drift.okCount} ok
3056
+ `Drift: ${drift.sourceDriftCount} source-drift, ${drift.graphDriftCount} graph-drift, ${drift.fullDriftCount} full-drift, ${drift.missingCount} missing, ${drift.unmaterializedCount} unmaterialized, ${drift.okCount} ok
2825
3057
  `
2826
3058
  );
2827
3059
  process.stdout.write(`Validation: ${errorCount} errors, ${warningCount} warnings
2828
3060
  `);
3061
+ const fillPct = totalSlots > 0 ? Math.round(filledSlots / totalSlots * 100) : 0;
3062
+ const totalRelations = structuralRelations + eventRelations;
3063
+ const avgRel = graph.nodes.size > 0 ? (totalRelations / graph.nodes.size).toFixed(1) : "0";
3064
+ process.stdout.write(`
3065
+ Quality:
3066
+ `);
3067
+ process.stdout.write(
3068
+ ` Artifacts: ${filledSlots}/${totalSlots} slots filled (${fillPct}%) \u2014 ${configuredArtifactTypes.length} types \xD7 ${graph.nodes.size} nodes
3069
+ `
3070
+ );
3071
+ process.stdout.write(
3072
+ ` Relations: avg ${avgRel}/node, max ${maxRelCount}${maxRelNode ? ` (${maxRelNode})` : ""}
3073
+ `
3074
+ );
3075
+ process.stdout.write(
3076
+ ` Mapping: ${mappedNodeCount}/${graph.nodes.size} nodes mapped to source
3077
+ `
3078
+ );
3079
+ process.stdout.write(
3080
+ ` Aspects: ${aspectCoveredNodes}/${graph.nodes.size} nodes have aspect coverage
3081
+ `
3082
+ );
2829
3083
  } catch (error) {
2830
3084
  process.stderr.write(`Error: ${error.message}
2831
3085
  `);
@@ -2842,10 +3096,10 @@ function registerTreeCommand(program2) {
2842
3096
  let roots;
2843
3097
  let showProjectName;
2844
3098
  if (options.root?.trim()) {
2845
- const path16 = options.root.trim().replace(/\/$/, "");
2846
- const node = graph.nodes.get(path16);
3099
+ const path18 = options.root.trim().replace(/\/$/, "");
3100
+ const node = graph.nodes.get(path18);
2847
3101
  if (!node) {
2848
- process.stderr.write(`Error: path '${path16}' not found
3102
+ process.stderr.write(`Error: path '${path18}' not found
2849
3103
  `);
2850
3104
  process.exit(1);
2851
3105
  }
@@ -2873,7 +3127,7 @@ function printNode(node, prefix, isLast, depth, maxDepth) {
2873
3127
  const connector = isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
2874
3128
  const name = node.path.split("/").pop() ?? node.path;
2875
3129
  const type = `[${node.meta.type}]`;
2876
- const tags = node.meta.tags?.length ? ` tags:${node.meta.tags.join(",")}` : "";
3130
+ const tags = node.meta.aspects?.length ? ` aspects:${node.meta.aspects.join(",")}` : "";
2877
3131
  const blackbox = node.meta.blackbox ? " \u25A0 blackbox" : "";
2878
3132
  const relationCount = node.meta.relations?.length ?? 0;
2879
3133
  process.stdout.write(
@@ -2889,6 +3143,8 @@ function printNode(node, prefix, isLast, depth, maxDepth) {
2889
3143
  }
2890
3144
 
2891
3145
  // src/cli/owner.ts
3146
+ import path14 from "path";
3147
+ import { access as access2 } from "fs/promises";
2892
3148
  function normalizeForMatch(inputPath) {
2893
3149
  return inputPath.replace(/\\/g, "/").replace(/\/+$/, "");
2894
3150
  }
@@ -2913,12 +3169,28 @@ function findOwner(graph, projectRoot, rawPath) {
2913
3169
  function registerOwnerCommand(program2) {
2914
3170
  program2.command("owner").description("Find which graph node owns a source file").requiredOption("--file <path>", "File path (relative to repository root)").action(async (options) => {
2915
3171
  try {
2916
- const projectRoot = process.cwd();
2917
- const graph = await loadGraph(projectRoot);
2918
- const result = findOwner(graph, projectRoot, options.file);
3172
+ const cwd = process.cwd();
3173
+ const graph = await loadGraph(cwd);
3174
+ const repoRoot = projectRootFromGraph(graph.rootPath);
3175
+ const rawPath = options.file.trim();
3176
+ const absolute = path14.resolve(cwd, rawPath);
3177
+ const repoRelative = path14.relative(repoRoot, absolute).split(path14.sep).join("/");
3178
+ const result = findOwner(graph, repoRoot, repoRelative);
2919
3179
  if (!result.nodePath) {
2920
- process.stdout.write(`${result.file} -> no graph coverage
3180
+ const absPath = path14.resolve(repoRoot, result.file);
3181
+ let exists = true;
3182
+ try {
3183
+ await access2(absPath);
3184
+ } catch {
3185
+ exists = false;
3186
+ }
3187
+ if (exists) {
3188
+ process.stdout.write(`${result.file} -> no graph coverage
2921
3189
  `);
3190
+ } else {
3191
+ process.stdout.write(`${result.file} -> no graph coverage (file not found)
3192
+ `);
3193
+ }
2922
3194
  } else {
2923
3195
  process.stdout.write(`${result.file} -> ${result.nodePath}
2924
3196
  `);
@@ -2932,13 +3204,13 @@ function registerOwnerCommand(program2) {
2932
3204
  }
2933
3205
 
2934
3206
  // src/core/dependency-resolver.ts
2935
- import { execSync as execSync2 } from "child_process";
2936
- import path13 from "path";
2937
- var STRUCTURAL_RELATION_TYPES2 = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements"]);
3207
+ import { execSync } from "child_process";
3208
+ import path15 from "path";
3209
+ var STRUCTURAL_RELATION_TYPES3 = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements"]);
2938
3210
  var EVENT_RELATION_TYPES2 = /* @__PURE__ */ new Set(["emits", "listens"]);
2939
3211
  function filterRelationType(relType, filter) {
2940
3212
  if (filter === "all") return true;
2941
- if (filter === "structural") return STRUCTURAL_RELATION_TYPES2.has(relType);
3213
+ if (filter === "structural") return STRUCTURAL_RELATION_TYPES3.has(relType);
2942
3214
  if (filter === "event") return EVENT_RELATION_TYPES2.has(relType);
2943
3215
  return false;
2944
3216
  }
@@ -2993,7 +3265,7 @@ function registerDepsCommand(program2) {
2993
3265
  try {
2994
3266
  const graph = await loadGraph(process.cwd());
2995
3267
  const typeFilter = options.type === "structural" || options.type === "event" || options.type === "all" ? options.type : "all";
2996
- const nodePath = options.node.trim().replace(/\/$/, "");
3268
+ const nodePath = options.node.trim().replace(/^\.\//, "").replace(/\/+$/, "");
2997
3269
  const text = formatDependencyTree(graph, nodePath, {
2998
3270
  depth: options.depth,
2999
3271
  relationType: typeFilter
@@ -3010,24 +3282,24 @@ function registerDepsCommand(program2) {
3010
3282
  // src/core/graph-from-git.ts
3011
3283
  import { mkdtemp, rm } from "fs/promises";
3012
3284
  import { tmpdir } from "os";
3013
- import path14 from "path";
3014
- import { execSync as execSync3 } from "child_process";
3285
+ import path16 from "path";
3286
+ import { execSync as execSync2 } from "child_process";
3015
3287
  async function loadGraphFromRef(projectRoot, ref = "HEAD") {
3016
3288
  const yggPath = ".yggdrasil";
3017
3289
  let tmpDir = null;
3018
3290
  try {
3019
- execSync3(`git rev-parse ${ref}`, { cwd: projectRoot, stdio: "pipe" });
3291
+ execSync2(`git rev-parse ${ref}`, { cwd: projectRoot, stdio: "pipe" });
3020
3292
  } catch {
3021
3293
  return null;
3022
3294
  }
3023
3295
  try {
3024
- tmpDir = await mkdtemp(path14.join(tmpdir(), "ygg-git-"));
3025
- const archivePath = path14.join(tmpDir, "archive.tar");
3026
- execSync3(`git archive ${ref} ${yggPath} -o "${archivePath}"`, {
3296
+ tmpDir = await mkdtemp(path16.join(tmpdir(), "ygg-git-"));
3297
+ const archivePath = path16.join(tmpDir, "archive.tar");
3298
+ execSync2(`git archive ${ref} ${yggPath} -o "${archivePath}"`, {
3027
3299
  cwd: projectRoot,
3028
3300
  stdio: "pipe"
3029
3301
  });
3030
- execSync3(`tar -xf "${archivePath}" -C "${tmpDir}"`, { stdio: "pipe" });
3302
+ execSync2(`tar -xf "${archivePath}" -C "${tmpDir}"`, { stdio: "pipe" });
3031
3303
  const graph = await loadGraph(tmpDir);
3032
3304
  return graph;
3033
3305
  } catch {
@@ -3069,14 +3341,14 @@ function collectReverseDependents(graph, targetNode) {
3069
3341
  }
3070
3342
  return {
3071
3343
  direct,
3072
- transitive: [...seen].sort(),
3344
+ allDependents: [...seen].sort(),
3073
3345
  reverse,
3074
3346
  relationFrom
3075
3347
  };
3076
3348
  }
3077
- function buildTransitiveChains(targetNode, direct, transitive, reverse) {
3349
+ function buildTransitiveChains(targetNode, direct, allDependents, reverse) {
3078
3350
  const directSet = new Set(direct);
3079
- const transitiveOnly = transitive.filter((t) => !directSet.has(t));
3351
+ const transitiveOnly = allDependents.filter((t) => !directSet.has(t));
3080
3352
  if (transitiveOnly.length === 0) return [];
3081
3353
  const parent = /* @__PURE__ */ new Map();
3082
3354
  const queue = [targetNode];
@@ -3092,164 +3364,391 @@ function buildTransitiveChains(targetNode, direct, transitive, reverse) {
3092
3364
  }
3093
3365
  const chains = [];
3094
3366
  for (const node of transitiveOnly) {
3095
- const path16 = [];
3367
+ const path18 = [];
3096
3368
  let current = node;
3097
3369
  while (current) {
3098
- path16.unshift(current);
3370
+ path18.unshift(current);
3099
3371
  current = parent.get(current);
3100
3372
  }
3101
- if (path16.length >= 2) {
3102
- chains.push(path16.map((p) => `<- ${p}`).join(" "));
3373
+ if (path18.length >= 3) {
3374
+ chains.push(path18.slice(1).map((p) => `<- ${p}`).join(" "));
3103
3375
  }
3104
3376
  }
3105
3377
  return chains.sort();
3106
3378
  }
3107
- function registerImpactCommand(program2) {
3108
- program2.command("impact").description("Show reverse dependency impact for a node").requiredOption("--node <path>", "Node path relative to .yggdrasil/model/").option("--simulate", "Simulate context package impact (compare HEAD vs current)").action(async (options) => {
3379
+ function collectDescendants(graph, nodePath) {
3380
+ const node = graph.nodes.get(nodePath);
3381
+ if (!node) return [];
3382
+ const result = [];
3383
+ const stack = [...node.children];
3384
+ while (stack.length > 0) {
3385
+ const child = stack.pop();
3386
+ result.push(child.path);
3387
+ stack.push(...child.children);
3388
+ }
3389
+ return result.sort();
3390
+ }
3391
+ async function runSimulation(graph, nodePaths, targetNodePath) {
3392
+ const budget = graph.config.quality?.context_budget ?? { warning: 1e4, error: 2e4 };
3393
+ process.stdout.write("\nChanges in context packages:\n\n");
3394
+ const baselineGraph = await loadGraphFromRef(process.cwd(), "HEAD");
3395
+ const driftReport = await detectDrift(graph);
3396
+ const driftByNode = new Map(driftReport.entries.map((e) => [e.nodePath, e]));
3397
+ for (const dep of nodePaths) {
3109
3398
  try {
3110
- const graph = await loadGraph(process.cwd());
3111
- const nodePath = options.node.trim().replace(/\/$/, "");
3112
- if (!graph.nodes.has(nodePath)) {
3113
- process.stderr.write(`Node not found: ${nodePath}
3114
- `);
3115
- process.exit(1);
3399
+ const pkg2 = await buildContext(graph, dep);
3400
+ const status = pkg2.tokenCount >= budget.error ? "error" : pkg2.tokenCount >= budget.warning ? "warning" : "ok";
3401
+ let baselineTokens = null;
3402
+ if (baselineGraph?.nodes.has(dep)) {
3403
+ try {
3404
+ const baselinePkg = await buildContext(baselineGraph, dep);
3405
+ baselineTokens = baselinePkg.tokenCount;
3406
+ } catch {
3407
+ }
3116
3408
  }
3117
- const { direct, transitive, reverse, relationFrom } = collectReverseDependents(
3118
- graph,
3119
- nodePath
3409
+ const hasDepOnTarget = targetNodePath && graph.nodes.get(dep)?.meta.relations?.some(
3410
+ (r) => r.target === targetNodePath && STRUCTURAL_TYPES.has(r.type)
3120
3411
  );
3121
- const chains = buildTransitiveChains(nodePath, direct, transitive, reverse);
3122
- const flows = [];
3123
- for (const flow of graph.flows) {
3124
- if (flow.nodes.includes(nodePath)) {
3125
- flows.push(flow.name);
3412
+ const changedLine = hasDepOnTarget ? ` + Changed dependency interface: ${targetNodePath}
3413
+ ` : "";
3414
+ const budgetLine = baselineTokens !== null ? ` Budget: ${baselineTokens} -> ${pkg2.tokenCount} tokens (${status})
3415
+ ` : ` Budget: ${pkg2.tokenCount} tokens (${status})
3416
+ `;
3417
+ const driftEntry = driftByNode.get(dep);
3418
+ const driftLine = driftEntry && driftEntry.status !== "ok" ? ` Mapped files (on-disk): ${driftEntry.status}${driftEntry.details ? ` (${driftEntry.details})` : ""}
3419
+ ` : driftEntry ? ` Mapped files (on-disk): ok
3420
+ ` : "";
3421
+ process.stdout.write(`${dep}:
3422
+ ${changedLine}${budgetLine}${driftLine}
3423
+ `);
3424
+ } catch {
3425
+ process.stdout.write(`${dep}:
3426
+ failed to build context
3427
+
3428
+ `);
3429
+ }
3430
+ }
3431
+ }
3432
+ async function handleAspectImpact(graph, aspectId, simulate) {
3433
+ const aspect = graph.aspects.find((a) => a.id === aspectId);
3434
+ if (!aspect) {
3435
+ process.stderr.write(`Aspect not found: ${aspectId}
3436
+ `);
3437
+ process.exit(1);
3438
+ }
3439
+ const affected = [];
3440
+ for (const [nodePath] of graph.nodes) {
3441
+ const effective = collectEffectiveAspectIds(graph, nodePath);
3442
+ if (effective.has(aspectId)) {
3443
+ const node = graph.nodes.get(nodePath);
3444
+ const ownAspects = new Set(node.meta.aspects ?? []);
3445
+ if (ownAspects.has(aspectId)) {
3446
+ affected.push({ path: nodePath, source: "own" });
3447
+ } else {
3448
+ let fromHierarchy = false;
3449
+ let anc = node.parent;
3450
+ while (anc) {
3451
+ if ((anc.meta.aspects ?? []).includes(aspectId)) {
3452
+ fromHierarchy = true;
3453
+ break;
3454
+ }
3455
+ anc = anc.parent;
3126
3456
  }
3127
- }
3128
- const aspectsInScope = [];
3129
- const targetNode = graph.nodes.get(nodePath);
3130
- const targetTags = new Set(targetNode.meta.tags ?? []);
3131
- for (const aspect of graph.aspects) {
3132
- if (targetTags.has(aspect.tag)) {
3133
- aspectsInScope.push(aspect.name);
3457
+ if (fromHierarchy) {
3458
+ affected.push({ path: nodePath, source: `hierarchy from ${anc.path}` });
3459
+ } else {
3460
+ const ancestorPaths = /* @__PURE__ */ new Set([nodePath, ...collectAncestors(node).map((a) => a.path)]);
3461
+ const flow = graph.flows.find(
3462
+ (f) => (f.aspects ?? []).includes(aspectId) && f.nodes.some((n) => ancestorPaths.has(n))
3463
+ );
3464
+ affected.push({ path: nodePath, source: flow ? `flow: ${flow.name}` : "implied" });
3134
3465
  }
3135
3466
  }
3136
- const knowledgeInScope = [];
3137
- for (const k of graph.knowledge) {
3138
- if (k.scope === "global") {
3139
- knowledgeInScope.push(k.path);
3140
- continue;
3467
+ }
3468
+ }
3469
+ affected.sort((a, b) => a.path.localeCompare(b.path));
3470
+ const propagatingFlows = graph.flows.filter((f) => (f.aspects ?? []).includes(aspectId)).map((f) => f.name);
3471
+ const impliedBy = graph.aspects.filter((a) => (a.implies ?? []).includes(aspectId)).map((a) => a.id);
3472
+ const implies = aspect.implies ?? [];
3473
+ process.stdout.write(`Impact of changes in aspect ${aspectId}:
3474
+
3475
+ `);
3476
+ process.stdout.write(`Affected nodes (${affected.length}):
3477
+ `);
3478
+ if (affected.length === 0) {
3479
+ process.stdout.write(" (none)\n");
3480
+ } else {
3481
+ for (const { path: p, source } of affected) {
3482
+ process.stdout.write(` ${p} (${source})
3483
+ `);
3484
+ }
3485
+ }
3486
+ process.stdout.write(
3487
+ `
3488
+ Flows propagating this aspect: ${propagatingFlows.length > 0 ? propagatingFlows.join(", ") : "(none)"}
3489
+ `
3490
+ );
3491
+ process.stdout.write(`Implied by: ${impliedBy.length > 0 ? impliedBy.join(", ") : "(none)"}
3492
+ `);
3493
+ process.stdout.write(`Implies: ${implies.length > 0 ? implies.join(", ") : "(none)"}
3494
+ `);
3495
+ process.stdout.write(`
3496
+ Total scope: ${affected.length} nodes, ${propagatingFlows.length} flows
3497
+ `);
3498
+ if (simulate && affected.length > 0) {
3499
+ await runSimulation(
3500
+ graph,
3501
+ affected.map((a) => a.path),
3502
+ null
3503
+ );
3504
+ }
3505
+ }
3506
+ async function handleFlowImpact(graph, flowName, simulate) {
3507
+ const flow = graph.flows.find((f) => f.name === flowName || f.path === flowName);
3508
+ if (!flow) {
3509
+ process.stderr.write(`Flow not found: ${flowName}
3510
+ `);
3511
+ process.exit(1);
3512
+ }
3513
+ const participants = /* @__PURE__ */ new Set();
3514
+ for (const nodePath of flow.nodes) {
3515
+ if (graph.nodes.has(nodePath)) {
3516
+ participants.add(nodePath);
3517
+ for (const desc of collectDescendants(graph, nodePath)) {
3518
+ participants.add(desc);
3519
+ }
3520
+ }
3521
+ }
3522
+ const sorted = [...participants].sort();
3523
+ const flowAspects = flow.aspects ?? [];
3524
+ process.stdout.write(`Impact of changes in flow ${flow.name}:
3525
+
3526
+ `);
3527
+ process.stdout.write("Participants:\n");
3528
+ if (sorted.length === 0) {
3529
+ process.stdout.write(" (none)\n");
3530
+ } else {
3531
+ for (const p of sorted) {
3532
+ const isDeclared = flow.nodes.includes(p);
3533
+ const suffix = isDeclared ? "" : " (descendant)";
3534
+ process.stdout.write(` ${p}${suffix}
3535
+ `);
3536
+ }
3537
+ }
3538
+ process.stdout.write(
3539
+ `
3540
+ Flow aspects: ${flowAspects.length > 0 ? flowAspects.join(", ") : "(none)"}
3541
+ `
3542
+ );
3543
+ process.stdout.write(`
3544
+ Total scope: ${sorted.length} nodes
3545
+ `);
3546
+ if (simulate && sorted.length > 0) {
3547
+ await runSimulation(graph, sorted, null);
3548
+ }
3549
+ }
3550
+ function registerImpactCommand(program2) {
3551
+ program2.command("impact").description("Show reverse dependency impact for a node, aspect, or flow").option("--node <path>", "Node path relative to .yggdrasil/model/").option("--aspect <id>", "Aspect id (directory path under aspects/)").option("--flow <name>", "Flow name (directory name under flows/)").option("--simulate", "Simulate context package impact (compare HEAD vs current)").action(
3552
+ async (options) => {
3553
+ try {
3554
+ const modeCount = [options.node, options.aspect, options.flow].filter(Boolean).length;
3555
+ if (modeCount === 0) {
3556
+ process.stderr.write(
3557
+ "Error: one of --node, --aspect, or --flow is required\n"
3558
+ );
3559
+ process.exit(1);
3560
+ }
3561
+ if (modeCount > 1) {
3562
+ process.stderr.write(
3563
+ "Error: --node, --aspect, and --flow are mutually exclusive\n"
3564
+ );
3565
+ process.exit(1);
3566
+ }
3567
+ const graph = await loadGraph(process.cwd());
3568
+ if (options.aspect) {
3569
+ await handleAspectImpact(graph, options.aspect.trim(), options.simulate);
3570
+ return;
3571
+ }
3572
+ if (options.flow) {
3573
+ await handleFlowImpact(graph, options.flow.trim(), options.simulate);
3574
+ return;
3141
3575
  }
3142
- if (typeof k.scope === "object" && "tags" in k.scope) {
3143
- if (k.scope.tags.some((t) => targetTags.has(t))) {
3144
- knowledgeInScope.push(k.path);
3576
+ const nodePath = options.node.trim().replace(/^\.\//, "").replace(/\/+$/, "");
3577
+ if (!graph.nodes.has(nodePath)) {
3578
+ process.stderr.write(`Node not found: ${nodePath}
3579
+ `);
3580
+ process.exit(1);
3581
+ }
3582
+ const { direct, allDependents, reverse, relationFrom } = collectReverseDependents(
3583
+ graph,
3584
+ nodePath
3585
+ );
3586
+ const chains = buildTransitiveChains(nodePath, direct, allDependents, reverse);
3587
+ const flows = [];
3588
+ for (const flow of graph.flows) {
3589
+ if (flow.nodes.includes(nodePath)) {
3590
+ flows.push(flow.name);
3145
3591
  }
3146
- continue;
3147
3592
  }
3148
- if (typeof k.scope === "object" && "nodes" in k.scope) {
3149
- if (k.scope.nodes.includes(nodePath)) {
3150
- knowledgeInScope.push(k.path);
3593
+ const targetEffective = collectEffectiveAspectIds(graph, nodePath);
3594
+ const aspectsInScope = [];
3595
+ for (const aspect of graph.aspects) {
3596
+ if (targetEffective.has(aspect.id)) {
3597
+ aspectsInScope.push(aspect.name);
3151
3598
  }
3152
3599
  }
3153
- }
3154
- const budget = graph.config.quality?.context_budget ?? { warning: 1e4, error: 2e4 };
3155
- process.stdout.write(`Impact of changes in ${nodePath}:
3600
+ process.stdout.write(`Impact of changes in ${nodePath}:
3156
3601
 
3157
3602
  `);
3158
- process.stdout.write("Directly dependent:\n");
3159
- if (direct.length === 0) {
3160
- process.stdout.write(" (none)\n");
3161
- } else {
3162
- for (const dep of direct) {
3163
- const rel = relationFrom.get(`${dep}->${nodePath}`);
3164
- const annot = rel?.consumes?.length ? ` (${rel.type}, you consume: ${rel.consumes.join(", ")})` : rel ? ` (${rel.type})` : "";
3165
- process.stdout.write(` <- ${dep}${annot}
3603
+ process.stdout.write("Directly dependent:\n");
3604
+ if (direct.length === 0) {
3605
+ process.stdout.write(" (none)\n");
3606
+ } else {
3607
+ for (const dep of direct) {
3608
+ const rel = relationFrom.get(`${dep}->${nodePath}`);
3609
+ const annot = rel?.consumes?.length ? ` (${rel.type}, you consume: ${rel.consumes.join(", ")})` : rel ? ` (${rel.type})` : "";
3610
+ process.stdout.write(` <- ${dep}${annot}
3166
3611
  `);
3612
+ }
3167
3613
  }
3168
- }
3169
- process.stdout.write("\nTransitively dependent:\n");
3170
- if (chains.length === 0) {
3171
- process.stdout.write(" (none)\n");
3172
- } else {
3173
- for (const chain of chains) {
3174
- process.stdout.write(` ${chain}
3614
+ process.stdout.write("\nTransitively dependent:\n");
3615
+ if (chains.length === 0) {
3616
+ process.stdout.write(" (none)\n");
3617
+ } else {
3618
+ for (const chain of chains) {
3619
+ process.stdout.write(` ${chain}
3175
3620
  `);
3621
+ }
3176
3622
  }
3177
- }
3178
- process.stdout.write(`
3179
- Flows: ${flows.length > 0 ? flows.join(", ") : "(none)"}
3623
+ const descendants = collectDescendants(graph, nodePath);
3624
+ if (descendants.length > 0) {
3625
+ process.stdout.write("\nDescendants (hierarchy impact):\n");
3626
+ for (const desc of descendants) {
3627
+ process.stdout.write(` ${desc}
3180
3628
  `);
3181
- process.stdout.write(
3182
- `Aspects (scope covers node): ${aspectsInScope.length > 0 ? aspectsInScope.join(", ") : "(none)"}
3183
- `
3184
- );
3185
- process.stdout.write(
3186
- `Knowledge (scope covers node): ${knowledgeInScope.length > 0 ? knowledgeInScope.join(", ") : "(none)"}
3629
+ }
3630
+ }
3631
+ process.stdout.write(
3632
+ `
3633
+ Flows: ${flows.length > 0 ? flows.join(", ") : "(none)"}
3187
3634
  `
3188
- );
3189
- process.stdout.write(
3190
- `
3191
- Total scope: ${transitive.length} nodes, ${flows.length} flows, ${aspectsInScope.length} aspects, ${knowledgeInScope.length} knowledge
3635
+ );
3636
+ process.stdout.write(
3637
+ `Aspects (scope covers node): ${aspectsInScope.length > 0 ? aspectsInScope.join(", ") : "(none)"}
3192
3638
  `
3193
- );
3194
- if (options.simulate && transitive.length > 0) {
3195
- process.stdout.write("\nChanges in context packages:\n\n");
3196
- const baselineGraph = await loadGraphFromRef(process.cwd(), "HEAD");
3197
- const driftReport = await detectDrift(graph);
3198
- const driftByNode = new Map(driftReport.entries.map((e) => [e.nodePath, e]));
3199
- for (const dep of transitive) {
3200
- try {
3201
- const pkg = await buildContext(graph, dep);
3202
- const status = pkg.tokenCount >= budget.error ? "error" : pkg.tokenCount >= budget.warning ? "warning" : "ok";
3203
- let baselineTokens = null;
3204
- if (baselineGraph?.nodes.has(dep)) {
3205
- try {
3206
- const baselinePkg = await buildContext(baselineGraph, dep);
3207
- baselineTokens = baselinePkg.tokenCount;
3208
- } catch {
3209
- }
3639
+ );
3640
+ const coAspectNodes = [];
3641
+ if (targetEffective.size > 0) {
3642
+ for (const [p] of graph.nodes) {
3643
+ if (p === nodePath) continue;
3644
+ const nodeEffective = collectEffectiveAspectIds(graph, p);
3645
+ const shared = [...targetEffective].filter((id) => nodeEffective.has(id));
3646
+ if (shared.length > 0) {
3647
+ coAspectNodes.push({ path: p, shared });
3210
3648
  }
3211
- const hasDepOnTarget = graph.nodes.get(dep)?.meta.relations?.some(
3212
- (r) => r.target === nodePath && STRUCTURAL_TYPES.has(r.type)
3213
- );
3214
- const changedLine = hasDepOnTarget ? ` + Changed dependency interface: ${nodePath}
3215
- ` : "";
3216
- const budgetLine = baselineTokens !== null ? ` Budget: ${baselineTokens} -> ${pkg.tokenCount} tokens (${status})
3217
- ` : ` Budget: ${pkg.tokenCount} tokens (${status})
3218
- `;
3219
- const driftEntry = driftByNode.get(dep);
3220
- const driftLine = driftEntry && driftEntry.status !== "ok" ? ` Mapped files (on-disk): ${driftEntry.status}${driftEntry.details ? ` (${driftEntry.details})` : ""}
3221
- ` : driftEntry ? ` Mapped files (on-disk): ok
3222
- ` : "";
3223
- process.stdout.write(`${dep}:
3224
- ${changedLine}${budgetLine}${driftLine}
3225
- `);
3226
- } catch {
3227
- process.stdout.write(`${dep}:
3228
- failed to build context
3229
-
3649
+ }
3650
+ }
3651
+ if (coAspectNodes.length > 0) {
3652
+ process.stdout.write("Nodes sharing aspects:\n");
3653
+ for (const { path: p, shared } of coAspectNodes.sort(
3654
+ (a, b) => a.path.localeCompare(b.path)
3655
+ )) {
3656
+ process.stdout.write(` ${p} (${shared.join(", ")})
3230
3657
  `);
3231
3658
  }
3232
3659
  }
3660
+ const allAffected = /* @__PURE__ */ new Set([...allDependents, ...descendants]);
3661
+ process.stdout.write(
3662
+ `
3663
+ Total scope: ${allAffected.size} nodes, ${flows.length} flows, ${aspectsInScope.length} aspects
3664
+ `
3665
+ );
3666
+ if (options.simulate && allAffected.size > 0) {
3667
+ await runSimulation(graph, allAffected, nodePath);
3668
+ }
3669
+ } catch (error) {
3670
+ process.stderr.write(`Error: ${error.message}
3671
+ `);
3672
+ process.exit(1);
3673
+ }
3674
+ }
3675
+ );
3676
+ }
3677
+
3678
+ // src/cli/aspects.ts
3679
+ import { stringify as yamlStringify } from "yaml";
3680
+ function registerAspectsCommand(program2) {
3681
+ program2.command("aspects").description("List aspects with metadata (YAML output)").action(async () => {
3682
+ try {
3683
+ const yggRoot = await findYggRoot(process.cwd());
3684
+ const graph = await loadGraph(yggRoot);
3685
+ const output = graph.aspects.sort((a, b) => a.id.localeCompare(b.id)).map((aspect) => {
3686
+ const entry = { id: aspect.id, name: aspect.name };
3687
+ if (aspect.description) entry.description = aspect.description;
3688
+ if (aspect.implies && aspect.implies.length > 0) entry.implies = aspect.implies;
3689
+ return entry;
3690
+ });
3691
+ process.stdout.write(yamlStringify(output));
3692
+ } catch (error) {
3693
+ const err = error;
3694
+ if (err.code === "ENOENT") {
3695
+ process.stderr.write(
3696
+ `Error: No .yggdrasil/ directory found. Run 'yg init' first.
3697
+ `
3698
+ );
3699
+ } else {
3700
+ process.stderr.write(`Error: ${error.message}
3701
+ `);
3233
3702
  }
3703
+ process.exit(1);
3704
+ }
3705
+ });
3706
+ }
3707
+
3708
+ // src/cli/flows.ts
3709
+ import { stringify as yamlStringify2 } from "yaml";
3710
+ function registerFlowsCommand(program2) {
3711
+ program2.command("flows").description("List flows with metadata (YAML output)").action(async () => {
3712
+ try {
3713
+ const yggRoot = await findYggRoot(process.cwd());
3714
+ const graph = await loadGraph(yggRoot);
3715
+ const output = graph.flows.sort((a, b) => a.name.localeCompare(b.name)).map((flow) => {
3716
+ const entry = {
3717
+ name: flow.name,
3718
+ participants: flow.nodes.length,
3719
+ nodes: flow.nodes.sort()
3720
+ };
3721
+ if (flow.aspects && flow.aspects.length > 0) entry.aspects = flow.aspects;
3722
+ return entry;
3723
+ });
3724
+ process.stdout.write(yamlStringify2(output));
3234
3725
  } catch (error) {
3235
- process.stderr.write(`Error: ${error.message}
3726
+ const err = error;
3727
+ if (err.code === "ENOENT") {
3728
+ process.stderr.write(
3729
+ `Error: No .yggdrasil/ directory found. Run 'yg init' first.
3730
+ `
3731
+ );
3732
+ } else {
3733
+ process.stderr.write(`Error: ${error.message}
3236
3734
  `);
3735
+ }
3237
3736
  process.exit(1);
3238
3737
  }
3239
3738
  });
3240
3739
  }
3241
3740
 
3242
3741
  // src/io/journal-store.ts
3243
- import { readFile as readFile13, writeFile as writeFile4, mkdir as mkdir3, rename, access as access2 } from "fs/promises";
3244
- import { parse as parseYaml8, stringify as stringifyYaml2 } from "yaml";
3245
- import path15 from "path";
3742
+ import { readFile as readFile13, writeFile as writeFile4, mkdir as mkdir3, rename, access as access3 } from "fs/promises";
3743
+ import { parse as parseYaml6, stringify as stringifyYaml } from "yaml";
3744
+ import path17 from "path";
3246
3745
  var JOURNAL_FILE = ".journal.yaml";
3247
3746
  var ARCHIVE_DIR = "journals-archive";
3248
3747
  async function readJournal(yggRoot) {
3249
- const filePath = path15.join(yggRoot, JOURNAL_FILE);
3748
+ const filePath = path17.join(yggRoot, JOURNAL_FILE);
3250
3749
  try {
3251
3750
  const content = await readFile13(filePath, "utf-8");
3252
- const raw = parseYaml8(content);
3751
+ const raw = parseYaml6(content);
3253
3752
  const entries = raw.entries ?? [];
3254
3753
  return Array.isArray(entries) ? entries : [];
3255
3754
  } catch {
@@ -3261,26 +3760,26 @@ async function appendJournalEntry(yggRoot, note, target) {
3261
3760
  const at = (/* @__PURE__ */ new Date()).toISOString();
3262
3761
  const entry = target ? { at, target, note } : { at, note };
3263
3762
  entries.push(entry);
3264
- const filePath = path15.join(yggRoot, JOURNAL_FILE);
3265
- const content = stringifyYaml2({ entries });
3763
+ const filePath = path17.join(yggRoot, JOURNAL_FILE);
3764
+ const content = stringifyYaml({ entries });
3266
3765
  await writeFile4(filePath, content, "utf-8");
3267
3766
  return entry;
3268
3767
  }
3269
3768
  async function archiveJournal(yggRoot) {
3270
- const journalPath = path15.join(yggRoot, JOURNAL_FILE);
3769
+ const journalPath = path17.join(yggRoot, JOURNAL_FILE);
3271
3770
  try {
3272
- await access2(journalPath);
3771
+ await access3(journalPath);
3273
3772
  } catch {
3274
3773
  return null;
3275
3774
  }
3276
3775
  const entries = await readJournal(yggRoot);
3277
3776
  if (entries.length === 0) return null;
3278
- const archiveDir = path15.join(yggRoot, ARCHIVE_DIR);
3777
+ const archiveDir = path17.join(yggRoot, ARCHIVE_DIR);
3279
3778
  await mkdir3(archiveDir, { recursive: true });
3280
3779
  const now = /* @__PURE__ */ new Date();
3281
3780
  const timestamp = `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, "0")}${String(now.getUTCDate()).padStart(2, "0")}-${String(now.getUTCHours()).padStart(2, "0")}${String(now.getUTCMinutes()).padStart(2, "0")}${String(now.getUTCSeconds()).padStart(2, "0")}`;
3282
3781
  const archiveName = `.journal.${timestamp}.yaml`;
3283
- const archivePath = path15.join(archiveDir, archiveName);
3782
+ const archivePath = path17.join(archiveDir, archiveName);
3284
3783
  await rename(journalPath, archivePath);
3285
3784
  return { archiveName, entryCount: entries.length };
3286
3785
  }
@@ -3356,9 +3855,93 @@ function registerJournalArchiveCommand(program2) {
3356
3855
  });
3357
3856
  }
3358
3857
 
3858
+ // src/cli/preflight.ts
3859
+ function registerPreflightCommand(program2) {
3860
+ program2.command("preflight").description("Unified diagnostic report: journal, drift, status, validation").option("--quick", "Skip drift detection for faster results").action(async (options) => {
3861
+ try {
3862
+ const cwd = process.cwd();
3863
+ const graph = await loadGraph(cwd);
3864
+ const yggRoot = await findYggRoot(cwd);
3865
+ const journalEntries = await readJournal(yggRoot);
3866
+ const driftedEntries = options.quick ? [] : (await detectDrift(graph)).entries.filter((e) => e.status !== "ok");
3867
+ const nodeCount = graph.nodes.size;
3868
+ const aspectCount = graph.aspects.length;
3869
+ const flowCount = graph.flows.length;
3870
+ let mappedPathCount = 0;
3871
+ for (const node of graph.nodes.values()) {
3872
+ mappedPathCount += normalizeMappingPaths(node.meta.mapping).length;
3873
+ }
3874
+ const validation = await validate(graph, "all");
3875
+ const errors = validation.issues.filter((i) => i.severity === "error");
3876
+ const warnings = validation.issues.filter((i) => i.severity === "warning");
3877
+ const lines = [];
3878
+ lines.push("=== Preflight Report ===");
3879
+ lines.push("");
3880
+ if (journalEntries.length === 0) {
3881
+ lines.push("Journal: clean");
3882
+ } else {
3883
+ lines.push(`Journal: ${journalEntries.length} pending entries`);
3884
+ for (const entry of journalEntries) {
3885
+ const target = entry.target ? ` [${entry.target}]` : "";
3886
+ lines.push(` - ${entry.note}${target}`);
3887
+ }
3888
+ }
3889
+ lines.push("");
3890
+ if (options.quick) {
3891
+ lines.push("Drift: skipped (--quick)");
3892
+ } else if (driftedEntries.length === 0) {
3893
+ lines.push("Drift: clean");
3894
+ } else {
3895
+ lines.push(`Drift: ${driftedEntries.length} nodes need attention`);
3896
+ for (const entry of driftedEntries) {
3897
+ lines.push(` - ${entry.nodePath}: ${entry.status}`);
3898
+ }
3899
+ }
3900
+ lines.push("");
3901
+ lines.push(
3902
+ `Status: ${nodeCount} nodes, ${aspectCount} aspects, ${flowCount} flows, ${mappedPathCount} mapped paths`
3903
+ );
3904
+ if (nodeCount === 0) {
3905
+ lines.push("");
3906
+ lines.push(" \u26A1 No nodes found. Enter BOOTSTRAP MODE:");
3907
+ lines.push(" Create nodes under .yggdrasil/model/ for your active work area.");
3908
+ lines.push(" See: yg help build-context");
3909
+ }
3910
+ lines.push("");
3911
+ if (errors.length === 0 && warnings.length === 0) {
3912
+ lines.push("Validation: clean");
3913
+ } else {
3914
+ const parts = [];
3915
+ if (errors.length > 0) parts.push(`${errors.length} errors`);
3916
+ if (warnings.length > 0) parts.push(`${warnings.length} warnings`);
3917
+ lines.push(`Validation: ${parts.join(", ")}`);
3918
+ for (const issue of [...errors, ...warnings]) {
3919
+ const code = issue.code ? `[${issue.code}] ` : "";
3920
+ const loc = issue.nodePath ? `${issue.nodePath} -> ` : "";
3921
+ lines.push(` - ${code}${loc}${issue.message}`);
3922
+ }
3923
+ }
3924
+ lines.push("");
3925
+ process.stdout.write(lines.join("\n"));
3926
+ const hasIssues = journalEntries.length > 0 || !options.quick && driftedEntries.length > 0 || errors.length > 0;
3927
+ process.exit(hasIssues ? 1 : 0);
3928
+ } catch (error) {
3929
+ process.stderr.write(`Error: ${error.message}
3930
+ `);
3931
+ process.exit(1);
3932
+ }
3933
+ });
3934
+ }
3935
+
3359
3936
  // src/bin.ts
3937
+ import { readFileSync } from "fs";
3938
+ import { fileURLToPath as fileURLToPath3 } from "url";
3939
+ import { dirname, join } from "path";
3940
+ var __filename = fileURLToPath3(import.meta.url);
3941
+ var __dirname = dirname(__filename);
3942
+ var pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
3360
3943
  var program = new Command();
3361
- program.name("yg").description("Yggdrasil \u2014 architectural knowledge infrastructure for AI agents").version("0.1.0");
3944
+ program.name("yg").description("Yggdrasil \u2014 architectural knowledge infrastructure for AI agents").version(pkg.version);
3362
3945
  registerInitCommand(program);
3363
3946
  registerBuildCommand(program);
3364
3947
  registerValidateCommand(program);
@@ -3369,8 +3952,11 @@ registerTreeCommand(program);
3369
3952
  registerOwnerCommand(program);
3370
3953
  registerDepsCommand(program);
3371
3954
  registerImpactCommand(program);
3955
+ registerAspectsCommand(program);
3956
+ registerFlowsCommand(program);
3372
3957
  registerJournalAddCommand(program);
3373
3958
  registerJournalReadCommand(program);
3374
3959
  registerJournalArchiveCommand(program);
3960
+ registerPreflightCommand(program);
3375
3961
  program.parse();
3376
3962
  //# sourceMappingURL=bin.js.map