@chrisdudek/yg 0.3.2 → 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,242 +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.
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.
146
110
 
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.
111
+ ### Failure States
148
112
 
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.
113
+ You have broken Yggdrasil if you do any of the following:
156
114
 
157
- ---
158
-
159
- ## 3. WORKFLOW: MODIFYING OR CREATING FILES (Code-First)
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.
160
129
 
161
- You are NOT ALLOWED to edit or create source code without establishing graph coverage first.
130
+ ### Escape Hatch
162
131
 
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.
132
+ If the user explicitly requests a code-only change, comply but:
164
133
 
165
- **Step 1: Check coverage** -> Run \`yg owner --file <path>\`
134
+ - Warn: "This creates drift. Run \`yg drift\` next session to reconcile."
135
+ - Do NOT run \`yg drift-sync\` \u2014 leave the drift visible.
166
136
 
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:
137
+ ### Environment Check
169
138
 
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)
139
+ Before preflight:
174
140
 
175
- **Step 3: If Owner NOT FOUND (Uncovered Area)**
176
- STOP. Do not modify the code. First determine: **Is this greenfield or existing code?**
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
177
145
 
178
- * **If GREENFIELD (empty directory, new project):** Do NOT offer blackbox. Create proper nodes (reverse engineering or upfront design) before implementing.
179
- * **If PARTIALLY MAPPED (file is unmapped, but lives inside a mapped module):** Stop and ask the user if this file should be added to the existing node or if a new node is required.
180
- * **If EXISTING CODE (legacy, third-party):** Present the user with 3 options and wait:
181
- * **Option 1: Reverse Engineering:** Create/extend standard nodes to map the area fully before modifying.
182
- * **Option 2: Blackbox Coverage:** Create a \`blackbox: true\` node to establish ownership without deep semantic exploration.
183
- * **Option 3: Abort/Change Plan:** Do not touch the file.
184
-
185
- ---
146
+ ### Conversation Lifecycle
186
147
 
187
- ## 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.
188
155
 
189
- 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.
190
162
 
191
- 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.
192
- 2. **Read Config & Templates:**
193
- * Check \`.yggdrasil/config.yaml\` for allowed \`node_types\` and \`tags\`.
194
- * **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.
195
- 3. **Validate & Fix:** Run \`yg validate\`. You must fix all E-codes (Errors).
196
- 4. **Token Economy & W-codes:**
197
- * W005/W006: Context package too large. Consider splitting the node.
198
- * 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
+ \`\`\`
199
169
 
200
- **Graph Modification Checklist**
201
- Whenever you change the graph structure or semantics, you MUST output and execute this exact checklist:
170
+ ### Modify Source Code
202
171
 
203
- - [ ] 1. Read schema from \`.yggdrasil/templates/\` (node.yaml, aspect.yaml, flow.yaml, or knowledge.yaml for the element type)
204
- - [ ] 2. Edit graph files (\`node.yaml\`, artifacts)
205
- - [ ] 3. Verify corresponding source files exist and their behavior matches updated artifacts
206
- - [ ] 4. Validate (ran \`yg validate\` \u2014 fix all Errors)
207
- - [ ] 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.
208
173
 
209
- **Journaling (Iterative Mode Scope):**
210
- * **Default:** Write changes directly to graph files immediately. Do not defer.
211
- * **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>\`
212
175
 
213
- ---
176
+ **Step 2a** \u2014 Owner found: execute checklist:
214
177
 
215
- ## 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
216
184
 
217
- To avoid broken references (\`E004\`, \`E005\`), use correct relative paths:
218
- * **Node paths** (used in CLI, relations, flow nodes): Relative to \`.yggdrasil/model/\` (e.g., \`orders/order-service\`).
219
- * **File paths** (used in mapping, \`yg owner\`): Relative to the repository root (e.g., \`src/modules/orders/order.service.ts\`).
220
- * **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:
221
186
 
222
- ---
187
+ *Partially mapped* (file unmapped but inside a mapped module): ask whether to add to existing node or create new one.
223
188
 
224
- ## 6. GRAPH STRUCTURE, CONFIG & TEMPLATES CHEAT SHEET
189
+ *Existing code:*
225
190
 
226
- 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
227
194
 
228
- * **\`.yggdrasil/config.yaml\`**: Defines \`node_types\`, \`tags\`, \`artifacts\`, \`knowledge_categories\`.
229
- * **\`.yggdrasil/templates/\`**: Schemas for each graph layer \u2014 \`node.yaml\`, \`aspect.yaml\`, \`flow.yaml\`, \`knowledge.yaml\`.
230
- * **\`.yggdrasil/model/\`**: Node tree. Each node is a directory with \`node.yaml\` and artifact files.
231
- * **\`.yggdrasil/aspects/\`**: Cross-cutting rules. Directory contains \`aspect.yaml\` and \`.md\` content.
232
- * **\`.yggdrasil/flows/\`**: End-to-end processes. Directory contains \`flow.yaml\` and \`.md\` content.
233
- * **\`.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.
234
196
 
235
- ---
197
+ After the user chooses, return to Step 1 and follow Step 2a.
236
198
 
237
- ## 7. CONTEXT ASSEMBLY & KNOWLEDGE DECONSTRUCTION (HOW TO MAP FILES)
199
+ ### Modify Graph
238
200
 
239
- 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
240
207
 
241
- 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
242
209
 
243
- ### CRITICAL RULE: CAPTURE INTENT, BUT NEVER INVENT IT
244
- 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.
245
211
 
246
- 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.
247
- 2. **NEVER Invent the "Why":** Artifacts that imply human judgment (e.g. local decisions, \`knowledge/invariants\`) must reflect ACTUAL human choices.
248
- 3. **NO Hallucinations:** You MUST NEVER infer or hallucinate a rationale, an architectural decision, or a business rule.
249
- 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:
250
213
 
251
- 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>\`
252
223
 
253
- ### Layer 1: Unit Identity (Local Node Artifacts)
254
- * **What goes here:** Things exclusively true for this specific node.
255
- * **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\`.
256
- * 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:**
257
225
 
258
- **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?"
259
229
 
260
- ### Optional Artifacts \u2014 Explicit Consideration
230
+ ### Bootstrap Mode
261
231
 
262
- 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.
263
233
 
264
- **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
265
241
 
266
- **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.
267
243
 
268
- **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
269
245
 
270
- ### Layer 2: Surroundings (Relations & Flows)
271
- * **What goes here:** How this node interacts with others. You must not duplicate external interfaces locally.
272
- * **Routing:**
273
- * 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).
274
- * 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.
275
- * **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.
276
247
 
277
- ### Layer 3: Domain Context (Hierarchy)
278
- * **What goes here:** Business rules shared by a family of nodes.
279
- * **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
280
253
 
281
- ### Layer 4: Cross-Cutting Rules (Aspects)
282
- * **What goes here:** Horizontal requirements like logging, auth, rate-limiting, or specific frameworks.
283
- * **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.
284
255
 
285
- ### Layer 5: Long-Term Memory (Knowledge Elements)
286
- * **What goes here:** Global architectural decisions, design patterns, and systemic invariants.
287
- * **Routing:** Read \`config.yaml\` (the \`knowledge_categories\` section) to know what categories exist.
288
- * If the file implements a standard pattern: Do not describe the pattern locally. Add a \`knowledge\` reference in \`node.yaml\` to the existing pattern.
289
- * 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
290
257
 
291
- **THE COMPLETENESS CHECK:**
292
- 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?"*
293
- - If no -> You missed a local constraint, a relation, or you failed to capture the user's provided rationale.
294
- - 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
295
264
 
296
- ---
265
+ ### Graph Structure
297
266
 
298
- ## 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
+ \`\`\`
299
277
 
300
- 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.
301
283
 
302
- * \`yg owner --file <file_path>\` -> Find owning node.
303
- * \`yg build-context --node <node_path>\` -> Assemble strict specification.
304
- * \`yg tree [--root <node_path>] [--depth N]\` -> Print graph structure.
305
- * \`yg deps --node <node_path> [--type structural|event|all]\` -> Show dependencies.
306
- * \`yg impact --node <node_path> --simulate\` -> Simulate blast radius.
307
- * \`yg status\` -> Graph health metrics.
308
- * \`yg validate [--scope <node_path>|all]\` -> Compile/check graph. Run after EVERY graph edit.
309
- * \`yg drift [--scope <node_path>|all]\` -> Check code vs graph baseline.
310
- * \`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
311
285
 
312
- *(Iterative mode only)*
313
- * \`yg journal-read\`
314
- * \`yg journal-add --note "<content>" [--target <node_path>]\`
315
- * \`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\` |
316
364
  `;
365
+ var AGENT_RULES_CONTENT = [CORE_PROTOCOL, OPERATIONS, KNOWLEDGE_BASE].join("\n\n---\n\n");
317
366
 
318
367
  // src/templates/platform.ts
319
368
  var AGENT_RULES_IMPORT = "@.yggdrasil/agent-rules.md";
@@ -556,10 +605,10 @@ function escapeRegex(s) {
556
605
  }
557
606
 
558
607
  // src/cli/init.ts
559
- function getGraphTemplatesDir() {
608
+ function getGraphSchemasDir() {
560
609
  const currentDir = path2.dirname(fileURLToPath(import.meta.url));
561
610
  const packageRoot = path2.join(currentDir, "..");
562
- return path2.join(packageRoot, "graph-templates");
611
+ return path2.join(packageRoot, "graph-schemas");
563
612
  }
564
613
  var GITIGNORE_CONTENT = `.journal.yaml
565
614
  journals-archive/
@@ -607,23 +656,20 @@ function registerInitCommand(program2) {
607
656
  await mkdir2(path2.join(yggRoot, "model"), { recursive: true });
608
657
  await mkdir2(path2.join(yggRoot, "aspects"), { recursive: true });
609
658
  await mkdir2(path2.join(yggRoot, "flows"), { recursive: true });
610
- await mkdir2(path2.join(yggRoot, "knowledge", "decisions"), { recursive: true });
611
- await mkdir2(path2.join(yggRoot, "knowledge", "patterns"), { recursive: true });
612
- await mkdir2(path2.join(yggRoot, "knowledge", "invariants"), { recursive: true });
613
- const templatesDir = path2.join(yggRoot, "templates");
614
- await mkdir2(templatesDir, { recursive: true });
615
- const graphTemplatesDir = getGraphTemplatesDir();
659
+ const schemasDir = path2.join(yggRoot, "schemas");
660
+ await mkdir2(schemasDir, { recursive: true });
661
+ const graphSchemasDir = getGraphSchemasDir();
616
662
  try {
617
- const entries = await readdir(graphTemplatesDir, { withFileTypes: true });
618
- const templateFiles = entries.filter((e) => e.isFile()).map((e) => e.name);
619
- for (const file of templateFiles) {
620
- 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);
621
667
  const content = await readFile2(srcPath, "utf-8");
622
- await writeFile2(path2.join(templatesDir, file), content, "utf-8");
668
+ await writeFile2(path2.join(schemasDir, file), content, "utf-8");
623
669
  }
624
670
  } catch (err) {
625
671
  process.stderr.write(
626
- `Warning: Could not copy graph templates from ${graphTemplatesDir}: ${err.message}
672
+ `Warning: Could not copy graph schemas from ${graphSchemasDir}: ${err.message}
627
673
  `
628
674
  );
629
675
  }
