@chrisdudek/yg 0.3.4 → 1.0.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,302 @@ 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>
116
90
 
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.
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
+ \`\`\`
127
102
 
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.
103
+ ### Five Core Rules
140
104
 
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
- ---
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.** When the user explains a reason, record it in the graph immediately. Conversation evaporates; graph persists.
109
+ 5. **Ask before resolving ambiguity.** When multiple valid interpretations exist, stop, list options, ask the user. Never silently choose.
158
110
 
159
- ## 3. WORKFLOW: MODIFYING OR CREATING FILES (Code-First)
111
+ ### Failure States
160
112
 
161
- You are NOT ALLOWED to edit or create source code without establishing graph coverage first.
113
+ You have broken Yggdrasil if you do any of the following:
162
114
 
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.
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.
164
129
 
165
- **Step 1: Check coverage** -> Run \`yg owner --file <path>\`
130
+ ### Escape Hatch
166
131
 
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:
132
+ If the user explicitly requests a code-only change, comply but:
169
133
 
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)
134
+ - Warn: "This creates drift. Run \`yg drift\` next session to reconcile."
135
+ - Do NOT run \`yg drift-sync\` \u2014 leave the drift visible.
174
136
 
175
- **Step 3: If Owner NOT FOUND (Uncovered Area)**
176
- STOP. Do not modify the code. First determine: **Is this greenfield or existing code?**
137
+ ### Environment Check
177
138
 
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.
139
+ Before preflight:
184
140
 
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.
141
+ - Verify \`yg\` CLI is available. If not found, inform user and stop.
142
+ - If \`yg preflight\` shows 0 nodes \u2192 enter BOOTSTRAP MODE (see Operations).
143
+ - If drift report shows >10 drifted nodes \u2192 report scope to user, ask which area to prioritize. Do not resolve all at once.`;
144
+ var OPERATIONS = `## OPERATIONS
186
145
 
187
- ---
146
+ ### Conversation Lifecycle
188
147
 
189
- ## 4. WORKFLOW: MODIFYING THE GRAPH & BLAST RADIUS (Graph-First)
148
+ \`\`\`
149
+ PREFLIGHT (every conversation, before any work):
150
+ - [ ] 1. yg preflight \u2192 read unified report
151
+ - [ ] 2. If journal entries: consolidate to graph, then yg journal-archive
152
+ - [ ] 3. If drift: resolve per Drift Resolution, then yg drift-sync per node
153
+ - [ ] 4. If validation errors: fix, re-run yg validate
154
+ Exception: read-only requests (explain, analyze) \u2014 skip preflight.
190
155
 
191
- When adding features, changing architecture, or doing graph-first design:
156
+ UNDERSTANDING mapped code (questions, research, OR planning):
157
+ - [ ] 1. yg owner --file <path>
158
+ - [ ] 2. Owner found \u2192 yg build-context --node <path>. Use context package as primary source.
159
+ - [ ] 3. Owner not found \u2192 use file analysis, state it is not graph-backed.
160
+ Never use grep or raw file reads as primary understanding when graph coverage exists.
161
+ Raw reads supplement the context package \u2014 they do not replace it.
192
162
 
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.
163
+ WRAP-UP (user signals "done", "wrap up", "that's enough"):
164
+ - [ ] 1. Consolidate journal if used \u2192 yg journal-archive
165
+ - [ ] 2. yg drift --drifted-only \u2192 resolve
166
+ - [ ] 3. yg validate \u2192 fix errors
167
+ - [ ] 4. Report: which nodes and files were changed
168
+ \`\`\`
201
169
 
202
- **Graph Modification Checklist**
203
- Whenever you change the graph structure or semantics, you MUST output and execute this exact checklist:
170
+ ### Modify Source Code
204
171
 
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)
172
+ You are not allowed to edit or create source code without establishing graph coverage first.
210
173
 
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.
174
+ **Step 1** \u2014 Check coverage: \`yg owner --file <path>\`
214
175
 
215
- ---
176
+ **Step 2a** \u2014 Owner found: execute checklist:
216
177
 
217
- ## 5. PATH CONVENTIONS (CRITICAL)
178
+ - [ ] 1. Read specification: \`yg build-context --node <node_path>\`
179
+ - [ ] 2. Assess blast radius: \`yg impact --node <node_path>\` \u2014 review dependents, descendants, and co-aspect nodes before changing interfaces or shared behavior
180
+ - [ ] 3. Modify source code
181
+ - [ ] 4. Sync graph artifacts \u2014 edit artifact files to reflect the changes
182
+ - [ ] 5. Run \`yg validate\` \u2014 fix all errors (if unfixable after 3 attempts \u2192 stop, report to user)
183
+ - [ ] 6. Run \`yg drift-sync --node <node_path>\` \u2014 only after graph and code are both current
218
184
 
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\`).
185
+ **Step 2b** \u2014 Owner not found: establish coverage first. Present options to the user:
223
186
 
224
- ---
187
+ *Partially mapped* (file unmapped but inside a mapped module): ask whether to add to existing node or create new one.
225
188
 
226
- ## 6. GRAPH STRUCTURE, CONFIG & TEMPLATES CHEAT SHEET
189
+ *Existing code:*
227
190
 
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.
191
+ - Option A \u2014 Full node: create node(s), map files, write artifacts from code analysis
192
+ - Option B \u2014 Blackbox: create a blackbox node at agreed granularity
193
+ - Option C \u2014 Abort
229
194
 
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.
195
+ *Greenfield (new code):* Only Option A. Blackbox is forbidden for new code. Create nodes with full artifacts, then materialize.
236
196
 
237
- ---
197
+ After the user chooses, return to Step 1 and follow Step 2a.
238
198
 
239
- ## 7. CONTEXT ASSEMBLY & KNOWLEDGE DECONSTRUCTION (HOW TO MAP FILES)
199
+ ### Modify Graph
240
200
 
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.
201
+ - [ ] 1. Read the relevant schema from \`schemas/\` before touching any YAML
202
+ - [ ] 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
203
+ - [ ] 3. Make changes
204
+ - [ ] 4. Run \`yg validate\` immediately \u2014 fix all errors
205
+ - [ ] 5. Verify affected source files are consistent \u2014 update if needed
206
+ - [ ] 6. Run \`yg drift-sync\` for affected nodes
242
207
 
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.
208
+ ### Reverse Engineering
244
209
 
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".
210
+ **Order:** aspects (cross-cutting patterns) \u2192 flows (business processes) \u2192 model nodes. Never create nodes before aspects and flows are understood.
247
211
 
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.
212
+ Per area checklist:
252
213
 
253
- When mapping a file, execute this mental routing:
214
+ - [ ] 1. \`yg owner --file <path>\` \u2014 confirm no coverage
215
+ - [ ] 2. Determine node granularity \u2014 propose to user if unclear
216
+ - [ ] 3. Create node directory, read \`schemas/node.yaml\`, create \`node.yaml\`
217
+ - [ ] 4. Analyze source \u2014 for each artifact type in \`config.artifacts\`: extract content, do not invent
218
+ - [ ] 5. Identify relations \u2014 add to \`node.yaml\`
219
+ - [ ] 6. Identify cross-cutting requirements \u2014 add matching aspects, create if needed
220
+ - [ ] 7. Identify business process participation \u2014 add to flow, ask user if process unclear
221
+ - [ ] 8. \`yg validate\` \u2014 fix errors
222
+ - [ ] 9. \`yg drift-sync --node <path>\`
254
223
 
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.
224
+ **When to ask:**
259
225
 
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.
226
+ - Business process unclear: "This code appears to be part of a larger process. Can you describe what it means from a business perspective?"
227
+ - Constraint without rationale: "I see [constraint X]. Do you know why this exists? I want to record the reason, not just the rule."
228
+ - Unexplained architectural choice: "I see [approach X]. What was the reason for this choice?"
261
229
 
262
- ### Optional Artifacts \u2014 Explicit Consideration
230
+ ### Bootstrap Mode
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
+ Trigger: \`yg preflight\` shows 0 nodes, or no nodes cover the active work area.
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
+ - [ ] 1. Identify the active work area (files the user wants to modify)
235
+ - [ ] 2. Scan for cross-cutting patterns \u2192 create aspects
236
+ - [ ] 3. Ask user about business processes \u2192 create flows if applicable
237
+ - [ ] 4. Propose node structure for the area
238
+ - [ ] 5. Create node(s) with initial artifacts, map files
239
+ - [ ] 6. \`yg validate\`, \`yg drift-sync\`
240
+ - [ ] 7. Proceed with user's original request
267
241
 
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.
242
+ Constraint: Do NOT map the entire repository. Focus on the active area. Expand incrementally.
269
243
 
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.
244
+ ### Drift Resolution
271
245
 
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.
246
+ Always ask the user before resolving drift. Never auto-resolve.
278
247
 
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.
248
+ - **Source drift** (source files changed) \u2192 update graph artifacts to match source, then \`yg drift-sync\`
249
+ - **Graph drift** (graph artifacts changed) \u2192 review affected source, update if needed, then \`yg drift-sync\`
250
+ - **Full drift** (both changed) \u2192 present both sides to user, ask which direction wins
251
+ - **Missing** \u2192 ask: re-materialize or remove mapping?
252
+ - **Unmaterialized** \u2192 ask user how to proceed
282
253
 
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.
254
+ Threshold: >10 drifted nodes \u2192 ask user which area to prioritize. Do not resolve all at once.
286
255
 
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.
256
+ ### Error Recovery
292
257
 
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.
258
+ - **\`yg\` not found** \u2192 inform user: "yg CLI is not installed or not in PATH." Stop.
259
+ - **Unfixable validate errors** \u2192 if not resolved after 3 attempts, stop and report to user. Do not loop.
260
+ - **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.
261
+ - **Corrupted \`.yggdrasil/\` files** \u2192 report to user. Do not attempt repair.
262
+ - **Incremental sync** \u2192 run \`yg drift-sync\` every 3-5 source files during multi-file tasks. Do not defer to end.`;
263
+ var KNOWLEDGE_BASE = `## KNOWLEDGE BASE
297
264
 
298
- ---
265
+ ### Graph Structure
299
266
 
300
- ## 8. CLI TOOLS REFERENCE (\`yg\`)
267
+ \`\`\`
268
+ .yggdrasil/
269
+ config.yaml \u2190 vocabulary, stack, node types, artifact rules, required aspects
270
+ model/ \u2190 what exists: nodes, hierarchy, relations, file mappings
271
+ aspects/ \u2190 what must: cross-cutting requirements with rationale and guidance
272
+ flows/ \u2190 why and in what process: business processes with node participation
273
+ schemas/ \u2190 YAML schemas \u2014 read before creating any graph element
274
+ .drift-state \u2190 generated by CLI; never edit manually
275
+ .journal.yaml \u2190 generated by CLI; never edit manually
276
+ \`\`\`
301
277
 
302
- Always use these exact commands.
278
+ Key facts:
279
+
280
+ - **Hierarchy:** nodes nest in \`model/\`. Children inherit parent context. Do not repeat parent content in children.
281
+ - **Aspect id = directory path** under \`aspects/\`. Each aspect has \`aspect.yaml\` + content \`.md\` files. No automatic parent-child \u2014 use \`implies\` explicitly.
282
+ - **Flows = business processes.** A flow describes what happens in the world, not code sequences. Flow aspects propagate to all participants.
303
283
 
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.
284
+ ### Context Assembly
313
285
 
314
- *(Iterative mode only)*
315
- * \`yg journal-read\`
316
- * \`yg journal-add --note "<content>" [--target <node_path>]\`
317
- * \`yg journal-archive\`
286
+ 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.
287
+
288
+ ### Information Routing
289
+
290
+ When you encounter information, route it to the correct location:
291
+
292
+ - **Specific to this node** \u2192 local node artifact (check \`config.yaml artifacts\` for available types)
293
+ - **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\`
294
+ - **Business process** \u2192 flow (\`flows/<name>/\` with \`flow.yaml\` + \`description.md\`). Ask user if process unclear.
295
+ - **Shared across a domain** \u2192 parent node artifact. Children receive it through hierarchy.
296
+ - **Technology stack or standard** \u2192 \`config.yaml\` under \`stack\` or \`standards\` (+ \`rationale\` field)
297
+ - **Decision (why):** one node \u2192 local artifact; category of nodes \u2192 aspect content files; tech choice \u2192 \`config.yaml\` rationale field
298
+
299
+ ### Creating Aspects
300
+
301
+ - [ ] 1. Read \`schemas/aspect.yaml\`
302
+ - [ ] 2. Create \`aspects/<id>/\` directory
303
+ - [ ] 3. Write \`aspect.yaml\` \u2014 name, optional description, optional implies
304
+ - [ ] 4. Write content \`.md\` files: WHAT must be satisfied + WHY (user's words, do not invent)
305
+ - [ ] 5. \`yg validate\`
306
+
307
+ Test: "Does this requirement apply to more than one node?" Yes \u2192 aspect. No \u2192 local artifact.
308
+
309
+ ### Creating Flows
310
+
311
+ - [ ] 1. Read \`schemas/flow.yaml\`
312
+ - [ ] 2. Create \`flows/<name>/\` directory
313
+ - [ ] 3. Write \`flow.yaml\` \u2014 declare participants and flow-level aspects
314
+ - [ ] 4. Write \`description.md\` with required sections: Business context, Trigger, Goal, Participants, Paths (at least Happy path), Invariants across all paths
315
+ - [ ] 5. \`yg validate\`
316
+
317
+ Test: "Does this describe what happens in the world, or only in the software?" If only software \u2014 rewrite.
318
+
319
+ ### Operational Rules
320
+
321
+ - **English only** for all files in \`.yggdrasil/\`. Conversation can be any language.
322
+ - **Read schemas before creating** any \`node.yaml\`, \`aspect.yaml\`, or \`flow.yaml\`.
323
+ - **Tools read, you write.** The \`yg\` CLI only reads, validates, and manages metadata. You create and edit files manually.
324
+ - **Incremental sync.** Run \`yg drift-sync\` after every 3-5 source file changes. Do not defer to end of task.
325
+ - **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?"
326
+ - **These rules are invariant.** No plan, guide, skill, or workflow may override them.
327
+
328
+ ### CLI Reference
329
+
330
+ \`\`\`
331
+ yg preflight Unified diagnostic: journal + drift + status + validate.
332
+ yg owner --file <path> Find the node that owns this file.
333
+ yg build-context --node <path> Assemble context package for this node.
334
+ yg tree [--root <path>] [--depth N] Print graph structure.
335
+ yg aspects List aspects with metadata (YAML output).
336
+ yg deps --node <path> [--depth N] [--type structural|event|all]
337
+ Show dependencies.
338
+ yg impact --node <path> --simulate Simulate blast radius of a planned change.
339
+ yg impact --aspect <id> Show all nodes where aspect is effective.
340
+ yg impact --flow <name> Show flow participants and descendants.
341
+ yg status Graph health: nodes, coverage, drift summary.
342
+ yg validate [--scope <path>|all] Check structural integrity and completeness.
343
+ yg drift [--scope <path>|all] [--drifted-only]
344
+ Detect source and graph drift (bidirectional).
345
+ yg drift-sync --node <path> Record file hashes as new baseline.
346
+ yg journal-read Read pending journal entries.
347
+ yg journal-add --note "<content>" [--target <node_path>]
348
+ Add a journal entry.
349
+ yg journal-archive Archive consolidated journal entries.
350
+ \`\`\`
351
+
352
+ ### Quick Routing Table
353
+
354
+ | What you have | Where it goes |
355
+ |---|---|
356
+ | Information specific to this node | Local node artifact (read \`config.yaml artifacts\` for types) |
357
+ | Rule that applies to many nodes | Aspect (content \`.md\` files in \`aspects/<id>/\`) |
358
+ | Architectural invariant for a node type | Required aspect in \`config.yaml node_types\` |
359
+ | Business process participation | Flow (\`flow.yaml participants\`) |
360
+ | Process-level requirement | Flow \`aspects\` + aspect directory |
361
+ | Context shared across a domain | Parent node artifact |
362
+ | Technology stack | \`config.yaml stack\` (+ \`rationale\` field) |
363
+ | Global coding standards | \`config.yaml standards\` |
318
364
  `;
365
+ var AGENT_RULES_CONTENT = [CORE_PROTOCOL, OPERATIONS, KNOWLEDGE_BASE].join("\n\n---\n\n");
319
366
 
320
367
  // src/templates/platform.ts
321
368
  var AGENT_RULES_IMPORT = "@.yggdrasil/agent-rules.md";
@@ -558,10 +605,10 @@ function escapeRegex(s) {
558
605
  }
559
606
 
560
607
  // src/cli/init.ts
