@chrisdudek/yg 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -105,7 +105,7 @@ WHEN UNSURE: ask the user. Never guess. Never assume.
105
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
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
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.
108
+ 4. **Always capture why \u2014 especially why NOT.** When the user explains a reason, record it in the graph immediately. When a design choice is made, also record rejected alternatives: "Chose X over Y because Z." Rejected alternatives are the highest-value information \u2014 invisible in code and irrecoverable once forgotten. Conversation evaporates; graph persists.
109
109
  5. **Ask before resolving ambiguity.** When multiple valid interpretations exist, stop, list options, ask the user. Never silently choose.
110
110
 
111
111
  ### Failure States
@@ -126,6 +126,7 @@ You have broken Yggdrasil if you do any of the following:
126
126
  - \u274C Answered a question about a mapped file without running \`yg build-context\` first.
127
127
  - \u274C Read mapped source files to plan or research changes without running \`yg build-context\` first.
128
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.
129
130
 
130
131
  ### Escape Hatch
131
132
 
@@ -165,6 +166,10 @@ WRAP-UP (user signals "done", "wrap up", "that's enough"):
165
166
  - [ ] 2. yg drift --drifted-only \u2192 resolve
166
167
  - [ ] 3. yg validate \u2192 fix errors
167
168
  - [ ] 4. Report: which nodes and files were changed
169
+
170
+ BEFORE ENDING ANY RESPONSE (self-audit):
171
+ - [ ] Did I modify source code? If yes \u2192 did I update graph artifacts in this same response?
172
+ - [ ] If you changed code and did not sync the graph, you have broken the protocol. Do not finish until both are done.
168
173
  \`\`\`
169
174
 
170
175
  ### Modify Source Code
@@ -192,7 +197,14 @@ You are not allowed to edit or create source code without establishing graph cov
192
197
  - Option B \u2014 Blackbox: create a blackbox node at agreed granularity
193
198
  - Option C \u2014 Abort
194
199
 
195
- *Greenfield (new code):* Only Option A. Blackbox is forbidden for new code. Create nodes with full artifacts, then materialize.
200
+ *Greenfield (new code):* Only Option A. Blackbox is forbidden for new code. Follow the graph-first workflow:
201
+
202
+ 1. Create aspects first (cross-cutting requirements the new code must satisfy)
203
+ 2. Create flows if the code participates in a business process
204
+ 3. Create nodes with full artifacts \u2014 responsibility, constraints, decisions, interface, logic
205
+ 4. Review the context package (\`yg build-context\`) \u2014 it is now the behavioral specification
206
+ 5. Implement code that satisfies the specification
207
+ 6. The graph specifies WHAT and WHY; the code implements HOW (framework APIs, library choices)
196
208
 
197
209
  After the user chooses, return to Step 1 and follow Step 2a.
198
210
 
@@ -226,6 +238,7 @@ Per area checklist:
226
238
  - Business process unclear: "This code appears to be part of a larger process. Can you describe what it means from a business perspective?"
227
239
  - Constraint without rationale: "I see [constraint X]. Do you know why this exists? I want to record the reason, not just the rule."
228
240
  - 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\`.
229
242
 
230
243
  ### Bootstrap Mode
231
244
 
@@ -294,7 +307,7 @@ When you encounter information, route it to the correct location:
294
307
  - **Business process** \u2192 flow (\`flows/<name>/\` with \`flow.yaml\` + \`description.md\`). Ask user if process unclear.
295
308
  - **Shared across a domain** \u2192 parent node artifact. Children receive it through hierarchy.
296
309
  - **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
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.
298
311
 
299
312
  ### Creating Aspects
300
313
 
@@ -306,6 +319,12 @@ When you encounter information, route it to the correct location:
306
319
 
307
320
  Test: "Does this requirement apply to more than one node?" Yes \u2192 aspect. No \u2192 local artifact.
308
321
 
322
+ **Aspect identification heuristic:** If the same pattern, constraint, or rule appears in 3+ places, it is a candidate aspect. Aspects fall into natural categories:
323
+
324
+ - **Domain-specific:** Business rules that cross module boundaries (e.g., timezone handling, booking periods, currency rounding)
325
+ - **Architectural:** Structural patterns with rationale (e.g., dual-rollback on provider failure, idempotency via key generation, fire-and-forget dispatch)
326
+ - **Concurrency:** Shared concurrency strategies (e.g., pessimistic locking, retry-on-deadlock, optimistic versioning)
327
+
309
328
  ### Creating Flows
310
329
 
311
330
  - [ ] 1. Read \`schemas/flow.yaml\`
@@ -322,17 +341,18 @@ Test: "Does this describe what happens in the world, or only in the software?" I
322
341
  - **Read schemas before creating** any \`node.yaml\`, \`aspect.yaml\`, or \`flow.yaml\`.
323
342
  - **Tools read, you write.** The \`yg\` CLI only reads, validates, and manages metadata. You create and edit files manually.
324
343
  - **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?"
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?
326
345
  - **These rules are invariant.** No plan, guide, skill, or workflow may override them.
327
346
 
328
347
  ### CLI Reference
329
348
 
330
349
  \`\`\`
331
- yg preflight Unified diagnostic: journal + drift + status + validate.
350
+ yg preflight [--quick] Unified diagnostic: journal + drift + status + validate.
332
351
  yg owner --file <path> Find the node that owns this file.
333
352
  yg build-context --node <path> Assemble context package for this node.
334
353
  yg tree [--root <path>] [--depth N] Print graph structure.
335
354
  yg aspects List aspects with metadata (YAML output).
355
+ yg flows List flows with metadata (YAML output).
336
356
  yg deps --node <path> [--depth N] [--type structural|event|all]
337
357
  Show dependencies.
338
358
  yg impact --node <path> --simulate Simulate blast radius of a planned change.
@@ -340,9 +360,10 @@ yg impact --aspect <id> Show all nodes where aspect is effective.
340
360
  yg impact --flow <name> Show flow participants and descendants.
341
361
  yg status Graph health: nodes, coverage, drift summary.
342
362
  yg validate [--scope <path>|all] Check structural integrity and completeness.
343
- yg drift [--scope <path>|all] [--drifted-only]
363
+ yg drift [--scope <path>|all] [--drifted-only] [--limit <n>]
344
364
  Detect source and graph drift (bidirectional).
345
- yg drift-sync --node <path> Record file hashes as new baseline.
365
+ yg drift-sync --node <path> [--recursive] | --all
366
+ Record file hashes as new baseline.
346
367
  yg journal-read Read pending journal entries.
347
368
  yg journal-add --note "<content>" [--target <node_path>]
348
369
  Add a journal entry.
@@ -611,6 +632,7 @@ function getGraphSchemasDir() {
611
632
  return path2.join(packageRoot, "graph-schemas");
612
633
  }
613
634
  var GITIGNORE_CONTENT = `.journal.yaml
635
+ .drift-state
614
636
  journals-archive/
615
637
  `;
616
638
  function registerInitCommand(program2) {
@@ -683,7 +705,7 @@ function registerInitCommand(program2) {
683
705
  process.stdout.write(" .yggdrasil/model/\n");
684
706
  process.stdout.write(" .yggdrasil/aspects/\n");
685
707
  process.stdout.write(" .yggdrasil/flows/\n");
686
- process.stdout.write(" .yggdrasil/schemas/ (node, aspect, flow)\n");
708
+ process.stdout.write(" .yggdrasil/schemas/ (config, node, aspect, flow)\n");
687
709
  process.stdout.write(` ${path2.relative(projectRoot, rulesPath)} (rules)