@@ -637,10 +683,7 @@ function registerInitCommand(program2) {
637
683
  process.stdout.write(" .yggdrasil/model/\n");
638
684
  process.stdout.write(" .yggdrasil/aspects/\n");
639
685
  process.stdout.write(" .yggdrasil/flows/\n");
640
- process.stdout.write(" .yggdrasil/knowledge/ (decisions, patterns, invariants)\n");
641
- process.stdout.write(
642
- " .yggdrasil/templates/ (node, aspect, flow, knowledge)\n"
643
- );
686
+ process.stdout.write(" .yggdrasil/schemas/ (node, aspect, flow)\n");
644
687
  process.stdout.write(` ${path2.relative(projectRoot, rulesPath)} (rules)
645
688
 
646
689
  `);
@@ -652,8 +695,8 @@ function registerInitCommand(program2) {
652
695
  }
653
696
 
654
697
  // src/core/graph-loader.ts
655
- import { readdir as readdir3 } from "fs/promises";
656
- import path6 from "path";
698
+ import { readdir as readdir3, readFile as readFile9 } from "fs/promises";
699
+ import path7 from "path";
657
700
 
658
701
  // src/io/config-parser.ts
659
702
  import { readFile as readFile3 } from "fs/promises";
@@ -661,8 +704,7 @@ import { parse as parseYaml } from "yaml";
661
704
  var DEFAULT_QUALITY = {
662
705
  min_artifact_length: 50,
663
706
  max_direct_relations: 10,
664
- context_budget: { warning: 1e4, error: 2e4 },
665
- knowledge_staleness_days: 90
707
+ context_budget: { warning: 1e4, error: 2e4 }
666
708
  };
667
709
  async function parseConfig(filePath) {
668
710
  const content = await readFile3(filePath, "utf-8");
@@ -670,18 +712,34 @@ async function parseConfig(filePath) {
670
712
  if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
671
713
  throw new Error(`config.yaml: missing or invalid 'name' field`);
672
714
  }
673
- const nodeTypes = raw.node_types;
674
- if (!Array.isArray(nodeTypes) || nodeTypes.length === 0) {
715
+ const nodeTypesRaw = raw.node_types;
716
+ if (!Array.isArray(nodeTypesRaw) || nodeTypesRaw.length === 0) {
675
717
  throw new Error(`config.yaml: 'node_types' must be a non-empty array`);
676
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
+ });
677
735
  const artifacts = raw.artifacts;
678
736
  if (!artifacts || typeof artifacts !== "object" || Array.isArray(artifacts) || Object.keys(artifacts).length === 0) {
679
737
  throw new Error(`config.yaml: 'artifacts' must be a non-empty object`);
680
738
  }
681
739
  const artifactsMap = {};
682
740
  for (const [key, val] of Object.entries(artifacts)) {
683
- if (key === "node") {
684
- 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`);
685
743
  }
686
744
  const a = val;
687
745
  const required = a.required;
@@ -690,10 +748,10 @@ async function parseConfig(filePath) {
690
748
  }
691
749
  if (typeof required === "object" && required && "when" in required) {
692
750
  const when = required.when;
693
- 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:"));
694
752
  if (!validWhen) {
695
753
  throw new Error(
696
- `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>`
697
755
  );
698
756
  }
699
757
  }
@@ -703,24 +761,6 @@ async function parseConfig(filePath) {
703
761
  structural_context: a.structural_context ?? false
704
762
  };
705
763
  }
706
- if (!("knowledge_categories" in raw)) {
707
- throw new Error(
708
- `config.yaml: missing 'knowledge_categories' field (required, may be empty list)`
709
- );
710
- }
711
- const knowledgeCategoriesRaw = raw.knowledge_categories;
712
- if (!Array.isArray(knowledgeCategoriesRaw)) {
713
- throw new Error(`config.yaml: 'knowledge_categories' must be an array`);
714
- }
715
- const knowledgeCategories = knowledgeCategoriesRaw;
716
- const categoryNames = /* @__PURE__ */ new Set();
717
- for (const kc of knowledgeCategories) {
718
- if (!kc?.name || typeof kc.name !== "string") continue;
719
- if (categoryNames.has(kc.name)) {
720
- throw new Error(`config.yaml: duplicate knowledge category '${kc.name}'`);
721
- }
722
- categoryNames.add(kc.name);
723
- }
724
764
  const qualityRaw = raw.quality;
725
765
  const quality = qualityRaw ? {
726
766
  min_artifact_length: qualityRaw.min_artifact_length ?? DEFAULT_QUALITY.min_artifact_length,
@@ -728,30 +768,19 @@ async function parseConfig(filePath) {
728
768
  context_budget: {
729
769
  warning: qualityRaw.context_budget?.warning ?? DEFAULT_QUALITY.context_budget.warning,
730
770
  error: qualityRaw.context_budget?.error ?? DEFAULT_QUALITY.context_budget.error
731
- },
732
- knowledge_staleness_days: qualityRaw.knowledge_staleness_days ?? DEFAULT_QUALITY.knowledge_staleness_days
771
+ }
733
772
  } : DEFAULT_QUALITY;
734
773
  if (quality.context_budget.error < quality.context_budget.warning) {
735
774
  throw new Error(
736
775
  `config.yaml: quality.context_budget.error (${quality.context_budget.error}) must be >= warning (${quality.context_budget.warning})`
737
776
  );
738
777
  }
739
- if (!("tags" in raw)) {
740
- throw new Error(`config.yaml: missing 'tags' field (required, may be empty list)`);
741
- }
742
- const tags = raw.tags;
743
- if (!Array.isArray(tags)) {
744
- throw new Error(`config.yaml: 'tags' must be an array`);
745
- }
746
- const tagsList = tags.filter((t) => typeof t === "string");
747
778
  return {
748
779
  name: raw.name.trim(),
749
780
  stack: raw.stack ?? {},
750
781
  standards: typeof raw.standards === "string" ? raw.standards : "",
751
- tags: tagsList,
752
782
  node_types: nodeTypes,
753
783
  artifacts: artifactsMap,
754
- knowledge_categories: knowledgeCategories.filter((kc) => kc?.name),
755
784
  quality
756
785
  };
757
786
  }
@@ -784,10 +813,9 @@ async function parseNodeYaml(filePath) {
784
813
  return {
785
814
  name: raw.name.trim(),
786
815
  type: raw.type.trim(),
787
- tags: parseStringArray(raw.tags),
816
+ aspects: parseStringArray(raw.aspects) ?? parseStringArray(raw.tags),
788
817
  blackbox: raw.blackbox ?? false,
789
818
  relations: relations.length > 0 ? relations : void 0,
790
- knowledge: parseStringArray(raw.knowledge),
791
819
  mapping
792
820
  };
793
821
  }
@@ -887,25 +915,37 @@ async function readArtifacts(dirPath, excludeFiles = ["node.yaml"], includeFiles
887
915
  }
888
916
 
889
917
  // src/io/aspect-parser.ts
890
- 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
+ }
891
923
  const content = await readFile6(aspectYamlPath, "utf-8");
892
924
  const raw = parseYaml3(content);
893
925
  if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
894
926
  throw new Error(`Aspect file ${aspectYamlPath}: missing or empty 'name'`);
895
927
  }
896
- if (!raw.tag || typeof raw.tag !== "string" || raw.tag.trim() === "") {
897
- throw new Error(`Aspect file ${aspectYamlPath}: missing or empty 'tag'`);
898
- }
928
+ const description = typeof raw.description === "string" ? raw.description.trim() : void 0;
899
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
+ }
900
937
  return {
901
938
  name: raw.name.trim(),
902
- tag: raw.tag.trim(),
939
+ id: idTrimmed,
940
+ description,
941
+ implies,
903
942
  artifacts
904
943
  };
905
944
  }
906
945
 
907
946
  // src/io/flow-parser.ts
908
947
  import { readFile as readFile7 } from "fs/promises";
948
+ import path4 from "path";
909
949
  import { parse as parseYaml4 } from "yaml";
910
950
  async function parseFlow(flowDir, flowYamlPath) {
911
951
  const content = await readFile7(flowYamlPath, "utf-8");
@@ -921,79 +961,44 @@ async function parseFlow(flowDir, flowYamlPath) {
921
961
  if (nodePaths.length === 0) {
922
962
  throw new Error(`flow.yaml at ${flowYamlPath}: 'nodes' must contain string node paths`);
923
963
  }
924
- 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
+ }
925
972
  const artifacts = await readArtifacts(flowDir, ["flow.yaml"]);
926
973
  return {
974
+ path: path4.basename(flowDir),
927
975
  name: raw.name.trim(),
928
976
  nodes: nodePaths,
929
- knowledge,
977
+ ...aspects !== void 0 && { aspects },
930
978
  artifacts
931
979
  };
932
980
  }
933
981
 
934
- // src/io/knowledge-parser.ts
982
+ // src/io/schema-parser.ts
935
983
  import { readFile as readFile8 } from "fs/promises";
984
+ import path5 from "path";
936
985
  import { parse as parseYaml5 } from "yaml";
937
- async function parseKnowledge(knowledgeDir, knowledgeYamlPath, category, relativePath) {
938
- const content = await readFile8(knowledgeYamlPath, "utf-8");
939
- const raw = parseYaml5(content);
940
- if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
941
- throw new Error(`knowledge.yaml at ${knowledgeYamlPath}: missing or empty 'name'`);
942
- }
943
- const scope = parseScope(raw.scope, knowledgeYamlPath);
944
- const artifacts = await readArtifacts(knowledgeDir, ["knowledge.yaml"]);
945
- return {
946
- name: raw.name.trim(),
947
- scope,
948
- category,
949
- path: relativePath,
950
- artifacts
951
- };
952
- }
953
- function parseScope(raw, filePath) {
954
- if (raw === "global") {
955
- return "global";
956
- }
957
- if (raw && typeof raw === "object") {
958
- const obj = raw;
959
- if (Array.isArray(obj.tags)) {
960
- const tags = obj.tags.filter((t) => typeof t === "string");
961
- if (tags.length === 0) {
962
- throw new Error(`knowledge.yaml at ${filePath}: scope.tags must be a non-empty array`);
963
- }
964
- return { tags };
965
- }
966
- if (Array.isArray(obj.nodes)) {
967
- const nodes = obj.nodes.filter((n) => typeof n === "string");
968
- if (nodes.length === 0) {
969
- throw new Error(`knowledge.yaml at ${filePath}: scope.nodes must be a non-empty array`);
970
- }
971
- return { nodes };
972
- }
973
- }
974
- throw new Error(`knowledge.yaml at ${filePath}: invalid 'scope' value`);
975
- }
976
-
977
- // src/io/template-parser.ts
978
- import { readFile as readFile9 } from "fs/promises";
979
- import path4 from "path";
980
- import { parse as parseYaml6 } from "yaml";
981
986
  async function parseSchema(filePath) {
982
- const content = await readFile9(filePath, "utf-8");
983
- parseYaml6(content);
984
- 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));
985
990
  return { schemaType };
986
991
  }
987
992
 
988
993
  // src/utils/paths.ts
989
- import path5 from "path";
994
+ import path6 from "path";
990
995
  import { fileURLToPath as fileURLToPath2 } from "url";
991
996
  import { stat as stat2 } from "fs/promises";
992
997
  async function findYggRoot(projectRoot) {
993
- let current = path5.resolve(projectRoot);
994
- const root = path5.parse(current).root;
998
+ let current = path6.resolve(projectRoot);
999
+ const root = path6.parse(current).root;
995
1000
  while (true) {
996
- const yggPath = path5.join(current, ".yggdrasil");
1001
+ const yggPath = path6.join(current, ".yggdrasil");
997
1002
  try {
998
1003
  const st = await stat2(yggPath);
999
1004
  if (!st.isDirectory()) {
@@ -1007,7 +1012,7 @@ async function findYggRoot(projectRoot) {
1007
1012
  if (current === root) {
1008
1013
  throw new Error(`No .yggdrasil/ directory found. Run 'yg init' first.`, { cause: err });
1009
1014
  }
1010
- current = path5.dirname(current);
1015
+ current = path6.dirname(current);
1011
1016
  continue;
1012
1017
  }
1013
1018
  throw err;
@@ -1023,41 +1028,39 @@ function normalizeProjectRelativePath(projectRoot, rawPath) {
1023
1028
  if (normalizedInput.length === 0) {
1024
1029
  throw new Error("Path cannot be empty");
1025
1030
  }
1026
- const absolute = path5.resolve(projectRoot, normalizedInput);
1027
- const relative = path5.relative(projectRoot, absolute);
1028
- 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);
1029
1034
  if (isOutside) {
1030
1035
  throw new Error(`Path is outside project root: ${rawPath}`);
1031
1036
  }
1032
- return relative.split(path5.sep).join("/");
1037
+ return relative.split(path6.sep).join("/");
1033
1038
  }
1034
1039
 
1035
1040
  // src/core/graph-loader.ts
1036
1041
  function toModelPath(absolutePath, modelDir) {
1037
- return path6.relative(modelDir, absolutePath).split(path6.sep).join("/");
1042
+ return path7.relative(modelDir, absolutePath).split(path7.sep).join("/");
1038
1043
  }
1039
1044
  var FALLBACK_CONFIG = {
1040
1045
  name: "",
1041
1046
  stack: {},
1042
1047
  standards: "",
1043
- tags: [],
1044
1048
  node_types: [],
1045
- artifacts: {},
1046
- knowledge_categories: []
1049
+ artifacts: {}
1047
1050
  };
1048
1051
  async function loadGraph(projectRoot, options = {}) {
1049
1052
  const yggRoot = await findYggRoot(projectRoot);
1050
1053
  let configError;
1051
1054
  let config = FALLBACK_CONFIG;
1052
1055
  try {
1053
- config = await parseConfig(path6.join(yggRoot, "config.yaml"));
1056
+ config = await parseConfig(path7.join(yggRoot, "config.yaml"));
1054
1057
  } catch (error) {
1055
1058
  if (!options.tolerateInvalidConfig) {
1056
1059
  throw error;
1057
1060
  }
1058
1061
  configError = error.message;
1059
1062
  }
1060
- const modelDir = path6.join(yggRoot, "model");
1063
+ const modelDir = path7.join(yggRoot, "model");
1061
1064
  const nodes = /* @__PURE__ */ new Map();
1062
1065
  const nodeParseErrors = [];
1063
1066
  const artifactFilenames = Object.keys(config.artifacts ?? {});
@@ -1071,13 +1074,9 @@ async function loadGraph(projectRoot, options = {}) {
1071
1074
  }
1072
1075
  throw err;
1073
1076
  }
1074
- const aspects = await loadAspects(path6.join(yggRoot, "aspects"));
1075
- const flows = await loadFlows(path6.join(yggRoot, "flows"));
1076
- const knowledge = await loadKnowledge(
1077
- path6.join(yggRoot, "knowledge"),
1078
- config.knowledge_categories
1079
- );
1080
- 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"));
1081
1080
  return {
1082
1081
  config,
1083
1082
  configError,
@@ -1085,7 +1084,6 @@ async function loadGraph(projectRoot, options = {}) {
1085
1084
  nodes,
1086
1085
  aspects,
1087
1086
  flows,
1088
- knowledge,
1089
1087
  schemas,
1090
1088
  rootPath: yggRoot
1091
1089
  };
@@ -1098,9 +1096,12 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
1098
1096
  }
1099
1097
  if (hasNodeYaml) {
1100
1098
  const graphPath = toModelPath(dirPath, modelDir);
1099
+ const nodeYamlPath = path7.join(dirPath, "node.yaml");
1101
1100
  let meta;
1101
+ let nodeYamlRaw;
1102
1102
  try {
1103
- meta = await parseNodeYaml(path6.join(dirPath, "node.yaml"));
1103
+ nodeYamlRaw = await readFile9(nodeYamlPath, "utf-8");
1104
+ meta = await parseNodeYaml(nodeYamlPath);
1104
1105
  } catch (err) {
1105
1106
  nodeParseErrors.push({
1106
1107
  nodePath: graphPath,
@@ -1112,6 +1113,7 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
1112
1113
  const node = {
1113
1114
  path: graphPath,
1114
1115
  meta,
1116
+ nodeYamlRaw,
1115
1117
  artifacts,
1116
1118
  children: [],
1117
1119
  parent
@@ -1124,7 +1126,7 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
1124
1126
  if (!entry.isDirectory()) continue;
1125
1127
  if (entry.name.startsWith(".")) continue;
1126
1128
  await scanModelDirectory(
1127
- path6.join(dirPath, entry.name),
1129
+ path7.join(dirPath, entry.name),
1128
1130
  modelDir,
1129
1131
  node,
1130
1132
  nodes,
@@ -1137,7 +1139,7 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
1137
1139
  if (!entry.isDirectory()) continue;
1138
1140
  if (entry.name.startsWith(".")) continue;
1139
1141
  await scanModelDirectory(
1140
- path6.join(dirPath, entry.name),
1142
+ path7.join(dirPath, entry.name),
1141
1143
  modelDir,
1142
1144
  null,
1143
1145
  nodes,
@@ -1149,27 +1151,36 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
1149
1151
  }
1150
1152
  async function loadAspects(aspectsDir) {
1151
1153
  try {
1152
- const entries = await readdir3(aspectsDir, { withFileTypes: true });
1153
1154
  const aspects = [];
1154
- for (const entry of entries) {
1155
- if (!entry.isDirectory()) continue;
1156
- const aspectYamlPath = path6.join(aspectsDir, entry.name, "aspect.yaml");
1157
- const aspect = await parseAspect(path6.join(aspectsDir, entry.name), aspectYamlPath);
1158
- aspects.push(aspect);
1159
- }
1155
+ await scanAspectsDirectory(aspectsDir, aspectsDir, aspects);
1160
1156
  return aspects;
1161
1157
  } catch {
1162
1158
  return [];
1163
1159
  }
1164
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
+ }
1165
1176
  async function loadFlows(flowsDir) {
1166
1177
  try {
1167
1178
  const entries = await readdir3(flowsDir, { withFileTypes: true });
1168
1179
  const flows = [];
1169
1180
  for (const entry of entries) {
1170
1181
  if (!entry.isDirectory()) continue;
1171
- const flowYamlPath = path6.join(flowsDir, entry.name, "flow.yaml");
1172
- 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);
1173
1184
  flows.push(flow);
1174
1185
  }
1175
1186
  return flows;
@@ -1177,37 +1188,14 @@ async function loadFlows(flowsDir) {
1177
1188
  return [];
1178
1189
  }
1179
1190
  }
1180
- async function loadKnowledge(knowledgeDir, categories) {
1181
- const items = [];
1182
- const categorySet = new Set(categories.map((c) => c.name));
1183
- try {
1184
- const catEntries = await readdir3(knowledgeDir, { withFileTypes: true });
1185
- for (const catEntry of catEntries) {
1186
- if (!catEntry.isDirectory()) continue;
1187
- if (!categorySet.has(catEntry.name)) continue;
1188
- const catPath = path6.join(knowledgeDir, catEntry.name);
1189
- const itemEntries = await readdir3(catPath, { withFileTypes: true });
1190
- for (const itemEntry of itemEntries) {
1191
- if (!itemEntry.isDirectory()) continue;
1192
- const itemDir = path6.join(catPath, itemEntry.name);
1193
- const knowledgeYamlPath = path6.join(itemDir, "knowledge.yaml");
1194
- const relativePath = `${catEntry.name}/${itemEntry.name}`;
1195
- const item = await parseKnowledge(itemDir, knowledgeYamlPath, catEntry.name, relativePath);
1196
- items.push(item);
1197
- }
1198
- }
1199
- } catch {
1200
- }
1201
- return items;
1202
- }
1203
- async function loadSchemas(templatesDir) {
1191
+ async function loadSchemas(schemasDir) {
1204
1192
  try {
1205
- const entries = await readdir3(templatesDir, { withFileTypes: true });
1193
+ const entries = await readdir3(schemasDir, { withFileTypes: true });
1206
1194
  const schemas = [];
1207
1195
  for (const entry of entries) {
1208
1196
  if (!entry.isFile()) continue;
1209
1197
  if (!entry.name.endsWith(".yaml") && !entry.name.endsWith(".yml")) continue;
1210
- const s = await parseSchema(path6.join(templatesDir, entry.name));
1198
+ const s = await parseSchema(path7.join(schemasDir, entry.name));
1211
1199
  schemas.push(s);
1212
1200
  }
1213
1201
  return schemas;
@@ -1218,7 +1206,7 @@ async function loadSchemas(templatesDir) {
1218
1206
 
1219
1207
  // src/core/context-builder.ts
1220
1208
  import { readFile as readFile10 } from "fs/promises";
1221
- import path7 from "path";
1209
+ import path8 from "path";
1222
1210
 
1223
1211
  // src/utils/tokens.ts
1224
1212
  function estimateTokens(text) {
@@ -1233,18 +1221,13 @@ async function buildContext(graph, nodePath) {
1233
1221
  if (!node) {
1234
1222
  throw new Error(`Node not found: ${nodePath}`);
1235
1223
  }
1236
- const nodeTags = new Set(node.meta.tags ?? []);
1237
- const seenKnowledge = /* @__PURE__ */ new Set();
1238
1224
  const layers = [];
1239
1225
  layers.push(buildGlobalLayer(graph.config));
1240
- for (const k of collectKnowledgeItems(graph, nodePath, nodeTags, seenKnowledge)) {
1241
- layers.push(buildKnowledgeLayer(k));
1242
- }
1243
1226
  const ancestors = collectAncestors(node);
1244
1227
  for (const ancestor of ancestors) {
1245
- layers.push(buildHierarchyLayer(ancestor, graph.config));
1228
+ layers.push(buildHierarchyLayer(ancestor, graph.config, graph));
1246
1229
  }
1247
- layers.push(await buildOwnLayer(node, graph.config, graph.rootPath));
1230
+ layers.push(await buildOwnLayer(node, graph.config, graph.rootPath, graph));
1248
1231
  for (const relation of node.meta.relations ?? []) {
1249
1232
  const target = graph.nodes.get(relation.target);
1250
1233
  if (!target) {
@@ -1256,24 +1239,22 @@ async function buildContext(graph, nodePath) {
1256
1239
  layers.push(buildEventRelationLayer(target, relation));
1257
1240
  }
1258
1241
  }
1259
- for (const tag of nodeTags) {
1260
- for (const aspect of graph.aspects) {
1261
- if (aspect.tag === tag) {
1262
- layers.push(buildAspectLayer(aspect));
1263
- }
1264
- }
1265
- }
1266
1242
  for (const flow of collectParticipatingFlows(graph, node)) {
1267
- layers.push(buildFlowLayer(flow));
1268
- for (const kPath of flow.knowledge ?? []) {
1269
- const norm = kPath.replace(/\/$/, "");
1270
- const k = graph.knowledge.find((item) => item.path === norm || item.path === kPath);
1271
- if (k && !seenKnowledge.has(k.path)) {
1272
- seenKnowledge.add(k.path);
1273
- 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);
1274
1251
  }
1275
1252
  }
1276
1253
  }
1254
+ const aspectsToInclude = resolveAspects(allAspectIds, graph.aspects);
1255
+ for (const aspect of aspectsToInclude) {
1256
+ layers.push(buildAspectLayer(aspect));
1257
+ }
1277
1258
  const fullText = layers.map((l) => l.content).join("\n\n");
1278
1259
  const tokenCount = estimateTokens(fullText);
1279
1260
  const mapping = normalizeMappingPaths(node.meta.mapping);
@@ -1287,47 +1268,46 @@ async function buildContext(graph, nodePath) {
1287
1268
  tokenCount
1288
1269
  };
1289
1270
  }
1290
- 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
+ }
1291
1280
  const result = [];
1292
- for (const k of graph.knowledge) {
1293
- if (k.scope === "global" && !seenKnowledge.has(k.path)) {
1294
- seenKnowledge.add(k.path);
1295
- result.push(k);
1296
- }
1297
- }
1298
- for (const k of graph.knowledge) {
1299
- if (typeof k.scope === "object" && "tags" in k.scope) {
1300
- const overlap = k.scope.tags.some((t) => nodeTags.has(t));
1301
- if (overlap && !seenKnowledge.has(k.path)) {
1302
- seenKnowledge.add(k.path);
1303
- result.push(k);
1304
- }
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}'`);
1305
1286
  }
1306
- }
1307
- for (const k of graph.knowledge) {
1308
- if (typeof k.scope === "object" && "nodes" in k.scope) {
1309
- if (k.scope.nodes.includes(nodePath) && !seenKnowledge.has(k.path)) {
1310
- seenKnowledge.add(k.path);
1311
- 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);
1312
1295
  }
1313
1296
  }
1297
+ stack.delete(id);
1314
1298
  }
