@chrisdudek/yg 1.2.0 → 1.4.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
@@ -21,6 +21,7 @@ node_types:
21
21
  - name: module
22
22
  - name: service
23
23
  - name: library
24
+ - name: infrastructure
24
25
 
25
26
  artifacts:
26
27
  responsibility.md:
@@ -30,29 +31,11 @@ artifacts:
30
31
  interface.md:
31
32
  required:
32
33
  when: has_incoming_relations
33
- description: "Public API \u2014 methods, parameters, return types, contracts"
34
+ description: "Public API \u2014 methods, parameters, return types, contracts, failure modes, exposed data structures"
34
35
  structural_context: true
35
- logic.md:
36
+ internals.md:
36
37
  required: never
37
- description: "Algorithmic flow, control flow, branching logic, decision trees \u2014 the 'how' of execution"
38
- constraints.md:
39
- required: never
40
- description: "Validation rules, business rules, invariants"
41
- structural_context: true
42
- errors.md:
43
- required:
44
- when: has_incoming_relations
45
- description: "Failure modes, edge cases, error conditions, recovery behavior"
46
- structural_context: true
47
- model.md:
48
- required: never
49
- description: "Data structures, schemas, entities, type definitions \u2014 the shape of data this node owns or manages"
50
- state.md:
51
- required: never
52
- description: "State machines, lifecycle, transitions"
53
- decisions.md:
54
- required: never
55
- description: "Local design decisions and rationale \u2014 choices specific to this node, not system-wide"
38
+ description: "How the node works and why \u2014 algorithms, business rules, state machines, design decisions with rejected alternatives"
56
39
 
57
40
  quality:
58
41
  min_artifact_length: 50