561
- function getGraphTemplatesDir() {
608
+ function getGraphSchemasDir() {
562
609
  const currentDir = path2.dirname(fileURLToPath(import.meta.url));
563
610
  const packageRoot = path2.join(currentDir, "..");
564
- return path2.join(packageRoot, "graph-templates");
611
+ return path2.join(packageRoot, "graph-schemas");
565
612
  }
566
613
  var GITIGNORE_CONTENT = `.journal.yaml
567
614
  journals-archive/
@@ -609,23 +656,20 @@ function registerInitCommand(program2) {
609
656
  await mkdir2(path2.join(yggRoot, "model"), { recursive: true });
610
657
  await mkdir2(path2.join(yggRoot, "aspects"), { recursive: true });
611
658
  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();
659
+ const schemasDir = path2.join(yggRoot, "schemas");
660
+ await mkdir2(schemasDir, { recursive: true });
661
+ const graphSchemasDir = getGraphSchemasDir();
618
662
  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);
663
+ const entries = await readdir(graphSchemasDir, { withFileTypes: true });
664
+ const schemaFiles = entries.filter((e) => e.isFile()).map((e) => e.name);
665
+ for (const file of schemaFiles) {
666
+ const srcPath = path2.join(graphSchemasDir, file);
623
667
  const content = await readFile2(srcPath, "utf-8");
624
- await writeFile2(path2.join(templatesDir, file), content, "utf-8");
668
+ await writeFile2(path2.join(schemasDir, file), content, "utf-8");
625
669
  }
626
670
  } catch (err) {
627
671
  process.stderr.write(
628
- `Warning: Could not copy graph templates from ${graphTemplatesDir}: ${err.message}
672
+ `Warning: Could not copy graph schemas from ${graphSchemasDir}: ${err.message}
629
673
  `
630
674
  );
631
675
  }
@@ -639,10 +683,7 @@ function registerInitCommand(program2) {
639
683
  process.stdout.write(" .yggdrasil/model/\n");
640
684
  process.stdout.write(" .yggdrasil/aspects/\n");
641
685
  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
- );
686
+ process.stdout.write(" .yggdrasil/schemas/ (node, aspect, flow)\n");
646
687
  process.stdout.write(` ${path2.relative(projectRoot, rulesPath)} (rules)
647
688
 
648
689
  `);
@@ -654,8 +695,8 @@ function registerInitCommand(program2) {
654
695
  }
655
696
 
656
697
  // src/core/graph-loader.ts
657
- import { readdir as readdir3 } from "fs/promises";
658
- import path6 from "path";
698
+ import { readdir as readdir3, readFile as readFile9 } from "fs/promises";
699
+ import path7 from "path";
659
700
 
660
701
  // src/io/config-parser.ts
661
702
  import { readFile as readFile3 } from "fs/promises";
@@ -663,8 +704,7 @@ import { parse as parseYaml } from "yaml";
663
704
  var DEFAULT_QUALITY = {
664
705
  min_artifact_length: 50,
665
706
  max_direct_relations: 10,
666
- context_budget: { warning: 1e4, error: 2e4 },
667
- knowledge_staleness_days: 90
707
+ context_budget: { warning: 1e4, error: 2e4 }
668
708
  };