1315
- const node = graph.nodes.get(nodePath);
1316
- if (node?.meta.knowledge) {
1317
- for (const kPath of node.meta.knowledge) {
1318
- const norm = kPath.replace(/\/$/, "");
1319
- const k = graph.knowledge.find((item) => item.path === norm || item.path === kPath);
1320
- if (k && !seenKnowledge.has(k.path)) {
1321
- seenKnowledge.add(k.path);
1322
- result.push(k);
1323
- }
1324
- }
1299
+ for (const id of aspectIds) {
1300
+ collect(id);
1325
1301
  }
1326
1302
  return result;
1327
1303
  }
1328
- function collectParticipatingFlows(graph, node) {
1329
- const paths = /* @__PURE__ */ new Set([node.path, ...collectAncestors(node).map((a) => a.path)]);
1330
- 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);
1331
1311
  }
1332
1312
  function buildGlobalLayer(config) {
1333
1313
  let content = `**Project:** ${config.name}
@@ -1345,41 +1325,39 @@ ${config.standards || "(none)"}
1345
1325
  `;
1346
1326
  return { type: "global", label: "Global Context", content };
1347
1327
  }
1348
- function buildKnowledgeLayer(k, fromFlow) {
1349
- const categoryLabel = k.category.charAt(0).toUpperCase() + k.category.slice(1);
1350
- const content = k.artifacts.map((a) => `### ${a.filename}
1351
- ${a.content}`).join("\n\n");
1352
- const label = fromFlow ? `Long-term Memory (from flow): ${k.name}` : `${categoryLabel}: ${k.name}`;
1353
- return {
1354
- type: "knowledge",
1355
- label,
1356
- content
1357
- };
1358
- }
1359
1328
  function filterArtifactsByConfig(artifacts, config) {
1360
1329
  const allowed = new Set(Object.keys(config.artifacts ?? {}));
1361
1330
  return artifacts.filter((a) => allowed.has(a.filename));
1362
1331
  }