@@ -77,11 +60,12 @@ Yggdrasil is persistent semantic memory stored in \`.yggdrasil/\`. It maps the r
77
60
  BEFORE reading, researching, planning, OR modifying ANY mapped file:
78
61
  1. yg owner --file <path>
79
62
  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.
63
+ The context package is your primary source of ARCHITECTURAL understanding:
64
+ intent, constraints, relations, rationale. For IMPLEMENTATION precision
65
+ (exact behavior, error handling, await patterns, edge cases) \u2014 verify
66
+ against source code. Aspects describe intended patterns; individual
67
+ implementations may deviate.
83
68
  If the context package seems insufficient \u2014 enrich the graph.
84
- Do not bypass it.
85
69
 
86
70
  AFTER modifying:
87
71
  3. Update graph artifacts to reflect changes
@@ -102,31 +86,42 @@ WHEN UNSURE: ask the user. Never guess. Never assume.
102
86
 
103
87
  ### Five Core Rules
104
88
 
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.
89
+ 1. **Graph first.** Before reading, researching, planning, or modifying mapped files, run \`yg owner\` and \`yg build-context\`. Always. The context package is your primary source of architectural understanding. For implementation-level precision (exact behavior, error paths, edge cases) \u2014 verify against source code after loading the context package.
106
90
  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
91
  3. **Never invent why.** The graph captures human intent. If you don't know why something was decided, ask. Never hallucinate rationale.
108
92
  4. **Always capture why \u2014 especially why NOT.** When the user explains a reason, record it in the graph immediately. When a design choice is made, also record rejected alternatives: "Chose X over Y because Z." Rejected alternatives are the highest-value information \u2014 invisible in code and irrecoverable once forgotten. Conversation evaporates; graph persists.
109
93
  5. **Ask before resolving ambiguity.** When multiple valid interpretations exist, stop, list options, ask the user. Never silently choose.
110
94
 
95
+ ### Recognizing Graph-Required Actions
96
+
97
+ What matters is the ACTION you are performing, not what instructed it. If the action involves understanding mapped code, the graph protocol applies \u2014 whether the instruction came from a skill, a plan, a user message, a workflow step, or your own initiative.
98
+
99
+ **Actions that require \`yg owner\` + \`yg build-context\` first:**
100
+
101
+ - Reading or exploring source files to understand a component
102
+ - Proposing approaches, designs, or plans for changing code
103
+ - Reviewing or debugging code
104
+ - Any form of reasoning about how mapped code works or should change
105
+
106
+ **Actions that do NOT require yg:**
107
+
108
+ - Git operations (log, diff, status, blame)
109
+ - Reading documentation, READMEs, or config files outside \`.yggdrasil/\`
110
+ - Running tests, builds, or linters
111
+ - Working with files that \`yg owner\` reports as unmapped
112
+
111
113
  ### Failure States
112
114
 
113
115
  You have broken Yggdrasil if you do any of the following:
114
116
 
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.
117
+ - \u274C Worked on a mapped file without running \`yg owner\` + \`yg build-context\` first \u2014 regardless of what instructed the action (skill, plan, user request, workflow step).
118
+ - \u274C Modified source code without updating graph artifacts in the same response, or vice versa.
119
+ - \u274C Resolved a code-graph inconsistency or ambiguity without asking the user first.
119
120
  - \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.
121
+ - \u274C Ran \`yg drift-sync\` before both graph artifacts and source code are current.
122
+ - \u274C Placed a cross-cutting requirement in a local artifact instead of an aspect, or used an aspect id with no \`aspects/\` directory.
123
+ - \u274C Invented a rationale, business rule, or decision \u2014 or recorded a decision without documenting rejected alternatives and rationale (use "rationale: unknown" if unknown).
125
124
  - \u274C Used blackbox coverage for greenfield (new) code.
126
- - \u274C Answered a question about a mapped file without running \`yg build-context\` first.
127
- - \u274C Read mapped source files to plan or research changes without running \`yg build-context\` first.
128
- - \u274C Deferred \`yg drift-sync\` to the end of a multi-step task instead of running it incrementally after each logical group of changes.
129
- - \u274C Recorded a design decision without documenting which alternatives were rejected and why.
130
125
 
131
126
  ### Escape Hatch
132
127
 
@@ -201,7 +196,7 @@ You are not allowed to edit or create source code without establishing graph cov
201
196
 
202
197
  1. Create aspects first (cross-cutting requirements the new code must satisfy)
203
198
  2. Create flows if the code participates in a business process
204
- 3. Create nodes with full artifacts \u2014 responsibility, constraints, decisions, interface, logic
199
+ 3. Create nodes with full artifacts \u2014 responsibility, interface, internals
205
200
  4. Review the context package (\`yg build-context\`) \u2014 it is now the behavioral specification
206
201
  5. Implement code that satisfies the specification
207
202
  6. The graph specifies WHAT and WHY; the code implements HOW (framework APIs, library choices)
@@ -229,6 +224,7 @@ Per area checklist:
229
224
  - [ ] 4. Analyze source \u2014 for each artifact type in \`config.artifacts\`: extract content, do not invent
230
225
  - [ ] 5. Identify relations \u2014 add to \`node.yaml\`
231
226
  - [ ] 6. Identify cross-cutting requirements \u2014 add matching aspects, create if needed
227
+ - [ ] 6b. For each aspect on the node: identify 2-5 code anchors (function names, constants) that evidence the pattern \u2192 add to \`node.yaml\` \`anchors\` field
232
228
  - [ ] 7. Identify business process participation \u2014 add to flow, ask user if process unclear
233
229
  - [ ] 8. \`yg validate\` \u2014 fix errors
234
230
  - [ ] 9. \`yg drift-sync --node <path>\`
@@ -238,7 +234,8 @@ Per area checklist:
238
234
  - Business process unclear: "This code appears to be part of a larger process. Can you describe what it means from a business perspective?"
239
235
  - Constraint without rationale: "I see [constraint X]. Do you know why this exists? I want to record the reason, not just the rule."
240
236
  - Unexplained architectural choice: "I see [approach X]. What was the reason for this choice?"
241
- - Decision without alternatives: "You chose [X]. What alternatives did you consider, and why did you reject them?" Record the answer in \`decisions.md\`.
237
+ - Decision without alternatives: "You chose [X]. What alternatives did you consider, and why did you reject them?" Record the answer in the Decisions section of \`internals.md\`.
238
+ - Decision without known rationale: Record the decision in \`internals.md\` with "rationale: unknown \u2014 inferred from code, not confirmed by developer." A recorded decision with unknown rationale is infinitely more valuable than no record at all, and safer than an invented rationale.
242
239
 
243
240
  ### Bootstrap Mode
244
241
 
@@ -266,6 +263,27 @@ Always ask the user before resolving drift. Never auto-resolve.
266
263
 
267
264
  Threshold: >10 drifted nodes \u2192 ask user which area to prioritize. Do not resolve all at once.
268
265
 
266
+ **Drift triage:** Prioritize aspects and \`internals.md\` (highest decay rate), then \`responsibility.md\` and \`interface.md\` (most stable).
267
+
268
+ ### Graph Audit
269
+
270
+ When reviewing graph quality (triggered by user or quality improvement):
271
+
272
+ **Step 1 \u2014 Consistency** (catches WRONG information):
273
+
274
+ - [ ] 1. \`yg build-context --node <path>\`
275
+ - [ ] 2. Read mapped source files
276
+ - [ ] 3. For each claim in graph: verify against source code
277
+ - [ ] 4. For each aspect: verify the pattern holds in THIS node. If it deviates, add \`aspect_exceptions\` in \`node.yaml\`
278
+ - [ ] 5. Report inconsistencies
279
+
280
+ **Step 2 \u2014 Completeness** (catches MISSING information):
281
+
282
+ - [ ] 1. For each public method: is it in \`interface.md\`?
283
+ - [ ] 2. For each error path: is it in \`interface.md\` (Failure Modes section)?
284
+ - [ ] 3. For each behavioral invariant: is it in the graph?
285
+ - [ ] 4. Report omissions separately from inconsistencies
286
+
269
287
  ### Error Recovery
270
288
 
271
289
  - **\`yg\` not found** \u2192 inform user: "yg CLI is not installed or not in PATH." Stop.
@@ -294,9 +312,26 @@ Key facts:
294
312
  - **Aspect id = directory path** under \`aspects/\`. Each aspect has \`aspect.yaml\` + content \`.md\` files. No automatic parent-child \u2014 use \`implies\` explicitly.
295
313
  - **Flows = business processes.** A flow describes what happens in the world, not code sequences. Flow aspects propagate to all participants.
296
314
 
315
+ **Node type guidance:**
316
+
317
+ - \`module\` \u2014 business logic unit with clear domain responsibility
318
+ - \`service\` \u2014 component providing functionality to other nodes
319
+ - \`library\` \u2014 shared utility code with no domain knowledge
320
+ - \`infrastructure\` \u2014 guards, resolvers, middleware, interceptors, validators that intercept or modify request flow. These affect blast radius of changes but are invisible in call graphs. Map them to make blast radius analysis accurate. Key signal: code that runs WITHOUT being explicitly called by business logic (e.g., NestJS guards, Express middleware, GraphQL resolvers).
321
+
322
+ ### Artifact Structure
323
+
324
+ Three artifacts capture node knowledge at three levels:
325
+
326
+ - **responsibility.md** (always required) \u2014 WHAT: identity, boundaries, what the node is NOT responsible for.
327
+ - **interface.md** (required when node has consumers) \u2014 HOW TO USE: public methods, parameters, return types, contracts, failure modes, exposed data structures. Everything another node needs to interact with this one.
328
+ - **internals.md** (optional, highest value for cross-module nodes) \u2014 HOW IT WORKS + WHY: algorithms, control flow, business rules, invariants, state machines, lifecycle, and design decisions with rejected alternatives. Use sections within the file: ## Logic, ## Constraints, ## State, ## Decisions (with "Chose X over Y because Z" format).
329
+
330
+ **Enrichment priority (when adding incrementally):** \`interface.md\` first (highest cross-module ROI \u2014 contracts enable other nodes to reason about interactions), then \`responsibility.md\` (identity and boundaries), then \`internals.md\` (depth for complex nodes). A node with only \`interface.md\` provides more cross-module value than one with only \`internals.md\`.
331
+
297
332
  ### Context Assembly
298
333
 
299
- 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.
334
+ Run \`yg build-context --node <path>\` to get the deterministic context package for a node. The package assembles global config, hierarchy, own artifacts, aspects, and relational context. It is your architectural map. For implementation-level claims (exact call patterns, error handling, await vs fire-and-forget) \u2014 verify against source code. If the package is insufficient, enrich the graph.
300
335
 
301
336
  ### Information Routing
302
337
 
@@ -307,7 +342,7 @@ When you encounter information, route it to the correct location:
307
342
  - **Business process** \u2192 flow (\`flows/<name>/\` with \`flow.yaml\` + \`description.md\`). Ask user if process unclear.
308
343
  - **Shared across a domain** \u2192 parent node artifact. Children receive it through hierarchy.
309
344
  - **Technology stack or standard** \u2192 \`config.yaml\` under \`stack\` or \`standards\` (+ \`rationale\` field)
310
- - **Decision (why + why NOT):** one node \u2192 \`decisions.md\` with format "Chose X over Y because Z"; category of nodes \u2192 aspect content files; tech choice \u2192 \`config.yaml\` rationale field. Always include rejected alternatives \u2014 they are the highest-value graph content.
345
+ - **Decision (why + why NOT):** one node \u2192 Decisions section of \`internals.md\` with format "Chose X over Y because Z"; category of nodes \u2192 aspect content files; tech choice \u2192 \`config.yaml\` rationale field. Always include rejected alternatives \u2014 they are the highest-value graph content. If the rationale is unknown: record the decision with "rationale: unknown" and note what CAN be observed from the code. Never invent a plausible-sounding rationale.
311
346
 
312
347
  ### Creating Aspects
313
348
 
@@ -325,6 +360,18 @@ Test: "Does this requirement apply to more than one node?" Yes \u2192 aspect. No
325
360
  - **Architectural:** Structural patterns with rationale (e.g., dual-rollback on provider failure, idempotency via key generation, fire-and-forget dispatch)
326
361
  - **Concurrency:** Shared concurrency strategies (e.g., pessimistic locking, retry-on-deadlock, optimistic versioning)
327
362
 
363
+ When a node follows an aspect's pattern with exceptions, record exceptions in \`node.yaml\` under \`aspect_exceptions\`. Example: aspect says "fire-and-forget" but this node awaits the publish call. The exception appears in the context package next to the aspect content, preventing abstractions from masking implementation details.
364
+
365
+ **Aspect lifecycle warning.** Aspects decay CATASTROPHICALLY \u2014 a pattern either exists or it doesn't. When a pattern changes, ALL aspect claims become wrong at once. This differs from other artifacts: \`interface.md\` and \`responsibility.md\` are most stable (~9-year half-life); \`internals.md\` has moderate stability (~2.5-year half-life); aspects are least stable (~2.4-year half-life, binary decay). After any significant feature addition, review ALL aspects touching the affected area. Don't wait for drift \u2014 aspects can be 100% wrong without any mapped file changing.
366
+
367
+ **Aspect stability tiers.** If an aspect has a \`stability\` field in \`aspect.yaml\`, use it to calibrate review urgency:
368
+
369
+ - \`schema\` \u2014 enforced by data model; review only when data model changes (most stable)
370
+ - \`protocol\` \u2014 contractual pattern; review when contracts or interfaces change
371
+ - \`implementation\` \u2014 specific mechanism; review after ANY significant code change (least stable)
372
+
373
+ When code anchors (\`anchors\` field in \`node.yaml\`) are present for an aspect, they list code patterns (function names, constants, SQL fragments) evidencing the aspect's implementation in this node. \`yg validate\` checks that each anchor exists in the node's mapped source files \u2014 a missing anchor (W014) signals the aspect may be stale for this node.
374
+
328
375
  ### Creating Flows
329
376
 
330
377
  - [ ] 1. Read \`schemas/flow.yaml\`
@@ -335,13 +382,18 @@ Test: "Does this requirement apply to more than one node?" Yes \u2192 aspect. No
335
382
 
336
383
  Test: "Does this describe what happens in the world, or only in the software?" If only software \u2014 rewrite.
337
384
 
385
+ **Warning:** Flow descriptions must describe business processes, not code sequences. "The OrderService calls PaymentGateway.charge()" is WRONG. "The system charges the customer's payment method" is CORRECT.
386
+
338
387
  ### Operational Rules
339
388
 
340
389
  - **English only** for all files in \`.yggdrasil/\`. Conversation can be any language.
341
390
  - **Read schemas before creating** any \`node.yaml\`, \`aspect.yaml\`, or \`flow.yaml\`.
342
391
  - **Tools read, you write.** The \`yg\` CLI only reads, validates, and manages metadata. You create and edit files manually.
343
392
  - **Incremental sync.** Run \`yg drift-sync\` after every 3-5 source file changes. Do not defer to end of task.
344
- - **Completeness test:** "If I delete the source file and give another agent ONLY the \`yg build-context\` output \u2014 can they recreate it correctly, understanding not just WHAT but WHY?" Test specifically: Can they explain rejected alternatives? Can they implement the correct algorithm (not a simplified version)? Can they argue for the current design against plausible alternatives?
393
+ - **Completeness test:** Two checks, both required:
394
+ 1. **Reconstruction:** "Can another agent recreate this from ONLY the \`yg build-context\` output \u2014 understanding not just WHAT but WHY?" Test: rejected alternatives, correct algorithm, design arguments.
395
+ 2. **Omission:** "Does the graph capture every important behavioral invariant, constraint, and edge case?" Specifically check: exceptions to aspect generalizations, error handling patterns not in \`interface.md\`, concurrency behaviors not in \`internals.md\`.
396
+ - **Value calibration.** Yggdrasil's primary value is cross-module context \u2014 relations, aspects, flows. For a single simple module, \`responsibility.md\` and \`interface.md\` provide most value. Invest depth (\`internals.md\`) where cross-module interactions demand it.
345
397
  - **These rules are invariant.** No plan, guide, skill, or workflow may override them.
346
398
 
347
399
  ### CLI Reference
@@ -356,6 +408,7 @@ yg flows List flows with metadata (YAML output).
356
408
  yg deps --node <path> [--depth N] [--type structural|event|all]
357
409
  Show dependencies.
358
410
  yg impact --node <path> --simulate Simulate blast radius of a planned change.
411
+ yg impact --node <path> --method <name> Filter impact to dependents consuming a specific method.
359
412
  yg impact --aspect <id> Show all nodes where aspect is effective.
360
413
  yg impact --flow <name> Show flow participants and descendants.
361
414
  yg status Graph health: nodes, coverage, drift summary.
@@ -374,16 +427,15 @@ yg journal-archive Archive consolidated journal entries.
374
427
 
375
428
  | What you have | Where it goes |
376
429
  |---|---|
377
- | Information specific to this node | Local node artifact (read \`config.yaml artifacts\` for types) |
430
+ | Information specific to this node | Local node artifact (check \`config.yaml artifacts\` for types) |
378
431
  | Rule that applies to many nodes | Aspect (content \`.md\` files in \`aspects/<id>/\`) |
379
432
  | Architectural invariant for a node type | Required aspect in \`config.yaml node_types\` |
380
433
  | Business process participation | Flow (\`flow.yaml participants\`) |
381
434
  | Process-level requirement | Flow \`aspects\` + aspect directory |
382
435
  | Context shared across a domain | Parent node artifact |
383
436
  | Technology stack | \`config.yaml stack\` (+ \`rationale\` field) |
384
- | Global coding standards | \`config.yaml standards\` |
385
- `;
386
- var AGENT_RULES_CONTENT = [CORE_PROTOCOL, OPERATIONS, KNOWLEDGE_BASE].join("\n\n---\n\n");
437
+ | Global coding standards | \`config.yaml standards\` |`;
438
+ var AGENT_RULES_CONTENT = [CORE_PROTOCOL, OPERATIONS, KNOWLEDGE_BASE].join("\n\n---\n\n") + "\n";
387
439
 
