@chrisdudek/yg 5.4.0 → 5.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -17,6 +17,17 @@ var DEFAULT_CONFIG = `version: "5.1.0"
17
17
  quality:
18
18
  max_direct_relations: 10
19
19
 
20
+ # Coverage \u2014 which files must belong to a node.
21
+ # Fresh projects start in "require nothing" mode: every unmapped file surfaces as
22
+ # a NON-blocking warning (not a blocking error), so your very first \`yg check\` is
23
+ # green while you adopt incrementally. Add a path prefix to \`required\` (e.g.
24
+ # "src/") to make that area blocking once you start mapping it. NOTE: an ABSENT
25
+ # coverage block defaults to requiring the WHOLE repo \u2014 this explicit empty list
26
+ # is what opts a fresh project into require-nothing.
27
+ coverage:
28
+ required: []
29
+ excluded: []
30
+
20
31
  # Reviewer configuration added by: yg init
21
32
  # (see yg schemas read config + yg knowledge read configuration)
22
33
 
@@ -3890,6 +3901,13 @@ async function ensureYggdrasilGitignore(yggRoot) {
3890
3901
  }
3891
3902
  var API_PROVIDERS = ["anthropic", "openai", "google", "openai-compatible", "ollama"];
3892
3903
  var CLI_PROVIDERS = ["claude-code", "codex", "gemini-cli"];
3904
+ var ALL_PROVIDERS = [...CLI_PROVIDERS, ...API_PROVIDERS];
3905
+ var API_KEY_ENV = {
3906
+ anthropic: "ANTHROPIC_API_KEY",
3907
+ openai: "OPENAI_API_KEY",
3908
+ google: "GOOGLE_API_KEY",
3909
+ "openai-compatible": "OPENAI_API_KEY"
3910
+ };
3893
3911
  var CLAUDE_CODE_ALIASES = [
3894
3912
  { value: "haiku", label: "haiku" },
3895
3913
  { value: "sonnet", label: "sonnet" },
@@ -3984,15 +4002,16 @@ async function promptModelText(provider) {
3984
4002
  async function runReviewerConfigFlow() {
3985
4003
  const provider = await p.select({
3986
4004
  message: "Which provider should verify your code?",
4005
+ initialValue: "claude-code",
3987
4006
  options: [
3988
- { value: "anthropic", label: "Anthropic", hint: "API \u2014 Claude models" },
3989
- { value: "openai", label: "OpenAI", hint: "API \u2014 GPT models" },
3990
- { value: "google", label: "Google", hint: "API \u2014 Gemini models" },
3991
- { value: "ollama", label: "Ollama", hint: "Local \u2014 no API costs" },
3992
- { value: "openai-compatible", label: "OpenAI-compatible", hint: "API \u2014 custom endpoint" },
3993
- { value: "claude-code", label: "Claude Code", hint: "CLI \u2014 uses installed claude" },
3994
- { value: "codex", label: "Codex", hint: "CLI \u2014 uses installed codex" },
3995
- { value: "gemini-cli", label: "Gemini CLI", hint: "CLI \u2014 uses installed gemini" }
4007
+ { value: "claude-code", label: "Claude Code", hint: "CLI \u2014 free, no API key; uses installed claude" },
4008
+ { value: "codex", label: "Codex", hint: "CLI \u2014 free, no API key; uses installed codex" },
4009
+ { value: "gemini-cli", label: "Gemini CLI", hint: "CLI \u2014 free, no API key; uses installed gemini" },
4010
+ { value: "ollama", label: "Ollama", hint: "Local \u2014 no API costs; needs a local install" },
4011
+ { value: "anthropic", label: "Anthropic", hint: "API key \u2014 Claude models" },
4012
+ { value: "openai", label: "OpenAI", hint: "API key \u2014 GPT models" },
4013
+ { value: "google", label: "Google", hint: "API key \u2014 Gemini models" },
4014
+ { value: "openai-compatible", label: "OpenAI-compatible", hint: "API key \u2014 custom endpoint" }
3996
4015
  ]
3997
4016
  });
3998
4017
  assertNotCancelled(provider);
@@ -4123,9 +4142,9 @@ async function freshInit(projectRoot) {
4123
4142
  const yggRoot = path14.join(projectRoot, ".yggdrasil");
4124
4143
  if (!isTTY()) {
4125
4144
  process.stderr.write(chalk2.red(`Error: ${buildIssueMessage({
4126
- what: "yg init requires an interactive terminal.",
4127
- why: "Setup requires interactive prompts to configure platform and reviewer.",
4128
- next: "Run yg init in an interactive terminal session."
4145
+ what: "yg init requires an interactive terminal (no --provider given).",
4146
+ why: "The interactive wizard needs prompts to configure platform and reviewer. Docker, devcontainer, and CI runs have no TTY.",
4147
+ next: `Run yg init in an interactive terminal, OR bootstrap non-interactively: yg init --platform <name> --provider <name> [--model <m>] [--endpoint <url>]. Supported providers: ${ALL_PROVIDERS.join(", ")}.`
4129
4148
  })}