1363
- function buildHierarchyLayer(ancestor, config) {
1332
+ function buildHierarchyLayer(ancestor, config, graph) {
1364
1333
  const filtered = filterArtifactsByConfig(ancestor.artifacts, config);
1365
1334
  const content = filtered.map((a) => `### ${a.filename}
1366
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;
1367
1339
  return {
1368
1340
  type: "hierarchy",
1369
1341
  label: `Module Context (${ancestor.path}/)`,
1370
- content
1342
+ content,
1343
+ attrs
1371
1344
  };
1372
1345
  }
1373
- async function buildOwnLayer(node, config, graphRootPath) {
1346
+ async function buildOwnLayer(node, config, graphRootPath, graph) {
1374
1347
  const parts = [];
1375
- const nodeYamlPath = path7.join(graphRootPath, "model", node.path, "node.yaml");
1376
- try {
1377
- const nodeYamlContent = await readFile10(nodeYamlPath, "utf-8");
1348
+ if (node.nodeYamlRaw) {
1378
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
1379
1356
  ${nodeYamlContent.trim()}`);
1380
- } catch {
1381
- parts.push(`### node.yaml
1357
+ } catch {
1358
+ parts.push(`### node.yaml
1382
1359
  (not found)`);
1360
+ }
1383
1361
  }
1384
1362
  const filtered = filterArtifactsByConfig(node.artifacts, config);
1385
1363
  for (const a of filtered) {
@@ -1387,10 +1365,14 @@ ${nodeYamlContent.trim()}`);
1387
1365
  ${a.content}`);
1388
1366
  }
1389
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;
1390
1371
  return {
1391
1372
  type: "own",
1392
1373
  label: `Node: ${node.meta.name}`,
1393
- content
1374
+ content,
1375
+ attrs
1394
1376
  };
1395
1377
  }
1396
1378
  function buildStructuralRelationLayer(target, relation, config) {
@@ -1418,10 +1400,17 @@ ${a.content}`).join("\n\n");
1418
1400
  content += filtered.map((a) => `### ${a.filename}
1419
1401
  ${a.content}`).join("\n\n");
1420
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;
1421
1409
  return {
1422
1410
  type: "relational",
1423
1411
  label: `Dependency: ${target.meta.name} (${relation.type}) \u2014 ${target.path}`,
1424
- content: content.trim()
1412
+ content: content.trim(),
1413
+ attrs
1425
1414
  };
1426
1415
  }
1427
1416
  function buildEventRelationLayer(target, relation) {
@@ -1434,10 +1423,17 @@ You listen for ${eventName}.`;
1434
1423
  content += `
1435
1424
  Consumes: ${relation.consumes.join(", ")}`;
1436
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(", ");
1437
1432
  return {
1438
1433
  type: "relational",
1439
1434
  label: `Event: ${eventName} [${relation.type}]`,
1440
- content
1435
+ content,
1436
+ attrs
1441
1437
  };
1442
1438
  }
1443
1439
  function buildAspectLayer(aspect) {
@@ -1445,17 +1441,21 @@ function buildAspectLayer(aspect) {
1445
1441
  ${a.content}`).join("\n\n");
1446
1442
  return {
1447
1443
  type: "aspects",
1448
- label: `${aspect.name} (tag: ${aspect.tag})`,
1444
+ label: `${aspect.name} (aspect: ${aspect.id})`,
1449
1445
  content
1450
1446
  };
1451
1447
  }
1452
- function buildFlowLayer(flow) {
1448
+ function buildFlowLayer(flow, graph) {
1453
1449
  const content = flow.artifacts.map((a) => `### ${a.filename}
1454
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;
1455
1454
  return {
1456
1455
  type: "flows",
1457
1456
  label: `Flow: ${flow.name}`,
1458
- content: content || "(no artifacts)"
1457
+ content: content || "(no artifacts)",
1458
+ attrs
1459
1459
  };
1460
1460
  }
1461
1461
  function buildSections(layers, mapping) {
@@ -1469,12 +1469,16 @@ function buildSections(layers, mapping) {
1469
1469
  }
1470
1470
  return [
1471
1471
  { key: "Global", layers: layers.filter((l) => l.type === "global") },
1472
- { key: "Knowledge", layers: layers.filter((l) => l.type === "knowledge") },
1473
1472
  { key: "Hierarchy", layers: layers.filter((l) => l.type === "hierarchy") },
1474
1473
  { key: "OwnArtifacts", layers: ownLayers },
1475
- { key: "Dependencies", layers: layers.filter((l) => l.type === "relational") },
1476
1474
  { key: "Aspects", layers: layers.filter((l) => l.type === "aspects") },
1477
- { 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
+ }
1478
1482
  ];
1479
1483
  }
1480
1484
  function collectAncestors(node) {
@@ -1486,30 +1490,27 @@ function collectAncestors(node) {
1486
1490
  }
1487
1491
  return ancestors;
1488
1492
  }
1489
-
1490
- // src/core/validator.ts
1491
- import { readdir as readdir4 } from "fs/promises";
1492
- import path9 from "path";
1493
-
1494
- // src/utils/git.ts
1495
- import { execSync } from "child_process";
1496
- import path8 from "path";
1497
- function getLastCommitTimestamp(projectRoot, relativePath) {
1498
- const normalized = path8.normalize(relativePath).replace(/\\/g, "/");
1499
- try {
1500
- const out = execSync(`git log -1 --format=%ct -- "${normalized}"`, {
1501
- cwd: projectRoot,
1502
- encoding: "utf-8",
1503
- stdio: ["pipe", "pipe", "pipe"]
1504
- });
1505
- const ts = parseInt(out.trim(), 10);
1506
- return Number.isNaN(ts) ? null : ts;
1507
- } catch {
1508
- 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
+ }
1509
1507
  }
1508
+ return new Set(expandAspects([...raw], graph.aspects));
1510
1509
  }
1511
1510
 
1512
1511
  // src/core/validator.ts
1512
+ import { readdir as readdir4 } from "fs/promises";
1513
+ import path9 from "path";
1513
1514
  var RESERVED_DIRS = /* @__PURE__ */ new Set();
1514
1515
  async function validate(graph, scope = "all") {
1515
1516
  const issues = [];
@@ -1532,28 +1533,25 @@ async function validate(graph, scope = "all") {
1532
1533
  }
1533
1534
  if (!graph.configError) {
1534
1535
  issues.push(...checkNodeTypes(graph));
1535
- issues.push(...checkTagsDefined(graph));
1536
- issues.push(...checkAspectTags(graph));
1537
- 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));
1538
1542
  issues.push(...checkRequiredArtifacts(graph));
1539
- issues.push(...await checkUnknownKnowledgeCategories(graph));
1540
1543
  issues.push(...checkInvalidArtifactConditions(graph));
1541
- issues.push(...checkScopeTagsDefined(graph));
1542
- issues.push(...await checkMissingPatternExamples(graph));
1543
1544
  issues.push(...await checkContextBudget(graph));
1544
1545
  issues.push(...checkHighFanOut(graph));
1545
- issues.push(...await checkStaleKnowledge(graph));
1546
1546
  }
1547
1547
  issues.push(...checkSchemas(graph));
1548
1548
  issues.push(...checkRelationTargets(graph));
1549
1549
  issues.push(...checkNoCycles(graph));
1550
1550
  issues.push(...checkMappingOverlap(graph));
1551
- issues.push(...checkBrokenKnowledgeRefs(graph));
1552
1551
  issues.push(...checkBrokenFlowRefs(graph));
1553
- issues.push(...checkBrokenScopeRefs(graph));
1552
+ issues.push(...checkFlowAspectIds(graph));
1554
1553
  issues.push(...await checkDirectoriesHaveNodeYaml(graph));
1555
1554
  issues.push(...await checkShallowArtifacts(graph));
1556
- issues.push(...await checkUnreachableKnowledge(graph));
1557
1555
  issues.push(...checkUnpairedEvents(graph));
1558
1556
  let filtered = issues;
1559
1557
  let nodesScanned = graph.nodes.size;
@@ -1571,7 +1569,7 @@ async function validate(graph, scope = "all") {
1571
1569
  }
1572
1570
  function checkNodeTypes(graph) {
1573
1571
  const issues = [];
1574
- const allowedTypes = new Set(graph.config.node_types ?? []);
1572
+ const allowedTypes = new Set((graph.config.node_types ?? []).map((t) => t.name));
1575
1573
  for (const [nodePath, node] of graph.nodes) {
1576
1574
  if (!allowedTypes.has(node.meta.type)) {
1577
1575
  issues.push({
@@ -1634,17 +1632,17 @@ function checkRelationTargets(graph) {
1634
1632
  }
1635
1633
  return issues;
1636
1634
  }
1637
- function checkTagsDefined(graph) {
1635
+ function checkAspectsDefined(graph) {
1638
1636
  const issues = [];
1639
- const definedTags = new Set(graph.config.tags ?? []);
1637
+ const validAspectIds = new Set(graph.aspects.map((a) => a.id));
1640
1638
  for (const [nodePath, node] of graph.nodes) {
1641
- for (const tag of node.meta.tags ?? []) {
1642
- if (!definedTags.has(tag)) {
1639
+ for (const aspectId of node.meta.aspects ?? []) {
1640
+ if (!validAspectIds.has(aspectId)) {
1643
1641
  issues.push({
1644
1642
  severity: "error",
1645
1643
  code: "E003",
1646
- rule: "unknown-tag",
1647
- message: `Tag '${tag}' not defined in config.yaml`,
1644
+ rule: "unknown-aspect",
1645
+ message: `Aspect '${aspectId}' has no corresponding directory in aspects/`,
1648
1646
  nodePath
1649
1647
  });
1650
1648
  }
@@ -1652,40 +1650,124 @@ function checkTagsDefined(graph) {
1652
1650
  }
1653
1651
  return issues;
1654
1652
  }
1655
- function checkAspectTags(graph) {
1656
- const issues = [];
1657
- const definedTags = new Set(graph.config.tags ?? []);
1658
- for (const aspect of graph.aspects) {
1659
- if (!definedTags.has(aspect.tag)) {
1660
- issues.push({
1661
- severity: "error",
1662
- code: "E007",
1663
- rule: "broken-aspect-tag",
1664
- message: `Aspect '${aspect.name}' references undefined tag '${aspect.tag}'`
1665
- });
1666
- }
1667
- }
1668
- return issues;
1653
+ function checkAspectIds(_graph) {
1654
+ return [];
1669
1655
  }
1670
- function checkAspectTagUniqueness(graph) {
1656
+ function checkAspectIdUniqueness(graph) {
1671
1657
  const issues = [];
1672
- const byTag = /* @__PURE__ */ new Map();
1658
+ const byId = /* @__PURE__ */ new Map();
1673
1659
  for (const aspect of graph.aspects) {
1674
- const names = byTag.get(aspect.tag) ?? [];
1660
+ const names = byId.get(aspect.id) ?? [];
1675
1661
  names.push(aspect.name);
1676
- byTag.set(aspect.tag, names);
1662
+ byId.set(aspect.id, names);
1677
1663
  }
1678
- for (const [tag, names] of byTag) {
1664
+ for (const [id, names] of byId) {
1679
1665
  if (names.length <= 1) continue;
1680
1666
  issues.push({
1681
1667
  severity: "error",
1682
1668
  code: "E014",
1683
1669
  rule: "duplicate-aspect-binding",
1684
- message: `Tag '${tag}' is bound to multiple aspects (${names.join(", ")})`
1670
+ message: `Aspect '${id}' is bound to multiple aspects (${names.join(", ")})`
1685
1671
  });
1686
1672
  }
1687
1673
  return issues;
1688
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
+ }
1689
1771
  function checkNoCycles(graph) {
1690
1772
  const WHITE = 0;
1691
1773
  const GRAY = 1;
@@ -1787,9 +1869,10 @@ function artifactRequiredReason(graph, nodePath, node, required) {
1787
1869
  const count = node.meta.relations?.length ?? 0;
1788
1870
  return count > 0 ? `${count} outgoing relation(s)` : null;
1789
1871
  }
1790
- if (when.startsWith("has_tag:")) {
1791
- const tag = when.slice(8);
1792
- 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;
1793
1876
  }
1794
1877
  return null;
1795
1878
  }
@@ -1829,29 +1912,9 @@ function checkRequiredArtifacts(graph) {
1829
1912
  }
1830
1913
  return issues;
1831
1914
  }
1832
- function checkBrokenKnowledgeRefs(graph) {
1833
- const issues = [];
1834
- const knowledgePaths = new Set(graph.knowledge.map((k) => k.path));
1835
- for (const [nodePath, node] of graph.nodes) {
1836
- for (const kPath of node.meta.knowledge ?? []) {
1837
- const norm = kPath.replace(/\/$/, "");
1838
- if (!knowledgePaths.has(norm) && !knowledgePaths.has(kPath)) {
1839
- issues.push({
1840
- severity: "error",
1841
- code: "E005",
1842
- rule: "broken-knowledge-ref",
1843
- message: `Knowledge ref '${kPath}' does not resolve to existing knowledge item`,
1844
- nodePath
1845
- });
1846
- }
1847
- }
1848
- }
1849
- return issues;
1850
- }
1851
1915
  function checkBrokenFlowRefs(graph) {
1852
1916
  const issues = [];
1853
1917
  const nodePaths = new Set(graph.nodes.keys());
1854
- const knowledgePaths = new Set(graph.knowledge.map((k) => k.path));
1855
1918
  for (const flow of graph.flows) {
1856
1919
  for (const n of flow.nodes) {
1857
1920
  if (!nodePaths.has(n)) {
@@ -1863,107 +1926,43 @@ function checkBrokenFlowRefs(graph) {
1863
1926
  });
1864
1927
  }
1865
1928
  }
1866
- for (const kPath of flow.knowledge ?? []) {
1867
- const norm = kPath.replace(/\/$/, "");
1868
- if (!knowledgePaths.has(norm) && !knowledgePaths.has(kPath)) {
1869
- issues.push({
1870
- severity: "error",
1871
- code: "E005",
1872
- rule: "broken-knowledge-ref",
1873
- message: `Flow '${flow.name}' references non-existent knowledge '${kPath}'`,
1874
- nodePath: `flows/${flow.name}`
1875
- });
1876
- }
1877
- }
1878
- }
1879
- return issues;
1880
- }
1881
- function checkBrokenScopeRefs(graph) {
1882
- const issues = [];
1883
- const nodePaths = new Set(graph.nodes.keys());
1884
- for (const k of graph.knowledge) {
1885
- if (typeof k.scope === "object" && "nodes" in k.scope) {
1886
- for (const n of k.scope.nodes) {
1887
- if (!nodePaths.has(n)) {
1888
- issues.push({
1889
- severity: "error",
1890
- code: "E008",
1891
- rule: "broken-scope-ref",
1892
- message: `Knowledge '${k.path}' scope references non-existent node '${n}'`
1893
- });
1894
- }
1895
- }
1896
- }
1897
- }
1898
- return issues;
1899
- }
1900
- function checkScopeTagsDefined(graph) {
1901
- const issues = [];
1902
- const definedTags = new Set(graph.config.tags ?? []);
1903
- for (const k of graph.knowledge) {
1904
- if (typeof k.scope !== "object" || !("tags" in k.scope)) continue;
1905
- for (const tag of k.scope.tags) {
1906
- if (definedTags.has(tag)) continue;
1907
- issues.push({
1908
- severity: "error",
1909
- code: "E008",
1910
- rule: "broken-scope-ref",
1911
- message: `Knowledge '${k.path}' scope references undefined tag '${tag}'`
1912
- });
1913
- }
1914
1929
  }
1915
1930
  return issues;
1916
1931
  }
1917
- async function checkUnknownKnowledgeCategories(graph) {
1932
+ function checkFlowAspectIds(graph) {
1918
1933
  const issues = [];
1919
- const categorySet = new Set((graph.config.knowledge_categories ?? []).map((c) => c.name));
1920
- const knowledgeDir = path9.join(graph.rootPath, "knowledge");
1921
- const existingDirs = /* @__PURE__ */ new Set();
1922
- try {
1923
- const entries = await readdir4(knowledgeDir, { withFileTypes: true });
1924
- for (const e of entries) {
1925
- if (!e.isDirectory()) continue;
1926
- if (e.name.startsWith(".")) continue;
1927
- existingDirs.add(e.name);
1928
- 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)) {
1929
1938
  issues.push({
1930
1939
  severity: "error",
1931
- code: "E011",
1932
- rule: "unknown-knowledge-category",
1933
- 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/`
1934
1943
  });
1935
1944
  }