688
710
 
689
711
  `);
@@ -709,6 +731,9 @@ var DEFAULT_QUALITY = {
709
731
  async function parseConfig(filePath) {
710
732
  const content = await readFile3(filePath, "utf-8");
711
733
  const raw = parseYaml(content);
734
+ if (!raw || typeof raw !== "object") {
735
+ throw new Error(`config.yaml: file is empty or not a valid YAML mapping`);
736
+ }
712
737
  if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
713
738
  throw new Error(`config.yaml: missing or invalid 'name' field`);
714
739
  }
@@ -802,6 +827,9 @@ function isValidRelationType(t) {
802
827
  async function parseNodeYaml(filePath) {
803
828
  const content = await readFile4(filePath, "utf-8");
804
829
  const raw = parseYaml2(content);
830
+ if (!raw || typeof raw !== "object") {
831
+ throw new Error(`node.yaml at ${filePath}: file is empty or not a valid YAML mapping`);
832
+ }
805
833
  if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
806
834
  throw new Error(`node.yaml at ${filePath}: missing or empty 'name'`);
807
835
  }
@@ -922,6 +950,9 @@ async function parseAspect(aspectDir, aspectYamlPath, id) {
922
950
  }
923
951
  const content = await readFile6(aspectYamlPath, "utf-8");
924
952
  const raw = parseYaml3(content);
953
+ if (!raw || typeof raw !== "object") {
954
+ throw new Error(`Aspect file ${aspectYamlPath}: file is empty or not a valid YAML mapping`);
955
+ }
925
956
  if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
926
957
  throw new Error(`Aspect file ${aspectYamlPath}: missing or empty 'name'`);
927
958
  }
@@ -950,6 +981,9 @@ import { parse as parseYaml4 } from "yaml";
950
981
  async function parseFlow(flowDir, flowYamlPath) {
951
982
  const content = await readFile7(flowYamlPath, "utf-8");
952
983
  const raw = parseYaml4(content);
984
+ if (!raw || typeof raw !== "object") {
985
+ throw new Error(`flow.yaml at ${flowYamlPath}: file is empty or not a valid YAML mapping`);
986
+ }
953
987
  if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
954
988
  throw new Error(`flow.yaml at ${flowYamlPath}: missing or empty 'name'`);
955
989
  }
@@ -1036,6 +1070,9 @@ function normalizeProjectRelativePath(projectRoot, rawPath) {
1036
1070
  }
1037
1071
  return relative.split(path6.sep).join("/");
1038
1072
  }
1073
+ function projectRootFromGraph(yggRootPath) {
1074
+ return path6.dirname(yggRootPath);
1075
+ }
1039
1076
 
1040
1077
  // src/core/graph-loader.ts
1041
1078
  function toModelPath(absolutePath, modelDir) {
@@ -1228,11 +1265,13 @@ async function buildContext(graph, nodePath) {
1228
1265
  layers.push(buildHierarchyLayer(ancestor, graph.config, graph));
1229
1266
  }
1230
1267
  layers.push(await buildOwnLayer(node, graph.config, graph.rootPath, graph));
1268
+ const ancestorPaths = new Set(ancestors.map((a) => a.path));
1231
1269
  for (const relation of node.meta.relations ?? []) {
1232
1270
  const target = graph.nodes.get(relation.target);
1233
1271
  if (!target) {
1234
1272
  throw new Error(`Broken relation: ${nodePath} -> ${relation.target} (target not found)`);
1235
1273
  }
1274
+ if (ancestorPaths.has(relation.target)) continue;
1236
1275
  if (STRUCTURAL_RELATION_TYPES.has(relation.type)) {
1237
1276
  layers.push(buildStructuralRelationLayer(target, relation, graph.config));
1238
1277
  } else if (EVENT_RELATION_TYPES.has(relation.type)) {
@@ -1548,6 +1587,7 @@ async function validate(graph, scope = "all") {
1548
1587
  issues.push(...checkRelationTargets(graph));
1549
1588
  issues.push(...checkNoCycles(graph));
1550
1589
  issues.push(...checkMappingOverlap(graph));
1590
+ issues.push(...await checkMappingPathsExist(graph));
1551
1591
  issues.push(...checkBrokenFlowRefs(graph));
1552
1592
  issues.push(...checkFlowAspectIds(graph));
1553
1593
  issues.push(...await checkDirectoriesHaveNodeYaml(graph));
@@ -1557,13 +1597,29 @@ async function validate(graph, scope = "all") {
1557
1597
  let nodesScanned = graph.nodes.size;
1558
1598
  if (scope !== "all" && scope.trim()) {
1559
1599
  if (!graph.nodes.has(scope)) {
1600
+ const parseError = (graph.nodeParseErrors ?? []).find(
1601
+ (e) => e.nodePath === scope || scope.startsWith(e.nodePath + "/")
1602
+ );
1603
+ if (parseError) {
1604
+ return {
1605
+ issues: [{
1606
+ severity: "error",
1607
+ code: "E001",
1608
+ rule: "invalid-node-yaml",
1609
+ message: parseError.message,
1610
+ nodePath: parseError.nodePath
1611
+ }],
1612
+ nodesScanned: 0
1613
+ };
1614
+ }
1560
1615
  return {
1561
1616
  issues: [{ severity: "error", rule: "invalid-scope", message: `Node not found: ${scope}` }],
1562
1617
  nodesScanned: 0
1563
1618
  };
1564
1619
  }
1565
- filtered = issues.filter((i) => !i.nodePath || i.nodePath === scope);
1566
- nodesScanned = 1;
1620
+ const scopePrefix = scope + "/";
1621
+ filtered = issues.filter((i) => !i.nodePath || i.nodePath === scope || i.nodePath.startsWith(scopePrefix));
1622
+ nodesScanned = [...graph.nodes.keys()].filter((p) => p === scope || p.startsWith(scopePrefix)).length;
1567
1623
  }
1568
1624
  return { issues: filtered, nodesScanned };
1569
1625
  }
@@ -1820,6 +1876,9 @@ function arePathsOverlapping(pathA, pathB) {
1820
1876
  if (pathA === pathB) return true;
1821
1877
  return pathA.startsWith(pathB + "/") || pathB.startsWith(pathA + "/");
1822
1878
  }
1879
+ function isAncestorNode(possibleAncestor, possibleDescendant) {
1880
+ return possibleDescendant.startsWith(possibleAncestor + "/");
1881
+ }
1823
1882
  function checkMappingOverlap(graph) {
1824
1883
  const issues = [];
1825
1884
  const ownership = [];
@@ -1835,6 +1894,9 @@ function checkMappingOverlap(graph) {
1835
1894
  const candidate = ownership[nestedIndex];
1836
1895
  if (current.nodePath === candidate.nodePath) continue;
1837
1896
  if (!arePathsOverlapping(current.mappingPath, candidate.mappingPath)) continue;
1897
+ const isContainment = current.mappingPath !== candidate.mappingPath;
1898
+ const isHierarchical = isAncestorNode(current.nodePath, candidate.nodePath) || isAncestorNode(candidate.nodePath, current.nodePath);
1899
+ if (isContainment && isHierarchical) continue;
1838
1900
  issues.push({
1839
1901
  severity: "error",
1840
1902
  code: "E009",
@@ -1846,6 +1908,29 @@ function checkMappingOverlap(graph) {
1846
1908
  }
1847
1909
  return issues;
1848
1910
  }
1911
+ async function checkMappingPathsExist(graph) {
1912
+ const issues = [];
1913
+ const projectRoot = path9.dirname(graph.rootPath);
1914
+ const { access: access4 } = await import("fs/promises");
1915
+ for (const [nodePath, node] of graph.nodes) {
1916
+ const mappingPaths = normalizeMappingPaths(node.meta.mapping);
1917
+ for (const mp of mappingPaths) {
1918
+ const absPath = path9.join(projectRoot, mp);
1919
+ try {
1920
+ await access4(absPath);
1921
+ } catch {
1922
+ issues.push({
1923
+ severity: "warning",
1924
+ code: "W012",
1925
+ rule: "mapping-path-missing",
1926
+ message: `Mapping path '${mp}' does not exist on disk`,
1927
+ nodePath
1928
+ });
1929
+ }
1930
+ }
1931
+ }
1932
+ return issues;
1933
+ }
1849
1934
  function getIncomingRelationSources(graph, nodePath) {
1850
1935
  const sources = [];
1851
1936
  for (const [srcPath, node] of graph.nodes) {
@@ -2077,16 +2162,27 @@ async function checkDirectoriesHaveNodeYaml(graph) {
2077
2162
  const hasNodeYaml = entries.some((e) => e.isFile() && e.name === "node.yaml");
2078
2163
  const dirName = path9.basename(dirPath);
2079
2164
  if (RESERVED_DIRS.has(dirName)) return;
2080
- const hasContent = entries.some((e) => e.isFile()) || entries.some((e) => e.isDirectory());
2165
+ const hasFiles = entries.some((e) => e.isFile());
2166
+ const hasSubdirs = entries.some((e) => e.isDirectory() && !RESERVED_DIRS.has(e.name) && !e.name.startsWith("."));
2081
2167
  const graphPath = segments.join("/");
2082
- if (hasContent && !hasNodeYaml && graphPath !== "") {
2083
- issues.push({
2084
- severity: "error",
2085
- code: "E015",
2086
- rule: "missing-node-yaml",
2087
- message: `Directory '${graphPath}' has content but no node.yaml`,
2088
- nodePath: graphPath
2089
- });
2168
+ if (!hasNodeYaml && graphPath !== "") {
2169
+ if (hasFiles) {
2170
+ issues.push({
2171
+ severity: "error",
2172
+ code: "E015",
2173
+ rule: "missing-node-yaml",
2174
+ message: `Directory '${graphPath}' has files but no node.yaml`,
2175
+ nodePath: graphPath
2176
+ });
2177
+ } else if (hasSubdirs) {
2178
+ issues.push({
2179
+ severity: "warning",
2180
+ code: "W013",
2181
+ rule: "directory-without-node",
2182
+ message: `Directory '${graphPath}' has subdirectories but no node.yaml \u2014 consider creating a node`,
2183
+ nodePath: graphPath
2184
+ });
2185
+ }
2090
2186
  }
2091
2187
  for (const entry of entries) {
2092
2188
  if (!entry.isDirectory()) continue;
@@ -2210,22 +2306,47 @@ function formatContextText(pkg2) {
2210
2306
  }
2211
2307
 
2212
2308
  // src/cli/build-context.ts
2309
+ function collectRelevantNodePaths(graph, nodePath) {
2310
+ const relevant = /* @__PURE__ */ new Set();
2311
+ const node = graph.nodes.get(nodePath);
2312
+ if (!node) return relevant;
2313
+ relevant.add(nodePath);
2314
+ for (const ancestor of collectAncestors(node)) {
2315
+ relevant.add(ancestor.path);
2316
+ }
2317
+ for (const rel of node.meta.relations ?? []) {
2318
+ relevant.add(rel.target);
2319
+ }
2320
+ return relevant;
2321
+ }
2213
2322
  function registerBuildCommand(program2) {
2214
2323
  program2.command("build-context").description("Assemble a context package for one node").requiredOption("--node <node-path>", "Node path relative to .yggdrasil/model/").action(async (options) => {
2215
2324
  try {
2216
2325
  const graph = await loadGraph(process.cwd());
2326
+ const nodePath = options.node.trim().replace(/^\.\//, "").replace(/\/$/, "");
2327
+ const relevantNodes = collectRelevantNodePaths(graph, nodePath);
2217
2328
  const validationResult = await validate(graph, "all");
2218
- const structuralErrors = validationResult.issues.filter(
2219
- (issue) => issue.severity === "error"
2329
+ const relevantErrors = validationResult.issues.filter(
2330
+ (issue) => issue.severity === "error" && // Global errors (no nodePath) always block — e.g., E012 invalid config
2331
+ (!issue.nodePath || relevantNodes.has(issue.nodePath))
2220
2332
  );
2221
- if (structuralErrors.length > 0) {
2222
- process.stderr.write(
2223
- `Error: build-context requires a structurally valid graph (${structuralErrors.length} errors found).
2224
- `
2225
- );
2333
+ if (relevantErrors.length > 0) {
2334
+ const totalErrors = validationResult.issues.filter((i) => i.severity === "error").length;
2335
+ const skippedErrors = totalErrors - relevantErrors.length;
2336
+ let msg = `Error: build-context blocked by ${relevantErrors.length} error(s) affecting this node's context.
2337
+ `;
2338
+ if (skippedErrors > 0) {
2339
+ msg += `(${skippedErrors} unrelated error(s) in other nodes ignored.)
2340
+ `;
2341
+ }
2342
+ for (const err of relevantErrors) {
2343
+ const loc = err.nodePath ? `${err.nodePath}: ` : "";
2344
+ msg += ` ${err.code ?? ""} ${loc}${err.message}
2345
+ `;
2346
+ }
2347
+ process.stderr.write(msg);
2226
2348
  process.exit(1);
2227
2349
  }
2228
- const nodePath = options.node.trim().replace(/\/$/, "");
2229
2350
  const pkg2 = await buildContext(graph, nodePath);
2230
2351
  const warningThreshold = graph.config.quality?.context_budget.warning ?? 1e4;
2231
2352
  const errorThreshold = graph.config.quality?.context_budget.error ?? 2e4;
@@ -2254,7 +2375,8 @@ function registerValidateCommand(program2) {
2254
2375
  program2.command("validate").description("Validate graph structural integrity and completeness signals").option("--scope <scope>", "Scope: all or node-path (default: all)", "all").action(async (options) => {
2255
2376
  try {
2256
2377
  const graph = await loadGraph(process.cwd(), { tolerateInvalidConfig: true });
2257
- const scope = (options.scope ?? "all").trim() || "all";
2378
+ const rawScope = (options.scope ?? "all").trim() || "all";
2379
+ const scope = rawScope === "all" ? "all" : rawScope.replace(/^\.\//, "").replace(/\/+$/, "");
2258
2380
  const result = await validate(graph, scope);
2259
2381
  process.stdout.write(`${result.nodesScanned} nodes scanned