4130
4149
  `));
4131
4150
  process.exit(1);
@@ -4139,7 +4158,7 @@ async function freshInit(projectRoot) {
4139
4158
  const platform = await promptPlatform();
4140
4159
  p.log.step("Step 2: Reviewer provider");
4141
4160
  p.log.info(
4142
- "The reviewer checks your source code against aspect rules during yg check --approve.\n API providers make HTTP calls. CLI providers delegate to an installed agent.\n For local review without API costs, use Ollama."
4161
+ "The reviewer checks your source code against aspect rules during yg check --approve.\n If you already run an agent CLI (Claude Code, Codex, Gemini), pick it \u2014 the\n reviewer then needs no API key and adds no separate API bill. Ollama runs\n locally with no API cost. API providers (Anthropic, OpenAI, Google) need a key."
4143
4162
  );
4144
4163
  const reviewerConfig = await runReviewerConfigFlow();
4145
4164
  await createYggdrasilStructure(projectRoot, yggRoot, platform);
@@ -4159,6 +4178,54 @@ async function createYggdrasilStructure(projectRoot, yggRoot, platform) {
4159
4178
  await ensureYggdrasilGitignore(yggRoot);
4160
4179
  await installRulesForPlatform(projectRoot, platform);
4161
4180
  }
4181
+ async function freshInitNonInteractive(projectRoot, yggRoot, opts) {
4182
+ const { platform, provider } = opts;
4183
+ const model = opts.model?.trim();
4184
+ if (!model) {
4185
+ process.stderr.write(chalk2.red(`Error: ${buildIssueMessage({
4186
+ what: "--model is required for non-interactive init.",
4187
+ why: "Non-interactive init records the reviewer model verbatim and applies no default \u2014 the model must be named explicitly.",
4188
+ next: `Re-run naming a model: yg init --platform ${platform} --provider ${provider} --model <name>.`
4189
+ })}
4190
+ `));
4191
+ process.exit(1);
4192
+ }
4193
+ let endpoint = opts.endpoint?.trim() || void 0;
4194
+ if (needsEndpoint(provider) && !endpoint) {
4195
+ if (provider === "ollama") {
4196
+ endpoint = "http://localhost:11434";
4197
+ } else {
4198
+ process.stderr.write(chalk2.red(`Error: ${buildIssueMessage({
4199
+ what: `--endpoint is required for provider '${provider}'.`,
4200
+ why: "An OpenAI-compatible provider has no default base URL \u2014 the reviewer needs an endpoint to call.",
4201
+ next: `Re-run naming an endpoint: yg init --platform ${platform} --provider ${provider} --model ${model} --endpoint <url>.`
4202
+ })}
4203
+ `));
4204
+ process.exit(1);
4205
+ }
4206
+ }
4207
+ await createYggdrasilStructure(projectRoot, yggRoot, platform);
4208
+ await writeReviewerConfig(yggRoot, { provider, model, endpoint });
4209
+ if (needsApiKey(provider)) {
4210
+ const envVar = API_KEY_ENV[provider];
4211
+ const apiKey = (envVar ? process.env[envVar] : void 0)?.trim();
4212
+ if (apiKey) {
4213
+ await writeSecretsFile(yggRoot, apiKey);
4214
+ } else {
4215
+ process.stdout.write(chalk2.yellow(`${buildIssueMessage({
4216
+ what: `No API key found${envVar ? ` in $${envVar}` : ""}; wrote the config without one.`,
4217
+ why: "An API provider needs a key before the reviewer can run; init records the config anyway so setup is not blocked.",
4218
+ next: `Set ${envVar ?? "the provider API key environment variable"} (or add the key to .yggdrasil/yg-secrets.yaml) before running yg check --approve.`
4219
+ })}
4220
+ `));
4221
+ }
4222
+ }
4223
+ await ensureGitattributes(projectRoot);
4224
+ process.stdout.write(chalk2.green(
4225
+ `Yggdrasil initialized (platform: ${platform}, provider: ${provider}, model: ${model}). Run yg check to get started.
4226
+ `
4227
+ ));
4228
+ }
4162
4229
  async function runVersionUpgrade2(projectRoot, yggRoot, platform) {
4163
4230
  const { migrationActions, migrationWarnings, withheld } = await runVersionUpgrade({
4164
4231
  yggRoot,
@@ -4249,7 +4316,7 @@ async function existingInit(projectRoot) {
4249
4316
  }
4250
4317
  }
4251
4318
  function registerInitCommand(program2) {
4252
- program2.command("init").description("Initialize Yggdrasil graph in current project").option("--upgrade", "Non-interactive: refresh rules").option("--platform <name>", `Platform for rules file (${PLATFORMS.join(", ")})`).action(async (options) => {
4319
+ program2.command("init").description("Initialize Yggdrasil graph in current project").option("--upgrade", "Non-interactive: refresh rules").option("--platform <name>", `Platform for rules file (${PLATFORMS.join(", ")})`).option("--provider <name>", `Non-interactive fresh init: reviewer provider (${ALL_PROVIDERS.join(", ")})`).option("--model <name>", "Non-interactive fresh init: reviewer model (required)").option("--endpoint <url>", "Non-interactive fresh init: reviewer endpoint (ollama / openai-compatible)").action(async (options) => {
4253
4320
  try {
4254
4321
  const projectRoot = process.cwd();
4255
4322
  const yggRoot = path14.join(projectRoot, ".yggdrasil");
@@ -4359,6 +4426,40 @@ function registerInitCommand(program2) {
4359
4426
  }
4360
4427
  if (exists) {
4361
4428
  await existingInit(projectRoot);
4429
+ } else if (options.provider) {
4430
+ if (!options.platform) {
4431
+ process.stderr.write(chalk2.red(`Error: ${buildIssueMessage({
4432
+ what: "--provider requires --platform for non-interactive init.",
4433
+ why: "A fresh graph must install the rules file for a specific agent platform; there is no prompt to ask in non-interactive mode.",
4434
+ next: `Pass --platform <name>. Supported: ${PLATFORMS.join(", ")}.`
4435
+ })}
4436
+ `));
4437
+ process.exit(1);
4438
+ }
4439
+ if (!PLATFORMS.includes(options.platform)) {
4440
+ process.stderr.write(chalk2.red(`Error: ${buildIssueMessage({
4441
+ what: `Unknown platform '${options.platform}'.`,
4442
+ why: "The --platform value must match one of the supported agent platforms.",
4443
+ next: `Use one of: ${PLATFORMS.join(", ")}`
4444
+ })}
4445
+ `));
4446
+ process.exit(1);
4447
+ }
4448
+ if (!ALL_PROVIDERS.includes(options.provider)) {
4449
+ process.stderr.write(chalk2.red(`Error: ${buildIssueMessage({
4450
+ what: `Unknown provider '${options.provider}'.`,
4451
+ why: "The --provider value must match one of the supported reviewer providers.",
4452
+ next: `Use one of: ${ALL_PROVIDERS.join(", ")}`
4453
+ })}
4454
+ `));
4455
+ process.exit(1);
4456
+ }
4457
+ await freshInitNonInteractive(projectRoot, yggRoot, {
4458
+ platform: options.platform,
4459
+ provider: options.provider,
4460
+ model: options.model,
4461
+ endpoint: options.endpoint
4462
+ });
4362
4463
  } else {
4363
4464
  await freshInit(projectRoot);
4364
4465
  }
@@ -16056,6 +16157,7 @@ async function runFill(graph, opts) {
16056
16157
  }
16057
16158
  const detPairs = unverifiedPairs.filter((p2) => p2.kind === "deterministic");
16058
16159
  const llmPairs = onlyDeterministic ? [] : unverifiedPairs.filter((p2) => p2.kind === "llm");
16160
+ const skippedLlmPairs = onlyDeterministic ? unverifiedPairs.filter((p2) => p2.kind === "llm").length : 0;
16059
16161
  const aspectById = /* @__PURE__ */ new Map();
16060
16162
  for (const a of graph.aspects) aspectById.set(a.id, a);
16061
16163
  const deterministicAspectIds = new Set(
@@ -16073,6 +16175,12 @@ async function runFill(graph, opts) {
16073
16175
  `Filling ${unverifiedPairs.length} unverified pairs across ${nodeSet.size} nodes \u2014 ${detPairs.length} deterministic (no cost), ${reviewerCallBudget} reviewer calls (consensus included)