1936
1945
  }
1937
- } catch {
1938
- }
1939
- for (const cat of graph.config.knowledge_categories ?? []) {
1940
- if (!existingDirs.has(cat.name)) {
1941
- issues.push({
1942
- severity: "error",
1943
- code: "E017",
1944
- rule: "missing-knowledge-category-dir",
1945
- message: `Category '${cat.name}' in config has no knowledge/${cat.name}/ directory`
1946
- });
1947
- }
1948
1946
  }
1949
1947
  return issues;
1950
1948
  }
1951
1949
  function checkInvalidArtifactConditions(graph) {
1952
1950
  const issues = [];
1953
- const definedTags = new Set(graph.config.tags ?? []);
1951
+ const validAspectIds = new Set(graph.aspects.map((a) => a.id));
1954
1952
  const artifacts = graph.config.artifacts ?? {};
1955
1953
  for (const [artifactName, config] of Object.entries(artifacts)) {
1956
1954
  const required = config.required;
1957
1955
  if (typeof required === "object" && required && "when" in required) {
1958
1956
  const when = required.when;
1959
- if (when.startsWith("has_tag:")) {
1960
- const tag = when.slice(8);
1961
- 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)) {
1962
1961
  issues.push({
1963
1962
  severity: "error",
1964
1963
  code: "E013",
1965
1964
  rule: "invalid-artifact-condition",
1966
- message: `Artifact '${artifactName}' condition has_tag:${tag} references undefined tag`
1965
+ message: `Artifact '${artifactName}' condition has_aspect:${aspectId} has no corresponding aspect in aspects/`
1967
1966
  });
1968
1967
  }
1969
1968
  }
@@ -1981,7 +1980,7 @@ async function checkShallowArtifacts(graph) {
1981
1980
  severity: "warning",
1982
1981
  code: "W002",
1983
1982
  rule: "shallow-artifact",
1984
- 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})`,
1985
1984
  nodePath
1986
1985
  });
1987
1986
  }
@@ -1989,100 +1988,7 @@ async function checkShallowArtifacts(graph) {
1989
1988
  }
1990
1989
  return issues;
1991
1990
  }
1992
- async function checkUnreachableKnowledge(graph) {
1993
- const issues = [];
1994
- const nodePaths = new Set(graph.nodes.keys());
1995
- const nodeTags = /* @__PURE__ */ new Map();
1996
- for (const [p, n] of graph.nodes) {
1997
- nodeTags.set(p, new Set(n.meta.tags ?? []));
1998
- }
1999
- const knowledgeReachable = /* @__PURE__ */ new Set();
2000
- for (const k of graph.knowledge) {
2001
- if (k.scope === "global") {
2002
- knowledgeReachable.add(k.path);
2003
- continue;
2004
- }
2005
- if (typeof k.scope === "object" && "tags" in k.scope) {
2006
- for (const [, tags] of nodeTags) {
2007
- if (k.scope.tags.some((t) => tags.has(t))) {
2008
- knowledgeReachable.add(k.path);
2009
- break;
2010
- }
2011
- }
2012
- }
2013
- if (typeof k.scope === "object" && "nodes" in k.scope) {
2014
- if (k.scope.nodes.some((n) => nodePaths.has(n))) {
2015
- knowledgeReachable.add(k.path);
2016
- }
2017
- }
2018
- }
2019
- for (const [, node] of graph.nodes) {
2020
- for (const kPath of node.meta.knowledge ?? []) {
2021
- const k = graph.knowledge.find(
2022
- (i) => i.path === kPath || i.path === kPath.replace(/\/$/, "")
2023
- );
2024
- if (k) knowledgeReachable.add(k.path);
2025
- }
2026
- }
2027
- for (const flow of graph.flows) {
2028
- for (const kPath of flow.knowledge ?? []) {
2029
- const k = graph.knowledge.find(
2030
- (i) => i.path === kPath || i.path === kPath.replace(/\/$/, "")
2031
- );
2032
- if (k) knowledgeReachable.add(k.path);
2033
- }
2034
- }
2035
- for (const k of graph.knowledge) {
2036
- if (!knowledgeReachable.has(k.path)) {
2037
- issues.push({
2038
- severity: "warning",
2039
- code: "W003",
2040
- rule: "unreachable-knowledge",
2041
- message: `Knowledge '${k.path}' does not reach any context package`
2042
- });
2043
- }
2044
- }
2045
- return issues;
2046
- }
2047
- async function checkMissingPatternExamples(graph) {
2048
- const issues = [];
2049
- const hasPatterns = (graph.config.knowledge_categories ?? []).some((c) => c.name === "patterns");
2050
- if (!hasPatterns) return issues;
2051
- const patternsDir = path9.join(graph.rootPath, "knowledge", "patterns");
2052
- try {
2053
- const entries = await readdir4(patternsDir, { withFileTypes: true });
2054
- const exampleExtensions = /* @__PURE__ */ new Set([
2055
- ".ts",
2056
- ".js",
2057
- ".tsx",
2058
- ".jsx",
2059
- ".py",
2060
- ".go",
2061
- ".rs",
2062
- ".java",
2063
- ".kt"
2064
- ]);
2065
- for (const e of entries) {
2066
- if (!e.isDirectory()) continue;
2067
- const itemDir = path9.join(patternsDir, e.name);
2068
- const itemEntries = await readdir4(itemDir, { withFileTypes: true });
2069
- const hasExample = itemEntries.some(
2070
- (f) => f.isFile() && f.name !== "knowledge.yaml" && (f.name.startsWith("example") || exampleExtensions.has(path9.extname(f.name).toLowerCase()))
2071
- );
2072
- if (!hasExample) {
2073
- issues.push({
2074
- severity: "warning",
2075
- code: "W004",
2076
- rule: "missing-example",
2077
- message: `Pattern 'patterns/${e.name}' has no example file`
2078
- });
2079
- }
2080
- }
2081
- } catch {
2082
- }
2083
- return issues;
2084
- }
2085
- function checkHighFanOut(graph) {
1991
+ function checkHighFanOut(graph) {
2086
1992
  const issues = [];
2087
1993
  const maxRel = graph.config.quality?.max_direct_relations ?? 10;
2088
1994
  for (const [nodePath, node] of graph.nodes) {
@@ -2099,57 +2005,6 @@ function checkHighFanOut(graph) {
2099
2005
  }
2100
2006
  return issues;
2101
2007
  }
2102
- function getNodesInScope(k, graph) {
2103
- if (k.scope === "global") {
2104
- return [...graph.nodes.keys()];
2105
- }
2106
- if (typeof k.scope === "object" && "nodes" in k.scope && k.scope.nodes) {
2107
- return k.scope.nodes.filter((p) => graph.nodes.has(p));
2108
- }
2109
- if (typeof k.scope === "object" && "tags" in k.scope && k.scope.tags) {
2110
- const tagSet = new Set(k.scope.tags);
2111
- return [...graph.nodes.keys()].filter((p) => {
2112
- const node = graph.nodes.get(p);
2113
- return (node.meta.tags ?? []).some((t) => tagSet.has(t));
2114
- });
2115
- }
2116
- return [];
2117
- }
2118
- async function checkStaleKnowledge(graph) {
2119
- const issues = [];
2120
- const stalenessDays = graph.config.quality?.knowledge_staleness_days ?? 90;
2121
- const projectRoot = path9.dirname(graph.rootPath);
2122
- const yggRel = path9.relative(projectRoot, graph.rootPath).replace(/\\/g, "/") || ".yggdrasil";
2123
- for (const k of graph.knowledge) {
2124
- const scopeNodes = getNodesInScope(k, graph);
2125
- if (scopeNodes.length === 0) continue;
2126
- const kPath = `${yggRel}/knowledge/${k.path}`;
2127
- const tK = getLastCommitTimestamp(projectRoot, kPath);
2128
- if (tK === null) continue;
2129
- let maxTp = 0;
2130
- let latestNode = "";
2131
- for (const nodePath of scopeNodes) {
2132
- const nodePathRel = `${yggRel}/model/${nodePath}`;
2133
- const tP = getLastCommitTimestamp(projectRoot, nodePathRel);
2134
- if (tP !== null && tP > maxTp) {
2135
- maxTp = tP;
2136
- latestNode = nodePath;
2137
- }
2138
- }
2139
- if (maxTp === 0) continue;
2140
- const diffDays = (maxTp - tK) / (60 * 60 * 24);
2141
- if (diffDays > stalenessDays) {
2142
- issues.push({
2143
- severity: "warning",
2144
- code: "W008",
2145
- rule: "stale-knowledge",
2146
- message: `Knowledge '${k.path}' may be stale: node '${latestNode}' modified ${Math.floor(diffDays)} days later (Git commits)`,
2147
- nodePath: latestNode
2148
- });
2149
- }
2150
- }
2151
- return issues;
2152
- }
2153
2008
  function checkUnpairedEvents(graph) {
2154
2009
  const issues = [];
2155
2010
  const emitsTo = /* @__PURE__ */ new Map();
@@ -2198,7 +2053,7 @@ function checkUnpairedEvents(graph) {
2198
2053
  }
2199
2054
  return issues;
2200
2055
  }
2201
- var REQUIRED_SCHEMAS = ["node", "aspect", "flow", "knowledge"];
2056
+ var REQUIRED_SCHEMAS = ["node", "aspect", "flow"];
2202
2057
  function checkSchemas(graph) {
2203
2058
  const issues = [];
2204
2059
  const present = new Set(graph.schemas.map((s) => s.schemaType));
@@ -2208,7 +2063,7 @@ function checkSchemas(graph) {
2208
2063
  severity: "warning",
2209
2064
  code: "W010",
2210
2065
  rule: "missing-schema",
2211
- message: `Schema '${required}.yaml' missing from .yggdrasil/templates/`
2066
+ message: `Schema '${required}.yaml' missing from .yggdrasil/schemas/`
2212
2067
  });
2213
2068
  }
2214
2069
  }
@@ -2253,26 +2108,26 @@ async function checkDirectoriesHaveNodeYaml(graph) {
2253
2108
  }
2254
2109
  async function checkContextBudget(graph) {
2255
2110
  const issues = [];
2256
- const warningThreshold = graph.config.quality?.context_budget.warning ?? 5e3;
2257
- 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;
2258
2113
  for (const [nodePath, node] of graph.nodes) {
2259
2114
  if (node.meta.blackbox) continue;
2260
2115
  try {
2261
- const pkg = await buildContext(graph, nodePath);
2262
- if (pkg.tokenCount >= errorThreshold) {
2116
+ const pkg2 = await buildContext(graph, nodePath);
2117
+ if (pkg2.tokenCount >= errorThreshold) {
2263
2118
  issues.push({
2264
2119
  severity: "warning",
2265
2120
  code: "W006",
2266
2121
  rule: "budget-error",
2267
- 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`,
2268
2123
  nodePath
