@intentius/behold 0.11.0 → 0.13.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/cli.js CHANGED
@@ -716,11 +716,11 @@ async function createK8sClient(options = {}) {
716
716
  }
717
717
  return void 0;
718
718
  }
719
- async function resolve8(selector, signal) {
719
+ async function resolve9(selector, signal) {
720
720
  return "apiVersion" in selector ? resolveByGvk(selector.apiVersion, selector.kind, signal) : resolveByResourceString(selector.resource, selector.group, signal);
721
721
  }
722
722
  async function resolveOrThrow(selector, signal) {
723
- const info = await resolve8(selector, signal);
723
+ const info = await resolve9(selector, signal);
724
724
  if (!info) throw new UnknownResourceError(selectorText(selector));
725
725
  return info;
726
726
  }
@@ -732,7 +732,7 @@ async function createK8sClient(options = {}) {
732
732
  return parts.join("/");
733
733
  }
734
734
  async function pathFor(ref, signal) {
735
- const info = await resolve8({ apiVersion: ref.apiVersion, kind: ref.kind }, signal);
735
+ const info = await resolve9({ apiVersion: ref.apiVersion, kind: ref.kind }, signal);
736
736
  if (!info) return void 0;
737
737
  return objectPath(info, ref.name, ref.namespace);
738
738
  }
@@ -847,7 +847,7 @@ async function createK8sClient(options = {}) {
847
847
  return {
848
848
  provenance,
849
849
  defaultNamespace,
850
- resolve: resolve8,
850
+ resolve: resolve9,
851
851
  read,
852
852
  pathFor,
853
853
  readIfPresent,
@@ -1038,8 +1038,8 @@ var init_src = __esm({
1038
1038
  });
1039
1039
 
1040
1040
  // src/cli.ts
1041
- import { resolve as resolve7, dirname as dirname6, join as join18 } from "node:path";
1042
- import { realpathSync, existsSync as existsSync14, readFileSync as readFileSync15, rmSync as rmSync2 } from "node:fs";
1041
+ import { resolve as resolve8, dirname as dirname7, join as join20 } from "node:path";
1042
+ import { realpathSync, existsSync as existsSync14, readFileSync as readFileSync16, rmSync as rmSync2 } from "node:fs";
1043
1043
  import { fileURLToPath as fileURLToPath5 } from "node:url";
1044
1044
  import { spawn as spawn6, spawnSync as spawnSync2 } from "node:child_process";
1045
1045
 
@@ -1120,8 +1120,8 @@ import { serve } from "@hono/node-server";
1120
1120
  import { fileURLToPath as fileURLToPath3 } from "node:url";
1121
1121
  import { execFile as execFile3 } from "node:child_process";
1122
1122
  import { promisify as promisify3 } from "node:util";
1123
- import { existsSync as existsSync13, readFileSync as readFileSync13 } from "node:fs";
1124
- import { basename, dirname as dirname4, join as join16, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
1123
+ import { existsSync as existsSync13, readFileSync as readFileSync14 } from "node:fs";
1124
+ import { basename, dirname as dirname5, join as join18, relative as relative4, resolve as resolve6, sep as sep4 } from "node:path";
1125
1125
 
1126
1126
  // src/recents.ts
1127
1127
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
@@ -1310,6 +1310,8 @@ function detectProjectShape(projectDir) {
1310
1310
  }
1311
1311
 
1312
1312
  // node_modules/@intentius/chant/src/yaml.ts
1313
+ var KEY_LINE = /^(\s*)([^\s:][^:]*?):(?=\s|$)\s*(.*)$/;
1314
+ var ITEM_KEY = /^([^\s:][^:]*?):(?=\s|$)\s*(.*)$/;
1313
1315
  function parseYAML(content) {
1314
1316
  try {
1315
1317
  return JSON.parse(content);
@@ -1385,7 +1387,7 @@ function parseYAMLLines(lines, startIndex, baseIndent) {
1385
1387
  const indent = line.search(/\S/);
1386
1388
  if (indent < baseIndent) break;
1387
1389
  if (indent > baseIndent && startIndex > 0) break;
1388
- const keyMatch = line.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
1390
+ const keyMatch = line.match(KEY_LINE);
1389
1391
  if (keyMatch) {
1390
1392
  const key = keyMatch[2].trim();
1391
1393
  const inlineValue = keyMatch[3].trim();
@@ -1518,7 +1520,7 @@ function parseYAMLArray(lines, startIndex, baseIndent) {
1518
1520
  if (itemMatch && indent === baseIndent) {
1519
1521
  const itemValue = itemMatch[2].trim();
1520
1522
  const isQuotedScalar = itemValue.startsWith('"') && itemValue.endsWith('"') || itemValue.startsWith("'") && itemValue.endsWith("'");
1521
- const kvMatch = !isQuotedScalar && itemValue.match(/^([^\s:][^:]*?):\s*(.*)$/);
1523
+ const kvMatch = !isQuotedScalar && itemValue.match(ITEM_KEY);
1522
1524
  if (kvMatch) {
1523
1525
  const obj = {};
1524
1526
  obj[kvMatch[1].trim()] = parseArrayItemValue(kvMatch[2].trim(), lines, i, indent + 2);
@@ -1534,7 +1536,7 @@ function parseYAMLArray(lines, startIndex, baseIndent) {
1534
1536
  const ni = nextLine.search(/\S/);
1535
1537
  if (ni < nextIndent) break;
1536
1538
  if (ni > nextIndent) break;
1537
- const nextKV = nextLine.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
1539
+ const nextKV = nextLine.match(KEY_LINE);
1538
1540
  if (nextKV) {
1539
1541
  const nextVal = nextKV[3].trim();
1540
1542
  obj[nextKV[2].trim()] = parseArrayItemValue(nextVal, lines, j, ni);
@@ -1572,13 +1574,68 @@ function parseScalar(value) {
1572
1574
  if (value === "" || value === "~" || value === "null") return null;
1573
1575
  if (value === "true" || value === "yes") return true;
1574
1576
  if (value === "false" || value === "no") return false;
1575
- if (value.startsWith("'") && value.endsWith("'") || value.startsWith('"') && value.endsWith('"')) {
1576
- return value.slice(1, -1);
1577
+ if (value.length >= 2) {
1578
+ if (value.startsWith('"') && value.endsWith('"')) {
1579
+ const unescaped = unescapeDoubleQuoted(value.slice(1, -1));
1580
+ return unescaped ?? value.slice(1, -1);
1581
+ }
1582
+ if (value.startsWith("'") && value.endsWith("'")) {
1583
+ return value.slice(1, -1).replace(/''/g, "'");
1584
+ }
1577
1585
  }
1578
1586
  const num = Number(value);
1579
1587
  if (!isNaN(num) && value !== "") return num;
1580
1588
  return value;
1581
1589
  }
1590
+ var DQ_ESCAPES = {
1591
+ "0": "\0",
1592
+ a: "\x07",
1593
+ b: "\b",
1594
+ t: " ",
1595
+ " ": " ",
1596
+ n: "\n",
1597
+ v: "\v",
1598
+ f: "\f",
1599
+ r: "\r",
1600
+ e: "\x1B",
1601
+ " ": " ",
1602
+ '"': '"',
1603
+ "/": "/",
1604
+ "\\": "\\",
1605
+ N: "\x85",
1606
+ _: "\xA0",
1607
+ L: "\u2028",
1608
+ P: "\u2029"
1609
+ };
1610
+ function unescapeDoubleQuoted(body) {
1611
+ let out = "";
1612
+ for (let i = 0; i < body.length; i++) {
1613
+ const ch = body[i];
1614
+ if (ch === '"') return null;
1615
+ if (ch !== "\\") {
1616
+ out += ch;
1617
+ continue;
1618
+ }
1619
+ const esc = body[++i];
1620
+ if (esc === void 0) return null;
1621
+ const width = esc === "x" ? 2 : esc === "u" ? 4 : esc === "U" ? 8 : 0;
1622
+ if (width > 0) {
1623
+ const hex = body.slice(i + 1, i + 1 + width);
1624
+ if (hex.length < width || !/^[0-9a-fA-F]+$/.test(hex)) return null;
1625
+ out += String.fromCodePoint(parseInt(hex, 16));
1626
+ i += width;
1627
+ continue;
1628
+ }
1629
+ if (esc === "\n") {
1630
+ while (body[i + 1] === " " || body[i + 1] === " ") i++;
1631
+ continue;
1632
+ }
1633
+ const mapped = DQ_ESCAPES[esc];
1634
+ if (mapped === void 0) return null;
1635
+ out += mapped;
1636
+ }
1637
+ return out;
1638
+ }
1582
1639
 
1583
1640
  // src/chant.ts
1584
1641
  function envOverridesFor(opts) {
@@ -1836,6 +1893,25 @@ function lifecycleDiffArgs(env) {
1836
1893
  function lifecycleDiffLive(projectDir, env, opts = {}) {
1837
1894
  return runChantJson(lifecycleDiffArgs(env), projectDir, envOverridesFor(opts));
1838
1895
  }
1896
+ function helmRendersArgs() {
1897
+ return ["helm", "renders", "--json"];
1898
+ }
1899
+ function helmRenders(projectDir, opts = {}) {
1900
+ return runChantJson(helmRendersArgs(), projectDir, envOverridesFor(opts));
1901
+ }
1902
+ function helmRenderDiffArgs(contentDigest, env) {
1903
+ return ["helm", "diff", contentDigest, env, "--live", "--json"];
1904
+ }
1905
+ async function helmRenderDiffLive(projectDir, contentDigest, env, opts = {}) {
1906
+ const args = helmRenderDiffArgs(contentDigest, env);
1907
+ const { code, stdout } = await runChantRaw(args, projectDir, envOverridesFor(opts));
1908
+ if (code !== 0) return void 0;
1909
+ try {
1910
+ return JSON.parse(stdout);
1911
+ } catch {
1912
+ return void 0;
1913
+ }
1914
+ }
1839
1915
  function applyArgs(target, env) {
1840
1916
  return ["run", target, "--components", "--env", env, "--progress-json"];
1841
1917
  }
@@ -2042,19 +2118,19 @@ function finishPipelineProgress(state, exitCode) {
2042
2118
  }
2043
2119
 
2044
2120
  // src/gh-run.ts
2045
- var defaultGhExec = (args) => new Promise((resolve8) => {
2121
+ var defaultGhExec = (args) => new Promise((resolve9) => {
2046
2122
  let out = "";
2047
2123
  let proc;
2048
2124
  try {
2049
2125
  proc = spawn2("gh", args, { stdio: ["ignore", "pipe", "pipe"] });
2050
2126
  } catch {
2051
- resolve8({ code: 127, out: "" });
2127
+ resolve9({ code: 127, out: "" });
2052
2128
  return;
2053
2129
  }
2054
2130
  proc.stdout.on("data", (d) => out += d);
2055
2131
  proc.stderr.on("data", (d) => out += d);
2056
- proc.on("error", () => resolve8({ code: 127, out }));
2057
- proc.on("close", (code) => resolve8({ code: code ?? 1, out }));
2132
+ proc.on("error", () => resolve9({ code: 127, out }));
2133
+ proc.on("close", (code) => resolve9({ code: code ?? 1, out }));
2058
2134
  });
2059
2135
  async function ghReady(exec = defaultGhExec) {
2060
2136
  const { code, out } = await exec(["auth", "status"]);
@@ -2329,14 +2405,60 @@ function isOwnNameAttr(kind, key) {
2329
2405
  return typeSegment(kind).toLowerCase().includes(prefix);
2330
2406
  }
2331
2407
  var MIN_NAME_LEN = 6;
2408
+ var MAX_NAME_LEN = 200;
2409
+ var MAX_DEPTH = 12;
2410
+ var SKIP_KEYS = /* @__PURE__ */ new Set([
2411
+ "status",
2412
+ "annotations",
2413
+ "labels",
2414
+ "matchLabels",
2415
+ "managedFields",
2416
+ "ownerReferences",
2417
+ "creationTimestamp",
2418
+ "resourceVersion",
2419
+ "uid",
2420
+ "selfLink",
2421
+ "generation",
2422
+ "finalizers"
2423
+ ]);
2424
+ function joinable(v) {
2425
+ return v.length >= MIN_NAME_LEN && v.length <= MAX_NAME_LEN && !/\s/.test(v);
2426
+ }
2427
+ function stringLeaves(attrs) {
2428
+ const out = [];
2429
+ const onPath = /* @__PURE__ */ new Set();
2430
+ const step = (v, key, path, depth) => {
2431
+ if (typeof v === "string") {
2432
+ out.push({ path, key, value: v });
2433
+ return;
2434
+ }
2435
+ if (depth >= MAX_DEPTH || !v || typeof v !== "object") return;
2436
+ if (onPath.has(v)) return;
2437
+ onPath.add(v);
2438
+ if (Array.isArray(v)) {
2439
+ for (const item of v) step(item, key, path, depth + 1);
2440
+ } else {
2441
+ for (const [k, val] of Object.entries(v)) {
2442
+ if (k.startsWith("_") || SKIP_KEYS.has(k)) continue;
2443
+ step(val, k, path ? `${path}.${k}` : k, depth + 1);
2444
+ }
2445
+ }
2446
+ onPath.delete(v);
2447
+ };
2448
+ for (const [k, v] of Object.entries(attrs ?? {})) {
2449
+ if (k.startsWith("_") || SKIP_KEYS.has(k)) continue;
2450
+ step(v, k, k, 0);
2451
+ }
2452
+ return out;
2453
+ }
2332
2454
  function addValueMatchEdges(ir) {
2333
2455
  const nodes = ir.nodes;
2334
2456
  const owner = /* @__PURE__ */ new Map();
2335
2457
  for (const n of nodes) {
2336
- for (const [k, v] of Object.entries(n.attrs ?? {})) {
2337
- if (typeof v !== "string" || v.length < MIN_NAME_LEN) continue;
2338
- if (!isOwnNameAttr(n.kind, k)) continue;
2339
- owner.set(v, owner.has(v) ? null : n.id);
2458
+ for (const { path, key, value } of stringLeaves(n.attrs)) {
2459
+ if (path !== key && path !== `metadata.${key}`) continue;
2460
+ if (!joinable(value) || !isOwnNameAttr(n.kind, key)) continue;
2461
+ owner.set(value, owner.has(value) ? null : n.id);
2340
2462
  }
2341
2463
  }
2342
2464
  if (owner.size === 0) return ir;
@@ -2344,14 +2466,14 @@ function addValueMatchEdges(ir) {
2344
2466
  for (const e of ir.edges) declared.add(`${e.from}\0${e.to}`);
2345
2467
  const added = /* @__PURE__ */ new Set();
2346
2468
  for (const a of nodes) {
2347
- for (const [k, v] of Object.entries(a.attrs ?? {})) {
2348
- if (typeof v !== "string" || v.length < MIN_NAME_LEN) continue;
2349
- const b = owner.get(v);
2469
+ for (const { path, value } of stringLeaves(a.attrs)) {
2470
+ if (!joinable(value)) continue;
2471
+ const b = owner.get(value);
2350
2472
  if (!b || b === a.id) continue;
2351
2473
  const key = `${a.id}\0${b}`;
2352
2474
  if (declared.has(key) || added.has(key)) continue;
2353
2475
  added.add(key);
2354
- ir.edges.push({ from: a.id, to: b, kind: "ref", viaAttr: k, inferred: true });
2476
+ ir.edges.push({ from: a.id, to: b, kind: "ref", viaAttr: path, inferred: true });
2355
2477
  }
2356
2478
  }
2357
2479
  return ir;
@@ -2364,6 +2486,11 @@ var WORKLOAD_KINDS = /* @__PURE__ */ new Set([
2364
2486
  "K8s::Apps::DaemonSet",
2365
2487
  "K8s::Apps::ReplicaSet"
2366
2488
  ]);
2489
+ var SELECTOR_CARRIERS = [
2490
+ { kind: "K8s::Core::Service", read: (spec) => spec?.selector, viaAttr: "selector" },
2491
+ { kind: "K8s::Policy::PodDisruptionBudget", read: (spec) => labelSelector(spec?.selector), viaAttr: "selector" },
2492
+ { kind: "K8s::Networking::NetworkPolicy", read: (spec) => labelSelector(spec?.podSelector), viaAttr: "podSelector" }
2493
+ ];
2367
2494
  function rec(v) {
2368
2495
  return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
2369
2496
  }
@@ -2386,6 +2513,13 @@ function labelRecord(v) {
2386
2513
  }
2387
2514
  return Object.keys(out).length > 0 ? out : void 0;
2388
2515
  }
2516
+ function labelSelector(v) {
2517
+ const sel = rec(v);
2518
+ if (!sel) return void 0;
2519
+ const expressions = sel.matchExpressions;
2520
+ if (Array.isArray(expressions) && expressions.length > 0) return void 0;
2521
+ return sel.matchLabels;
2522
+ }
2389
2523
  function selects(selector, labels) {
2390
2524
  return Object.entries(selector).every(([k, v]) => labels[k] === v);
2391
2525
  }
@@ -2480,15 +2614,19 @@ function deriveK8sEdges(nodes) {
2480
2614
  const target = candidates.length === 1 ? candidates[0] : candidates.find((p) => namespaceOf(p) === namespaceOf(n));
2481
2615
  if (target) add(n.id, target.id, viaAttr);
2482
2616
  };
2617
+ const addSelector = (n, raw, viaAttr) => {
2618
+ const selector = labelRecord(raw);
2619
+ if (!selector) return;
2620
+ for (const w of workloads) {
2621
+ if (namespaceOf(w) !== namespaceOf(n)) continue;
2622
+ const labels = templateLabels(w);
2623
+ if (labels && selects(selector, labels)) add(n.id, w.id, viaAttr);
2624
+ }
2625
+ };
2483
2626
  for (const n of k8s) {
2484
- if (n.kind === "K8s::Core::Service") {
2485
- const selector = labelRecord(rec(n.attrs?.spec)?.selector);
2486
- if (!selector) continue;
2487
- for (const w of workloads) {
2488
- if (namespaceOf(w) !== namespaceOf(n)) continue;
2489
- const labels = templateLabels(w);
2490
- if (labels && selects(selector, labels)) add(n.id, w.id, "selector");
2491
- }
2627
+ const carrier = SELECTOR_CARRIERS.find((c) => c.kind === n.kind);
2628
+ if (carrier) {
2629
+ addSelector(n, carrier.read(rec(n.attrs?.spec)), carrier.viaAttr);
2492
2630
  continue;
2493
2631
  }
2494
2632
  if (n.kind === "K8s::Networking::Ingress") {
@@ -2598,172 +2736,597 @@ function applyHelmArtifacts(ir, observed) {
2598
2736
  return { charts, installed };
2599
2737
  }
2600
2738
 
2601
- // src/cluster-anchor.ts
2602
- var MANAGED_CLUSTER_KINDS = /* @__PURE__ */ new Set([
2603
- "AWS::EKS::Cluster",
2604
- "GCP::Container::Cluster",
2605
- "Microsoft.ContainerService/managedClusters",
2606
- "K3d::Cluster"
2607
- ]);
2608
- var ANCHOR_VIA = "runs-on";
2609
- function metadata(node) {
2610
- const m = node.attrs?.metadata;
2611
- return m && typeof m === "object" && !Array.isArray(m) ? m : void 0;
2612
- }
2613
- function metaString(node, key) {
2614
- const v = metadata(node)?.[key];
2615
- return typeof v === "string" && v.length > 0 ? v : void 0;
2616
- }
2617
- function isK8s(node) {
2618
- return node.lexicon === "k8s";
2619
- }
2620
- function isNamespace(node) {
2621
- return node.kind === "K8s::Core::Namespace";
2622
- }
2623
- function managedClusterName(node) {
2624
- const metaName = metaString(node, "name");
2625
- if (metaName) return metaName;
2626
- for (const key of ["name", "clusterName"]) {
2627
- const v = node.attrs?.[key];
2628
- if (typeof v === "string" && v.length > 0) return v;
2739
+ // src/estate.ts
2740
+ import { statSync as statSync3 } from "node:fs";
2741
+ import { availableParallelism } from "node:os";
2742
+ import { join as join7, resolve as resolve3, sep } from "node:path";
2743
+ import { composeStacks, shortStackNames } from "@intentius/pinhole";
2744
+
2745
+ // src/member-ir.ts
2746
+ import { createHash } from "node:crypto";
2747
+ import { readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
2748
+ import { join as join6, relative, resolve as resolve2 } from "node:path";
2749
+ var SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", ".git"]);
2750
+ function memberIrCacheSize(env = process.env) {
2751
+ const override = Number.parseInt(env.BEHOLD_ESTATE_IR_CACHE ?? "", 10);
2752
+ return Number.isInteger(override) && override >= 0 ? override : 64;
2753
+ }
2754
+ function memberSourceStamp(dir) {
2755
+ const root = resolve2(dir);
2756
+ const h = createHash("sha1");
2757
+ let any = false;
2758
+ const walk = (at) => {
2759
+ const entries = readdirSync3(at, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1);
2760
+ for (const e of entries) {
2761
+ if (SKIP.has(e.name)) continue;
2762
+ const path = join6(at, e.name);
2763
+ if (e.isDirectory()) {
2764
+ walk(path);
2765
+ continue;
2766
+ }
2767
+ if (!e.isFile()) continue;
2768
+ const st = statSync2(path);
2769
+ h.update(`${relative(root, path)}\0${st.mtimeMs}\0${st.size}
2770
+ `);
2771
+ any = true;
2772
+ }
2773
+ };
2774
+ try {
2775
+ walk(root);
2776
+ } catch {
2777
+ return void 0;
2629
2778
  }
2630
- return node.id;
2779
+ return any ? h.digest("hex") : void 0;
2631
2780
  }
2632
- function boundManagedCluster(nodes, boundContext) {
2633
- const clusters = nodes.filter((n) => MANAGED_CLUSTER_KINDS.has(n.kind));
2634
- if (clusters.length === 1) return clusters[0];
2635
- if (clusters.length === 0 || !boundContext) return void 0;
2636
- const bound = clusters.filter((n) => contextBindsCluster(boundContext, managedClusterName(n)));
2637
- return bound.length === 1 ? bound[0] : void 0;
2781
+ function cacheKey(dir, opts) {
2782
+ const chant = resolveChant(dir);
2783
+ const canonical = JSON.stringify(opts, Object.keys(opts).sort());
2784
+ return `${resolve2(dir)}\0${chant.bin}\0${chant.version ?? ""}\0${canonical}`;
2638
2785
  }
2639
- function addClusterAnchorEdges(ir, boundContext) {
2640
- const nodes = ir.nodes;
2641
- const cluster = boundManagedCluster(ir.nodes, boundContext);
2642
- if (!cluster) return ir;
2643
- const k8sNodes = nodes.filter(isK8s);
2644
- if (k8sNodes.length === 0) return ir;
2645
- const namespaceByName = /* @__PURE__ */ new Map();
2646
- for (const n of k8sNodes) {
2647
- if (!isNamespace(n)) continue;
2648
- const name = metaString(n, "name");
2649
- if (name && !namespaceByName.has(name)) namespaceByName.set(name, n.id);
2650
- }
2651
- const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
2652
- const add = (from, to) => {
2653
- if (from === to) return;
2654
- const key = `${from}\0${to}`;
2655
- if (existing.has(key)) return;
2656
- existing.add(key);
2657
- ir.edges.push({ from, to, kind: "ref", viaAttr: ANCHOR_VIA, inferred: true });
2658
- };
2659
- for (const n of k8sNodes) {
2660
- if (isNamespace(n)) {
2661
- add(cluster.id, n.id);
2662
- continue;
2663
- }
2664
- const ns = metaString(n, "namespace");
2665
- const nsNode = ns ? namespaceByName.get(ns) : void 0;
2666
- add(nsNode ?? cluster.id, n.id);
2786
+ var cache = /* @__PURE__ */ new Map();
2787
+ var hits = 0;
2788
+ var misses = 0;
2789
+ function invalidateMember(dir) {
2790
+ if (dir === void 0) {
2791
+ cache.clear();
2792
+ return;
2667
2793
  }
2668
- return ir;
2794
+ const root = resolve2(dir);
2795
+ for (const [key, entry] of cache) if (entry.dir === root) cache.delete(key);
2796
+ }
2797
+ async function memberIr(dir, opts = {}) {
2798
+ const size = memberIrCacheSize();
2799
+ if (opts.live || opts.overlay || size === 0) return graphIr(dir, opts);
2800
+ const stamp = memberSourceStamp(dir);
2801
+ const key = cacheKey(dir, opts);
2802
+ const hit = stamp !== void 0 ? cache.get(key) : void 0;
2803
+ if (hit && hit.stamp === stamp) {
2804
+ hits++;
2805
+ cache.delete(key);
2806
+ cache.set(key, hit);
2807
+ return structuredClone(hit.ir);
2808
+ }
2809
+ misses++;
2810
+ const ir = await graphIr(dir, opts);
2811
+ if (stamp === void 0 || memberSourceStamp(dir) !== stamp) return ir;
2812
+ cache.delete(key);
2813
+ cache.set(key, { dir: resolve2(dir), stamp, ir });
2814
+ for (const oldest of cache.keys()) {
2815
+ if (cache.size <= size) break;
2816
+ cache.delete(oldest);
2817
+ }
2818
+ return structuredClone(ir);
2669
2819
  }
2670
2820
 
2671
- // src/logical-azure.ts
2672
- var AZURE_HEADLINE_KINDS = /* @__PURE__ */ new Set([
2673
- // compute / workloads
2674
- "Microsoft.Compute/virtualMachines",
2675
- "Microsoft.Compute/virtualMachineScaleSets",
2676
- "Microsoft.ContainerService/managedClusters",
2677
- "Microsoft.ContainerInstance/containerGroups",
2678
- "Microsoft.Web/sites",
2679
- // network edge
2680
- "Microsoft.Network/publicIPAddresses",
2681
- "Microsoft.Network/loadBalancers",
2682
- "Microsoft.Network/applicationGateways",
2683
- "Microsoft.Network/natGateways",
2684
- "Microsoft.Network/dnsZones",
2685
- // data stores
2686
- "Microsoft.Storage/storageAccounts",
2687
- "Microsoft.Sql/servers",
2688
- "Microsoft.DocumentDB/databaseAccounts",
2689
- "Microsoft.Cache/redis",
2690
- "Microsoft.DBforPostgreSQL/flexibleServers",
2691
- // registry / identity edge
2692
- "Microsoft.ContainerRegistry/registries"
2693
- ]);
2694
- var AZURE_FABRIC_KINDS = /* @__PURE__ */ new Set([
2695
- "Microsoft.Network/virtualNetworks",
2696
- "Microsoft.Network/virtualNetworks_subnets",
2697
- "Microsoft.Network/networkSecurityGroups",
2698
- "Microsoft.Network/routeTables",
2699
- "Microsoft.Network/networkInterfaces",
2700
- // identity/authorization hubs — a role assignment or managed identity is
2701
- // referenced by half the estate, so contracting through one invents edges
2702
- // between unrelated resources.
2703
- "Microsoft.Authorization/roleAssignments",
2704
- "Microsoft.ManagedIdentity/userAssignedIdentities",
2705
- "Microsoft.KeyVault/vaults"
2706
- ]);
2707
- var VNET_KIND = "Microsoft.Network/virtualNetworks";
2708
- var SUBNET_KIND = "Microsoft.Network/virtualNetworks_subnets";
2709
- var NSG_KIND = "Microsoft.Network/networkSecurityGroups";
2710
- var RESOURCE_ID_RE = /resourceId\(\s*([^)]*)\)/g;
2711
- var QUOTED_RE = /'([^']*)'/g;
2712
- function resourceIdNames(value) {
2713
- if (typeof value !== "string" || !value.includes("resourceId(")) return [];
2714
- const out = [];
2715
- for (const call of value.matchAll(RESOURCE_ID_RE)) {
2716
- const args = [...call[1].matchAll(QUOTED_RE)].map((m) => m[1]);
2717
- if (args.length >= 2) out.push(args[args.length - 1]);
2821
+ // src/zoom-notes.ts
2822
+ function zoomNote(zoom, ir, compositeEdgesAttached) {
2823
+ const empty = ir.nodes.length === 0;
2824
+ if (zoom === "components") {
2825
+ return empty ? "no components discovered \u2014 nothing declares one in this project" : void 0;
2718
2826
  }
2719
- return out;
2720
- }
2721
- function collectAzureRefs(value, out) {
2722
- if (value === null || typeof value !== "object") {
2723
- for (const name of resourceIdNames(value)) out.add(name);
2724
- return;
2827
+ if (zoom === "logical") {
2828
+ return empty ? "logical is an AWS projection \u2014 no AWS resources in this estate" : void 0;
2725
2829
  }
2726
- const ref = value.$ref;
2727
- if (typeof ref === "string") out.add(ref.split(".")[0]);
2728
- for (const v of Object.values(value)) collectAzureRefs(v, out);
2830
+ if (zoom === "composites") {
2831
+ return compositeEdgesAttached === 0 ? "no component ownership to join \u2014 showing the resource graph unchanged" : void 0;
2832
+ }
2833
+ if (zoom === "runtime") {
2834
+ const children = ir.nodes.filter((n) => n.runtimeOwner).length;
2835
+ return children === 0 ? "nothing below the declaration boundary \u2014 no owner-referenced children on this substrate" : void 0;
2836
+ }
2837
+ return void 0;
2729
2838
  }
2730
- function strAttr(node, key) {
2731
- const v = node.attrs?.[key];
2732
- return typeof v === "string" ? v : void 0;
2839
+ function logicalKept(before, after) {
2840
+ if (after > 0 && after * 3 >= before) return void 0;
2841
+ return after === 0 ? `logical projected nothing from ${before} resources \u2014 it is a cloud-topology lens, and this estate declares none of the kinds it nests (behold#74)` : `logical kept ${after} of ${before} resources \u2014 it is a cloud-topology lens, and the rest are kinds it does not nest (behold#74)`;
2733
2842
  }
2734
- function resourceName(node) {
2735
- return strAttr(node, "name");
2843
+ function edgelessNote(zoom, ir, detail) {
2844
+ if (zoom === "components" || zoom === "logical") return void 0;
2845
+ if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
2846
+ if (detail !== void 0 && detail < 3) {
2847
+ return "no edges at this detail \u2014 sourceRef/dependsOn and other attrs-derived references only appear at detail 3 (\u2318K \u2192 attributes, or add &detail=3)";
2848
+ }
2849
+ return "no edges \u2014 nothing in this estate references anything else";
2736
2850
  }
2737
- function subnetVnetName(node) {
2738
- const name = resourceName(node);
2739
- if (!name || !name.includes("/")) return void 0;
2740
- return name.split("/")[0];
2851
+ var CLUSTER_SCOPED = /* @__PURE__ */ new Set([
2852
+ "K8s::Core::Namespace",
2853
+ "K8s::Rbac::ClusterRole",
2854
+ "K8s::Rbac::ClusterRoleBinding",
2855
+ "K8s::Storage::StorageClass",
2856
+ "K8s::Apiextensions::CustomResourceDefinition"
2857
+ ]);
2858
+ function namespaceMismatchNote(nodes) {
2859
+ const k8s = nodes.filter((n) => n?.lexicon === "k8s");
2860
+ if (k8s.length === 0) return void 0;
2861
+ const pending = k8s.filter((n) => n.attrs?._status === "accent" && !CLUSTER_SCOPED.has(n.kind ?? ""));
2862
+ if (pending.length < 2) return void 0;
2863
+ if (pending.some((n) => typeof n.attrs?.metadata?.namespace === "string" && n.attrs.metadata.namespace)) return void 0;
2864
+ if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
2865
+ return `${pending.length} pending k8s objects declare no metadata.namespace \u2014 if a controller stamps it at apply time (e.g. Flux's targetNamespace), the live read looked in "default", not where they run`;
2741
2866
  }
2742
- function subnetShortName(node) {
2743
- const name = resourceName(node);
2744
- return name?.includes("/") ? name.split("/").slice(1).join("/") : name;
2867
+ function namespaceJoinNote(joined) {
2868
+ if (joined.length === 0) return void 0;
2869
+ const each = joined.map((j) => `${j.name} in "${j.namespace}"`).join(", ");
2870
+ return `read ${each} \u2014 the namespace the estate's own Kustomization targetNamespace binds, not the app project's own declaration`;
2745
2871
  }
2746
- function vnetCidr(node) {
2747
- const space = node.attrs?.addressSpace;
2748
- const prefixes = space?.addressPrefixes;
2749
- return Array.isArray(prefixes) && typeof prefixes[0] === "string" ? prefixes[0] : void 0;
2872
+ function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore, detail) {
2873
+ const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
2874
+ const notes = [primary, edgelessNote(zoom, ir, detail)].filter((n) => n !== void 0);
2875
+ return notes.length ? notes.join(" \xB7 ") : void 0;
2750
2876
  }
2751
- function projectAzureLogical(ir, env) {
2752
- const azure = ir.nodes.filter((n) => n.lexicon === "azure");
2753
- if (azure.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
2754
- const componentOf = componentNamer(azure);
2755
- const byId = new Map(azure.map((n) => [n.id, n]));
2756
- const byName = /* @__PURE__ */ new Map();
2757
- for (const n of azure) {
2758
- const name = resourceName(n);
2759
- if (name && !byName.has(name)) byName.set(name, n);
2760
- }
2761
- const vnets = azure.filter((n) => n.kind === VNET_KIND);
2762
- const subnets = azure.filter((n) => n.kind === SUBNET_KIND);
2763
- const vnetByName = new Map(vnets.map((v) => [resourceName(v) ?? v.id, v]));
2764
- const refOut = /* @__PURE__ */ new Map();
2765
- for (const n of azure) {
2766
- const names = /* @__PURE__ */ new Set();
2877
+ function tierMismatchNote(ir, tiers, currentTier) {
2878
+ const values = tiers?.values ?? [];
2879
+ if (values.length < 2) return void 0;
2880
+ const byLexicon = /* @__PURE__ */ new Map();
2881
+ for (const n of ir.nodes) {
2882
+ const status = n.attrs?._status;
2883
+ if (status !== "good" && status !== "accent") continue;
2884
+ const lexicon = n.lexicon ?? "?";
2885
+ const c = byLexicon.get(lexicon) ?? { good: 0, accent: 0 };
2886
+ c[status === "good" ? "good" : "accent"]++;
2887
+ byLexicon.set(lexicon, c);
2888
+ }
2889
+ for (const [lexicon, { good, accent }] of [...byLexicon].sort()) {
2890
+ if (accent < 2 || accent * 2 < good + accent) continue;
2891
+ const here = currentTier ? `tier "${currentTier}"` : "the default tier";
2892
+ const others = values.filter((v) => v !== currentTier).join(", ");
2893
+ return `${accent} of ${good + accent} ${lexicon} resources read "declared, not deployed" at ${here} \u2014 if the estate runs another declared tier (${others}), pick it (\u2318K \u2192 tier)`;
2894
+ }
2895
+ return void 0;
2896
+ }
2897
+
2898
+ // src/estate.ts
2899
+ function estateReadPool(members, env = process.env) {
2900
+ const override = Number.parseInt(env.BEHOLD_ESTATE_CONCURRENCY ?? "", 10);
2901
+ const cap = Number.isInteger(override) && override >= 1 ? override : Math.min(4, availableParallelism());
2902
+ return Math.max(1, Math.min(cap, members));
2903
+ }
2904
+ async function mapPool(items, width, fn) {
2905
+ const results = new Array(items.length);
2906
+ let next = 0;
2907
+ const worker = async () => {
2908
+ for (let i = next++; i < items.length; i = next++) {
2909
+ results[i] = await fn(items[i], i);
2910
+ }
2911
+ };
2912
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(width, items.length)) }, () => worker()));
2913
+ return results;
2914
+ }
2915
+ function estateMembers(projectDirs) {
2916
+ const names = shortStackNames([...projectDirs]);
2917
+ return projectDirs.map((dir, i) => ({ name: names[i], dir }));
2918
+ }
2919
+ async function composeEstate(projectDirs, opts = {}) {
2920
+ const names = shortStackNames(projectDirs);
2921
+ const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => ({
2922
+ name: names[i],
2923
+ ir: await memberIr(dir, opts)
2924
+ }));
2925
+ return composeStacks(stacks);
2926
+ }
2927
+ var firstLine2 = (e) => {
2928
+ const msg = e instanceof Error ? e.message : String(e);
2929
+ const m = msg.match(/exited \d+:\s*([\s\S]*)/);
2930
+ return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
2931
+ };
2932
+ function namespaceRuntimeOwners(name, ir) {
2933
+ const own = new Set(ir.nodes.map((n) => n.id));
2934
+ for (const n of ir.nodes) {
2935
+ if (n.runtimeOwner && own.has(n.runtimeOwner)) n.runtimeOwner = `${name}/${n.runtimeOwner}`;
2936
+ }
2937
+ return ir;
2938
+ }
2939
+ var NAMESPACE_JOIN_FLOOR = "0.44.5";
2940
+ var rec2 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
2941
+ var str2 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
2942
+ var realIsDir = (p) => {
2943
+ try {
2944
+ return statSync3(p).isDirectory();
2945
+ } catch {
2946
+ return false;
2947
+ }
2948
+ };
2949
+ function declaredNamespaceBindings(ir) {
2950
+ const out = [];
2951
+ for (const n of ir.nodes) {
2952
+ if (n.kind !== "K8s::Flux::Kustomization") continue;
2953
+ const spec = rec2(n.attrs?.spec);
2954
+ const namespace = str2(spec?.targetNamespace);
2955
+ const path = str2(spec?.path);
2956
+ if (namespace && path) out.push({ path, namespace });
2957
+ }
2958
+ return out;
2959
+ }
2960
+ function awaitsNamespaceBinding(ir) {
2961
+ const namespaced = ir.nodes.filter((n) => n.lexicon === "k8s" && !CLUSTER_SCOPED.has(n.kind ?? ""));
2962
+ if (namespaced.length === 0) return false;
2963
+ return namespaced.every((n) => !str2(rec2(n.attrs?.metadata)?.namespace));
2964
+ }
2965
+ function declaredSegments(path) {
2966
+ if (path.startsWith("/")) return void 0;
2967
+ const segs = path.split("/").filter((s) => s !== "" && s !== ".");
2968
+ if (segs.length === 0 || segs.includes("..")) return void 0;
2969
+ return segs;
2970
+ }
2971
+ function pathAlignment(declared, dir, isDir = realIsDir) {
2972
+ const want = declaredSegments(declared);
2973
+ if (!want) return 0;
2974
+ const root = resolve3(dir);
2975
+ const have = root.split(sep).filter(Boolean);
2976
+ for (let k = Math.min(want.length, have.length); k >= 1; k--) {
2977
+ if (!have.slice(-k).every((s, i) => s === want[i])) continue;
2978
+ if (!isDir(join7(root, ...want.slice(k)))) continue;
2979
+ return k;
2980
+ }
2981
+ return 0;
2982
+ }
2983
+ function joinNamespaceBindings(members, isDir = realIsDir) {
2984
+ const bindings = members.flatMap(
2985
+ (m) => m.ir ? declaredNamespaceBindings(m.ir).map((b) => ({ ...b, declaredBy: m.dir })) : []
2986
+ );
2987
+ if (bindings.length === 0) return [];
2988
+ const awaiting = members.filter((m) => m.ir && awaitsNamespaceBinding(m.ir));
2989
+ if (awaiting.length === 0) return [];
2990
+ const best = /* @__PURE__ */ new Map();
2991
+ for (const b of bindings) {
2992
+ let winner;
2993
+ let tied = false;
2994
+ for (const m of awaiting) {
2995
+ if (m.dir === b.declaredBy) continue;
2996
+ const score = pathAlignment(b.path, m.dir, isDir);
2997
+ if (score === 0) continue;
2998
+ if (!winner || score > winner.score) {
2999
+ winner = { dir: m.dir, score };
3000
+ tied = false;
3001
+ } else if (score === winner.score) {
3002
+ tied = true;
3003
+ }
3004
+ }
3005
+ if (!winner || tied) continue;
3006
+ const prev = best.get(winner.dir);
3007
+ if (prev === "ambiguous") continue;
3008
+ const join21 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
3009
+ if (!prev) best.set(winner.dir, { score: winner.score, join: join21 });
3010
+ else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
3011
+ else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join21 });
3012
+ }
3013
+ return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
3014
+ }
3015
+ async function estateNamespaceScopes(projectDirs, opts, isDir = realIsDir) {
3016
+ if (projectDirs.length < 2) return /* @__PURE__ */ new Map();
3017
+ const { live: _live, overlay: _overlay, namespace: _namespace, ...src } = opts;
3018
+ const irs = await mapPool(
3019
+ projectDirs,
3020
+ estateReadPool(projectDirs.length),
3021
+ (dir) => memberIr(dir, { ...src, detail: 3 }).catch(() => void 0)
3022
+ );
3023
+ const scopes = /* @__PURE__ */ new Map();
3024
+ for (const j of joinNamespaceBindings(projectDirs.map((dir, i) => ({ dir, ir: irs[i] })), isDir)) {
3025
+ if (!meetsFloor(resolveChant(j.dir).version, NAMESPACE_JOIN_FLOOR)) continue;
3026
+ scopes.set(j.dir, j.namespace);
3027
+ }
3028
+ return scopes;
3029
+ }
3030
+ function withoutJoinedMembers(nodes, joined) {
3031
+ if (joined.length === 0) return [...nodes];
3032
+ return nodes.filter((n) => !joined.some((j) => n.id.startsWith(`${j.name}/`)));
3033
+ }
3034
+ async function composeEstateOverlay(projectDirs, opts, classify) {
3035
+ const names = shortStackNames(projectDirs);
3036
+ const unobserved = [];
3037
+ const dropped = [];
3038
+ const joined = [];
3039
+ const stacks = new Array(projectDirs.length);
3040
+ const scopes = await estateNamespaceScopes(projectDirs, opts);
3041
+ await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => {
3042
+ const name = names[i];
3043
+ const namespace = scopes.get(dir);
3044
+ try {
3045
+ const live = { ...opts, live: true, overlay: true, ...namespace ? { namespace } : {} };
3046
+ stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, live))) };
3047
+ if (namespace) joined.push({ name, namespace });
3048
+ } catch (err) {
3049
+ const reason = firstLine2(err);
3050
+ try {
3051
+ const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
3052
+ const src = await memberIr(dir, srcOpts);
3053
+ for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
3054
+ stacks[i] = { name, ir: src };
3055
+ unobserved.push({ name, reason });
3056
+ } catch (err2) {
3057
+ dropped.push({ name, reason: firstLine2(err2) });
3058
+ }
3059
+ }
3060
+ });
3061
+ const present = stacks.filter((s) => !!s);
3062
+ return {
3063
+ ir: composeStacks(present),
3064
+ observed: present.length - unobserved.length,
3065
+ total: projectDirs.length,
3066
+ unobserved,
3067
+ dropped,
3068
+ // Concurrent reads finish in whatever order the clusters answer; the note
3069
+ // this feeds should read the same twice.
3070
+ joined: joined.sort((a, b) => a.name.localeCompare(b.name))
3071
+ };
3072
+ }
3073
+
3074
+ // src/helm-drift.ts
3075
+ function countChanges(docs) {
3076
+ return docs.reduce((n, d) => n + d.changes.length, 0);
3077
+ }
3078
+ function provenanceOf(live) {
3079
+ const m = live.manifest;
3080
+ return {
3081
+ contentDigest: m.contentDigest,
3082
+ inputDigest: m.inputDigest,
3083
+ valuesDigest: m.valuesDigest,
3084
+ chart: m.chart,
3085
+ chartVersion: m.chartVersion,
3086
+ repo: m.repo,
3087
+ releaseName: m.releaseName,
3088
+ namespace: m.namespace,
3089
+ profile: m.capabilityProfile?.cluster ?? "",
3090
+ kubeVersion: m.capabilityProfile?.kubeVersion ?? "",
3091
+ renderedAt: m.renderedAt,
3092
+ helmVersion: m.helmVersion,
3093
+ chantVersion: m.chantVersion,
3094
+ sourceRef: m.sourceRef
3095
+ };
3096
+ }
3097
+ function helmRenderReport(record, live) {
3098
+ const base = { render: record.name, chart: record.chart };
3099
+ if (!live) {
3100
+ return { ...base, verdict: "unobserved", reason: record.contentDigest ? "no-stored-render" : "unpinned" };
3101
+ }
3102
+ const d = live.diff;
3103
+ const counts = {
3104
+ drifted: d.drifted.length,
3105
+ changes: countChanges(d.drifted),
3106
+ unchanged: d.unchanged.length,
3107
+ unobserved: d.unobserved.length,
3108
+ undeclared: d.undeclaredEntities.length
3109
+ };
3110
+ const provenance = provenanceOf(live);
3111
+ if (d.drifted.length > 0) {
3112
+ return { ...base, verdict: "drifted", counts, provenance, drifted: d.drifted };
3113
+ }
3114
+ if (d.drifted.length + d.accepted.length + d.unchanged.length === 0) {
3115
+ return { ...base, verdict: "unobserved", reason: "nothing-observed", counts, provenance };
3116
+ }
3117
+ if (d.unobserved.length > 0) {
3118
+ return { ...base, verdict: "unobserved", reason: "partly-observed", counts, provenance };
3119
+ }
3120
+ return { ...base, verdict: "in-sync", counts, provenance };
3121
+ }
3122
+ async function readHelmRenderDrift(projectDir, env, opts = {}) {
3123
+ const report = await helmRenders(projectDir, opts).catch(() => void 0);
3124
+ const records = report?.records ?? [];
3125
+ if (records.length === 0) return [];
3126
+ return mapPool(records, estateReadPool(records.length), async (record) => {
3127
+ if (!record.contentDigest) return helmRenderReport(record, void 0);
3128
+ const live = await helmRenderDiffLive(projectDir, record.contentDigest, env, opts).catch(() => void 0);
3129
+ return helmRenderReport(record, live);
3130
+ });
3131
+ }
3132
+ function chartNameOf2(node) {
3133
+ const name = node.attrs?.name;
3134
+ return typeof name === "string" && name.length > 0 ? name : void 0;
3135
+ }
3136
+ var SEVERITY = { drifted: 2, unobserved: 1, "in-sync": 0 };
3137
+ function applyHelmRenderDrift(ir, reports) {
3138
+ if (reports.length === 0) return { charts: 0, drifted: 0 };
3139
+ const byChart = /* @__PURE__ */ new Map();
3140
+ for (const r of reports) {
3141
+ const list3 = byChart.get(r.chart);
3142
+ if (list3) list3.push(r);
3143
+ else byChart.set(r.chart, [r]);
3144
+ }
3145
+ let charts = 0;
3146
+ let drifted = 0;
3147
+ for (const node of ir.nodes) {
3148
+ if (node.lexicon !== "helm" || node.kind !== "Helm::Chart") continue;
3149
+ const chartName = chartNameOf2(node);
3150
+ const matched = chartName ? byChart.get(chartName) : void 0;
3151
+ if (!matched || matched.length === 0) continue;
3152
+ const worst = matched.reduce((a, b) => SEVERITY[b.verdict] > SEVERITY[a.verdict] ? b : a);
3153
+ const attrs = node.attrs ??= {};
3154
+ attrs._renderDrift = matched.length === 1 ? worst : { ...worst, renders: matched };
3155
+ charts++;
3156
+ if (worst.verdict === "drifted") {
3157
+ attrs._status = "warn";
3158
+ drifted++;
3159
+ }
3160
+ }
3161
+ return { charts, drifted };
3162
+ }
3163
+
3164
+ // src/cluster-anchor.ts
3165
+ var MANAGED_CLUSTER_KINDS = /* @__PURE__ */ new Set([
3166
+ "AWS::EKS::Cluster",
3167
+ "GCP::Container::Cluster",
3168
+ "Microsoft.ContainerService/managedClusters",
3169
+ "K3d::Cluster"
3170
+ ]);
3171
+ var ANCHOR_VIA = "runs-on";
3172
+ function metadata(node) {
3173
+ const m = node.attrs?.metadata;
3174
+ return m && typeof m === "object" && !Array.isArray(m) ? m : void 0;
3175
+ }
3176
+ function metaString(node, key) {
3177
+ const v = metadata(node)?.[key];
3178
+ return typeof v === "string" && v.length > 0 ? v : void 0;
3179
+ }
3180
+ function isK8s(node) {
3181
+ return node.lexicon === "k8s";
3182
+ }
3183
+ function isNamespace(node) {
3184
+ return node.kind === "K8s::Core::Namespace";
3185
+ }
3186
+ function managedClusterName(node) {
3187
+ const metaName = metaString(node, "name");
3188
+ if (metaName) return metaName;
3189
+ for (const key of ["name", "clusterName"]) {
3190
+ const v = node.attrs?.[key];
3191
+ if (typeof v === "string" && v.length > 0) return v;
3192
+ }
3193
+ return node.id;
3194
+ }
3195
+ function boundManagedCluster(nodes, boundContext) {
3196
+ const clusters = nodes.filter((n) => MANAGED_CLUSTER_KINDS.has(n.kind));
3197
+ if (clusters.length === 1) return clusters[0];
3198
+ if (clusters.length === 0 || !boundContext) return void 0;
3199
+ const bound = clusters.filter((n) => contextBindsCluster(boundContext, managedClusterName(n)));
3200
+ return bound.length === 1 ? bound[0] : void 0;
3201
+ }
3202
+ function addClusterAnchorEdges(ir, boundContext) {
3203
+ const nodes = ir.nodes;
3204
+ const cluster = boundManagedCluster(ir.nodes, boundContext);
3205
+ if (!cluster) return ir;
3206
+ const k8sNodes = nodes.filter(isK8s);
3207
+ if (k8sNodes.length === 0) return ir;
3208
+ const namespaceByName = /* @__PURE__ */ new Map();
3209
+ for (const n of k8sNodes) {
3210
+ if (!isNamespace(n)) continue;
3211
+ const name = metaString(n, "name");
3212
+ if (name && !namespaceByName.has(name)) namespaceByName.set(name, n.id);
3213
+ }
3214
+ const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
3215
+ const add = (from, to) => {
3216
+ if (from === to) return;
3217
+ const key = `${from}\0${to}`;
3218
+ if (existing.has(key)) return;
3219
+ existing.add(key);
3220
+ ir.edges.push({ from, to, kind: "ref", viaAttr: ANCHOR_VIA, inferred: true });
3221
+ };
3222
+ for (const n of k8sNodes) {
3223
+ if (isNamespace(n)) {
3224
+ add(cluster.id, n.id);
3225
+ continue;
3226
+ }
3227
+ const ns = metaString(n, "namespace");
3228
+ const nsNode = ns ? namespaceByName.get(ns) : void 0;
3229
+ add(nsNode ?? cluster.id, n.id);
3230
+ }
3231
+ return ir;
3232
+ }
3233
+
3234
+ // src/logical-azure.ts
3235
+ var AZURE_HEADLINE_KINDS = /* @__PURE__ */ new Set([
3236
+ // compute / workloads
3237
+ "Microsoft.Compute/virtualMachines",
3238
+ "Microsoft.Compute/virtualMachineScaleSets",
3239
+ "Microsoft.ContainerService/managedClusters",
3240
+ "Microsoft.ContainerInstance/containerGroups",
3241
+ "Microsoft.Web/sites",
3242
+ // network edge
3243
+ "Microsoft.Network/publicIPAddresses",
3244
+ "Microsoft.Network/loadBalancers",
3245
+ "Microsoft.Network/applicationGateways",
3246
+ "Microsoft.Network/natGateways",
3247
+ "Microsoft.Network/dnsZones",
3248
+ // data stores
3249
+ "Microsoft.Storage/storageAccounts",
3250
+ "Microsoft.Sql/servers",
3251
+ "Microsoft.DocumentDB/databaseAccounts",
3252
+ "Microsoft.Cache/redis",
3253
+ "Microsoft.DBforPostgreSQL/flexibleServers",
3254
+ // registry / identity edge
3255
+ "Microsoft.ContainerRegistry/registries"
3256
+ ]);
3257
+ var AZURE_FABRIC_KINDS = /* @__PURE__ */ new Set([
3258
+ "Microsoft.Network/virtualNetworks",
3259
+ "Microsoft.Network/virtualNetworks_subnets",
3260
+ "Microsoft.Network/networkSecurityGroups",
3261
+ "Microsoft.Network/routeTables",
3262
+ "Microsoft.Network/networkInterfaces",
3263
+ // identity/authorization hubs — a role assignment or managed identity is
3264
+ // referenced by half the estate, so contracting through one invents edges
3265
+ // between unrelated resources.
3266
+ "Microsoft.Authorization/roleAssignments",
3267
+ "Microsoft.ManagedIdentity/userAssignedIdentities",
3268
+ "Microsoft.KeyVault/vaults"
3269
+ ]);
3270
+ var VNET_KIND = "Microsoft.Network/virtualNetworks";
3271
+ var SUBNET_KIND = "Microsoft.Network/virtualNetworks_subnets";
3272
+ var NSG_KIND = "Microsoft.Network/networkSecurityGroups";
3273
+ var RESOURCE_ID_RE = /resourceId\(\s*([^)]*)\)/g;
3274
+ var QUOTED_RE = /'([^']*)'/g;
3275
+ function resourceIdNames(value) {
3276
+ if (typeof value !== "string" || !value.includes("resourceId(")) return [];
3277
+ const out = [];
3278
+ for (const call of value.matchAll(RESOURCE_ID_RE)) {
3279
+ const args = [...call[1].matchAll(QUOTED_RE)].map((m) => m[1]);
3280
+ if (args.length >= 2) out.push(args[args.length - 1]);
3281
+ }
3282
+ return out;
3283
+ }
3284
+ function collectAzureRefs(value, out) {
3285
+ if (value === null || typeof value !== "object") {
3286
+ for (const name of resourceIdNames(value)) out.add(name);
3287
+ return;
3288
+ }
3289
+ const ref = value.$ref;
3290
+ if (typeof ref === "string") out.add(ref.split(".")[0]);
3291
+ for (const v of Object.values(value)) collectAzureRefs(v, out);
3292
+ }
3293
+ function strAttr(node, key) {
3294
+ const v = node.attrs?.[key];
3295
+ return typeof v === "string" ? v : void 0;
3296
+ }
3297
+ function resourceName(node) {
3298
+ return strAttr(node, "name");
3299
+ }
3300
+ function subnetVnetName(node) {
3301
+ const name = resourceName(node);
3302
+ if (!name || !name.includes("/")) return void 0;
3303
+ return name.split("/")[0];
3304
+ }
3305
+ function subnetShortName(node) {
3306
+ const name = resourceName(node);
3307
+ return name?.includes("/") ? name.split("/").slice(1).join("/") : name;
3308
+ }
3309
+ function vnetCidr(node) {
3310
+ const space = node.attrs?.addressSpace;
3311
+ const prefixes = space?.addressPrefixes;
3312
+ return Array.isArray(prefixes) && typeof prefixes[0] === "string" ? prefixes[0] : void 0;
3313
+ }
3314
+ function projectAzureLogical(ir, env) {
3315
+ const azure = ir.nodes.filter((n) => n.lexicon === "azure");
3316
+ if (azure.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
3317
+ const componentOf = componentNamer(azure);
3318
+ const byId = new Map(azure.map((n) => [n.id, n]));
3319
+ const byName = /* @__PURE__ */ new Map();
3320
+ for (const n of azure) {
3321
+ const name = resourceName(n);
3322
+ if (name && !byName.has(name)) byName.set(name, n);
3323
+ }
3324
+ const vnets = azure.filter((n) => n.kind === VNET_KIND);
3325
+ const subnets = azure.filter((n) => n.kind === SUBNET_KIND);
3326
+ const vnetByName = new Map(vnets.map((v) => [resourceName(v) ?? v.id, v]));
3327
+ const refOut = /* @__PURE__ */ new Map();
3328
+ for (const n of azure) {
3329
+ const names = /* @__PURE__ */ new Set();
2767
3330
  collectAzureRefs(n.attrs, names);
2768
3331
  const targets = /* @__PURE__ */ new Set();
2769
3332
  for (const name of names) {
@@ -3088,7 +3651,7 @@ function projectK8sLogical(ir, env, boundContext) {
3088
3651
  function releaseBoxTitle(chartName) {
3089
3652
  return `release ${chartName}`;
3090
3653
  }
3091
- function chartNameOf2(node) {
3654
+ function chartNameOf3(node) {
3092
3655
  const name = node.attrs?.name;
3093
3656
  return typeof name === "string" && name.length > 0 ? name : node.id;
3094
3657
  }
@@ -3109,7 +3672,7 @@ function projectHelmLogical(ir) {
3109
3672
  };
3110
3673
  const k8s = ir.nodes.filter((n) => n.lexicon === "k8s");
3111
3674
  for (const chart of charts) {
3112
- const box = releaseBoxTitle(chartNameOf2(chart));
3675
+ const box = releaseBoxTitle(chartNameOf3(chart));
3113
3676
  child(box, chart.id);
3114
3677
  const chartDir = dirOf(chart);
3115
3678
  if (chartDir === "") continue;
@@ -3126,7 +3689,7 @@ function projectHelmLogical(ir) {
3126
3689
 
3127
3690
  // src/logical-kustomize.ts
3128
3691
  import { existsSync as existsSync5 } from "node:fs";
3129
- import { join as join6 } from "node:path";
3692
+ import { join as join8 } from "node:path";
3130
3693
  function overlayBoxTitle(name) {
3131
3694
  return `overlay ${name}`;
3132
3695
  }
@@ -3146,7 +3709,7 @@ function kustomizationRoot(bases, dir, exists) {
3146
3709
  let current = dir;
3147
3710
  for (let i = 0; i < 4 && current; i++) {
3148
3711
  for (const base of bases) {
3149
- if (exists(join6(base, current, "kustomization.yaml")) || exists(join6(base, current, "kustomization.yml"))) {
3712
+ if (exists(join8(base, current, "kustomization.yaml")) || exists(join8(base, current, "kustomization.yml"))) {
3150
3713
  return current;
3151
3714
  }
3152
3715
  }
@@ -3445,8 +4008,42 @@ function projectTopology(ir, env, boundContext, sourceRoots) {
3445
4008
  }
3446
4009
  placeHelmReleases(nodes, byContainer, ir, env, boundContext);
3447
4010
  nestReleaseBoxes(byContainer);
4011
+ retainCrossLensEdges(ir, projections, nodes, edges, byContainer);
3448
4012
  return { ir: { nodes, edges, groups: {} }, byContainer };
3449
4013
  }
4014
+ function retainCrossLensEdges(ir, projections, nodes, edges, byContainer) {
4015
+ const cards = new Set(nodes.map((n) => n.id));
4016
+ const perLens = projections.map((p) => new Set(p.ir.nodes.map((n) => n.id)));
4017
+ const pairKey = (a, b) => a < b ? `${a}|${b}` : `${b}|${a}`;
4018
+ const drawn = new Set(edges.map((e) => pairKey(e.from, e.to)));
4019
+ const parentsOf = /* @__PURE__ */ new Map();
4020
+ for (const [parent, children] of Object.entries(byContainer))
4021
+ for (const c of children) (parentsOf.get(c) ?? parentsOf.set(c, []).get(c)).push(parent);
4022
+ const nests = (outer, inner) => {
4023
+ const seen = /* @__PURE__ */ new Set([inner]);
4024
+ let frontier = [...parentsOf.get(inner) ?? []];
4025
+ while (frontier.length) {
4026
+ const next = [];
4027
+ for (const p of frontier) {
4028
+ if (seen.has(p)) continue;
4029
+ seen.add(p);
4030
+ if (p === outer) return true;
4031
+ for (const up of parentsOf.get(p) ?? []) if (!seen.has(up)) next.push(up);
4032
+ }
4033
+ frontier = next;
4034
+ }
4035
+ return false;
4036
+ };
4037
+ for (const e of ir.edges) {
4038
+ if (e.from === e.to || !cards.has(e.from) || !cards.has(e.to)) continue;
4039
+ if (perLens.some((ids) => ids.has(e.from) && ids.has(e.to))) continue;
4040
+ const key = pairKey(e.from, e.to);
4041
+ if (drawn.has(key)) continue;
4042
+ if (nests(e.from, e.to) || nests(e.to, e.from)) continue;
4043
+ drawn.add(key);
4044
+ edges.push(e);
4045
+ }
4046
+ }
3450
4047
  function placeHelmReleases(nodes, byContainer, ir, env, boundContext) {
3451
4048
  const releases = nodes.filter((n) => n.kind === "Helm::Release");
3452
4049
  if (releases.length === 0) return;
@@ -3677,126 +4274,49 @@ function componentRepresentatives(ir, dag) {
3677
4274
  if (typeof n !== "string" || !nodeById.has(n)) return false;
3678
4275
  const src = sourceComponent(n);
3679
4276
  return fallbackShaped ? src === comp.id : src === void 0 || src === comp.id;
3680
- });
3681
- if (owned.length === 0) continue;
3682
- compToNode.set(comp.id, owned.find((n) => !n.startsWith("byo")) ?? owned[0]);
3683
- }
3684
- for (const n of ir.nodes) {
3685
- const component = n.attrs?.component;
3686
- if (typeof component !== "string" || !knownComponents2.has(component) || compToNode.has(component)) continue;
3687
- compToNode.set(component, n.id);
3688
- }
3689
- const unmapped = /* @__PURE__ */ new Set();
3690
- for (const e of dag.edges) {
3691
- if (!compToNode.has(e.from)) unmapped.add(e.from);
3692
- if (!compToNode.has(e.to)) unmapped.add(e.to);
3693
- }
3694
- if (unmapped.size > 0) {
3695
- for (const n of ir.nodes) {
3696
- if (n.kind.includes("::")) continue;
3697
- const comp = kebabKind(n.kind);
3698
- if (!unmapped.has(comp)) continue;
3699
- const current = compToNode.get(comp);
3700
- if (!current || current.startsWith("byo") && !n.id.startsWith("byo")) compToNode.set(comp, n.id);
3701
- }
3702
- }
3703
- return compToNode;
3704
- }
3705
- function addCompositeDeps(ir, dag) {
3706
- const compToNode = componentRepresentatives(ir, dag);
3707
- const declared = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
3708
- for (const e of dag.edges) {
3709
- const from = compToNode.get(e.from);
3710
- const to = compToNode.get(e.to);
3711
- if (!from || !to || from === to) continue;
3712
- const key = `${from}\0${to}`;
3713
- if (declared.has(key)) continue;
3714
- declared.add(key);
3715
- ir.edges.push({ from, to, kind: "ref", viaAttr: "dependsOn", inferred: true });
3716
- }
3717
- return ir;
3718
- }
3719
- function addCompositeDepsCounted(ir, dag) {
3720
- const before = ir.edges.length;
3721
- const out = addCompositeDeps(ir, dag);
3722
- return { ir: out, attached: out.edges.length - before };
3723
- }
3724
-
3725
- // src/zoom-notes.ts
3726
- function zoomNote(zoom, ir, compositeEdgesAttached) {
3727
- const empty = ir.nodes.length === 0;
3728
- if (zoom === "components") {
3729
- return empty ? "no components discovered \u2014 nothing declares one in this project" : void 0;
3730
- }
3731
- if (zoom === "logical") {
3732
- return empty ? "logical is an AWS projection \u2014 no AWS resources in this estate" : void 0;
3733
- }
3734
- if (zoom === "composites") {
3735
- return compositeEdgesAttached === 0 ? "no component ownership to join \u2014 showing the resource graph unchanged" : void 0;
3736
- }
3737
- if (zoom === "runtime") {
3738
- const children = ir.nodes.filter((n) => n.runtimeOwner).length;
3739
- return children === 0 ? "nothing below the declaration boundary \u2014 no owner-referenced children on this substrate" : void 0;
3740
- }
3741
- return void 0;
3742
- }
3743
- function logicalKept(before, after) {
3744
- if (after > 0 && after * 3 >= before) return void 0;
3745
- return after === 0 ? `logical projected nothing from ${before} resources \u2014 it is a cloud-topology lens, and this estate declares none of the kinds it nests (behold#74)` : `logical kept ${after} of ${before} resources \u2014 it is a cloud-topology lens, and the rest are kinds it does not nest (behold#74)`;
3746
- }
3747
- function edgelessNote(zoom, ir, detail) {
3748
- if (zoom === "components" || zoom === "logical") return void 0;
3749
- if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
3750
- if (detail !== void 0 && detail < 3) {
3751
- return "no edges at this detail \u2014 sourceRef/dependsOn and other attrs-derived references only appear at detail 3 (\u2318K \u2192 attributes, or add &detail=3)";
3752
- }
3753
- return "no edges \u2014 nothing in this estate references anything else";
3754
- }
3755
- var CLUSTER_SCOPED = /* @__PURE__ */ new Set([
3756
- "K8s::Core::Namespace",
3757
- "K8s::Rbac::ClusterRole",
3758
- "K8s::Rbac::ClusterRoleBinding",
3759
- "K8s::Storage::StorageClass",
3760
- "K8s::Apiextensions::CustomResourceDefinition"
3761
- ]);
3762
- function namespaceMismatchNote(nodes) {
3763
- const k8s = nodes.filter((n) => n?.lexicon === "k8s");
3764
- if (k8s.length === 0) return void 0;
3765
- const pending = k8s.filter((n) => n.attrs?._status === "accent" && !CLUSTER_SCOPED.has(n.kind ?? ""));
3766
- if (pending.length < 2) return void 0;
3767
- if (pending.some((n) => typeof n.attrs?.metadata?.namespace === "string" && n.attrs.metadata.namespace)) return void 0;
3768
- if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
3769
- return `${pending.length} pending k8s objects declare no metadata.namespace \u2014 if a controller stamps it at apply time (e.g. Flux's targetNamespace), the live read looked in "default", not where they run`;
3770
- }
3771
- function namespaceJoinNote(joined) {
3772
- if (joined.length === 0) return void 0;
3773
- const each = joined.map((j) => `${j.name} in "${j.namespace}"`).join(", ");
3774
- return `read ${each} \u2014 the namespace the estate's own Kustomization targetNamespace binds, not the app project's own declaration`;
3775
- }
3776
- function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore, detail) {
3777
- const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
3778
- const notes = [primary, edgelessNote(zoom, ir, detail)].filter((n) => n !== void 0);
3779
- return notes.length ? notes.join(" \xB7 ") : void 0;
3780
- }
3781
- function tierMismatchNote(ir, tiers, currentTier) {
3782
- const values = tiers?.values ?? [];
3783
- if (values.length < 2) return void 0;
3784
- const byLexicon = /* @__PURE__ */ new Map();
4277
+ });
4278
+ if (owned.length === 0) continue;
4279
+ compToNode.set(comp.id, owned.find((n) => !n.startsWith("byo")) ?? owned[0]);
4280
+ }
3785
4281
  for (const n of ir.nodes) {
3786
- const status = n.attrs?._status;
3787
- if (status !== "good" && status !== "accent") continue;
3788
- const lexicon = n.lexicon ?? "?";
3789
- const c = byLexicon.get(lexicon) ?? { good: 0, accent: 0 };
3790
- c[status === "good" ? "good" : "accent"]++;
3791
- byLexicon.set(lexicon, c);
4282
+ const component = n.attrs?.component;
4283
+ if (typeof component !== "string" || !knownComponents2.has(component) || compToNode.has(component)) continue;
4284
+ compToNode.set(component, n.id);
3792
4285
  }
3793
- for (const [lexicon, { good, accent }] of [...byLexicon].sort()) {
3794
- if (accent < 2 || accent * 2 < good + accent) continue;
3795
- const here = currentTier ? `tier "${currentTier}"` : "the default tier";
3796
- const others = values.filter((v) => v !== currentTier).join(", ");
3797
- return `${accent} of ${good + accent} ${lexicon} resources read "declared, not deployed" at ${here} \u2014 if the estate runs another declared tier (${others}), pick it (\u2318K \u2192 tier)`;
4286
+ const unmapped = /* @__PURE__ */ new Set();
4287
+ for (const e of dag.edges) {
4288
+ if (!compToNode.has(e.from)) unmapped.add(e.from);
4289
+ if (!compToNode.has(e.to)) unmapped.add(e.to);
3798
4290
  }
3799
- return void 0;
4291
+ if (unmapped.size > 0) {
4292
+ for (const n of ir.nodes) {
4293
+ if (n.kind.includes("::")) continue;
4294
+ const comp = kebabKind(n.kind);
4295
+ if (!unmapped.has(comp)) continue;
4296
+ const current = compToNode.get(comp);
4297
+ if (!current || current.startsWith("byo") && !n.id.startsWith("byo")) compToNode.set(comp, n.id);
4298
+ }
4299
+ }
4300
+ return compToNode;
4301
+ }
4302
+ function addCompositeDeps(ir, dag) {
4303
+ const compToNode = componentRepresentatives(ir, dag);
4304
+ const declared = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
4305
+ for (const e of dag.edges) {
4306
+ const from = compToNode.get(e.from);
4307
+ const to = compToNode.get(e.to);
4308
+ if (!from || !to || from === to) continue;
4309
+ const key = `${from}\0${to}`;
4310
+ if (declared.has(key)) continue;
4311
+ declared.add(key);
4312
+ ir.edges.push({ from, to, kind: "ref", viaAttr: "dependsOn", inferred: true });
4313
+ }
4314
+ return ir;
4315
+ }
4316
+ function addCompositeDepsCounted(ir, dag) {
4317
+ const before = ir.edges.length;
4318
+ const out = addCompositeDeps(ir, dag);
4319
+ return { ir: out, attached: out.edges.length - before };
3800
4320
  }
3801
4321
 
3802
4322
  // src/resources.ts
@@ -3830,6 +4350,19 @@ function resourcesByComponent(ir, known) {
3830
4350
  }
3831
4351
 
3832
4352
  // src/reconcile.ts
4353
+ var DISRUPTION_SEVERITY = {
4354
+ "in-place": 0,
4355
+ rolling: 1,
4356
+ unknown: 1,
4357
+ replace: 2,
4358
+ destroy: 3
4359
+ };
4360
+ var DISRUPTION_LEVELS = ["in-place", "rolling", "replace", "destroy", "unknown"];
4361
+ function worstDisruption(a, b) {
4362
+ if (!a) return b;
4363
+ if (!b) return a;
4364
+ return DISRUPTION_SEVERITY[b] > DISRUPTION_SEVERITY[a] ? b : a;
4365
+ }
3833
4366
  function summarizePlan(plan, byComponent, nonResource) {
3834
4367
  const componentByEntity = /* @__PURE__ */ new Map();
3835
4368
  for (const [component, resources] of Object.entries(byComponent)) {
@@ -3838,6 +4371,9 @@ function summarizePlan(plan, byComponent, nonResource) {
3838
4371
  const counts = {};
3839
4372
  const unobservedCounts = {};
3840
4373
  const runtimeCounts = {};
4374
+ const disruption = Object.fromEntries(DISRUPTION_LEVELS.map((l) => [l, 0]));
4375
+ const disruptionByComponent = {};
4376
+ let disruptionUncorrelated;
3841
4377
  let uncorrelated = 0;
3842
4378
  let unobservedUncorrelated = 0;
3843
4379
  let runtimeUncorrelated = 0;
@@ -3863,6 +4399,12 @@ function summarizePlan(plan, byComponent, nonResource) {
3863
4399
  total++;
3864
4400
  if (component) counts[component] = (counts[component] ?? 0) + 1;
3865
4401
  else uncorrelated++;
4402
+ const level = entry.disruption;
4403
+ if (!level) continue;
4404
+ if (!DISRUPTION_LEVELS.includes(level)) continue;
4405
+ disruption[level]++;
4406
+ if (component) disruptionByComponent[component] = worstDisruption(disruptionByComponent[component], level);
4407
+ else disruptionUncorrelated = worstDisruption(disruptionUncorrelated, level);
3866
4408
  }
3867
4409
  return {
3868
4410
  env: plan.env,
@@ -3874,7 +4416,10 @@ function summarizePlan(plan, byComponent, nonResource) {
3874
4416
  unobservedUncorrelated,
3875
4417
  runtime,
3876
4418
  runtimeByComponent: runtimeCounts,
3877
- runtimeUncorrelated
4419
+ runtimeUncorrelated,
4420
+ disruption,
4421
+ disruptionByComponent,
4422
+ ...disruptionUncorrelated ? { disruptionUncorrelated } : {}
3878
4423
  };
3879
4424
  }
3880
4425
 
@@ -3890,10 +4435,10 @@ import {
3890
4435
 
3891
4436
  // src/icon-packs.ts
3892
4437
  import { readFileSync as readFileSync6 } from "node:fs";
3893
- import { dirname as dirname3, join as join7 } from "node:path";
4438
+ import { dirname as dirname3, join as join9 } from "node:path";
3894
4439
  import { fileURLToPath as fileURLToPath2 } from "node:url";
3895
- var ICON_ROOT = join7(dirname3(fileURLToPath2(import.meta.url)), "..", "web", "icons");
3896
- var cache = /* @__PURE__ */ new Map();
4440
+ var ICON_ROOT = join9(dirname3(fileURLToPath2(import.meta.url)), "..", "web", "icons");
4441
+ var cache2 = /* @__PURE__ */ new Map();
3897
4442
  function unprefix(body) {
3898
4443
  return body.replace(/\s(?:inkscape|sodipodi):[\w-]+\s*=\s*(["'])[\s\S]*?\1/g, "").replace(/\sxlink:href\s*=/g, " href=");
3899
4444
  }
@@ -3912,9 +4457,9 @@ function plate(glyph) {
3912
4457
  return { ...glyph, body: `${ground}<g transform="${inset}">${glyph.body}</g>` };
3913
4458
  }
3914
4459
  function loadIcon(rel) {
3915
- const hit = cache.get(rel);
4460
+ const hit = cache2.get(rel);
3916
4461
  if (hit) return hit;
3917
- const raw = readFileSync6(join7(ICON_ROOT, `${rel}.svg`), "utf8");
4462
+ const raw = readFileSync6(join9(ICON_ROOT, `${rel}.svg`), "utf8");
3918
4463
  const open = raw.indexOf("<svg");
3919
4464
  const openEnd = raw.indexOf(">", open);
3920
4465
  const close = raw.lastIndexOf("</svg");
@@ -3923,7 +4468,7 @@ function loadIcon(rel) {
3923
4468
  if (!viewBox) throw new Error(`vendored icon has no viewBox: ${rel}.svg`);
3924
4469
  const bare = { body: unprefix(raw.slice(openEnd + 1, close).trim()), colored: true, viewBox };
3925
4470
  const glyph = PLATED.has(rel) ? plate(bare) : bare;
3926
- cache.set(rel, glyph);
4471
+ cache2.set(rel, glyph);
3927
4472
  return glyph;
3928
4473
  }
3929
4474
  var K8S_ICON_BY_KIND = {
@@ -4110,68 +4655,704 @@ function carveReportToIr(report) {
4110
4655
  kind: tfTypeOf(r.address, r.kind),
4111
4656
  lexicon: "terraform",
4112
4657
  attrs: {
4113
- // The drift palette's channel — `_`-prefixed, so it paints the card and
4114
- // stays out of both the card's fields and the inspect pane's list.
4115
- _status: status,
4116
- score: r.score,
4117
- carve: verdictForStatus(status),
4118
- band: r.band,
4119
- arithmetic: scoreArithmetic(r),
4120
- boundaryWork: boundaryWorkOf(r),
4121
- ...r.mapsTo ? { mapsTo: r.mapsTo } : {},
4122
- tier: b.tier === null || b.tier === void 0 ? "none" : b.tier,
4123
- inbound: b.inbound ?? 0,
4124
- outbound: b.outbound ?? 0,
4125
- outputs: b.outputs ?? 0,
4126
- instances: b.instances ?? 1,
4127
- dynamic: b.hasDynamic ?? false,
4128
- // The predicted diff, when the report carries the edge lists: who needs
4129
- // a `data` source the moment this is carved, and what becomes a
4130
- // deploy-time input. Absent (not empty) when the report has no lists —
4131
- // "none" and "not reported" are different claims.
4132
- ...patches.length ? { patchOnCarve: patches.join(", ") } : {},
4133
- ...inputs.length ? { deferredInputs: inputs.join(", ") } : {}
4658
+ // The drift palette's channel — `_`-prefixed, so it paints the card and
4659
+ // stays out of both the card's fields and the inspect pane's list.
4660
+ _status: status,
4661
+ score: r.score,
4662
+ carve: verdictForStatus(status),
4663
+ band: r.band,
4664
+ arithmetic: scoreArithmetic(r),
4665
+ boundaryWork: boundaryWorkOf(r),
4666
+ ...r.mapsTo ? { mapsTo: r.mapsTo } : {},
4667
+ tier: b.tier === null || b.tier === void 0 ? "none" : b.tier,
4668
+ inbound: b.inbound ?? 0,
4669
+ outbound: b.outbound ?? 0,
4670
+ outputs: b.outputs ?? 0,
4671
+ instances: b.instances ?? 1,
4672
+ dynamic: b.hasDynamic ?? false,
4673
+ // The predicted diff, when the report carries the edge lists: who needs
4674
+ // a `data` source the moment this is carved, and what becomes a
4675
+ // deploy-time input. Absent (not empty) when the report has no lists —
4676
+ // "none" and "not reported" are different claims.
4677
+ ...patches.length ? { patchOnCarve: patches.join(", ") } : {},
4678
+ ...inputs.length ? { deferredInputs: inputs.join(", ") } : {}
4679
+ }
4680
+ };
4681
+ });
4682
+ const seen = /* @__PURE__ */ new Set();
4683
+ const edges = [];
4684
+ for (const r of resources) {
4685
+ for (const e of boundaryEdgesOf(r)) {
4686
+ const direction = directionOf(e);
4687
+ const from = direction === "inbound" ? e.survivor : e.carved;
4688
+ const to = direction === "inbound" ? e.carved : e.survivor;
4689
+ if (!known.has(from) || !known.has(to)) continue;
4690
+ const key = `${from}|${to}`;
4691
+ if (seen.has(key)) continue;
4692
+ seen.add(key);
4693
+ edges.push({
4694
+ from,
4695
+ to,
4696
+ kind: "ref",
4697
+ viaAttr: direction,
4698
+ ...e.attrs?.length ? { toAttr: e.attrs.join(", ") } : {}
4699
+ });
4700
+ }
4701
+ }
4702
+ const byStack = {};
4703
+ for (const order of ["good", "warn", "neutral"]) {
4704
+ const members = resources.filter((r) => statusForBand(r.band, r.score) === order).map((r) => r.address);
4705
+ if (members.length) byStack[verdictForStatus(order)] = members;
4706
+ }
4707
+ return { nodes, edges, groups: { byStack } };
4708
+ }
4709
+ function carveNote(report, ir) {
4710
+ const counts = Object.entries(report.bands ?? {}).filter(([, n]) => n > 0).map(([band, n]) => `${n} ${band}`).join(" \xB7 ");
4711
+ const head = `chant carve advisory${report.from ? ` for ${report.from}` : ""}${counts ? ` \u2014 ${counts}` : ""}`;
4712
+ if (ir.edges.length) return `${head}. Read-only: nothing is emitted, patched, or applied.`;
4713
+ return `${head}. This report carries per-resource boundary COUNTS but no edge lists, so no boundary edges are drawn (chant#1636). Read-only: nothing is emitted, patched, or applied.`;
4714
+ }
4715
+
4716
+ // src/carve-manifest.ts
4717
+ import { readdirSync as readdirSync4, readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
4718
+ import { dirname as dirname4, join as join10 } from "node:path";
4719
+ var CARVE_MANIFEST_SUFFIX = ".carve.json";
4720
+ var APPLY_IS_HUMAN = "`chant carve apply` graduates ownership \u2014 it resolves the marker that makes chant the owner of a live resource. behold shows what the manifest records and echoes the command; it has no apply endpoint and no apply button, by design. Read the graduation plan, then run it yourself.";
4721
+ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4722
+ var strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
4723
+ function parseCarveManifest(value) {
4724
+ if (!isRecord2(value)) return null;
4725
+ if (value.version !== 1) return null;
4726
+ if (typeof value.target !== "string" || !value.target) return null;
4727
+ return value;
4728
+ }
4729
+ function readCarveManifest(path, readFile = (p) => readFileSync7(p, "utf8")) {
4730
+ let text;
4731
+ try {
4732
+ text = readFile(path);
4733
+ } catch {
4734
+ return null;
4735
+ }
4736
+ try {
4737
+ return parseCarveManifest(JSON.parse(text));
4738
+ } catch {
4739
+ return null;
4740
+ }
4741
+ }
4742
+ function carveStageOf(m) {
4743
+ if (m.apply) return "applied";
4744
+ if (m.bridge) return "bridged";
4745
+ return "emitted";
4746
+ }
4747
+ function carveWordFor(stage) {
4748
+ switch (stage) {
4749
+ case "applied":
4750
+ return "carved \u2192 chant";
4751
+ case "bridged":
4752
+ return "bridged \u2014 apply is yours";
4753
+ default:
4754
+ return "emitted \u2014 not bridged";
4755
+ }
4756
+ }
4757
+ function carveToneFor(stage) {
4758
+ return stage === "applied" ? "good" : "accent";
4759
+ }
4760
+ function carveStageNote(state) {
4761
+ switch (state.stage) {
4762
+ case "applied":
4763
+ return `graduated \u2014 \`carve apply\` recorded the ownership marker` + (state.ownership?.stack ? ` (stack ${state.ownership.stack}${state.ownership.env ? `, env ${state.ownership.env}` : ""})` : "") + ". chant owns it.";
4764
+ case "bridged":
4765
+ return "emitted and bridged \u2014 the survivor patch is proposed. Terraform still owns the resource until `carve apply`.";
4766
+ default:
4767
+ return "emitted \u2014 typed chant source exists, and the surviving Terraform still reads the resource directly. `carve bridge` is next.";
4768
+ }
4769
+ }
4770
+ function carveStateOf(m, path) {
4771
+ const stage = carveStageOf(m);
4772
+ const deferred = Object.entries(m.emit?.params ?? {}).map(([name, p]) => `${name} \u2190 ${p?.survivor ?? "a survivor"}`);
4773
+ return {
4774
+ target: m.target,
4775
+ ...m.tfType ? { tfType: m.tfType } : {},
4776
+ stage,
4777
+ graduated: stage === "applied",
4778
+ // The last stamped step wins — that is the age of the state, not the age
4779
+ // of the carve.
4780
+ ...m.apply?.at || m.bridge?.at || m.emit?.at ? { at: m.apply?.at ?? m.bridge?.at ?? m.emit?.at } : {},
4781
+ files: strings(m.emit?.files),
4782
+ excised: strings(m.bridge?.excised),
4783
+ deferredInputs: deferred,
4784
+ ...m.apply ? {
4785
+ ownership: {
4786
+ ...m.apply.marker?.stack ? { stack: m.apply.marker.stack } : {},
4787
+ ...m.apply.marker?.env ? { env: m.apply.marker.env } : {},
4788
+ ...m.apply.ownershipTags ? { tags: m.apply.ownershipTags } : {}
4789
+ }
4790
+ } : {},
4791
+ ...m.from ? { from: m.from } : {},
4792
+ outDir: dirname4(path),
4793
+ path
4794
+ };
4795
+ }
4796
+ var nodeCarveIo = {
4797
+ readdir: (dir) => readdirSync4(dir),
4798
+ isDirectory: (p) => {
4799
+ try {
4800
+ return statSync4(p).isDirectory();
4801
+ } catch {
4802
+ return false;
4803
+ }
4804
+ },
4805
+ readFile: (p) => readFileSync7(p, "utf8")
4806
+ };
4807
+ var MANIFEST_SCAN_DEPTH = 2;
4808
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "cdk.out", ".terraform", ".git"]);
4809
+ function listCarveManifests(dir, io = nodeCarveIo, depth = MANIFEST_SCAN_DEPTH) {
4810
+ const out = [];
4811
+ const walk = (d, level) => {
4812
+ let entries;
4813
+ try {
4814
+ entries = io.readdir(d).sort();
4815
+ } catch {
4816
+ return;
4817
+ }
4818
+ for (const name of entries) {
4819
+ const full = join10(d, name);
4820
+ if (name.endsWith(CARVE_MANIFEST_SUFFIX)) {
4821
+ out.push(full);
4822
+ continue;
4823
+ }
4824
+ if (level >= depth) continue;
4825
+ if (name.startsWith(".") || SKIP_DIRS.has(name)) continue;
4826
+ if (io.isDirectory(full)) walk(full, level + 1);
4827
+ }
4828
+ };
4829
+ walk(dir, 0);
4830
+ return out;
4831
+ }
4832
+ function readCarveStates(dirs, io = nodeCarveIo, depth = MANIFEST_SCAN_DEPTH) {
4833
+ const rank = { emitted: 0, bridged: 1, applied: 2 };
4834
+ const byTarget = /* @__PURE__ */ new Map();
4835
+ for (const dir of dirs) {
4836
+ for (const path of listCarveManifests(dir, io, depth)) {
4837
+ const manifest = readCarveManifest(path, io.readFile);
4838
+ if (!manifest) continue;
4839
+ const state = carveStateOf(manifest, path);
4840
+ const held = byTarget.get(state.target);
4841
+ if (!held || rank[state.stage] > rank[held.stage]) byTarget.set(state.target, state);
4842
+ }
4843
+ }
4844
+ return byTarget;
4845
+ }
4846
+ function carveProgress(states, total) {
4847
+ let applied = 0;
4848
+ let bridged = 0;
4849
+ let emitted = 0;
4850
+ for (const s of states) {
4851
+ if (s.stage === "applied") applied++;
4852
+ else if (s.stage === "bridged") bridged++;
4853
+ else emitted++;
4854
+ }
4855
+ const inFlight = bridged + emitted;
4856
+ const parts = [];
4857
+ if (emitted) parts.push(`${emitted} emitted`);
4858
+ if (bridged) parts.push(`${bridged} bridged`);
4859
+ return {
4860
+ applied,
4861
+ bridged,
4862
+ emitted,
4863
+ inFlight,
4864
+ total: typeof total === "number" ? total : null,
4865
+ label: typeof total === "number" ? `${applied} of ${total} carved` : `${applied} carved`,
4866
+ detail: parts.length ? `${parts.join(", ")} \u2014 apply is yours` : ""
4867
+ };
4868
+ }
4869
+ function applyCommandFor(state, labels = {}) {
4870
+ const env = state.ownership?.env ?? "<env>";
4871
+ const stack = state.ownership?.stack ?? "<stack>";
4872
+ const from = labels.from ?? state.from ?? "<terraform-dir>";
4873
+ const out = labels.out ?? state.outDir;
4874
+ return `chant carve apply --from ${from} --output ${out} --env ${env} --stack ${stack} --write-source`;
4875
+ }
4876
+ function carveStatePayload(states, total, labels = () => ({})) {
4877
+ const list3 = [...states.values()].sort((a, b) => a.target.localeCompare(b.target));
4878
+ return {
4879
+ manifests: list3.length,
4880
+ progress: carveProgress(list3, total),
4881
+ states: list3.map((s) => ({ ...s, note: carveStageNote(s), applyCommand: applyCommandFor(s, labels(s)) })),
4882
+ apply: { human: true, note: APPLY_IS_HUMAN }
4883
+ };
4884
+ }
4885
+ function splitCarveState(tfIr, states, opts = {}) {
4886
+ if (states.size === 0) return { tf: tfIr, graduated: [] };
4887
+ const shorten = opts.shorten ?? ((p) => p);
4888
+ const source = (files) => files.map(shorten).join(", ");
4889
+ const gone = /* @__PURE__ */ new Set();
4890
+ const graduated = [];
4891
+ const nodes = [];
4892
+ for (const node of tfIr.nodes) {
4893
+ const state = states.get(node.id);
4894
+ if (!state) {
4895
+ nodes.push(node);
4896
+ continue;
4897
+ }
4898
+ if (state.graduated) {
4899
+ gone.add(node.id);
4900
+ graduated.push({
4901
+ ...node,
4902
+ attrs: {
4903
+ ...node.attrs,
4904
+ _status: "good",
4905
+ carve: carveWordFor(state.stage),
4906
+ carveState: state.stage,
4907
+ ...state.ownership?.stack ? { ownedStack: state.ownership.stack } : {},
4908
+ ...state.ownership?.env ? { ownedEnv: state.ownership.env } : {},
4909
+ ...state.at ? { carvedAt: state.at } : {},
4910
+ ...state.files.length ? { chantSource: source(state.files) } : {}
4911
+ }
4912
+ });
4913
+ continue;
4914
+ }
4915
+ nodes.push({
4916
+ ...node,
4917
+ attrs: {
4918
+ ...node.attrs,
4919
+ _status: carveToneFor(state.stage),
4920
+ carve: carveWordFor(state.stage),
4921
+ carveState: state.stage,
4922
+ ...state.at ? { carvedAt: state.at } : {},
4923
+ ...state.files.length ? { chantSource: source(state.files) } : {},
4924
+ ...state.excised.length ? { excisesOnApply: state.excised.join(", ") } : {},
4925
+ ...state.deferredInputs.length ? { deferredInputs: state.deferredInputs.join(", ") } : {}
4926
+ }
4927
+ });
4928
+ }
4929
+ const byStack = {};
4930
+ for (const [band, members] of Object.entries(tfIr.groups?.byStack ?? {})) {
4931
+ const left = members.filter((id) => !gone.has(id));
4932
+ if (left.length) byStack[band] = left;
4933
+ }
4934
+ return {
4935
+ tf: {
4936
+ nodes,
4937
+ edges: tfIr.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
4938
+ groups: { ...tfIr.groups, byStack }
4939
+ },
4940
+ graduated
4941
+ };
4942
+ }
4943
+ var GRADUATED_BAND = "carved \u2192 chant";
4944
+ function bandGraduated(tf, graduated) {
4945
+ if (graduated.length === 0) return tf;
4946
+ return {
4947
+ nodes: [...graduated, ...tf.nodes],
4948
+ edges: tf.edges,
4949
+ groups: {
4950
+ ...tf.groups,
4951
+ byStack: {
4952
+ [GRADUATED_BAND]: graduated.map((n) => n.id),
4953
+ ...tf.groups?.byStack ?? {}
4954
+ }
4955
+ }
4956
+ };
4957
+ }
4958
+ function carveStateNote(progress, states) {
4959
+ if (states.size === 0) return "";
4960
+ const head = ` Carve state (from ${states.size} manifest${states.size === 1 ? "" : "s"}): ${progress.label}`;
4961
+ return progress.detail ? `${head}, ${progress.detail}.` : `${head}.`;
4962
+ }
4963
+
4964
+ // src/ops-lens.ts
4965
+ var SUPPORTED_MAJOR2 = 1;
4966
+ var refuse2 = (error, remedy) => ({ ok: false, refusal: { error, code: "op-ir", remedy } });
4967
+ var HOW_TO_EMIT = "Build the project with a chant >= 0.50 (`chant build --lexicon temporal`), which writes `dist/ops/<name>/op.json` beside each Op's generated workflow.";
4968
+ var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4969
+ function parseOpIr(value) {
4970
+ if (!isRecord3(value)) {
4971
+ return refuse2("That file isn't an op.json \u2014 its top level isn't a JSON object.", HOW_TO_EMIT);
4972
+ }
4973
+ if (typeof value.name !== "string" || !Array.isArray(value.phases)) {
4974
+ return refuse2(
4975
+ "That JSON doesn't look like an op.json \u2014 it needs a string `name` and a `phases` array.",
4976
+ HOW_TO_EMIT
4977
+ );
4978
+ }
4979
+ const bad = value.phases.findIndex((p) => !isRecord3(p) || typeof p.name !== "string" || !Array.isArray(p.steps));
4980
+ if (bad >= 0) {
4981
+ return refuse2(
4982
+ `That JSON has a \`phases\` array, but entry ${bad} isn't a phase (needs a string \`name\` and a \`steps\` array).`,
4983
+ HOW_TO_EMIT
4984
+ );
4985
+ }
4986
+ const version = value.formatVersion;
4987
+ if (version !== void 0) {
4988
+ const major = Number(String(version).split(".")[0]);
4989
+ if (!Number.isFinite(major) || major !== SUPPORTED_MAJOR2) {
4990
+ return refuse2(
4991
+ `This op.json declares format version ${String(version)}; behold reads version ${SUPPORTED_MAJOR2}.`,
4992
+ "Upgrade behold, or render the Op with a behold that speaks its version."
4993
+ );
4994
+ }
4995
+ }
4996
+ return { ok: true, ir: value };
4997
+ }
4998
+ function readOpIr(path, readFile) {
4999
+ let text;
5000
+ try {
5001
+ text = readFile(path);
5002
+ } catch (err) {
5003
+ return refuse2(`Couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`, HOW_TO_EMIT);
5004
+ }
5005
+ let json;
5006
+ try {
5007
+ json = JSON.parse(text);
5008
+ } catch (err) {
5009
+ return refuse2(`${path} isn't valid JSON: ${err instanceof Error ? err.message : String(err)}`, HOW_TO_EMIT);
5010
+ }
5011
+ return parseOpIr(json);
5012
+ }
5013
+ function receiptName(step) {
5014
+ const r = step.receipt;
5015
+ if (typeof r === "string") return r;
5016
+ return r && typeof r.name === "string" && r.name || "effect";
5017
+ }
5018
+ function stepLabel(step) {
5019
+ if (step.kind === "gate") return `gate:${step.signalName}`;
5020
+ if (step.kind === "effect") return `effect:${receiptName(step)}`;
5021
+ return step.fn;
5022
+ }
5023
+ function stepKind(step) {
5024
+ if (step.kind === "gate") return "Gate";
5025
+ if (step.kind === "effect") return "Effect";
5026
+ return step.fn;
5027
+ }
5028
+ function stepStatus(step) {
5029
+ return step.kind === "gate" ? "warn" : step.kind === "effect" ? "accent" : "neutral";
5030
+ }
5031
+ function argsLine(args) {
5032
+ const entries = Object.entries(args ?? {});
5033
+ if (!entries.length) return void 0;
5034
+ return entries.map(([k, v]) => `${k}=${typeof v === "object" && v !== null ? JSON.stringify(v) : String(v)}`).join(", ");
5035
+ }
5036
+ function profileLine(p) {
5037
+ if (!p) return void 0;
5038
+ const parts = [];
5039
+ if (p.startToCloseTimeout) parts.push(`${p.startToCloseTimeout} start-to-close`);
5040
+ if (p.heartbeatTimeout) parts.push(`${p.heartbeatTimeout} heartbeat`);
5041
+ if (p.retry?.maximumAttempts) parts.push(`${p.retry.maximumAttempts} attempts`);
5042
+ if (p.retry?.initialInterval) parts.push(`from ${p.retry.initialInterval}`);
5043
+ return parts.length ? parts.join(", ") : void 0;
5044
+ }
5045
+ function placeSteps(op) {
5046
+ const out = [];
5047
+ const lists = [
5048
+ [op.phases ?? [], "phases"],
5049
+ [op.onFailure ?? [], "onFailure"]
5050
+ ];
5051
+ const used = /* @__PURE__ */ new Set();
5052
+ for (const [phases, when] of lists) {
5053
+ for (const phase of phases) {
5054
+ const box = when === "onFailure" ? `${op.name} \xB7 onFailure: ${phase.name}` : `${op.name} \xB7 ${phase.name}`;
5055
+ for (const step of phase.steps ?? []) {
5056
+ let id = `${op.name}/${phase.name}/${stepLabel(step)}`;
5057
+ for (let n = 2; used.has(id); n += 1) id = `${op.name}/${phase.name}/${stepLabel(step)}#${n}`;
5058
+ used.add(id);
5059
+ out.push({ id, step, phase, when, box });
5060
+ }
5061
+ }
5062
+ }
5063
+ return out;
5064
+ }
5065
+ function opPlacedSteps(op) {
5066
+ return placeSteps(op).map((p) => ({
5067
+ id: p.id,
5068
+ step: p.step,
5069
+ phase: p.phase.name,
5070
+ parallel: Boolean(p.phase.parallel),
5071
+ when: p.when
5072
+ }));
5073
+ }
5074
+ var RULE_BOX = (opName) => `${opName} \xB7 rule table`;
5075
+ var ruleId = (opName, rule) => `${opName}/rules/${rule.id}`;
5076
+ var isPredicate = (v) => isRecord3(v) && typeof v.kind === "string";
5077
+ function isConvergeRule(v) {
5078
+ if (!isRecord3(v)) return false;
5079
+ if (typeof v.id !== "string" || !v.id) return false;
5080
+ if (typeof v.why !== "string" || !v.why) return false;
5081
+ if (!isPredicate(v.when)) return false;
5082
+ const then = v.then;
5083
+ if (!isRecord3(then)) return false;
5084
+ return then.kind === "run" && typeof then.op === "string" || then.kind === "report" && typeof then.reason === "string";
5085
+ }
5086
+ function convergeTable(op) {
5087
+ for (const placed of placeSteps(op)) {
5088
+ const { step } = placed;
5089
+ if (step.kind !== "activity" || step.fn !== "convergeTick") continue;
5090
+ const rules = step.args?.rules;
5091
+ if (!Array.isArray(rules)) return void 0;
5092
+ const kept = rules.filter(isConvergeRule);
5093
+ if (!kept.length) return void 0;
5094
+ return {
5095
+ tickStepId: placed.id,
5096
+ phase: placed.phase.name,
5097
+ rules: kept,
5098
+ ...typeof step.args?.dial === "string" ? { dial: step.args.dial } : {},
5099
+ ...typeof step.args?.budget === "number" ? { budget: step.args.budget } : {},
5100
+ ...typeof step.args?.env === "string" ? { env: step.args.env } : {}
5101
+ };
5102
+ }
5103
+ return void 0;
5104
+ }
5105
+ var COMPARISON_TEXT = {
5106
+ eq: "=",
5107
+ neq: "!=",
5108
+ gt: ">",
5109
+ gte: ">=",
5110
+ lt: "<",
5111
+ lte: "<="
5112
+ };
5113
+ function predicateText(predicate) {
5114
+ switch (predicate.kind) {
5115
+ case "field-comparison": {
5116
+ const op = COMPARISON_TEXT[predicate.op];
5117
+ if (!op) return `(unreadable comparison on ${predicate.field})`;
5118
+ const value = typeof predicate.value === "string" ? JSON.stringify(predicate.value) : String(predicate.value);
5119
+ return `${predicate.field} ${op} ${value}`;
5120
+ }
5121
+ case "field-truthiness":
5122
+ return `${predicate.field} is ${predicate.op}`;
5123
+ case "all-of":
5124
+ case "any-of": {
5125
+ const joiner = predicate.kind === "all-of" ? " and " : " or ";
5126
+ const parts = (predicate.predicates ?? []).filter(isPredicate);
5127
+ if (!parts.length) return predicate.kind === "all-of" ? "(always)" : "(never)";
5128
+ return parts.map((p) => p.kind === "all-of" || p.kind === "any-of" ? `(${predicateText(p)})` : predicateText(p)).join(joiner);
5129
+ }
5130
+ default:
5131
+ return "(predicate outside the kinds behold reads)";
5132
+ }
5133
+ }
5134
+ function actionText(action) {
5135
+ return action.kind === "run" ? `run(${action.op})` : `report: ${action.reason}`;
5136
+ }
5137
+ function ruleStatus(rule, declared) {
5138
+ if (rule.then.kind !== "run") return "neutral";
5139
+ return declared.has(rule.then.op) ? "accent" : "warn";
5140
+ }
5141
+ function ruleNode(op, table, rule, declared) {
5142
+ const dispatches = rule.then.kind === "run" && declared.has(rule.then.op) ? rule.then.op : void 0;
5143
+ const dangling = rule.then.kind === "run" && !declared.has(rule.then.op) ? rule.then.op : void 0;
5144
+ return {
5145
+ id: ruleId(op.name, rule),
5146
+ // The card's kind, as an activity's is its `fn`: a converge rule is what
5147
+ // this is, and the whole table reads as one kind of thing in the track.
5148
+ kind: "ConvergeRule",
5149
+ lexicon: "op",
5150
+ attrs: {
5151
+ _status: ruleStatus(rule, declared),
5152
+ _step: "rule",
5153
+ rule: rule.id,
5154
+ op: op.name,
5155
+ phase: table.phase,
5156
+ // The condition, the action, and the rationale — the three columns a rule
5157
+ // table has. `why` is chant's required field, verbatim: the accessible-ops
5158
+ // promise is that the answer to "why did it do that" is already written
5159
+ // down, so behold's job is to show it, not to summarise it.
5160
+ when: predicateText(rule.when),
5161
+ then: actionText(rule.then),
5162
+ why: rule.why,
5163
+ ...dispatches ? { dispatches } : {},
5164
+ // A `then` naming an Op that isn't in this set: stated, not guessed at.
5165
+ // chant's TMP014 refuses a run() naming an Op the PROJECT doesn't declare,
5166
+ // so this is behold's own composition boundary showing through, and saying
5167
+ // which one it is beats an edge into nowhere.
5168
+ ...dangling ? { dangling: `${dangling} \u2014 not among the Ops rendered here, so no edge is drawn` } : {},
5169
+ // `flapThreshold` absent means chant's own default applies. Named the way
5170
+ // a gate's unset timeout is, rather than restating a number chant owns.
5171
+ flap: rule.flapThreshold !== void 0 ? `${rule.flapThreshold} consecutive ticks` : "(chant default)",
5172
+ ...table.dial ? { dial: table.dial } : {}
5173
+ }
5174
+ };
5175
+ }
5176
+ function nodeFor(op, placed, guards) {
5177
+ const { step, phase, when } = placed;
5178
+ const common = {
5179
+ op: op.name,
5180
+ phase: phase.name,
5181
+ ...phase.parallel ? { parallel: true } : {},
5182
+ ...when === "onFailure" ? { when: "onFailure" } : {}
5183
+ };
5184
+ if (step.kind === "gate") {
5185
+ return {
5186
+ id: placed.id,
5187
+ kind: stepKind(step),
5188
+ lexicon: "op",
5189
+ attrs: {
5190
+ _status: stepStatus(step),
5191
+ _step: "gate",
5192
+ gate: step.signalName,
5193
+ timeout: step.timeout ?? "(chant default)",
5194
+ ...common,
5195
+ ...step.description ? { description: step.description } : {},
5196
+ // "gates drawn as their own marks on the step they guard" (#284): the
5197
+ // gate names what it holds back, so the pause reads from the gate card
5198
+ // without hunting for the next one in the track.
5199
+ ...guards ? { guards } : {}
5200
+ }
5201
+ };
5202
+ }
5203
+ if (step.kind === "effect") {
5204
+ return {
5205
+ id: placed.id,
5206
+ kind: stepKind(step),
5207
+ lexicon: "op",
5208
+ attrs: {
5209
+ _status: stepStatus(step),
5210
+ _step: "effect",
5211
+ effect: receiptName(step),
5212
+ ...common,
5213
+ ...step.expectation !== void 0 ? { expectation: String(step.expectation) } : {},
5214
+ ...step.description ? { description: step.description } : {},
5215
+ // The nested steps, named but not drawn — see OpIrEffectStep's doc.
5216
+ ...step.steps?.length ? { runs: step.steps.map(stepLabel).join(" \u2192 ") } : {}
4134
5217
  }
4135
5218
  };
4136
- });
4137
- const seen = /* @__PURE__ */ new Set();
4138
- const edges = [];
4139
- for (const r of resources) {
4140
- for (const e of boundaryEdgesOf(r)) {
4141
- const direction = directionOf(e);
4142
- const from = direction === "inbound" ? e.survivor : e.carved;
4143
- const to = direction === "inbound" ? e.carved : e.survivor;
4144
- if (!known.has(from) || !known.has(to)) continue;
4145
- const key = `${from}|${to}`;
4146
- if (seen.has(key)) continue;
4147
- seen.add(key);
4148
- edges.push({
4149
- from,
4150
- to,
4151
- kind: "ref",
4152
- viaAttr: direction,
4153
- ...e.attrs?.length ? { toAttr: e.attrs.join(", ") } : {}
4154
- });
5219
+ }
5220
+ const profile = step.profile;
5221
+ const policy = profileLine(profile ? op.activityProfiles?.[profile] : void 0);
5222
+ const args = argsLine(step.args);
5223
+ return {
5224
+ id: placed.id,
5225
+ kind: stepKind(step),
5226
+ lexicon: "op",
5227
+ attrs: {
5228
+ _status: stepStatus(step),
5229
+ _step: "activity",
5230
+ fn: step.fn,
5231
+ ...common,
5232
+ ...profile ? { profile } : {},
5233
+ ...policy ? { policy } : {},
5234
+ // The resolved args, verbatim from the IR. They carry the Op's SCOPE
5235
+ // (env, stack, template path) and never an estate entity id — see the
5236
+ // module doc on why no edge is drawn from them.
5237
+ ...args ? { args } : {},
5238
+ ...step.outcomeAttribute ? { outcome: step.outcomeAttribute.from ? `${step.outcomeAttribute.name} \u2190 ${step.outcomeAttribute.from}` : step.outcomeAttribute.name } : {},
5239
+ ...op.activityContracts?.[step.fn] ? { contract: "declared (args/returns JSON Schema in op.json)" } : {}
4155
5240
  }
5241
+ };
5242
+ }
5243
+ function chainPhases(placed, when) {
5244
+ const byPhase = [];
5245
+ for (const p of placed) {
5246
+ if (p.when !== when) continue;
5247
+ const last = byPhase[byPhase.length - 1];
5248
+ if (last && last.phase === p.phase) last.ids.push(p.id);
5249
+ else byPhase.push({ phase: p.phase, ids: [p.id] });
4156
5250
  }
5251
+ return byPhase.map(
5252
+ ({ phase, ids }) => phase.parallel ? { heads: ids, tails: ids } : { heads: ids.slice(0, 1), tails: ids.slice(-1) }
5253
+ );
5254
+ }
5255
+ function opsToIr(ops) {
5256
+ const nodes = [];
5257
+ const edges = [];
4157
5258
  const byStack = {};
4158
- for (const order of ["good", "warn", "neutral"]) {
4159
- const members = resources.filter((r) => statusForBand(r.band, r.score) === order).map((r) => r.address);
4160
- if (members.length) byStack[verdictForStatus(order)] = members;
5259
+ const endpoints = /* @__PURE__ */ new Map();
5260
+ const declared = new Set(ops.map((o) => o.name));
5261
+ const dispatches = [];
5262
+ for (const op of ops) {
5263
+ const placed = placeSteps(op);
5264
+ for (let i = 0; i < placed.length; i += 1) {
5265
+ const p = placed[i];
5266
+ const next = placed[i + 1];
5267
+ const guards = p.step.kind === "gate" && next && next.when === p.when ? stepLabel(next.step) : void 0;
5268
+ nodes.push(nodeFor(op, p, guards));
5269
+ (byStack[p.box] ??= []).push(p.id);
5270
+ }
5271
+ for (const when of ["phases", "onFailure"]) {
5272
+ const track = chainPhases(placed, when);
5273
+ for (let i = 0; i + 1 < track.length; i += 1) {
5274
+ for (const from of track[i].tails) {
5275
+ for (const to of track[i + 1].heads) edges.push({ from, to, kind: "ref", viaAttr: "then" });
5276
+ }
5277
+ }
5278
+ if (when === "phases" && track.length) {
5279
+ endpoints.set(op.name, { first: track[0].heads, last: track[track.length - 1].tails });
5280
+ }
5281
+ }
5282
+ const table = convergeTable(op);
5283
+ if (!table) continue;
5284
+ for (const rule of table.rules) {
5285
+ const node = ruleNode(op, table, rule, declared);
5286
+ nodes.push(node);
5287
+ (byStack[RULE_BOX(op.name)] ??= []).push(node.id);
5288
+ edges.push({ from: table.tickStepId, to: node.id, kind: "ref", viaAttr: "rule" });
5289
+ if (rule.then.kind === "run") dispatches.push({ from: node.id, op: rule.then.op });
5290
+ }
5291
+ }
5292
+ for (const op of ops) {
5293
+ const mine = endpoints.get(op.name);
5294
+ if (!mine) continue;
5295
+ for (const dep of op.depends ?? []) {
5296
+ const theirs = endpoints.get(dep);
5297
+ if (!theirs) continue;
5298
+ for (const from of theirs.last) {
5299
+ for (const to of mine.first) edges.push({ from, to, kind: "ref", viaAttr: "depends" });
5300
+ }
5301
+ }
5302
+ }
5303
+ for (const { from, op } of dispatches) {
5304
+ const target = endpoints.get(op);
5305
+ if (!target) continue;
5306
+ for (const to of target.first) edges.push({ from, to, kind: "ref", viaAttr: "run" });
4161
5307
  }
4162
5308
  return { nodes, edges, groups: { byStack } };
4163
5309
  }
4164
- function carveNote(report, ir) {
4165
- const counts = Object.entries(report.bands ?? {}).filter(([, n]) => n > 0).map(([band, n]) => `${n} ${band}`).join(" \xB7 ");
4166
- const head = `chant carve advisory${report.from ? ` for ${report.from}` : ""}${counts ? ` \u2014 ${counts}` : ""}`;
4167
- if (ir.edges.length) return `${head}. Read-only: nothing is emitted, patched, or applied.`;
4168
- return `${head}. This report carries per-resource boundary COUNTS but no edge lists, so no boundary edges are drawn (chant#1636). Read-only: nothing is emitted, patched, or applied.`;
5310
+ function opCardFields(node) {
5311
+ const step = node.attrs._step;
5312
+ const phase = typeof node.attrs.phase === "string" ? node.attrs.phase : void 0;
5313
+ const declared = step === "gate" ? [
5314
+ { label: "gate", value: String(node.attrs.gate ?? "") },
5315
+ { label: "timeout", value: String(node.attrs.timeout ?? "") }
5316
+ ] : step === "effect" ? [
5317
+ { label: "effect", value: String(node.attrs.effect ?? "") },
5318
+ ...phase ? [{ label: "phase", value: phase }] : []
5319
+ ] : step === "activity" ? [
5320
+ ...phase ? [{ label: "phase", value: phase }] : [],
5321
+ ...typeof node.attrs.profile === "string" ? [{ label: "profile", value: node.attrs.profile }] : []
5322
+ ] : (
5323
+ // A converge rule card leads with the two columns that make it a
5324
+ // rule — the condition and what it does about it. `why` is the long
5325
+ // one and belongs in the inspect pane, where it reads in full
5326
+ // rather than being cut to fit a card.
5327
+ step === "rule" ? [
5328
+ { label: "when", value: String(node.attrs.when ?? "") },
5329
+ { label: "then", value: String(node.attrs.then ?? "") }
5330
+ ] : void 0
5331
+ );
5332
+ if (!declared) return void 0;
5333
+ const run3 = typeof node.attrs.run === "string" ? node.attrs.run : void 0;
5334
+ return run3 ? [{ label: "run", value: run3 }, ...declared.slice(0, 1)] : declared;
5335
+ }
5336
+ function opsNote(ops, ir) {
5337
+ const gates = ir.nodes.filter((n) => n.attrs._step === "gate").length;
5338
+ const rules = ir.nodes.filter((n) => n.attrs._step === "rule");
5339
+ const steps = ir.nodes.length - rules.length;
5340
+ const dangling = rules.filter((n) => n.attrs.dangling !== void 0).length;
5341
+ const counts = [
5342
+ `${ops.length} declared Op${ops.length === 1 ? "" : "s"}`,
5343
+ `${steps} step${steps === 1 ? "" : "s"}`,
5344
+ ...gates ? [`${gates} gate${gates === 1 ? "" : "s"}`] : [],
5345
+ ...rules.length ? [`${rules.length} converge rule${rules.length === 1 ? "" : "s"}`] : []
5346
+ ].join(" \xB7 ");
5347
+ const ruleNote = rules.length ? ` Each converge rule carries the why chant requires of it, verbatim, and a rule whose then is run(<op>) is drawn as an edge to that Op's first step.` + (dangling ? ` ${dangling} rule${dangling === 1 ? " names an Op" : "s name Ops"} not in this set \u2014 stated on the card, not drawn.` : "") : "";
5348
+ return `${counts}, from each Op's emitted dist/ops/<name>/op.json.${ruleNote} No estate cross-links: op.json names an Op's env and stack but never an estate entity id, so nothing here is joined to the graph's nodes (chant ask). Read-only: this renders the declared Op \u2014 nothing is started, signalled, or applied.`;
4169
5349
  }
4170
5350
 
4171
5351
  // src/render.ts
4172
5352
  registerPack({ lexicon: "k8s", iconFor: k8sIconFor });
4173
5353
  registerPack({ lexicon: "helm", iconFor: helmIconFor });
4174
5354
  registerPack({ lexicon: "terraform", iconFor: () => void 0, fields: carveCardFields });
5355
+ registerPack({ lexicon: "op", iconFor: () => void 0, fields: opCardFields });
4175
5356
  function renderArchitecture(ir, byContainer, opts = {}) {
4176
5357
  const spread = Math.min(1.5, ir.edges.length / Math.max(ir.nodes.length, 1));
4177
5358
  const layout = layoutArchitecture(ir, byContainer, {
@@ -4261,7 +5442,8 @@ function bandedPlan(ir, bands) {
4261
5442
  return { placed, boxes, width: panelW, height: Math.max(1, top - BAND_GAP) };
4262
5443
  }
4263
5444
  function renderCarveEstate(tfIr, appIr, opts) {
4264
- const comp = composeCarveEstate(tfIr, namespaceAppIr(appIr, opts.appTitle), opts);
5445
+ const staged = withCarveState(tfIr, appIr, opts);
5446
+ const comp = composeCarveEstate(staged.tf, staged.appNs, { ...opts, carvedTitle: staged.carvedTitle });
4265
5447
  const svg = renderSvg(comp.ir, comp.layout, {
4266
5448
  fit: true,
4267
5449
  hideTitle: true,
@@ -4270,6 +5452,17 @@ function renderCarveEstate(tfIr, appIr, opts) {
4270
5452
  });
4271
5453
  return { svg, ir: comp.ir };
4272
5454
  }
5455
+ function withCarveState(tfIr, appIr, opts) {
5456
+ const states = opts.carved ?? /* @__PURE__ */ new Map();
5457
+ const { tf, graduated } = splitCarveState(tfIr, states, { ...opts.shorten ? { shorten: opts.shorten } : {} });
5458
+ const ns = namespaceAppIr(appIr, opts.appTitle);
5459
+ const progress = carveProgress(states.values(), tfIr.nodes.length);
5460
+ return {
5461
+ tf,
5462
+ appNs: { ...ns, nodes: [...ns.nodes, ...graduated] },
5463
+ carvedTitle: states.size ? `carved so far \u2014 ${progress.label}` : "carved so far"
5464
+ };
5465
+ }
4273
5466
  function namespaceAppIr(appIr, appTitle) {
4274
5467
  const prefix = `${appTitle.split(" ")[0]}/`;
4275
5468
  const appId = (id) => `${prefix}${id}`;
@@ -4282,7 +5475,7 @@ function namespaceAppIr(appIr, appTitle) {
4282
5475
  }
4283
5476
  function composeCarveEstate(tfIr, appNs, opts) {
4284
5477
  const tfPlan = bandedPlan(tfIr, tfIr.groups.byStack ?? {});
4285
- const appPlan = bandedPlan(appNs, { "carved so far": appNs.nodes.map((n) => n.id) });
5478
+ const appPlan = bandedPlan(appNs, { [opts.carvedTitle ?? "carved so far"]: appNs.nodes.map((n) => n.id) });
4286
5479
  const memberH = (plan) => TITLE + PAD + plan.height + PAD;
4287
5480
  const memberW = (plan) => plan.width + PAD * 2;
4288
5481
  const appX0 = memberW(tfPlan) + MEMBER_GAP;
@@ -4321,23 +5514,25 @@ function composeCarveEstate(tfIr, appNs, opts) {
4321
5514
  };
4322
5515
  }
4323
5516
  function renderCarveMorph(tfIr, appIr, carved, opts) {
4324
- const appNs = namespaceAppIr(appIr, opts.appTitle);
4325
- const before = composeCarveEstate(tfIr, appNs, opts);
5517
+ const staged = withCarveState(tfIr, appIr, opts);
5518
+ const appNs = staged.appNs;
5519
+ const morphOpts = { ...opts, carvedTitle: "carved so far" };
5520
+ const before = composeCarveEstate(staged.tf, appNs, morphOpts);
4326
5521
  const gone = new Set(carved);
4327
- const bands = tfIr.groups.byStack ?? {};
5522
+ const bands = staged.tf.groups.byStack ?? {};
4328
5523
  const bandsAfter = {};
4329
5524
  for (const [band, members] of Object.entries(bands)) {
4330
5525
  const left = members.filter((id) => !gone.has(id));
4331
5526
  if (left.length) bandsAfter[band] = left;
4332
5527
  }
4333
5528
  const tfAfter = {
4334
- nodes: tfIr.nodes.filter((n) => !gone.has(n.id)),
4335
- edges: tfIr.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
5529
+ nodes: staged.tf.nodes.filter((n) => !gone.has(n.id)),
5530
+ edges: staged.tf.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
4336
5531
  groups: { byStack: bandsAfter }
4337
5532
  };
4338
- const carvedNodes = tfIr.nodes.filter((n) => gone.has(n.id)).map((n) => ({ ...n, attrs: { ...n.attrs, _status: "good", carve: "carved \u2192 chant" } }));
5533
+ const carvedNodes = staged.tf.nodes.filter((n) => gone.has(n.id)).map((n) => ({ ...n, attrs: { ...n.attrs, _status: "good", carve: "carved \u2192 chant" } }));
4339
5534
  const appAfter = { ...appNs, nodes: [...appNs.nodes, ...carvedNodes] };
4340
- const after = composeCarveEstate(tfAfter, appAfter, opts);
5535
+ const after = composeCarveEstate(tfAfter, appAfter, morphOpts);
4341
5536
  const view = (name, comp) => ({
4342
5537
  name,
4343
5538
  ir: comp.ir,
@@ -4599,12 +5794,12 @@ function radializeLayout(layout, groupOf, size = /* @__PURE__ */ new Map()) {
4599
5794
  }
4600
5795
 
4601
5796
  // src/carve-actions.ts
4602
- import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync9, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
4603
- import { isAbsolute, join as join10, relative, resolve as resolve2, sep } from "node:path";
5797
+ import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync10, readdirSync as readdirSync5, statSync as statSync5 } from "node:fs";
5798
+ import { isAbsolute, join as join13, relative as relative2, resolve as resolve4, sep as sep2 } from "node:path";
4604
5799
 
4605
5800
  // src/layout.ts
4606
- import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync7, renameSync, rmSync, writeFileSync as writeFileSync2, accessSync, constants } from "node:fs";
4607
- import { join as join8 } from "node:path";
5801
+ import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync8, renameSync, rmSync, writeFileSync as writeFileSync2, accessSync, constants } from "node:fs";
5802
+ import { join as join11 } from "node:path";
4608
5803
  var LAYOUT_DIR = ".behold";
4609
5804
  var LAYOUT_FILE = "layout.json";
4610
5805
  var MAX_BODY_BYTES = 64 * 1024;
@@ -4645,7 +5840,7 @@ function lensFromQuery(params) {
4645
5840
  return [slug(zoom), params.get("radial") === "1" ? "radial" : null, stack ? slug(`stack-${stack}`) : null].filter(Boolean).join("+");
4646
5841
  }
4647
5842
  function layoutPath(projectDir) {
4648
- return join8(projectDir, LAYOUT_DIR, LAYOUT_FILE);
5843
+ return join11(projectDir, LAYOUT_DIR, LAYOUT_FILE);
4649
5844
  }
4650
5845
  function unwritableReason(projectDir) {
4651
5846
  if (!projectDir || !existsSync6(projectDir)) return `no such project directory: ${projectDir || "(none)"}`;
@@ -4661,7 +5856,7 @@ function readLayoutFile(projectDir) {
4661
5856
  const file = layoutPath(projectDir);
4662
5857
  try {
4663
5858
  if (!existsSync6(file)) return empty;
4664
- const raw = readFileSync7(file, "utf8");
5859
+ const raw = readFileSync8(file, "utf8");
4665
5860
  if (raw.length > MAX_FILE_BYTES) return empty;
4666
5861
  const parsed = JSON.parse(raw);
4667
5862
  if (!parsed || typeof parsed !== "object" || !parsed.lenses || typeof parsed.lenses !== "object") return empty;
@@ -4701,7 +5896,7 @@ function writeLens(projectDir, lens, deltas) {
4701
5896
  }
4702
5897
  const body = JSON.stringify({ version: 1, lenses }, null, 2) + "\n";
4703
5898
  if (body.length > MAX_FILE_BYTES) throw new LayoutTooLarge(`a layout sidecar is capped at ${MAX_FILE_BYTES} bytes`);
4704
- mkdirSync2(join8(projectDir, LAYOUT_DIR), { recursive: true });
5899
+ mkdirSync2(join11(projectDir, LAYOUT_DIR), { recursive: true });
4705
5900
  const tmp = `${file}.tmp`;
4706
5901
  writeFileSync2(tmp, body, "utf8");
4707
5902
  renameSync(tmp, file);
@@ -4786,8 +5981,8 @@ function reanchorEdges(svg, moved) {
4786
5981
 
4787
5982
  // src/carve-live.ts
4788
5983
  import { spawn as spawn3 } from "node:child_process";
4789
- import { readFileSync as readFileSync8, writeFileSync as writeFileSync3, existsSync as existsSync7 } from "node:fs";
4790
- import { join as join9 } from "node:path";
5984
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync3, existsSync as existsSync7 } from "node:fs";
5985
+ import { join as join12 } from "node:path";
4791
5986
  var LIVE_CONTAINER = "behold-carve-floci";
4792
5987
  var LIVE_PORT = 4602;
4793
5988
  var LIVE_IMAGE = "floci/floci:1.5.34";
@@ -4846,9 +6041,9 @@ async function teardownScratchFloci() {
4846
6041
  await capture("docker", ["rm", "-f", LIVE_CONTAINER], process.cwd()).catch(() => void 0);
4847
6042
  }
4848
6043
  async function applyIntoFloci(fromDir, step) {
4849
- const template = join9(fromDir, "floci-override.tf.disabled");
6044
+ const template = join12(fromDir, "floci-override.tf.disabled");
4850
6045
  if (!existsSync7(template)) return `no floci-override.tf.disabled in ${fromDir} \u2014 this estate wasn't authored for the live tier`;
4851
- writeFileSync3(join9(fromDir, "floci_override.tf"), armOverride(readFileSync8(template, "utf8"), LIVE_PORT));
6046
+ writeFileSync3(join12(fromDir, "floci_override.tf"), armOverride(readFileSync9(template, "utf8"), LIVE_PORT));
4852
6047
  const init = await step("terraform", ["init", "-input=false", "-no-color"], fromDir);
4853
6048
  if (init !== 0) return `terraform init exited ${init} (provider downloads need network)`;
4854
6049
  const apply = await step(
@@ -4872,7 +6067,7 @@ ${tail}`, exitCode: r.code };
4872
6067
  // src/carve-actions.ts
4873
6068
  async function runCarvePlan(demo) {
4874
6069
  if (!demo?.live) {
4875
- return refuse2(
6070
+ return refuse3(
4876
6071
  "carve-action",
4877
6072
  "terraform plan is the live tier's beat, and this server isn't serving one",
4878
6073
  "run `behold demo carve --live` (needs docker + terraform)"
@@ -4880,7 +6075,7 @@ async function runCarvePlan(demo) {
4880
6075
  }
4881
6076
  const r = await runLivePlan(demo.from);
4882
6077
  if ("error" in r) {
4883
- return refuse2("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".");
6078
+ return refuse3("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".");
4884
6079
  }
4885
6080
  return {
4886
6081
  ok: true,
@@ -4892,14 +6087,14 @@ async function runCarvePlan(demo) {
4892
6087
  }
4893
6088
  async function runCarveObserve(demo, select) {
4894
6089
  if (!demo.live) {
4895
- return refuse2(
6090
+ return refuse3(
4896
6091
  "carve-action",
4897
6092
  "the observe beat reads the scratch Floci, and this server isn't serving one",
4898
6093
  "run `behold demo carve --live` (needs docker + terraform)"
4899
6094
  );
4900
6095
  }
4901
- if (!existsSync8(join10(demo.out, "src"))) {
4902
- return refuse2("carve-action", "nothing has been emitted yet", "run Emit first \u2014 observe reads the carveout emit writes");
6096
+ if (!existsSync8(join13(demo.out, "src"))) {
6097
+ return refuse3("carve-action", "nothing has been emitted yet", "run Emit first \u2014 observe reads the carveout emit writes");
4903
6098
  }
4904
6099
  const args = ["lifecycle", "diff", "prod", "--live", "--json"];
4905
6100
  const run3 = await runChantRaw(args, demo.out, { AWS_ENDPOINT_URL: demo.live.endpoint }).catch((err) => ({
@@ -4908,18 +6103,26 @@ async function runCarveObserve(demo, select) {
4908
6103
  stderr: err instanceof Error ? err.message : String(err)
4909
6104
  }));
4910
6105
  if (run3.code !== 0) {
4911
- return refuse2(
6106
+ return refuse3(
4912
6107
  "carve-action",
4913
6108
  `chant lifecycle diff exited ${run3.code}: ${merge(run3, demo.root) || "(no output)"}`,
4914
6109
  "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + "."
4915
6110
  );
4916
6111
  }
4917
- let aws;
6112
+ let parsed;
4918
6113
  try {
4919
- aws = JSON.parse(run3.stdout).lexicons?.aws ?? {};
6114
+ parsed = JSON.parse(run3.stdout);
4920
6115
  } catch {
4921
- return refuse2("carve-action", "chant lifecycle diff answered something that isn't JSON", "re-run \u2014 a partial read is not a verdict");
6116
+ return refuse3("carve-action", "chant lifecycle diff answered something that isn't JSON", "re-run \u2014 a partial read is not a verdict");
4922
6117
  }
6118
+ if (parsed.stacks && !parsed.lexicons) {
6119
+ return refuse3(
6120
+ "carve-action",
6121
+ "chant lifecycle diff answered a stacked shape ({stacks}) \u2014 observe only reads the stack-less {lexicons} shape",
6122
+ "point the target at a stack-less carveout, or file a shape-read gap against observe for the stacked case"
6123
+ );
6124
+ }
6125
+ const aws = parsed.lexicons?.aws ?? {};
4923
6126
  const entity = select.split(".").pop() ?? select;
4924
6127
  const command = `AWS_ENDPOINT_URL=${demo.live.endpoint} chant ${args.join(" ")} # in ${shortenIn(demo.out, demo.root)}`;
4925
6128
  const queried = aws.resources?.queried?.[entity];
@@ -4954,13 +6157,13 @@ async function runCarveObserve(demo, select) {
4954
6157
  var MAX_ARTIFACT_BYTES = 64 * 1024;
4955
6158
  var MAX_ARTIFACTS = 24;
4956
6159
  var MAX_OUTPUT_BYTES = 32 * 1024;
4957
- var refuse2 = (code, error, remedy) => ({
6160
+ var refuse3 = (code, error, remedy) => ({
4958
6161
  ok: false,
4959
6162
  refusal: { error, code, remedy }
4960
6163
  });
4961
6164
  var BUILD_CAVEAT = "The gate shown is `chant lint`, not `chant build`. chant#1637's fold is applied now: the bucket's versioning and public-access-block sub-resources come out as native props, not just folded-in mentions. `chant build` still fails, but on one rule only \u2014 WAW042, a TLS-deny bucket policy the source Terraform never declared. That's real drift the estate inherited, not a gap in the carve. example-carve/README.md, \"Why build still fails\", has the full statement.";
4962
6165
  function insideDemo(root, p) {
4963
- const rel = relative(resolve2(root), resolve2(p));
6166
+ const rel = relative2(resolve4(root), resolve4(p));
4964
6167
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
4965
6168
  }
4966
6169
  function selectFromReport(report, raw) {
@@ -4981,17 +6184,17 @@ function readArtifacts(demo, dir, filter) {
4981
6184
  if (out.length >= MAX_ARTIFACTS || depth > 2) return;
4982
6185
  let entries;
4983
6186
  try {
4984
- entries = readdirSync3(d).sort();
6187
+ entries = readdirSync5(d).sort();
4985
6188
  } catch {
4986
6189
  return;
4987
6190
  }
4988
6191
  for (const name of entries) {
4989
6192
  if (out.length >= MAX_ARTIFACTS) return;
4990
6193
  if (name === "node_modules" || name.startsWith(".")) continue;
4991
- const full = join10(d, name);
6194
+ const full = join13(d, name);
4992
6195
  let st;
4993
6196
  try {
4994
- st = statSync2(full);
6197
+ st = statSync5(full);
4995
6198
  } catch {
4996
6199
  continue;
4997
6200
  }
@@ -4999,11 +6202,11 @@ function readArtifacts(demo, dir, filter) {
4999
6202
  walk(full, depth + 1);
5000
6203
  continue;
5001
6204
  }
5002
- const rel = relative(demo.root, full).split(sep).join("/");
6205
+ const rel = relative2(demo.root, full).split(sep2).join("/");
5003
6206
  if (!filter(rel)) continue;
5004
6207
  let text = "";
5005
6208
  try {
5006
- text = readFileSync9(full, "utf8");
6209
+ text = readFileSync10(full, "utf8");
5007
6210
  } catch {
5008
6211
  continue;
5009
6212
  }
@@ -5025,17 +6228,17 @@ function carveSlug(select) {
5025
6228
  }
5026
6229
  var clip = (s) => s.length > MAX_OUTPUT_BYTES ? s.slice(0, MAX_OUTPUT_BYTES) + "\n\u2026 (truncated)" : s;
5027
6230
  function shortenIn(arg, root) {
5028
- return arg.startsWith(root + sep) ? arg.slice(root.length + 1) : arg === root ? "." : arg;
6231
+ return arg.startsWith(root + sep2) ? arg.slice(root.length + 1) : arg === root ? "." : arg;
5029
6232
  }
5030
6233
  function merge(r, root) {
5031
6234
  const joined = stripAnsi([r.stdout, r.stderr].filter((s) => s.trim()).join("\n").trim());
5032
- return clip(joined.split(root + sep).join("").split(root).join("."));
6235
+ return clip(joined.split(root + sep2).join("").split(root).join("."));
5033
6236
  }
5034
6237
  async function runCarveEmit(demo, select) {
5035
6238
  const block = carveWriteBlock(demo);
5036
- if (block) return refuse2("read-only", block, "Start the walkthrough with `behold demo carve`.");
6239
+ if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
5037
6240
  mkdirSync3(demo.out, { recursive: true });
5038
- const reportFile = join10(demo.out, `${carveSlug(select)}-boundary.json`);
6241
+ const reportFile = join13(demo.out, `${carveSlug(select)}-boundary.json`);
5039
6242
  const args = [
5040
6243
  "carve",
5041
6244
  "emit",
@@ -5056,7 +6259,7 @@ async function runCarveEmit(demo, select) {
5056
6259
  }));
5057
6260
  const output = merge(run3, demo.root);
5058
6261
  if (run3.code !== 0) {
5059
- return refuse2(
6262
+ return refuse3(
5060
6263
  "carve-action",
5061
6264
  `chant carve emit exited ${run3.code}: ${output || "(no output)"}`,
5062
6265
  "The offline emit needs `@cdktf/hcl2json` in the demo copy and the demo's own chant install \u2014 `npm install` in the copy's `app/` and re-run `behold demo carve`."
@@ -5064,10 +6267,10 @@ async function runCarveEmit(demo, select) {
5064
6267
  }
5065
6268
  let boundary = null;
5066
6269
  try {
5067
- boundary = JSON.parse(readFileSync9(reportFile, "utf8"));
6270
+ boundary = JSON.parse(readFileSync10(reportFile, "utf8"));
5068
6271
  } catch {
5069
6272
  }
5070
- const lintPath = relative(demo.project, join10(demo.out, "src")).split(sep).join("/");
6273
+ const lintPath = relative2(demo.project, join13(demo.out, "src")).split(sep2).join("/");
5071
6274
  const lintArgs = ["lint", lintPath];
5072
6275
  const lint = await runChantRaw(lintArgs, demo.project).catch((err) => ({
5073
6276
  code: 127,
@@ -5091,7 +6294,7 @@ async function runCarveEmit(demo, select) {
5091
6294
  }
5092
6295
  async function runCarveBridge(demo, select) {
5093
6296
  const block = carveWriteBlock(demo);
5094
- if (block) return refuse2("read-only", block, "Start the walkthrough with `behold demo carve`.");
6297
+ if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
5095
6298
  mkdirSync3(demo.out, { recursive: true });
5096
6299
  const args = [
5097
6300
  "carve",
@@ -5111,7 +6314,7 @@ async function runCarveBridge(demo, select) {
5111
6314
  }));
5112
6315
  const output = merge(run3, demo.root);
5113
6316
  if (run3.code !== 0) {
5114
- return refuse2(
6317
+ return refuse3(
5115
6318
  "carve-action",
5116
6319
  `chant carve bridge exited ${run3.code}: ${output || "(no output)"}`,
5117
6320
  "Run the Emit step first \u2014 bridge reads the carve manifest emit leaves in the output directory."
@@ -5130,8 +6333,8 @@ async function runCarveBridge(demo, select) {
5130
6333
  }
5131
6334
 
5132
6335
  // src/ops.ts
5133
- import { readdirSync as readdirSync4, readFileSync as readFileSync10, existsSync as existsSync9 } from "node:fs";
5134
- import { join as join11 } from "node:path";
6336
+ import { readdirSync as readdirSync6, readFileSync as readFileSync11, existsSync as existsSync9 } from "node:fs";
6337
+ import { join as join14 } from "node:path";
5135
6338
  var APPLY_TARGET_LEXICON = {
5136
6339
  cloudformation: "aws",
5137
6340
  kubectl: "k8s",
@@ -5153,11 +6356,11 @@ function discoverOps(projectDir) {
5153
6356
  const seen = /* @__PURE__ */ new Set();
5154
6357
  const out = [];
5155
6358
  for (const sub of ["ops", "src", "."]) {
5156
- const dir = join11(projectDir, sub);
6359
+ const dir = join14(projectDir, sub);
5157
6360
  if (!existsSync9(dir)) continue;
5158
- for (const f of readdirSync4(dir)) {
6361
+ for (const f of readdirSync6(dir)) {
5159
6362
  if (!f.endsWith(".op.ts")) continue;
5160
- const content = readFileSync10(join11(dir, f), "utf8");
6363
+ const content = readFileSync11(join14(dir, f), "utf8");
5161
6364
  const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
5162
6365
  if (!name || seen.has(name)) continue;
5163
6366
  seen.add(name);
@@ -5165,29 +6368,607 @@ function discoverOps(projectDir) {
5165
6368
  const env = content.match(/\benv:\s*["'`]([^"'`]+)["'`]/)?.[1];
5166
6369
  const target = content.match(/\btarget:\s*["'`]([^"'`]+)["'`]/)?.[1];
5167
6370
  const substrate = target ? APPLY_TARGET_LEXICON[target] : void 0;
6371
+ const irPath = opIrPath(projectDir, name);
6372
+ out.push({
6373
+ name,
6374
+ kind: kindOf(content),
6375
+ dir: projectDir,
6376
+ ...gate ? { gate } : {},
6377
+ ...env ? { env } : {},
6378
+ ...substrate ? { substrate } : {},
6379
+ ...existsSync9(irPath) ? { irPath } : {}
6380
+ });
6381
+ }
6382
+ }
6383
+ return out.sort((a, b) => a.name.localeCompare(b.name));
6384
+ }
6385
+ function opIrPath(projectDir, name) {
6386
+ return join14(projectDir, "dist", "ops", name, "op.json");
6387
+ }
6388
+ function discoverOpIrs(projectDirs) {
6389
+ const seen = /* @__PURE__ */ new Set();
6390
+ const out = [];
6391
+ for (const dir of projectDirs) {
6392
+ const root = join14(dir, "dist", "ops");
6393
+ if (!existsSync9(root)) continue;
6394
+ for (const name of readdirSync6(root)) {
6395
+ if (seen.has(name)) continue;
6396
+ const path = opIrPath(dir, name);
6397
+ if (!existsSync9(path)) continue;
6398
+ seen.add(name);
6399
+ out.push({ name, dir, path });
6400
+ }
6401
+ }
6402
+ return out.sort((a, b) => a.name.localeCompare(b.name));
6403
+ }
6404
+ function discoverEstateOps(projectDirs) {
6405
+ const seen = /* @__PURE__ */ new Set();
6406
+ const out = [];
6407
+ for (const dir of projectDirs) {
6408
+ for (const op of discoverOps(dir)) {
6409
+ if (seen.has(op.name)) continue;
6410
+ seen.add(op.name);
6411
+ out.push(op);
6412
+ }
6413
+ }
6414
+ return out.sort((a, b) => a.name.localeCompare(b.name));
6415
+ }
6416
+
6417
+ // src/run-playhead.ts
6418
+ var RECEIPT_READ_FN = "receiptRead";
6419
+ var RECEIPT_WRITE_FN = "receiptWrite";
6420
+ var STEP_STATUSES = /* @__PURE__ */ new Set(["ok", "fail", "skipped"]);
6421
+ var isRecord4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6422
+ function asStepRecord(value) {
6423
+ if (!isRecord4(value)) return null;
6424
+ if (typeof value.phase !== "string" || typeof value.fn !== "string") return null;
6425
+ if (typeof value.status !== "string" || !STEP_STATUSES.has(value.status)) return null;
6426
+ if (typeof value.durationMs !== "number") return null;
6427
+ return value;
6428
+ }
6429
+ function parseRunProgressLine(line) {
6430
+ let parsed;
6431
+ try {
6432
+ parsed = JSON.parse(line);
6433
+ } catch {
6434
+ return null;
6435
+ }
6436
+ const one = asStepRecord(parsed);
6437
+ if (one) return [one];
6438
+ if (isRecord4(parsed) && typeof parsed.op === "string" && Array.isArray(parsed.records)) {
6439
+ const records = parsed.records.map(asStepRecord).filter((r) => r !== null);
6440
+ return records.length ? records : null;
6441
+ }
6442
+ return null;
6443
+ }
6444
+ function recordLine(record) {
6445
+ const mark = record.status === "ok" ? "\u2713" : record.status === "fail" ? "\u2717" : "\u2022";
6446
+ const took = record.status === "skipped" ? "skipped" : record.durationMs < 1e3 ? `${record.durationMs}ms` : `${(record.durationMs / 1e3).toFixed(1)}s`;
6447
+ return `${mark} ${record.phase} \xB7 ${record.fn} \u2014 ${took}${record.error ? ` \u2014 ${record.error}` : ""}`;
6448
+ }
6449
+ var ANSI = /\x1b\[[0-9;]*m/g;
6450
+ var strip = (s) => s.replace(ANSI, "");
6451
+ function statusFields(stdout) {
6452
+ const out = /* @__PURE__ */ new Map();
6453
+ for (const raw of strip(stdout).split("\n")) {
6454
+ const m = /^\s{2,}([A-Za-z][A-Za-z ]*?)\s*:\s*(.*)$/.exec(raw);
6455
+ if (m) out.set(m[1].trim(), m[2].trim());
6456
+ }
6457
+ return out;
6458
+ }
6459
+ function parseGateLine(line) {
6460
+ const m = /^(.*?)(?: — (.*?))? \(waiting since (.+)\)$/.exec(line.trim());
6461
+ if (!m) return null;
6462
+ const signalName = m[1].trim();
6463
+ if (!signalName) return null;
6464
+ return { signalName, ...m[2] ? { description: m[2].trim() } : {}, since: m[3].trim() };
6465
+ }
6466
+ function readRunStatus(op, run3) {
6467
+ if (run3.code !== 0) {
6468
+ const message = strip(run3.stderr).trim() || strip(run3.stdout).trim() || `chant run status exited ${run3.code}`;
6469
+ if (/not available in local mode|@temporalio\/client is not installed/.test(message)) {
6470
+ return {
6471
+ ok: false,
6472
+ refusal: {
6473
+ error: `No Temporal run state for "${op}" \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
6474
+ code: "no-temporal",
6475
+ remedy: "Gate state and per-step run records come from a durable run. Configure a Temporal profile in chant.config.ts (and `npm install @temporalio/client`), then run the Op with --temporal."
6476
+ }
6477
+ };
6478
+ }
6479
+ if (/not found|NotFound/.test(message)) {
6480
+ return {
6481
+ ok: false,
6482
+ refusal: {
6483
+ error: `No run of "${op}" to report \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
6484
+ code: "no-run",
6485
+ remedy: "Start the Op (Run) and the playhead fills in as each step settles."
6486
+ }
6487
+ };
6488
+ }
6489
+ return {
6490
+ ok: false,
6491
+ refusal: {
6492
+ error: `chant run status ${op} failed: ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
6493
+ code: "run-status",
6494
+ remedy: "Run `chant run status " + op + " --temporal` in the project to see the whole error."
6495
+ }
6496
+ };
6497
+ }
6498
+ const f = statusFields(run3.stdout);
6499
+ const activities = /^(\d+)\/(\d+)/.exec(f.get("Activities") ?? "");
6500
+ const gateLine = f.get("Gate");
6501
+ return {
6502
+ ok: true,
6503
+ read: {
6504
+ op,
6505
+ ...f.get("Workflow ID") ? { workflowId: f.get("Workflow ID") } : {},
6506
+ ...f.get("Run ID") ? { runId: f.get("Run ID") } : {},
6507
+ ...f.get("Status") ? { status: f.get("Status") } : {},
6508
+ ...f.get("Started") ? { startedAt: f.get("Started") } : {},
6509
+ ...f.get("Closed") ? { closedAt: f.get("Closed") } : {},
6510
+ ...activities ? { activities: { completed: Number(activities[1]), scheduled: Number(activities[2]) } } : {},
6511
+ // No Gate line means chant printed none, which it does both when the
6512
+ // query says "nothing pending" and when the Op registers no handler.
6513
+ // Either way: no gate is waiting on a human right now.
6514
+ gate: gateLine ? parseGateLine(gateLine) : null
6515
+ }
6516
+ };
6517
+ }
6518
+ var initialRunState = {
6519
+ op: null,
6520
+ mode: null,
6521
+ status: "idle",
6522
+ records: [],
6523
+ gate: null,
6524
+ gateNote: null,
6525
+ startedAt: null,
6526
+ endedAt: null,
6527
+ lost: null
6528
+ };
6529
+ function runStarted(op, mode, at) {
6530
+ return { ...initialRunState, op, mode, status: "running", startedAt: at };
6531
+ }
6532
+ function runRecord(state, record) {
6533
+ return { ...state, records: [...state.records, record] };
6534
+ }
6535
+ function runGate(state, result) {
6536
+ return result.ok ? { ...state, gate: result.read.gate, gateNote: null } : { ...state, gate: null, gateNote: result.refusal.error };
6537
+ }
6538
+ function runEnded(state, exitCode, at) {
6539
+ if (state.status !== "running") return state;
6540
+ if (exitCode === 0) return { ...state, status: "ok", endedAt: at };
6541
+ if (state.records.some((r) => r.status === "fail")) return { ...state, status: "failed", endedAt: at };
6542
+ return {
6543
+ ...state,
6544
+ status: "lost",
6545
+ endedAt: at,
6546
+ lost: `the progress stream ended (exit ${exitCode}) after ${state.records.length} settled step${state.records.length === 1 ? "" : "s"} without a failed step \u2014 behold does not know whether the Op failed or the stream broke, so the playhead stays at the last settled step.`
6547
+ };
6548
+ }
6549
+ function slotsFor(placed) {
6550
+ const out = [];
6551
+ for (const p of placed) {
6552
+ if (p.step.kind === "activity") {
6553
+ out.push({ id: p.id, phase: p.phase, fn: p.step.fn });
6554
+ } else if (p.step.kind === "effect") {
6555
+ out.push({ id: p.id, phase: p.phase, fn: RECEIPT_READ_FN });
6556
+ for (const nested of p.step.steps ?? []) {
6557
+ if (nested.kind === "activity") out.push({ id: p.id, phase: p.phase, fn: nested.fn });
6558
+ }
6559
+ out.push({ id: p.id, phase: p.phase, fn: RECEIPT_WRITE_FN });
6560
+ }
6561
+ }
6562
+ return out;
6563
+ }
6564
+ function mergeRun(prev, record) {
6565
+ const rank = { fail: 3, ok: 2, skipped: 1 };
6566
+ const keepPrev = prev && rank[prev.status] >= rank[record.status];
6567
+ return {
6568
+ status: keepPrev ? prev.status : record.status,
6569
+ durationMs: (prev?.durationMs ?? 0) + record.durationMs,
6570
+ ...keepPrev && prev.error ? { error: prev.error } : record.error ? { error: record.error } : {},
6571
+ ...keepPrev && prev.outcome ? { outcome: prev.outcome } : record.outcome ? { outcome: `${record.outcome.name}=${String(record.outcome.value)}` } : {},
6572
+ records: (prev?.records ?? 0) + 1
6573
+ };
6574
+ }
6575
+ function joinRun(op, records, gate) {
6576
+ const placed = opPlacedSteps(op);
6577
+ const slots = slotsFor(placed);
6578
+ const byKey = /* @__PURE__ */ new Map();
6579
+ for (const s of slots) {
6580
+ const key = `${s.phase}\0${s.fn}`;
6581
+ if (!byKey.has(key)) byKey.set(key, []);
6582
+ byKey.get(key).push({ id: s.id });
6583
+ }
6584
+ const cursor = /* @__PURE__ */ new Map();
6585
+ const byNode = /* @__PURE__ */ new Map();
6586
+ const unplaced = [];
6587
+ let lastSettledIndex = -1;
6588
+ const indexOf = new Map(placed.map((p, i) => [p.id, i]));
6589
+ for (const record of records) {
6590
+ const key = `${record.phase}\0${record.fn}`;
6591
+ const list3 = byKey.get(key);
6592
+ const i = cursor.get(key) ?? 0;
6593
+ const slot = list3?.[i];
6594
+ if (!slot) {
6595
+ unplaced.push(record);
6596
+ continue;
6597
+ }
6598
+ cursor.set(key, i + 1);
6599
+ byNode.set(slot.id, mergeRun(byNode.get(slot.id), record));
6600
+ if (record.status !== "skipped") {
6601
+ lastSettledIndex = Math.max(lastSettledIndex, indexOf.get(slot.id) ?? -1);
6602
+ }
6603
+ }
6604
+ if (gate) {
6605
+ const gateNode = placed.find((p) => p.step.kind === "gate" && p.step.signalName === gate.signalName);
6606
+ if (gateNode) {
6607
+ return {
6608
+ byNode,
6609
+ at: gateNode.id,
6610
+ atReason: `waiting for ${gate.signalName} since ${gate.since}`,
6611
+ unplaced
6612
+ };
6613
+ }
6614
+ }
6615
+ const next = placed.find((p, i) => i > lastSettledIndex && p.when === "phases");
6616
+ return {
6617
+ byNode,
6618
+ at: lastSettledIndex >= 0 && next ? next.id : null,
6619
+ atReason: lastSettledIndex >= 0 && next ? "reached \u2014 not settled yet" : null,
6620
+ unplaced
6621
+ };
6622
+ }
6623
+ var TONE = {
6624
+ ok: "good",
6625
+ fail: "warn",
6626
+ skipped: "neutral"
6627
+ };
6628
+ var RAN = { ok: "ok", fail: "failed", skipped: "skipped" };
6629
+ function tookLabel(ms) {
6630
+ return ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
6631
+ }
6632
+ function paintPlayhead(ir, join21, gate) {
6633
+ if (!join21.byNode.size && !join21.at) return ir;
6634
+ const nodes = ir.nodes.map((node) => {
6635
+ const run3 = join21.byNode.get(node.id);
6636
+ const here = join21.at === node.id;
6637
+ if (!run3 && !here) return node;
6638
+ const isGate = node.attrs?._step === "gate";
6639
+ const attrs = { ...node.attrs };
6640
+ if (run3) {
6641
+ attrs._status = TONE[run3.status];
6642
+ attrs.run = run3.status === "skipped" ? "skipped" : `${RAN[run3.status]} \xB7 ${tookLabel(run3.durationMs)}`;
6643
+ attrs.ran = tookLabel(run3.durationMs);
6644
+ if (run3.error) attrs.error = run3.error;
6645
+ if (run3.outcome) attrs.outcome = run3.outcome;
6646
+ if (run3.records > 1) attrs.runRecords = run3.records;
6647
+ }
6648
+ if (here) {
6649
+ attrs._playhead = true;
6650
+ attrs._status = isGate && gate ? "warn" : "accent";
6651
+ attrs.run = isGate && gate ? `waiting for approval since ${gate.since}` : join21.atReason ?? "here";
6652
+ if (isGate && gate?.description) attrs.gateDescription = gate.description;
6653
+ }
6654
+ return { ...node, attrs };
6655
+ });
6656
+ return { ...ir, nodes };
6657
+ }
6658
+ function pendingGateCard(op, gate, ir) {
6659
+ const node = ir.nodes.find((n) => n.attrs?._step === "gate" && n.attrs?.gate === gate.signalName && n.attrs?.op === op);
6660
+ return {
6661
+ op,
6662
+ signalName: gate.signalName,
6663
+ ...gate.description ? { description: gate.description } : {},
6664
+ since: gate.since,
6665
+ ...node ? { node: node.id } : {},
6666
+ ...node && typeof node.attrs?.guards === "string" ? { guards: node.attrs.guards } : {},
6667
+ approve: {
6668
+ method: "POST",
6669
+ path: `/api/ops/${encodeURIComponent(op)}/signal/${encodeURIComponent(gate.signalName)}`
6670
+ }
6671
+ };
6672
+ }
6673
+ function playheadNote(state, join21) {
6674
+ if (!state.op) {
6675
+ return "No run state: nothing has been run from this behold session, so every step paints as declared.";
6676
+ }
6677
+ const settled = join21 ? join21.byNode.size : state.records.length;
6678
+ const bits = [];
6679
+ const head = state.status === "idle" ? `Run state for ${state.op}: no per-step records \u2014 this behold session didn't start the run, so only the gate query answers` : `Run: ${state.op} (${state.mode === "temporal" ? "durable" : "local executor"}) \u2014 ${settled} step${settled === 1 ? "" : "s"} settled`;
6680
+ if (state.status === "running") {
6681
+ bits.push(
6682
+ state.mode === "local" ? "still running; the local executor reports every step at once when it ends, so the track fills in on completion" : "still running; only settled steps are painted"
6683
+ );
6684
+ }
6685
+ if (state.status === "ok") bits.push("the run exited cleanly");
6686
+ if (state.status === "failed") bits.push("a step failed");
6687
+ if (state.status === "lost") bits.push(`stream lost \u2014 ${state.lost ?? "the progress stream ended without a verdict"}`);
6688
+ if (state.gate) {
6689
+ bits.push(`waiting on gate ${state.gate.signalName} since ${state.gate.since} \u2014 approve is a delegated signal, behold never signals on its own`);
6690
+ } else if (state.gateNote) {
6691
+ bits.push(`gate state unavailable \u2014 ${state.gateNote}`);
6692
+ }
6693
+ if (join21?.unplaced.length) {
6694
+ bits.push(
6695
+ `${join21.unplaced.length} record${join21.unplaced.length === 1 ? "" : "s"} matched no declared step and ${join21.unplaced.length === 1 ? "is" : "are"} not painted`
6696
+ );
6697
+ }
6698
+ if (!bits.length) return `${head}.`;
6699
+ const rest = bits.join("; ");
6700
+ return `${head} \u2014 ${rest}.`;
6701
+ }
6702
+
6703
+ // src/operator.ts
6704
+ var ANSI2 = /\x1b\[[0-9;]*m/g;
6705
+ var strip2 = (s) => s.replace(ANSI2, "");
6706
+ var isRecord5 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6707
+ var HOW_TO_DECLARE = "An operating loop is a ConvergeOp: declare one in a `*.op.ts` (`ConvergeOp({ name, env, dial, rules })` from @intentius/chant-lexicon-temporal), build the project, and tick it with `chant operator` (or a cron / CronJob invoking it).";
6708
+ function readRow(value) {
6709
+ if (!isRecord5(value)) return null;
6710
+ if (typeof value.op !== "string" || typeof value.env !== "string") return null;
6711
+ if (!Array.isArray(value.pendingGates)) return null;
6712
+ const gates = [];
6713
+ for (const g of value.pendingGates) {
6714
+ if (!isRecord5(g) || typeof g.rule !== "string" || typeof g.gate !== "string") return null;
6715
+ gates.push({ rule: g.rule, gate: g.gate, ...typeof g.op === "string" ? { op: g.op } : {} });
6716
+ }
6717
+ const tick = isRecord5(value.lastTick) ? value.lastTick : void 0;
6718
+ const usable = tick && typeof tick.timestamp === "string" && typeof tick.log === "string";
6719
+ const lease = isRecord5(value.lease) ? value.lease : void 0;
6720
+ return {
6721
+ op: value.op,
6722
+ env: value.env,
6723
+ ...usable ? { lastTick: tick } : {},
6724
+ pendingGates: gates,
6725
+ ...lease && typeof lease.holder === "string" && typeof lease.expiresAt === "string" ? { lease: { holder: lease.holder, expiresAt: lease.expiresAt } } : {}
6726
+ };
6727
+ }
6728
+ function readOperatorStatus(run3) {
6729
+ const err = strip2(run3.stderr);
6730
+ const out = strip2(run3.stdout).trim();
6731
+ if (run3.code !== 0) {
6732
+ const message = err.trim() || out || `chant operator status exited ${run3.code}`;
6733
+ const first = message.split("\n")[0].replace(/^error:\s*/i, "");
6734
+ if (/Unknown command|Unknown operator subcommand/i.test(message)) {
6735
+ return {
6736
+ ok: false,
6737
+ refusal: {
6738
+ error: `This chant has no \`operator status\` \u2014 ${first}`,
6739
+ code: "no-operator-cli",
6740
+ remedy: "The operating loop's status surface landed in chant 0.52 (chant#1485). Upgrade chant to read it."
6741
+ }
6742
+ };
6743
+ }
6744
+ return {
6745
+ ok: false,
6746
+ refusal: {
6747
+ error: `chant operator status failed: ${first}`,
6748
+ code: "operator-status",
6749
+ remedy: "Run `chant operator status --json` in the project to see the whole error."
6750
+ }
6751
+ };
6752
+ }
6753
+ if (!out) {
6754
+ return {
6755
+ ok: false,
6756
+ refusal: {
6757
+ error: "This project declares no operating loop \u2014 chant found no ConvergeOp.",
6758
+ code: "no-operator",
6759
+ remedy: HOW_TO_DECLARE
6760
+ }
6761
+ };
6762
+ }
6763
+ let parsed;
6764
+ try {
6765
+ parsed = JSON.parse(out);
6766
+ } catch (e) {
6767
+ return {
6768
+ ok: false,
6769
+ refusal: {
6770
+ error: `chant operator status --json didn't print JSON: ${e instanceof Error ? e.message : String(e)}`,
6771
+ code: "operator-status",
6772
+ remedy: "Run `chant operator status --json` in the project and check what it printed."
6773
+ }
6774
+ };
6775
+ }
6776
+ if (!Array.isArray(parsed)) {
6777
+ return {
6778
+ ok: false,
6779
+ refusal: {
6780
+ error: "chant operator status --json answered with something that isn't an array of ConvergeOp rows.",
6781
+ code: "operator-status",
6782
+ remedy: "behold reads one row per ConvergeOp: `{op, env, lastTick?, pendingGates[], lease?}`."
6783
+ }
6784
+ };
6785
+ }
6786
+ const rows = [];
6787
+ for (const value of parsed) {
6788
+ const row = readRow(value);
6789
+ if (!row) {
6790
+ return {
6791
+ ok: false,
6792
+ refusal: {
6793
+ error: "chant operator status --json answered with a row behold couldn't read (needs `op`, `env`, `pendingGates[]`).",
6794
+ code: "operator-status",
6795
+ remedy: "Check `chant operator status --json` in the project \u2014 and file the shape change if chant's is the newer one."
6796
+ }
6797
+ };
6798
+ }
6799
+ rows.push(row);
6800
+ }
6801
+ return { ok: true, rows };
6802
+ }
6803
+ function declaredConvergeOps(ops) {
6804
+ return ops.filter((o) => o.searchAttributes?.Converge === "true").map((o) => ({
6805
+ name: o.name,
6806
+ ...o.searchAttributes?.Env ? { env: o.searchAttributes.Env } : {},
6807
+ ...o.searchAttributes?.Dial ? { dial: o.searchAttributes.Dial } : {}
6808
+ }));
6809
+ }
6810
+ var MANAGED_BY = "app.kubernetes.io/managed-by";
6811
+ var COMPONENT = "app.kubernetes.io/component";
6812
+ var NAME_LABEL = "app.kubernetes.io/name";
6813
+ var CHANT = "chant";
6814
+ var CONVERGE_TICK = "converge-tick";
6815
+ var CRONJOB_KIND = "K8s::Batch::CronJob";
6816
+ var NAMESPACE_KIND = "K8s::Core::Namespace";
6817
+ var rec3 = (v) => isRecord5(v) ? v : void 0;
6818
+ var str3 = (v) => typeof v === "string" && v ? v : void 0;
6819
+ var labelsOf = (n) => rec3(rec3(n.attrs?.metadata)?.labels) ?? {};
6820
+ function operatorHomes(ir) {
6821
+ const byNamespace = /* @__PURE__ */ new Map();
6822
+ const conflicted = /* @__PURE__ */ new Set();
6823
+ for (const n of ir.nodes) {
6824
+ if (n.kind !== CRONJOB_KIND) continue;
6825
+ const labels = labelsOf(n);
6826
+ if (labels[MANAGED_BY] !== CHANT || labels[COMPONENT] !== CONVERGE_TICK) continue;
6827
+ const meta = rec3(n.attrs?.metadata);
6828
+ const ns = str3(meta?.namespace);
6829
+ const op = str3(meta?.name);
6830
+ if (!ns || !op) continue;
6831
+ const home = byNamespace.get(ns) ?? { namespace: ns, ticks: [] };
6832
+ const stack = str3(labels[NAME_LABEL]);
6833
+ if (stack && !conflicted.has(ns)) {
6834
+ if (home.stack === void 0) home.stack = stack;
6835
+ else if (home.stack !== stack) {
6836
+ conflicted.add(ns);
6837
+ delete home.stack;
6838
+ }
6839
+ }
6840
+ const schedule = str3(rec3(n.attrs?.spec)?.schedule);
6841
+ home.ticks.push({ node: n.id, op, ...schedule ? { schedule } : {} });
6842
+ byNamespace.set(ns, home);
6843
+ }
6844
+ if (!byNamespace.size) return [];
6845
+ for (const n of ir.nodes) {
6846
+ if (n.kind !== NAMESPACE_KIND) continue;
6847
+ const name = str3(rec3(n.attrs?.metadata)?.name);
6848
+ const home = name ? byNamespace.get(name) : void 0;
6849
+ if (home && !home.node) home.node = n.id;
6850
+ }
6851
+ for (const home of byNamespace.values()) home.ticks.sort((a, b) => a.op.localeCompare(b.op));
6852
+ return [...byNamespace.values()].sort((a, b) => a.namespace.localeCompare(b.namespace));
6853
+ }
6854
+ function markOperatorHome(ir) {
6855
+ const homes = operatorHomes(ir);
6856
+ if (!homes.length) return ir;
6857
+ const marks = /* @__PURE__ */ new Map();
6858
+ for (const home of homes) {
6859
+ if (home.node) {
6860
+ marks.set(home.node, {
6861
+ role: "home",
6862
+ namespace: home.namespace,
6863
+ ...home.stack ? { stack: home.stack } : {},
6864
+ ticks: home.ticks.map((t) => t.op)
6865
+ });
6866
+ }
6867
+ for (const tick of home.ticks) {
6868
+ marks.set(tick.node, {
6869
+ role: "tick",
6870
+ namespace: home.namespace,
6871
+ ...home.stack ? { stack: home.stack } : {},
6872
+ op: tick.op,
6873
+ ...tick.schedule ? { schedule: tick.schedule } : {}
6874
+ });
6875
+ }
6876
+ }
6877
+ return {
6878
+ ...ir,
6879
+ nodes: ir.nodes.map((n) => {
6880
+ const mark = marks.get(n.id);
6881
+ return mark ? { ...n, attrs: { ...n.attrs, _operator: mark } } : n;
6882
+ })
6883
+ };
6884
+ }
6885
+ function operatorStrip(rows, now = Date.now()) {
6886
+ const out = rows.map((r) => {
6887
+ const expiresAt = r.lease ? Date.parse(r.lease.expiresAt) : NaN;
6888
+ const lease = !r.lease ? "free" : Number.isFinite(expiresAt) && expiresAt <= now ? "expired" : "held";
6889
+ return {
6890
+ op: r.op,
6891
+ env: r.env,
6892
+ log: r.lastTick?.log ?? null,
6893
+ at: r.lastTick?.timestamp ?? null,
6894
+ lease,
6895
+ leaseHolder: r.lease?.holder ?? null,
6896
+ leaseExpiresAt: r.lease?.expiresAt ?? null,
6897
+ pendingGates: r.pendingGates.length
6898
+ };
6899
+ });
6900
+ return { rows: out, pendingGates: out.reduce((n, r) => n + r.pendingGates, 0) };
6901
+ }
6902
+ var CONVERGE_GATE_LOOP = "converge";
6903
+ var APPROVE_SEMANTICS = "Approving records a fact in the gate ledger; it does not unblock the dispatch. The next tick reads it.";
6904
+ function convergeGateCards(rows) {
6905
+ const out = [];
6906
+ for (const row of rows) {
6907
+ for (const g of row.pendingGates) {
6908
+ if (!g.op) continue;
5168
6909
  out.push({
5169
- name,
5170
- kind: kindOf(content),
5171
- dir: projectDir,
5172
- ...gate ? { gate } : {},
5173
- ...env ? { env } : {},
5174
- ...substrate ? { substrate } : {}
6910
+ loop: CONVERGE_GATE_LOOP,
6911
+ rule: g.rule,
6912
+ op: g.op,
6913
+ gate: g.gate,
6914
+ convergeOp: row.op,
6915
+ env: row.env,
6916
+ since: row.lastTick?.timestamp ?? null,
6917
+ semantics: APPROVE_SEMANTICS,
6918
+ approve: {
6919
+ method: "POST",
6920
+ path: `/api/operator/approve/${encodeURIComponent(g.op)}/${encodeURIComponent(g.gate)}`,
6921
+ command: `chant approve ${g.op} ${g.gate}`
6922
+ }
5175
6923
  });
5176
6924
  }
5177
6925
  }
5178
- return out.sort((a, b) => a.name.localeCompare(b.name));
6926
+ return out;
5179
6927
  }
5180
- function discoverEstateOps(projectDirs) {
5181
- const seen = /* @__PURE__ */ new Set();
5182
- const out = [];
5183
- for (const dir of projectDirs) {
5184
- for (const op of discoverOps(dir)) {
5185
- if (seen.has(op.name)) continue;
5186
- seen.add(op.name);
5187
- out.push(op);
5188
- }
6928
+ var initialOperatorState = {
6929
+ declared: [],
6930
+ strip: null,
6931
+ gates: [],
6932
+ note: null,
6933
+ code: null,
6934
+ readAt: null
6935
+ };
6936
+ function operatorRead(state, result, at, now = Date.now()) {
6937
+ if (!result.ok) {
6938
+ return { ...state, strip: null, gates: [], note: result.refusal.error, code: result.refusal.code, readAt: at };
5189
6939
  }
5190
- return out.sort((a, b) => a.name.localeCompare(b.name));
6940
+ return {
6941
+ ...state,
6942
+ strip: operatorStrip(result.rows, now),
6943
+ gates: convergeGateCards(result.rows),
6944
+ note: null,
6945
+ code: null,
6946
+ readAt: at
6947
+ };
6948
+ }
6949
+ function operatorNote(state) {
6950
+ if (!state.declared.length) {
6951
+ return "No operating loop: this project declares no ConvergeOp, so there is no operator strip.";
6952
+ }
6953
+ const loops = `${state.declared.length} ConvergeOp${state.declared.length === 1 ? "" : "s"}`;
6954
+ if (!state.strip) {
6955
+ return state.note ? `Operating loop: ${loops} declared \u2014 status unavailable: ${state.note}` : `Operating loop: ${loops} declared \u2014 not read yet.`;
6956
+ }
6957
+ const bits = [];
6958
+ const ticked = state.strip.rows.filter((r) => r.at).length;
6959
+ bits.push(
6960
+ ticked ? `last tick only \u2014 chant's status surface exposes one tick per loop, not the history (chant#2029), so this is a strip and not a timeline` : `no tick recorded yet on any loop`
6961
+ );
6962
+ const held = state.strip.rows.filter((r) => r.lease === "held").length;
6963
+ if (held) bits.push(`${held} lease${held === 1 ? "" : "s"} held`);
6964
+ const expired = state.strip.rows.filter((r) => r.lease === "expired").length;
6965
+ if (expired) bits.push(`${expired} lease${expired === 1 ? "" : "s"} expired \u2014 the next round reclaims it`);
6966
+ if (state.strip.pendingGates) {
6967
+ bits.push(
6968
+ `${state.strip.pendingGates} converge gate${state.strip.pendingGates === 1 ? "" : "s"} pending \u2014 approving one records a fact for the next tick, it does not release a workflow (that is the run gate, a different card), and a gate fact carries no URL yet (chant#2028)`
6969
+ );
6970
+ }
6971
+ return `Operating loop: ${loops} \u2014 ${bits.join("; ")}.`;
5191
6972
  }
5192
6973
 
5193
6974
  // src/adopt.ts
@@ -5270,21 +7051,21 @@ function classifyHealth(status) {
5270
7051
  if (HEALTHY.test(text)) return "healthy";
5271
7052
  return "unknown";
5272
7053
  }
5273
- function rec2(v) {
7054
+ function rec4(v) {
5274
7055
  return typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
5275
7056
  }
5276
- function str2(v) {
7057
+ function str4(v) {
5277
7058
  return typeof v === "string" && v.length > 0 ? v : void 0;
5278
7059
  }
5279
7060
  function statusTree(o) {
5280
- const attrs = rec2(o.attributes);
7061
+ const attrs = rec4(o.attributes);
5281
7062
  if (!attrs) return void 0;
5282
- return rec2(attrs.status) ?? attrs;
7063
+ return rec4(attrs.status) ?? attrs;
5283
7064
  }
5284
7065
  function specTree(o) {
5285
- const attrs = rec2(o.attributes);
7066
+ const attrs = rec4(o.attributes);
5286
7067
  if (!attrs) return void 0;
5287
- return rec2(attrs.spec) ?? attrs;
7068
+ return rec4(attrs.spec) ?? attrs;
5288
7069
  }
5289
7070
  var ARGO_HEALTH = {
5290
7071
  Healthy: "healthy",
@@ -5310,9 +7091,9 @@ function argoErrorMessage(tree) {
5310
7091
  if (m) return m[2];
5311
7092
  continue;
5312
7093
  }
5313
- const type = str2(rec2(c)?.type);
7094
+ const type = str4(rec4(c)?.type);
5314
7095
  if (!type || !ARGO_ERROR_CONDITION.test(type)) continue;
5315
- const message = str2(rec2(c)?.message);
7096
+ const message = str4(rec4(c)?.message);
5316
7097
  if (message) return message;
5317
7098
  }
5318
7099
  return void 0;
@@ -5320,10 +7101,10 @@ function argoErrorMessage(tree) {
5320
7101
  function argoVerdict(o) {
5321
7102
  if (o.type !== "K8s::Argo::Application") return void 0;
5322
7103
  const tree = statusTree(o);
5323
- let health = str2(rec2(tree?.health)?.status);
5324
- let sync = str2(rec2(tree?.sync)?.status);
7104
+ let health = str4(rec4(tree?.health)?.status);
7105
+ let sync = str4(rec4(tree?.sync)?.status);
5325
7106
  if (!health && !sync) {
5326
- const word = str2(o.status);
7107
+ const word = str4(o.status);
5327
7108
  if (word === "READY") {
5328
7109
  health = "Healthy";
5329
7110
  sync = "Synced";
@@ -5349,16 +7130,16 @@ function readyFromConditions(tree) {
5349
7130
  const conditions = tree?.conditions;
5350
7131
  if (!Array.isArray(conditions)) return void 0;
5351
7132
  for (const c of conditions) {
5352
- const cond = rec2(c);
7133
+ const cond = rec4(c);
5353
7134
  if (!cond || cond.type !== "Ready") continue;
5354
- const status = str2(cond.status);
7135
+ const status = str4(cond.status);
5355
7136
  if (status !== "True" && status !== "False" && status !== "Unknown") continue;
5356
- return { status, reason: str2(cond.reason) };
7137
+ return { status, reason: str4(cond.reason) };
5357
7138
  }
5358
7139
  return void 0;
5359
7140
  }
5360
7141
  function readyFromChantConditions(o) {
5361
- const conditions = rec2(o.attributes)?.conditions;
7142
+ const conditions = rec4(o.attributes)?.conditions;
5362
7143
  if (!Array.isArray(conditions)) return void 0;
5363
7144
  for (const c of conditions) {
5364
7145
  if (typeof c !== "string") continue;
@@ -5370,7 +7151,7 @@ function readyFromChantConditions(o) {
5370
7151
  return void 0;
5371
7152
  }
5372
7153
  function readyFromStatusWord(o) {
5373
- const word = str2(o.status);
7154
+ const word = str4(o.status);
5374
7155
  if (!word || word === "PRESENT") return void 0;
5375
7156
  if (word === "READY") return { status: "True" };
5376
7157
  if (word === "NOT-READY") return { status: "False" };
@@ -5380,8 +7161,8 @@ function describeReady(ready) {
5380
7161
  return `Ready=${ready.status}${ready.reason ? ` (${ready.reason})` : ""}`;
5381
7162
  }
5382
7163
  function revisionVerdict(tree) {
5383
- const applied = str2(tree?.lastAppliedRevision);
5384
- const attempted = str2(tree?.lastAttemptedRevision);
7164
+ const applied = str4(tree?.lastAppliedRevision);
7165
+ const attempted = str4(tree?.lastAttemptedRevision);
5385
7166
  if (!applied || !attempted || applied === attempted) return void 0;
5386
7167
  return { health: "progressing", detail: `applied ${applied}, attempted ${attempted}` };
5387
7168
  }
@@ -5394,23 +7175,23 @@ function fluxVerdict(o) {
5394
7175
  if (ready?.status === "False") return { health: "degraded", detail: describeReady(ready) };
5395
7176
  if (ready?.status === "Unknown") return { health: "progressing", detail: describeReady(ready) };
5396
7177
  if (ready?.status === "True") return revisionVerdict(tree) ?? { health: "healthy", detail: describeReady(ready) };
5397
- if (o.type === FLUX_HELMREPOSITORY_TYPE && str2(specTree(o)?.type) === "oci") {
7178
+ if (o.type === FLUX_HELMREPOSITORY_TYPE && str4(specTree(o)?.type) === "oci") {
5398
7179
  return { health: "healthy", detail: "spec.type=oci (nothing to reconcile)" };
5399
7180
  }
5400
7181
  const conditions = tree?.conditions;
5401
7182
  const reconciling = Array.isArray(conditions) && conditions.some((c) => {
5402
- const cond = rec2(c);
7183
+ const cond = rec4(c);
5403
7184
  return cond?.type === "Reconciling" && cond.status === "True";
5404
7185
  });
5405
7186
  if (reconciling) return { health: "progressing", detail: "Reconciling=True" };
5406
- if (!str2(o.status) || o.status === "PRESENT") {
7187
+ if (!str4(o.status) || o.status === "PRESENT") {
5407
7188
  return { health: "progressing", detail: "no Ready condition yet" };
5408
7189
  }
5409
7190
  return void 0;
5410
7191
  }
5411
7192
  var DEPLOYMENT_TYPE = "K8s::Apps::Deployment";
5412
7193
  function unhappyCondition(o, type) {
5413
- const fromStrings = rec2(o.attributes)?.conditions;
7194
+ const fromStrings = rec4(o.attributes)?.conditions;
5414
7195
  if (Array.isArray(fromStrings)) {
5415
7196
  for (const c of fromStrings) {
5416
7197
  if (typeof c === "string" && c.startsWith(type)) return c;
@@ -5420,13 +7201,13 @@ function unhappyCondition(o, type) {
5420
7201
  const raw = tree?.conditions;
5421
7202
  if (Array.isArray(raw)) {
5422
7203
  for (const c of raw) {
5423
- const cond = rec2(c);
7204
+ const cond = rec4(c);
5424
7205
  if (!cond || cond.type !== type) continue;
5425
- const status = str2(cond.status);
7206
+ const status = str4(cond.status);
5426
7207
  const unhappy = type === "ReplicaFailure" ? status === "True" : status === "False";
5427
7208
  if (!unhappy) continue;
5428
- const reason = str2(cond.reason);
5429
- const message = str2(cond.message);
7209
+ const reason = str4(cond.reason);
7210
+ const message = str4(cond.message);
5430
7211
  return `${type}${reason ? `=${reason}` : ""}${message ? `: ${message}` : ""}`;
5431
7212
  }
5432
7213
  }
@@ -5555,6 +7336,19 @@ var OpRunner = class {
5555
7336
  * before the first event lands so a reload between trigger and first event
5556
7337
  * doesn't show the PREVIOUS run's stale terminal state). */
5557
7338
  lastApplyProgress = initialApplyProgress;
7339
+ /** The run playhead (#284 item 2): the settled StepRecords of the last Op
7340
+ * `trigger()` started, plus the last `gateState` answer. Kept after the run
7341
+ * ends and across renders for the same reason `lastApplyProgress` is — a
7342
+ * client opening the ops lens mid-run (or after a reload) hydrates the
7343
+ * playhead instead of starting blank. In-process only: a run started
7344
+ * elsewhere is invisible here, and `chant run status` (src/server.ts's
7345
+ * `/api/ops/:name/status`) is what answers for one. */
7346
+ lastRunState = initialRunState;
7347
+ /** The operator strip (#234 join 3): the last `chant operator status --json`
7348
+ * read, plus the ConvergeOps the built project declares. Held here for the
7349
+ * reason `lastRunState` is — it is a durable status read, not a run, and a
7350
+ * client opening the ops lens hydrates from it instead of starting blank. */
7351
+ lastOperatorState = initialOperatorState;
5558
7352
  /** #195: re-point delegated writes at a switched project. The runner reads
5559
7353
  * `deps.projectDir` at trigger time, so this is the whole retarget. */
5560
7354
  retarget(projectDir) {
@@ -5569,6 +7363,32 @@ var OpRunner = class {
5569
7363
  get applyProgress() {
5570
7364
  return this.lastApplyProgress;
5571
7365
  }
7366
+ /** The run playhead's model (#284 item 2) — `initialRunState` until an Op has
7367
+ * been triggered this session. */
7368
+ get runState() {
7369
+ return this.lastRunState;
7370
+ }
7371
+ /**
7372
+ * Fold a `chant run status <name>` read (src/run-playhead.ts's
7373
+ * `readRunStatus`) into the playhead, and broadcast it.
7374
+ *
7375
+ * The gate is a workflow QUERY, not part of the record stream, so it can only
7376
+ * arrive this way. A read for an Op other than the one being watched is
7377
+ * returned to the caller but not folded — one playhead, one Op, and silently
7378
+ * re-pointing it at whatever was last polled would make the picture lie about
7379
+ * which run it shows. A read arriving before anything has run this session
7380
+ * adopts the Op: a gate pending on a run behold didn't start is exactly the
7381
+ * case the pending card exists for.
7382
+ */
7383
+ noteGate(op, result) {
7384
+ if (this.lastRunState.op !== null && this.lastRunState.op !== op) return this.lastRunState;
7385
+ const base = this.lastRunState.op === op ? this.lastRunState : { ...initialRunState, op, mode: "temporal" };
7386
+ const next = runGate(base, result);
7387
+ const changed = JSON.stringify(next) !== JSON.stringify(this.lastRunState);
7388
+ this.lastRunState = next;
7389
+ if (changed) this.emitRun();
7390
+ return this.lastRunState;
7391
+ }
5572
7392
  /**
5573
7393
  * Start `chant run <name>` unless one is already running (the Sync/Adopt/auto-
5574
7394
  * sync path). `cwd` is the Op's own project dir (#31 multi-estate); defaults to
@@ -5578,9 +7398,78 @@ var OpRunner = class {
5578
7398
  * set it from the Op's declared gate: chant refuses a gated Op outright in
5579
7399
  * local mode ("gates and schedules need a durable runtime"), so without the
5580
7400
  * flag a gated Op could never run from behold at all.
7401
+ *
7402
+ * Either way the run is asked for structured per-step records (#284 item 2,
7403
+ * chant#1676) — `--progress-json` streams one NDJSON `StepRecord` per settled
7404
+ * step on the durable path; `--json` prints the whole `OpRunResult` at the end
7405
+ * on the local one. Same record shape, which is what lets one playhead render
7406
+ * both. `--json` replaces chant's own human per-step summary on the local
7407
+ * path, so each parsed record is re-emitted to the `op` now-line
7408
+ * ({@link recordLine}) and the operator loses no narration.
5581
7409
  */
5582
7410
  trigger(name, opEnv, cwd, temporal) {
5583
- return this.start(temporal ? ["run", name, "--temporal"] : ["run", name], name, opEnv, cwd);
7411
+ if (this.current) return false;
7412
+ const mode = temporal ? "temporal" : "local";
7413
+ const args = temporal ? ["run", name, "--temporal", "--progress-json"] : ["run", name, "--json"];
7414
+ this.lastRunState = runStarted(name, mode, (/* @__PURE__ */ new Date()).toISOString());
7415
+ this.emitRun();
7416
+ return this.start(
7417
+ args,
7418
+ name,
7419
+ opEnv,
7420
+ cwd,
7421
+ (line) => {
7422
+ const records = parseRunProgressLine(line);
7423
+ if (!records) return false;
7424
+ for (const record of records) {
7425
+ this.lastRunState = runRecord(this.lastRunState, record);
7426
+ this.deps.broadcaster.emit("op", recordLine(record));
7427
+ }
7428
+ this.emitRun();
7429
+ return true;
7430
+ },
7431
+ (code) => {
7432
+ this.lastRunState = runEnded(this.lastRunState, code, (/* @__PURE__ */ new Date()).toISOString());
7433
+ this.emitRun();
7434
+ }
7435
+ );
7436
+ }
7437
+ emitRun() {
7438
+ this.deps.broadcaster.emit("run", JSON.stringify(this.lastRunState));
7439
+ }
7440
+ /** The operator strip's model (#234 join 3) — `initialOperatorState` until the
7441
+ * ops lens has been opened once. */
7442
+ get operatorState() {
7443
+ return this.lastOperatorState;
7444
+ }
7445
+ /**
7446
+ * Record which ConvergeOps the built project declares, read from the emitted
7447
+ * op.json (src/operator.ts's `declaredConvergeOps`) — no subprocess, and the
7448
+ * thing that gates the strip. Broadcast on a change so a project rebuilt (or
7449
+ * switched, #195) under an open tab grows or loses its strip without a reload.
7450
+ */
7451
+ noteDeclaredConvergeOps(declared) {
7452
+ return this.foldOperator({ ...this.lastOperatorState, declared });
7453
+ }
7454
+ /**
7455
+ * Fold a `chant operator status --json` read (src/operator.ts's
7456
+ * `readOperatorStatus`) into the strip, and broadcast it.
7457
+ *
7458
+ * Broadcast only a CHANGED answer, for `noteGate`'s reason: a client asks when
7459
+ * it opens the ops lens and re-pulls the lens on the broadcast, so an
7460
+ * unchanged answer that still broadcast would have the two chasing each other.
7461
+ */
7462
+ noteOperator(result, at = (/* @__PURE__ */ new Date()).toISOString()) {
7463
+ return this.foldOperator(operatorRead(this.lastOperatorState, result, at));
7464
+ }
7465
+ /** Compare-and-broadcast, ignoring `readAt` — the instant behold asked changes
7466
+ * on every poll and is not itself news; broadcasting on it would make the
7467
+ * interval a repaint loop. */
7468
+ foldOperator(next) {
7469
+ const same = JSON.stringify({ ...next, readAt: null }) === JSON.stringify({ ...this.lastOperatorState, readAt: null });
7470
+ this.lastOperatorState = next;
7471
+ if (!same) this.deps.broadcaster.emit("operator", JSON.stringify(next));
7472
+ return this.lastOperatorState;
5584
7473
  }
5585
7474
  /**
5586
7475
  * Run an arbitrary `chant` invocation through the same guard/stream/PR/capture
@@ -5712,8 +7601,12 @@ var OpRunner = class {
5712
7601
  * fully handled that line (apply()'s progress-JSON parsing) — `start()` then
5713
7602
  * skips its own `op`/`pr` broadcast for that one line, leaving every other
5714
7603
  * line's raw-log fallback untouched.
7604
+ *
7605
+ * `onExit` (the run playhead, #284 item 2) sees the exit code the moment the
7606
+ * process ends — the only signal that separates "the run finished" from "the
7607
+ * stream stopped", and the reason the playhead never has to infer completion.
5715
7608
  */
5716
- start(args, label, opEnv, cwd, onLine) {
7609
+ start(args, label, opEnv, cwd, onLine, onExit) {
5717
7610
  if (this.current) return false;
5718
7611
  const { projectDir, broadcaster } = this.deps;
5719
7612
  broadcaster.emit("op", `\u25B6 chant ${args.join(" ")}`);
@@ -5726,6 +7619,7 @@ var OpRunner = class {
5726
7619
  this.current = label;
5727
7620
  void op.done.then((code) => {
5728
7621
  broadcaster.emit("op", `\u25A0 ${label} exited ${code}`);
7622
+ onExit?.(code);
5729
7623
  this.current = null;
5730
7624
  Promise.resolve(this.deps.onDone(opEnv)).then(() => broadcaster.emit("changed")).catch((err) => broadcaster.emit("op", `\u26A0 post-op capture: ${err instanceof Error ? err.message : String(err)}`));
5731
7625
  });
@@ -5735,24 +7629,24 @@ var OpRunner = class {
5735
7629
 
5736
7630
  // src/substrates.ts
5737
7631
  import { spawn as spawn4 } from "node:child_process";
5738
- import { existsSync as existsSync10, readFileSync as readFileSync11 } from "node:fs";
5739
- import { join as join12 } from "node:path";
7632
+ import { existsSync as existsSync10, readFileSync as readFileSync12 } from "node:fs";
7633
+ import { join as join15 } from "node:path";
5740
7634
  import { platform } from "node:os";
5741
7635
  var DOCKER_DOWN_DETAIL = "docker is down";
5742
7636
  function probe(cmd, args) {
5743
- return new Promise((resolve8) => {
7637
+ return new Promise((resolve9) => {
5744
7638
  let out = "";
5745
7639
  let proc;
5746
7640
  try {
5747
7641
  proc = spawn4(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
5748
7642
  } catch {
5749
- resolve8({ code: 127, out: "" });
7643
+ resolve9({ code: 127, out: "" });
5750
7644
  return;
5751
7645
  }
5752
7646
  proc.stdout.on("data", (d) => out += d);
5753
7647
  proc.stderr.on("data", (d) => out += d);
5754
- proc.on("error", () => resolve8({ code: 127, out }));
5755
- proc.on("close", (code) => resolve8({ code: code ?? 1, out }));
7648
+ proc.on("error", () => resolve9({ code: 127, out }));
7649
+ proc.on("close", (code) => resolve9({ code: code ?? 1, out }));
5756
7650
  });
5757
7651
  }
5758
7652
  async function dockerAvailable(run3) {
@@ -5765,11 +7659,11 @@ async function dockerRunning(run3, nameFilter) {
5765
7659
  return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
5766
7660
  }
5767
7661
  function scriptBringUp(projectDir, relPath, label) {
5768
- return existsSync10(join12(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
7662
+ return existsSync10(join15(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
5769
7663
  }
5770
7664
  function projectLexicons(projectDir) {
5771
7665
  try {
5772
- const src = readFileSync11(join12(projectDir, "chant.config.ts"), "utf-8");
7666
+ const src = readFileSync12(join15(projectDir, "chant.config.ts"), "utf-8");
5773
7667
  const m = src.match(/lexicons\s*:\s*\[([^\]]*)\]/);
5774
7668
  if (!m) return [];
5775
7669
  return [...m[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1]);
@@ -5821,7 +7715,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
5821
7715
  ["forgejo", "Forgejo", ".forgejo", "test/forgejo-runtime-e2e.sh"]
5822
7716
  ];
5823
7717
  for (const [name, label, marker, script] of forges) {
5824
- if (!existsSync10(join12(projectDir, marker))) continue;
7718
+ if (!existsSync10(join15(projectDir, marker))) continue;
5825
7719
  const c = docker ? await dockerRunning(run3, name) : [];
5826
7720
  const d = dep(c.length > 0, "container up", "on-demand (pipeline run)");
5827
7721
  subs.push({
@@ -5855,7 +7749,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
5855
7749
  detail: endpoint ? `targeting ${endpoint}` : "real Fly (FLY_FLAPS_BASE_URL unset)"
5856
7750
  });
5857
7751
  }
5858
- if (existsSync10(join12(projectDir, ".github", "workflows"))) {
7752
+ if (existsSync10(join15(projectDir, ".github", "workflows"))) {
5859
7753
  const gh = await run3("gh", ["auth", "status"]);
5860
7754
  const ready = gh.code === 0;
5861
7755
  subs.push({
@@ -5868,7 +7762,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
5868
7762
  if (lexicons.includes("temporal")) {
5869
7763
  let hasProfiles = false;
5870
7764
  try {
5871
- hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync11(join12(projectDir, "chant.config.ts"), "utf-8"));
7765
+ hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync12(join15(projectDir, "chant.config.ts"), "utf-8"));
5872
7766
  } catch {
5873
7767
  }
5874
7768
  subs.push({
@@ -5971,185 +7865,113 @@ async function openRollbackBranches(projectDir, env) {
5971
7865
  }
5972
7866
  }
5973
7867
 
5974
- // src/estate.ts
5975
- import { statSync as statSync3 } from "node:fs";
5976
- import { availableParallelism } from "node:os";
5977
- import { join as join13, resolve as resolve3, sep as sep2 } from "node:path";
5978
- import { composeStacks, shortStackNames } from "@intentius/pinhole";
5979
- function estateReadPool(members, env = process.env) {
5980
- const override = Number.parseInt(env.BEHOLD_ESTATE_CONCURRENCY ?? "", 10);
5981
- const cap = Number.isInteger(override) && override >= 1 ? override : Math.min(4, availableParallelism());
5982
- return Math.max(1, Math.min(cap, members));
5983
- }
5984
- async function mapPool(items, width, fn) {
5985
- const results = new Array(items.length);
5986
- let next = 0;
5987
- const worker = async () => {
5988
- for (let i = next++; i < items.length; i = next++) {
5989
- results[i] = await fn(items[i], i);
5990
- }
5991
- };
5992
- await Promise.all(Array.from({ length: Math.max(1, Math.min(width, items.length)) }, () => worker()));
5993
- return results;
5994
- }
5995
- async function composeEstate(projectDirs, opts = {}) {
5996
- const names = shortStackNames(projectDirs);
5997
- const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => ({
5998
- name: names[i],
5999
- ir: await graphIr(dir, opts)
6000
- }));
6001
- return composeStacks(stacks);
6002
- }
6003
- var firstLine2 = (e) => {
6004
- const msg = e instanceof Error ? e.message : String(e);
6005
- const m = msg.match(/exited \d+:\s*([\s\S]*)/);
6006
- return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
6007
- };
6008
- function namespaceRuntimeOwners(name, ir) {
6009
- const own = new Set(ir.nodes.map((n) => n.id));
6010
- for (const n of ir.nodes) {
6011
- if (n.runtimeOwner && own.has(n.runtimeOwner)) n.runtimeOwner = `${name}/${n.runtimeOwner}`;
6012
- }
6013
- return ir;
6014
- }
6015
- var NAMESPACE_JOIN_FLOOR = "0.44.5";
6016
- var rec3 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
6017
- var str3 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
6018
- var realIsDir = (p) => {
7868
+ // src/estate-edges.ts
7869
+ import { statSync as statSync6 } from "node:fs";
7870
+ var rec5 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
7871
+ var str5 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
7872
+ var realIsDir2 = (p) => {
6019
7873
  try {
6020
- return statSync3(p).isDirectory();
7874
+ return statSync6(p).isDirectory();
6021
7875
  } catch {
6022
7876
  return false;
6023
7877
  }
6024
7878
  };
6025
- function declaredNamespaceBindings(ir) {
6026
- const out = [];
7879
+ function declaredDeliveries(n) {
7880
+ const spec = rec5(n.attrs?.spec);
7881
+ if (n.kind === "K8s::Flux::Kustomization") {
7882
+ const path = str5(spec?.path);
7883
+ return path ? [{ from: n.id, path, viaAttr: "reconciles" }] : [];
7884
+ }
7885
+ if (n.kind === "K8s::Argo::Application") {
7886
+ const sources = [spec?.source, ...Array.isArray(spec?.sources) ? spec.sources : []];
7887
+ const out = [];
7888
+ for (const s of sources) {
7889
+ const path = str5(rec5(s)?.path);
7890
+ if (path) out.push({ from: n.id, path, viaAttr: "delivers" });
7891
+ }
7892
+ return out;
7893
+ }
7894
+ return [];
7895
+ }
7896
+ function membershipOf(ir, members) {
7897
+ const named = new Set(members.map((m) => m.name));
7898
+ const byStack = ir.groups?.byStack;
7899
+ const out = /* @__PURE__ */ new Map();
7900
+ const grouped = rec5(byStack);
7901
+ if (grouped) {
7902
+ for (const [name, ids] of Object.entries(grouped)) {
7903
+ if (!named.has(name) || !Array.isArray(ids)) continue;
7904
+ for (const id of ids) if (typeof id === "string") out.set(id, name);
7905
+ }
7906
+ }
7907
+ if (out.size > 0) return out;
7908
+ const byLength = [...members].sort((a, b) => b.name.length - a.name.length);
6027
7909
  for (const n of ir.nodes) {
6028
- if (n.kind !== "K8s::Flux::Kustomization") continue;
6029
- const spec = rec3(n.attrs?.spec);
6030
- const namespace = str3(spec?.targetNamespace);
6031
- const path = str3(spec?.path);
6032
- if (namespace && path) out.push({ path, namespace });
7910
+ const m = byLength.find((c) => n.id.startsWith(`${c.name}/`));
7911
+ if (m) out.set(n.id, m.name);
6033
7912
  }
6034
7913
  return out;
6035
7914
  }
6036
- function awaitsNamespaceBinding(ir) {
6037
- const namespaced = ir.nodes.filter((n) => n.lexicon === "k8s" && !CLUSTER_SCOPED.has(n.kind ?? ""));
6038
- if (namespaced.length === 0) return false;
6039
- return namespaced.every((n) => !str3(rec3(n.attrs?.metadata)?.namespace));
6040
- }
6041
- function declaredSegments(path) {
6042
- if (path.startsWith("/")) return void 0;
6043
- const segs = path.split("/").filter((s) => s !== "" && s !== ".");
6044
- if (segs.length === 0 || segs.includes("..")) return void 0;
6045
- return segs;
6046
- }
6047
- function pathAlignment(declared, dir, isDir = realIsDir) {
6048
- const want = declaredSegments(declared);
6049
- if (!want) return 0;
6050
- const root = resolve3(dir);
6051
- const have = root.split(sep2).filter(Boolean);
6052
- for (let k = Math.min(want.length, have.length); k >= 1; k--) {
6053
- if (!have.slice(-k).every((s, i) => s === want[i])) continue;
6054
- if (!isDir(join13(root, ...want.slice(k)))) continue;
6055
- return k;
7915
+ function deriveEstateMemberEdges(ir, members, isDir = realIsDir2) {
7916
+ if (members.length < 2) return [];
7917
+ const memberOf = membershipOf(ir, members);
7918
+ if (memberOf.size === 0) return [];
7919
+ const present = new Set(ir.nodes.map((n) => n.id));
7920
+ const pointedAt = /* @__PURE__ */ new Set();
7921
+ for (const e of ir.edges) {
7922
+ const from = memberOf.get(e.from);
7923
+ if (from && from === memberOf.get(e.to)) pointedAt.add(e.to);
6056
7924
  }
6057
- return 0;
6058
- }
6059
- function joinNamespaceBindings(members, isDir = realIsDir) {
6060
- const bindings = members.flatMap(
6061
- (m) => m.ir ? declaredNamespaceBindings(m.ir).map((b) => ({ ...b, declaredBy: m.dir })) : []
6062
- );
6063
- if (bindings.length === 0) return [];
6064
- const awaiting = members.filter((m) => m.ir && awaitsNamespaceBinding(m.ir));
6065
- if (awaiting.length === 0) return [];
6066
- const best = /* @__PURE__ */ new Map();
6067
- for (const b of bindings) {
6068
- let winner;
6069
- let tied = false;
6070
- for (const m of awaiting) {
6071
- if (m.dir === b.declaredBy) continue;
6072
- const score = pathAlignment(b.path, m.dir, isDir);
6073
- if (score === 0) continue;
6074
- if (!winner || score > winner.score) {
6075
- winner = { dir: m.dir, score };
6076
- tied = false;
6077
- } else if (score === winner.score) {
6078
- tied = true;
7925
+ const entryNodes = (name, lexicon) => {
7926
+ const own = ir.nodes.filter((n) => memberOf.get(n.id) === name && n.lexicon === lexicon).map((n) => n.id);
7927
+ const entries = own.filter((id) => !pointedAt.has(id));
7928
+ return entries.length > 0 ? entries : own;
7929
+ };
7930
+ const out = [];
7931
+ const seen = /* @__PURE__ */ new Set();
7932
+ for (const n of ir.nodes) {
7933
+ const home = memberOf.get(n.id);
7934
+ if (!home) continue;
7935
+ for (const delivery of declaredDeliveries(n)) {
7936
+ let winner;
7937
+ let tied = false;
7938
+ for (const m of members) {
7939
+ if (m.name === home) continue;
7940
+ const score = pathAlignment(delivery.path, m.dir, isDir);
7941
+ if (score === 0) continue;
7942
+ if (!winner || score > winner.score) {
7943
+ winner = { name: m.name, score };
7944
+ tied = false;
7945
+ } else if (score === winner.score) {
7946
+ tied = true;
7947
+ }
7948
+ }
7949
+ if (!winner || tied) continue;
7950
+ for (const to of entryNodes(winner.name, n.lexicon)) {
7951
+ if (!present.has(to) || to === delivery.from) continue;
7952
+ const key = `${delivery.from}\0${to}`;
7953
+ if (seen.has(key)) continue;
7954
+ seen.add(key);
7955
+ out.push({ from: delivery.from, to, kind: "ref", viaAttr: delivery.viaAttr, inferred: true });
6079
7956
  }
6080
7957
  }
6081
- if (!winner || tied) continue;
6082
- const prev = best.get(winner.dir);
6083
- if (prev === "ambiguous") continue;
6084
- const join19 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
6085
- if (!prev) best.set(winner.dir, { score: winner.score, join: join19 });
6086
- else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
6087
- else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join19 });
6088
7958
  }
6089
- return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
7959
+ return out;
6090
7960
  }
6091
- async function estateNamespaceScopes(projectDirs, opts, isDir = realIsDir) {
6092
- if (projectDirs.length < 2) return /* @__PURE__ */ new Map();
6093
- const { live: _live, overlay: _overlay, namespace: _namespace, ...src } = opts;
6094
- const irs = await mapPool(
6095
- projectDirs,
6096
- estateReadPool(projectDirs.length),
6097
- (dir) => graphIr(dir, { ...src, detail: 3 }).catch(() => void 0)
6098
- );
6099
- const scopes = /* @__PURE__ */ new Map();
6100
- for (const j of joinNamespaceBindings(projectDirs.map((dir, i) => ({ dir, ir: irs[i] })), isDir)) {
6101
- if (!meetsFloor(resolveChant(j.dir).version, NAMESPACE_JOIN_FLOOR)) continue;
6102
- scopes.set(j.dir, j.namespace);
7961
+ function addEstateMemberEdges(ir, members, isDir = realIsDir2) {
7962
+ const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
7963
+ for (const e of deriveEstateMemberEdges(ir, members, isDir)) {
7964
+ const key = `${e.from}\0${e.to}`;
7965
+ if (existing.has(key)) continue;
7966
+ existing.add(key);
7967
+ ir.edges.push(e);
6103
7968
  }
6104
- return scopes;
6105
- }
6106
- function withoutJoinedMembers(nodes, joined) {
6107
- if (joined.length === 0) return [...nodes];
6108
- return nodes.filter((n) => !joined.some((j) => n.id.startsWith(`${j.name}/`)));
6109
- }
6110
- async function composeEstateOverlay(projectDirs, opts, classify) {
6111
- const names = shortStackNames(projectDirs);
6112
- const unobserved = [];
6113
- const dropped = [];
6114
- const joined = [];
6115
- const stacks = new Array(projectDirs.length);
6116
- const scopes = await estateNamespaceScopes(projectDirs, opts);
6117
- await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => {
6118
- const name = names[i];
6119
- const namespace = scopes.get(dir);
6120
- try {
6121
- const live = { ...opts, live: true, overlay: true, ...namespace ? { namespace } : {} };
6122
- stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, live))) };
6123
- if (namespace) joined.push({ name, namespace });
6124
- } catch (err) {
6125
- const reason = firstLine2(err);
6126
- try {
6127
- const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
6128
- const src = await graphIr(dir, srcOpts);
6129
- for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
6130
- stacks[i] = { name, ir: src };
6131
- unobserved.push({ name, reason });
6132
- } catch (err2) {
6133
- dropped.push({ name, reason: firstLine2(err2) });
6134
- }
6135
- }
6136
- });
6137
- const present = stacks.filter((s) => !!s);
6138
- return {
6139
- ir: composeStacks(present),
6140
- observed: present.length - unobserved.length,
6141
- total: projectDirs.length,
6142
- unobserved,
6143
- dropped,
6144
- // Concurrent reads finish in whatever order the clusters answer; the note
6145
- // this feeds should read the same twice.
6146
- joined: joined.sort((a, b) => a.name.localeCompare(b.name))
6147
- };
7969
+ return ir;
6148
7970
  }
6149
7971
 
6150
7972
  // src/events.ts
6151
7973
  import { watch, existsSync as existsSync11 } from "node:fs";
6152
- import { join as join14 } from "node:path";
7974
+ import { join as join16 } from "node:path";
6153
7975
  var Broadcaster = class {
6154
7976
  listeners = /* @__PURE__ */ new Set();
6155
7977
  subscribe(fn) {
@@ -6167,7 +7989,7 @@ var Broadcaster = class {
6167
7989
  };
6168
7990
  var IGNORE = /(^|[\\/])(node_modules|dist|\.git)([\\/]|$)/;
6169
7991
  function watchSource(projectDir, onChange, debounceMs = 200) {
6170
- const dir = existsSync11(join14(projectDir, "src")) ? join14(projectDir, "src") : projectDir;
7992
+ const dir = existsSync11(join16(projectDir, "src")) ? join16(projectDir, "src") : projectDir;
6171
7993
  let timer;
6172
7994
  const watcher = watch(dir, { recursive: true }, (_event, file) => {
6173
7995
  const name = typeof file === "string" ? file : "";
@@ -6416,8 +8238,8 @@ function renderLanes(frames, summaries) {
6416
8238
  status: Object.fromEntries(f.ir.nodes.map((n) => [n.id, n.attrs?._status ?? ""])),
6417
8239
  lexicon: Object.fromEntries(f.ir.nodes.map((n) => [n.id, n.lexicon]))
6418
8240
  }));
6419
- const strip = `<style>${LANES_CSS}</style>` + THEME_BOOT + `<div id="behold-diff"></div><div id="behold-lanes"><div class="hd"><span>deployment lanes</span><span id="behold-lanes-meta"></span><span class="rt" id="behold-lanes-rt" style="display:none">offset \u2014 graph shows real time</span><a href="/">\u2190 graph</a><span id="behold-lanes-pickers"></span></div><canvas id="behold-lanes-canvas"></canvas></div>` + laneStripScript(laneFrames);
6420
- return doc.includes("</body>") ? doc.replace("</body>", `${strip}</body>`) : doc + strip;
8241
+ const strip3 = `<style>${LANES_CSS}</style>` + THEME_BOOT + `<div id="behold-diff"></div><div id="behold-lanes"><div class="hd"><span>deployment lanes</span><span id="behold-lanes-meta"></span><span class="rt" id="behold-lanes-rt" style="display:none">offset \u2014 graph shows real time</span><a href="/">\u2190 graph</a><span id="behold-lanes-pickers"></span></div><canvas id="behold-lanes-canvas"></canvas></div>` + laneStripScript(laneFrames);
8242
+ return doc.includes("</body>") ? doc.replace("</body>", `${strip3}</body>`) : doc + strip3;
6421
8243
  }
6422
8244
 
6423
8245
  // src/emulator.ts
@@ -6455,13 +8277,13 @@ async function emulatorDown(projectDir) {
6455
8277
  }
6456
8278
 
6457
8279
  // src/demos.ts
6458
- import { readFileSync as readFileSync12, existsSync as existsSync12, cpSync } from "node:fs";
6459
- import { join as join15, relative as relative2, resolve as resolve4, sep as sep3 } from "node:path";
8280
+ import { readFileSync as readFileSync13, existsSync as existsSync12, cpSync } from "node:fs";
8281
+ import { join as join17, relative as relative3, resolve as resolve5, sep as sep3 } from "node:path";
6460
8282
  import { spawn as spawn5, spawnSync } from "node:child_process";
6461
8283
  function loadDemoRegistry(pkgRoot2) {
6462
8284
  let raw;
6463
8285
  try {
6464
- raw = JSON.parse(readFileSync12(join15(pkgRoot2, "demos.json"), "utf8"));
8286
+ raw = JSON.parse(readFileSync13(join17(pkgRoot2, "demos.json"), "utf8"));
6465
8287
  } catch {
6466
8288
  return [];
6467
8289
  }
@@ -6501,22 +8323,22 @@ function fetchesFromNetwork(entry) {
6501
8323
  return entry.source === "git";
6502
8324
  }
6503
8325
  function demoTargetDir(entry, cwd = process.cwd()) {
6504
- const legacy = resolve4(cwd, "behold-demo");
8326
+ const legacy = resolve5(cwd, "behold-demo");
6505
8327
  if (entry.name === "writes" && existsSync12(legacy)) return legacy;
6506
- return resolve4(cwd, "behold-demos", entry.name);
8328
+ return resolve5(cwd, "behold-demos", entry.name);
6507
8329
  }
6508
8330
  async function loadDemo(entry, opts) {
6509
8331
  const { pkgRoot: pkgRoot2, target } = opts;
6510
8332
  const say = (line) => opts.log?.(`behold demo ${entry.name} \u2192 ${line}`);
6511
8333
  if (!existsSync12(target)) {
6512
8334
  if (entry.source === "bundled") {
6513
- const bundled = join15(pkgRoot2, entry.dir);
8335
+ const bundled = join17(pkgRoot2, entry.dir);
6514
8336
  if (!existsSync12(bundled)) return { ok: false, error: `this install has no bundled ${entry.dir}` };
6515
8337
  say(`copying to ${target} (it's yours \u2014 edit it)`);
6516
8338
  try {
6517
8339
  cpSync(bundled, target, {
6518
8340
  recursive: true,
6519
- filter: (src) => !relative2(bundled, src).split(sep3).includes("node_modules")
8341
+ filter: (src) => !relative3(bundled, src).split(sep3).includes("node_modules")
6520
8342
  });
6521
8343
  } catch (err) {
6522
8344
  return { ok: false, error: `copy failed: ${err instanceof Error ? err.message : String(err)}` };
@@ -6530,7 +8352,7 @@ async function loadDemo(entry, opts) {
6530
8352
  } else {
6531
8353
  say(`reusing ${target}`);
6532
8354
  }
6533
- if (existsSync12(join15(target, "package.json")) && !existsSync12(join15(target, "node_modules"))) {
8355
+ if (existsSync12(join17(target, "package.json")) && !existsSync12(join17(target, "node_modules"))) {
6534
8356
  say("npm install\u2026");
6535
8357
  if (await runStep("npm", ["install"], { cwd: target, shell: process.platform === "win32" }) !== 0) {
6536
8358
  return { ok: false, error: `npm install failed in ${target}` };
@@ -6542,7 +8364,7 @@ async function loadDemo(entry, opts) {
6542
8364
  return { ok: false, error: `setup failed (${entry.setup})` };
6543
8365
  }
6544
8366
  }
6545
- return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join15(target, d)) : [target] };
8367
+ return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join17(target, d)) : [target] };
6546
8368
  }
6547
8369
  function runStep(cmd, args, opts = {}) {
6548
8370
  return new Promise((res) => {
@@ -6553,8 +8375,8 @@ function runStep(cmd, args, opts = {}) {
6553
8375
  }
6554
8376
 
6555
8377
  // src/server.ts
6556
- var pkgRoot = join16(dirname4(fileURLToPath3(import.meta.url)), "..");
6557
- var webRoot = join16(pkgRoot, "web");
8378
+ var pkgRoot = join18(dirname5(fileURLToPath3(import.meta.url)), "..");
8379
+ var webRoot = join18(pkgRoot, "web");
6558
8380
  var execFileP = async (cmd, args) => (await promisify3(execFile3)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
6559
8381
  function optsFromQuery(url, tierEnvVar, projectDir) {
6560
8382
  const q = url.searchParams;
@@ -6603,8 +8425,8 @@ function tierFailure(tier, message) {
6603
8425
  }
6604
8426
  function beholdVersion() {
6605
8427
  try {
6606
- const here = dirname4(fileURLToPath3(import.meta.url));
6607
- return JSON.parse(readFileSync13(join16(here, "..", "package.json"), "utf8")).version ?? "unknown";
8428
+ const here = dirname5(fileURLToPath3(import.meta.url));
8429
+ return JSON.parse(readFileSync14(join18(here, "..", "package.json"), "utf8")).version ?? "unknown";
6608
8430
  } catch {
6609
8431
  return "unknown";
6610
8432
  }
@@ -6649,7 +8471,9 @@ async function captureFrame(projectDir, env, frames, broadcaster) {
6649
8471
  }
6650
8472
  }
6651
8473
  function carveRoutes(app, reportPath, demo) {
6652
- const load = () => readCarveReport(reportPath, (p) => readFileSync13(p, "utf8"));
8474
+ const load = () => readCarveReport(reportPath, (p) => readFileSync14(p, "utf8"));
8475
+ const carveDirs = [dirname5(reportPath), ...demo ? [demo.out] : []];
8476
+ const carveStates = () => readCarveStates(carveDirs, nodeCarveIo, 0);
6653
8477
  const demoBlock = () => carveWriteBlock(demo);
6654
8478
  const demoInfo = () => {
6655
8479
  if (!demo) return null;
@@ -6661,8 +8485,8 @@ function carveRoutes(app, reportPath, demo) {
6661
8485
  project: demo.project,
6662
8486
  out: demo.out,
6663
8487
  // Paths the UI can print without leaking the operator's whole home dir.
6664
- outLabel: relative3(demo.root, demo.out).split(sep4).join("/"),
6665
- fromLabel: relative3(demo.root, demo.from).split(sep4).join("/"),
8488
+ outLabel: relative4(demo.root, demo.out).split(sep4).join("/"),
8489
+ fromLabel: relative4(demo.root, demo.from).split(sep4).join("/"),
6666
8490
  runnable: !block,
6667
8491
  ...block ? { reason: block } : {},
6668
8492
  // The live tier, when this boot armed it: the scratch endpoint the
@@ -6679,7 +8503,7 @@ function carveRoutes(app, reportPath, demo) {
6679
8503
  let appGraph;
6680
8504
  const appGraphOnce = () => {
6681
8505
  if (!demo) return Promise.resolve(null);
6682
- appGraph ??= graphIr(demo.project).then((ir) => ir.nodes.length ? { ir, label: relative3(demo.root, demo.project).split(sep4).join("/") } : null).catch(() => null);
8506
+ appGraph ??= graphIr(demo.project).then((ir) => ir.nodes.length ? { ir, label: relative4(demo.root, demo.project).split(sep4).join("/") } : null).catch(() => null);
6683
8507
  return appGraph;
6684
8508
  };
6685
8509
  app.get("/api/graph", async (c) => {
@@ -6687,10 +8511,21 @@ function carveRoutes(app, reportPath, demo) {
6687
8511
  if (!parsed.ok) return c.json(parsed.refusal, 422);
6688
8512
  const tfIr = carveReportToIr(parsed.report);
6689
8513
  const appSide = await appGraphOnce();
8514
+ const states = carveStates();
8515
+ const shorten = demo ? (p) => shortenIn(p, demo.root) : (p) => p;
8516
+ const single = () => {
8517
+ const split = splitCarveState(tfIr, states, { shorten });
8518
+ return bandGraduated(split.tf, split.graduated);
8519
+ };
6690
8520
  const { svg, ir } = appSide ? renderCarveEstate(tfIr, appSide.ir, {
6691
- tfTitle: `${relative3(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
6692
- appTitle: `${appSide.label} \u2014 chant`
6693
- }) : { ...renderBanded(tfIr), ir: tfIr };
8521
+ tfTitle: `${relative4(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
8522
+ appTitle: `${appSide.label} \u2014 chant`,
8523
+ carved: states,
8524
+ shorten
8525
+ }) : (() => {
8526
+ const banded = single();
8527
+ return { ...renderBanded(banded), ir: banded };
8528
+ })();
6694
8529
  return c.json({
6695
8530
  ir,
6696
8531
  svg,
@@ -6702,7 +8537,7 @@ function carveRoutes(app, reportPath, demo) {
6702
8537
  carve: true,
6703
8538
  // A demo whose own advisor run failed says so on the statusbar, not
6704
8539
  // only in the terminal the viewer isn't looking at.
6705
- note: carveNote(parsed.report, tfIr) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : "")
8540
+ note: carveNote(parsed.report, tfIr) + carveStateNote(carveProgress(states.values(), tfIr.nodes.length), states) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : "")
6706
8541
  }
6707
8542
  });
6708
8543
  });
@@ -6734,14 +8569,22 @@ function carveRoutes(app, reportPath, demo) {
6734
8569
  }
6735
8570
  const tfIr = carveReportToIr(parsed.report);
6736
8571
  const html = renderCarveMorph(tfIr, appSide.ir, [select], {
6737
- tfTitle: `${relative3(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
8572
+ tfTitle: `${relative4(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
6738
8573
  appTitle: `${appSide.label} \u2014 chant`,
6739
- title: `carve \u2014 ${select}`
8574
+ title: `carve \u2014 ${select}`,
8575
+ carved: carveStates(),
8576
+ shorten: (p) => shortenIn(p, demo.root)
6740
8577
  });
6741
8578
  return c.html(html);
6742
8579
  });
6743
8580
  app.get("/api/project", (c) => {
6744
8581
  const parsed = load();
8582
+ const total = parsed.ok ? parsed.report.count ?? parsed.report.resources.length : null;
8583
+ const state = carveStatePayload(
8584
+ carveStates(),
8585
+ total,
8586
+ () => demo ? { from: shortenIn(demo.from, demo.root), out: shortenIn(demo.out, demo.root) } : {}
8587
+ );
6745
8588
  return c.json({
6746
8589
  projectDir: reportPath,
6747
8590
  recents: [],
@@ -6755,11 +8598,12 @@ function carveRoutes(app, reportPath, demo) {
6755
8598
  carve: parsed.ok ? {
6756
8599
  report: reportPath,
6757
8600
  from: parsed.report.from ?? null,
6758
- count: parsed.report.count ?? parsed.report.resources.length,
8601
+ count: total,
6759
8602
  bands: parsed.report.bands ?? {},
6760
8603
  advisory: parsed.report.advisory ?? null,
6761
- demo: demoInfo()
6762
- } : { report: reportPath, demo: demoInfo() }
8604
+ demo: demoInfo(),
8605
+ state
8606
+ } : { report: reportPath, demo: demoInfo(), state }
6763
8607
  });
6764
8608
  });
6765
8609
  const runStep2 = async (c, run3) => {
@@ -6869,7 +8713,17 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6869
8713
  // of starting blank — the `apply` SSE event (below) carries every update
6870
8714
  // after that. `status: "idle"` (initialApplyProgress) when nothing has
6871
8715
  // applied yet this session.
6872
- applyProgress: runner.applyProgress
8716
+ applyProgress: runner.applyProgress,
8717
+ // The run playhead (#284 item 2): the settled StepRecords of the Op this
8718
+ // session last triggered, so a client opening (or reloading) mid-run
8719
+ // hydrates the playhead instead of starting blank — the `run` SSE event
8720
+ // carries every update after that. `status: "idle"` when nothing has run.
8721
+ runState: runner.runState,
8722
+ // #234 join 3: the operator strip's last known model, so a client that
8723
+ // opens (or reloads) hydrates the strip instead of starting blank — same
8724
+ // reason `runState` is here. `declared: []` until the ops lens has been
8725
+ // opened once, since that is where the op.json read happens.
8726
+ operatorState: runner.operatorState
6873
8727
  })
6874
8728
  );
6875
8729
  app.post("/api/ops/:name/run", (c) => {
@@ -6910,8 +8764,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6910
8764
  return c.json({ started: true, name, ran: label });
6911
8765
  });
6912
8766
  app.post("/api/local/reset", (c) => {
6913
- const down = join16(cfg.projectDir, "scripts/local/local-down.sh");
6914
- const up = join16(cfg.projectDir, "scripts/local/local-up.sh");
8767
+ const down = join18(cfg.projectDir, "scripts/local/local-down.sh");
8768
+ const up = join18(cfg.projectDir, "scripts/local/local-up.sh");
6915
8769
  if (!existsSync13(down) || !existsSync13(up)) {
6916
8770
  return c.json({ error: "no local-down.sh / local-up.sh in scripts/local \u2014 reset is only for local emulator projects" }, 400);
6917
8771
  }
@@ -6920,6 +8774,62 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6920
8774
  }
6921
8775
  return c.json({ started: true, ran: "local-down + local-up" });
6922
8776
  });
8777
+ app.get("/api/ops/:name/status", async (c) => {
8778
+ const name = c.req.param("name");
8779
+ const info = estateOps().find((o) => o.name === name);
8780
+ const run3 = await runChantRaw(["run", "status", name, "--temporal"], info?.dir ?? cfg.projectDir);
8781
+ const result = readRunStatus(name, run3);
8782
+ const runState = runner.noteGate(name, result);
8783
+ if (!result.ok) return c.json({ ...result.refusal, runState }, 422);
8784
+ return c.json({ ...result.read, runState });
8785
+ });
8786
+ const convergeDirs = () => {
8787
+ const dirs = [];
8788
+ const declared = [];
8789
+ for (const f of discoverOpIrs(estateDirs)) {
8790
+ const parsed = readOpIr(f.path, (p) => readFileSync14(p, "utf8"));
8791
+ if (!parsed.ok) continue;
8792
+ const mine = declaredConvergeOps([parsed.ir]);
8793
+ if (!mine.length) continue;
8794
+ declared.push(...mine);
8795
+ if (!dirs.includes(f.dir)) dirs.push(f.dir);
8796
+ }
8797
+ return { dirs, declared };
8798
+ };
8799
+ app.get("/api/operator/status", async (c) => {
8800
+ const { dirs, declared } = convergeDirs();
8801
+ runner.noteDeclaredConvergeOps(declared);
8802
+ if (!dirs.length) {
8803
+ const state2 = runner.noteOperator({
8804
+ ok: false,
8805
+ refusal: {
8806
+ error: "This project declares no operating loop \u2014 no emitted Op carries chant's `Converge` search attribute.",
8807
+ code: "no-operator",
8808
+ remedy: HOW_TO_DECLARE
8809
+ }
8810
+ });
8811
+ return c.json({ ...state2, operator: state2 }, 200);
8812
+ }
8813
+ const rows = [];
8814
+ for (const dir of dirs) {
8815
+ const run3 = await runChantRaw(["operator", "status", "--json"], dir);
8816
+ const result = readOperatorStatus(run3);
8817
+ if (!result.ok) return c.json({ ...result.refusal, operator: runner.noteOperator(result) }, 422);
8818
+ rows.push(...result.rows);
8819
+ }
8820
+ const state = runner.noteOperator({ ok: true, rows });
8821
+ return c.json({ ...state, operator: state });
8822
+ });
8823
+ app.post("/api/operator/approve/:op/:gate", async (c) => {
8824
+ const { op, gate } = c.req.param();
8825
+ const info = estateOps().find((o) => o.name === op);
8826
+ broadcaster.emit("op", `\u270E approve ${op} ${gate} \u2014 records a fact; the next tick acts on it`);
8827
+ const { code, stderr, stdout } = await runChantRaw(["approve", op, gate], info?.dir ?? cfg.projectDir);
8828
+ if (code !== 0) {
8829
+ return c.json({ error: stderr.trim() || stdout.trim() || `approve exited ${code}`, code: "approve" }, 500);
8830
+ }
8831
+ return c.json({ recorded: true, op, gate, semantics: APPROVE_SEMANTICS });
8832
+ });
6923
8833
  app.post("/api/ops/:name/signal/:gate", async (c) => {
6924
8834
  const { name, gate } = c.req.param();
6925
8835
  const info = estateOps().find((o) => o.name === name);
@@ -6946,8 +8856,11 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6946
8856
  const { environments, lexicons, stacks, k8sProfiles } = await detectProject(cfg.projectDir);
6947
8857
  const k8sTarget = lexicons.includes("k8s") ? resolveK8sTarget(k8sProfiles, cfg.env, await loadKubeconfig()) : void 0;
6948
8858
  const axes = deployAxes(tierEnvVar, lexicons, k8sTarget);
8859
+ const emittedOps = discoverOpIrs(estateDirs).length;
8860
+ const carveState = carveStatePayload(readCarveStates(estateDirs));
6949
8861
  return c.json({
6950
8862
  projectDir: cfg.projectDir,
8863
+ ...carveState.manifests ? { carve: { state: carveState } } : {},
6951
8864
  // #195: the full estate composition (multi-project serves) and the
6952
8865
  // switcher's recents, so the SPA's project section can show what's
6953
8866
  // loaded and offer where to go. Recents exclude nothing here — the SPA
@@ -6978,6 +8891,12 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6978
8891
  // the names — `graphPath` (chant.ts) resolves a picked name to its `src`
6979
8892
  // server-side; the SPA never needs the path.
6980
8893
  ...stacks?.length ? { stacks: stacks.map((s) => s.name) } : {},
8894
+ // The ops lens stop (#284), gated exactly like `tiers`/`stacks` above:
8895
+ // present only when the estate has actually emitted Ops, so a project on
8896
+ // a chant < 0.50, or one that has never been built, grows no dead zoom
8897
+ // stop and no dead ⌘K entry. The count, not a bare flag — the SPA shows
8898
+ // it, and "ops: 0" is not a thing this can ever say.
8899
+ ...emittedOps ? { ops: emittedOps } : {},
6981
8900
  targets: deployTargets(lexicons, k8sTarget),
6982
8901
  // Where the k8s binding came from, so the SPA never implies behold chose
6983
8902
  // it (#106). Absent for a project with no k8s lexicon or no resolvable
@@ -6989,9 +8908,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6989
8908
  app.post("/api/project/open", async (c) => {
6990
8909
  if (cfg.previewMode) return c.json({ error: "switching projects is locked in preview mode" }, 403);
6991
8910
  const body = await c.req.json().catch(() => ({}));
6992
- const dir = typeof body.dir === "string" && body.dir.trim() ? resolve5(body.dir.trim()) : "";
8911
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve6(body.dir.trim()) : "";
6993
8912
  if (!dir || !existsSync13(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
6994
- if (!existsSync13(join16(dir, "chant.config.ts"))) {
8913
+ if (!existsSync13(join18(dir, "chant.config.ts"))) {
6995
8914
  return c.json({ error: `${dir} doesn't look like a chant project \u2014 no chant.config.ts` }, 400);
6996
8915
  }
6997
8916
  switchServedProject([dir]);
@@ -7011,7 +8930,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7011
8930
  };
7012
8931
  app.post("/api/project/reveal", async (c) => {
7013
8932
  const body = await c.req.json().catch(() => ({}));
7014
- const dir = typeof body.dir === "string" && body.dir.trim() ? resolve5(body.dir.trim()) : cfg.projectDir;
8933
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve6(body.dir.trim()) : cfg.projectDir;
7015
8934
  const known = /* @__PURE__ */ new Set([cfg.projectDir, ...cfg.projectDirs ?? [], ...listRecents().map((r) => r.dir)]);
7016
8935
  if (!known.has(dir)) return c.json({ error: "not a served or recent project directory" }, 400);
7017
8936
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
@@ -7079,7 +8998,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7079
8998
  const loaded = await loadDemo(entry, { pkgRoot, target, log: (line) => process.stdout.write(line + "\n") });
7080
8999
  if (!loaded.ok) return c.json({ error: loaded.error }, 500);
7081
9000
  const dirs = loaded.serveDirs;
7082
- if (!existsSync13(join16(dirs[0], "chant.config.ts"))) {
9001
+ if (!existsSync13(join18(dirs[0], "chant.config.ts"))) {
7083
9002
  return c.json({ error: `${entry.name} loaded to ${target} but ${dirs[0]} has no chant.config.ts` }, 500);
7084
9003
  }
7085
9004
  if (entry.serve.local) {
@@ -7177,7 +9096,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7177
9096
  path: "/api/demos/open",
7178
9097
  desc: "load a demo and serve it: JSON body {name} (a catalog name, never a path) \u2014 copies/clones, installs, runs its setup, then switches (preview-locked)"
7179
9098
  },
7180
- { method: "GET", path: "/api/graph", desc: "the graph {ir, svg, meta} \u2014 params: detail=0..3, components=1, logical=1, env, stack, tier, target, lens, up=1, down=1, radial=1, layout=1" },
9099
+ { method: "GET", path: "/api/graph", desc: "the graph {ir, svg, meta} \u2014 params: detail=0..3, components=1, logical=1, ops=1, env, stack, tier, target, lens, up=1, down=1, radial=1, layout=1" },
7181
9100
  ...cfg.carveReport ? [
7182
9101
  { method: "GET", path: "/api/carve", desc: "carve mode: the raw `chant carve advise --json` peelability report this server is rendering" },
7183
9102
  {
@@ -7199,14 +9118,17 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7199
9118
  { method: "GET", path: "/api/resources", desc: "component \u2192 declared resources" },
7200
9119
  { method: "GET", path: "/api/ci", desc: "generated CI pipeline projection {stages, jobs, forge}" },
7201
9120
  { method: "GET", path: "/api/substrates", desc: "substrate readiness {substrates: [{name, label, status, detail, bringUp?}]}" },
7202
- { method: "GET", path: "/api/ops", desc: "committed Ops + adopt lexicons + apply progress" },
9121
+ { method: "GET", path: "/api/ops", desc: "committed Ops + adopt lexicons + apply progress + run playhead" },
9122
+ { method: "GET", path: "/api/ops/:name/status", desc: "an Op's durable run status + pending gate (chant run status)" },
9123
+ { method: "GET", path: "/api/operator/status", desc: "the operating loop's strip: last tick per ConvergeOp + lease + pending converge gates" },
7203
9124
  { method: "GET", path: "/api/history", desc: "recent source commits (rollback targets)" },
7204
9125
  { method: "GET", path: "/api/frames", desc: "captured lanes frames" },
7205
- { method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / pr" },
9126
+ { method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / run / pr" },
7206
9127
  { method: "POST", path: "/api/refresh", desc: "re-observe live now (?env=) \u2014 returns the fresh graph" },
7207
9128
  { method: "POST", path: "/api/apply", desc: "delegated apply: ?env=&component=<name|all> (guarded, preview-locked)" },
7208
9129
  { method: "POST", path: "/api/ops/:name/run", desc: "run a committed Op (delegated write)" },
7209
- { method: "POST", path: "/api/ops/:name/signal/:gate", desc: "approve an Op's gate" },
9130
+ { method: "POST", path: "/api/ops/:name/signal/:gate", desc: "approve an Op's gate (releases the waiting run)" },
9131
+ { method: "POST", path: "/api/operator/approve/:op/:gate", desc: "record a converge gate's resolution (chant approve \u2014 a fact for the next tick, not an unblock)" },
7210
9132
  { method: "POST", path: "/api/rollback", desc: "open a rollback PR: ?to=<sha>" },
7211
9133
  { method: "POST", path: "/api/substrates/:name/up", desc: "bring a substrate up" },
7212
9134
  { method: "POST", path: "/api/local/reset", desc: "reset the local emulator" },
@@ -7218,6 +9140,58 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7218
9140
  const url = new URL(c.req.url);
7219
9141
  const opts = optsFromQuery(url, tierEnvVar, cfg.projectDir);
7220
9142
  try {
9143
+ if (url.searchParams.get("ops") === "1") {
9144
+ const files = discoverOpIrs(estateDirs);
9145
+ if (!files.length) {
9146
+ return c.json(
9147
+ {
9148
+ error: "this project has no emitted Ops \u2014 nothing under dist/ops/<name>/op.json",
9149
+ code: "op-ir",
9150
+ remedy: HOW_TO_EMIT
9151
+ },
9152
+ 422
9153
+ );
9154
+ }
9155
+ const parsedOps = [];
9156
+ for (const f of files) {
9157
+ const parsed = readOpIr(f.path, (p) => readFileSync14(p, "utf8"));
9158
+ if (!parsed.ok) return c.json(parsed.refusal, 422);
9159
+ parsedOps.push(parsed.ir);
9160
+ }
9161
+ const declaredIr = opsToIr(parsedOps);
9162
+ const runState = runner.runState;
9163
+ const ranOp = runState.op ? parsedOps.find((o) => o.name === runState.op) : void 0;
9164
+ const join21 = ranOp ? joinRun(ranOp, runState.records, runState.gate) : null;
9165
+ const opsIr = join21 ? paintPlayhead(declaredIr, join21, runState.gate) : declaredIr;
9166
+ const { svg: svg2 } = renderGraph(opsIr, { boxes: "byStack" });
9167
+ const operator = runner.noteDeclaredConvergeOps(declaredConvergeOps(parsedOps));
9168
+ return c.json({
9169
+ ir: opsIr,
9170
+ svg: svg2,
9171
+ meta: {
9172
+ projectDir: cfg.projectDir,
9173
+ env: cfg.env ?? null,
9174
+ tier: null,
9175
+ target: null,
9176
+ mode: "ops",
9177
+ ops: parsedOps.length,
9178
+ note: `${opsNote(parsedOps, opsIr)} ${playheadNote(runState, join21)} ${operatorNote(operator)}`,
9179
+ // The operator strip's own model: the ConvergeOps this project
9180
+ // declares, plus whatever the last status read said. `declared`
9181
+ // without a `strip` is the honest starting state — a loop exists and
9182
+ // behold hasn't asked about it yet.
9183
+ operator,
9184
+ // The playhead's own model, so the SPA renders the pending card and
9185
+ // the run legend from the same answer that painted the graph.
9186
+ run: runState,
9187
+ // #234's pending-approval card, whole: the gate, when it started
9188
+ // waiting, the step it holds back, and the delegated write its
9189
+ // approve button performs. Present only when `gateState` NAMED a
9190
+ // pending gate — never inferred from a stalled track.
9191
+ gate: runState.gate && runState.op ? pendingGateCard(runState.op, runState.gate, opsIr) : null
9192
+ }
9193
+ });
9194
+ }
7221
9195
  const components = url.searchParams.get("components") === "1";
7222
9196
  const logical = url.searchParams.get("logical") === "1";
7223
9197
  const multi = cfg.projectDirs && cfg.projectDirs.length > 1;
@@ -7226,8 +9200,10 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7226
9200
  let metaEnv = cfg.env ?? null;
7227
9201
  if (multi) {
7228
9202
  ir = await composeEstate(cfg.projectDirs, logical ? { ...opts, detail: 3 } : opts);
7229
- ir = addValueMatchEdges(ir);
7230
9203
  ir = addK8sDeclaredEdges(ir);
9204
+ ir = addValueMatchEdges(ir);
9205
+ ir = addEstateMemberEdges(ir, estateMembers(cfg.projectDirs));
9206
+ ir = markOperatorHome(ir);
7231
9207
  const estateContext = await boundK8sContext(metaEnv ?? void 0);
7232
9208
  ir = addClusterAnchorEdges(ir, estateContext);
7233
9209
  if (logical) {
@@ -7262,7 +9238,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7262
9238
  ir = joinCiProgress(ir, runner.applyProgress);
7263
9239
  } else if (logical) {
7264
9240
  const raw = mergeClusterRoot(await graphIr(cfg.projectDir, { ...opts, detail: 3 }), await clusterRootGraphIr(cfg.projectDir, opts));
7265
- const base = addK8sDeclaredEdges(addValueMatchEdges(raw));
9241
+ const base = addValueMatchEdges(addK8sDeclaredEdges(raw));
7266
9242
  const { ir: projected, byContainer } = projectTopology(base, metaEnv ?? void 0, await boundK8sContext(metaEnv ?? void 0), [await graphPath(cfg.projectDir, opts), cfg.projectDir]);
7267
9243
  const { svg: svg2 } = renderArchitecture(projected, byContainer);
7268
9244
  const logicalNote = notesFor("logical", projected, void 0, base.nodes.length);
@@ -7270,8 +9246,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7270
9246
  } else {
7271
9247
  ir = mergeClusterRoot(await graphIr(cfg.projectDir, opts), await clusterRootGraphIr(cfg.projectDir, opts));
7272
9248
  if ((opts.detail ?? 2) < 3) ir = pruneImports(ir);
7273
- ir = addValueMatchEdges(ir);
7274
9249
  ir = addK8sDeclaredEdges(ir);
9250
+ ir = addValueMatchEdges(ir);
9251
+ ir = markOperatorHome(ir);
7275
9252
  ir = addClusterAnchorEdges(ir, await boundK8sContext(metaEnv ?? void 0));
7276
9253
  }
7277
9254
  let srcCompositeEdgesAttached;
@@ -7285,14 +9262,14 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7285
9262
  srcCompositeEdgesAttached = 0;
7286
9263
  }
7287
9264
  }
7288
- if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync13(join16(cfg.projectDir, "chant.config.ts"))) {
9265
+ if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync13(join18(cfg.projectDir, "chant.config.ts"))) {
7289
9266
  return c.json(noProjectError(cfg.projectDir), 404);
7290
9267
  }
7291
9268
  const radial = new URL(c.req.url).searchParams.get("radial") === "1";
7292
9269
  const { svg } = renderGraph(ir, multi ? { boxes: "byStack" } : { radial });
7293
9270
  const srcZoom = components ? "components" : logical ? "logical" : opts.detail === 1 ? "composites" : opts.detail === 3 ? "attributes" : "resources";
7294
9271
  const estateLensNote = multi && components ? "the components lens doesn't apply to a composed estate yet \u2014 showing the composed entity graph" : void 0;
7295
- const srcNote = multi ? estateLensNote : notesFor(srcZoom, ir, srcCompositeEdgesAttached);
9272
+ const srcNote = multi ? estateLensNote : notesFor(srcZoom, ir, srcCompositeEdgesAttached, void 0, opts.detail ?? 2);
7296
9273
  return c.json({
7297
9274
  ir,
7298
9275
  svg,
@@ -7401,8 +9378,10 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7401
9378
  const composed = est.ir;
7402
9379
  let ir2 = runtime ? attachRuntimeContainment(composed) : pruneRuntimeChildren(composed);
7403
9380
  if ((detail ?? 2) < 3) ir2 = pruneImports(ir2);
7404
- ir2 = addValueMatchEdges(ir2);
7405
9381
  ir2 = addK8sDeclaredEdges(ir2);
9382
+ ir2 = addValueMatchEdges(ir2);
9383
+ ir2 = addEstateMemberEdges(ir2, estateMembers(cfg.projectDirs));
9384
+ ir2 = markOperatorHome(ir2);
7406
9385
  const boundContext2 = await boundK8sContext(env);
7407
9386
  ir2 = addClusterAnchorEdges(ir2, boundContext2);
7408
9387
  const coverNote = est.unobserved.length || est.dropped.length ? `live observe covered ${est.observed} of ${est.total} projects \u2014 ` + [
@@ -7427,11 +9406,12 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7427
9406
  });
7428
9407
  }
7429
9408
  const { svg: svg2 } = renderGraph(ir2, { boxes: runtime ? "byContainer" : "byStack" });
9409
+ const zoom2 = runtime ? "runtime" : detail === 1 ? "composites" : detail === 3 ? "attributes" : "resources";
7430
9410
  const note2 = [
7431
9411
  coverNote,
7432
9412
  namespaceJoinNote(est.joined),
7433
9413
  namespaceMismatchNote(withoutJoinedMembers(ir2.nodes, est.joined)),
7434
- runtime ? notesFor("runtime", ir2, void 0, void 0, detail) : void 0
9414
+ notesFor(zoom2, ir2, void 0, void 0, detail ?? 2)
7435
9415
  ].filter(Boolean).join(" \xB7 ");
7436
9416
  return c.json({
7437
9417
  ir: ir2,
@@ -7448,19 +9428,21 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7448
9428
  const observed = await lifecycleDiffLive(cfg.projectDir, env, query).then((d) => d.lexicons?.helm?.observedArtifacts).catch(() => void 0);
7449
9429
  applyHelmArtifacts(ir, observed);
7450
9430
  synthesizeHelmReleases(ir, observed, discoverReleaseUnits(cfg.projectDir));
9431
+ applyHelmRenderDrift(ir, await readHelmRenderDrift(cfg.projectDir, env, query));
7451
9432
  }
7452
9433
  ir = runtime ? attachRuntimeContainment(ir) : pruneRuntimeChildren(ir);
7453
9434
  if (logical) {
7454
9435
  const logicalBefore = ir.nodes.length;
7455
- const { ir: projected, byContainer } = projectTopology(addK8sDeclaredEdges(addValueMatchEdges(ir)), env, boundContext, [await graphPath(cfg.projectDir, opts), cfg.projectDir]);
9436
+ const { ir: projected, byContainer } = projectTopology(addValueMatchEdges(addK8sDeclaredEdges(ir)), env, boundContext, [await graphPath(cfg.projectDir, opts), cfg.projectDir]);
7456
9437
  const { svg: svg2 } = renderArchitecture(projected, byContainer);
7457
9438
  const logicalTierNote = tierMismatchNote(projected, beholdConfig.tiers, query.tier);
7458
9439
  const logicalNote = [logicalTierNote, notesFor("logical", projected, void 0, logicalBefore)].filter(Boolean).join(" \xB7 ");
7459
9440
  return c.json({ ir: projected, svg: svg2, byContainer, meta: { projectDir: cfg.projectDir, env, mode: "logical", ...logicalNote ? { note: logicalNote } : {} } });
7460
9441
  }
7461
9442
  if ((query.detail ?? 2) < 3) ir = pruneImports(ir);
7462
- ir = addValueMatchEdges(ir);
7463
9443
  ir = addK8sDeclaredEdges(ir);
9444
+ ir = addValueMatchEdges(ir);
9445
+ ir = markOperatorHome(ir);
7464
9446
  ir = addClusterAnchorEdges(ir, boundContext);
7465
9447
  let compositeEdgesAttached;
7466
9448
  if (query.detail === 1) {
@@ -7496,6 +9478,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7496
9478
  const observed = await lifecycleDiffLive(cfg.projectDir, env).then((d) => d.lexicons?.helm?.observedArtifacts).catch(() => void 0);
7497
9479
  applyHelmArtifacts(result.ir, observed);
7498
9480
  synthesizeHelmReleases(result.ir, observed, discoverReleaseUnits(cfg.projectDir));
9481
+ applyHelmRenderDrift(result.ir, await readHelmRenderDrift(cfg.projectDir, env));
7499
9482
  }
7500
9483
  }
7501
9484
  if (env) {
@@ -7503,8 +9486,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7503
9486
  else pruneRuntimeChildren(result.ir);
7504
9487
  }
7505
9488
  if ((optsFromQuery(new URL(c.req.url)).detail ?? 2) < 3) pruneImports(result.ir);
7506
- addValueMatchEdges(result.ir);
7507
9489
  addK8sDeclaredEdges(result.ir);
9490
+ addValueMatchEdges(result.ir);
7508
9491
  addClusterAnchorEdges(result.ir, await boundK8sContext(env));
7509
9492
  const { svg } = renderGraph(result.ir, { boxes: "byContainer" });
7510
9493
  return c.json({
@@ -7623,9 +9606,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7623
9606
  }
7624
9607
  return c.json({ started: true, component, env });
7625
9608
  });
7626
- const rel = relative3(process.cwd(), webRoot) || ".";
9609
+ const rel = relative4(process.cwd(), webRoot) || ".";
7627
9610
  app.use("/*", serveStatic({ root: rel }));
7628
- app.get("/", serveStatic({ path: join16(rel, "index.html") }));
9611
+ app.get("/", serveStatic({ path: join18(rel, "index.html") }));
7629
9612
  return app;
7630
9613
  }
7631
9614
  async function bootLocalEmulators(dir, who) {
@@ -7654,7 +9637,7 @@ async function bootLocalEmulators(dir, who) {
7654
9637
  async function startServer(cfg) {
7655
9638
  if (cfg.local) cfg.emulators = await bootLocalEmulators(cfg.projectDir, "behold serve --local");
7656
9639
  for (const dir of cfg.projectDirs ?? [cfg.projectDir]) {
7657
- if (existsSync13(join16(dir, "chant.config.ts"))) addRecent(dir);
9640
+ if (existsSync13(join18(dir, "chant.config.ts"))) addRecent(dir);
7658
9641
  }
7659
9642
  const broadcaster = new Broadcaster();
7660
9643
  const frames = new FrameBuffer();
@@ -7670,6 +9653,10 @@ async function startServer(cfg) {
7670
9653
  broadcaster.emit("changed", memberDir ?? "");
7671
9654
  void capture2();
7672
9655
  };
9656
+ const onMemberSourceChange = (memberDir) => {
9657
+ invalidateMember(memberDir);
9658
+ onEstateChange(memberDir);
9659
+ };
7673
9660
  const memberTag = (dir) => (cfg.projectDirs?.length ?? 0) > 1 ? ` [${basename(dir)}]` : "";
7674
9661
  const onPollDrift = (dir, movedLexicons) => {
7675
9662
  onEstateChange(dir);
@@ -7699,7 +9686,7 @@ async function startServer(cfg) {
7699
9686
  };
7700
9687
  const carve = !!cfg.carveReport;
7701
9688
  let stopWatch = carve ? () => {
7702
- } : watchSources(cfg.projectDirs ?? [cfg.projectDir], onEstateChange);
9689
+ } : watchSources(cfg.projectDirs ?? [cfg.projectDir], onMemberSourceChange);
7703
9690
  let stopPoll = !carve && cfg.env && cfg.pollSecs ? startDriftPoll({
7704
9691
  intervalMs: cfg.pollSecs * 1e3,
7705
9692
  // Per-member queries, swept sequentially inside the poll (#297): a
@@ -7716,7 +9703,7 @@ async function startServer(cfg) {
7716
9703
  };
7717
9704
  cfg.onProjectSwitch = (dir) => {
7718
9705
  stopWatch();
7719
- stopWatch = watchSources(cfg.projectDirs ?? [dir], onEstateChange);
9706
+ stopWatch = watchSources(cfg.projectDirs ?? [dir], onMemberSourceChange);
7720
9707
  stopPoll();
7721
9708
  stopPoll = () => {
7722
9709
  };
@@ -7789,12 +9776,12 @@ async function startServer(cfg) {
7789
9776
  }
7790
9777
 
7791
9778
  // src/export.ts
7792
- import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, copyFileSync, readFileSync as readFileSync14, readdirSync as readdirSync5 } from "node:fs";
7793
- import { join as join17, dirname as dirname5, basename as basename2 } from "node:path";
9779
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, copyFileSync, readFileSync as readFileSync15, readdirSync as readdirSync7 } from "node:fs";
9780
+ import { join as join19, dirname as dirname6, basename as basename2 } from "node:path";
7794
9781
  import { fileURLToPath as fileURLToPath4 } from "node:url";
7795
- var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
9782
+ var LENS_PARAMS = ["components", "detail", "env", "logical", "ops", "radial", "tier"];
7796
9783
  function canonicalKey(path, params) {
7797
- const flat = params.get("components") === "1" || params.get("logical") === "1";
9784
+ const flat = params.get("components") === "1" || params.get("logical") === "1" || params.get("ops") === "1";
7798
9785
  const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial"))).map((k) => `${k}=${params.get(k)}`).join("&");
7799
9786
  return q ? `${path}?${q}` : path;
7800
9787
  }
@@ -7808,6 +9795,7 @@ function captureKeys(axes) {
7808
9795
  add("/api/project", {});
7809
9796
  add("/api/substrates", {});
7810
9797
  add("/api/ops", {});
9798
+ if (axes.ops) add("/api/graph", { ops: "1" });
7811
9799
  const tiers = axes.tiers && axes.tiers.length ? axes.tiers : [""];
7812
9800
  const envs = ["", ...axes.environments];
7813
9801
  for (const env of envs) {
@@ -7838,7 +9826,7 @@ function captureKeys(axes) {
7838
9826
  return [...keys];
7839
9827
  }
7840
9828
  function webDir() {
7841
- return join17(dirname5(fileURLToPath4(import.meta.url)), "..", "web");
9829
+ return join19(dirname6(fileURLToPath4(import.meta.url)), "..", "web");
7842
9830
  }
7843
9831
  function workerName(project, override) {
7844
9832
  const raw = override ?? `behold-${basename2(project)}`;
@@ -7848,8 +9836,8 @@ function workerName(project, override) {
7848
9836
  async function runExport(cfg, outDir, opts = {}) {
7849
9837
  const app = createApp({ ...cfg, layoutWrites: false });
7850
9838
  const proj = await (await app.request("/api/project")).json();
7851
- const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
7852
- const snapDir = join17(outDir, "snapshots");
9839
+ const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [], ...proj.ops ? { ops: proj.ops } : {} };
9840
+ const snapDir = join19(outDir, "snapshots");
7853
9841
  mkdirSync4(snapDir, { recursive: true });
7854
9842
  const keyToFile = {};
7855
9843
  let ok = 0;
@@ -7858,7 +9846,7 @@ async function runExport(cfg, outDir, opts = {}) {
7858
9846
  const res = await app.request(`${key}${key.includes("?") ? "&" : "?"}layout=1`);
7859
9847
  const body = await res.text();
7860
9848
  const file = slug2(key);
7861
- writeFileSync4(join17(snapDir, file), body);
9849
+ writeFileSync4(join19(snapDir, file), body);
7862
9850
  keyToFile[key] = `snapshots/${file}`;
7863
9851
  if (res.ok) ok++;
7864
9852
  else failed++;
@@ -7870,21 +9858,21 @@ async function runExport(cfg, outDir, opts = {}) {
7870
9858
  axes,
7871
9859
  keyToFile
7872
9860
  };
7873
- writeFileSync4(join17(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
7874
- const html = readFileSync14(join17(webDir(), "index.html"), "utf8").replace(
9861
+ writeFileSync4(join19(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
9862
+ const html = readFileSync15(join19(webDir(), "index.html"), "utf8").replace(
7875
9863
  /<\/head>/i,
7876
9864
  ` <script>window.__BEHOLD_STATIC__ = true;</script>
7877
9865
  </head>`
7878
9866
  );
7879
- writeFileSync4(join17(outDir, "index.html"), html);
7880
- for (const f of readdirSync5(webDir())) {
9867
+ writeFileSync4(join19(outDir, "index.html"), html);
9868
+ for (const f of readdirSync7(webDir())) {
7881
9869
  if (f === "index.html") continue;
7882
- copyFileSync(join17(webDir(), f), join17(outDir, f));
9870
+ copyFileSync(join19(webDir(), f), join19(outDir, f));
7883
9871
  }
7884
- writeFileSync4(join17(outDir, "README.md"), BUNDLE_README);
9872
+ writeFileSync4(join19(outDir, "README.md"), BUNDLE_README);
7885
9873
  const name = workerName(cfg.projectDir, opts.name);
7886
9874
  writeFileSync4(
7887
- join17(outDir, "wrangler.jsonc"),
9875
+ join19(outDir, "wrangler.jsonc"),
7888
9876
  JSON.stringify(
7889
9877
  { $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
7890
9878
  null,
@@ -7932,7 +9920,7 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
7932
9920
  `;
7933
9921
 
7934
9922
  // src/doctor.ts
7935
- import { relative as relative4, resolve as resolve6 } from "node:path";
9923
+ import { relative as relative5, resolve as resolve7 } from "node:path";
7936
9924
  var list2 = (xs) => xs.join(", ");
7937
9925
  var EMPTY_KUBECONFIG = { contexts: /* @__PURE__ */ new Map(), servers: /* @__PURE__ */ new Map() };
7938
9926
  function summarize(xs, max = 4) {
@@ -7940,7 +9928,7 @@ function summarize(xs, max = 4) {
7940
9928
  }
7941
9929
  function labelFor(root, target, estate) {
7942
9930
  if (!estate) return "";
7943
- const rel = relative4(root, target);
9931
+ const rel = relative5(root, target);
7944
9932
  return `${rel || "."}: `;
7945
9933
  }
7946
9934
  function chantCheck(root, targets, estate) {
@@ -7953,7 +9941,7 @@ function chantCheck(root, targets, estate) {
7953
9941
  name: "chant",
7954
9942
  status: "fail",
7955
9943
  detail: `${list2(resolutions.map(describe))} \u2014 behold shells the project's own chant`,
7956
- fix: `Run \`npm install\` in ${list2(missing.map(({ target }) => relative4(root, target) || target))}`
9944
+ fix: `Run \`npm install\` in ${list2(missing.map(({ target }) => relative5(root, target) || target))}`
7957
9945
  };
7958
9946
  }
7959
9947
  const stale = resolutions.filter(({ res }) => !meetsFloor(res.version, floor));
@@ -8081,7 +10069,7 @@ function opsCheck(root, ops, chantSource, estate) {
8081
10069
  return { name: "ops", status: "pass", detail: `${ops.length} committed: ${list2(names)}; ${mcp}` };
8082
10070
  }
8083
10071
  async function diagnose(dir, probes = {}) {
8084
- const root = resolve6(dir);
10072
+ const root = resolve7(dir);
8085
10073
  const shape = detectProjectShape(root);
8086
10074
  const behold = beholdVersion();
8087
10075
  if (shape.kind === "none") {
@@ -8101,16 +10089,16 @@ async function diagnose(dir, probes = {}) {
8101
10089
  };
8102
10090
  }
8103
10091
  const estate = shape.kind === "estate";
8104
- const members = (shape.members ?? []).map((m) => resolve6(root, m));
10092
+ const members = (shape.members ?? []).map((m) => resolve7(root, m));
8105
10093
  const targets = estate ? members : [root];
8106
10094
  const primary = targets[0];
8107
10095
  const projectCheck = estate ? {
8108
10096
  name: "project",
8109
10097
  status: "pass",
8110
10098
  detail: `estate of ${members.length} projects (${shape.membersFrom === "behold-config" ? ".behold.json members" : "npm workspaces"}): ${list2(
8111
- members.map((m) => relative4(root, m))
10099
+ members.map((m) => relative5(root, m))
8112
10100
  )}`
8113
- } : { name: "project", status: "pass", detail: `chant project (${relative4(root, shape.configFile)})` };
10101
+ } : { name: "project", status: "pass", detail: `chant project (${relative5(root, shape.configFile)})` };
8114
10102
  const infos = await Promise.all(targets.map(async (t) => ({ target: t, info: await detectProject(t) })));
8115
10103
  const declared = new Map(infos.map(({ target, info }) => [target, info.lexicons]));
8116
10104
  const lexicons = [...new Set(infos.flatMap(({ info }) => info.lexicons))];
@@ -8335,7 +10323,7 @@ ${USAGE}`);
8335
10323
  process.stderr.write("behold serve: --auto-sync needs --env and --poll (it acts on polled drift)\n");
8336
10324
  process.exit(2);
8337
10325
  }
8338
- const dirs = projectDirs.map((d) => resolve7(d));
10326
+ const dirs = projectDirs.map((d) => resolve8(d));
8339
10327
  for (const d of dirs) warnIfNotChantProject(d);
8340
10328
  await startServer({
8341
10329
  projectDir: dirs[0],
@@ -8354,7 +10342,7 @@ function warnIfNotChantProject(dir) {
8354
10342
  if (shape.kind === "estate") {
8355
10343
  process.stderr.write(
8356
10344
  `behold: warning \u2014 ${dir} is an estate root, not a chant project itself.
8357
- Serve its members composed: behold serve ${shape.members.map((m) => join18(dir, m)).join(" ")}
10345
+ Serve its members composed: behold serve ${shape.members.map((m) => join20(dir, m)).join(" ")}
8358
10346
  `
8359
10347
  );
8360
10348
  return;
@@ -8389,8 +10377,8 @@ async function runCarve(rest) {
8389
10377
  );
8390
10378
  process.exit(2);
8391
10379
  }
8392
- const reportPath = resolve7(fileArg);
8393
- const parsed = readCarveReport(reportPath, (p) => readFileSync15(p, "utf8"));
10380
+ const reportPath = resolve8(fileArg);
10381
+ const parsed = readCarveReport(reportPath, (p) => readFileSync16(p, "utf8"));
8394
10382
  if (!parsed.ok) {
8395
10383
  process.stderr.write(`behold carve: ${parsed.refusal.error}
8396
10384
  ${parsed.refusal.remedy}
@@ -8401,7 +10389,7 @@ async function runCarve(rest) {
8401
10389
  `behold carve \u2014 ${parsed.report.resources.length} resource(s)/module(s) ranked${parsed.report.from ? ` from ${parsed.report.from}` : ""}
8402
10390
  `
8403
10391
  );
8404
- await startServer({ projectDir: dirname6(reportPath), carveReport: reportPath, port });
10392
+ await startServer({ projectDir: dirname7(reportPath), carveReport: reportPath, port });
8405
10393
  }
8406
10394
  async function runDoctor(rest) {
8407
10395
  let json = false;
@@ -8417,8 +10405,8 @@ async function runDoctor(rest) {
8417
10405
  }
8418
10406
  }
8419
10407
  const dir = dirArg ?? ".";
8420
- if (!existsSync14(resolve7(dir))) {
8421
- process.stderr.write(`behold doctor: no such directory: ${resolve7(dir)}
10408
+ if (!existsSync14(resolve8(dir))) {
10409
+ process.stderr.write(`behold doctor: no such directory: ${resolve8(dir)}
8422
10410
  `);
8423
10411
  process.exit(2);
8424
10412
  }
@@ -8427,7 +10415,7 @@ async function runDoctor(rest) {
8427
10415
  if (!report.ok) process.exitCode = 1;
8428
10416
  }
8429
10417
  async function runDemo(rest) {
8430
- const pkgRoot2 = join18(dirname6(fileURLToPath5(import.meta.url)), "..");
10418
+ const pkgRoot2 = join20(dirname7(fileURLToPath5(import.meta.url)), "..");
8431
10419
  const registry = loadDemoRegistry(pkgRoot2);
8432
10420
  let port = 4600;
8433
10421
  let live = false;
@@ -8481,7 +10469,7 @@ async function runDemo(rest) {
8481
10469
  `);
8482
10470
  process.exit(2);
8483
10471
  }
8484
- const target = dirArg ? resolve7(dirArg) : demoTargetDir(entry);
10472
+ const target = dirArg ? resolve8(dirArg) : demoTargetDir(entry);
8485
10473
  const loaded = await loadDemo(entry, { pkgRoot: pkgRoot2, target, log: (line) => process.stdout.write(line + "\n") });
8486
10474
  if (!loaded.ok) {
8487
10475
  process.stderr.write(`behold demo ${entry.name}: ${loaded.error}
@@ -8512,7 +10500,7 @@ function spawnStep(cmd, args, cwd) {
8512
10500
  });
8513
10501
  }
8514
10502
  async function serveCarveDemo(target, carve, port, live = false) {
8515
- const at = (rel) => resolve7(target, rel);
10503
+ const at = (rel) => resolve8(target, rel);
8516
10504
  const project = at(carve.project);
8517
10505
  const from = at(carve.from);
8518
10506
  const state = carve.state ? at(carve.state) : void 0;
@@ -8552,7 +10540,7 @@ async function serveCarveDemo(target, carve, port, live = false) {
8552
10540
  };
8553
10541
  process.stdout.write("behold demo carve --live \u2192 the tfstate is real now; the advisor reads what terraform wrote.\n");
8554
10542
  }
8555
- if (existsSync14(join18(project, "package.json")) && !existsSync14(join18(project, "node_modules"))) {
10543
+ if (existsSync14(join20(project, "package.json")) && !existsSync14(join20(project, "node_modules"))) {
8556
10544
  process.stdout.write(`behold demo carve \u2192 npm install in ${carve.project}/ (the chant this walkthrough shells)\u2026
8557
10545
  `);
8558
10546
  const code = await spawnStep("npm", ["install"], project);
@@ -8563,7 +10551,7 @@ async function serveCarveDemo(target, carve, port, live = false) {
8563
10551
  }
8564
10552
  }
8565
10553
  let degraded;
8566
- if (!existsSync14(join18(target, "node_modules", "@cdktf", "hcl2json"))) {
10554
+ if (!existsSync14(join20(target, "node_modules", "@cdktf", "hcl2json"))) {
8567
10555
  process.stdout.write("behold demo carve \u2192 npm install @cdktf/hcl2json (chant's HCL parser, ~2MB, once)\u2026\n");
8568
10556
  const code = await spawnStep("npm", ["install", "--no-save", "--no-package-lock", "@cdktf/hcl2json"], target);
8569
10557
  if (code !== 0) degraded = "couldn't install @cdktf/hcl2json (chant's HCL parser) \u2014 no network?";
@@ -8632,7 +10620,7 @@ async function runPreview(rest) {
8632
10620
  process.stderr.write("behold preview: --port must be a number\n");
8633
10621
  process.exit(2);
8634
10622
  }
8635
- const projectDir = resolve7(dirArg ?? process.cwd());
10623
+ const projectDir = resolve8(dirArg ?? process.cwd());
8636
10624
  if (!existsSync14(projectDir)) {
8637
10625
  process.stderr.write(`behold preview: project not found at ${projectDir}
8638
10626
  `);
@@ -8653,21 +10641,21 @@ async function runPreview(rest) {
8653
10641
  await startServer({ projectDir, port, env: "local", previewMode: true });
8654
10642
  }
8655
10643
  async function runExportCmd(rest) {
8656
- let outDir = resolve7("behold-export");
10644
+ let outDir = resolve8("behold-export");
8657
10645
  let env;
8658
10646
  let name;
8659
10647
  let dirArg;
8660
10648
  let emulator = false;
8661
10649
  for (let i = 0; i < rest.length; i++) {
8662
10650
  const a = rest[i];
8663
- if (a === "--out") outDir = resolve7(rest[++i]);
10651
+ if (a === "--out") outDir = resolve8(rest[++i]);
8664
10652
  else if (a === "--env") env = rest[++i];
8665
10653
  else if (a === "--name") name = rest[++i];
8666
10654
  else if (a === "--emulator") emulator = true;
8667
10655
  else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
8668
10656
  else if (!a.startsWith("-")) dirArg = a;
8669
10657
  }
8670
- const projectDir = resolve7(dirArg ?? process.cwd());
10658
+ const projectDir = resolve8(dirArg ?? process.cwd());
8671
10659
  if (emulator) {
8672
10660
  injectEmulatorEnv(env);
8673
10661
  env ??= "local";