16074
16176
  `
16075
16177
  );
16178
+ if (skippedLlmPairs > 0) {
16179
+ write(
16180
+ ` Deterministic-only mode \u2014 ${skippedLlmPairs} LLM pair${skippedLlmPairs === 1 ? "" : "s"} will NOT be reviewed this run; run \`yg check --approve\` to review ${skippedLlmPairs === 1 ? "it" : "them"}.
16181
+ `
16182
+ );
16183
+ }
16076
16184
  if (dryRun) {
16077
16185
  const byNode = /* @__PURE__ */ new Map();
16078
16186
  for (const p2 of unverifiedPairs) {
@@ -16113,7 +16221,7 @@ async function runFill(graph, opts) {
16113
16221
  return writeChain;
16114
16222
  };
16115
16223
  const setEntry = async (aspectId, unitKey, entry) => {
16116
- (lock.verdicts[aspectId] ??= {})[unitKey] = entry;
16224
+ (lock.verdicts[aspectId] ??= {})[toPosixPath(unitKey)] = entry;
16117
16225
  await persistLock();
16118
16226
  };
16119
16227
  const blockedNodes = /* @__PURE__ */ new Set();
@@ -16274,7 +16382,14 @@ async function runFill(graph, opts) {
16274
16382
  }
16275
16383
  await garbageCollectAndRewrite(graph, lock, persistLock);
16276
16384
  if (reviewerCallsMade === 0 && infraFailures === 0 && runtimeErrors === 0 && companionRuntimeErrors === 0) {
16277
- write("0 reviewer calls made \u2014 all expected pairs hold valid verdicts\n");
16385
+ if (skippedLlmPairs > 0) {
16386
+ write(
16387
+ `0 reviewer calls made \u2014 deterministic-only mode; ${skippedLlmPairs} LLM pair${skippedLlmPairs === 1 ? "" : "s"} left unverified. Run \`yg check --approve\` to review ${skippedLlmPairs === 1 ? "it" : "them"}.
16388
+ `
16389
+ );
16390
+ } else {
16391
+ write("0 reviewer calls made \u2014 all expected pairs hold valid verdicts\n");
16392
+ }
16278
16393
  }
16279
16394
  if (infraFailures > 0) {
16280
16395
  const providers = [...new Set(infraReport.map((r) => r.provider).filter(Boolean))].join(", ");
@@ -16608,6 +16723,10 @@ function resolveTopValue(raw) {
16608
16723
  if (Number.isNaN(n) || n < 1) return null;
16609
16724
  return n;
16610
16725
  }
16726
+ function nextPointer(next) {
16727
+ const firstLine = next.split("\n")[0];
16728
+ return firstLine.trimEnd().endsWith(":") ? next : firstLine;
16729
+ }
16611
16730
  function residualAfterNext(result) {
16612
16731
  if (!result.suggestedNext?.startsWith("yg check --approve")) return "";
16613
16732
  const errors = result.issues.filter((i) => i.severity === "error");
@@ -16656,7 +16775,7 @@ function formatOutput(result, view = { kind: "full" }, autoFilled = false, emoji
16656
16775
  }
16657
16776
  const firstFiltered = [...filteredErrors, ...filteredWarnings][0];
16658
16777
  if (firstFiltered?.messageData.next) {
16659
- const nextCmd = firstFiltered.messageData.next.split("\n")[0];
16778
+ const nextCmd = nextPointer(firstFiltered.messageData.next);
16660
16779
  sections.push("");
16661
16780
  sections.push(`Next (this group): ${nextCmd}`);
16662
16781
  sections.push("");
@@ -16686,7 +16805,7 @@ function formatOutput(result, view = { kind: "full" }, autoFilled = false, emoji
16686
16805
  }
16687
16806
  }
16688
16807
  if (result.suggestedNext) {
16689
- const nextCmd = result.suggestedNext.split("\n")[0];
16808
+ const nextCmd = nextPointer(result.suggestedNext);
16690
16809
  const residual = view.kind === "full" || view.kind === "details" ? residualAfterNext(result) : "";
16691
16810
  sections.push("");
16692
16811
  sections.push(`Next: ${nextCmd}${residual}`);
@@ -21045,8 +21164,11 @@ cached and FINAL for unchanged inputs. Interrupting is safe \u2014 finished pair
21045
21164
  persist, the next run resumes.
21046
21165
 
21047
21166
  When nothing was unverified, the summary says \`0 reviewer calls made \u2014 all
21048
- expected pairs hold valid verdicts\`. Use \`yg impact\` to predict cost before
21049
- editing.
21167
+ expected pairs hold valid verdicts\`. Under \`--only-deterministic\` the header and
21168
+ summary instead name the LLM pairs left unverified \u2014 they are skipped by design,
21169
+ not reviewed \u2014 and point at a full \`yg check --approve\` to review them, so a
21170
+ deterministic-only run never reads as if it verified everything. Use \`yg impact\`
21171
+ to predict cost before editing.
21050
21172
 
21051
21173
  \`--dry-run\` (with \`--approve\`) is a free cost preview: it runs the same
21052
21174
  structural gate, pair classification, and budget computation, prints the
@@ -23135,16 +23257,6 @@ async function computePortalFreshness(graph, lock) {
23135
23257
  }
23136
23258
 
23137
23259
  // src/portal/derive-node-state.ts
23138
- var PAIR_RANK = {
23139
- refused: 4,
23140
- unverified: 3,
23141
- warning: 2,
23142
- verified: 1,
23143
- "n/a": 0
23144
- };
23145
- function worstPairState(states) {
23146
- return states.reduce((worst, s) => PAIR_RANK[s] > PAIR_RANK[worst] ? s : worst, "verified");
23147
- }
23148
23260
  var STATE_RANK = {
23149
23261
  refused: 4,
23150
23262
  unverified: 3,
@@ -23196,6 +23308,18 @@ function computeNotApplicable(node, graph, effectiveIds) {
23196
23308
  return out;
23197
23309
  }
23198
23310
 
23311
+ // src/portal/derive-pair-state.ts
23312
+ var PAIR_RANK = {
23313
+ refused: 4,
23314
+ unverified: 3,
23315
+ warning: 2,
23316
+ verified: 1,
23317
+ "n/a": 0
23318
+ };
23319
+ function worstPairState(states) {
23320
+ return states.reduce((worst, s) => PAIR_RANK[s] > PAIR_RANK[worst] ? s : worst, "verified");
23321
+ }
23322
+
23199
23323
  // src/portal/derive-nodes.ts
23200
23324
  function displayPairState(rawKind, status) {
23201
23325
  if (rawKind === "verified") return "verified";
@@ -23513,28 +23637,6 @@ function buildTypes(graph) {
23513
23637
  }
23514
23638
 
23515
23639
  // src/portal/derive-rest.ts
23516
- function buildBoundary(input) {
23517
- if (input === null) {
23518
- return { phantom: [], declaredOnly: [], forbiddenType: [], unknown: true };
23519
- }
23520
- return {
23521
- phantom: dedupeEdges(input.phantom),
23522
- declaredOnly: dedupeEdges(input.declaredOnly),
23523
- forbiddenType: dedupeEdges(input.forbiddenType),
23524
- unknown: false
23525
- };
23526
- }
23527
- function dedupeEdges(edges) {
23528
- const seen = /* @__PURE__ */ new Set();
23529
- const out = [];
23530
- for (const e of edges) {
23531
- const key = `${e.source}\0${e.target}`;
23532
- if (seen.has(key)) continue;
23533
- seen.add(key);
23534
- out.push(e);
23535
- }
23536
- return out.sort((a, b) => a.source.localeCompare(b.source, "en") || a.target.localeCompare(b.target, "en"));
23537
- }
23538
23640
  function buildSuppressions(markers) {
23539
23641
  return markers.map((m) => ({
23540
23642
  aspectId: m.aspectId,
@@ -23576,6 +23678,30 @@ function buildWorklist(check) {
23576
23678
  }));
23577
23679
  }
23578
23680
 
23681
+ // src/portal/derive-boundary.ts
23682
+ function buildBoundary(input) {
23683
+ if (input === null) {
23684
+ return { phantom: [], declaredOnly: [], forbiddenType: [], unknown: true };
23685
+ }
23686
+ return {
23687
+ phantom: dedupeEdges(input.phantom),
23688
+ declaredOnly: dedupeEdges(input.declaredOnly),
23689
+ forbiddenType: dedupeEdges(input.forbiddenType),
23690
+ unknown: false
23691
+ };
23692
+ }
23693
+ function dedupeEdges(edges) {
23694
+ const seen = /* @__PURE__ */ new Set();
23695
+ const out = [];
23696
+ for (const e of edges) {
23697
+ const key = `${e.source}\0${e.target}`;
23698
+ if (seen.has(key)) continue;
23699
+ seen.add(key);
23700
+ out.push(e);
23701
+ }
23702
+ return out.sort((a, b) => a.source.localeCompare(b.source, "en") || a.target.localeCompare(b.target, "en"));
23703
+ }
23704
+
23579
23705
  // src/portal/extract.ts
23580
23706
  async function extractPortalData(projectRoot, opts) {
23581
23707
  const graph = await loadPortalGraph(projectRoot);
@@ -23829,6 +23955,78 @@ async function readPortalAsset(relPath) {
23829
23955
  // src/portal/server/server.ts
23830
23956
  import { createServer } from "http";
23831
23957
 
23958
+ // src/portal/server/boot-pages.ts
23959
+ var BOOT_PAGE_CSS = `
23960
+ :root{color-scheme:light dark;--yg-bg:#fcfcfd;--yg-surface:#f9f9fb;--yg-fg:#1c2024;--yg-muted:#60646c;--yg-accent:#0090ff;--yg-border:#cdced6}
23961
+ @media (prefers-color-scheme:dark){:root{--yg-bg:#111113;--yg-surface:#18191b;--yg-fg:#edeef0;--yg-muted:#b0b4ba;--yg-accent:#3b9eff;--yg-border:#43484e}}
23962
+ html,body{height:100%}
23963
+ body{margin:0;background:var(--yg-bg);color:var(--yg-fg);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;display:grid;place-items:center;padding:24px;box-sizing:border-box}
23964
+ .yg-box{display:flex;flex-direction:column;align-items:center;gap:14px;text-align:center;max-width:56ch}
23965
+ .yg-title{font-weight:600;font-size:16px}
23966
+ .yg-sub{color:var(--yg-muted);font-size:13.5px;max-width:46ch}
23967
+ .yg-sub code,.yg-detail{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
23968
+ .yg-sub code{font-size:12px;background:var(--yg-surface);border:1px solid var(--yg-border);border-radius:4px;padding:1px 5px}`;
23969
+ function renderLoadingShell() {
23970
+ return `<!doctype html>
23971
+ <html lang="en">
23972
+ <head>
23973
+ <meta charset="utf-8" />
23974
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
23975
+ <title>Yggdrasil Portal</title>
23976
+ <style>${BOOT_PAGE_CSS}
23977
+ .yg-spinner{width:34px;height:34px;border-radius:50%;border:3px solid var(--yg-border);border-top-color:var(--yg-accent);animation:yg-spin .8s linear infinite}
23978
+ @keyframes yg-spin{to{transform:rotate(360deg)}}
23979
+ @media (prefers-reduced-motion:reduce){.yg-spinner{animation-duration:2.4s}}
23980
+ </style>
23981
+ </head>
23982
+ <body>
23983
+ <div class="yg-box" id="yg-boot">
23984
+ <div class="yg-spinner" role="status" aria-label="Loading"></div>
23985
+ <div class="yg-title">Reading your architecture\u2026</div>
23986
+ <div class="yg-sub">Checking the graph and rendering the portal. This can take a moment on a large project.</div>
23987
+ </div>
23988
+ <script>
23989
+ (function(){
23990
+ fetch('/render' + location.search, { headers: { accept: 'text/html' } })
23991
+ .then(function(r){ return r.text(); })
23992
+ .then(function(html){ document.open(); document.write(html); document.close(); })
23993
+ .catch(function(){
23994
+ var b = document.getElementById('yg-boot');
23995
+ if (b) { b.innerHTML =
23996
+ '<div class="yg-title">Couldn\\u2019t reach the portal</div>' +
23997
+ '<div class="yg-sub">The local portal process may have stopped. Restart it in your terminal, then reload this page.</div>'; }
23998
+ });
23999
+ })();
24000
+ </script>
24001
+ </body>
24002
+ </html>
24003
+ `;
24004
+ }
24005
+ function renderErrorPage(message) {
24006
+ return `<!doctype html>
24007
+ <html lang="en">
24008
+ <head>
24009
+ <meta charset="utf-8" />
24010
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
24011
+ <title>Yggdrasil Portal \u2014 couldn\u2019t load</title>
24012
+ <style>${BOOT_PAGE_CSS}
24013
+ .yg-detail{margin-top:4px;max-width:74ch;text-align:left;white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.5;color:var(--yg-muted);background:var(--yg-surface);border:1px solid var(--yg-border);border-radius:8px;padding:12px 14px}
24014
+ </style>
24015
+ </head>
24016
+ <body>
24017
+ <div class="yg-box">
24018
+ <div class="yg-title">The portal couldn\u2019t load your architecture</div>
24019
+ <div class="yg-sub">Something went wrong while reading the graph and building the page. Make sure this is a project with a <code>.yggdrasil/</code> graph, then reload. If it keeps happening, run <code>yg check</code> in your terminal to see the underlying problem.</div>
24020
+ <div class="yg-detail">${escapeHtmlText(message)}</div>
24021
+ </div>
24022
+ </body>
24023
+ </html>
24024
+ `;
24025
+ }
24026
+ function escapeHtmlText(s) {
24027
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
24028
+ }
24029
+
23832
24030
  // src/portal/server/page.ts
23833
24031
  async function freshPortalData(projectRoot, writeEnabled) {
23834
24032
  return extractPortalData(projectRoot, { writeEnabled });
@@ -23836,6 +24034,12 @@ async function freshPortalData(projectRoot, writeEnabled) {
23836
24034
  async function renderLivePage(data) {
23837
24035
  return renderPortalPage(data);
23838
24036
  }
24037
+ function loadingShell() {
24038
+ return renderLoadingShell();
24039
+ }
24040
+ function errorPage(message) {
24041
+ return renderErrorPage(message);
24042
+ }
23839
24043
  async function readStaticAsset(relPath) {
23840
24044
  return readPortalAsset(relPath);
23841
24045
  }
@@ -23927,9 +24131,18 @@ async function handleRequest(req, res, config) {
23927
24131
  const pathname = url.pathname;
23928
24132
  try {
23929
24133
  if (method === "GET" && pathname === "/") {
23930
- const data = await freshPortalData(config.projectRoot, config.writeEnabled);
23931
- const html = await renderLivePage(data);
23932
- sendText(res, 200, "text/html; charset=utf-8", html);
24134
+ sendText(res, 200, "text/html; charset=utf-8", loadingShell());
24135
+ return;
24136
+ }
24137
+ if (method === "GET" && pathname === "/render") {
24138
+ try {
24139
+ const data = await freshPortalData(config.projectRoot, config.writeEnabled);
24140
+ const html = await renderLivePage(data);
24141
+ sendText(res, 200, "text/html; charset=utf-8", html);
24142
+ } catch (err) {
24143
+ const message = err instanceof Error ? err.message : String(err);
24144
+ sendText(res, 500, "text/html; charset=utf-8", errorPage(message));
24145
+ }
23933
24146
  return;
23934
24147
  }
23935
24148
  if (method === "GET" && pathname === "/data") {