2269
2124
  });
2270
- } else if (pkg.tokenCount >= warningThreshold) {
2125
+ } else if (pkg2.tokenCount >= warningThreshold) {
2271
2126
  issues.push({
2272
2127
  severity: "warning",
2273
2128
  code: "W005",
2274
2129
  rule: "budget-warning",
2275
- 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.`,
2276
2131
  nodePath
2277
2132
  });
2278
2133
  }
@@ -2282,42 +2137,76 @@ async function checkContextBudget(graph) {
2282
2137
  return issues;
2283
2138
  }
2284
2139
 
2285
- // src/formatters/markdown.ts
2286
- function formatContextMarkdown(pkg) {
2287
- let md = "";
2288
- md += `# Context Package: ${pkg.nodeName}
2289
- `;
2290
- md += `# Path: ${pkg.nodePath}
2291
- `;
2292
- md += `# Generated: ${(/* @__PURE__ */ new Date()).toISOString()}
2293
-
2294
- `;
2295
- md += `---
2296
-
2297
- `;
2298
- for (const section of pkg.sections) {
2299
- if (section.layers.length === 0) continue;
2300
- 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}>
2301
2201
 
2302
2202
  `;
2203
+ for (const section of pkg2.sections) {
2303
2204
  for (const layer of section.layers) {
2304
- md += `### ${layer.label}
2305
-
2306
- `;
2307
- md += layer.content;
2308
- md += `
2309
-
2310
- `;
2205
+ out += formatLayer(layer) + "\n\n";
2311
2206
  }
2312
- md += `---
2313
-
2314
- `;
2315
2207
  }
2316
- md += `Context size: ${pkg.tokenCount.toLocaleString()} tokens
2317
- `;
2318
- md += `Layers: ${pkg.layers.map((l) => l.type).join(", ")}
2319
- `;
2320
- return md;
2208
+ out += "</context-package>";
2209
+ return out;
2321
2210
  }
2322
2211
 
2323
2212
  // src/cli/build-context.ts
@@ -2337,20 +2226,19 @@ function registerBuildCommand(program2) {
2337
2226
  process.exit(1);
2338
2227
  }
2339
2228
  const nodePath = options.node.trim().replace(/\/$/, "");
2340
- const pkg = await buildContext(graph, nodePath);
2341
- const warningThreshold = graph.config.quality?.context_budget.warning ?? 5e3;
2342
- const errorThreshold = graph.config.quality?.context_budget.error ?? 1e4;
2343
- const budgetStatus = pkg.tokenCount >= errorThreshold ? "error" : pkg.tokenCount >= warningThreshold ? "warning" : "ok";
2344
- 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);
2345
2234
  output += `Budget status: ${budgetStatus}
2346
2235
  `;
2347
2236
  process.stdout.write(output);
2348
2237
  if (budgetStatus === "error") {
2349
2238
  process.stderr.write(
2350
- `Error: context package exceeds error budget (${pkg.tokenCount} >= ${errorThreshold}).
2239
+ `Warning: context package exceeds error budget (${pkg2.tokenCount} >= ${errorThreshold}). Consider splitting the node.
2351
2240
  `
2352
2241
  );
2353
- process.exit(1);
2354
2242
  }
2355
2243
  } catch (error) {
2356
2244
  process.stderr.write(`Error: ${error.message}
@@ -2409,40 +2297,28 @@ import chalk2 from "chalk";
2409
2297
 
2410
2298
  // src/io/drift-state-store.ts
2411
2299
  import { readFile as readFile11, writeFile as writeFile3 } from "fs/promises";
2412
- import { parse as parseYaml7, stringify as stringifyYaml } from "yaml";
2413
2300
  import path10 from "path";
2301
+ import { stringify, parse } from "yaml";
2414
2302
  var DRIFT_STATE_FILE = ".drift-state";
2415
- function getCanonicalHash(entry) {
2416
- return typeof entry === "string" ? entry : entry.hash;
2417
- }
2418
- function getFileHashes(entry) {
2419
- return typeof entry === "object" ? entry.files : void 0;
2420
- }
2421
2303
  async function readDriftState(yggRoot) {
2422
- const filePath = path10.join(yggRoot, DRIFT_STATE_FILE);
2423
2304
  try {
2424
- const content = await readFile11(filePath, "utf-8");
2425
- const raw = parseYaml7(content);
2426
- if (raw && typeof raw === "object" && !Array.isArray(raw)) {
2427
- const result = {};
2428
- for (const [k, v] of Object.entries(raw)) {
2429
- if (typeof k === "string" && typeof v === "string") {
2430
- result[k] = v;
2431
- } else if (typeof k === "string" && typeof v === "object" && v !== null && "hash" in v) {
2432
- result[k] = v;
2433
- }
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;
2434
2312
  }
2435
- return result;
2436
2313
  }
2437
- return {};
2314
+ return state;
2438
2315
  } catch {
2439
2316
  return {};
2440
2317
  }
2441
2318
  }
2442
2319
  async function writeDriftState(yggRoot, state) {
2443
- const filePath = path10.join(yggRoot, DRIFT_STATE_FILE);
2444
- const content = stringifyYaml(state);
2445
- 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");
2446
2322
  }
2447
2323
 
2448
2324
  // src/utils/hash.ts
@@ -2456,46 +2332,29 @@ async function hashFile(filePath) {
2456
2332
  const content = await readFile12(filePath);
2457
2333
  return createHash("sha256").update(content).digest("hex");
2458
2334
  }
2459
- async function hashPath(targetPath, options = {}) {
2460
- const projectRoot = options.projectRoot ? path11.resolve(options.projectRoot) : void 0;
2461
- const gitignoreMatcher = await loadGitignoreMatcher(projectRoot);
2462
- const targetStat = await stat3(targetPath);
2463
- if (targetStat.isFile()) {
2464
- if (isIgnoredPath(targetPath, projectRoot, gitignoreMatcher)) {
2465
- return hashString("");
2466
- }
2467
- return hashFile(targetPath);
2468
- }
2469
- if (targetStat.isDirectory()) {
2470
- const fileHashes = await collectDirectoryFileHashes(targetPath, targetPath, {
2471
- projectRoot,
2472
- gitignoreMatcher
2473
- });
2474
- const digestInput = fileHashes.sort((a, b) => a.path.localeCompare(b.path)).map((entry) => `${entry.path}:${entry.hash}`).join("\n");
2475
- return hashString(digestInput);
2476
- }
2477
- throw new Error(`Unsupported mapping path type: ${targetPath}`);
2478
- }
2479
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
+ }
2480
2344
  const entries = await readdir5(directoryPath, { withFileTypes: true });
2481
2345
  const result = [];
2482
2346
  for (const entry of entries) {
2483
2347
  const absoluteChildPath = path11.join(directoryPath, entry.name);
2484
- if (isIgnoredPath(absoluteChildPath, options.projectRoot, options.gitignoreMatcher)) {
2348
+ if (isIgnoredByStack(absoluteChildPath, stack)) {
2485
2349
  continue;
2486
2350
  }
2487
2351
  if (entry.isDirectory()) {
2488
2352
  const nested = await collectDirectoryFileHashes(
2489
2353
  absoluteChildPath,
2490
2354
  rootDirectoryPath,
2491
- options
2355
+ { projectRoot: options.projectRoot, gitignoreStack: stack }
2492
2356
  );
2493
- for (const nestedEntry of nested) {
2494
- result.push({
2495
- path: path11.relative(rootDirectoryPath, path11.join(absoluteChildPath, nestedEntry.path)),
2496
- hash: nestedEntry.hash
2497
- });
2498
- }
2357
+ result.push(...nested);
2499
2358
  continue;
2500
2359
  }
2501
2360
  if (!entry.isFile()) {
@@ -2508,83 +2367,152 @@ async function collectDirectoryFileHashes(directoryPath, rootDirectoryPath, opti
2508
2367
  }
2509
2368
  return result;
2510
2369
  }
2511
- async function loadGitignoreMatcher(projectRoot) {
2512
- if (!projectRoot) {
2513
- return void 0;
2514
- }
2370
+ async function loadRootGitignoreStack(projectRoot) {
2371
+ if (!projectRoot) return [];
2515
2372
  try {
2516
- const gitignorePath = path11.join(projectRoot, ".gitignore");
2517
- const gitignoreContent = await readFile12(gitignorePath, "utf-8");
2373
+ const content = await readFile12(path11.join(projectRoot, ".gitignore"), "utf-8");
2518
2374
  const matcher = ignoreFactory();
2519
- matcher.add(gitignoreContent);
2520
- return matcher;
2375
+ matcher.add(content);
2376
+ return [{ basePath: projectRoot, matcher }];
2521
2377
  } catch {
2522
- return void 0;
2378
+ return [];
2523
2379
  }
2524
2380
  }
2525
- function isIgnoredPath(candidatePath, projectRoot, matcher) {
2526
- if (!projectRoot || !matcher) {
2527
- return false;
2528
- }
2529
- const relativePath = path11.relative(projectRoot, candidatePath);
2530
- if (relativePath === "" || relativePath.startsWith("..")) {
2531
- 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;
2532
2386
  }
2533
- return matcher.ignores(relativePath) || matcher.ignores(relativePath + "/");
2387
+ return false;
2534
2388
  }
2535
2389
  function hashString(content) {
2536
2390
  return createHash("sha256").update(content).digest("hex");
2537
2391
  }
2538
- async function perFileHashes(projectRoot, mapping) {
2539
- const root = path11.resolve(projectRoot);
2540
- const paths = mapping.paths ?? [];
2541
- if (paths.length === 0) return [];
2542
- const result = [];
2543
- const gitignoreMatcher = await loadGitignoreMatcher(root);
2544
- for (const p of paths) {
2545
- const absPath = path11.join(root, p);
2546
- const st = await stat3(absPath);
2547
- if (st.isFile()) {
2548
- result.push({ path: p, hash: await hashFile(absPath) });
2549
- } else if (st.isDirectory()) {
2550
- const hashes = await collectDirectoryFileHashes(absPath, absPath, {
2551
- projectRoot: root,
2552
- gitignoreMatcher
2553
- });
2554
- for (const h of hashes) {
2555
- result.push({
2556
- path: path11.join(p, h.path).split(path11.sep).join("/"),
2557
- 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
2558
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);
2559
2410
  }
2411
+ } catch {
2412
+ continue;
2560
2413
  }
2561
2414
  }
2562
- 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 };
2563
2419
  }
2564
- async function hashForMapping(projectRoot, mapping) {
2565
- const root = path11.resolve(projectRoot);
2566
- const paths = mapping.paths ?? [];
2567
- if (paths.length === 0) throw new Error("Invalid mapping for hash: no paths");
2568
- const pairs = [];
2569
- for (const p of paths) {
2570
- const absPath = path11.join(root, p);
2571
- const st = await stat3(absPath);
2572
- if (st.isFile()) {
2573
- pairs.push({ path: p, hash: await hashFile(absPath) });
2574
- } else if (st.isDirectory()) {
2575
- const dirHash = await hashPath(absPath, { projectRoot: root });
2576
- 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");
2577
2498
  }
2578
2499
  }
2579
- const digestInput = pairs.sort((a, b) => a.path.localeCompare(b.path)).map((e) => `${e.path}:${e.hash}`).join("\n");
2580
- 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)));
2581
2509
  }
2582
2510
 
2583
2511
  // src/core/drift-detector.ts
2584
2512
  import { access } from "fs/promises";
2585
- import path12 from "path";
2513
+ import path13 from "path";
2586
2514
  async function detectDrift(graph, filterNodePath) {
2587
- const projectRoot = path12.dirname(graph.rootPath);
2515
+ const projectRoot = path13.dirname(graph.rootPath);
2588
2516
  const driftState = await readDriftState(graph.rootPath);
2589
2517
  const entries = [];
2590
2518
  for (const [nodePath, node] of graph.nodes) {
@@ -2598,67 +2526,80 @@ async function detectDrift(graph, filterNodePath) {
2598
2526
  const allMissing = await allPathsMissing(projectRoot, mappingPaths);
2599
2527
  entries.push({
2600
2528
  nodePath,
2601
- mappingPaths,
2602
- status: allMissing ? "unmaterialized" : "drift",
2529
+ status: allMissing ? "unmaterialized" : "source-drift",
2603
2530
  details: allMissing ? "No drift state recorded, files do not exist" : "No drift state recorded, files exist (run drift-sync after materialization)"
2604
2531
  });
2605
2532
  continue;
2606
2533
  }
2607
- const storedHash = getCanonicalHash(storedEntry);
2608
- let status = "ok";
2609
- let details = "";
2610
- try {
2611
- const currentHash = await hashForMapping(projectRoot, mapping);
2612
- if (currentHash !== storedHash) {
2613
- status = "drift";
2614
- const changedFiles = await diagnoseChangedFiles(
2615
- projectRoot,
2616
- mapping,
2617
- getFileHashes(storedEntry)
2618
- );
2619
- 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
+ });
2620
2558
  }
2621
- } catch {
2622
- status = "missing";
2623
- details = "Mapped path(s) do not exist";
2624
2559
  }
2625
- 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 });
2626
2582
  }