2260
2382
 
@@ -2298,12 +2420,17 @@ import chalk2 from "chalk";
2298
2420
  // src/io/drift-state-store.ts
2299
2421
  import { readFile as readFile11, writeFile as writeFile3 } from "fs/promises";
2300
2422
  import path10 from "path";
2301
- import { stringify, parse } from "yaml";
2423
+ import { parse as yamlParse } from "yaml";
2302
2424
  var DRIFT_STATE_FILE = ".drift-state";
2303
2425
  async function readDriftState(yggRoot) {
2304
2426
  try {
2305
2427
  const content = await readFile11(path10.join(yggRoot, DRIFT_STATE_FILE), "utf-8");
2306
- const raw = parse(content);
2428
+ let raw;
2429
+ try {
2430
+ raw = JSON.parse(content);
2431
+ } catch {
2432
+ raw = yamlParse(content);
2433
+ }
2307
2434
  if (!raw || typeof raw !== "object") return {};
2308
2435
  const state = {};
2309
2436
  for (const [key, value] of Object.entries(raw)) {
@@ -2317,7 +2444,7 @@ async function readDriftState(yggRoot) {
2317
2444
  }
2318
2445
  }
2319
2446
  async function writeDriftState(yggRoot, state) {
2320
- const content = stringify(state, { lineWidth: 0 });
2447
+ const content = JSON.stringify(state);
2321
2448
  await writeFile3(path10.join(yggRoot, DRIFT_STATE_FILE), content, "utf-8");
2322
2449
  }
2323
2450
 
@@ -2332,41 +2459,6 @@ async function hashFile(filePath) {
2332
2459
  const content = await readFile12(filePath);
2333
2460
  return createHash("sha256").update(content).digest("hex");
2334
2461
  }
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
- }
2344
- const entries = await readdir5(directoryPath, { withFileTypes: true });
2345
- const result = [];
2346
- for (const entry of entries) {
2347
- const absoluteChildPath = path11.join(directoryPath, entry.name);
2348
- if (isIgnoredByStack(absoluteChildPath, stack)) {
2349
- continue;
2350
- }
2351
- if (entry.isDirectory()) {
2352
- const nested = await collectDirectoryFileHashes(
2353
- absoluteChildPath,
2354
- rootDirectoryPath,
2355
- { projectRoot: options.projectRoot, gitignoreStack: stack }
2356
- );
2357
- result.push(...nested);
2358
- continue;
2359
- }
2360
- if (!entry.isFile()) {
2361
- continue;
2362
- }
2363
- result.push({
2364
- path: path11.relative(rootDirectoryPath, absoluteChildPath),
2365
- hash: await hashFile(absoluteChildPath)
2366
- });
2367
- }
2368
- return result;
2369
- }
2370
2462
  async function loadRootGitignoreStack(projectRoot) {
2371
2463
  if (!projectRoot) return [];
2372
2464
  try {
@@ -2389,33 +2481,95 @@ function isIgnoredByStack(candidatePath, stack) {
2389
2481
  function hashString(content) {
2390
2482
  return createHash("sha256").update(content).digest("hex");
2391
2483
  }
2392
- async function hashTrackedFiles(projectRoot, trackedFiles) {
2484
+ async function hashTrackedFiles(projectRoot, trackedFiles, storedFileData, excludePrefixes) {
2393
2485
  const fileHashes = {};
2486
+ const fileMtimes = {};
2394
2487
  const gitignoreStack = await loadRootGitignoreStack(projectRoot);
2488
+ const allFiles = [];
2395
2489
  for (const tf of trackedFiles) {
2396
2490
  const absPath = path11.join(projectRoot, tf.path);
2397
2491
  try {
2398
2492
  const st = await stat3(absPath);
2399
2493
  if (st.isDirectory()) {
2400
- const dirHashes = await collectDirectoryFileHashes(absPath, absPath, {
2494
+ const dirEntries = await collectDirectoryFilePaths(absPath, absPath, {
2401
2495
  projectRoot,
2402
2496
  gitignoreStack
2403
2497
  });
2404
- for (const entry of dirHashes) {
2405
- const fullRelPath = path11.join(tf.path, entry.path).replace(/\\/g, "/");
2406
- fileHashes[fullRelPath] = entry.hash;
2498
+ for (const entry of dirEntries) {
2499
+ allFiles.push({
2500
+ relPath: path11.join(tf.path, entry.relPath).replace(/\\/g, "/"),
2501
+ absPath: entry.absPath,
2502
+ mtimeMs: entry.mtimeMs
2503
+ });
2407
2504
  }
2408
2505
  } else {
2409
- fileHashes[tf.path] = await hashFile(absPath);
2506
+ allFiles.push({ relPath: tf.path, absPath, mtimeMs: st.mtimeMs });
2410
2507
  }
2411
2508
  } catch {
2412
2509
  continue;
2413
2510
  }
2414
2511
  }
2512
+ const filtered = excludePrefixes?.length ? allFiles.filter((entry) => !excludePrefixes.some((prefix) => entry.relPath === prefix || entry.relPath.startsWith(prefix + "/"))) : allFiles;
2513
+ const dirty = [];
2514
+ for (const entry of filtered) {
2515
+ const storedMtime = storedFileData?.mtimes[entry.relPath];
2516
+ const storedHash = storedFileData?.hashes[entry.relPath];
2517
+ if (storedMtime !== void 0 && storedHash !== void 0 && entry.mtimeMs === storedMtime) {
2518
+ fileHashes[entry.relPath] = storedHash;
2519
+ } else {
2520
+ dirty.push(entry);
2521
+ }
2522
+ fileMtimes[entry.relPath] = entry.mtimeMs;
2523
+ }
2524
+ const BATCH_SIZE = 256;
2525
+ for (let i = 0; i < dirty.length; i += BATCH_SIZE) {
2526
+ const batch = dirty.slice(i, i + BATCH_SIZE);
2527
+ const hashes = await Promise.all(batch.map((e) => hashFile(e.absPath)));
2528
+ for (let j = 0; j < batch.length; j++) {
2529
+ fileHashes[batch[j].relPath] = hashes[j];
2530
+ }
2531
+ }
2415
2532
  const sorted = Object.entries(fileHashes).sort(([a], [b]) => a.localeCompare(b));
2416
2533
  const digest = sorted.map(([p, h]) => `${p}:${h}`).join("\n");
2417
2534
  const canonicalHash = hashString(digest);
2418
- return { canonicalHash, fileHashes };
2535
+ return { canonicalHash, fileHashes, fileMtimes };
2536
+ }
2537
+ async function collectDirectoryFilePaths(directoryPath, rootDirectoryPath, options) {
2538
+ let stack = options.gitignoreStack ?? [];
2539
+ try {
2540
+ const localContent = await readFile12(path11.join(directoryPath, ".gitignore"), "utf-8");
2541
+ const localMatcher = ignoreFactory();
2542
+ localMatcher.add(localContent);
2543
+ stack = [...stack, { basePath: directoryPath, matcher: localMatcher }];
2544
+ } catch {
2545
+ }
2546
+ const entries = await readdir5(directoryPath, { withFileTypes: true });
2547
+ const dirs = [];
2548
+ const files = [];
2549
+ for (const entry of entries) {
2550
+ const absoluteChildPath = path11.join(directoryPath, entry.name);
2551
+ if (isIgnoredByStack(absoluteChildPath, stack)) continue;
2552
+ if (entry.isDirectory()) dirs.push(absoluteChildPath);
2553
+ else if (entry.isFile()) files.push(absoluteChildPath);
2554
+ }
2555
+ const [dirResults, fileStats] = await Promise.all([
2556
+ Promise.all(dirs.map((d) => collectDirectoryFilePaths(d, rootDirectoryPath, {
2557
+ projectRoot: options.projectRoot,
2558
+ gitignoreStack: stack
2559
+ }))),
2560
+ Promise.all(files.map(async (f) => {
2561
+ const fileStat = await stat3(f);
2562
+ return {
2563
+ relPath: path11.relative(rootDirectoryPath, f),
2564
+ absPath: f,
2565
+ mtimeMs: fileStat.mtimeMs
2566
+ };
2567
+ }))
2568
+ ]);
2569
+ const result = [];
2570
+ for (const nested of dirResults) result.push(...nested);
2571
+ result.push(...fileStats);
2572
+ return result;
2419
2573
  }
2420
2574
 
2421
2575
  // src/core/context-files.ts
@@ -2511,12 +2665,32 @@ function collectParticipatingFlows2(graph, node, ancestors) {
2511
2665
  // src/core/drift-detector.ts
2512
2666
  import { access } from "fs/promises";
2513
2667
  import path13 from "path";
2668
+ function getChildMappingExclusions(graph, nodePath) {
2669
+ const node = graph.nodes.get(nodePath);
2670
+ if (!node) return [];
2671
+ const parentMappings = normalizeMappingPaths(node.meta.mapping);
2672
+ if (parentMappings.length === 0) return [];
2673
+ const exclusions = [];
2674
+ for (const [childPath, childNode] of graph.nodes) {
2675
+ if (childPath === nodePath) continue;
2676
+ if (!childPath.startsWith(nodePath + "/")) continue;
2677
+ const childMappings = normalizeMappingPaths(childNode.meta.mapping);
2678
+ for (const cm of childMappings) {
2679
+ for (const pm of parentMappings) {
2680
+ if (cm === pm || cm.startsWith(pm + "/")) {
2681
+ exclusions.push(cm);
2682
+ }
2683
+ }
2684
+ }
2685
+ }
2686
+ return exclusions;
2687
+ }
2514
2688
  async function detectDrift(graph, filterNodePath) {
2515
2689
  const projectRoot = path13.dirname(graph.rootPath);
2516
2690
  const driftState = await readDriftState(graph.rootPath);
2517
2691
  const entries = [];
2518
2692
  for (const [nodePath, node] of graph.nodes) {
2519
- if (filterNodePath && nodePath !== filterNodePath) continue;
2693
+ if (filterNodePath && nodePath !== filterNodePath && !nodePath.startsWith(filterNodePath + "/")) continue;
2520
2694
  const mapping = node.meta.mapping;
2521
2695
  if (!mapping) continue;
2522
2696
  const mappingPaths = normalizeMappingPaths(mapping);
@@ -2541,7 +2715,9 @@ async function detectDrift(graph, filterNodePath) {
2541
2715
  continue;
2542
2716
  }
2543
2717
  const trackedFiles = collectTrackedFiles(node, graph);
2544
- const { canonicalHash, fileHashes } = await hashTrackedFiles(projectRoot, trackedFiles);
2718
+ const excludePrefixes = getChildMappingExclusions(graph, nodePath);
2719
+ const storedFileData = storedEntry.files ? { hashes: storedEntry.files, mtimes: storedEntry.mtimes ?? {} } : void 0;
2720
+ const { canonicalHash, fileHashes } = await hashTrackedFiles(projectRoot, trackedFiles, storedFileData, excludePrefixes);
2545
2721
  if (canonicalHash === storedEntry.hash) {
2546
2722
  entries.push({ nodePath, status: "ok" });
2547
2723
  continue;
@@ -2614,20 +2790,29 @@ async function syncDriftState(graph, nodePath) {
2614
2790
  if (!node) throw new Error(`Node not found: ${nodePath}`);
2615
2791
  if (!node.meta.mapping) throw new Error(`Node has no mapping: ${nodePath}`);
2616
2792
  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);
2793
+ const excludePrefixes = getChildMappingExclusions(graph, nodePath);
2794
+ const existingState = await readDriftState(graph.rootPath);
2795
+ const existingEntry = existingState[nodePath];
2796
+ const storedFileData = existingEntry?.files ? { hashes: existingEntry.files, mtimes: existingEntry.mtimes ?? {} } : void 0;
2797
+ const { canonicalHash, fileHashes, fileMtimes } = await hashTrackedFiles(projectRoot, trackedFiles, storedFileData, excludePrefixes);
2798
+ const previousHash = existingEntry?.hash;
2799
+ existingState[nodePath] = { hash: canonicalHash, files: fileHashes, mtimes: fileMtimes };
2800
+ for (const key of Object.keys(existingState)) {
2801
+ if (!graph.nodes.has(key)) {
2802
+ delete existingState[key];
2803
+ }
2804
+ }
2805
+ await writeDriftState(graph.rootPath, existingState);
2622
2806
  return { previousHash, currentHash: canonicalHash };
2623
2807
  }
2624
2808
 
2625
2809
  // src/cli/drift.ts
2626
2810
  function registerDriftCommand(program2) {
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) => {
2811
+ program2.command("drift").description("Detect divergences between graph and mapped files").option("--scope <scope>", 'Scope: "all" or node path', "all").option("--drifted-only", "Show only nodes with drift (hide ok entries)").option("--limit <n>", "Maximum number of entries to show per section", parseInt).action(async (opts) => {
2628
2812
  try {
2629
2813
  const graph = await loadGraph(process.cwd());
2630
- const scope = (opts.scope ?? "all").trim() || "all";
2814
+ const rawScope = (opts.scope ?? "all").trim() || "all";
2815
+ const scope = rawScope === "all" ? "all" : rawScope.replace(/^\.\//, "").replace(/\/+$/, "");
2631
2816
  if (scope !== "all") {
2632
2817
  const node = graph.nodes.get(scope);
2633
2818
  if (!node) {
@@ -2635,7 +2820,8 @@ function registerDriftCommand(program2) {
2635
2820
  `);
2636
2821
  process.exit(1);
2637
2822
  }
2638
- if (!node.meta.mapping) {
2823
+ const hasAnyMapping = node.meta.mapping || [...graph.nodes.entries()].some(([p, n]) => p.startsWith(scope + "/") && n.meta.mapping);
2824
+ if (!hasAnyMapping) {
2639
2825
  process.stderr.write(`Error: Node has no mapping: ${scope}
2640
2826
  `);
2641
2827
  process.exit(1);
@@ -2643,7 +2829,7 @@ function registerDriftCommand(program2) {
2643
2829
  }
2644
2830
  const scopeNode = scope === "all" ? void 0 : scope;
2645
2831
  const report = await detectDrift(graph, scopeNode);
2646
- printReport(report, opts.driftedOnly ?? false);
2832
+ printReport(report, opts.driftedOnly ?? false, opts.limit);
2647
2833
  const hasIssues = report.sourceDriftCount > 0 || report.graphDriftCount > 0 || report.fullDriftCount > 0 || report.missingCount > 0 || report.unmaterializedCount > 0;
2648
2834
  process.exit(hasIssues ? 1 : 0);
2649
2835
  } catch (error) {
@@ -2653,13 +2839,23 @@ function registerDriftCommand(program2) {
2653
2839
  }
2654
2840
  });
2655
2841
  }
2656
- function printReport(report, driftedOnly) {
2842
+ function printReport(report, driftedOnly, limit) {
2657
2843
  const sourceEntries = classifyForSection(report.entries, "source", driftedOnly);
2658
2844
  const graphEntries = classifyForSection(report.entries, "graph", driftedOnly);
2845
+ const sourceShown = limit !== void 0 ? sourceEntries.slice(0, limit) : sourceEntries;
2846
+ const graphShown = limit !== void 0 ? graphEntries.slice(0, limit) : graphEntries;
2659
2847
  process.stdout.write("Source drift:\n");
2660
- printSectionEntries(sourceEntries, "source");
2848
+ printSectionEntries(sourceShown, "source");
2849
+ if (limit !== void 0 && sourceEntries.length > limit) {
2850
+ process.stdout.write(chalk2.dim(` ... ${sourceEntries.length - limit} more (${sourceEntries.length} total)
2851
+ `));
2852
+ }
2661
2853
  process.stdout.write("\nGraph drift:\n");
2662
- printSectionEntries(graphEntries, "graph");
2854
+ printSectionEntries(graphShown, "graph");
2855
+ if (limit !== void 0 && graphEntries.length > limit) {
2856
+ process.stdout.write(chalk2.dim(` ... ${graphEntries.length - limit} more (${graphEntries.length} total)
2857
+ `));
2858
+ }
2663
2859
  const parts = [
2664
2860
  `${report.sourceDriftCount} source-drift`,
2665
2861
  `${report.graphDriftCount} graph-drift`,
@@ -2745,17 +2941,49 @@ function printChangedFiles(entry, section) {
2745
2941
  // src/cli/drift-sync.ts
2746
2942
  import chalk3 from "chalk";
2747
2943
  function registerDriftSyncCommand(program2) {
2748
- program2.command("drift-sync").description("Record current file hash after resolving drift").requiredOption("--node <path>", "Node path to sync").action(async (options) => {
2944
+ program2.command("drift-sync").description("Record current file hash after resolving drift").option("--node <path>", "Node path to sync").option("--recursive", "Also sync all descendant nodes").option("--all", "Sync all nodes with mappings").action(async (options) => {
2749
2945
  try {
2946
+ if (!options.node && !options.all) {
2947
+ process.stderr.write("Error: either '--node <path>' or '--all' is required\n");
2948
+ process.exit(1);
2949
+ }
2750
2950
  const graph = await loadGraph(process.cwd());
2751
- const nodePath = options.node.trim().replace(/\/$/, "");
2752
- const { previousHash, currentHash } = await syncDriftState(graph, nodePath);
2753
- process.stdout.write(chalk3.green(`Synchronized: ${nodePath}
2951
+ let nodesToSync;
2952
+ if (options.all) {
2953
+ nodesToSync = [...graph.nodes.entries()].filter(([, n]) => normalizeMappingPaths(n.meta.mapping).length > 0).map(([p]) => p).sort();
2954
+ } else {
2955
+ const nodePath = options.node.trim().replace(/^\.\//, "").replace(/\/+$/, "");
2956
+ if (!graph.nodes.has(nodePath)) {
2957
+ await syncDriftState(graph, nodePath);
2958
+ return;
2959
+ }
2960
+ nodesToSync = [nodePath];
2961
+ if (options.recursive) {
2962
+ const prefix = nodePath + "/";
2963
+ for (const [p] of graph.nodes) {
2964
+ if (p.startsWith(prefix)) {
2965
+ nodesToSync.push(p);
2966
+ }
2967
+ }
2968
+ nodesToSync.sort();
2969
+ }
2970
+ }
2971
+ for (const np of nodesToSync) {
2972
+ const node = graph.nodes.get(np);
2973
+ if (normalizeMappingPaths(node.meta.mapping).length === 0) {
2974
+ if (!options.all && !options.recursive && np === options.node) {
2975
+ await syncDriftState(graph, np);
2976
+ }
2977
+ continue;
2978
+ }
2979
+ const { previousHash, currentHash } = await syncDriftState(graph, np);
2980
+ process.stdout.write(chalk3.green(`Synchronized: ${np}
2754
2981
  `));
2755
- process.stdout.write(
2756
- ` Hash: ${previousHash ? previousHash.slice(0, 8) : "none"} -> ${currentHash.slice(0, 8)}
2982
+ process.stdout.write(
2983
+ ` Hash: ${previousHash ? previousHash.slice(0, 8) : "none"} -> ${currentHash.slice(0, 8)}
2757
2984
  `
2758
- );
2985
+ );
2986
+ }
2759
2987
  } catch (error) {
2760
2988
  process.stderr.write(`Error: ${error.message}
2761
2989
  `);
@@ -2872,10 +3100,10 @@ function registerTreeCommand(program2) {
2872
3100
  let roots;
2873
3101
  let showProjectName;
2874
3102
  if (options.root?.trim()) {
2875
- const path17 = options.root.trim().replace(/\/$/, "");
2876
- const node = graph.nodes.get(path17);
3103
+ const path18 = options.root.trim().replace(/\/$/, "");
3104
+ const node = graph.nodes.get(path18);
2877
3105
  if (!node) {
2878
- process.stderr.write(`Error: path '${path17}' not found
3106
+ process.stderr.write(`Error: path '${path18}' not found
2879
3107
  `);
2880
3108
  process.exit(1);
2881
3109
  }
@@ -2919,6 +3147,8 @@ function printNode(node, prefix, isLast, depth, maxDepth) {
2919
3147
  }
2920
3148
 
2921
3149
  // src/cli/owner.ts
3150
+ import path14 from "path";
3151
+ import { access as access2 } from "fs/promises";
2922
3152
  function normalizeForMatch(inputPath) {
2923
3153
  return inputPath.replace(/\\/g, "/").replace(/\/+$/, "");
2924
3154
  }
@@ -2929,7 +3159,7 @@ function findOwner(graph, projectRoot, rawPath) {
2929
3159
  const mappingPaths = normalizeMappingPaths(node.meta.mapping).map(normalizeForMatch).filter((mappingPath) => mappingPath.length > 0);
2930
3160
  for (const mappingPath of mappingPaths) {
2931
3161
  if (file === mappingPath) {
2932
- return { file, nodePath, mappingPath };
3162
+ return { file, nodePath, mappingPath, direct: true };
2933
3163
  }
2934
3164
  if (file.startsWith(mappingPath + "/")) {
2935
3165
  if (!best || best && mappingPath.length > best.mappingPath.length) {
@@ -2938,20 +3168,42 @@ function findOwner(graph, projectRoot, rawPath) {
2938
3168
  }
2939
3169
  }
2940
3170
  }
2941
- return best ? { file, nodePath: best.nodePath, mappingPath: best.mappingPath } : { file, nodePath: null };
3171
+ return best ? { file, nodePath: best.nodePath, mappingPath: best.mappingPath, direct: false } : { file, nodePath: null };
2942
3172
  }
2943
3173
  function registerOwnerCommand(program2) {
2944
3174
  program2.command("owner").description("Find which graph node owns a source file").requiredOption("--file <path>", "File path (relative to repository root)").action(async (options) => {
2945
3175
  try {
2946
- const projectRoot = process.cwd();
2947
- const graph = await loadGraph(projectRoot);
2948
- const result = findOwner(graph, projectRoot, options.file);
3176
+ const cwd = process.cwd();
3177
+ const graph = await loadGraph(cwd);
3178
+ const repoRoot = projectRootFromGraph(graph.rootPath);
3179
+ const rawPath = options.file.trim();
3180
+ const absolute = path14.resolve(cwd, rawPath);
3181
+ const repoRelative = path14.relative(repoRoot, absolute).split(path14.sep).join("/");
3182
+ const result = findOwner(graph, repoRoot, repoRelative);
2949
3183
  if (!result.nodePath) {
2950
- process.stdout.write(`${result.file} -> no graph coverage
3184
+ const absPath = path14.resolve(repoRoot, result.file);
3185
+ let exists = true;
3186
+ try {
3187
+ await access2(absPath);
3188
+ } catch {
3189
+ exists = false;
3190
+ }
3191
+ if (exists) {
3192
+ process.stdout.write(`${result.file} -> no graph coverage
3193
+ `);
3194
+ } else {
3195
+ process.stdout.write(`${result.file} -> no graph coverage (file not found)
2951
3196
  `);
3197
+ }
2952
3198
  } else {
2953
3199
  process.stdout.write(`${result.file} -> ${result.nodePath}
2954
3200
  `);
3201
+ if (result.direct === false && result.mappingPath) {
3202
+ process.stdout.write(
3203
+ ` Plik nie ma w\u0142asnego mapowania; kontekst pochodzi z nadrz\u0119dnego katalogu ${result.mappingPath}. U\u017Cyj: yg build-context --node ${result.nodePath}
3204
+ `
3205
+ );
3206
+ }
2955
3207
  }
2956
3208
  } catch (error) {
2957
3209
  process.stderr.write(`Error: ${error.message}
@@ -2963,7 +3215,7 @@ function registerOwnerCommand(program2) {
2963
3215
 
2964
3216
  // src/core/dependency-resolver.ts
2965
3217
  import { execSync } from "child_process";
2966
- import path14 from "path";
3218
+ import path15 from "path";
2967
3219
  var STRUCTURAL_RELATION_TYPES3 = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements"]);
2968
3220
  var EVENT_RELATION_TYPES2 = /* @__PURE__ */ new Set(["emits", "listens"]);
2969
3221
  function filterRelationType(relType, filter) {
@@ -3023,7 +3275,7 @@ function registerDepsCommand(program2) {
3023
3275
  try {
3024
3276
  const graph = await loadGraph(process.cwd());
3025
3277
  const typeFilter = options.type === "structural" || options.type === "event" || options.type === "all" ? options.type : "all";
3026
- const nodePath = options.node.trim().replace(/\/$/, "");
3278
+ const nodePath = options.node.trim().replace(/^\.\//, "").replace(/\/+$/, "");
3027
3279
  const text = formatDependencyTree(graph, nodePath, {
3028
3280
  depth: options.depth,
3029
3281
  relationType: typeFilter
@@ -3040,7 +3292,7 @@ function registerDepsCommand(program2) {
3040
3292
  // src/core/graph-from-git.ts
3041
3293
  import { mkdtemp, rm } from "fs/promises";
3042
3294
  import { tmpdir } from "os";
3043
- import path15 from "path";
3295
+ import path16 from "path";
3044
3296
  import { execSync as execSync2 } from "child_process";
3045
3297
  async function loadGraphFromRef(projectRoot, ref = "HEAD") {
3046
3298
  const yggPath = ".yggdrasil";
@@ -3051,8 +3303,8 @@ async function loadGraphFromRef(projectRoot, ref = "HEAD") {
3051
3303
  return null;
3052
3304
  }
3053
3305
  try {
3054
- tmpDir = await mkdtemp(path15.join(tmpdir(), "ygg-git-"));
3055
- const archivePath = path15.join(tmpDir, "archive.tar");
3306
+ tmpDir = await mkdtemp(path16.join(tmpdir(), "ygg-git-"));
3307
+ const archivePath = path16.join(tmpDir, "archive.tar");
3056
3308
  execSync2(`git archive ${ref} ${yggPath} -o "${archivePath}"`, {
3057
3309
  cwd: projectRoot,
3058
3310
  stdio: "pipe"
@@ -3122,14 +3374,14 @@ function buildTransitiveChains(targetNode, direct, allDependents, reverse) {
3122
3374
  }
3123
3375
  const chains = [];
3124
3376
  for (const node of transitiveOnly) {
3125
- const path17 = [];
3377
+ const path18 = [];
3126
3378
  let current = node;
3127
3379
  while (current) {
3128
- path17.unshift(current);
3380
+ path18.unshift(current);
3129
3381
  current = parent.get(current);
3130
3382
  }
3131
- if (path17.length >= 3) {
3132
- chains.push(path17.slice(1).map((p) => `<- ${p}`).join(" "));
3383
+ if (path18.length >= 3) {
3384
+ chains.push(path18.slice(1).map((p) => `<- ${p}`).join(" "));
3133
3385
  }
3134
3386
  }
3135
3387
  return chains.sort();
@@ -3331,7 +3583,7 @@ function registerImpactCommand(program2) {
3331
3583
  await handleFlowImpact(graph, options.flow.trim(), options.simulate);
3332
3584
  return;
3333
3585
  }
3334
- const nodePath = options.node.trim().replace(/\/$/, "");
3586
+ const nodePath = options.node.trim().replace(/^\.\//, "").replace(/\/+$/, "");
3335
3587
  if (!graph.nodes.has(nodePath)) {
3336
3588
  process.stderr.write(`Node not found: ${nodePath}
3337
3589
  `);
@@ -3463,14 +3715,47 @@ function registerAspectsCommand(program2) {
3463
3715
  });
3464
3716
  }
3465
3717
 
3718
+ // src/cli/flows.ts
3719
+ import { stringify as yamlStringify2 } from "yaml";
3720
+ function registerFlowsCommand(program2) {
3721
+ program2.command("flows").description("List flows with metadata (YAML output)").action(async () => {
3722
+ try {
3723
+ const yggRoot = await findYggRoot(process.cwd());
3724
+ const graph = await loadGraph(yggRoot);
3725
+ const output = graph.flows.sort((a, b) => a.name.localeCompare(b.name)).map((flow) => {
3726
+ const entry = {
3727
+ name: flow.name,
3728
+ participants: flow.nodes.length,
3729
+ nodes: flow.nodes.sort()
3730
+ };
3731
+ if (flow.aspects && flow.aspects.length > 0) entry.aspects = flow.aspects;
3732
+ return entry;
3733
+ });
3734
+ process.stdout.write(yamlStringify2(output));
3735
+ } catch (error) {
3736
+ const err = error;
3737
+ if (err.code === "ENOENT") {
3738
+ process.stderr.write(
3739
+ `Error: No .yggdrasil/ directory found. Run 'yg init' first.
3740
+ `
3741
+ );
3742
+ } else {
3743
+ process.stderr.write(`Error: ${error.message}
3744
+ `);
3745
+ }
3746
+ process.exit(1);
3747
+ }
3748
+ });
3749
+ }
3750
+
3466
3751
  // src/io/journal-store.ts
3467
- import { readFile as readFile13, writeFile as writeFile4, mkdir as mkdir3, rename, access as access2 } from "fs/promises";
3752
+ import { readFile as readFile13, writeFile as writeFile4, mkdir as mkdir3, rename, access as access3 } from "fs/promises";
3468
3753
  import { parse as parseYaml6, stringify as stringifyYaml } from "yaml";
3469
- import path16 from "path";
3754
+ import path17 from "path";
3470
3755
  var JOURNAL_FILE = ".journal.yaml";
3471
3756
  var ARCHIVE_DIR = "journals-archive";
3472
3757
  async function readJournal(yggRoot) {
3473
- const filePath = path16.join(yggRoot, JOURNAL_FILE);
3758
+ const filePath = path17.join(yggRoot, JOURNAL_FILE);
3474
3759
  try {
3475
3760
  const content = await readFile13(filePath, "utf-8");
3476
3761
  const raw = parseYaml6(content);
@@ -3485,26 +3770,26 @@ async function appendJournalEntry(yggRoot, note, target) {
3485
3770
  const at = (/* @__PURE__ */ new Date()).toISOString();
3486
3771
  const entry = target ? { at, target, note } : { at, note };
3487
3772
  entries.push(entry);
3488
- const filePath = path16.join(yggRoot, JOURNAL_FILE);
3773
+ const filePath = path17.join(yggRoot, JOURNAL_FILE);
3489
3774
  const content = stringifyYaml({ entries });
3490
3775
  await writeFile4(filePath, content, "utf-8");
3491
3776
  return entry;
3492
3777
  }
3493
3778
  async function archiveJournal(yggRoot) {
3494
- const journalPath = path16.join(yggRoot, JOURNAL_FILE);
3779
+ const journalPath = path17.join(yggRoot, JOURNAL_FILE);
3495
3780
  try {
3496
- await access2(journalPath);
3781
+ await access3(journalPath);
3497
3782
  } catch {
3498
3783
  return null;
3499
3784
  }
3500
3785
  const entries = await readJournal(yggRoot);
3501
3786
  if (entries.length === 0) return null;
3502
- const archiveDir = path16.join(yggRoot, ARCHIVE_DIR);
3787
+ const archiveDir = path17.join(yggRoot, ARCHIVE_DIR);
3503
3788
  await mkdir3(archiveDir, { recursive: true });
3504
3789
  const now = /* @__PURE__ */ new Date();
3505
3790
  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")}`;
3506
3791
  const archiveName = `.journal.${timestamp}.yaml`;
3507
- const archivePath = path16.join(archiveDir, archiveName);
3792
+ const archivePath = path17.join(archiveDir, archiveName);
3508
3793
  await rename(journalPath, archivePath);
3509
3794
  return { archiveName, entryCount: entries.length };
3510
3795
  }
@@ -3582,14 +3867,13 @@ function registerJournalArchiveCommand(program2) {
3582
3867
 
3583
3868
  // src/cli/preflight.ts
3584
3869
  function registerPreflightCommand(program2) {
3585
- program2.command("preflight").description("Unified diagnostic report: journal, drift, status, validation").action(async () => {
3870
+ program2.command("preflight").description("Unified diagnostic report: journal, drift, status, validation").option("--quick", "Skip drift detection for faster results").action(async (options) => {
3586
3871
  try {
3587
3872
  const cwd = process.cwd();
3588
3873
  const graph = await loadGraph(cwd);
3589
3874
  const yggRoot = await findYggRoot(cwd);
3590
3875
  const journalEntries = await readJournal(yggRoot);
3591
- const drift = await detectDrift(graph);
3592
- const driftedEntries = drift.entries.filter((e) => e.status !== "ok");
3876
+ const driftedEntries = options.quick ? [] : (await detectDrift(graph)).entries.filter((e) => e.status !== "ok");
3593
3877
  const nodeCount = graph.nodes.size;
3594
3878
  const aspectCount = graph.aspects.length;
3595
3879
  const flowCount = graph.flows.length;
@@ -3613,7 +3897,9 @@ function registerPreflightCommand(program2) {
3613
3897
  }
3614
3898
  }
3615
3899
  lines.push("");
3616
- if (driftedEntries.length === 0) {
3900
+ if (options.quick) {
3901
+ lines.push("Drift: skipped (--quick)");
3902
+ } else if (driftedEntries.length === 0) {
3617
3903
  lines.push("Drift: clean");
3618
3904
  } else {
3619
3905
  lines.push(`Drift: ${driftedEntries.length} nodes need attention`);
@@ -3625,6 +3911,12 @@ function registerPreflightCommand(program2) {
3625
3911
  lines.push(
3626
3912
  `Status: ${nodeCount} nodes, ${aspectCount} aspects, ${flowCount} flows, ${mappedPathCount} mapped paths`
3627
3913
  );
3914
+ if (nodeCount === 0) {
3915
+ lines.push("");
3916
+ lines.push(" \u26A1 No nodes found. Enter BOOTSTRAP MODE:");
3917
+ lines.push(" Create nodes under .yggdrasil/model/ for your active work area.");
3918
+ lines.push(" See: yg help build-context");
3919
+ }
3628
3920
  lines.push("");
3629
3921
  if (errors.length === 0 && warnings.length === 0) {
3630
3922
  lines.push("Validation: clean");
@@ -3635,12 +3927,13 @@ function registerPreflightCommand(program2) {
3635
3927
  lines.push(`Validation: ${parts.join(", ")}`);
3636
3928
  for (const issue of [...errors, ...warnings]) {
3637
3929
  const code = issue.code ? `[${issue.code}] ` : "";
3638
- lines.push(` - ${code}${issue.message}`);
3930
+ const loc = issue.nodePath ? `${issue.nodePath} -> ` : "";
3931
+ lines.push(` - ${code}${loc}${issue.message}`);
3639
3932
  }
3640
3933
  }
3641
3934
  lines.push("");
3642
3935
  process.stdout.write(lines.join("\n"));
3643
- const hasIssues = journalEntries.length > 0 || driftedEntries.length > 0 || errors.length > 0;
3936
+ const hasIssues = journalEntries.length > 0 || !options.quick && driftedEntries.length > 0 || errors.length > 0;
3644
3937
  process.exit(hasIssues ? 1 : 0);
3645
3938
  } catch (error) {
3646
3939
  process.stderr.write(`Error: ${error.message}
@@ -3670,6 +3963,7 @@ registerOwnerCommand(program);
3670
3963
  registerDepsCommand(program);
3671
3964
  registerImpactCommand(program);
3672
3965
  registerAspectsCommand(program);
3966
+ registerFlowsCommand(program);
3673
3967
  registerJournalAddCommand(program);
3674
3968
  registerJournalReadCommand(program);
3675
3969
  registerJournalArchiveCommand(program);