669
709
  async function parseConfig(filePath) {
670
710
  const content = await readFile3(filePath, "utf-8");
@@ -672,18 +712,34 @@ async function parseConfig(filePath) {
672
712
  if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
673
713
  throw new Error(`config.yaml: missing or invalid 'name' field`);
674
714
  }
675
- const nodeTypes = raw.node_types;
676
- if (!Array.isArray(nodeTypes) || nodeTypes.length === 0) {
715
+ const nodeTypesRaw = raw.node_types;
716
+ if (!Array.isArray(nodeTypesRaw) || nodeTypesRaw.length === 0) {
677
717
  throw new Error(`config.yaml: 'node_types' must be a non-empty array`);
678
718
  }
719
+ const nodeTypes = nodeTypesRaw.map((item) => {
720
+ if (typeof item === "string") {
721
+ return { name: item };
722
+ }
723
+ if (typeof item === "object" && item !== null && "name" in item && typeof item.name === "string") {
724
+ const obj = item;
725
+ 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;
726
+ return {
727
+ name: obj.name,
728
+ required_aspects: requiredAspects && requiredAspects.length > 0 ? requiredAspects : void 0
729
+ };
730
+ }
731
+ throw new Error(
732
+ `config.yaml: node_types entry must be string or { name, required_aspects? }`
733
+ );
734
+ });
679
735
  const artifacts = raw.artifacts;
680
736
  if (!artifacts || typeof artifacts !== "object" || Array.isArray(artifacts) || Object.keys(artifacts).length === 0) {
681
737
  throw new Error(`config.yaml: 'artifacts' must be a non-empty object`);
682
738
  }
683
739
  const artifactsMap = {};
684
740
  for (const [key, val] of Object.entries(artifacts)) {
685
- if (key === "node") {
686
- throw new Error(`config.yaml: artifact name 'node' is reserved`);
741
+ if (key === "node.yaml") {
742
+ throw new Error(`config.yaml: artifact name 'node.yaml' is reserved`);
687
743
  }
688
744
  const a = val;
689
745
  const required = a.required;
@@ -692,10 +748,10 @@ async function parseConfig(filePath) {
692
748
  }
693
749
  if (typeof required === "object" && required && "when" in required) {
694
750
  const when = required.when;
695
- const validWhen = when === "has_incoming_relations" || when === "has_outgoing_relations" || typeof when === "string" && when.startsWith("has_tag:");
751
+ const validWhen = when === "has_incoming_relations" || when === "has_outgoing_relations" || typeof when === "string" && (when.startsWith("has_aspect:") || when.startsWith("has_tag:"));
696
752
  if (!validWhen) {
697
753
  throw new Error(
698
- `config.yaml: artifact '${key}' has invalid 'required.when': must be has_incoming_relations, has_outgoing_relations, or has_tag:<name>`
754
+ `config.yaml: artifact '${key}' has invalid 'required.when': must be has_incoming_relations, has_outgoing_relations, or has_aspect:<name>`
699
755
  );
700
756
  }
701
757
  }
@@ -705,24 +761,6 @@ async function parseConfig(filePath) {
705
761
  structural_context: a.structural_context ?? false
706
762
  };
707
763
  }
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
764
  const qualityRaw = raw.quality;
727
765
  const quality = qualityRaw ? {
728
766
  min_artifact_length: qualityRaw.min_artifact_length ?? DEFAULT_QUALITY.min_artifact_length,
@@ -730,30 +768,19 @@ async function parseConfig(filePath) {
730
768
  context_budget: {
731
769
  warning: qualityRaw.context_budget?.warning ?? DEFAULT_QUALITY.context_budget.warning,
732
770
  error: qualityRaw.context_budget?.error ?? DEFAULT_QUALITY.context_budget.error
733
- },
734
- knowledge_staleness_days: qualityRaw.knowledge_staleness_days ?? DEFAULT_QUALITY.knowledge_staleness_days
771
+ }
735
772
  } : DEFAULT_QUALITY;
736
773
  if (quality.context_budget.error < quality.context_budget.warning) {
737
774
  throw new Error(
738
775
  `config.yaml: quality.context_budget.error (${quality.context_budget.error}) must be >= warning (${quality.context_budget.warning})`
739
776
  );
740
777
  }
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
778
  return {
750
779
  name: raw.name.trim(),
751
780
  stack: raw.stack ?? {},
752
781
  standards: typeof raw.standards === "string" ? raw.standards : "",
753
- tags: tagsList,
754
782
  node_types: nodeTypes,
755
783
  artifacts: artifactsMap,
756
- knowledge_categories: knowledgeCategories.filter((kc) => kc?.name),
757
784
  quality
758
785
  };
759
786
  }
@@ -786,10 +813,9 @@ async function parseNodeYaml(filePath) {
786
813
  return {
787
814
  name: raw.name.trim(),
788
815
  type: raw.type.trim(),
789
- tags: parseStringArray(raw.tags),
816
+ aspects: parseStringArray(raw.aspects) ?? parseStringArray(raw.tags),
790
817
  blackbox: raw.blackbox ?? false,
791
818
  relations: relations.length > 0 ? relations : void 0,
792
- knowledge: parseStringArray(raw.knowledge),
793
819
  mapping
794
820
  };
795
821
  }
@@ -889,25 +915,37 @@ async function readArtifacts(dirPath, excludeFiles = ["node.yaml"], includeFiles
889
915
  }
890
916
 
891
917
  // src/io/aspect-parser.ts
892
- async function parseAspect(aspectDir, aspectYamlPath) {
918
+ async function parseAspect(aspectDir, aspectYamlPath, id) {
919
+ const idTrimmed = id?.trim() ?? "";
920
+ if (!idTrimmed) {
921
+ throw new Error(`Aspect id must be non-empty (relative path in aspects/)`);
922
+ }
893
923
  const content = await readFile6(aspectYamlPath, "utf-8");
894
924
  const raw = parseYaml3(content);
895
925
  if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
896
926
  throw new Error(`Aspect file ${aspectYamlPath}: missing or empty 'name'`);
897
927
  }
898
- if (!raw.tag || typeof raw.tag !== "string" || raw.tag.trim() === "") {
899
- throw new Error(`Aspect file ${aspectYamlPath}: missing or empty 'tag'`);
900
- }
928
+ const description = typeof raw.description === "string" ? raw.description.trim() : void 0;
901
929
  const artifacts = await readArtifacts(aspectDir, ["aspect.yaml"]);
930
+ let implies;
931
+ if (raw.implies !== void 0) {
932
+ if (!Array.isArray(raw.implies)) {
933
+ throw new Error(`Aspect file ${aspectYamlPath}: 'implies' must be an array of strings`);
934
+ }
935
+ implies = raw.implies.filter((t) => typeof t === "string");
936
+ }
902
937
  return {
903
938
  name: raw.name.trim(),
904
- tag: raw.tag.trim(),
939
+ id: idTrimmed,
940
+ description,
941
+ implies,
905
942
  artifacts
906
943
  };
907
944
  }
908
945
 
909
946
  // src/io/flow-parser.ts
910
947
  import { readFile as readFile7 } from "fs/promises";
948
+ import path4 from "path";
911
949
  import { parse as parseYaml4 } from "yaml";
912
950
  async function parseFlow(flowDir, flowYamlPath) {
913
951
  const content = await readFile7(flowYamlPath, "utf-8");
@@ -923,79 +961,44 @@ async function parseFlow(flowDir, flowYamlPath) {
923
961
  if (nodePaths.length === 0) {
924
962
  throw new Error(`flow.yaml at ${flowYamlPath}: 'nodes' must contain string node paths`);
925
963
  }
926
- const knowledge = Array.isArray(raw.knowledge) ? raw.knowledge.filter((k) => typeof k === "string") : void 0;
964
+ let aspects;
965
+ if (raw.aspects !== void 0) {
966
+ if (!Array.isArray(raw.aspects)) {
967
+ throw new Error(`flow.yaml at ${flowYamlPath}: 'aspects' must be an array of strings`);
968
+ }
969
+ const aspectTags = raw.aspects.filter((a) => typeof a === "string");
970
+ aspects = aspectTags.length > 0 ? aspectTags : [];
971
+ }
927
972
  const artifacts = await readArtifacts(flowDir, ["flow.yaml"]);
928
973
  return {
974
+ path: path4.basename(flowDir),
929
975
  name: raw.name.trim(),
930
976
  nodes: nodePaths,
931
- knowledge,
977
+ ...aspects !== void 0 && { aspects },
932
978
  artifacts
933
979
  };
934
980
  }
935
981
 
936
- // src/io/knowledge-parser.ts
982
+ // src/io/schema-parser.ts
937
983
  import { readFile as readFile8 } from "fs/promises";
984
+ import path5 from "path";
938
985
  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
986
  async function parseSchema(filePath) {
984
- const content = await readFile9(filePath, "utf-8");
985
- parseYaml6(content);
986
- const schemaType = path4.basename(filePath, path4.extname(filePath));
987
+ const content = await readFile8(filePath, "utf-8");
988
+ parseYaml5(content);
989
+ const schemaType = path5.basename(filePath, path5.extname(filePath));
987
990
  return { schemaType };
988
991
  }
989
992
 
990
993
  // src/utils/paths.ts
991
- import path5 from "path";
994
+ import path6 from "path";
992
995
  import { fileURLToPath as fileURLToPath2 } from "url";
993
996
  import { stat as stat2 } from "fs/promises";
994
997
  async function findYggRoot(projectRoot) {
995
- let current = path5.resolve(projectRoot);
996
- const root = path5.parse(current).root;
998
+ let current = path6.resolve(projectRoot);
999
+ const root = path6.parse(current).root;
997
1000
  while (true) {
998
- const yggPath = path5.join(current, ".yggdrasil");
1001
+ const yggPath = path6.join(current, ".yggdrasil");
999
1002
  try {
1000
1003
  const st = await stat2(yggPath);
1001
1004
  if (!st.isDirectory()) {
@@ -1009,7 +1012,7 @@ async function findYggRoot(projectRoot) {
1009
1012
  if (current === root) {
1010
1013
  throw new Error(`No .yggdrasil/ directory found. Run 'yg init' first.`, { cause: err });
1011
1014
  }
1012
- current = path5.dirname(current);
1015
+ current = path6.dirname(current);
1013
1016
  continue;
1014
1017
  }
1015
1018
  throw err;
@@ -1025,41 +1028,39 @@ function normalizeProjectRelativePath(projectRoot, rawPath) {
1025
1028
  if (normalizedInput.length === 0) {
1026
1029
  throw new Error("Path cannot be empty");
1027
1030
  }
1028
- const absolute = path5.resolve(projectRoot, normalizedInput);
1029
- const relative = path5.relative(projectRoot, absolute);
1030
- const isOutside = relative.startsWith("..") || path5.isAbsolute(relative);
1031
+ const absolute = path6.resolve(projectRoot, normalizedInput);
1032
+ const relative = path6.relative(projectRoot, absolute);
1033
+ const isOutside = relative.startsWith("..") || path6.isAbsolute(relative);
1031
1034
  if (isOutside) {
1032
1035
  throw new Error(`Path is outside project root: ${rawPath}`);
1033
1036
  }
1034
- return relative.split(path5.sep).join("/");
1037
+ return relative.split(path6.sep).join("/");
1035
1038
  }
1036
1039
 
1037
1040
  // src/core/graph-loader.ts
1038
1041
  function toModelPath(absolutePath, modelDir) {
1039
- return path6.relative(modelDir, absolutePath).split(path6.sep).join("/");
1042
+ return path7.relative(modelDir, absolutePath).split(path7.sep).join("/");
1040
1043
  }
1041
1044
  var FALLBACK_CONFIG = {
1042
1045
  name: "",
1043
1046
  stack: {},
1044
1047
  standards: "",
1045
- tags: [],
1046
1048
  node_types: [],
1047
- artifacts: {},
1048
- knowledge_categories: []
1049
+ artifacts: {}
1049
1050
  };
1050
1051
  async function loadGraph(projectRoot, options = {}) {
1051
1052
  const yggRoot = await findYggRoot(projectRoot);
1052
1053
  let configError;
1053
1054
  let config = FALLBACK_CONFIG;
1054
1055
  try {
1055
- config = await parseConfig(path6.join(yggRoot, "config.yaml"));
1056
+ config = await parseConfig(path7.join(yggRoot, "config.yaml"));
1056
1057
  } catch (error) {
1057
1058
  if (!options.tolerateInvalidConfig) {
1058
1059
  throw error;
1059
1060
  }
1060
1061
  configError = error.message;
1061
1062
  }
1062
- const modelDir = path6.join(yggRoot, "model");
1063
+ const modelDir = path7.join(yggRoot, "model");
1063
1064
  const nodes = /* @__PURE__ */ new Map();
1064
1065
  const nodeParseErrors = [];
1065
1066
  const artifactFilenames = Object.keys(config.artifacts ?? {});
@@ -1073,13 +1074,9 @@ async function loadGraph(projectRoot, options = {}) {
1073
1074
  }
1074
1075
  throw err;
1075
1076
  }
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"));
1077
+ const aspects = await loadAspects(path7.join(yggRoot, "aspects"));
1078
+ const flows = await loadFlows(path7.join(yggRoot, "flows"));
1079
+ const schemas = await loadSchemas(path7.join(yggRoot, "schemas"));
1083
1080
  return {
1084
1081
  config,
1085
1082
  configError,
@@ -1087,7 +1084,6 @@ async function loadGraph(projectRoot, options = {}) {
1087
1084
  nodes,
1088
1085
  aspects,
1089
1086
  flows,
1090
- knowledge,
1091
1087
  schemas,
1092
1088
  rootPath: yggRoot
1093
1089
  };
@@ -1100,9 +1096,12 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
1100
1096
  }
1101
1097
  if (hasNodeYaml) {
1102
1098
  const graphPath = toModelPath(dirPath, modelDir);
1099
+ const nodeYamlPath = path7.join(dirPath, "node.yaml");
1103
1100
  let meta;
1101
+ let nodeYamlRaw;
1104
1102
  try {
1105
- meta = await parseNodeYaml(path6.join(dirPath, "node.yaml"));
1103
+ nodeYamlRaw = await readFile9(nodeYamlPath, "utf-8");
1104
+ meta = await parseNodeYaml(nodeYamlPath);
1106
1105
  } catch (err) {
1107
1106
  nodeParseErrors.push({
1108
1107
  nodePath: graphPath,
@@ -1114,6 +1113,7 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
1114
1113
  const node = {
1115
1114
  path: graphPath,
1116
1115
  meta,
1116
+ nodeYamlRaw,
1117
1117
  artifacts,
1118
1118
  children: [],
1119
1119
  parent
@@ -1126,7 +1126,7 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
1126
1126
  if (!entry.isDirectory()) continue;
1127
1127
  if (entry.name.startsWith(".")) continue;
1128
1128
  await scanModelDirectory(
1129
- path6.join(dirPath, entry.name),
1129
+ path7.join(dirPath, entry.name),
1130
1130
  modelDir,
1131
1131
  node,
1132
1132
  nodes,
@@ -1139,7 +1139,7 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
1139
1139
  if (!entry.isDirectory()) continue;
1140
1140
  if (entry.name.startsWith(".")) continue;
1141
1141
  await scanModelDirectory(
1142
- path6.join(dirPath, entry.name),
1142
+ path7.join(dirPath, entry.name),
1143
1143
  modelDir,
1144
1144
  null,
1145
1145
  nodes,
@@ -1151,27 +1151,36 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
1151
1151
  }
1152
1152
  async function loadAspects(aspectsDir) {
1153
1153
  try {
1154
- const entries = await readdir3(aspectsDir, { withFileTypes: true });
1155
1154
  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
- }
1155
+ await scanAspectsDirectory(aspectsDir, aspectsDir, aspects);
1162
1156
  return aspects;
1163
1157
  } catch {
1164
1158
  return [];
1165
1159
  }
1166
1160
  }
1161
+ async function scanAspectsDirectory(dirPath, aspectsRoot, aspects) {
1162
+ const entries = await readdir3(dirPath, { withFileTypes: true });
1163
+ const hasAspectYaml = entries.some((e) => e.isFile() && e.name === "aspect.yaml");
1164
+ if (hasAspectYaml) {
1165
+ const id = path7.relative(aspectsRoot, dirPath).split(path7.sep).join("/");
1166
+ const aspectYamlPath = path7.join(dirPath, "aspect.yaml");
1167
+ const aspect = await parseAspect(dirPath, aspectYamlPath, id);
1168
+ aspects.push(aspect);
1169
+ }
1170
+ for (const entry of entries) {
1171
+ if (!entry.isDirectory()) continue;
1172
+ if (entry.name.startsWith(".")) continue;
1173
+ await scanAspectsDirectory(path7.join(dirPath, entry.name), aspectsRoot, aspects);
1174
+ }
1175
+ }
1167
1176
  async function loadFlows(flowsDir) {
1168
1177
  try {
1169
1178
  const entries = await readdir3(flowsDir, { withFileTypes: true });
1170
1179
  const flows = [];
1171
1180
  for (const entry of entries) {
1172
1181
  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);
1182
+ const flowYamlPath = path7.join(flowsDir, entry.name, "flow.yaml");
1183
+ const flow = await parseFlow(path7.join(flowsDir, entry.name), flowYamlPath);
1175
1184
  flows.push(flow);
1176
1185
  }
1177
1186
  return flows;
@@ -1179,37 +1188,14 @@ async function loadFlows(flowsDir) {
1179
1188
  return [];
1180
1189
  }
1181
1190
  }
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) {
1191
+ async function loadSchemas(schemasDir) {
1206
1192
  try {
1207
- const entries = await readdir3(templatesDir, { withFileTypes: true });
1193
+ const entries = await readdir3(schemasDir, { withFileTypes: true });
1208
1194
  const schemas = [];
1209
1195
  for (const entry of entries) {
1210
1196
  if (!entry.isFile()) continue;
1211
1197
  if (!entry.name.endsWith(".yaml") && !entry.name.endsWith(".yml")) continue;
1212
- const s = await parseSchema(path6.join(templatesDir, entry.name));
1198
+ const s = await parseSchema(path7.join(schemasDir, entry.name));
1213
1199
  schemas.push(s);
1214
1200
  }
1215
1201
  return schemas;
@@ -1220,7 +1206,7 @@ async function loadSchemas(templatesDir) {
1220
1206
 
1221
1207
  // src/core/context-builder.ts
1222
1208
  import { readFile as readFile10 } from "fs/promises";
1223
- import path7 from "path";
1209
+ import path8 from "path";
1224
1210
 
1225
1211
  // src/utils/tokens.ts
1226
1212
  function estimateTokens(text) {
@@ -1235,18 +1221,13 @@ async function buildContext(graph, nodePath) {
1235
1221
  if (!node) {
1236
1222
  throw new Error(`Node not found: ${nodePath}`);
1237
1223
  }
1238
- const nodeTags = new Set(node.meta.tags ?? []);
1239
- const seenKnowledge = /* @__PURE__ */ new Set();
1240
1224
  const layers = [];
1241
1225
  layers.push(buildGlobalLayer(graph.config));
1242
- for (const k of collectKnowledgeItems(graph, nodePath, nodeTags, seenKnowledge)) {
1243
- layers.push(buildKnowledgeLayer(k));
1244
- }
1245
1226
  const ancestors = collectAncestors(node);
1246
1227
  for (const ancestor of ancestors) {
1247
- layers.push(buildHierarchyLayer(ancestor, graph.config));
1228
+ layers.push(buildHierarchyLayer(ancestor, graph.config, graph));
1248
1229
  }
1249
- layers.push(await buildOwnLayer(node, graph.config, graph.rootPath));
1230
+ layers.push(await buildOwnLayer(node, graph.config, graph.rootPath, graph));
1250
1231
  for (const relation of node.meta.relations ?? []) {
1251
1232
  const target = graph.nodes.get(relation.target);
1252
1233
  if (!target) {
@@ -1258,24 +1239,22 @@ async function buildContext(graph, nodePath) {
1258
1239
  layers.push(buildEventRelationLayer(target, relation));
1259
1240
  }
1260
1241
  }
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
1242
  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));
1243
+ layers.push(buildFlowLayer(flow, graph));
1244
+ }
1245
+ const allAspectIds = /* @__PURE__ */ new Set();
1246
+ for (const l of layers) {
1247
+ const aspects = l.attrs?.aspects;
1248
+ if (aspects) {
1249
+ for (const id of aspects.split(",").map((t) => t.trim()).filter(Boolean)) {
1250
+ allAspectIds.add(id);
1276
1251
  }
1277
1252
  }
1278
1253
  }
1254
+ const aspectsToInclude = resolveAspects(allAspectIds, graph.aspects);
1255
+ for (const aspect of aspectsToInclude) {
1256
+ layers.push(buildAspectLayer(aspect));
1257
+ }
1279
1258
  const fullText = layers.map((l) => l.content).join("\n\n");
1280
1259
  const tokenCount = estimateTokens(fullText);
1281
1260
  const mapping = normalizeMappingPaths(node.meta.mapping);
@@ -1289,47 +1268,46 @@ async function buildContext(graph, nodePath) {
1289
1268
  tokenCount
1290
1269
  };
1291
1270
  }
1292
- function collectKnowledgeItems(graph, nodePath, nodeTags, seenKnowledge) {
1271
+ function collectParticipatingFlows(graph, node) {
1272
+ const paths = /* @__PURE__ */ new Set([node.path, ...collectAncestors(node).map((a) => a.path)]);
1273
+ return graph.flows.filter((f) => f.nodes.some((n) => paths.has(n)));
1274
+ }
1275
+ function expandAspects(aspectIds, aspects) {
1276
+ const idToAspect = /* @__PURE__ */ new Map();
1277
+ for (const a of aspects) {
1278
+ idToAspect.set(a.id, a);
1279
+ }
1293
1280
  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
- }
1299
- }
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
- }
1281
+ const visited = /* @__PURE__ */ new Set();
1282
+ const stack = /* @__PURE__ */ new Set();
1283
+ function collect(id) {
1284
+ if (stack.has(id)) {
1285
+ throw new Error(`Aspect implies cycle detected involving aspect '${id}'`);
1307
1286
  }
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);
1287
+ if (visited.has(id)) return;
1288
+ stack.add(id);
1289
+ visited.add(id);
1290
+ result.push(id);
1291
+ const aspect = idToAspect.get(id);
1292
+ if (aspect) {
1293
+ for (const implied of aspect.implies ?? []) {
1294
+ collect(implied);
1314
1295
  }
1315
1296
  }
1297
+ stack.delete(id);
1316
1298
  }
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
- }
1299
+ for (const id of aspectIds) {
1300
+ collect(id);
1327
1301
  }
1328
1302
  return result;
1329
1303
  }
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)));
1304
+ function resolveAspects(aspectIds, aspects) {
1305
+ const idToAspect = /* @__PURE__ */ new Map();
1306
+ for (const a of aspects) {
1307
+ idToAspect.set(a.id, a);
1308
+ }
1309
+ const expandedIds = expandAspects([...aspectIds], aspects);
1310
+ return expandedIds.map((id) => idToAspect.get(id)).filter((a) => a !== void 0);
1333
1311
  }
1334
1312
  function buildGlobalLayer(config) {
1335
1313
  let content = `**Project:** ${config.name}
@@ -1347,41 +1325,39 @@ ${config.standards || "(none)"}
1347
1325
  `;
1348
1326
  return { type: "global", label: "Global Context", content };
1349
1327
  }
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
1328
  function filterArtifactsByConfig(artifacts, config) {
1362
1329
  const allowed = new Set(Object.keys(config.artifacts ?? {}));
1363
1330
  return artifacts.filter((a) => allowed.has(a.filename));
1364
1331
  }
1365
- function buildHierarchyLayer(ancestor, config) {
1332
+ function buildHierarchyLayer(ancestor, config, graph) {
1366
1333
  const filtered = filterArtifactsByConfig(ancestor.artifacts, config);
1367
1334
  const content = filtered.map((a) => `### ${a.filename}
1368
1335
  ${a.content}`).join("\n\n");
1336
+ const nodeAspects = ancestor.meta.aspects ?? [];
1337
+ const expanded = expandAspects(nodeAspects, graph.aspects);
1338
+ const attrs = expanded.length > 0 ? { aspects: expanded.join(",") } : void 0;
1369
1339
  return {
1370
1340
  type: "hierarchy",
1371
1341
  label: `Module Context (${ancestor.path}/)`,
1372
- content
1342
+ content,
1343
+ attrs
1373
1344
  };
1374
1345
  }
1375
- async function buildOwnLayer(node, config, graphRootPath) {
1346
+ async function buildOwnLayer(node, config, graphRootPath, graph) {
1376
1347
  const parts = [];
1377
- const nodeYamlPath = path7.join(graphRootPath, "model", node.path, "node.yaml");
1378
- try {
1379
- const nodeYamlContent = await readFile10(nodeYamlPath, "utf-8");
1348
+ if (node.nodeYamlRaw) {
1380
1349
  parts.push(`### node.yaml
1350
+ ${node.nodeYamlRaw.trim()}`);
1351
+ } else {
1352
+ const nodeYamlPath = path8.join(graphRootPath, "model", node.path, "node.yaml");
1353
+ try {
1354
+ const nodeYamlContent = await readFile10(nodeYamlPath, "utf-8");
1355
+ parts.push(`### node.yaml
1381
1356
  ${nodeYamlContent.trim()}`);
1382
- } catch {
1383
- parts.push(`### node.yaml
1357
+ } catch {
1358
+ parts.push(`### node.yaml
1384
1359
  (not found)`);
1360
+ }
1385
1361
  }
1386
1362
  const filtered = filterArtifactsByConfig(node.artifacts, config);
1387
1363
  for (const a of filtered) {
@@ -1389,10 +1365,14 @@ ${nodeYamlContent.trim()}`);
1389
1365
  ${a.content}`);
1390
1366
  }
1391
1367
  const content = parts.join("\n\n");
1368
+ const nodeAspects = node.meta.aspects ?? [];
1369
+ const expanded = expandAspects(nodeAspects, graph.aspects);
1370
+ const attrs = expanded.length > 0 ? { aspects: expanded.join(",") } : void 0;
1392
1371
  return {
1393
1372
  type: "own",
1394
1373
  label: `Node: ${node.meta.name}`,
1395
- content
1374
+ content,
1375
+ attrs
1396
1376
  };
1397
1377
  }
1398
1378
  function buildStructuralRelationLayer(target, relation, config) {
@@ -1420,10 +1400,17 @@ ${a.content}`).join("\n\n");
1420
1400
  content += filtered.map((a) => `### ${a.filename}
1421
1401
  ${a.content}`).join("\n\n");
1422
1402
  }
1403
+ const attrs = {
1404
+ target: target.path,
1405
+ type: relation.type
1406
+ };
1407
+ if (relation.consumes?.length) attrs.consumes = relation.consumes.join(", ");
1408
+ if (relation.failure) attrs.failure = relation.failure;
1423
1409
  return {
1424
1410
  type: "relational",
1425
1411
  label: `Dependency: ${target.meta.name} (${relation.type}) \u2014 ${target.path}`,
1426
- content: content.trim()
1412
+ content: content.trim(),
1413
+ attrs
1427
1414
  };
1428
1415
  }
1429
1416
  function buildEventRelationLayer(target, relation) {
@@ -1436,10 +1423,17 @@ You listen for ${eventName}.`;
1436
1423
  content += `
1437
1424
  Consumes: ${relation.consumes.join(", ")}`;
1438
1425
  }
1426
+ const attrs = {
1427
+ target: target.path,
1428
+ type: relation.type,
1429
+ "event-name": eventName
1430
+ };
1431
+ if (relation.consumes?.length) attrs.consumes = relation.consumes.join(", ");
1439
1432
  return {
1440
1433
  type: "relational",
1441
1434
  label: `Event: ${eventName} [${relation.type}]`,
1442
- content
1435
+ content,
1436
+ attrs
1443
1437
  };
1444
1438
  }
1445
1439
  function buildAspectLayer(aspect) {
@@ -1447,17 +1441,21 @@ function buildAspectLayer(aspect) {
1447
1441
  ${a.content}`).join("\n\n");
1448
1442
  return {
1449
1443
  type: "aspects",
1450
- label: `${aspect.name} (tag: ${aspect.tag})`,
1444
+ label: `${aspect.name} (aspect: ${aspect.id})`,
1451
1445
  content
1452
1446
  };
1453
1447
  }
1454
- function buildFlowLayer(flow) {
1448
+ function buildFlowLayer(flow, graph) {
1455
1449
  const content = flow.artifacts.map((a) => `### ${a.filename}
1456
1450
  ${a.content}`).join("\n\n");
1451
+ const flowAspects = flow.aspects ?? [];
1452
+ const expanded = expandAspects(flowAspects, graph.aspects);
1453
+ const attrs = expanded.length > 0 ? { aspects: expanded.join(",") } : void 0;
1457
1454
  return {
1458
1455
  type: "flows",
1459
1456
  label: `Flow: ${flow.name}`,
1460
- content: content || "(no artifacts)"
1457
+ content: content || "(no artifacts)",
1458
+ attrs
1461
1459
  };
1462
1460
  }
1463
1461
  function buildSections(layers, mapping) {
@@ -1471,12 +1469,16 @@ function buildSections(layers, mapping) {
1471
1469
  }
1472
1470
  return [
1473
1471
  { key: "Global", layers: layers.filter((l) => l.type === "global") },
1474
- { key: "Knowledge", layers: layers.filter((l) => l.type === "knowledge") },
1475
1472
  { key: "Hierarchy", layers: layers.filter((l) => l.type === "hierarchy") },
1476
1473
  { key: "OwnArtifacts", layers: ownLayers },
1477
- { key: "Dependencies", layers: layers.filter((l) => l.type === "relational") },
1478
1474
  { key: "Aspects", layers: layers.filter((l) => l.type === "aspects") },
1479
- { key: "Flows", layers: layers.filter((l) => l.type === "flows") }
1475
+ {
1476
+ key: "Relational",
1477
+ layers: [
1478
+ ...layers.filter((l) => l.type === "relational"),
1479
+ ...layers.filter((l) => l.type === "flows")
1480
+ ]
1481
+ }
1480
1482
  ];
1481
1483
  }
1482
1484
  function collectAncestors(node) {
@@ -1488,30 +1490,27 @@ function collectAncestors(node) {
1488
1490
  }
1489
1491
  return ancestors;
1490
1492
  }
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;
1493
+ function collectEffectiveAspectIds(graph, nodePath) {
1494
+ const node = graph.nodes.get(nodePath);
1495
+ if (!node) return /* @__PURE__ */ new Set();
1496
+ const raw = new Set(node.meta.aspects ?? []);
1497
+ let ancestor = node.parent;
1498
+ while (ancestor) {
1499
+ for (const id of ancestor.meta.aspects ?? []) raw.add(id);
1500
+ ancestor = ancestor.parent;
1501
+ }
1502
+ const ancestorPaths = /* @__PURE__ */ new Set([nodePath, ...collectAncestors(node).map((a) => a.path)]);
1503
+ for (const flow of graph.flows) {
1504
+ if (flow.nodes.some((n) => ancestorPaths.has(n))) {
1505
+ for (const id of flow.aspects ?? []) raw.add(id);
1506
+ }
1511
1507
  }
1508
+ return new Set(expandAspects([...raw], graph.aspects));
1512
1509
  }
1513
1510
 
1514
1511
  // src/core/validator.ts
1512
+ import { readdir as readdir4 } from "fs/promises";
1513
+ import path9 from "path";
1515
1514
  var RESERVED_DIRS = /* @__PURE__ */ new Set();
1516
1515
  async function validate(graph, scope = "all") {
1517
1516
  const issues = [];
@@ -1534,28 +1533,25 @@ async function validate(graph, scope = "all") {
1534
1533
  }
1535
1534
  if (!graph.configError) {
1536
1535
  issues.push(...checkNodeTypes(graph));
1537
- issues.push(...checkTagsDefined(graph));
1538
- issues.push(...checkAspectTags(graph));
1539
- issues.push(...checkAspectTagUniqueness(graph));
1536
+ issues.push(...checkAspectsDefined(graph));
1537
+ issues.push(...checkAspectIds(graph));
1538
+ issues.push(...checkAspectIdUniqueness(graph));
1539
+ issues.push(...checkImpliedAspectsExist(graph));
1540
+ issues.push(...checkImpliesNoCycles(graph));
1541
+ issues.push(...checkRequiredAspectsCoverage(graph));
1540
1542
  issues.push(...checkRequiredArtifacts(graph));
1541
- issues.push(...await checkUnknownKnowledgeCategories(graph));
1542
1543
  issues.push(...checkInvalidArtifactConditions(graph));
1543
- issues.push(...checkScopeTagsDefined(graph));
1544
- issues.push(...await checkMissingPatternExamples(graph));
1545
1544
  issues.push(...await checkContextBudget(graph));
1546
1545
  issues.push(...checkHighFanOut(graph));
1547
- issues.push(...await checkStaleKnowledge(graph));
1548
1546
  }
1549
1547
  issues.push(...checkSchemas(graph));
1550
1548
  issues.push(...checkRelationTargets(graph));
1551
1549
  issues.push(...checkNoCycles(graph));
1552
1550
  issues.push(...checkMappingOverlap(graph));
1553
- issues.push(...checkBrokenKnowledgeRefs(graph));
1554
1551
  issues.push(...checkBrokenFlowRefs(graph));
1555
- issues.push(...checkBrokenScopeRefs(graph));
1552
+ issues.push(...checkFlowAspectIds(graph));
1556
1553
  issues.push(...await checkDirectoriesHaveNodeYaml(graph));
1557
1554
  issues.push(...await checkShallowArtifacts(graph));
1558
- issues.push(...await checkUnreachableKnowledge(graph));
1559
1555
  issues.push(...checkUnpairedEvents(graph));
1560
1556
  let filtered = issues;
1561
1557
  let nodesScanned = graph.nodes.size;
@@ -1573,7 +1569,7 @@ async function validate(graph, scope = "all") {
1573
1569
  }
1574
1570
  function checkNodeTypes(graph) {
1575
1571
  const issues = [];
1576
- const allowedTypes = new Set(graph.config.node_types ?? []);
1572
+ const allowedTypes = new Set((graph.config.node_types ?? []).map((t) => t.name));
1577
1573
  for (const [nodePath, node] of graph.nodes) {
1578
1574
  if (!allowedTypes.has(node.meta.type)) {
1579
1575
  issues.push({
@@ -1636,17 +1632,17 @@ function checkRelationTargets(graph) {
1636
1632
  }
1637
1633
  return issues;
1638
1634
  }
1639
- function checkTagsDefined(graph) {
1635
+ function checkAspectsDefined(graph) {
1640
1636
  const issues = [];
1641
- const definedTags = new Set(graph.config.tags ?? []);
1637
+ const validAspectIds = new Set(graph.aspects.map((a) => a.id));
1642
1638
  for (const [nodePath, node] of graph.nodes) {
1643
- for (const tag of node.meta.tags ?? []) {
1644
- if (!definedTags.has(tag)) {
1639
+ for (const aspectId of node.meta.aspects ?? []) {
1640
+ if (!validAspectIds.has(aspectId)) {
1645
1641
  issues.push({
1646
1642
  severity: "error",
1647
1643
  code: "E003",
1648
- rule: "unknown-tag",
1649
- message: `Tag '${tag}' not defined in config.yaml`,
1644
+ rule: "unknown-aspect",
1645
+ message: `Aspect '${aspectId}' has no corresponding directory in aspects/`,
1650
1646
  nodePath
1651
1647
  });
1652
1648
  }
@@ -1654,40 +1650,124 @@ function checkTagsDefined(graph) {
1654
1650
  }
1655
1651
  return issues;
1656
1652
  }
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;
1653
+ function checkAspectIds(_graph) {
1654
+ return [];
1671
1655
  }
1672
- function checkAspectTagUniqueness(graph) {
1656
+ function checkAspectIdUniqueness(graph) {
1673
1657
  const issues = [];
1674
- const byTag = /* @__PURE__ */ new Map();
1658
+ const byId = /* @__PURE__ */ new Map();
1675
1659
  for (const aspect of graph.aspects) {
1676
- const names = byTag.get(aspect.tag) ?? [];
1660
+ const names = byId.get(aspect.id) ?? [];
1677
1661
  names.push(aspect.name);
1678
- byTag.set(aspect.tag, names);
1662
+ byId.set(aspect.id, names);
1679
1663
  }
1680
- for (const [tag, names] of byTag) {
1664
+ for (const [id, names] of byId) {
1681
1665
  if (names.length <= 1) continue;
1682
1666
  issues.push({
1683
1667
  severity: "error",
1684
1668
  code: "E014",
1685
1669
  rule: "duplicate-aspect-binding",
1686
- message: `Tag '${tag}' is bound to multiple aspects (${names.join(", ")})`
1670
+ message: `Aspect '${id}' is bound to multiple aspects (${names.join(", ")})`
1687
1671
  });
1688
1672
  }
1689
1673
  return issues;
1690
1674
  }
1675
+ function checkImpliedAspectsExist(graph) {
1676
+ const issues = [];
1677
+ const idToAspect = /* @__PURE__ */ new Map();
1678
+ for (const a of graph.aspects) {
1679
+ idToAspect.set(a.id, { name: a.name });
1680
+ }
1681
+ for (const aspect of graph.aspects) {
1682
+ for (const impliedId of aspect.implies ?? []) {
1683
+ if (!idToAspect.has(impliedId)) {
1684
+ issues.push({
1685
+ severity: "error",
1686
+ code: "E016",
1687
+ rule: "implied-aspect-missing",
1688
+ message: `Aspect '${aspect.name}' implies '${impliedId}' but no aspect with that id exists in aspects/`
1689
+ });
1690
+ }
1691
+ }
1692
+ }
1693
+ return issues;
1694
+ }
1695
+ function checkImpliesNoCycles(graph) {
1696
+ const idToAspect = /* @__PURE__ */ new Map();
1697
+ for (const a of graph.aspects) {
1698
+ idToAspect.set(a.id, { implies: a.implies });
1699
+ }
1700
+ const WHITE = 0;
1701
+ const GRAY = 1;
1702
+ const BLACK = 2;
1703
+ const color = /* @__PURE__ */ new Map();
1704
+ for (const id of idToAspect.keys()) color.set(id, WHITE);
1705
+ const issues = [];
1706
+ function dfs(id, pathArr) {
1707
+ color.set(id, GRAY);
1708
+ pathArr.push(id);
1709
+ const aspect = idToAspect.get(id);
1710
+ for (const implied of aspect?.implies ?? []) {
1711
+ if (color.get(implied) === GRAY) {
1712
+ const cycle = pathArr.slice(pathArr.indexOf(implied)).concat(implied);
1713
+ issues.push({
1714
+ severity: "error",
1715
+ code: "E017",
1716
+ rule: "aspect-implies-cycle",
1717
+ message: `Aspect implies cycle: ${cycle.join(" \u2192 ")}`
1718
+ });
1719
+ pathArr.pop();
1720
+ color.set(id, BLACK);
1721
+ return true;
1722
+ }
1723
+ if (color.get(implied) === WHITE && dfs(implied, pathArr)) {
1724
+ pathArr.pop();
1725
+ color.set(id, BLACK);
1726
+ return true;
1727
+ }
1728
+ }
1729
+ pathArr.pop();
1730
+ color.set(id, BLACK);
1731
+ return false;
1732
+ }
1733
+ for (const id of idToAspect.keys()) {
1734
+ if (color.get(id) === WHITE) {
1735
+ dfs(id, []);
1736
+ }
1737
+ }
1738
+ return issues;
1739
+ }
1740
+ function checkRequiredAspectsCoverage(graph) {
1741
+ const issues = [];
1742
+ const typeConfig = new Map(
1743
+ (graph.config.node_types ?? []).map((t) => [t.name, t.required_aspects ?? []])
1744
+ );
1745
+ for (const [nodePath, node] of graph.nodes) {
1746
+ if (node.meta.blackbox) continue;
1747
+ const requiredAspects = typeConfig.get(node.meta.type);
1748
+ if (!requiredAspects || requiredAspects.length === 0) continue;
1749
+ const nodeAspects = node.meta.aspects ?? [];
1750
+ let effectiveAspects;
1751
+ try {
1752
+ effectiveAspects = resolveAspects(nodeAspects, graph.aspects);
1753
+ } catch {
1754
+ continue;
1755
+ }
1756
+ const effectiveAspectIds = new Set(effectiveAspects.map((a) => a.id));
1757
+ for (const required of requiredAspects) {
1758
+ if (!effectiveAspectIds.has(required)) {
1759
+ issues.push({
1760
+ severity: "warning",
1761
+ code: "W011",
1762
+ rule: "missing-required-aspect-coverage",
1763
+ message: `Node '${nodePath}' (type: ${node.meta.type}) missing required aspect coverage for '${required}'`,
1764
+ nodePath
1765
+ });
1766
+ }
1767
+ }
1768
+ }
1769
+ return issues;
1770
+ }
1691
1771
  function checkNoCycles(graph) {
1692
1772
  const WHITE = 0;
1693
1773
  const GRAY = 1;
@@ -1789,9 +1869,10 @@ function artifactRequiredReason(graph, nodePath, node, required) {
1789
1869
  const count = node.meta.relations?.length ?? 0;
1790
1870
  return count > 0 ? `${count} outgoing relation(s)` : null;
1791
1871
  }
1792
- if (when.startsWith("has_tag:")) {
1793
- const tag = when.slice(8);
1794
- return (node.meta.tags ?? []).includes(tag) ? `node has tag '${tag}'` : null;
1872
+ if (when.startsWith("has_aspect:") || when.startsWith("has_tag:")) {
1873
+ const prefix = when.startsWith("has_aspect:") ? "has_aspect:" : "has_tag:";
1874
+ const aspectId = when.slice(prefix.length);
1875
+ return (node.meta.aspects ?? []).includes(aspectId) ? `node has aspect '${aspectId}'` : null;
1795
1876
  }
1796
1877
  return null;
1797
1878
  }
@@ -1831,29 +1912,9 @@ function checkRequiredArtifacts(graph) {
1831
1912
  }
1832
1913
  return issues;
1833
1914
  }
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
1915
  function checkBrokenFlowRefs(graph) {
1854
1916
  const issues = [];
1855
1917
  const nodePaths = new Set(graph.nodes.keys());
1856
- const knowledgePaths = new Set(graph.knowledge.map((k) => k.path));
1857
1918
  for (const flow of graph.flows) {
1858
1919
  for (const n of flow.nodes) {
1859
1920
  if (!nodePaths.has(n)) {
@@ -1865,107 +1926,43 @@ function checkBrokenFlowRefs(graph) {
1865
1926
  });
1866
1927
  }
1867
1928
  }
1868
- for (const kPath of flow.knowledge ?? []) {
1869
- const norm = kPath.replace(/\/$/, "");
1870
- if (!knowledgePaths.has(norm) && !knowledgePaths.has(kPath)) {
1871
- issues.push({
1872
- 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}`
1877
- });
1878
- }
1879
- }
1880
- }
1881
- return issues;
1882
- }
1883
- function checkBrokenScopeRefs(graph) {
1884
- 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
1929
  }
1917
1930
  return issues;
1918
1931
  }
1919
- async function checkUnknownKnowledgeCategories(graph) {
1932
+ function checkFlowAspectIds(graph) {
1920
1933
  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)) {
1934
+ const validAspectIds = new Set(graph.aspects.map((a) => a.id));
1935
+ for (const flow of graph.flows) {
1936
+ for (const aspectId of flow.aspects ?? []) {
1937
+ if (!validAspectIds.has(aspectId)) {
1931
1938
  issues.push({
1932
1939
  severity: "error",
1933
- code: "E011",
1934
- rule: "unknown-knowledge-category",
1935
- message: `Directory knowledge/${e.name}/ does not match any config.knowledge_categories`
1940
+ code: "E007",
1941
+ rule: "broken-aspect-ref",
1942
+ message: `Flow '${flow.name}' references aspect '${aspectId}' but no aspect with that id exists in aspects/`
1936
1943
  });
1937
1944
  }
1938
1945
  }
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
1946
  }
1951
1947
  return issues;
1952
1948
  }
1953
1949
  function checkInvalidArtifactConditions(graph) {
1954
1950
  const issues = [];
1955
- const definedTags = new Set(graph.config.tags ?? []);
1951
+ const validAspectIds = new Set(graph.aspects.map((a) => a.id));
1956
1952
  const artifacts = graph.config.artifacts ?? {};
1957
1953
  for (const [artifactName, config] of Object.entries(artifacts)) {
1958
1954
  const required = config.required;
1959
1955
  if (typeof required === "object" && required && "when" in required) {
1960
1956
  const when = required.when;
1961
- if (when.startsWith("has_tag:")) {
1962
- const tag = when.slice(8);
1963
- if (!definedTags.has(tag)) {
1957
+ if (when.startsWith("has_aspect:") || when.startsWith("has_tag:")) {
1958
+ const prefix = when.startsWith("has_aspect:") ? "has_aspect:" : "has_tag:";
1959
+ const aspectId = when.slice(prefix.length);
1960
+ if (!validAspectIds.has(aspectId)) {
1964
1961
  issues.push({
1965
1962
  severity: "error",
1966
1963
  code: "E013",
1967
1964
  rule: "invalid-artifact-condition",
1968
- message: `Artifact '${artifactName}' condition has_tag:${tag} references undefined tag`
1965
+ message: `Artifact '${artifactName}' condition has_aspect:${aspectId} has no corresponding aspect in aspects/`
1969
1966
  });
1970
1967
  }
1971
1968
  }
@@ -1983,7 +1980,7 @@ async function checkShallowArtifacts(graph) {
1983
1980
  severity: "warning",
1984
1981
  code: "W002",
1985
1982
  rule: "shallow-artifact",
1986
- message: `Artifact '${art.filename}' is below minimum length (${art.content.length} < ${minLen})`,
1983
+ message: `Artifact '${art.filename}' is below minimum length (${art.content.trim().length} < ${minLen})`,
1987
1984
  nodePath
1988
1985
  });
1989
1986
  }
@@ -1991,100 +1988,7 @@ async function checkShallowArtifacts(graph) {
1991
1988
  }
1992
1989
  return issues;
1993
1990
  }
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
- function checkHighFanOut(graph) {
1991
+ function checkHighFanOut(graph) {
2088
1992
  const issues = [];
2089
1993
  const maxRel = graph.config.quality?.max_direct_relations ?? 10;
2090
1994
  for (const [nodePath, node] of graph.nodes) {
@@ -2101,57 +2005,6 @@ function checkHighFanOut(graph) {
2101
2005
  }
2102
2006
  return issues;
2103
2007
  }
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
2008
  function checkUnpairedEvents(graph) {
2156
2009
  const issues = [];
2157
2010
  const emitsTo = /* @__PURE__ */ new Map();
@@ -2200,7 +2053,7 @@ function checkUnpairedEvents(graph) {
2200
2053
  }
2201
2054
  return issues;
2202
2055
  }
2203
- var REQUIRED_SCHEMAS = ["node", "aspect", "flow", "knowledge"];
2056
+ var REQUIRED_SCHEMAS = ["node", "aspect", "flow"];
2204
2057
  function checkSchemas(graph) {
2205
2058
  const issues = [];
2206
2059
  const present = new Set(graph.schemas.map((s) => s.schemaType));
@@ -2210,7 +2063,7 @@ function checkSchemas(graph) {
2210
2063
  severity: "warning",
2211
2064
  code: "W010",
2212
2065
  rule: "missing-schema",
2213
- message: `Schema '${required}.yaml' missing from .yggdrasil/templates/`
2066
+ message: `Schema '${required}.yaml' missing from .yggdrasil/schemas/`
2214
2067
  });
2215
2068
  }
2216
2069
  }
@@ -2255,26 +2108,26 @@ async function checkDirectoriesHaveNodeYaml(graph) {
2255
2108
  }
2256
2109
  async function checkContextBudget(graph) {
2257
2110
  const issues = [];
2258
- const warningThreshold = graph.config.quality?.context_budget.warning ?? 5e3;
2259
- const errorThreshold = graph.config.quality?.context_budget.error ?? 1e4;
2111
+ const warningThreshold = graph.config.quality?.context_budget.warning ?? 1e4;
2112
+ const errorThreshold = graph.config.quality?.context_budget.error ?? 2e4;
2260
2113
  for (const [nodePath, node] of graph.nodes) {
2261
2114
  if (node.meta.blackbox) continue;
2262
2115
  try {
2263
- const pkg = await buildContext(graph, nodePath);
2264
- if (pkg.tokenCount >= errorThreshold) {
2116
+ const pkg2 = await buildContext(graph, nodePath);
2117
+ if (pkg2.tokenCount >= errorThreshold) {
2265
2118
  issues.push({
2266
2119
  severity: "warning",
2267
2120
  code: "W006",
2268
2121
  rule: "budget-error",
2269
- message: `Context is ${pkg.tokenCount.toLocaleString()} tokens (error threshold: ${errorThreshold.toLocaleString()}) \u2014 blocks materialization, node must be split`,
2122
+ message: `Context is ${pkg2.tokenCount.toLocaleString()} tokens (error threshold: ${errorThreshold.toLocaleString()}) \u2014 blocks materialization, node must be split`,
2270
2123
  nodePath
2271
2124
  });
2272
- } else if (pkg.tokenCount >= warningThreshold) {
2125
+ } else if (pkg2.tokenCount >= warningThreshold) {
2273
2126
  issues.push({
2274
2127
  severity: "warning",
2275
2128
  code: "W005",
2276
2129
  rule: "budget-warning",
2277
- message: `Context is ${pkg.tokenCount.toLocaleString()} tokens (warning threshold: ${warningThreshold.toLocaleString()}). Consider splitting the node or reducing dependencies.`,
2130
+ message: `Context is ${pkg2.tokenCount.toLocaleString()} tokens (warning threshold: ${warningThreshold.toLocaleString()}). Consider splitting the node or reducing dependencies.`,
2278
2131
  nodePath
2279
2132
  });
2280
2133
  }
@@ -2284,42 +2137,76 @@ async function checkContextBudget(graph) {
2284
2137
  return issues;
2285
2138
  }
2286
2139
 
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}
2140
+ // src/formatters/context-text.ts
2141
+ function escapeAttr(val) {
2142
+ return val.replace(/"/g, "&quot;");
2143
+ }
2144
+ function formatLayer(layer) {
2145
+ switch (layer.type) {
2146
+ case "global":
2147
+ return `<global>
2148
+ ${layer.content}
2149
+ </global>`;
2150
+ case "hierarchy": {
2151
+ const pathMatch = layer.label.match(/\((.+)\/\)/);
2152
+ const pathAttr = pathMatch ? ` path="${escapeAttr(pathMatch[1])}"` : "";
2153
+ const aspectsAttr = layer.attrs?.aspects ? ` aspects="${escapeAttr(layer.attrs.aspects)}"` : "";
2154
+ return `<hierarchy${pathAttr}${aspectsAttr}>
2155
+ ${layer.content}
2156
+ </hierarchy>`;
2157
+ }
2158
+ case "own": {
2159
+ if (layer.label === "Materialization Target") {
2160
+ return `<materialization-target paths="${escapeAttr(layer.content)}" />`;
2161
+ }
2162
+ const ownAspectsAttr = layer.attrs?.aspects ? ` aspects="${escapeAttr(layer.attrs.aspects)}"` : "";
2163
+ return `<own-artifacts${ownAspectsAttr}>
2164
+ ${layer.content}
2165
+ </own-artifacts>`;
2166
+ }
2167
+ case "aspects": {
2168
+ const nameMatch = layer.label.match(/^(.+?) \(aspect: (.+)\)$/);
2169
+ const name = nameMatch ? escapeAttr(nameMatch[1]) : "";
2170
+ const id = nameMatch ? escapeAttr(nameMatch[2]) : "";
2171
+ return `<aspect name="${name}" id="${id}">
2172
+ ${layer.content}
2173
+ </aspect>`;
2174
+ }
2175
+ case "relational": {
2176
+ const attrs = layer.attrs ?? {};
2177
+ const attrStr = Object.entries(attrs).map(([k, v]) => ` ${k}="${escapeAttr(v)}"`).join("");
2178
+ const tagName = attrs.type && ["emits", "listens"].includes(attrs.type) ? "event" : "dependency";
2179
+ return `<${tagName}${attrStr}>
2180
+ ${layer.content}
2181
+ </${tagName}>`;
2182
+ }
2183
+ case "flows": {
2184
+ const flowName = layer.label.replace(/^Flow: /, "").trim();
2185
+ const flowAspectsAttr = layer.attrs?.aspects ? ` aspects="${escapeAttr(layer.attrs.aspects)}"` : "";
2186
+ return `<flow name="${escapeAttr(flowName)}"${flowAspectsAttr}>
2187
+ ${layer.content}
2188
+ </flow>`;
2189
+ }
2190
+ default:
2191
+ return layer.content;
2192
+ }
2193
+ }
2194
+ function formatContextText(pkg2) {
2195
+ const attrs = [
2196
+ `node-path="${escapeAttr(pkg2.nodePath)}"`,
2197
+ `node-name="${escapeAttr(pkg2.nodeName)}"`,
2198
+ `token-count="${pkg2.tokenCount}"`
2199
+ ].join(" ");
2200
+ let out = `<context-package ${attrs}>
2303
2201
 
2304
2202
  `;
2203
+ for (const section of pkg2.sections) {
2305
2204
  for (const layer of section.layers) {
2306
- md += `### ${layer.label}
2307
-
2308
- `;
2309
- md += layer.content;
2310
- md += `
2311
-
2312
- `;
2205
+ out += formatLayer(layer) + "\n\n";
2313
2206
  }
2314
- md += `---
2315
-
2316
- `;
2317
2207
  }
2318
- md += `Context size: ${pkg.tokenCount.toLocaleString()} tokens
2319
- `;
2320
- md += `Layers: ${pkg.layers.map((l) => l.type).join(", ")}
2321
- `;
2322
- return md;
2208
+ out += "</context-package>";
2209
+ return out;
2323
2210
  }
2324
2211
 
2325
2212
  // src/cli/build-context.ts
@@ -2339,20 +2226,19 @@ function registerBuildCommand(program2) {
2339
2226
  process.exit(1);
2340
2227
  }
2341
2228
  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);
2229
+ const pkg2 = await buildContext(graph, nodePath);
2230
+ const warningThreshold = graph.config.quality?.context_budget.warning ?? 1e4;
2231
+ const errorThreshold = graph.config.quality?.context_budget.error ?? 2e4;
2232
+ const budgetStatus = pkg2.tokenCount >= errorThreshold ? "error" : pkg2.tokenCount >= warningThreshold ? "warning" : "ok";
2233
+ let output = formatContextText(pkg2);
2347
2234
  output += `Budget status: ${budgetStatus}
2348
2235
  `;
2349
2236
  process.stdout.write(output);
2350
2237
  if (budgetStatus === "error") {
2351
2238
  process.stderr.write(
2352
- `Error: context package exceeds error budget (${pkg.tokenCount} >= ${errorThreshold}).
2239
+ `Warning: context package exceeds error budget (${pkg2.tokenCount} >= ${errorThreshold}). Consider splitting the node.
2353
2240
  `
2354
2241
  );
2355
- process.exit(1);
2356
2242
  }
2357
2243
  } catch (error) {
2358
2244
  process.stderr.write(`Error: ${error.message}
@@ -2411,40 +2297,28 @@ import chalk2 from "chalk";
2411
2297
 
2412
2298
  // src/io/drift-state-store.ts
2413
2299
  import { readFile as readFile11, writeFile as writeFile3 } from "fs/promises";
2414
- import { parse as parseYaml7, stringify as stringifyYaml } from "yaml";
2415
2300
  import path10 from "path";
2301
+ import { stringify, parse } from "yaml";
2416
2302
  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
2303
  async function readDriftState(yggRoot) {
2424
- const filePath = path10.join(yggRoot, DRIFT_STATE_FILE);
2425
2304
  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
- }
2305
+ const content = await readFile11(path10.join(yggRoot, DRIFT_STATE_FILE), "utf-8");
2306
+ const raw = parse(content);
2307
+ if (!raw || typeof raw !== "object") return {};
2308
+ const state = {};
2309
+ for (const [key, value] of Object.entries(raw)) {
2310
+ if (typeof value === "object" && value !== null && "hash" in value) {
2311
+ state[key] = value;
2436
2312
  }
2437
- return result;
2438
2313
  }
2439
- return {};
2314
+ return state;
2440
2315
  } catch {
2441
2316
  return {};
2442
2317
  }
2443
2318
  }
2444
2319
  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");
2320
+ const content = stringify(state, { lineWidth: 0 });
2321
+ await writeFile3(path10.join(yggRoot, DRIFT_STATE_FILE), content, "utf-8");
2448
2322
  }
2449
2323
 
2450
2324
  // src/utils/hash.ts
@@ -2458,46 +2332,29 @@ async function hashFile(filePath) {
2458
2332
  const content = await readFile12(filePath);
2459
2333
  return createHash("sha256").update(content).digest("hex");
2460
2334
  }
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);
2470
- }
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);
2478
- }
2479
- throw new Error(`Unsupported mapping path type: ${targetPath}`);
2480
- }
2481
2335
  async function collectDirectoryFileHashes(directoryPath, rootDirectoryPath, options) {
2336
+ let stack = options.gitignoreStack ?? [];
2337
+ try {
2338
+ const localContent = await readFile12(path11.join(directoryPath, ".gitignore"), "utf-8");
2339
+ const localMatcher = ignoreFactory();
2340
+ localMatcher.add(localContent);
2341
+ stack = [...stack, { basePath: directoryPath, matcher: localMatcher }];
2342
+ } catch {
2343
+ }
2482
2344
  const entries = await readdir5(directoryPath, { withFileTypes: true });
2483
2345
  const result = [];
2484
2346
  for (const entry of entries) {
2485
2347
  const absoluteChildPath = path11.join(directoryPath, entry.name);
2486
- if (isIgnoredPath(absoluteChildPath, options.projectRoot, options.gitignoreMatcher)) {
2348
+ if (isIgnoredByStack(absoluteChildPath, stack)) {
2487
2349
  continue;
2488
2350
  }
2489
2351
  if (entry.isDirectory()) {
2490
2352
  const nested = await collectDirectoryFileHashes(
2491
2353
  absoluteChildPath,
2492
2354
  rootDirectoryPath,
2493
- options
2355
+ { projectRoot: options.projectRoot, gitignoreStack: stack }
2494
2356
  );
2495
- for (const nestedEntry of nested) {
2496
- result.push({
2497
- path: path11.relative(rootDirectoryPath, path11.join(absoluteChildPath, nestedEntry.path)),
2498
- hash: nestedEntry.hash
2499
- });
2500
- }
2357
+ result.push(...nested);
2501
2358
  continue;
2502
2359
  }
2503
2360
  if (!entry.isFile()) {
@@ -2510,83 +2367,152 @@ async function collectDirectoryFileHashes(directoryPath, rootDirectoryPath, opti
2510
2367
  }
2511
2368
  return result;
2512
2369
  }
2513
- async function loadGitignoreMatcher(projectRoot) {
2514
- if (!projectRoot) {
2515
- return void 0;
2516
- }
2370
+ async function loadRootGitignoreStack(projectRoot) {
2371
+ if (!projectRoot) return [];
2517
2372
  try {
2518
- const gitignorePath = path11.join(projectRoot, ".gitignore");
2519
- const gitignoreContent = await readFile12(gitignorePath, "utf-8");
2373
+ const content = await readFile12(path11.join(projectRoot, ".gitignore"), "utf-8");
2520
2374
  const matcher = ignoreFactory();
2521
- matcher.add(gitignoreContent);
2522
- return matcher;
2375
+ matcher.add(content);
2376
+ return [{ basePath: projectRoot, matcher }];
2523
2377
  } catch {
2524
- return void 0;
2378
+ return [];
2525
2379
  }
2526
2380
  }
2527
- function isIgnoredPath(candidatePath, projectRoot, matcher) {
2528
- if (!projectRoot || !matcher) {
2529
- return false;
2530
- }
2531
- const relativePath = path11.relative(projectRoot, candidatePath);
2532
- if (relativePath === "" || relativePath.startsWith("..")) {
2533
- return false;
2381
+ function isIgnoredByStack(candidatePath, stack) {
2382
+ for (const { basePath, matcher } of stack) {
2383
+ const relativePath = path11.relative(basePath, candidatePath);
2384
+ if (relativePath === "" || relativePath.startsWith("..")) continue;
2385
+ if (matcher.ignores(relativePath) || matcher.ignores(relativePath + "/")) return true;
2534
2386
  }
2535
- return matcher.ignores(relativePath) || matcher.ignores(relativePath + "/");
2387
+ return false;
2536
2388
  }
2537
2389
  function hashString(content) {
2538
2390
  return createHash("sha256").update(content).digest("hex");
2539
2391
  }
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
2392
+ async function hashTrackedFiles(projectRoot, trackedFiles) {
2393
+ const fileHashes = {};
2394
+ const gitignoreStack = await loadRootGitignoreStack(projectRoot);
2395
+ for (const tf of trackedFiles) {
2396
+ const absPath = path11.join(projectRoot, tf.path);
2397
+ try {
2398
+ const st = await stat3(absPath);
2399
+ if (st.isDirectory()) {
2400
+ const dirHashes = await collectDirectoryFileHashes(absPath, absPath, {
2401
+ projectRoot,
2402
+ gitignoreStack
2560
2403
  });
2404
+ for (const entry of dirHashes) {
2405
+ const fullRelPath = path11.join(tf.path, entry.path).replace(/\\/g, "/");
2406
+ fileHashes[fullRelPath] = entry.hash;
2407
+ }
2408
+ } else {
2409
+ fileHashes[tf.path] = await hashFile(absPath);
2561
2410
  }
2411
+ } catch {
2412
+ continue;
2562
2413
  }
2563
2414
  }
2564
- return result;
2415
+ const sorted = Object.entries(fileHashes).sort(([a], [b]) => a.localeCompare(b));
2416
+ const digest = sorted.map(([p, h]) => `${p}:${h}`).join("\n");
2417
+ const canonicalHash = hashString(digest);
2418
+ return { canonicalHash, fileHashes };
2565
2419
  }
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 });
2420
+
2421
+ // src/core/context-files.ts
2422
+ import path12 from "path";
2423
+ var STRUCTURAL_RELATION_TYPES2 = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements"]);
2424
+ function collectTrackedFiles(node, graph) {
2425
+ const seen = /* @__PURE__ */ new Set();
2426
+ const result = [];
2427
+ const projectRoot = path12.dirname(graph.rootPath);
2428
+ const yggPrefix = path12.relative(projectRoot, graph.rootPath);
2429
+ const yggPrefixNormalized = yggPrefix.split(path12.sep).join("/");
2430
+ const configArtifactKeys = new Set(Object.keys(graph.config.artifacts ?? {}));
2431
+ function addFile(filePath, category) {
2432
+ if (seen.has(filePath)) return;
2433
+ seen.add(filePath);
2434
+ result.push({ path: filePath, category });
2435
+ }
2436
+ function graphPath(...segments) {
2437
+ return [yggPrefixNormalized, ...segments].join("/");
2438
+ }
2439
+ function addNodeFiles(n) {
2440
+ addFile(graphPath("model", n.path, "node.yaml"), "graph");
2441
+ for (const art of n.artifacts) {
2442
+ if (configArtifactKeys.has(art.filename)) {
2443
+ addFile(graphPath("model", n.path, art.filename), "graph");
2444
+ }
2445
+ }
2446
+ }
2447
+ addNodeFiles(node);
2448
+ const ancestors = collectAncestors(node);
2449
+ for (const ancestor of ancestors) {
2450
+ addNodeFiles(ancestor);
2451
+ }
2452
+ const allAspectIds = /* @__PURE__ */ new Set();
2453
+ for (const id of node.meta.aspects ?? []) {
2454
+ allAspectIds.add(id);
2455
+ }
2456
+ for (const ancestor of ancestors) {
2457
+ for (const id of ancestor.meta.aspects ?? []) {
2458
+ allAspectIds.add(id);
2459
+ }
2460
+ }
2461
+ const participatingFlows = collectParticipatingFlows2(graph, node, ancestors);
2462
+ for (const flow of participatingFlows) {
2463
+ for (const id of flow.aspects ?? []) {
2464
+ allAspectIds.add(id);
2465
+ }
2466
+ }
2467
+ const resolvedAspects = resolveAspects(allAspectIds, graph.aspects);
2468
+ for (const aspect of resolvedAspects) {
2469
+ addFile(graphPath("aspects", aspect.id, "aspect.yaml"), "graph");
2470
+ for (const art of aspect.artifacts) {
2471
+ addFile(graphPath("aspects", aspect.id, art.filename), "graph");
2472
+ }
2473
+ }
2474
+ for (const relation of node.meta.relations ?? []) {
2475
+ if (!STRUCTURAL_RELATION_TYPES2.has(relation.type)) continue;
2476
+ const target = graph.nodes.get(relation.target);
2477
+ if (!target) continue;
2478
+ const structuralFilenames = Object.entries(graph.config.artifacts ?? {}).filter(([, c]) => c.structural_context).map(([filename]) => filename);
2479
+ const structuralArts = structuralFilenames.filter(
2480
+ (filename) => target.artifacts.some((a) => a.filename === filename)
2481
+ );
2482
+ if (structuralArts.length > 0) {
2483
+ for (const filename of structuralArts) {
2484
+ addFile(graphPath("model", target.path, filename), "graph");
2485
+ }
2486
+ } else {
2487
+ for (const art of target.artifacts) {
2488
+ if (configArtifactKeys.has(art.filename)) {
2489
+ addFile(graphPath("model", target.path, art.filename), "graph");
2490
+ }
2491
+ }
2492
+ }
2493
+ }
2494
+ for (const flow of participatingFlows) {
2495
+ addFile(graphPath("flows", flow.path, "flow.yaml"), "graph");
2496
+ for (const art of flow.artifacts) {
2497
+ addFile(graphPath("flows", flow.path, art.filename), "graph");
2579
2498
  }
2580
2499
  }
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");
2500
+ const mappingPaths = normalizeMappingPaths(node.meta.mapping);
2501
+ for (const p of mappingPaths) {
2502
+ addFile(p, "source");
2503
+ }
2504
+ return result;
2505
+ }
2506
+ function collectParticipatingFlows2(graph, node, ancestors) {
2507
+ const paths = /* @__PURE__ */ new Set([node.path, ...ancestors.map((a) => a.path)]);
2508
+ return graph.flows.filter((f) => f.nodes.some((n) => paths.has(n)));
2583
2509
  }
2584
2510
 
2585
2511
  // src/core/drift-detector.ts
2586
2512
  import { access } from "fs/promises";
2587
- import path12 from "path";
2513
+ import path13 from "path";
2588
2514
  async function detectDrift(graph, filterNodePath) {
2589
- const projectRoot = path12.dirname(graph.rootPath);
2515
+ const projectRoot = path13.dirname(graph.rootPath);
2590
2516
  const driftState = await readDriftState(graph.rootPath);
2591
2517
  const entries = [];
2592
2518
  for (const [nodePath, node] of graph.nodes) {
@@ -2600,67 +2526,80 @@ async function detectDrift(graph, filterNodePath) {
2600
2526
  const allMissing = await allPathsMissing(projectRoot, mappingPaths);
2601
2527
  entries.push({
2602
2528
  nodePath,
2603
- mappingPaths,
2604
- status: allMissing ? "unmaterialized" : "drift",
2529
+ status: allMissing ? "unmaterialized" : "source-drift",
2605
2530
  details: allMissing ? "No drift state recorded, files do not exist" : "No drift state recorded, files exist (run drift-sync after materialization)"
2606
2531
  });
2607
2532
  continue;
2608
2533
  }
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";
2534
+ const sourceFilesMissing = await allPathsMissing(projectRoot, mappingPaths);
2535
+ if (sourceFilesMissing) {
2536
+ entries.push({
2537
+ nodePath,
2538
+ status: "missing",
2539
+ details: "All source mapping paths are missing"
2540
+ });
2541
+ continue;
2542
+ }
2543
+ const trackedFiles = collectTrackedFiles(node, graph);
2544
+ const { canonicalHash, fileHashes } = await hashTrackedFiles(projectRoot, trackedFiles);
2545
+ if (canonicalHash === storedEntry.hash) {
2546
+ entries.push({ nodePath, status: "ok" });
2547
+ continue;
2548
+ }
2549
+ const changedFiles = [];
2550
+ const storedFiles = storedEntry.files;
2551
+ for (const [filePath, hash] of Object.entries(fileHashes)) {
2552
+ const storedHash = storedFiles[filePath];
2553
+ if (!storedHash || storedHash !== hash) {
2554
+ changedFiles.push({
2555
+ filePath,
2556
+ category: categorizeFile(filePath, graph.rootPath, projectRoot)
2557
+ });
2622
2558
  }
2623
- } catch {
2624
- status = "missing";
2625
- details = "Mapped path(s) do not exist";
2626
2559
  }
2627
- entries.push({ nodePath, mappingPaths, status, details });
2560
+ for (const storedPath of Object.keys(storedFiles)) {
2561
+ if (!(storedPath in fileHashes)) {
2562
+ changedFiles.push({
2563
+ filePath: `${storedPath} (deleted)`,
2564
+ category: categorizeFile(storedPath, graph.rootPath, projectRoot)
2565
+ });
2566
+ }
2567
+ }
2568
+ const hasSourceChanges = changedFiles.some((f) => f.category === "source");
2569
+ const hasGraphChanges = changedFiles.some((f) => f.category === "graph");
2570
+ let status;
2571
+ if (hasSourceChanges && hasGraphChanges) {
2572
+ status = "full-drift";
2573
+ } else if (hasGraphChanges) {
2574
+ status = "graph-drift";
2575
+ } else if (hasSourceChanges) {
2576
+ status = "source-drift";
2577
+ } else {
2578
+ status = "source-drift";
2579
+ }
2580
+ const details = changedFiles.length > 0 ? `Changed files: ${changedFiles.map((f) => f.filePath).join(", ")}` : "File(s) modified since last sync";
2581
+ entries.push({ nodePath, status, details, changedFiles });
2628
2582
  }
2629
2583
  return {
2630
2584
  entries,
2631
2585
  totalChecked: entries.length,
2632
2586
  okCount: entries.filter((e) => e.status === "ok").length,
2633
- driftCount: entries.filter((e) => e.status === "drift").length,
2587
+ sourceDriftCount: entries.filter((e) => e.status === "source-drift").length,
2588
+ graphDriftCount: entries.filter((e) => e.status === "graph-drift").length,
2589
+ fullDriftCount: entries.filter((e) => e.status === "full-drift").length,
2634
2590
  missingCount: entries.filter((e) => e.status === "missing").length,
2635
2591
  unmaterializedCount: entries.filter((e) => e.status === "unmaterialized").length
2636
2592
  };
2637
2593
  }
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
- }
2594
+ function categorizeFile(filePath, _rootPath, projectRoot) {
2595
+ const yggPrefix = path13.relative(projectRoot, _rootPath);
2596
+ const normalizedPrefix = yggPrefix.split(path13.sep).join("/");
2597
+ const normalizedFilePath = filePath.replace(/\\/g, "/");
2598
+ return normalizedFilePath.startsWith(normalizedPrefix) ? "graph" : "source";
2660
2599
  }
2661
2600
  async function allPathsMissing(projectRoot, mappingPaths) {
2662
2601
  for (const mp of mappingPaths) {
2663
- const absPath = path12.join(projectRoot, mp);
2602
+ const absPath = path13.join(projectRoot, mp);
2664
2603
  try {
2665
2604
  await access(absPath);
2666
2605
  return false;
@@ -2670,82 +2609,43 @@ async function allPathsMissing(projectRoot, mappingPaths) {
2670
2609
  return true;
2671
2610
  }
2672
2611
  async function syncDriftState(graph, nodePath) {
2673
- const projectRoot = path12.dirname(graph.rootPath);
2612
+ const projectRoot = path13.dirname(graph.rootPath);
2674
2613
  const node = graph.nodes.get(nodePath);
2675
2614
  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;
2686
- }
2687
- const newEntry = { hash: currentHash, files };
2688
- driftState[nodePath] = newEntry;
2689
- await writeDriftState(graph.rootPath, driftState);
2690
- return { previousHash, currentHash };
2615
+ if (!node.meta.mapping) throw new Error(`Node has no mapping: ${nodePath}`);
2616
+ const trackedFiles = collectTrackedFiles(node, graph);
2617
+ const { canonicalHash, fileHashes } = await hashTrackedFiles(projectRoot, trackedFiles);
2618
+ const state = await readDriftState(graph.rootPath);
2619
+ const previousHash = state[nodePath]?.hash;
2620
+ state[nodePath] = { hash: canonicalHash, files: fileHashes };
2621
+ await writeDriftState(graph.rootPath, state);
2622
+ return { previousHash, currentHash: canonicalHash };
2691
2623
  }
2692
2624
 
2693
2625
  // src/cli/drift.ts
2694
2626
  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) => {
2627
+ 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)").action(async (opts) => {
2696
2628
  try {
2697
2629
  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}
2630
+ const scope = (opts.scope ?? "all").trim() || "all";
2631
+ if (scope !== "all") {
2632
+ const node = graph.nodes.get(scope);
2633
+ if (!node) {
2634
+ process.stderr.write(`Error: Node not found: ${scope}
2701
2635
  `);
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
2636
  process.exit(1);
2712
2637
  }
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}
2638
+ if (!node.meta.mapping) {
2639
+ process.stderr.write(`Error: Node has no mapping: ${scope}
2728
2640
  `);
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;
2641
+ process.exit(1);
2738
2642
  }
2739
2643
  }
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);
2644
+ const scopeNode = scope === "all" ? void 0 : scope;
2645
+ const report = await detectDrift(graph, scopeNode);
2646
+ printReport(report, opts.driftedOnly ?? false);
2647
+ const hasIssues = report.sourceDriftCount > 0 || report.graphDriftCount > 0 || report.fullDriftCount > 0 || report.missingCount > 0 || report.unmaterializedCount > 0;
2648
+ process.exit(hasIssues ? 1 : 0);
2749
2649
  } catch (error) {
2750
2650
  process.stderr.write(`Error: ${error.message}
2751
2651
  `);
@@ -2753,6 +2653,94 @@ Summary: ${report.driftCount} drift, ${report.missingCount} missing, ${report.un
2753
2653
  }
2754
2654
  });
2755
2655
  }
2656
+ function printReport(report, driftedOnly) {
2657
+ const sourceEntries = classifyForSection(report.entries, "source", driftedOnly);
2658
+ const graphEntries = classifyForSection(report.entries, "graph", driftedOnly);
2659
+ process.stdout.write("Source drift:\n");
2660
+ printSectionEntries(sourceEntries, "source");
2661
+ process.stdout.write("\nGraph drift:\n");
2662
+ printSectionEntries(graphEntries, "graph");
2663
+ const parts = [
2664
+ `${report.sourceDriftCount} source-drift`,
2665
+ `${report.graphDriftCount} graph-drift`,
2666
+ `${report.fullDriftCount} full-drift`,
2667
+ `${report.missingCount} missing`,
2668
+ `${report.unmaterializedCount} unmaterialized`
2669
+ ];
2670
+ let summary = `
2671
+ Summary: ${parts.join(", ")}`;
2672
+ if (driftedOnly && report.okCount > 0) {
2673
+ summary += ` (${report.okCount} ok hidden)`;
2674
+ } else {
2675
+ summary += `, ${report.okCount} ok`;
2676
+ }
2677
+ process.stdout.write(summary + "\n");
2678
+ }
2679
+ function classifyForSection(entries, section, driftedOnly) {
2680
+ return entries.filter((entry) => {
2681
+ if (section === "source") {
2682
+ if (entry.status === "graph-drift") return false;
2683
+ if (entry.status === "ok" && driftedOnly) return false;
2684
+ return true;
2685
+ } else {
2686
+ if (entry.status === "source-drift" || entry.status === "missing" || entry.status === "unmaterialized")
2687
+ return false;
2688
+ if (entry.status === "ok" && driftedOnly) return false;
2689
+ return true;
2690
+ }
2691
+ });
2692
+ }
2693
+ function printSectionEntries(entries, section) {
2694
+ if (entries.length === 0) {
2695
+ process.stdout.write(chalk2.dim(" (none)\n"));
2696
+ return;
2697
+ }
2698
+ for (const entry of entries) {
2699
+ printEntryLine(entry);
2700
+ printChangedFiles(entry, section);
2701
+ }
2702
+ }
2703
+ function printEntryLine(entry) {
2704
+ const pad = 13;
2705
+ switch (entry.status) {
2706
+ case "ok":
2707
+ process.stdout.write(chalk2.green(` ${"[ok]".padEnd(pad)}${entry.nodePath}
2708
+ `));
2709
+ break;
2710
+ case "source-drift":
2711
+ process.stdout.write(chalk2.red(` ${"[drift]".padEnd(pad)}${entry.nodePath}
2712
+ `));
2713
+ break;
2714
+ case "graph-drift":
2715
+ process.stdout.write(chalk2.magenta(` ${"[drift]".padEnd(pad)}${entry.nodePath}
2716
+ `));
2717
+ break;
2718
+ case "full-drift":
2719
+ process.stdout.write(chalk2.red(` ${"[drift]".padEnd(pad)}${entry.nodePath}
2720
+ `));
2721
+ break;
2722
+ case "missing":
2723
+ process.stdout.write(chalk2.yellow(` ${"[missing]".padEnd(pad)}${entry.nodePath}
2724
+ `));
2725
+ break;
2726
+ case "unmaterialized":
2727
+ process.stdout.write(chalk2.dim(` ${"[unmat.]".padEnd(pad)}${entry.nodePath}
2728
+ `));
2729
+ break;
2730
+ }
2731
+ }
2732
+ function printChangedFiles(entry, section) {
2733
+ if (!entry.changedFiles || entry.changedFiles.length === 0) return;
2734
+ const indent = " ".repeat(15);
2735
+ const relevantFiles = entry.changedFiles.filter((f) => {
2736
+ if (section === "source") return f.category === "source";
2737
+ return f.category === "graph";
2738
+ });
2739
+ for (const file of relevantFiles) {
2740
+ process.stdout.write(chalk2.dim(`${indent}${file.filePath} (changed)
2741
+ `));
2742
+ }
2743
+ }
2756
2744
 
2757
2745
  // src/cli/drift-sync.ts
2758
2746
  import chalk3 from "chalk";
@@ -2790,20 +2778,40 @@ function registerStatusCommand(program2) {
2790
2778
  let structuralRelations = 0;
2791
2779
  let eventRelations = 0;
2792
2780
  const structuralTypes = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements"]);
2781
+ let maxRelCount = 0;
2782
+ let maxRelNode = "";
2793
2783
  for (const node of graph.nodes.values()) {
2784
+ const relCount = (node.meta.relations ?? []).length;
2785
+ if (relCount > maxRelCount) {
2786
+ maxRelCount = relCount;
2787
+ maxRelNode = node.path;
2788
+ }
2794
2789
  for (const rel of node.meta.relations ?? []) {
2795
2790
  if (structuralTypes.has(rel.type)) structuralRelations += 1;
2796
2791
  else eventRelations += 1;
2797
2792
  }
2798
2793
  }
2799
2794
  const flowCount = graph.flows.length;
2800
- const knowledgeCount = graph.knowledge.length;
2801
2795
  const drift = await detectDrift(graph);
2802
2796
  const validation = await validate(graph, "all");
2803
2797
  const errorCount = validation.issues.filter((issue) => issue.severity === "error").length;
2804
2798
  const warningCount = validation.issues.filter(
2805
2799
  (issue) => issue.severity === "warning"
2806
2800
  ).length;
2801
+ const configuredArtifactTypes = Object.keys(graph.config.artifacts ?? {});
2802
+ const totalSlots = graph.nodes.size * configuredArtifactTypes.length;
2803
+ let filledSlots = 0;
2804
+ let mappedNodeCount = 0;
2805
+ for (const node of graph.nodes.values()) {
2806
+ const allowed = new Set(configuredArtifactTypes);
2807
+ filledSlots += node.artifacts.filter((a) => allowed.has(a.filename)).length;
2808
+ if (normalizeMappingPaths(node.meta.mapping).length > 0) mappedNodeCount++;
2809
+ }
2810
+ let aspectCoveredNodes = 0;
2811
+ for (const node of graph.nodes.values()) {
2812
+ const effective = collectEffectiveAspectIds(graph, node.path);
2813
+ if (effective.size > 0) aspectCoveredNodes++;
2814
+ }
2807
2815
  process.stdout.write(`Graph: ${graph.config.name}
2808
2816
  `);
2809
2817
  const pluralize = (word, count) => count === 1 ? word : word.endsWith("y") ? word.slice(0, -1) + "ies" : word + "s";
@@ -2817,15 +2825,37 @@ function registerStatusCommand(program2) {
2817
2825
  `
2818
2826
  );
2819
2827
  process.stdout.write(
2820
- `Aspects: ${graph.aspects.length} Flows: ${flowCount} Knowledge: ${knowledgeCount}
2828
+ `Aspects: ${graph.aspects.length} Flows: ${flowCount}
2821
2829
  `
2822
2830
  );
2823
2831
  process.stdout.write(
2824
- `Drift: ${drift.driftCount} drift, ${drift.missingCount} missing, ${drift.unmaterializedCount} unmaterialized, ${drift.okCount} ok
2832
+ `Drift: ${drift.sourceDriftCount} source-drift, ${drift.graphDriftCount} graph-drift, ${drift.fullDriftCount} full-drift, ${drift.missingCount} missing, ${drift.unmaterializedCount} unmaterialized, ${drift.okCount} ok
2825
2833
  `
2826
2834
  );
2827
2835
  process.stdout.write(`Validation: ${errorCount} errors, ${warningCount} warnings
2828
2836
  `);
2837
+ const fillPct = totalSlots > 0 ? Math.round(filledSlots / totalSlots * 100) : 0;
2838
+ const totalRelations = structuralRelations + eventRelations;
2839
+ const avgRel = graph.nodes.size > 0 ? (totalRelations / graph.nodes.size).toFixed(1) : "0";
2840
+ process.stdout.write(`
2841
+ Quality:
2842
+ `);
2843
+ process.stdout.write(
2844
+ ` Artifacts: ${filledSlots}/${totalSlots} slots filled (${fillPct}%) \u2014 ${configuredArtifactTypes.length} types \xD7 ${graph.nodes.size} nodes
2845
+ `
2846
+ );
2847
+ process.stdout.write(
2848
+ ` Relations: avg ${avgRel}/node, max ${maxRelCount}${maxRelNode ? ` (${maxRelNode})` : ""}
2849
+ `
2850
+ );
2851
+ process.stdout.write(
2852
+ ` Mapping: ${mappedNodeCount}/${graph.nodes.size} nodes mapped to source
2853
+ `
2854
+ );
2855
+ process.stdout.write(
2856
+ ` Aspects: ${aspectCoveredNodes}/${graph.nodes.size} nodes have aspect coverage
2857
+ `
2858
+ );
2829
2859
  } catch (error) {
2830
2860
  process.stderr.write(`Error: ${error.message}
2831
2861
  `);
@@ -2842,10 +2872,10 @@ function registerTreeCommand(program2) {
2842
2872
  let roots;
2843
2873
  let showProjectName;
2844
2874
  if (options.root?.trim()) {
2845
- const path16 = options.root.trim().replace(/\/$/, "");
2846
- const node = graph.nodes.get(path16);
2875
+ const path17 = options.root.trim().replace(/\/$/, "");
2876
+ const node = graph.nodes.get(path17);
2847
2877
  if (!node) {
2848
- process.stderr.write(`Error: path '${path16}' not found
2878
+ process.stderr.write(`Error: path '${path17}' not found
2849
2879
  `);
2850
2880
  process.exit(1);
2851
2881
  }
@@ -2873,7 +2903,7 @@ function printNode(node, prefix, isLast, depth, maxDepth) {
2873
2903
  const connector = isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
2874
2904
  const name = node.path.split("/").pop() ?? node.path;
2875
2905
  const type = `[${node.meta.type}]`;
2876
- const tags = node.meta.tags?.length ? ` tags:${node.meta.tags.join(",")}` : "";
2906
+ const tags = node.meta.aspects?.length ? ` aspects:${node.meta.aspects.join(",")}` : "";
2877
2907
  const blackbox = node.meta.blackbox ? " \u25A0 blackbox" : "";
2878
2908
  const relationCount = node.meta.relations?.length ?? 0;
2879
2909
  process.stdout.write(
@@ -2932,13 +2962,13 @@ function registerOwnerCommand(program2) {
2932
2962
  }
2933
2963
 
2934
2964
  // 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"]);
2965
+ import { execSync } from "child_process";
2966
+ import path14 from "path";
2967
+ var STRUCTURAL_RELATION_TYPES3 = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements"]);
2938
2968
  var EVENT_RELATION_TYPES2 = /* @__PURE__ */ new Set(["emits", "listens"]);
2939
2969
  function filterRelationType(relType, filter) {
2940
2970
  if (filter === "all") return true;
2941
- if (filter === "structural") return STRUCTURAL_RELATION_TYPES2.has(relType);
2971
+ if (filter === "structural") return STRUCTURAL_RELATION_TYPES3.has(relType);
2942
2972
  if (filter === "event") return EVENT_RELATION_TYPES2.has(relType);
2943
2973
  return false;
2944
2974
  }
@@ -3010,24 +3040,24 @@ function registerDepsCommand(program2) {
3010
3040
  // src/core/graph-from-git.ts
3011
3041
  import { mkdtemp, rm } from "fs/promises";
3012
3042
  import { tmpdir } from "os";
3013
- import path14 from "path";
3014
- import { execSync as execSync3 } from "child_process";
3043
+ import path15 from "path";
3044
+ import { execSync as execSync2 } from "child_process";
3015
3045
  async function loadGraphFromRef(projectRoot, ref = "HEAD") {
3016
3046
  const yggPath = ".yggdrasil";
3017
3047
  let tmpDir = null;
3018
3048
  try {
3019
- execSync3(`git rev-parse ${ref}`, { cwd: projectRoot, stdio: "pipe" });
3049
+ execSync2(`git rev-parse ${ref}`, { cwd: projectRoot, stdio: "pipe" });
3020
3050
  } catch {
3021
3051
  return null;
3022
3052
  }
3023
3053
  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}"`, {
3054
+ tmpDir = await mkdtemp(path15.join(tmpdir(), "ygg-git-"));
3055
+ const archivePath = path15.join(tmpDir, "archive.tar");
3056
+ execSync2(`git archive ${ref} ${yggPath} -o "${archivePath}"`, {
3027
3057
  cwd: projectRoot,
3028
3058
  stdio: "pipe"
3029
3059
  });
3030
- execSync3(`tar -xf "${archivePath}" -C "${tmpDir}"`, { stdio: "pipe" });
3060
+ execSync2(`tar -xf "${archivePath}" -C "${tmpDir}"`, { stdio: "pipe" });
3031
3061
  const graph = await loadGraph(tmpDir);
3032
3062
  return graph;
3033
3063
  } catch {
@@ -3069,14 +3099,14 @@ function collectReverseDependents(graph, targetNode) {
3069
3099
  }
3070
3100
  return {
3071
3101
  direct,
3072
- transitive: [...seen].sort(),
3102
+ allDependents: [...seen].sort(),
3073
3103
  reverse,
3074
3104
  relationFrom
3075
3105
  };
3076
3106
  }
3077
- function buildTransitiveChains(targetNode, direct, transitive, reverse) {
3107
+ function buildTransitiveChains(targetNode, direct, allDependents, reverse) {
3078
3108
  const directSet = new Set(direct);
3079
- const transitiveOnly = transitive.filter((t) => !directSet.has(t));
3109
+ const transitiveOnly = allDependents.filter((t) => !directSet.has(t));
3080
3110
  if (transitiveOnly.length === 0) return [];
3081
3111
  const parent = /* @__PURE__ */ new Map();
3082
3112
  const queue = [targetNode];
@@ -3092,148 +3122,342 @@ function buildTransitiveChains(targetNode, direct, transitive, reverse) {
3092
3122
  }
3093
3123
  const chains = [];
3094
3124
  for (const node of transitiveOnly) {
3095
- const path16 = [];
3125
+ const path17 = [];
3096
3126
  let current = node;
3097
3127
  while (current) {
3098
- path16.unshift(current);
3128
+ path17.unshift(current);
3099
3129
  current = parent.get(current);
3100
3130
  }
3101
- if (path16.length >= 2) {
3102
- chains.push(path16.map((p) => `<- ${p}`).join(" "));
3131
+ if (path17.length >= 3) {
3132
+ chains.push(path17.slice(1).map((p) => `<- ${p}`).join(" "));
3103
3133
  }
3104
3134
  }
3105
3135
  return chains.sort();
3106
3136
  }
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) => {
3137
+ function collectDescendants(graph, nodePath) {
3138
+ const node = graph.nodes.get(nodePath);
3139
+ if (!node) return [];
3140
+ const result = [];
3141
+ const stack = [...node.children];
3142
+ while (stack.length > 0) {
3143
+ const child = stack.pop();
3144
+ result.push(child.path);
3145
+ stack.push(...child.children);
3146
+ }
3147
+ return result.sort();
3148
+ }
3149
+ async function runSimulation(graph, nodePaths, targetNodePath) {
3150
+ const budget = graph.config.quality?.context_budget ?? { warning: 1e4, error: 2e4 };
3151
+ process.stdout.write("\nChanges in context packages:\n\n");
3152
+ const baselineGraph = await loadGraphFromRef(process.cwd(), "HEAD");
3153
+ const driftReport = await detectDrift(graph);
3154
+ const driftByNode = new Map(driftReport.entries.map((e) => [e.nodePath, e]));
3155
+ for (const dep of nodePaths) {
3109
3156
  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);
3157
+ const pkg2 = await buildContext(graph, dep);
3158
+ const status = pkg2.tokenCount >= budget.error ? "error" : pkg2.tokenCount >= budget.warning ? "warning" : "ok";
3159
+ let baselineTokens = null;
3160
+ if (baselineGraph?.nodes.has(dep)) {
3161
+ try {
3162
+ const baselinePkg = await buildContext(baselineGraph, dep);
3163
+ baselineTokens = baselinePkg.tokenCount;
3164
+ } catch {
3165
+ }
3116
3166
  }
3117
- const { direct, transitive, reverse, relationFrom } = collectReverseDependents(
3118
- graph,
3119
- nodePath
3167
+ const hasDepOnTarget = targetNodePath && graph.nodes.get(dep)?.meta.relations?.some(
3168
+ (r) => r.target === targetNodePath && STRUCTURAL_TYPES.has(r.type)
3120
3169
  );
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);
3170
+ const changedLine = hasDepOnTarget ? ` + Changed dependency interface: ${targetNodePath}
3171
+ ` : "";
3172
+ const budgetLine = baselineTokens !== null ? ` Budget: ${baselineTokens} -> ${pkg2.tokenCount} tokens (${status})
3173
+ ` : ` Budget: ${pkg2.tokenCount} tokens (${status})
3174
+ `;
3175
+ const driftEntry = driftByNode.get(dep);
3176
+ const driftLine = driftEntry && driftEntry.status !== "ok" ? ` Mapped files (on-disk): ${driftEntry.status}${driftEntry.details ? ` (${driftEntry.details})` : ""}
3177
+ ` : driftEntry ? ` Mapped files (on-disk): ok
3178
+ ` : "";
3179
+ process.stdout.write(`${dep}:
3180
+ ${changedLine}${budgetLine}${driftLine}
3181
+ `);
3182
+ } catch {
3183
+ process.stdout.write(`${dep}:
3184
+ failed to build context
3185
+
3186
+ `);
3187
+ }
3188
+ }
3189
+ }
3190
+ async function handleAspectImpact(graph, aspectId, simulate) {
3191
+ const aspect = graph.aspects.find((a) => a.id === aspectId);
3192
+ if (!aspect) {
3193
+ process.stderr.write(`Aspect not found: ${aspectId}
3194
+ `);
3195
+ process.exit(1);
3196
+ }
3197
+ const affected = [];
3198
+ for (const [nodePath] of graph.nodes) {
3199
+ const effective = collectEffectiveAspectIds(graph, nodePath);
3200
+ if (effective.has(aspectId)) {
3201
+ const node = graph.nodes.get(nodePath);
3202
+ const ownAspects = new Set(node.meta.aspects ?? []);
3203
+ if (ownAspects.has(aspectId)) {
3204
+ affected.push({ path: nodePath, source: "own" });
3205
+ } else {
3206
+ let fromHierarchy = false;
3207
+ let anc = node.parent;
3208
+ while (anc) {
3209
+ if ((anc.meta.aspects ?? []).includes(aspectId)) {
3210
+ fromHierarchy = true;
3211
+ break;
3212
+ }
3213
+ anc = anc.parent;
3126
3214
  }
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);
3215
+ if (fromHierarchy) {
3216
+ affected.push({ path: nodePath, source: `hierarchy from ${anc.path}` });
3217
+ } else {
3218
+ const ancestorPaths = /* @__PURE__ */ new Set([nodePath, ...collectAncestors(node).map((a) => a.path)]);
3219
+ const flow = graph.flows.find(
3220
+ (f) => (f.aspects ?? []).includes(aspectId) && f.nodes.some((n) => ancestorPaths.has(n))
3221
+ );
3222
+ affected.push({ path: nodePath, source: flow ? `flow: ${flow.name}` : "implied" });
3134
3223
  }
3135
3224
  }
3136
- const knowledgeInScope = [];
3137
- for (const k of graph.knowledge) {
3138
- if (k.scope === "global") {
3139
- knowledgeInScope.push(k.path);
3140
- continue;
3225
+ }
3226
+ }
3227
+ affected.sort((a, b) => a.path.localeCompare(b.path));
3228
+ const propagatingFlows = graph.flows.filter((f) => (f.aspects ?? []).includes(aspectId)).map((f) => f.name);
3229
+ const impliedBy = graph.aspects.filter((a) => (a.implies ?? []).includes(aspectId)).map((a) => a.id);
3230
+ const implies = aspect.implies ?? [];
3231
+ process.stdout.write(`Impact of changes in aspect ${aspectId}:
3232
+
3233
+ `);
3234
+ process.stdout.write(`Affected nodes (${affected.length}):
3235
+ `);
3236
+ if (affected.length === 0) {
3237
+ process.stdout.write(" (none)\n");
3238
+ } else {
3239
+ for (const { path: p, source } of affected) {
3240
+ process.stdout.write(` ${p} (${source})
3241
+ `);
3242
+ }
3243
+ }
3244
+ process.stdout.write(
3245
+ `
3246
+ Flows propagating this aspect: ${propagatingFlows.length > 0 ? propagatingFlows.join(", ") : "(none)"}
3247
+ `
3248
+ );
3249
+ process.stdout.write(`Implied by: ${impliedBy.length > 0 ? impliedBy.join(", ") : "(none)"}
3250
+ `);
3251
+ process.stdout.write(`Implies: ${implies.length > 0 ? implies.join(", ") : "(none)"}
3252
+ `);
3253
+ process.stdout.write(`
3254
+ Total scope: ${affected.length} nodes, ${propagatingFlows.length} flows
3255
+ `);
3256
+ if (simulate && affected.length > 0) {
3257
+ await runSimulation(
3258
+ graph,
3259
+ affected.map((a) => a.path),
3260
+ null
3261
+ );
3262
+ }
3263
+ }
3264
+ async function handleFlowImpact(graph, flowName, simulate) {
3265
+ const flow = graph.flows.find((f) => f.name === flowName || f.path === flowName);
3266
+ if (!flow) {
3267
+ process.stderr.write(`Flow not found: ${flowName}
3268
+ `);
3269
+ process.exit(1);
3270
+ }
3271
+ const participants = /* @__PURE__ */ new Set();
3272
+ for (const nodePath of flow.nodes) {
3273
+ if (graph.nodes.has(nodePath)) {
3274
+ participants.add(nodePath);
3275
+ for (const desc of collectDescendants(graph, nodePath)) {
3276
+ participants.add(desc);
3277
+ }
3278
+ }
3279
+ }
3280
+ const sorted = [...participants].sort();
3281
+ const flowAspects = flow.aspects ?? [];
3282
+ process.stdout.write(`Impact of changes in flow ${flow.name}:
3283
+
3284
+ `);
3285
+ process.stdout.write("Participants:\n");
3286
+ if (sorted.length === 0) {
3287
+ process.stdout.write(" (none)\n");
3288
+ } else {
3289
+ for (const p of sorted) {
3290
+ const isDeclared = flow.nodes.includes(p);
3291
+ const suffix = isDeclared ? "" : " (descendant)";
3292
+ process.stdout.write(` ${p}${suffix}
3293
+ `);
3294
+ }
3295
+ }
3296
+ process.stdout.write(
3297
+ `
3298
+ Flow aspects: ${flowAspects.length > 0 ? flowAspects.join(", ") : "(none)"}
3299
+ `
3300
+ );
3301
+ process.stdout.write(`
3302
+ Total scope: ${sorted.length} nodes
3303
+ `);
3304
+ if (simulate && sorted.length > 0) {
3305
+ await runSimulation(graph, sorted, null);
3306
+ }
3307
+ }
3308
+ function registerImpactCommand(program2) {
3309
+ 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(
3310
+ async (options) => {
3311
+ try {
3312
+ const modeCount = [options.node, options.aspect, options.flow].filter(Boolean).length;
3313
+ if (modeCount === 0) {
3314
+ process.stderr.write(
3315
+ "Error: one of --node, --aspect, or --flow is required\n"
3316
+ );
3317
+ process.exit(1);
3318
+ }
3319
+ if (modeCount > 1) {
3320
+ process.stderr.write(
3321
+ "Error: --node, --aspect, and --flow are mutually exclusive\n"
3322
+ );
3323
+ process.exit(1);
3324
+ }
3325
+ const graph = await loadGraph(process.cwd());
3326
+ if (options.aspect) {
3327
+ await handleAspectImpact(graph, options.aspect.trim(), options.simulate);
3328
+ return;
3329
+ }
3330
+ if (options.flow) {
3331
+ await handleFlowImpact(graph, options.flow.trim(), options.simulate);
3332
+ return;
3333
+ }
3334
+ const nodePath = options.node.trim().replace(/\/$/, "");
3335
+ if (!graph.nodes.has(nodePath)) {
3336
+ process.stderr.write(`Node not found: ${nodePath}
3337
+ `);
3338
+ process.exit(1);
3141
3339
  }
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);
3340
+ const { direct, allDependents, reverse, relationFrom } = collectReverseDependents(
3341
+ graph,
3342
+ nodePath
3343
+ );
3344
+ const chains = buildTransitiveChains(nodePath, direct, allDependents, reverse);
3345
+ const flows = [];
3346
+ for (const flow of graph.flows) {
3347
+ if (flow.nodes.includes(nodePath)) {
3348
+ flows.push(flow.name);
3145
3349
  }
3146
- continue;
3147
3350
  }
3148
- if (typeof k.scope === "object" && "nodes" in k.scope) {
3149
- if (k.scope.nodes.includes(nodePath)) {
3150
- knowledgeInScope.push(k.path);
3351
+ const targetEffective = collectEffectiveAspectIds(graph, nodePath);
3352
+ const aspectsInScope = [];
3353
+ for (const aspect of graph.aspects) {
3354
+ if (targetEffective.has(aspect.id)) {
3355
+ aspectsInScope.push(aspect.name);
3151
3356
  }
3152
3357
  }
3153
- }
3154
- const budget = graph.config.quality?.context_budget ?? { warning: 1e4, error: 2e4 };
3155
- process.stdout.write(`Impact of changes in ${nodePath}:
3358
+ process.stdout.write(`Impact of changes in ${nodePath}:
3156
3359
 
3157
3360
  `);
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}
3361
+ process.stdout.write("Directly dependent:\n");
3362
+ if (direct.length === 0) {
3363
+ process.stdout.write(" (none)\n");
3364
+ } else {
3365
+ for (const dep of direct) {
3366
+ const rel = relationFrom.get(`${dep}->${nodePath}`);
3367
+ const annot = rel?.consumes?.length ? ` (${rel.type}, you consume: ${rel.consumes.join(", ")})` : rel ? ` (${rel.type})` : "";
3368
+ process.stdout.write(` <- ${dep}${annot}
3166
3369
  `);
3370
+ }
3167
3371
  }
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}
3372
+ process.stdout.write("\nTransitively dependent:\n");
3373
+ if (chains.length === 0) {
3374
+ process.stdout.write(" (none)\n");
3375
+ } else {
3376
+ for (const chain of chains) {
3377
+ process.stdout.write(` ${chain}
3175
3378
  `);
3379
+ }
3176
3380
  }
3177
- }
3178
- process.stdout.write(`
3179
- Flows: ${flows.length > 0 ? flows.join(", ") : "(none)"}
3381
+ const descendants = collectDescendants(graph, nodePath);
3382
+ if (descendants.length > 0) {
3383
+ process.stdout.write("\nDescendants (hierarchy impact):\n");
3384
+ for (const desc of descendants) {
3385
+ process.stdout.write(` ${desc}
3180
3386
  `);
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)"}
3387
+ }
3388
+ }
3389
+ process.stdout.write(
3390
+ `
3391
+ Flows: ${flows.length > 0 ? flows.join(", ") : "(none)"}
3187
3392
  `
3188
- );
3189
- process.stdout.write(
3190
- `
3191
- Total scope: ${transitive.length} nodes, ${flows.length} flows, ${aspectsInScope.length} aspects, ${knowledgeInScope.length} knowledge
3393
+ );
3394
+ process.stdout.write(
3395
+ `Aspects (scope covers node): ${aspectsInScope.length > 0 ? aspectsInScope.join(", ") : "(none)"}
3192
3396
  `
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
- }
3397
+ );
3398
+ const coAspectNodes = [];
3399
+ if (targetEffective.size > 0) {
3400
+ for (const [p] of graph.nodes) {
3401
+ if (p === nodePath) continue;
3402
+ const nodeEffective = collectEffectiveAspectIds(graph, p);
3403
+ const shared = [...targetEffective].filter((id) => nodeEffective.has(id));
3404
+ if (shared.length > 0) {
3405
+ coAspectNodes.push({ path: p, shared });
3210
3406
  }
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
-
3407
+ }
3408
+ }
3409
+ if (coAspectNodes.length > 0) {
3410
+ process.stdout.write("Nodes sharing aspects:\n");
3411
+ for (const { path: p, shared } of coAspectNodes.sort(
3412
+ (a, b) => a.path.localeCompare(b.path)
3413
+ )) {
3414
+ process.stdout.write(` ${p} (${shared.join(", ")})
3230
3415
  `);
3231
3416
  }
3232
3417
  }
3418
+ const allAffected = /* @__PURE__ */ new Set([...allDependents, ...descendants]);
3419
+ process.stdout.write(
3420
+ `
3421
+ Total scope: ${allAffected.size} nodes, ${flows.length} flows, ${aspectsInScope.length} aspects
3422
+ `
3423
+ );
3424
+ if (options.simulate && allAffected.size > 0) {
3425
+ await runSimulation(graph, allAffected, nodePath);
3426
+ }
3427
+ } catch (error) {
3428
+ process.stderr.write(`Error: ${error.message}
3429
+ `);
3430
+ process.exit(1);
3233
3431
  }
3432
+ }
3433
+ );
3434
+ }
3435
+
3436
+ // src/cli/aspects.ts
3437
+ import { stringify as yamlStringify } from "yaml";
3438
+ function registerAspectsCommand(program2) {
3439
+ program2.command("aspects").description("List aspects with metadata (YAML output)").action(async () => {
3440
+ try {
3441
+ const yggRoot = await findYggRoot(process.cwd());
3442
+ const graph = await loadGraph(yggRoot);
3443
+ const output = graph.aspects.sort((a, b) => a.id.localeCompare(b.id)).map((aspect) => {
3444
+ const entry = { id: aspect.id, name: aspect.name };
3445
+ if (aspect.description) entry.description = aspect.description;
3446
+ if (aspect.implies && aspect.implies.length > 0) entry.implies = aspect.implies;
3447
+ return entry;
3448
+ });
3449
+ process.stdout.write(yamlStringify(output));
3234
3450
  } catch (error) {
3235
- process.stderr.write(`Error: ${error.message}
3451
+ const err = error;
3452
+ if (err.code === "ENOENT") {
3453
+ process.stderr.write(
3454
+ `Error: No .yggdrasil/ directory found. Run 'yg init' first.
3455
+ `
3456
+ );
3457
+ } else {
3458
+ process.stderr.write(`Error: ${error.message}
3236
3459
  `);
3460
+ }
3237
3461
  process.exit(1);
3238
3462
  }
3239
3463
  });
@@ -3241,15 +3465,15 @@ ${changedLine}${budgetLine}${driftLine}
3241
3465
 
3242
3466
  // src/io/journal-store.ts
3243
3467
  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";
3468
+ import { parse as parseYaml6, stringify as stringifyYaml } from "yaml";
3469
+ import path16 from "path";
3246
3470
  var JOURNAL_FILE = ".journal.yaml";
3247
3471
  var ARCHIVE_DIR = "journals-archive";
3248
3472
  async function readJournal(yggRoot) {
3249
- const filePath = path15.join(yggRoot, JOURNAL_FILE);
3473
+ const filePath = path16.join(yggRoot, JOURNAL_FILE);
3250
3474
  try {
3251
3475
  const content = await readFile13(filePath, "utf-8");
3252
- const raw = parseYaml8(content);
3476
+ const raw = parseYaml6(content);
3253
3477
  const entries = raw.entries ?? [];
3254
3478
  return Array.isArray(entries) ? entries : [];
3255
3479
  } catch {
@@ -3261,13 +3485,13 @@ async function appendJournalEntry(yggRoot, note, target) {
3261
3485
  const at = (/* @__PURE__ */ new Date()).toISOString();
3262
3486
  const entry = target ? { at, target, note } : { at, note };
3263
3487
  entries.push(entry);
3264
- const filePath = path15.join(yggRoot, JOURNAL_FILE);
3265
- const content = stringifyYaml2({ entries });
3488
+ const filePath = path16.join(yggRoot, JOURNAL_FILE);
3489
+ const content = stringifyYaml({ entries });
3266
3490
  await writeFile4(filePath, content, "utf-8");
3267
3491
  return entry;
3268
3492
  }
3269
3493
  async function archiveJournal(yggRoot) {
3270
- const journalPath = path15.join(yggRoot, JOURNAL_FILE);
3494
+ const journalPath = path16.join(yggRoot, JOURNAL_FILE);
3271
3495
  try {
3272
3496
  await access2(journalPath);
3273
3497
  } catch {
@@ -3275,12 +3499,12 @@ async function archiveJournal(yggRoot) {
3275
3499
  }
3276
3500
  const entries = await readJournal(yggRoot);
3277
3501
  if (entries.length === 0) return null;
3278
- const archiveDir = path15.join(yggRoot, ARCHIVE_DIR);
3502
+ const archiveDir = path16.join(yggRoot, ARCHIVE_DIR);
3279
3503
  await mkdir3(archiveDir, { recursive: true });
3280
3504
  const now = /* @__PURE__ */ new Date();
3281
3505
  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
3506
  const archiveName = `.journal.${timestamp}.yaml`;
3283
- const archivePath = path15.join(archiveDir, archiveName);
3507
+ const archivePath = path16.join(archiveDir, archiveName);
3284
3508
  await rename(journalPath, archivePath);
3285
3509
  return { archiveName, entryCount: entries.length };
3286
3510
  }
@@ -3356,9 +3580,85 @@ function registerJournalArchiveCommand(program2) {
3356
3580
  });
3357
3581
  }
3358
3582
 
3583
+ // src/cli/preflight.ts
3584
+ function registerPreflightCommand(program2) {
3585
+ program2.command("preflight").description("Unified diagnostic report: journal, drift, status, validation").action(async () => {
3586
+ try {
3587
+ const cwd = process.cwd();
3588
+ const graph = await loadGraph(cwd);
3589
+ const yggRoot = await findYggRoot(cwd);
3590
+ const journalEntries = await readJournal(yggRoot);
3591
+ const drift = await detectDrift(graph);
3592
+ const driftedEntries = drift.entries.filter((e) => e.status !== "ok");
3593
+ const nodeCount = graph.nodes.size;
3594
+ const aspectCount = graph.aspects.length;
3595
+ const flowCount = graph.flows.length;
3596
+ let mappedPathCount = 0;
3597
+ for (const node of graph.nodes.values()) {
3598
+ mappedPathCount += normalizeMappingPaths(node.meta.mapping).length;
3599
+ }
3600
+ const validation = await validate(graph, "all");
3601
+ const errors = validation.issues.filter((i) => i.severity === "error");
3602
+ const warnings = validation.issues.filter((i) => i.severity === "warning");
3603
+ const lines = [];
3604
+ lines.push("=== Preflight Report ===");
3605
+ lines.push("");
3606
+ if (journalEntries.length === 0) {
3607
+ lines.push("Journal: clean");
3608
+ } else {
3609
+ lines.push(`Journal: ${journalEntries.length} pending entries`);
3610
+ for (const entry of journalEntries) {
3611
+ const target = entry.target ? ` [${entry.target}]` : "";
3612
+ lines.push(` - ${entry.note}${target}`);
3613
+ }
3614
+ }
3615
+ lines.push("");
3616
+ if (driftedEntries.length === 0) {
3617
+ lines.push("Drift: clean");
3618
+ } else {
3619
+ lines.push(`Drift: ${driftedEntries.length} nodes need attention`);
3620
+ for (const entry of driftedEntries) {
3621
+ lines.push(` - ${entry.nodePath}: ${entry.status}`);
3622
+ }
3623
+ }
3624
+ lines.push("");
3625
+ lines.push(
3626
+ `Status: ${nodeCount} nodes, ${aspectCount} aspects, ${flowCount} flows, ${mappedPathCount} mapped paths`
3627
+ );
3628
+ lines.push("");
3629
+ if (errors.length === 0 && warnings.length === 0) {
3630
+ lines.push("Validation: clean");
3631
+ } else {
3632
+ const parts = [];
3633
+ if (errors.length > 0) parts.push(`${errors.length} errors`);
3634
+ if (warnings.length > 0) parts.push(`${warnings.length} warnings`);
3635
+ lines.push(`Validation: ${parts.join(", ")}`);
3636
+ for (const issue of [...errors, ...warnings]) {
3637
+ const code = issue.code ? `[${issue.code}] ` : "";
3638
+ lines.push(` - ${code}${issue.message}`);
3639
+ }
3640
+ }
3641
+ lines.push("");
3642
+ process.stdout.write(lines.join("\n"));
3643
+ const hasIssues = journalEntries.length > 0 || driftedEntries.length > 0 || errors.length > 0;
3644
+ process.exit(hasIssues ? 1 : 0);
3645
+ } catch (error) {
3646
+ process.stderr.write(`Error: ${error.message}
3647
+ `);
3648
+ process.exit(1);
3649
+ }
3650
+ });
3651
+ }
3652
+
3359
3653
  // src/bin.ts
3654
+ import { readFileSync } from "fs";
3655
+ import { fileURLToPath as fileURLToPath3 } from "url";
3656
+ import { dirname, join } from "path";
3657
+ var __filename = fileURLToPath3(import.meta.url);
3658
+ var __dirname = dirname(__filename);
3659
+ var pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
3360
3660
  var program = new Command();
3361
- program.name("yg").description("Yggdrasil \u2014 architectural knowledge infrastructure for AI agents").version("0.1.0");
3661
+ program.name("yg").description("Yggdrasil \u2014 architectural knowledge infrastructure for AI agents").version(pkg.version);
3362
3662
  registerInitCommand(program);
3363
3663
  registerBuildCommand(program);
3364
3664
  registerValidateCommand(program);
@@ -3369,8 +3669,10 @@ registerTreeCommand(program);
3369
3669
  registerOwnerCommand(program);
3370
3670
  registerDepsCommand(program);
3371
3671
  registerImpactCommand(program);
3672
+ registerAspectsCommand(program);
3372
3673
  registerJournalAddCommand(program);
3373
3674
  registerJournalReadCommand(program);
3374
3675
  registerJournalArchiveCommand(program);
3676
+ registerPreflightCommand(program);
3375
3677
  program.parse();
3376
3678
  //# sourceMappingURL=bin.js.map