2627
2583
  return {
2628
2584
  entries,
2629
2585
  totalChecked: entries.length,
2630
2586
  okCount: entries.filter((e) => e.status === "ok").length,
2631
- 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,
2632
2590
  missingCount: entries.filter((e) => e.status === "missing").length,
2633
2591
  unmaterializedCount: entries.filter((e) => e.status === "unmaterialized").length
2634
2592
  };
2635
2593
  }
2636
- async function diagnoseChangedFiles(projectRoot, mapping, storedFileHashes) {
2637
- try {
2638
- const currentHashes = await perFileHashes(projectRoot, mapping);
2639
- if (!storedFileHashes) {
2640
- return currentHashes.map((h) => h.path).sort();
2641
- }
2642
- const changed = [];
2643
- const storedPaths = new Set(Object.keys(storedFileHashes));
2644
- for (const { path: filePath, hash } of currentHashes) {
2645
- const stored = storedFileHashes[filePath];
2646
- if (!stored || stored !== hash) {
2647
- changed.push(filePath);
2648
- }
2649
- storedPaths.delete(filePath);
2650
- }
2651
- for (const removed of storedPaths) {
2652
- changed.push(`${removed} (deleted)`);
2653
- }
2654
- return changed.sort();
2655
- } catch {
2656
- return [];
2657
- }
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";
2658
2599
  }
2659
2600
  async function allPathsMissing(projectRoot, mappingPaths) {
2660
2601
  for (const mp of mappingPaths) {
2661
- const absPath = path12.join(projectRoot, mp);
2602
+ const absPath = path13.join(projectRoot, mp);
2662
2603
  try {
2663
2604
  await access(absPath);
2664
2605
  return false;
@@ -2668,82 +2609,43 @@ async function allPathsMissing(projectRoot, mappingPaths) {
2668
2609
  return true;
2669
2610
  }
2670
2611
  async function syncDriftState(graph, nodePath) {
2671
- const projectRoot = path12.dirname(graph.rootPath);
2612
+ const projectRoot = path13.dirname(graph.rootPath);
2672
2613
  const node = graph.nodes.get(nodePath);
2673
2614
  if (!node) throw new Error(`Node not found: ${nodePath}`);
2674
- const mapping = node.meta.mapping;
2675
- if (!mapping) throw new Error(`Node has no mapping: ${nodePath}`);
2676
- const currentHash = await hashForMapping(projectRoot, mapping);
2677
- const driftState = await readDriftState(graph.rootPath);
2678
- const previousEntry = driftState[nodePath];
2679
- const previousHash = previousEntry ? getCanonicalHash(previousEntry) : void 0;
2680
- const fileHashes = await perFileHashes(projectRoot, mapping);
2681
- const files = {};
2682
- for (const fh of fileHashes) {
2683
- files[fh.path] = fh.hash;
2684
- }
2685
- const newEntry = { hash: currentHash, files };
2686
- driftState[nodePath] = newEntry;
2687
- await writeDriftState(graph.rootPath, driftState);
2688
- 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 };
2689
2623
  }
2690
2624
 
2691
2625
  // src/cli/drift.ts
2692
2626
  function registerDriftCommand(program2) {
2693
- 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) => {
2694
2628
  try {
2695
2629
  const graph = await loadGraph(process.cwd());
2696
- const scope = (options.scope ?? "all").trim() || "all";
2697
- if (scope && scope !== "all" && !graph.nodes.has(scope)) {
2698
- 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}
2699
2635
  `);
2700
- process.exit(1);
2701
- }
2702
- if (scope && scope !== "all") {
2703
- const scopedNode = graph.nodes.get(scope);
2704
- if (!scopedNode.meta.mapping) {
2705
- process.stderr.write(
2706
- `Error: Node has no mapping (does not participate in drift detection): ${options.scope}
2707
- `
2708
- );
2709
2636
  process.exit(1);
2710
2637
  }
2711
- }
2712
- const scopeNode = scope === "all" ? void 0 : scope;
2713
- const report = await detectDrift(graph, scopeNode);
2714
- process.stdout.write("Drift:\n");
2715
- for (const entry of report.entries) {
2716
- const paths = entry.mappingPaths.join(", ");
2717
- switch (entry.status) {
2718
- case "ok":
2719
- process.stdout.write(chalk2.green(` ok ${entry.nodePath} -> ${paths}
2720
- `));
2721
- break;
2722
- case "drift":
2723
- process.stdout.write(chalk2.red(` drift ${entry.nodePath} -> ${paths}
2724
- `));
2725
- if (entry.details) process.stdout.write(` ${entry.details}
2638
+ if (!node.meta.mapping) {
2639
+ process.stderr.write(`Error: Node has no mapping: ${scope}
2726
2640
  `);
2727
- break;
2728
- case "missing":
2729
- process.stdout.write(chalk2.yellow(` missing ${entry.nodePath} -> ${paths}
2730
- `));
2731
- break;
2732
- case "unmaterialized":
2733
- process.stdout.write(chalk2.dim(` unmat. ${entry.nodePath} -> ${paths}
2734
- `));
2735
- break;
2641
+ process.exit(1);
2736
2642
  }
2737
2643
  }
2738
- process.stdout.write(
2739
- `
2740
- Summary: ${report.driftCount} drift, ${report.missingCount} missing, ${report.unmaterializedCount} unmaterialized, ${report.okCount} ok
2741
- `
2742
- );
2743
- if (report.driftCount > 0 || report.missingCount > 0 || report.unmaterializedCount > 0) {
2744
- process.exit(1);
2745
- }
2746
- 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);
2747
2649
  } catch (error) {
2748
2650
  process.stderr.write(`Error: ${error.message}
2749
2651
  `);
@@ -2751,6 +2653,94 @@ Summary: ${report.driftCount} drift, ${report.missingCount} missing, ${report.un
2751
2653
  }
2752
2654
  });
2753
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
+ }
2754
2744
 
2755
2745
  // src/cli/drift-sync.ts
2756
2746
  import chalk3 from "chalk";
@@ -2788,20 +2778,40 @@ function registerStatusCommand(program2) {
2788
2778
  let structuralRelations = 0;
2789
2779
  let eventRelations = 0;
2790
2780
  const structuralTypes = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements"]);
2781
+ let maxRelCount = 0;
2782
+ let maxRelNode = "";
2791
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
+ }
2792
2789
  for (const rel of node.meta.relations ?? []) {
2793
2790
  if (structuralTypes.has(rel.type)) structuralRelations += 1;
2794
2791
  else eventRelations += 1;
2795
2792
  }
2796
2793
  }
2797
2794
  const flowCount = graph.flows.length;
2798
- const knowledgeCount = graph.knowledge.length;
2799
2795
  const drift = await detectDrift(graph);
2800
2796
  const validation = await validate(graph, "all");
2801
2797
  const errorCount = validation.issues.filter((issue) => issue.severity === "error").length;
2802
2798
  const warningCount = validation.issues.filter(
2803
2799
  (issue) => issue.severity === "warning"
2804
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
+ }
2805
2815
  process.stdout.write(`Graph: ${graph.config.name}
2806
2816
  `);
2807
2817
  const pluralize = (word, count) => count === 1 ? word : word.endsWith("y") ? word.slice(0, -1) + "ies" : word + "s";
@@ -2815,15 +2825,37 @@ function registerStatusCommand(program2) {
2815
2825
  `
2816
2826
  );
2817
2827
  process.stdout.write(
2818
- `Aspects: ${graph.aspects.length} Flows: ${flowCount} Knowledge: ${knowledgeCount}
2828
+ `Aspects: ${graph.aspects.length} Flows: ${flowCount}
2819
2829
  `
2820
2830
  );
2821
2831
  process.stdout.write(
2822
- `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
2823
2833
  `
2824
2834
  );
2825
2835
  process.stdout.write(`Validation: ${errorCount} errors, ${warningCount} warnings
2826
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
+ );
2827
2859
  } catch (error) {
2828
2860
  process.stderr.write(`Error: ${error.message}
2829
2861
  `);
@@ -2840,10 +2872,10 @@ function registerTreeCommand(program2) {
2840
2872
  let roots;
2841
2873
  let showProjectName;
2842
2874
  if (options.root?.trim()) {
2843
- const path16 = options.root.trim().replace(/\/$/, "");
2844
- const node = graph.nodes.get(path16);
2875
+ const path17 = options.root.trim().replace(/\/$/, "");
2876
+ const node = graph.nodes.get(path17);
2845
2877
  if (!node) {
2846
- process.stderr.write(`Error: path '${path16}' not found
2878
+ process.stderr.write(`Error: path '${path17}' not found
2847
2879
  `);
2848
2880
  process.exit(1);
2849
2881
  }