388
440
  // src/templates/platform.ts
389
441
  var AGENT_RULES_IMPORT = "@.yggdrasil/agent-rules.md";
@@ -838,15 +890,81 @@ async function parseNodeYaml(filePath) {
838
890
  }
839
891
  const relations = parseRelations(raw.relations, filePath);
840
892
  const mapping = parseMapping(raw.mapping, filePath);
893
+ const aspects = parseStringArray(raw.aspects) ?? parseStringArray(raw.tags);
894
+ const aspectExceptions = parseAspectExceptions(raw.aspect_exceptions, aspects, filePath);
895
+ const anchors = parseAnchors(raw.anchors, filePath);
841
896
  return {
842
897
  name: raw.name.trim(),
843
898
  type: raw.type.trim(),
844
- aspects: parseStringArray(raw.aspects) ?? parseStringArray(raw.tags),
899
+ aspects,
900
+ aspect_exceptions: aspectExceptions,
845
901
  blackbox: raw.blackbox ?? false,
846
902
  relations: relations.length > 0 ? relations : void 0,
847
- mapping
903
+ mapping,
904
+ anchors
848
905
  };
849
906
  }
907
+ function parseAnchors(raw, filePath) {
908
+ if (raw === void 0 || raw === null) return void 0;
909
+ if (typeof raw !== "object" || Array.isArray(raw)) {
910
+ throw new Error(
911
+ `node.yaml at ${filePath}: 'anchors' must be an object mapping aspect ids to arrays of strings`
912
+ );
913
+ }
914
+ const obj = raw;
915
+ const entries = Object.entries(obj);
916
+ if (entries.length === 0) return void 0;
917
+ const result = {};
918
+ for (const [key, value] of entries) {
919
+ if (!Array.isArray(value) || value.length === 0) {
920
+ throw new Error(
921
+ `node.yaml at ${filePath}: 'anchors.${key}' must be a non-empty array of strings`
922
+ );
923
+ }
924
+ const strings = value.filter((v) => typeof v === "string");
925
+ if (strings.length === 0) {
926
+ throw new Error(
927
+ `node.yaml at ${filePath}: 'anchors.${key}' must be a non-empty array of strings`
928
+ );
929
+ }
930
+ result[key] = strings;
931
+ }
932
+ return result;
933
+ }
934
+ function parseAspectExceptions(raw, aspects, filePath) {
935
+ if (raw === void 0 || raw === null) return void 0;
936
+ if (!Array.isArray(raw)) {
937
+ throw new Error(`node.yaml at ${filePath}: 'aspect_exceptions' must be an array`);
938
+ }
939
+ if (raw.length === 0) return void 0;
940
+ const aspectSet = new Set(aspects ?? []);
941
+ const result = [];
942
+ for (let i = 0; i < raw.length; i++) {
943
+ const item = raw[i];
944
+ if (typeof item !== "object" || item === null) {
945
+ throw new Error(`node.yaml at ${filePath}: aspect_exceptions[${i}] must be an object`);
946
+ }
947
+ const obj = item;
948
+ if (typeof obj.aspect !== "string" || obj.aspect.trim() === "") {
949
+ throw new Error(
950
+ `node.yaml at ${filePath}: aspect_exceptions[${i}].aspect must be a non-empty string`
951
+ );
952
+ }
953
+ if (typeof obj.note !== "string" || obj.note.trim() === "") {
954
+ throw new Error(
955
+ `node.yaml at ${filePath}: aspect_exceptions[${i}].note must be a non-empty string`
956
+ );
957
+ }
958
+ const aspectId = obj.aspect.trim();
959
+ if (!aspectSet.has(aspectId)) {
960
+ throw new Error(
961
+ `node.yaml at ${filePath}: aspect_exceptions[${i}].aspect "${aspectId}" is not in this node's aspects list`
962
+ );
963
+ }
964
+ result.push({ aspect: aspectId, note: obj.note.trim() });
965
+ }
966
+ return result.length > 0 ? result : void 0;
967
+ }
850
968
  function parseStringArray(val) {
851
969
  if (!Array.isArray(val)) return void 0;
852
970
  const arr = val.filter((v) => typeof v === "string");
@@ -943,6 +1061,7 @@ async function readArtifacts(dirPath, excludeFiles = ["node.yaml"], includeFiles
943
1061
  }
944
1062
 
945
1063
  // src/io/aspect-parser.ts
1064
+ var VALID_STABILITY_VALUES = ["schema", "protocol", "implementation"];
946
1065
  async function parseAspect(aspectDir, aspectYamlPath, id) {
947
1066
  const idTrimmed = id?.trim() ?? "";
948
1067
  if (!idTrimmed) {
@@ -965,11 +1084,21 @@ async function parseAspect(aspectDir, aspectYamlPath, id) {
965
1084
  }
966
1085
  implies = raw.implies.filter((t) => typeof t === "string");
967
1086
  }
1087
+ let stability;
1088
+ if (raw.stability !== void 0) {
1089
+ if (typeof raw.stability !== "string" || !VALID_STABILITY_VALUES.includes(raw.stability)) {
1090
+ throw new Error(
1091
+ `Aspect file ${aspectYamlPath}: 'stability' must be one of: ${VALID_STABILITY_VALUES.join(", ")}`
1092
+ );
1093
+ }
1094
+ stability = raw.stability;
1095
+ }
968
1096
  return {
969
1097
  name: raw.name.trim(),
970
1098
  id: idTrimmed,
971
1099
  description,
972
1100
  implies,
1101
+ stability,
973
1102
  artifacts
974
1103
  };
975
1104
  }
@@ -1292,7 +1421,8 @@ async function buildContext(graph, nodePath) {
1292
1421
  }
1293
1422
  const aspectsToInclude = resolveAspects(allAspectIds, graph.aspects);
1294
1423
  for (const aspect of aspectsToInclude) {
1295
- layers.push(buildAspectLayer(aspect));
1424
+ const exception = node.meta.aspect_exceptions?.find((e) => e.aspect === aspect.id);
1425
+ layers.push(buildAspectLayer(aspect, exception?.note));
1296
1426
  }
1297
1427
  const fullText = layers.map((l) => l.content).join("\n\n");
1298
1428
  const tokenCount = estimateTokens(fullText);
@@ -1475,9 +1605,18 @@ Consumes: ${relation.consumes.join(", ")}`;
1475
1605
  attrs
1476
1606
  };
1477
1607
  }
1478
- function buildAspectLayer(aspect) {
1479
- const content = aspect.artifacts.map((a) => `### ${a.filename}
1608
+ function buildAspectLayer(aspect, exceptionNote) {
1609
+ let content = aspect.artifacts.map((a) => `### ${a.filename}
1480
1610
  ${a.content}`).join("\n\n");
1611
+ if (aspect.stability) {
1612
+ content += `
1613
+ **Stability tier:** ${aspect.stability}`;
1614
+ }
1615
+ if (exceptionNote) {
1616
+ content += `
1617
+
1618
+ \u26A0 **Exception for this node:** ${exceptionNote}`;
1619
+ }
1481
1620
  return {
1482
1621
  type: "aspects",
1483
1622
  label: `${aspect.name} (aspect: ${aspect.id})`,
@@ -1548,7 +1687,7 @@ function collectEffectiveAspectIds(graph, nodePath) {
1548
1687
  }
1549
1688
 
1550
1689
  // src/core/validator.ts
1551
- import { readdir as readdir4 } from "fs/promises";
1690
+ import { readdir as readdir4, readFile as readFile11, stat as stat3 } from "fs/promises";
1552
1691
  import path9 from "path";
1553
1692
  var RESERVED_DIRS = /* @__PURE__ */ new Set();
1554
1693
  async function validate(graph, scope = "all") {
@@ -1578,6 +1717,8 @@ async function validate(graph, scope = "all") {
1578
1717
  issues.push(...checkImpliedAspectsExist(graph));
1579
1718
  issues.push(...checkImpliesNoCycles(graph));
1580
1719
  issues.push(...checkRequiredAspectsCoverage(graph));
1720
+ issues.push(...checkAspectExceptions(graph));
1721
+ issues.push(...await checkAnchorPresence(graph));
1581
1722
  issues.push(...checkRequiredArtifacts(graph));
1582
1723
  issues.push(...checkInvalidArtifactConditions(graph));
1583
1724
  issues.push(...await checkContextBudget(graph));
@@ -1824,6 +1965,24 @@ function checkRequiredAspectsCoverage(graph) {
1824
1965
  }
1825
1966
  return issues;
1826
1967
  }
1968
+ function checkAspectExceptions(graph) {
1969
+ const issues = [];
1970
+ for (const [nodePath, node] of graph.nodes) {
1971
+ for (const exception of node.meta.aspect_exceptions ?? []) {
1972
+ const nodeAspects = node.meta.aspects ?? [];
1973
+ if (!nodeAspects.includes(exception.aspect)) {
1974
+ issues.push({
1975
+ severity: "error",
1976
+ code: "E018",
1977
+ rule: "invalid-aspect-exception",
1978
+ message: `aspect_exceptions references aspect '${exception.aspect}' which is not in this node's aspects list (${nodeAspects.join(", ") || "none"})`,
1979
+ nodePath
1980
+ });
1981
+ }
1982
+ }
1983
+ }
1984
+ return issues;
1985
+ }
1827
1986
  function checkNoCycles(graph) {
1828
1987
  const WHITE = 0;
1829
1988
  const GRAY = 1;
@@ -2202,6 +2361,81 @@ async function checkDirectoriesHaveNodeYaml(graph) {
2202
2361
  }
2203
2362
  return issues;
2204
2363
  }
2364
+ async function expandMappingToFiles(projectRoot, mappingPaths) {
2365
+ const files = [];
2366
+ async function collectFiles(absPath) {
2367
+ try {
2368
+ const s = await stat3(absPath);
2369
+ if (s.isFile()) {
2370
+ files.push(absPath);
2371
+ } else if (s.isDirectory()) {
2372
+ const entries = await readdir4(absPath, { withFileTypes: true });
2373
+ for (const entry of entries) {
2374
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
2375
+ const entryPath = path9.join(absPath, entry.name);
2376
+ if (entry.isFile()) {
2377
+ files.push(entryPath);
2378
+ } else if (entry.isDirectory()) {
2379
+ await collectFiles(entryPath);
2380
+ }
2381
+ }
2382
+ }
2383
+ } catch {
2384
+ }
2385
+ }
2386
+ for (const mp of mappingPaths) {
2387
+ await collectFiles(path9.join(projectRoot, mp));
2388
+ }
2389
+ return files;
2390
+ }
2391
+ async function checkAnchorPresence(graph) {
2392
+ const issues = [];
2393
+ const projectRoot = path9.dirname(graph.rootPath);
2394
+ for (const [nodePath, node] of graph.nodes) {
2395
+ const anchors = node.meta.anchors;
2396
+ if (!anchors) continue;
2397
+ const nodeAspects = new Set(node.meta.aspects ?? []);
2398
+ for (const aspectId of Object.keys(anchors)) {
2399
+ if (!nodeAspects.has(aspectId)) {
2400
+ issues.push({
2401
+ severity: "error",
2402
+ code: "E019",
2403
+ rule: "invalid-anchor-ref",
2404
+ message: `anchors references aspect '${aspectId}' which is not in this node's aspects list (${[...nodeAspects].join(", ") || "none"})`,
2405
+ nodePath
2406
+ });
2407
+ }
2408
+ }
2409
+ const mappingPaths = normalizeMappingPaths(node.meta.mapping);
2410
+ if (mappingPaths.length === 0) continue;
2411
+ const sourceFiles = await expandMappingToFiles(projectRoot, mappingPaths);
2412
+ if (sourceFiles.length === 0) continue;
2413
+ const fileContents = [];
2414
+ for (const filePath of sourceFiles) {
2415
+ try {
2416
+ const content = await readFile11(filePath, "utf-8");
2417
+ fileContents.push(content);
2418
+ } catch {
2419
+ }
2420
+ }
2421
+ for (const [aspectId, anchorList] of Object.entries(anchors)) {
2422
+ if (!nodeAspects.has(aspectId)) continue;
2423
+ for (const anchor of anchorList) {
2424
+ const found = fileContents.some((content) => content.includes(anchor));
2425
+ if (!found) {
2426
+ issues.push({
2427
+ severity: "warning",
2428
+ code: "W014",
2429
+ rule: "anchor-not-found",
2430
+ message: `Anchor '${anchor}' for aspect '${aspectId}' not found in mapped source files`,
2431
+ nodePath
2432
+ });
2433
+ }
2434
+ }
2435
+ }
2436
+ }
2437
+ return issues;
2438
+ }
2205
2439
  async function checkContextBudget(graph) {
2206
2440
  const issues = [];
2207
2441
  const warningThreshold = graph.config.quality?.context_budget.warning ?? 1e4;
@@ -2418,13 +2652,13 @@ ${errors.length} errors, ${warnings.length} warnings.
2418
2652
  import chalk2 from "chalk";
2419
2653
 
2420
2654
  // src/io/drift-state-store.ts
2421
- import { readFile as readFile11, writeFile as writeFile3 } from "fs/promises";
2655
+ import { readFile as readFile12, writeFile as writeFile3 } from "fs/promises";
2422
2656
  import path10 from "path";
2423
2657
  import { parse as yamlParse } from "yaml";
2424
2658
  var DRIFT_STATE_FILE = ".drift-state";
2425
2659
  async function readDriftState(yggRoot) {
2426
2660
  try {
2427
- const content = await readFile11(path10.join(yggRoot, DRIFT_STATE_FILE), "utf-8");
2661
+ const content = await readFile12(path10.join(yggRoot, DRIFT_STATE_FILE), "utf-8");
2428
2662
  let raw;
2429
2663
  try {
2430
2664
  raw = JSON.parse(content);
@@ -2449,20 +2683,20 @@ async function writeDriftState(yggRoot, state) {
2449
2683
  }
2450
2684
 
2451
2685
  // src/utils/hash.ts
2452
- import { readFile as readFile12, readdir as readdir5, stat as stat3 } from "fs/promises";
2686
+ import { readFile as readFile13, readdir as readdir5, stat as stat4 } from "fs/promises";
2453
2687
  import path11 from "path";
2454
2688
  import { createHash } from "crypto";
2455
2689
  import { createRequire } from "module";
2456
2690
  var require2 = createRequire(import.meta.url);
2457
2691
  var ignoreFactory = require2("ignore");
2458
2692
  async function hashFile(filePath) {
2459
- const content = await readFile12(filePath);
2693
+ const content = await readFile13(filePath);
2460
2694
  return createHash("sha256").update(content).digest("hex");
2461
2695
  }
2462
2696
  async function loadRootGitignoreStack(projectRoot) {
2463
2697
  if (!projectRoot) return [];
2464
2698
  try {
2465
- const content = await readFile12(path11.join(projectRoot, ".gitignore"), "utf-8");
2699
+ const content = await readFile13(path11.join(projectRoot, ".gitignore"), "utf-8");
2466
2700
  const matcher = ignoreFactory();
2467
2701
  matcher.add(content);
2468
2702
  return [{ basePath: projectRoot, matcher }];
@@ -2489,7 +2723,7 @@ async function hashTrackedFiles(projectRoot, trackedFiles, storedFileData, exclu
2489
2723
  for (const tf of trackedFiles) {
2490
2724
  const absPath = path11.join(projectRoot, tf.path);
2491
2725
  try {
2492
- const st = await stat3(absPath);
2726
+ const st = await stat4(absPath);
2493
2727
  if (st.isDirectory()) {
2494
2728
  const dirEntries = await collectDirectoryFilePaths(absPath, absPath, {
2495
2729
  projectRoot,
@@ -2537,7 +2771,7 @@ async function hashTrackedFiles(projectRoot, trackedFiles, storedFileData, exclu
2537
2771
  async function collectDirectoryFilePaths(directoryPath, rootDirectoryPath, options) {
2538
2772
  let stack = options.gitignoreStack ?? [];
2539
2773
  try {
2540
- const localContent = await readFile12(path11.join(directoryPath, ".gitignore"), "utf-8");
2774
+ const localContent = await readFile13(path11.join(directoryPath, ".gitignore"), "utf-8");
2541
2775
  const localMatcher = ignoreFactory();
2542
2776
  localMatcher.add(localContent);
2543
2777
  stack = [...stack, { basePath: directoryPath, matcher: localMatcher }];
@@ -2558,7 +2792,7 @@ async function collectDirectoryFilePaths(directoryPath, rootDirectoryPath, optio
2558
2792
  gitignoreStack: stack
2559
2793
  }))),
2560
2794
  Promise.all(files.map(async (f) => {
2561
- const fileStat = await stat3(f);
2795
+ const fileStat = await stat4(f);
2562
2796
  return {
2563
2797
  relPath: path11.relative(rootDirectoryPath, f),
2564
2798
  absPath: f,
@@ -3558,7 +3792,7 @@ Total scope: ${sorted.length} nodes
3558
3792
  }
3559
3793
  }
3560
3794
  function registerImpactCommand(program2) {
3561
- 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(
3795
+ 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("--method <name>", "Filter impact to dependents consuming a specific method (requires --node)").option("--simulate", "Simulate context package impact (compare HEAD vs current)").action(
3562
3796
  async (options) => {
3563
3797
  try {
3564
3798
  const modeCount = [options.node, options.aspect, options.flow].filter(Boolean).length;
@@ -3589,11 +3823,56 @@ function registerImpactCommand(program2) {
3589
3823
  `);
3590
3824
  process.exit(1);
3591
3825
  }
3826
+ if (options.method && !options.node) {
3827
+ process.stderr.write("Error: --method requires --node\n");
3828
+ process.exit(1);
3829
+ }
3592
3830
  const { direct, allDependents, reverse, relationFrom } = collectReverseDependents(
3593
3831
  graph,
3594
3832
  nodePath
3595
3833
  );
3596
- const chains = buildTransitiveChains(nodePath, direct, allDependents, reverse);
3834
+ const methodFilter = options.method?.trim();
3835
+ let filteredDirect = direct;
3836
+ let filteredAllDependents = allDependents;
3837
+ if (methodFilter) {
3838
+ filteredDirect = direct.filter((dep) => {
3839
+ const rel = relationFrom.get(`${dep}->${nodePath}`);
3840
+ return rel?.consumes?.includes(methodFilter) || !rel?.consumes?.length;
3841
+ });
3842
+ const filteredSet = new Set(filteredDirect);
3843
+ filteredAllDependents = allDependents.filter((dep) => filteredSet.has(dep));
3844
+ }
3845
+ const chains = buildTransitiveChains(nodePath, filteredDirect, filteredAllDependents, reverse);
3846
+ const eventDependents = [];
3847
+ for (const [np, n] of graph.nodes) {
3848
+ for (const rel of n.meta.relations ?? []) {
3849
+ if (rel.target === nodePath && (rel.type === "emits" || rel.type === "listens")) {
3850
+ eventDependents.push({
3851
+ path: np,
3852
+ type: rel.type,
3853
+ eventName: rel.event_name ?? n.meta.name
3854
+ });
3855
+ }
3856
+ }
3857
+ }
3858
+ const targetNode = graph.nodes.get(nodePath);
3859
+ for (const rel of targetNode.meta.relations ?? []) {
3860
+ if (rel.type === "emits") {
3861
+ const eventName = rel.event_name ?? rel.target;
3862
+ for (const [np, n] of graph.nodes) {
3863
+ if (np === nodePath) continue;
3864
+ for (const r of n.meta.relations ?? []) {
3865
+ if (r.type === "listens" && r.target === rel.target) {
3866
+ eventDependents.push({
3867
+ path: np,
3868
+ type: "listens",
3869
+ eventName: r.event_name ?? eventName
3870
+ });
3871
+ }
3872
+ }
3873
+ }
3874
+ }
3875
+ }
3597
3876
  const flows = [];
3598
3877
  for (const flow of graph.flows) {
3599
3878
  if (flow.nodes.includes(nodePath)) {
@@ -3607,17 +3886,25 @@ function registerImpactCommand(program2) {
3607
3886
  aspectsInScope.push(aspect.name);
3608
3887
  }
3609
3888
  }
3610
- process.stdout.write(`Impact of changes in ${nodePath}:
3889
+ const methodLabel = methodFilter ? ` (method: ${methodFilter})` : "";
3890
+ process.stdout.write(`Impact of changes in ${nodePath}${methodLabel}:
3611
3891
 
3612
3892
  `);
3613
3893
  process.stdout.write("Directly dependent:\n");
3614
- if (direct.length === 0) {
3894
+ if (filteredDirect.length === 0) {
3615
3895
  process.stdout.write(" (none)\n");
3616
3896
  } else {
3617
- for (const dep of direct) {
3897
+ for (const dep of filteredDirect) {
3618
3898
  const rel = relationFrom.get(`${dep}->${nodePath}`);
3619
- const annot = rel?.consumes?.length ? ` (${rel.type}, you consume: ${rel.consumes.join(", ")})` : rel ? ` (${rel.type})` : "";
3899
+ const annot = rel?.consumes?.length ? ` (${rel.type}, consumes: ${rel.consumes.join(", ")})` : rel ? ` (${rel.type})` : "";
3620
3900
  process.stdout.write(` <- ${dep}${annot}
3901
+ `);
3902
+ }
3903
+ }
3904
+ if (eventDependents.length > 0 && !methodFilter) {
3905
+ process.stdout.write("\nEvent-connected:\n");
3906
+ for (const { path: p, type, eventName } of eventDependents.sort((a, b) => a.path.localeCompare(b.path))) {
3907
+ process.stdout.write(` ${p} (${type}: ${eventName})
3621
3908
  `);
3622
3909
  }
3623
3910
  }
@@ -3667,7 +3954,7 @@ Flows: ${flows.length > 0 ? flows.join(", ") : "(none)"}
3667
3954
  `);
3668
3955
  }
3669
3956
  }
3670
- const allAffected = /* @__PURE__ */ new Set([...allDependents, ...descendants]);
3957
+ const allAffected = /* @__PURE__ */ new Set([...filteredAllDependents, ...descendants, ...eventDependents.map((e) => e.path)]);
3671
3958
  process.stdout.write(
3672
3959
  `
3673
3960
  Total scope: ${allAffected.size} nodes, ${flows.length} flows, ${aspectsInScope.length} aspects
@@ -3696,6 +3983,7 @@ function registerAspectsCommand(program2) {
3696
3983
  const entry = { id: aspect.id, name: aspect.name };
3697
3984
  if (aspect.description) entry.description = aspect.description;
3698
3985
  if (aspect.implies && aspect.implies.length > 0) entry.implies = aspect.implies;
3986
+ if (aspect.stability) entry.stability = aspect.stability;
3699
3987
  return entry;
3700
3988
  });
3701
3989
  process.stdout.write(yamlStringify(output));
@@ -3749,7 +4037,7 @@ function registerFlowsCommand(program2) {
3749
4037
  }
3750
4038
 
3751
4039
  // src/io/journal-store.ts
3752
- import { readFile as readFile13, writeFile as writeFile4, mkdir as mkdir3, rename, access as access3 } from "fs/promises";
4040
+ import { readFile as readFile14, writeFile as writeFile4, mkdir as mkdir3, rename, access as access3 } from "fs/promises";
3753
4041
  import { parse as parseYaml6, stringify as stringifyYaml } from "yaml";
3754
4042
  import path17 from "path";
3755
4043
  var JOURNAL_FILE = ".journal.yaml";
@@ -3757,7 +4045,7 @@ var ARCHIVE_DIR = "journals-archive";
3757
4045
  async function readJournal(yggRoot) {
3758
4046
  const filePath = path17.join(yggRoot, JOURNAL_FILE);
3759
4047
  try {
3760
- const content = await readFile13(filePath, "utf-8");
4048
+ const content = await readFile14(filePath, "utf-8");
3761
4049
  const raw = parseYaml6(content);
3762
4050
  const entries = raw.entries ?? [];
3763
4051
  return Array.isArray(entries) ? entries : [];