@@ -2871,7 +2903,7 @@ function printNode(node, prefix, isLast, depth, maxDepth) {
2871
2903
  const connector = isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
2872
2904
  const name = node.path.split("/").pop() ?? node.path;
2873
2905
  const type = `[${node.meta.type}]`;
2874
- const tags = node.meta.tags?.length ? ` tags:${node.meta.tags.join(",")}` : "";
2906
+ const tags = node.meta.aspects?.length ? ` aspects:${node.meta.aspects.join(",")}` : "";
2875
2907
  const blackbox = node.meta.blackbox ? " \u25A0 blackbox" : "";
2876
2908
  const relationCount = node.meta.relations?.length ?? 0;
2877
2909
  process.stdout.write(
@@ -2930,13 +2962,13 @@ function registerOwnerCommand(program2) {
2930
2962
  }
2931
2963
 
2932
2964
  // src/core/dependency-resolver.ts
2933
- import { execSync as execSync2 } from "child_process";
2934
- import path13 from "path";
2935
- 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"]);
2936
2968
  var EVENT_RELATION_TYPES2 = /* @__PURE__ */ new Set(["emits", "listens"]);
2937
2969
  function filterRelationType(relType, filter) {
2938
2970
  if (filter === "all") return true;
2939
- if (filter === "structural") return STRUCTURAL_RELATION_TYPES2.has(relType);
2971
+ if (filter === "structural") return STRUCTURAL_RELATION_TYPES3.has(relType);
2940
2972
  if (filter === "event") return EVENT_RELATION_TYPES2.has(relType);
2941
2973
  return false;
2942
2974
  }
@@ -3008,24 +3040,24 @@ function registerDepsCommand(program2) {
3008
3040
  // src/core/graph-from-git.ts
3009
3041
  import { mkdtemp, rm } from "fs/promises";
3010
3042
  import { tmpdir } from "os";
3011
- import path14 from "path";
3012
- import { execSync as execSync3 } from "child_process";
3043
+ import path15 from "path";
3044
+ import { execSync as execSync2 } from "child_process";
3013
3045
  async function loadGraphFromRef(projectRoot, ref = "HEAD") {
3014
3046
  const yggPath = ".yggdrasil";
3015
3047
  let tmpDir = null;
3016
3048
  try {
3017
- execSync3(`git rev-parse ${ref}`, { cwd: projectRoot, stdio: "pipe" });
3049
+ execSync2(`git rev-parse ${ref}`, { cwd: projectRoot, stdio: "pipe" });
3018
3050
  } catch {
3019
3051
  return null;
3020
3052
  }
3021
3053
  try {
3022
- tmpDir = await mkdtemp(path14.join(tmpdir(), "ygg-git-"));
3023
- const archivePath = path14.join(tmpDir, "archive.tar");
3024
- 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}"`, {
3025
3057
  cwd: projectRoot,
3026
3058
  stdio: "pipe"
3027
3059
  });
3028
- execSync3(`tar -xf "${archivePath}" -C "${tmpDir}"`, { stdio: "pipe" });
3060
+ execSync2(`tar -xf "${archivePath}" -C "${tmpDir}"`, { stdio: "pipe" });
3029
3061
  const graph = await loadGraph(tmpDir);
3030
3062
  return graph;
3031
3063
  } catch {
@@ -3067,14 +3099,14 @@ function collectReverseDependents(graph, targetNode) {
3067
3099
  }
3068
3100
  return {
3069
3101
  direct,
3070
- transitive: [...seen].sort(),
3102
+ allDependents: [...seen].sort(),
3071
3103
  reverse,
3072
3104
  relationFrom
3073
3105
  };
3074
3106
  }
3075
- function buildTransitiveChains(targetNode, direct, transitive, reverse) {
3107
+ function buildTransitiveChains(targetNode, direct, allDependents, reverse) {
3076
3108
  const directSet = new Set(direct);
3077
- const transitiveOnly = transitive.filter((t) => !directSet.has(t));
3109
+ const transitiveOnly = allDependents.filter((t) => !directSet.has(t));
3078
3110
  if (transitiveOnly.length === 0) return [];
3079
3111
  const parent = /* @__PURE__ */ new Map();
3080
3112
  const queue = [targetNode];
@@ -3090,148 +3122,342 @@ function buildTransitiveChains(targetNode, direct, transitive, reverse) {
3090
3122
  }
3091
3123
  const chains = [];
3092
3124
  for (const node of transitiveOnly) {
3093
- const path16 = [];
3125
+ const path17 = [];
3094
3126
  let current = node;
3095
3127
  while (current) {
3096
- path16.unshift(current);
3128
+ path17.unshift(current);
3097
3129
  current = parent.get(current);
3098
3130
  }
3099
- if (path16.length >= 2) {
3100
- chains.push(path16.map((p) => `<- ${p}`).join(" "));
3131
+ if (path17.length >= 3) {
3132
+ chains.push(path17.slice(1).map((p) => `<- ${p}`).join(" "));
3101
3133
  }
3102
3134
  }
3103
3135
  return chains.sort();
3104
3136
  }
3105
- function registerImpactCommand(program2) {
3106
- 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) {
3107
3156
  try {
3108
- const graph = await loadGraph(process.cwd());
3109
- const nodePath = options.node.trim().replace(/\/$/, "");
3110
- if (!graph.nodes.has(nodePath)) {
3111
- process.stderr.write(`Node not found: ${nodePath}
3112
- `);
3113
- 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
+ }
3114
3166
  }
3115
- const { direct, transitive, reverse, relationFrom } = collectReverseDependents(
3116
- graph,
3117
- nodePath
3167
+ const hasDepOnTarget = targetNodePath && graph.nodes.get(dep)?.meta.relations?.some(
3168
+ (r) => r.target === targetNodePath && STRUCTURAL_TYPES.has(r.type)
3118
3169
  );
3119
- const chains = buildTransitiveChains(nodePath, direct, transitive, reverse);
3120
- const flows = [];
3121
- for (const flow of graph.flows) {
3122
- if (flow.nodes.includes(nodePath)) {
3123
- 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;
3124
3214
  }
3125
- }
3126
- const aspectsInScope = [];
3127
- const targetNode = graph.nodes.get(nodePath);
3128
- const targetTags = new Set(targetNode.meta.tags ?? []);
3129
- for (const aspect of graph.aspects) {
3130
- if (targetTags.has(aspect.tag)) {
3131
- 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" });
3132
3223
  }
3133
3224
  }
3134
- const knowledgeInScope = [];
3135
- for (const k of graph.knowledge) {
3136
- if (k.scope === "global") {
3137
- knowledgeInScope.push(k.path);
3138
- 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);
3139
3339
  }
3140
- if (typeof k.scope === "object" && "tags" in k.scope) {
3141
- if (k.scope.tags.some((t) => targetTags.has(t))) {
3142
- 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);
3143
3349
  }
3144
- continue;
3145
3350
  }
3146
- if (typeof k.scope === "object" && "nodes" in k.scope) {
3147
- if (k.scope.nodes.includes(nodePath)) {
3148
- 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);
3149
3356
  }
3150
3357
  }
3151
- }
3152
- const budget = graph.config.quality?.context_budget ?? { warning: 1e4, error: 2e4 };
3153
- process.stdout.write(`Impact of changes in ${nodePath}:
3358
+ process.stdout.write(`Impact of changes in ${nodePath}:
3154
3359
 
3155
3360
  `);
3156
- process.stdout.write("Directly dependent:\n");
3157
- if (direct.length === 0) {
3158
- process.stdout.write(" (none)\n");
3159
- } else {
3160
- for (const dep of direct) {
3161
- const rel = relationFrom.get(`${dep}->${nodePath}`);
3162
- const annot = rel?.consumes?.length ? ` (${rel.type}, you consume: ${rel.consumes.join(", ")})` : rel ? ` (${rel.type})` : "";
3163
- 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}
3164
3369
  `);
3370
+ }
3165
3371
  }
3166
- }
3167
- process.stdout.write("\nTransitively dependent:\n");
3168
- if (chains.length === 0) {
3169
- process.stdout.write(" (none)\n");
3170
- } else {
3171
- for (const chain of chains) {
3172
- 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}
3173
3378
  `);
3379
+ }
3174
3380
  }
3175
- }
3176
- process.stdout.write(`
3177
- 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}
3178
3386
  `);
3179
- process.stdout.write(
3180
- `Aspects (scope covers node): ${aspectsInScope.length > 0 ? aspectsInScope.join(", ") : "(none)"}
3181
- `
3182
- );
3183
- process.stdout.write(
3184
- `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)"}
3185
3392
  `
3186
- );
3187
- process.stdout.write(
3188
- `
3189
- 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)"}
3190
3396
  `
3191
- );
3192
- if (options.simulate && transitive.length > 0) {
3193
- process.stdout.write("\nChanges in context packages:\n\n");
3194
- const baselineGraph = await loadGraphFromRef(process.cwd(), "HEAD");
3195
- const driftReport = await detectDrift(graph);
3196
- const driftByNode = new Map(driftReport.entries.map((e) => [e.nodePath, e]));
3197
- for (const dep of transitive) {
3198
- try {
3199
- const pkg = await buildContext(graph, dep);
3200
- const status = pkg.tokenCount >= budget.error ? "error" : pkg.tokenCount >= budget.warning ? "warning" : "ok";
3201
- let baselineTokens = null;
3202
- if (baselineGraph?.nodes.has(dep)) {
3203
- try {
3204
- const baselinePkg = await buildContext(baselineGraph, dep);
3205
- baselineTokens = baselinePkg.tokenCount;
3206
- } catch {
3207
- }
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 });
3208
3406
  }
3209
- const hasDepOnTarget = graph.nodes.get(dep)?.meta.relations?.some(
3210
- (r) => r.target === nodePath && STRUCTURAL_TYPES.has(r.type)
3211
- );
3212
- const changedLine = hasDepOnTarget ? ` + Changed dependency interface: ${nodePath}
3213
- ` : "";
3214
- const budgetLine = baselineTokens !== null ? ` Budget: ${baselineTokens} -> ${pkg.tokenCount} tokens (${status})
3215
- ` : ` Budget: ${pkg.tokenCount} tokens (${status})
3216
- `;
3217
- const driftEntry = driftByNode.get(dep);
3218
- const driftLine = driftEntry && driftEntry.status !== "ok" ? ` Mapped files (on-disk): ${driftEntry.status}${driftEntry.details ? ` (${driftEntry.details})` : ""}
3219
- ` : driftEntry ? ` Mapped files (on-disk): ok
3220
- ` : "";
3221
- process.stdout.write(`${dep}:
3222
- ${changedLine}${budgetLine}${driftLine}
3223
- `);
3224
- } catch {
3225
- process.stdout.write(`${dep}:
3226
- failed to build context
3227
-
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(", ")})
3228
3415
  `);
3229
3416
  }
3230
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);
3231
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));
3232
3450
  } catch (error) {
3233
- 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}
3234
3459
  `);
3460
+ }
3235
3461
  process.exit(1);
3236
3462
  }
3237
3463
  });
@@ -3239,15 +3465,15 @@ ${changedLine}${budgetLine}${driftLine}
3239
3465
 
3240
3466
  // src/io/journal-store.ts
3241
3467
  import { readFile as readFile13, writeFile as writeFile4, mkdir as mkdir3, rename, access as access2 } from "fs/promises";
3242
- import { parse as parseYaml8, stringify as stringifyYaml2 } from "yaml";
3243
- import path15 from "path";
3468
+ import { parse as parseYaml6, stringify as stringifyYaml } from "yaml";
3469
+ import path16 from "path";
3244
3470
  var JOURNAL_FILE = ".journal.yaml";
3245
3471
  var ARCHIVE_DIR = "journals-archive";
3246
3472
  async function readJournal(yggRoot) {
3247
- const filePath = path15.join(yggRoot, JOURNAL_FILE);
3473
+ const filePath = path16.join(yggRoot, JOURNAL_FILE);
3248
3474
  try {
3249
3475
  const content = await readFile13(filePath, "utf-8");
3250
- const raw = parseYaml8(content);
3476
+ const raw = parseYaml6(content);
3251
3477
  const entries = raw.entries ?? [];
3252
3478
  return Array.isArray(entries) ? entries : [];
3253
3479
  } catch {
@@ -3259,13 +3485,13 @@ async function appendJournalEntry(yggRoot, note, target) {
3259
3485
  const at = (/* @__PURE__ */ new Date()).toISOString();
3260
3486
  const entry = target ? { at, target, note } : { at, note };
3261
3487
  entries.push(entry);
3262
- const filePath = path15.join(yggRoot, JOURNAL_FILE);
3263
- const content = stringifyYaml2({ entries });
3488
+ const filePath = path16.join(yggRoot, JOURNAL_FILE);
3489
+ const content = stringifyYaml({ entries });
3264
3490
  await writeFile4(filePath, content, "utf-8");
3265
3491
  return entry;
3266
3492
  }
3267
3493
  async function archiveJournal(yggRoot) {
3268
- const journalPath = path15.join(yggRoot, JOURNAL_FILE);
3494
+ const journalPath = path16.join(yggRoot, JOURNAL_FILE);
3269
3495
  try {
3270
3496
  await access2(journalPath);
3271
3497
  } catch {
@@ -3273,12 +3499,12 @@ async function archiveJournal(yggRoot) {
3273
3499
  }
3274
3500
  const entries = await readJournal(yggRoot);
3275
3501
  if (entries.length === 0) return null;
3276
- const archiveDir = path15.join(yggRoot, ARCHIVE_DIR);
3502
+ const archiveDir = path16.join(yggRoot, ARCHIVE_DIR);
3277
3503
  await mkdir3(archiveDir, { recursive: true });
3278
3504
  const now = /* @__PURE__ */ new Date();
3279
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")}`;
3280
3506
  const archiveName = `.journal.${timestamp}.yaml`;
3281
- const archivePath = path15.join(archiveDir, archiveName);
3507
+ const archivePath = path16.join(archiveDir, archiveName);
3282
3508
  await rename(journalPath, archivePath);
3283
3509
  return { archiveName, entryCount: entries.length };
3284
3510
  }
@@ -3354,9 +3580,85 @@ function registerJournalArchiveCommand(program2) {
3354
3580
  });
3355
3581
  }
3356
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
+
3357
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"));
3358
3660
  var program = new Command();
3359
- 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);
3360
3662
  registerInitCommand(program);
3361
3663
  registerBuildCommand(program);
3362
3664
  registerValidateCommand(program);
@@ -3367,8 +3669,10 @@ registerTreeCommand(program);
3367
3669
  registerOwnerCommand(program);
3368
3670
  registerDepsCommand(program);
3369
3671
  registerImpactCommand(program);
3672
+ registerAspectsCommand(program);
3370
3673
  registerJournalAddCommand(program);
3371
3674
  registerJournalReadCommand(program);
3372
3675
  registerJournalArchiveCommand(program);
3676
+ registerPreflightCommand(program);
3373
3677
  program.parse();
3374
3678
  //# sourceMappingURL=bin.js.map